| base64 -d` writes travel as a single execve * argument, capped at ~128 KiB by MAX_ARG_STRLEN. base64 inflates by 4/3, * so keep inline content below that limit with room for command quoting. */ private const MAX_INLINE_WRITE_SIZE = 64 * 1024; /** Largest file, or uncompressed folder, the file browser may download. */ public const MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024; public function __construct( private Server $server, private string $container, ) {} public function buildListCommand(string $path): string { $escapedPath = $this->escapePath($path, 'list path'); // Emit the whole directory through batched stat calls (one for most folders) // instead of a per-entry loop. %F is the human file type ("directory", // "symbolic link", "regular file"), mapped to a token by parseListing. // Fields: typesizemtimepermsownergroupname. // A real tab is embedded in the format because stat's -c does NOT expand // a backslash \t escape (unlike printf) - it would emit the literal text. // POSIX `find ... -prune -exec {} +` lists hidden files without recursing // and batches names, so a huge directory cannot overflow the argument // limit like a `* .*` glob would. Names arrive as ./name; parseListing // strips that prefix. Names containing a tab/newline are skipped (v1 scope). $tab = "\t"; $format = '%F'.$tab.'%s'.$tab.'%Y'.$tab.'%a'.$tab.'%U'.$tab.'%G'.$tab.'%n'; $inner = 'cd '.$escapedPath.' 2>/dev/null || exit 0; ' .'find . ! -name . -prune -exec stat -c "'.$format.'" -- {} + 2>/dev/null'; return $this->dockerExecShell($inner); } /** * @return array */ public function list(string $path): array { $raw = instant_remote_process([$this->buildListCommand($path)], $this->server, throwError: false); return $this->parseListing($raw); } /** * Read an editable text file in a single SSH round trip. * * The remote command folds the size cap, binary probe and base64 read into * one docker exec: it prints TOOBIG or BINARY for a rejected file, otherwise * an "OK\n" header followed by the base64 payload (empty for a 0-byte file, * which is valid editable text). base64 keeps exact bytes safe from the * trailing-whitespace trim in instant_remote_process. * * @throws \RuntimeException when the file is binary or larger than the cap */ public function read(string $path): string { $escaped = $this->escapePath($path, 'read path'); $max = LocalFileVolume::MAX_CONTENT_SIZE; $cmd = '[ -f '.$escaped.' ] && [ -r '.$escaped.' ] || exit 1; ' .'sz=$(stat -Lc %s -- '.$escaped.') || exit 1; ' .'if [ "$sz" -gt '.$max.' ]; then echo TOOBIG; exit 0; fi; ' .'if [ -s '.$escaped.' ] && ! grep -qI \'\' -- '.$escaped.'; then echo BINARY; exit 0; fi; ' .'echo OK; base64 -- '.$escaped.' || exit 1'; $raw = (string) instant_remote_process( [$this->dockerExecShell($cmd)], $this->server, ); $newline = strpos($raw, "\n"); $status = $newline === false ? trim($raw) : substr($raw, 0, $newline); if ($status === 'TOOBIG' || $status === 'BINARY') { throw new \RuntimeException('File is not editable (binary or too large).'); } $payload = $newline === false ? '' : substr($raw, $newline + 1); $content = base64_decode(trim($payload), true); if ($status !== 'OK' || $content === false || strlen($content) > $max) { throw new \RuntimeException('Could not read file.'); } return $content; } public function buildWriteCommand(string $path, string $content): string { $escaped = $this->escapePath($path, 'write path'); $b64 = base64_encode($content); return $this->dockerExecShell("echo {$b64} | base64 -d > {$escaped}"); } public function buildMkdirCommand(string $path): string { $escaped = $this->escapePath($path, 'mkdir path'); return $this->dockerExecShell("mkdir -p -- {$escaped}"); } public function buildCreateFileCommand(string $path): string { $escaped = $this->escapePath($path, 'create file path'); // Create only if absent so an existing file is never truncated. return $this->dockerExecShell("[ -e {$escaped} ] || : > {$escaped}"); } public function buildChmodCommand(string $path, string $mode): string { $mode = $this->normalizeMode($mode); $escaped = $this->escapePath($path, 'chmod path'); return $this->dockerExecShell("chmod {$mode} -- {$escaped}"); } public function buildRenameCommand(string $from, string $to): string { $escapedFrom = $this->escapePath($from, 'rename source'); $escapedTo = $this->escapePath($to, 'rename target'); return $this->dockerExecShell("mv -- {$escapedFrom} {$escapedTo}"); } public function buildDeleteCommand(string $path): string { $escaped = $this->escapePath($path, 'delete path'); return $this->dockerExecShell("rm -rf -- {$escaped}"); } public function write(string $path, string $content): void { if (strlen($content) > self::MAX_INLINE_WRITE_SIZE) { $this->writeViaUpload($path, $content); return; } instant_remote_process([$this->buildWriteCommand($path, $content)], $this->server); } /** * Write content too large for a single shell argument by staging it to a * local temp file and reusing the docker cp upload path. */ protected function writeViaUpload(string $path, string $content): void { validateShellSafePath($path, 'write path'); $localTmp = storage_path('app/tmp/'.Str::random(16)); @mkdir(dirname($localTmp), 0755, true); file_put_contents($localTmp, $content); try { $this->upload($localTmp, $path); } finally { @unlink($localTmp); } } public function makeDirectory(string $path): void { instant_remote_process([$this->buildMkdirCommand($path)], $this->server); } public function createFile(string $path): void { instant_remote_process([$this->buildCreateFileCommand($path)], $this->server); } public function chmod(string $path, string $mode): void { instant_remote_process([$this->buildChmodCommand($path, $mode)], $this->server); } protected function normalizeMode(string $mode): string { $mode = trim($mode); if (! preg_match('/^[0-7]{3,4}$/', $mode)) { throw new \InvalidArgumentException('Invalid permission mode. Use octal like 644 or 0755.'); } return $mode; } public function rename(string $from, string $to): void { instant_remote_process([$this->buildRenameCommand($from, $to)], $this->server); } public function delete(string $path): void { instant_remote_process([$this->buildDeleteCommand($path)], $this->server); } public function defaultRoot(): string { $escapedContainer = escapeshellarg($this->container); $workingDir = instant_remote_process( ["docker inspect --format '{{.Config.WorkingDir}}' {$escapedContainer}"], $this->server, throwError: false, ); $workingDir = trim((string) $workingDir); return $workingDir !== '' ? $workingDir : '/'; } /** * @return array */ public function parseListing(?string $raw): array { if (blank($raw)) { return []; } $entries = []; foreach (explode("\n", trim($raw)) as $line) { if ($line === '') { continue; } $parts = explode("\t", $line, 7); $owner = ''; $group = ''; // Accept the current 7-field format (with owner/group) and the // legacy 5-field (perms) and 4-field formats. if (count($parts) === 7) { [$type, $size, $mtime, $perms, $owner, $group, $name] = $parts; } elseif (count($parts) === 5) { [$type, $size, $mtime, $perms, $name] = $parts; } elseif (count($parts) === 4) { [$type, $size, $mtime, $name] = $parts; $perms = ''; } else { continue; } // find prefixes names with ./; a name itself can never contain a slash. if (str_starts_with($name, './')) { $name = substr($name, 2); } if ($name === '' || $name === '.' || $name === '..') { continue; } $entries[] = new FileEntry( $name, $this->normalizeType($type), (int) $size, (int) $mtime, trim($perms), trim($owner), trim($group), ); } return FileEntry::sort($entries); } /** * Map a raw type field to a file/dir/symlink token. Accepts both the * already-tokenized legacy value and stat's human %F string. */ protected function normalizeType(string $raw): string { $t = strtolower(trim($raw)); if ($t === 'dir' || str_contains($t, 'directory')) { return 'dir'; } if ($t === 'symlink' || str_contains($t, 'symbolic link')) { return 'symlink'; } return 'file'; } public function upload(string $localTmpPath, string $destPath): void { validateShellSafePath($destPath, 'upload path'); $serverTmp = '/tmp/coolify-upload-'.Str::random(16); $escapedServerTmp = escapeshellarg($serverTmp); $escapedContainer = escapeshellarg($this->container); $escapedDest = escapeshellarg($destPath); try { instant_scp($localTmpPath, $serverTmp, $this->server); instant_remote_process( ["docker cp {$escapedServerTmp} {$escapedContainer}:{$escapedDest}"], $this->server, ); } finally { instant_remote_process(["rm -f {$escapedServerTmp}"], $this->server, throwError: false); } } /** * Inspect a download target in one round trip. Folder size is the * uncompressed disk usage, so the archive is never larger than reported. * * @return array{isDir: bool, size: int} * * @throws \RuntimeException when the path does not exist */ public function downloadInfo(string $path): array { $escaped = $this->escapePath($path, 'download path'); $cmd = 'if [ -d '.$escaped.' ]; then echo "dir $(du -sk '.$escaped.' | cut -f1)"; ' .'elif [ -e '.$escaped.' ]; then echo "file $(stat -L -c %s '.$escaped.')"; fi'; $raw = trim((string) instant_remote_process([$this->dockerExecShell($cmd)], $this->server)); [$kind, $size] = array_pad(explode(' ', $raw, 2), 2, ''); if (! in_array($kind, ['dir', 'file'], true) || ! ctype_digit($size)) { throw new \RuntimeException('File not found.'); } return [ 'isDir' => $kind === 'dir', 'size' => $kind === 'dir' ? (int) $size * 1024 : (int) $size, ]; } public function buildDownloadCommand(string $path, bool $isDir): string { $escaped = $this->escapePath($path, 'download path'); $escapedContainer = escapeshellarg($this->container); return $isDir ? "docker exec {$escapedContainer} tar czf - -C {$escaped} ." : "docker exec {$escapedContainer} cat -- {$escaped}"; } /** * Pipe a file, or a folder as .tar.gz, from the container to $write in * chunks. Nothing is stored on the Coolify host or the managed server. * * @param callable(string): void $write */ public function streamDownload(string $path, bool $isDir, callable $write): void { $command = $this->buildDownloadCommand($path, $isDir); if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect([$command]), $this->server)[0]; } $process = proc_open($this->sshCommand($command), [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); if (! is_resource($process)) { throw new \RuntimeException('Could not start the download.'); } try { while (! feof($pipes[1])) { $chunk = fread($pipes[1], 64 * 1024); if ($chunk === false || connection_aborted()) { break; } if ($chunk !== '') { $write($chunk); } } } finally { fclose($pipes[1]); proc_terminate($process); proc_close($process); } } /** * Stage the file, or the folder as .tar.gz, on the managed server and PUT * it to a presigned S3 URL. S3 needs a Content-Length, so a pipe without a * known size cannot be uploaded directly. The temp file is always removed. * * @param array $headers */ public function buildS3UploadCommand(string $path, bool $isDir, string $url, array $headers): string { $headerArgs = FileDownloadStorageService::curlHeaderArgs($headers); $script = 'tmp=$(mktemp /tmp/coolify-download-XXXXXXXXXX) || exit 1; ' .'trap \'rm -f "$tmp"\' EXIT; ' .$this->buildDownloadCommand($path, $isDir).' > "$tmp" && ' .'curl -fsS --retry 2 '.$headerArgs.' -T "$tmp" '.escapeshellarg($url); return 'sh -c '.escapeshellarg($script); } /** * @param array $headers */ public function uploadToS3(string $path, bool $isDir, string $url, array $headers): void { instant_remote_process([$this->buildS3UploadCommand($path, $isDir, $url, $headers)], $this->server, timeout: 3600); } protected function sshCommand(string $command): string { return SshMultiplexingHelper::generateSshCommand($this->server, $command); } protected function escapePath(string $path, string $label): string { validateShellSafePath($path, $label); return escapeshellarg($path); } protected function dockerExecShell(string $innerShell): string { $escapedContainer = escapeshellarg($this->container); return "docker exec {$escapedContainer} sh -c ".escapeshellarg($innerShell); } }