From ffacd18100c7c81f21f1fc8b323276a5a882148f Mon Sep 17 00:00:00 2001 From: Ian Cross <9467417+Deducer@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:55:24 -0700 Subject: [PATCH] feat: add container file browser for applications, databases, and services Adds a file browser allowing users to browse, upload, download, and manage files inside running containers. Accessible from the Files tab behind the canAccessTerminal gate. Closes #6519 --- app/Livewire/Project/Shared/FileBrowser.php | 550 ++++++++++++++++++ .../project/application/heading.blade.php | 4 + .../project/database/heading.blade.php | 4 + .../project/service/heading.blade.php | 4 + .../project/shared/file-browser.blade.php | 194 ++++++ routes/web.php | 4 + tests/Unit/Livewire/FileBrowserTest.php | 172 ++++++ 7 files changed, 932 insertions(+) create mode 100644 app/Livewire/Project/Shared/FileBrowser.php create mode 100644 resources/views/livewire/project/shared/file-browser.blade.php create mode 100644 tests/Unit/Livewire/FileBrowserTest.php diff --git a/app/Livewire/Project/Shared/FileBrowser.php b/app/Livewire/Project/Shared/FileBrowser.php new file mode 100644 index 000000000..316358bca --- /dev/null +++ b/app/Livewire/Project/Shared/FileBrowser.php @@ -0,0 +1,550 @@ +parameters = get_route_parameters(); + $this->containers = collect(); + $this->servers = collect(); + + if (data_get($this->parameters, 'application_uuid')) { + $this->type = 'application'; + $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + if ($this->resource->destination->server->isFunctional()) { + $this->servers = $this->servers->push($this->resource->destination->server); + } + foreach ($this->resource->additional_servers as $server) { + if ($server->isFunctional()) { + $this->servers = $this->servers->push($server); + } + } + $this->loadContainers(); + } elseif (data_get($this->parameters, 'database_uuid')) { + $this->type = 'database'; + $resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id')); + if (is_null($resource)) { + abort(404); + } + $this->resource = $resource; + if ($this->resource->destination->server->isFunctional()) { + $this->servers = $this->servers->push($this->resource->destination->server); + } + $this->loadContainers(); + } elseif (data_get($this->parameters, 'service_uuid')) { + $this->type = 'service'; + $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + if ($this->resource->server->isFunctional()) { + $this->servers = $this->servers->push($this->resource->server); + } + $this->loadContainers(); + } + } + + public function loadContainers(): void + { + foreach ($this->servers as $server) { + if (data_get($this->parameters, 'application_uuid')) { + if ($server->isSwarm()) { + $containers = collect([ + [ + 'Names' => $this->resource->uuid.'_'.$this->resource->uuid, + ], + ]); + } else { + $containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true); + } + foreach ($containers as $container) { + if (data_get($container, 'State') === 'running') { + $this->containers = $this->containers->push([ + 'server' => $server, + 'container' => $container, + ]); + } + } + } elseif (data_get($this->parameters, 'database_uuid')) { + if ($this->resource->isRunning()) { + $this->containers = $this->containers->push([ + 'server' => $server, + 'container' => [ + 'Names' => $this->resource->uuid, + ], + ]); + } + } elseif (data_get($this->parameters, 'service_uuid')) { + $this->resource->applications()->get()->each(function ($application) { + if ($application->isRunning()) { + $this->containers->push([ + 'server' => $this->resource->server, + 'container' => [ + 'Names' => data_get($application, 'name').'-'.data_get($this->resource, 'uuid'), + ], + ]); + } + }); + $this->resource->databases()->get()->each(function ($database) { + if ($database->isRunning()) { + $this->containers->push([ + 'server' => $this->resource->server, + 'container' => [ + 'Names' => data_get($database, 'name').'-'.data_get($this->resource, 'uuid'), + ], + ]); + } + }); + } + } + + $this->containers = $this->containers->sortBy(fn ($container) => data_get($container, 'container.Names')); + + if ($this->containers->count() === 1) { + $this->selected_container = data_get($this->containers->first(), 'container.Names'); + $this->browse('/'); + } + } + + public function updatedSelectedContainer(): void + { + if ($this->selected_container !== 'default') { + $this->browse('/'); + } + } + + public function browse(string $path): void + { + if (! $this->validatePath($path)) { + $this->dispatch('error', 'Invalid path.'); + + return; + } + + $resolved = $this->resolveContainerAndServer(); + if (is_null($resolved)) { + return; + } + + $this->isLoading = true; + + try { + $escapedContainer = escapeshellarg($resolved['containerName']); + $escapedPath = escapeshellarg($path); + + $output = instant_remote_process([ + "docker exec {$escapedContainer} ls -la {$escapedPath} 2>&1", + ], $resolved['server']); + + $this->entries = $this->parseLsOutput($output); + $this->currentPath = $path; + } catch (\Throwable $e) { + $this->dispatch('error', 'Failed to browse: '.$e->getMessage()); + } finally { + $this->isLoading = false; + } + } + + public function navigateTo(int $index): void + { + if (! isset($this->entries[$index]) || ! $this->entries[$index]['isDirectory']) { + $this->dispatch('error', 'Invalid entry.'); + + return; + } + + $name = $this->entries[$index]['name']; + $newPath = rtrim($this->currentPath, '/').'/'.ltrim($name, '/'); + $this->browse($newPath); + } + + public function navigateUp(): void + { + if ($this->currentPath === '/') { + return; + } + $parent = dirname($this->currentPath); + $this->browse($parent); + } + + public function createFolder(): void + { + if (empty(trim($this->newFolderName))) { + $this->dispatch('error', 'Folder name cannot be empty.'); + + return; + } + + if (! preg_match('/^[a-zA-Z0-9._\-]+$/', $this->newFolderName)) { + $this->dispatch('error', 'Folder name contains invalid characters.'); + + return; + } + + $resolved = $this->resolveContainerAndServer(); + if (is_null($resolved)) { + return; + } + + try { + $folderPath = rtrim($this->currentPath, '/').'/'.$this->newFolderName; + $escapedContainer = escapeshellarg($resolved['containerName']); + $escapedPath = escapeshellarg($folderPath); + + instant_remote_process([ + "docker exec {$escapedContainer} mkdir -p {$escapedPath}", + ], $resolved['server']); + + $this->newFolderName = ''; + $this->showCreateFolder = false; + $this->dispatch('success', 'Folder created.'); + $this->browse($this->currentPath); + } catch (\Throwable $e) { + $this->dispatch('error', 'Failed to create folder: '.$e->getMessage()); + } + } + + public function deleteEntry(int $index): void + { + if (! isset($this->entries[$index])) { + $this->dispatch('error', 'Invalid entry.'); + + return; + } + + $name = $this->entries[$index]['name']; + + $resolved = $this->resolveContainerAndServer(); + if (is_null($resolved)) { + return; + } + + try { + $entryPath = rtrim($this->currentPath, '/').'/'.$name; + $escapedContainer = escapeshellarg($resolved['containerName']); + $escapedPath = escapeshellarg($entryPath); + + instant_remote_process([ + "docker exec {$escapedContainer} rm -rf {$escapedPath}", + ], $resolved['server']); + + $this->dispatch('success', 'Deleted successfully.'); + $this->browse($this->currentPath); + } catch (\Throwable $e) { + $this->dispatch('error', 'Failed to delete: '.$e->getMessage()); + } + } + + public function downloadFile(int $index): mixed + { + if (! isset($this->entries[$index]) || $this->entries[$index]['isDirectory']) { + $this->dispatch('error', 'Invalid file.'); + + return null; + } + + $name = $this->entries[$index]['name']; + + $resolved = $this->resolveContainerAndServer(); + if (is_null($resolved)) { + return null; + } + + try { + $filePath = rtrim($this->currentPath, '/').'/'.$name; + $escapedContainer = escapeshellarg($resolved['containerName']); + $escapedPath = escapeshellarg($filePath); + + $sizeOutput = instant_remote_process([ + "docker exec {$escapedContainer} stat -c %s {$escapedPath} 2>/dev/null || echo 0", + ], $resolved['server'], throwError: false); + + $fileSize = (int) trim($sizeOutput); + + if ($fileSize > self::MAX_DOWNLOAD_SIZE) { + $this->dispatch('error', 'File is too large to download via browser (max 100MB). Use the terminal instead.'); + + return null; + } + + $content = instant_remote_process([ + "docker exec {$escapedContainer} sh -c 'base64 {$escapedPath}'", + ], $resolved['server']); + + $decoded = base64_decode(str_replace("\n", '', $content)); + + return response()->streamDownload(function () use ($decoded) { + echo $decoded; + }, $name); + } catch (\Throwable $e) { + $this->dispatch('error', 'Failed to download: '.$e->getMessage()); + + return null; + } + } + + public function uploadToContainer(): void + { + if (is_null($this->uploadFile)) { + $this->dispatch('error', 'No file selected.'); + + return; + } + + $resolved = $this->resolveContainerAndServer(); + if (is_null($resolved)) { + return; + } + + $this->isUploading = true; + + try { + $originalName = $this->uploadFile->getClientOriginalName(); + if (! preg_match('/^[a-zA-Z0-9._\- ]+$/', $originalName)) { + $this->dispatch('error', 'File name contains invalid characters.'); + + return; + } + + $uuid = (string) new Cuid2; + $localPath = $this->uploadFile->store("tmp/filebrowser-{$uuid}"); + $fullLocalPath = storage_path('app/'.$localPath); + + $remoteTmpPath = "/tmp/coolify-upload-{$uuid}"; + $containerDest = rtrim($this->currentPath, '/').'/'.$originalName; + $escapedContainer = escapeshellarg($resolved['containerName']); + $escapedRemoteTmp = escapeshellarg($remoteTmpPath); + $escapedContainerDest = escapeshellarg($containerDest); + + instant_scp($fullLocalPath, $remoteTmpPath, $resolved['server']); + + instant_remote_process([ + "docker cp {$escapedRemoteTmp} {$escapedContainer}:{$escapedContainerDest}", + ], $resolved['server']); + + instant_remote_process([ + "rm -f {$escapedRemoteTmp}", + ], $resolved['server']); + + @unlink($fullLocalPath); + @rmdir(dirname($fullLocalPath)); + + $this->uploadFile = null; + $this->dispatch('success', 'File uploaded.'); + $this->browse($this->currentPath); + } catch (\Throwable $e) { + $this->dispatch('error', 'Failed to upload: '.$e->getMessage()); + } finally { + $this->isUploading = false; + } + } + + public function downloadFolder(int $index): mixed + { + if (! isset($this->entries[$index]) || ! $this->entries[$index]['isDirectory']) { + $this->dispatch('error', 'Invalid folder.'); + + return null; + } + + $name = $this->entries[$index]['name']; + + $resolved = $this->resolveContainerAndServer(); + if (is_null($resolved)) { + return null; + } + + try { + $folderPath = rtrim($this->currentPath, '/').'/'.$name; + $escapedContainer = escapeshellarg($resolved['containerName']); + $escapedPath = escapeshellarg($folderPath); + + $sizeOutput = instant_remote_process([ + "docker exec {$escapedContainer} sh -c 'du -sb {$escapedPath} 2>/dev/null | cut -f1 || echo 0'", + ], $resolved['server'], throwError: false); + + $folderSize = (int) trim($sizeOutput); + + if ($folderSize > self::MAX_DOWNLOAD_SIZE) { + $this->dispatch('error', 'Folder is too large to download via browser (max 100MB). Use the terminal instead.'); + + return null; + } + + $content = instant_remote_process([ + "docker exec {$escapedContainer} sh -c 'tar czf - -C ".escapeshellarg(dirname($folderPath)).' '.escapeshellarg($name)." | base64'", + ], $resolved['server']); + + $decoded = base64_decode(str_replace("\n", '', $content)); + + return response()->streamDownload(function () use ($decoded) { + echo $decoded; + }, $name.'.tar.gz'); + } catch (\Throwable $e) { + $this->dispatch('error', 'Failed to download folder: '.$e->getMessage()); + + return null; + } + } + + private function resolveContainerAndServer(): ?array + { + if ($this->selected_container === 'default') { + $this->dispatch('error', 'Please select a container.'); + + return null; + } + + if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $this->selected_container)) { + $this->dispatch('error', 'Invalid container name.'); + + return null; + } + + $container = collect($this->containers)->firstWhere('container.Names', $this->selected_container); + if (is_null($container)) { + $this->dispatch('error', 'Container not found.'); + + return null; + } + + $server = data_get($container, 'server'); + if (! $server || ! $server instanceof Server) { + $this->dispatch('error', 'Invalid server configuration.'); + + return null; + } + + if ($server->isForceDisabled()) { + $this->dispatch('error', 'Server is disabled.'); + + return null; + } + + return [ + 'containerName' => data_get($container, 'container.Names'), + 'server' => $server, + ]; + } + + private function validatePath(string $path): bool + { + if (! str_starts_with($path, '/')) { + return false; + } + + if (str_contains($path, '..')) { + return false; + } + + if (preg_match('/[`$|;&<>!\\\]/', $path)) { + return false; + } + + if (str_contains($path, "\0")) { + return false; + } + + return true; + } + + /** + * @return array + */ + private function parseLsOutput(?string $output): array + { + if (empty($output)) { + return []; + } + + $lines = explode("\n", trim($output)); + $entries = []; + + foreach ($lines as $line) { + $line = trim($line); + if (empty($line) || str_starts_with($line, 'total ')) { + continue; + } + + if (preg_match('/^([d\-lbcps][rwxsStT\-]{9})\s+(\d+)\s+(\S+)\s+(\S+)\s+([\d,]+)\s+(.{12,18})\s+(.+)$/', $line, $matches)) { + $name = $matches[7]; + if ($name === '.' || $name === '..') { + continue; + } + + $isSymlink = str_starts_with($matches[1], 'l'); + $linkTarget = null; + if ($isSymlink && str_contains($name, ' -> ')) { + [$name, $linkTarget] = explode(' -> ', $name, 2); + } + + $sizeStr = str_replace(',', '', $matches[5]); + + $entries[] = [ + 'permissions' => $matches[1], + 'links' => (int) $matches[2], + 'owner' => $matches[3], + 'group' => $matches[4], + 'size' => (int) $sizeStr, + 'modified' => trim($matches[6]), + 'name' => $name, + 'isDirectory' => str_starts_with($matches[1], 'd'), + 'isSymlink' => $isSymlink, + 'linkTarget' => $linkTarget, + ]; + } + } + + usort($entries, function ($a, $b) { + if ($a['isDirectory'] !== $b['isDirectory']) { + return $b['isDirectory'] <=> $a['isDirectory']; + } + + return strcasecmp($a['name'], $b['name']); + }); + + return $entries; + } + + public function render() + { + return view('livewire.project.shared.file-browser'); + } +} diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index 4af466fc5..683045143 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -27,6 +27,10 @@ href="{{ route('project.application.command', $parameters) }}"> Terminal + + Files + @endcan @endif diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index 4087769cc..99fca600a 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -25,6 +25,10 @@ href="{{ route('project.database.command', $parameters) }}"> Terminal + + Files + @endcan @if ( $database->getMorphClass() === 'App\Models\StandalonePostgresql' || diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php index c33ebc279..c0e3c558d 100644 --- a/resources/views/livewire/project/service/heading.blade.php +++ b/resources/views/livewire/project/service/heading.blade.php @@ -23,6 +23,10 @@ href="{{ route('project.service.command', $parameters) }}"> + + + @endcan diff --git a/resources/views/livewire/project/shared/file-browser.blade.php b/resources/views/livewire/project/shared/file-browser.blade.php new file mode 100644 index 000000000..f3668125f --- /dev/null +++ b/resources/views/livewire/project/shared/file-browser.blade.php @@ -0,0 +1,194 @@ +
+ + {{ data_get_str($resource, 'name')->limit(10) }} > File Browser | Coolify + + @if ($type === 'application') + +

File Browser

+ + @elseif ($type === 'database') + +

File Browser

+ + @elseif ($type === 'service') + + + @endif + +

File Browser

+ + @if (count($containers) === 0) +
No running containers found or terminal access is disabled on this server.
+ @else + {{-- Container selector --}} +
+ + @foreach ($containers as $container) + @if ($loop->first) + + @endif + + @endforeach + +
+ + @if ($selected_container !== 'default') + {{-- Breadcrumb navigation --}} +
+ + @php + $pathParts = array_filter(explode('/', $currentPath)); + $accumulated = ''; + @endphp + @foreach ($pathParts as $part) + @php $accumulated .= '/' . $part; @endphp + / + + @endforeach +
+ + {{-- Toolbar --}} +
+ + + + + + Up + + + + + + Refresh + + + + + + New Folder + +
+ + {{-- Create folder form --}} + @if ($showCreateFolder) +
+ + Create + Cancel +
+ @endif + + {{-- Upload --}} +
+
+
+ + +
+ + Upload + Uploading... + +
+
+ + {{-- Loading indicator --}} +
+ +
+ + {{-- File listing --}} +
+ @if (count($entries) === 0 && $selected_container !== 'default') +
This directory is empty.
+ @else +
+ + + + + + + + + + + + + + @foreach ($entries as $entry) + + + + + + + + + + @endforeach + +
PermissionsOwnerGroupSizeModifiedNameActions
{{ $entry['permissions'] }}{{ $entry['owner'] }}{{ $entry['group'] }} + @if ($entry['isDirectory']) + — + @else + {{ formatBytes($entry['size']) }} + @endif + {{ $entry['modified'] }} + @if ($entry['isDirectory']) + + @elseif ($entry['isSymlink']) + + + + + + {{ $entry['name'] }} + @if ($entry['linkTarget']) + -> {{ $entry['linkTarget'] }} + @endif + + @else + + + + + + {{ $entry['name'] }} + + @endif + + @if ($entry['isDirectory']) + + | + @else + + | + @endif + +
+
+ @endif +
+ @endif + @endif +
diff --git a/routes/web.php b/routes/web.php index b6c6c95ce..662487037 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,6 +32,7 @@ use App\Livewire\Project\Service\Configuration as ServiceConfiguration; use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups; use App\Livewire\Project\Service\Index as ServiceIndex; use App\Livewire\Project\Shared\ExecuteContainerCommand; +use App\Livewire\Project\Shared\FileBrowser; use App\Livewire\Project\Shared\Logs; use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow; use App\Livewire\Project\Show as ProjectShow; @@ -215,6 +216,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/deployment/{deployment_uuid}', DeploymentShow::class)->name('project.application.deployment.show'); Route::get('/logs', Logs::class)->name('project.application.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.application.command')->middleware('can.access.terminal'); + Route::get('/file-browser', FileBrowser::class)->name('project.application.file-browser')->middleware('can.access.terminal'); Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.application.scheduled-tasks'); }); Route::prefix('project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}')->group(function () { @@ -232,6 +234,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/logs', Logs::class)->name('project.database.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.database.command')->middleware('can.access.terminal'); + Route::get('/file-browser', FileBrowser::class)->name('project.database.file-browser')->middleware('can.access.terminal'); Route::get('/backups', DatabaseBackupIndex::class)->name('project.database.backup.index'); Route::get('/backups/{backup_uuid}', DatabaseBackupExecution::class)->name('project.database.backup.execution'); }); @@ -246,6 +249,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/tags', ServiceConfiguration::class)->name('project.service.tags'); Route::get('/danger', ServiceConfiguration::class)->name('project.service.danger'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.service.command')->middleware('can.access.terminal'); + Route::get('/file-browser', FileBrowser::class)->name('project.service.file-browser')->middleware('can.access.terminal'); Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups'); Route::get('/{stack_service_uuid}/import', ServiceIndex::class)->name('project.service.database.import')->middleware('can.update.resource'); Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index'); diff --git a/tests/Unit/Livewire/FileBrowserTest.php b/tests/Unit/Livewire/FileBrowserTest.php new file mode 100644 index 000000000..1a8c736f9 --- /dev/null +++ b/tests/Unit/Livewire/FileBrowserTest.php @@ -0,0 +1,172 @@ +invoke($component, '/'))->toBeTrue(); + expect($method->invoke($component, '/home'))->toBeTrue(); + expect($method->invoke($component, '/var/log/app'))->toBeTrue(); + expect($method->invoke($component, '/usr/local/bin'))->toBeTrue(); + expect($method->invoke($component, '/tmp/my-file.txt'))->toBeTrue(); + expect($method->invoke($component, '/path/with spaces'))->toBeTrue(); +}); + +test('validatePath rejects relative paths', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'validatePath'); + + expect($method->invoke($component, 'relative/path'))->toBeFalse(); + expect($method->invoke($component, './current'))->toBeFalse(); + expect($method->invoke($component, 'file.txt'))->toBeFalse(); +}); + +test('validatePath rejects directory traversal', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'validatePath'); + + expect($method->invoke($component, '/path/../etc/passwd'))->toBeFalse(); + expect($method->invoke($component, '/path/..hidden'))->toBeFalse(); + expect($method->invoke($component, '/..'))->toBeFalse(); + expect($method->invoke($component, '/../../etc'))->toBeFalse(); +}); + +test('validatePath rejects shell metacharacters', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'validatePath'); + + expect($method->invoke($component, '/path/$(whoami)'))->toBeFalse(); + expect($method->invoke($component, '/path/`id`'))->toBeFalse(); + expect($method->invoke($component, '/path/;rm -rf /'))->toBeFalse(); + expect($method->invoke($component, '/path/|cat /etc/passwd'))->toBeFalse(); + expect($method->invoke($component, '/path/&bg'))->toBeFalse(); + expect($method->invoke($component, '/path/>output'))->toBeFalse(); + expect($method->invoke($component, '/path/toBeFalse(); + expect($method->invoke($component, '/path/!history'))->toBeFalse(); +}); + +test('validatePath rejects null bytes', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'validatePath'); + + expect($method->invoke($component, "/path/\0hidden"))->toBeFalse(); +}); + +test('parseLsOutput parses standard ls output', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'parseLsOutput'); + + $output = <<<'LS' +total 48 +drwxr-xr-x 2 root root 4096 Mar 2 12:00 config +-rw-r--r-- 1 www www 1234 Mar 1 09:30 index.html +-rwxr-xr-x 1 root root 567 Feb 28 15:45 start.sh +LS; + + $entries = $method->invoke($component, $output); + + expect($entries)->toHaveCount(3); + + expect($entries[0]['name'])->toBe('config'); + expect($entries[0]['isDirectory'])->toBeTrue(); + expect($entries[0]['permissions'])->toBe('drwxr-xr-x'); + expect($entries[0]['owner'])->toBe('root'); + + expect($entries[1]['name'])->toBe('index.html'); + expect($entries[1]['isDirectory'])->toBeFalse(); + expect($entries[1]['size'])->toBe(1234); + expect($entries[1]['owner'])->toBe('www'); + + expect($entries[2]['name'])->toBe('start.sh'); + expect($entries[2]['permissions'])->toBe('-rwxr-xr-x'); +}); + +test('parseLsOutput sorts directories first then alphabetically', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'parseLsOutput'); + + $output = <<<'LS' +total 16 +-rw-r--r-- 1 root root 100 Mar 1 10:00 zebra.txt +drwxr-xr-x 2 root root 4096 Mar 1 10:00 alpha +-rw-r--r-- 1 root root 200 Mar 1 10:00 apple.txt +drwxr-xr-x 2 root root 4096 Mar 1 10:00 beta +LS; + + $entries = $method->invoke($component, $output); + + expect($entries)->toHaveCount(4); + expect($entries[0]['name'])->toBe('alpha'); + expect($entries[0]['isDirectory'])->toBeTrue(); + expect($entries[1]['name'])->toBe('beta'); + expect($entries[1]['isDirectory'])->toBeTrue(); + expect($entries[2]['name'])->toBe('apple.txt'); + expect($entries[2]['isDirectory'])->toBeFalse(); + expect($entries[3]['name'])->toBe('zebra.txt'); + expect($entries[3]['isDirectory'])->toBeFalse(); +}); + +test('parseLsOutput skips . and .. entries', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'parseLsOutput'); + + $output = <<<'LS' +total 8 +drwxr-xr-x 3 root root 4096 Mar 1 10:00 . +drwxr-xr-x 5 root root 4096 Mar 1 10:00 .. +-rw-r--r-- 1 root root 100 Mar 1 10:00 file.txt +LS; + + $entries = $method->invoke($component, $output); + + expect($entries)->toHaveCount(1); + expect($entries[0]['name'])->toBe('file.txt'); +}); + +test('parseLsOutput handles symlinks', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'parseLsOutput'); + + $output = <<<'LS' +total 4 +lrwxrwxrwx 1 root root 11 Mar 1 10:00 link -> /etc/target +-rw-r--r-- 1 root root 100 Mar 1 10:00 normal.txt +LS; + + $entries = $method->invoke($component, $output); + + expect($entries)->toHaveCount(2); + expect($entries[0]['name'])->toBe('normal.txt'); + + $symlink = collect($entries)->firstWhere('name', 'link'); + expect($symlink['isSymlink'])->toBeTrue(); + expect($symlink['linkTarget'])->toBe('/etc/target'); +}); + +test('parseLsOutput handles empty output', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'parseLsOutput'); + + expect($method->invoke($component, ''))->toBe([]); + expect($method->invoke($component, null))->toBe([]); + expect($method->invoke($component, 'total 0'))->toBe([]); +}); + +test('parseLsOutput handles files with spaces in names', function () { + $component = new FileBrowser; + $method = new ReflectionMethod($component, 'parseLsOutput'); + + $output = <<<'LS' +total 4 +-rw-r--r-- 1 root root 100 Mar 1 10:00 my file name.txt +drwxr-xr-x 2 root root 4096 Mar 1 10:00 my folder +LS; + + $entries = $method->invoke($component, $output); + + expect($entries)->toHaveCount(2); + expect(collect($entries)->firstWhere('isDirectory', true)['name'])->toBe('my folder'); + expect(collect($entries)->firstWhere('isDirectory', false)['name'])->toBe('my file name.txt'); +});