diff --git a/app/Exceptions/ContainerShellUnavailableException.php b/app/Exceptions/ContainerShellUnavailableException.php new file mode 100644 index 0000000000..7c158b86d8 --- /dev/null +++ b/app/Exceptions/ContainerShellUnavailableException.php @@ -0,0 +1,11 @@ +id(), 404); @@ -33,7 +33,7 @@ class FileBrowserDownloadController extends Controller Gate::authorize('view', $resource); abort_unless((int) $resource->environment->project->team_id === (int) auth()->user()->currentTeam()->id, 403); - $filesystem = new ContainerFilesystemService(Server::findOrFail($download['server_id']), $download['container']); + $filesystem = new ContainerFilesystemService(Server::findOrFail($download['server_id']), $download['container'], $download['use_helper'] ?? false); return response()->streamDownload(function () use ($filesystem, $download) { set_time_limit(0); diff --git a/app/Jobs/PrepareFileDownloadJob.php b/app/Jobs/PrepareFileDownloadJob.php index b36d5ccd96..d121d5e648 100644 --- a/app/Jobs/PrepareFileDownloadJob.php +++ b/app/Jobs/PrepareFileDownloadJob.php @@ -36,6 +36,7 @@ class PrepareFileDownloadJob implements ShouldQueue public bool $isDir, public string $downloadName, public int $s3StorageId, + public bool $useHelper = false, ) {} public static function cacheKey(string $downloadId): string @@ -60,7 +61,7 @@ class PrepareFileDownloadJob implements ShouldQueue if ($this->container === null) { instant_remote_process([FileDownloadStorageService::buildHostFileUploadCommand($this->path, $upload['url'], $upload['headers'])], $server, timeout: 3600); } else { - (new ContainerFilesystemService($server, $this->container))->uploadToS3($this->path, $this->isDir, $upload['url'], $upload['headers']); + (new ContainerFilesystemService($server, $this->container, $this->useHelper))->uploadToS3($this->path, $this->isDir, $upload['url'], $upload['headers']); } $this->store(['status' => 'ready', 'url' => $downloads->downloadUrl($storage, $key)]); diff --git a/app/Livewire/Project/Shared/FileBrowser.php b/app/Livewire/Project/Shared/FileBrowser.php index 09f8f0c6b9..e634f9755d 100644 --- a/app/Livewire/Project/Shared/FileBrowser.php +++ b/app/Livewire/Project/Shared/FileBrowser.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use App\Exceptions\ContainerShellUnavailableException; use App\Http\Controllers\FileBrowserDownloadController; use App\Jobs\PrepareFileDownloadJob; use App\Models\Application; @@ -14,6 +15,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; use Livewire\Attributes\Locked; @@ -44,6 +46,17 @@ class FileBrowser extends Component #[Locked] public bool $loadFailed = false; + /** The container has no usable shell, so a helper container reaches its files. */ + #[Locked] + public bool $useHelper = false; + + #[Locked] + public bool $shellUnavailable = false; + + /** Why the helper container failed, shown when the reason is known. */ + #[Locked] + public ?string $helperError = null; + #[Locked] public ?string $container = null; @@ -103,6 +116,7 @@ class FileBrowser extends Component try { $this->availableContainers = $this->containerRunning ? $this->resolveContainers() : []; $this->container = $this->availableContainers[0] ?? null; + $this->useHelper = false; if (is_null($this->container)) { $this->containerRunning = false; } else { @@ -125,6 +139,7 @@ class FileBrowser extends Component } $this->closeEditor(); $this->container = $name; + $this->useHelper = false; $this->currentPath = $this->service()->defaultRoot(); $this->loadEntries(); } @@ -132,6 +147,9 @@ class FileBrowser extends Component public function open(string $name): void { $this->guard(); + if ($this->isHelperSymlink($name)) { + return; + } $this->currentPath = $this->childPath($name); $this->loadEntries(); } @@ -215,6 +233,9 @@ class FileBrowser extends Component public function openEditor(string $name): void { $this->guard(); + if ($this->isHelperSymlink($name)) { + return; + } $path = $this->childPath($name); try { // read() guards binary/oversized files internally and throws. @@ -276,6 +297,9 @@ class FileBrowser extends Component public function download(string $name): void { $this->guard(); + if ($this->isHelperSymlink($name)) { + return; + } try { $path = $this->childPath($name); $target = $this->service()->downloadInfo($path); @@ -297,7 +321,7 @@ class FileBrowser extends Component } $downloadId = (string) Str::uuid(); $this->pendingDownloads[] = $downloadId; - PrepareFileDownloadJob::dispatch($downloadId, auth()->id(), $this->server()->id, $this->container, $path, $target['isDir'], $downloadName, $s3Storage->id); + PrepareFileDownloadJob::dispatch($downloadId, auth()->id(), $this->server()->id, $this->container, $path, $target['isDir'], $downloadName, $s3Storage->id, $this->useHelper); $this->dispatch('info', 'Preparing '.$downloadName.'. The download starts when it is ready.'); return; @@ -313,6 +337,7 @@ class FileBrowser extends Component 'path' => $path, 'name' => $downloadName, 'is_dir' => $target['isDir'], + 'use_helper' => $this->useHelper, ], FileBrowserDownloadController::TOKEN_TTL_SECONDS); $this->redirect(route('project.files.download', $token)); @@ -442,7 +467,24 @@ class FileBrowser extends Component protected function loadEntries(): void { - $entries = $this->service()->list($this->currentPath); + $this->shellUnavailable = false; + $this->helperError = null; + try { + $entries = $this->service()->list($this->currentPath); + } catch (ContainerShellUnavailableException $e) { + if (! $this->useHelper) { + $this->useHelper = true; + $this->loadEntries(); + + return; + } + Log::warning('File browser helper container failed', ['container' => $this->container, 'error' => $e->getMessage()]); + $entries = []; + $this->shellUnavailable = true; + if (preg_match('/Unable to find image|failed to resolve reference|pull access denied|manifest unknown/i', $e->getMessage())) { + $this->helperError = 'The server could not pull the Coolify helper image ('.coolifyHelperImage().':'.getHelperVersion().'). Check that the server can reach the registry.'; + } + } $this->entries = array_map(fn ($e) => [ 'name' => $e->name, 'type' => $e->type, @@ -456,7 +498,22 @@ class FileBrowser extends Component protected function service(): ContainerFilesystemService { - return new ContainerFilesystemService($this->server(), $this->container); + return new ContainerFilesystemService($this->server(), $this->container, $this->useHelper); + } + + /** + * The helper resolves absolute symlinks in its own file system, not in the + * container, so it would show the wrong files. It cannot change them, + * because its root file system is read-only. + */ + protected function isHelperSymlink(string $name): bool + { + if (! $this->useHelper || ! collect($this->entries)->contains(fn (array $entry) => $entry['name'] === $name && $entry['type'] === 'symlink')) { + return false; + } + $this->dispatch('error', 'Symlinks cannot be opened in a container without a shell. Open the target path instead.'); + + return true; } protected function server(): Server diff --git a/app/Services/ContainerFilesystemService.php b/app/Services/ContainerFilesystemService.php index e7178ed765..5392f7d483 100644 --- a/app/Services/ContainerFilesystemService.php +++ b/app/Services/ContainerFilesystemService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Data\FileEntry; +use App\Exceptions\ContainerShellUnavailableException; use App\Helpers\SshMultiplexingHelper; use App\Models\LocalFileVolume; use App\Models\Server; @@ -20,9 +21,30 @@ class ContainerFilesystemService /** Largest file, or uncompressed folder, the file browser may download. */ public const MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024; + /** + * Linux capability names by bit number, as Docker expects them in --cap-add. + */ + private const CAPABILITIES = [ + 'CHOWN', 'DAC_OVERRIDE', 'DAC_READ_SEARCH', 'FOWNER', 'FSETID', 'KILL', 'SETGID', 'SETUID', + 'SETPCAP', 'LINUX_IMMUTABLE', 'NET_BIND_SERVICE', 'NET_BROADCAST', 'NET_ADMIN', 'NET_RAW', + 'IPC_LOCK', 'IPC_OWNER', 'SYS_MODULE', 'SYS_RAWIO', 'SYS_CHROOT', 'SYS_PTRACE', 'SYS_PACCT', + 'SYS_ADMIN', 'SYS_BOOT', 'SYS_NICE', 'SYS_RESOURCE', 'SYS_TIME', 'SYS_TTY_CONFIG', 'MKNOD', + 'LEASE', 'AUDIT_WRITE', 'AUDIT_CONTROL', 'SETFCAP', 'MAC_OVERRIDE', 'MAC_ADMIN', 'SYSLOG', + 'WAKE_ALARM', 'BLOCK_SUSPEND', 'AUDIT_READ', 'PERFMON', 'BPF', 'CHECKPOINT_RESTORE', + ]; + + /** @var array{user: string, groups: array, capabilities: array}|null */ + private ?array $helperIdentity = null; + + /** + * With $useHelper, commands run in a short-lived helper container that + * shares the PID namespace of the target and reaches its files through + * /proc/1/root. This supports images without a shell (scratch, distroless). + */ public function __construct( private Server $server, private string $container, + private bool $useHelper = false, ) {} public function buildListCommand(string $path): string @@ -41,18 +63,34 @@ class ContainerFilesystemService // 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'; + // Exit 127 when find or stat is missing, so list() can switch to the helper. + $inner = 'command -v find >/dev/null && command -v stat >/dev/null || exit 127; ' + .'cd '.$escapedPath.' 2>/dev/null || exit 0; ' + .'find . ! -name . -prune -exec stat -c "'.$format.'" -- {} + 2>/dev/null; exit 0'; return $this->dockerExecShell($inner); } /** + * docker exec exits with 126 or 127 when it cannot start `sh` or the + * listing tools in the container. Other docker exec failures still list + * as an empty folder. In helper mode, every failure is reported. + * * @return array + * + * @throws ContainerShellUnavailableException when the files cannot be listed this way */ public function list(string $path): array { - $raw = instant_remote_process([$this->buildListCommand($path)], $this->server, throwError: false); + try { + $raw = instant_remote_process([$this->buildListCommand($path)], $this->server); + } catch (\RuntimeException $e) { + if ($this->useHelper || in_array($e->getCode(), [126, 127], true)) { + throw new ContainerShellUnavailableException($e->getMessage(), (int) $e->getCode(), $e); + } + + return []; + } return $this->parseListing($raw); } @@ -96,6 +134,10 @@ class ContainerFilesystemService if ($status !== 'OK' || $content === false || strlen($content) > $max) { throw new \RuntimeException('Could not read file.'); } + // BusyBox grep ignores -I, so a binary file can pass the remote probe. + if (str_contains($content, "\0")) { + throw new \RuntimeException('File is not editable (binary or too large).'); + } return $content; } @@ -337,6 +379,9 @@ class ContainerFilesystemService public function buildDownloadCommand(string $path, bool $isDir): string { $escaped = $this->escapePath($path, 'download path'); + if ($this->useHelper) { + return $this->dockerExecShell($isDir ? "tar czf - -C {$escaped} ." : "cat -- {$escaped}"); + } $escapedContainer = escapeshellarg($this->container); return $isDir @@ -414,13 +459,94 @@ class ContainerFilesystemService { validateShellSafePath($path, $label); - return escapeshellarg($path); + return escapeshellarg(($this->useHelper ? '/proc/1/root' : '').$path); } protected function dockerExecShell(string $innerShell): string { $escapedContainer = escapeshellarg($this->container); + if (! $this->useHelper) { + return "docker exec {$escapedContainer} sh -c ".escapeshellarg($innerShell); + } - return "docker exec {$escapedContainer} sh -c ".escapeshellarg($innerShell); + // The helper gets exactly the user, groups and capabilities of the + // target process: the kernel requires them to reach /proc/1/root, + // and nothing more is given. It has no network, a read-only root + // file system, and no Docker socket or host mounts, and it is removed + // when the command ends. Exit 126 means the kernel denied access. + $identity = $this->helperIdentity(); + $options = [ + '--rm', + '--pid container:'.$escapedContainer, + '--user '.escapeshellarg($identity['user']), + ...array_map(fn (string $group) => '--group-add '.escapeshellarg($group), $identity['groups']), + '--cap-drop ALL', + ...array_map(fn (string $capability) => '--cap-add '.$capability, $identity['capabilities']), + '--security-opt no-new-privileges', + '--network none', + '--ipc none', + '--read-only', + '--log-driver none', + '--pids-limit 64', + '--label coolify.file-browser=true', + "--entrypoint ''", + ]; + $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion()); + + return 'docker run '.implode(' ', $options)." {$image} sh -c " + .escapeshellarg('[ -x /proc/1/root/ ] || exit 126; '.$innerShell); + } + + /** + * Read the effective user, group, supplementary groups and permitted + * capabilities of the main process of the target container. + * + * @return array{user: string, groups: array, capabilities: array} + */ + protected function helperIdentity(): array + { + if ($this->helperIdentity !== null) { + return $this->helperIdentity; + } + + $escapedContainer = escapeshellarg($this->container); + $status = (string) instant_remote_process( + ["cat /proc/$(docker inspect --format '{{.State.Pid}}' {$escapedContainer})/status"], + $this->server, + ); + + return $this->helperIdentity = $this->parseProcessStatus($status); + } + + /** + * @return array{user: string, groups: array, capabilities: array} + */ + public function parseProcessStatus(string $status): array + { + $fields = []; + foreach (explode("\n", $status) as $line) { + [$key, $value] = array_pad(explode(':', $line, 2), 2, ''); + $fields[trim($key)] = preg_split('/\s+/', trim($value), -1, PREG_SPLIT_NO_EMPTY); + } + + $uid = $fields['Uid'][1] ?? ''; + $gid = $fields['Gid'][1] ?? ''; + $capabilityMask = $fields['CapPrm'][0] ?? ''; + if (! ctype_digit($uid) || ! ctype_digit($gid) || ! ctype_xdigit($capabilityMask) || strlen($capabilityMask) > 16) { + throw new \RuntimeException('Could not read the user of the container process.'); + } + + $groups = array_values(array_filter($fields['Groups'] ?? [], fn (string $group) => ctype_digit($group) && $group !== $gid)); + + // Read the mask one hex digit at a time; hexdec() turns 64-bit masks into floats. + $digits = strrev($capabilityMask); + $capabilities = []; + foreach (self::CAPABILITIES as $bit => $name) { + if (hexdec($digits[intdiv($bit, 4)] ?? '0') & (1 << ($bit % 4))) { + $capabilities[] = $name; + } + } + + return ['user' => "{$uid}:{$gid}", 'groups' => $groups, 'capabilities' => $capabilities]; } } diff --git a/resources/views/livewire/project/shared/file-browser.blade.php b/resources/views/livewire/project/shared/file-browser.blade.php index c4b5ceaf84..dd916a77d0 100644 --- a/resources/views/livewire/project/shared/file-browser.blade.php +++ b/resources/views/livewire/project/shared/file-browser.blade.php @@ -23,7 +23,8 @@ delay skips the overlay for fast requests. --}} run(label, action) { const timer = setTimeout(() => this.busy = label, 150); - return Promise.resolve().then(action).catch(() => {}).finally(() => { + {{-- Start the action now: callers clear their inputs right after run(). --}} + return new Promise((resolve) => resolve(action())).catch(() => {}).finally(() => { clearTimeout(timer); this.busy = null; }); @@ -64,7 +65,7 @@ - @if ($containerRunning && $filesLoaded) + @if ($containerRunning && $filesLoaded && ! $shellUnavailable) + @elseif ($shellUnavailable) + + + + + @else {{-- Keep the current listing visible while an action runs: blur it and show the action label (from run()) on top of it. --}} diff --git a/tests/Feature/ContainerFilesystemServiceTest.php b/tests/Feature/ContainerFilesystemServiceTest.php index 3d89dfdb26..aa24f3fafe 100644 --- a/tests/Feature/ContainerFilesystemServiceTest.php +++ b/tests/Feature/ContainerFilesystemServiceTest.php @@ -1,6 +1,7 @@ name)->toBe('src'); }); +it('reports a container without a shell instead of an empty folder', function (int $exitCode) { + Process::fake(['*' => Process::result( + errorOutput: 'OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH', + exitCode: $exitCode, + )]); + + (new ContainerFilesystemService(fsServer(), 'app-123'))->list('/'); +})->with([126, 127])->throws(ContainerShellUnavailableException::class); + +it('lists other docker exec failures as an empty folder', function () { + Process::fake(['*' => Process::result(errorOutput: 'Error response from daemon: container is not running', exitCode: 1)]); + + expect((new ContainerFilesystemService(fsServer(), 'app-123'))->list('/'))->toBe([]); +}); + +function fsProcessStatus(string $uid, string $gid, string $groups, string $capPrm): string +{ + return "Name:\tapp\nUid:\t{$uid}\t{$uid}\t{$uid}\t{$uid}\nGid:\t{$gid}\t{$gid}\t{$gid}\t{$gid}\nGroups:\t{$groups} \nCapInh:\t0000000000000000\nCapPrm:\t{$capPrm}\nCapEff:\t{$capPrm}\n"; +} + +it('reads the user, groups and capabilities of the container process for the helper', function () { + // Docker's default capability set for a root process. + expect(fsService()->parseProcessStatus(fsProcessStatus('0', '0', '0 1 2', '00000000a80425fb')))->toBe([ + 'user' => '0:0', + 'groups' => ['1', '2'], + 'capabilities' => ['CHOWN', 'DAC_OVERRIDE', 'FOWNER', 'FSETID', 'KILL', 'SETGID', 'SETUID', 'SETPCAP', 'NET_BIND_SERVICE', 'NET_RAW', 'SYS_CHROOT', 'MKNOD', 'AUDIT_WRITE', 'SETFCAP'], + ]); + expect(fsService()->parseProcessStatus(fsProcessStatus('65532', '0', '0', '0000000000000000'))) + ->toBe(['user' => '65532:0', 'groups' => [], 'capabilities' => []]); + expect(fsService()->parseProcessStatus(fsProcessStatus('0', '0', '', '000001ffffffffff'))['capabilities']) + ->toHaveCount(41); +}); + +it('rejects an unreadable process status', function (string $status) { + fsService()->parseProcessStatus($status); +})->with(['', fsProcessStatus('x', '0', '', '0'), fsProcessStatus('0', '0', '', 'zz')])->throws(RuntimeException::class); + +it('lists through a locked-down helper container that runs as the container process', function () { + $server = fsServer(); + $server->update(['user' => 'root']); + $commands = []; + Process::fake(function ($process) use (&$commands) { + $commands[] = $process->command; + + return str_contains($process->command, '/status') + ? Process::result(output: fsProcessStatus('65532', '0', '0 1000', '0000000000000000')) + : Process::result(output: "regular file\t9\t1\t644\troot\troot\t./users.yml"); + }); + + $entries = (new ContainerFilesystemService($server, 'app-123', useHelper: true))->list('/data'); + + expect($entries)->toHaveCount(1) + ->and($entries[0]->name)->toBe('users.yml') + ->and($commands[0])->toContain("cat /proc/$(docker inspect --format '{{.State.Pid}}' 'app-123')/status") + ->and($commands[1])->toContain("docker run --rm --pid container:'app-123' --user '65532:0' --group-add '1000' --cap-drop ALL --security-opt no-new-privileges --network none --ipc none --read-only --log-driver none --pids-limit 64") + ->not->toContain('--cap-add') + ->not->toContain('docker exec') + ->toContain('/proc/1/root/data') + ->toContain('exit 126'); +}); + +it('reports every helper failure instead of an empty folder', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb'))) + ->push(Process::result(errorOutput: 'Unable to find image', exitCode: 125))]); + + (new ContainerFilesystemService(fsServer(), 'app-123', useHelper: true))->list('/'); +})->throws(ContainerShellUnavailableException::class, 'Unable to find image'); + +it('wraps the helper command in one sudo shell for non-root servers', function () { + $server = fsServer(); + $server->update(['user' => 'deploy']); + $commands = []; + Process::fake(function ($process) use (&$commands) { + $commands[] = $process->command; + + return count($commands) === 1 + ? Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb')) + : Process::result(output: ''); + }); + + (new ContainerFilesystemService($server, 'app-123', useHelper: true))->list('/'); + + expect($commands[0])->toContain("sudo cat /proc/$(sudo docker inspect --format '{{.State.Pid}}' 'app-123')/status") + ->and($commands[1])->toContain("sudo bash -c 'docker run --rm") + ->and(substr_count($commands[1], 'sudo'))->toBe(1); +}); + +it('streams downloads through the helper container in helper mode', function () { + Process::fake(['*' => Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb'))]); + $service = new ContainerFilesystemService(fsServer(), 'app-123', useHelper: true); + + expect($service->buildDownloadCommand('/data', true))->toStartWith('docker run --rm')->toContain("tar czf - -C '\\''/proc/1/root/data'\\'' .") + ->and($service->buildDownloadCommand('/data/users.yml', false))->toContain("cat -- '\\''/proc/1/root/data/users.yml'\\''"); +}); + +it('rejects a binary file that passes a grep without -I support', function () { + Process::fake(['*' => Process::result(output: "OK\n".base64_encode("ELF\0binary"))]); + + (new ContainerFilesystemService(fsServer(), 'app-123'))->read('/pause'); +})->throws(RuntimeException::class, 'File is not editable'); + it('falls back to / when the container has no WorkingDir', function () { Process::fake(['*' => Process::result(output: '')]); diff --git a/tests/Feature/Livewire/FileBrowserTest.php b/tests/Feature/Livewire/FileBrowserTest.php index 34698719d5..f9afb3bef6 100644 --- a/tests/Feature/Livewire/FileBrowserTest.php +++ b/tests/Feature/Livewire/FileBrowserTest.php @@ -15,6 +15,7 @@ use App\Models\Team; use App\Models\User; use App\Services\ContainerFilesystemService; use App\Services\FileDownloadStorageService; +use Illuminate\Contracts\Process\ProcessResult; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Process\Factory; @@ -142,6 +143,86 @@ function fbRunningDatabase($environment, $destination): StandalonePostgresql ]); } +function fbNoShellResult(): ProcessResult +{ + return Process::result( + errorOutput: 'OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH', + exitCode: 127, + ); +} + +function fbRootProcessStatus(): string +{ + return "Uid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\nGroups:\t0\nCapPrm:\t00000000a80425fb\n"; +} + +it('browses a container without a shell through the helper container', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/')) // defaultRoot inspect + ->push(fbNoShellResult()) // docker exec list + ->push(Process::result(output: fbRootProcessStatus())) // helper identity + ->push(Process::result(output: "directory\t0\t1\t755\troot\troot\t./data\nsymbolic link\t4\t1\t777\troot\troot\t./etc-link"))]); // helper list + + $database = fbRunningDatabase($this->environment, $this->destination); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->assertSet('useHelper', true) + ->assertSet('shellUnavailable', false) + ->assertSee('data') + ->assertSee('Upload a file to this folder') + ->call('open', 'etc-link') + ->assertDispatched('error', 'Symlinks cannot be opened in a container without a shell. Open the target path instead.') + ->assertSet('currentPath', '/'); +}); + +it('explains when neither the shell nor the helper container can open the files', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/')) + ->push(fbNoShellResult()) + ->push(Process::result(output: fbRootProcessStatus())) + ->push(Process::result(errorOutput: 'Unable to find image', exitCode: 125))]); + + $database = fbRunningDatabase($this->environment, $this->destination); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->assertSet('loadFailed', false) + ->assertSet('shellUnavailable', true) + ->assertSee('Coolify cannot open the files of this container.') + ->assertSee('The server could not pull the Coolify helper image') + ->assertDontSee('This folder is empty') + ->assertDontSee('Upload a file to this folder'); +}); + +it('keeps helper mode in the download token', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/')) + ->push(fbNoShellResult()) + ->push(Process::result(output: fbRootProcessStatus())) + ->push(Process::result(output: "directory\t0\t1\t755\troot\troot\t./data")) + ->push(Process::result(output: fbRootProcessStatus())) + ->push(Process::result(output: 'dir 4'))]); + + $database = fbRunningDatabase($this->environment, $this->destination); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'data'); + + $token = Str::afterLast($component->effects['redirect'], '/'); + expect(Cache::get(FileBrowserDownloadController::cacheKey($token)))->toMatchArray(['use_helper' => true, 'path' => '/data']); +}); + it('creates a directory and re-lists', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) // defaultRoot diff --git a/tests/v4/Browser/FileBrowserTest.php b/tests/v4/Browser/FileBrowserTest.php index 39c2d9add4..13ca6891ba 100644 --- a/tests/v4/Browser/FileBrowserTest.php +++ b/tests/v4/Browser/FileBrowserTest.php @@ -60,3 +60,32 @@ it('opens a directory whose name contains an apostrophe', function () { ->assertNoJavaScriptErrors() ->screenshot(filename: 'file-browser-apostrophe'); }); + +it('creates a file with the typed name from the New menu', function () { + config(['constants.ssh.mux_enabled' => false]); + Storage::fake('ssh-keys'); + $this->postgres->update(['status' => 'running']); + $commands = []; + Process::fake(function ($process) use (&$commands) { + $commands[] = $process->command; + + return Process::result(output: str_contains($process->command, 'docker inspect') ? '/data' : ''); + }); + loginAndSkipBoarding(); + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ).'/files'; + $page = visit($url) + ->assertSee('This folder is empty') + ->click('button:visible:has-text("New")') + ->click('button:visible:has-text("New file")') + ->fill('#newFileName', 'asd') + ->click('button:visible:has-text("Create file")') + ->assertSee('File created.') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'file-browser-create-file'); + + expect(collect($commands)->contains(fn (string $command) => str_contains($command, '/data/asd')))->toBeTrue(); +});