diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 12ac569009..14291d5f91 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -28,7 +28,7 @@ class StopApplication if ($server->isSwarm()) { $containerPresent = false; - instant_remote_process(["docker stack rm {$application->uuid}"], $server); + instant_remote_process(['docker stack rm '.escapeshellarg($application->uuid)], $server); continue; } @@ -41,9 +41,10 @@ class StopApplication $timeout = $application->settings->stopGracePeriodSeconds(); foreach ($containersToStop as $containerName) { - $commands = [dockerStopCommand($timeout, $containerName, $server)]; + $escapedContainerName = escapeshellarg($containerName); + $commands = [dockerStopCommand($timeout, $escapedContainerName, $server)]; if ($removeContainers) { - $commands[] = "docker rm -f $containerName"; + $commands[] = "docker rm -f {$escapedContainerName}"; } instant_remote_process(command: $commands, server: $server, throwError: false); diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index b25eb481b6..5b40702cd8 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -28,7 +28,7 @@ class StopApplicationOneServer if ($containerName) { instant_remote_process( [ - dockerStopCommand($timeout, $containerName, $server), + dockerStopCommand($timeout, escapeshellarg($containerName), $server), dockerRemoveCommand($containerName), ], $server diff --git a/app/Actions/Application/StopApplicationPreview.php b/app/Actions/Application/StopApplicationPreview.php index 8bb3a3dc08..954bde879a 100644 --- a/app/Actions/Application/StopApplicationPreview.php +++ b/app/Actions/Application/StopApplicationPreview.php @@ -17,9 +17,10 @@ class StopApplicationPreview $containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id); foreach ($containers->pluck('Names') as $containerName) { - $commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)]; + $escapedContainerName = escapeshellarg($containerName); + $commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $escapedContainerName, $server)]; if ($removeContainer) { - $commands[] = "docker rm -f $containerName"; + $commands[] = "docker rm -f {$escapedContainerName}"; } instant_remote_process($commands, $server, false); } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 52d9edda19..ce101c5b80 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -74,7 +74,7 @@ class StopService { $timeout = count($containersToStop) > 5 ? 10 : 30; $commands = []; - $containerList = implode(' ', $containersToStop); + $containerList = implode(' ', array_map('escapeshellarg', $containersToStop)); $commands[] = dockerStopCommand($timeout, $containerList, $server); $commands[] = "docker rm -f $containerList"; instant_remote_process( diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 393f997b9b..fee94e874d 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1246,7 +1246,7 @@ class ServicesController extends Controller // Validate for command injection BEFORE saving to database try { - validateDockerComposeForInjection($dockerComposeRaw); + validateDockerComposeForInjection($dockerComposeRaw, composeResourceDirectory($service)); } catch (\Exception $e) { return response()->json([ 'message' => 'Validation failed.', diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 6435c1a64b..6407f0b500 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -157,7 +157,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker'; - $execCommand = "{$dockerCommand} exec {$containerName} {$cmd}"; + $execCommand = "{$dockerCommand} exec ".escapeshellarg($containerName)." {$cmd}"; $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index bcc2996449..75742c8cd9 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -347,7 +347,7 @@ class General extends Component } try { - validateDockerComposeForInjection($this->dockerComposeRaw); + validateDockerComposeForInjection($this->dockerComposeRaw, composeResourceDirectory($this->application)); } catch (Exception $e) { throw new Exception(e($e->getMessage()), 0, $e); } diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 0572932dae..c27b739e8b 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -166,7 +166,7 @@ class StackForm extends Component $this->syncData(true); // Validate for command injection BEFORE any database operations - validateDockerComposeForInjection($this->service->docker_compose_raw); + validateDockerComposeForInjection($this->service->docker_compose_raw, composeResourceDirectory($this->service)); // Use transaction to ensure atomicity - if parse fails, save is rolled back DB::transaction(function () { diff --git a/app/Models/Application.php b/app/Models/Application.php index 605a320562..0bc88f592d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2281,7 +2281,7 @@ class Application extends BaseModel } if ($composeFileContent) { try { - validateDockerComposeForInjection($composeFileContent); + validateDockerComposeForInjection($composeFileContent, composeResourceDirectory($this)); } catch (\Exception $e) { $this->docker_compose_location = $initialDockerComposeLocation; $this->base_directory = $initialBaseDirectory; diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index e5d6b971ea..7021b24af4 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -283,7 +283,20 @@ class LocalFileVolume extends BaseModel } $path = data_get_str($this, 'fs_path'); $content = data_get($this, 'content'); - $pathForParentDirectory = str($this->fs_path); + $writesContent = $this->writesContentOnServer(); + if ($path->startsWith('.')) { + $path = $path->after('.'); + $path = $workdir.$path; + } + + if ($writesContent) { + $path = str($this->confinedContentPath($path->value(), $server)); + } elseif (! $this->isAdminControlledComposeMount()) { + $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); + $this->assertRemotePathIsConfined($workdir, $path->value(), $server); + } + + $pathForParentDirectory = $writesContent ? $path : str($this->fs_path); if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) { $parent_dir = $pathForParentDirectory->beforeLast('/'); if ($parent_dir != '') { @@ -291,15 +304,6 @@ class LocalFileVolume extends BaseModel $commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true"); } } - if ($path->startsWith('.')) { - $path = $path->after('.'); - $path = $workdir.$path; - } - - if (! $this->isAdminControlledComposeMount()) { - $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); - $this->assertRemotePathIsConfined($workdir, $path->value(), $server); - } // Validate and escape resolved path (may differ from fs_path if relative) validateShellSafePath($path, 'storage path'); @@ -373,6 +377,81 @@ class LocalFileVolume extends BaseModel return 'sh -c '.escapeshellarg(self::REMOTE_PATH_CONFINEMENT_SCRIPT).' sh '.escapeshellarg($baseDirectory).' '.escapeshellarg($path); } + /** + * Coolify writes file content (from Compose `content:`, the UI or the API) to the server. + */ + public function writesContentOnServer(): bool + { + return ! $this->is_directory && (string) $this->content !== ''; + } + + /** + * Coolify writes file content only inside the resource directory, also for Compose bind mounts + * that an administrator can point anywhere. The path must be below the directory (not the + * directory itself), and it must stay inside it after the server resolves symlinks. + * + * @throws \RuntimeException If the path is not inside the resource directory + */ + public function confinedContentPath(string $path, Server $server): string + { + $error = new \RuntimeException( + "Coolify writes file content only inside the resource directory. The path {$path} is outside of it. Use a relative source such as ./config/app.conf." + ); + + foreach ($this->contentBaseDirectories() as $baseDirectory) { + try { + $confinedPath = confinePathToBase($baseDirectory, $path, 'storage path'); + } catch (\Exception) { + continue; + } + if ($confinedPath === normalizeUnixPath($baseDirectory)) { + continue; + } + + try { + self::assertRemotePathIsConfined($baseDirectory, $confinedPath, $server); + } catch (\RuntimeException) { + throw $error; + } + + return $confinedPath; + } + + throw $error; + } + + /** + * The resource workdir, and the directory where the Compose parser resolves `./` sources. They + * differ only for services that use parser version 3. + * + * @return list + */ + public function contentBaseDirectories(): array + { + return array_values(array_unique([$this->ownerResource()->workdir(), $this->composeSourceDirectory()])); + } + + protected function composeSourceDirectory(): string + { + $owner = $this->ownerResource(); + + return $owner instanceof Application || $owner instanceof Service + ? composeResourceDirectory($owner) + : $owner->workdir(); + } + + /** + * The Application, Service or standalone database that owns the storage directory. + */ + protected function ownerResource(): mixed + { + $resource = $this->resource; + + return $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase + ? $resource->service + : $resource; + } + /** * Raw Compose bind mounts keep administrator-selected host path semantics. */ @@ -402,7 +481,7 @@ class LocalFileVolume extends BaseModel continue; } - $resolvedSource = replaceLocalSource(str((string) $source), str($this->resource->workdir())); + $resolvedSource = replaceLocalSource(str((string) $source), str($this->composeSourceDirectory())); if (normalizeUnixPath($resolvedSource->value()) === normalizeUnixPath($this->fs_path)) { return true; } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 21ef175a5e..e363b1c862 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -22,10 +22,11 @@ use Symfony\Component\Yaml\Yaml; * This should be called BEFORE saving to database to prevent malicious data from being stored. * * @param string $composeYaml The raw Docker Compose YAML content + * @param string|null $resourceDirectory The resource directory, if the resource exists (see validateComposeContentVolumeSource()) * * @throws Exception If the compose file contains command injection attempts */ -function validateDockerComposeForInjection(string $composeYaml): void +function validateDockerComposeForInjection(string $composeYaml, ?string $resourceDirectory = null): void { try { $parsed = Yaml::parse($composeYaml); @@ -78,6 +79,7 @@ function validateDockerComposeForInjection(string $composeYaml): void } } } + validateComposeContentVolumeSource($volume, $resourceDirectory); } } } @@ -105,6 +107,71 @@ function validateDockerComposeForInjection(string $composeYaml): void } } +/** + * The directory that the Compose parsers resolve `./` bind sources in. Services that use parser + * version 3 resolve them in the applications directory. + */ +function composeResourceDirectory(Application|Service $resource): string +{ + if ($resource instanceof Service && (int) $resource->compose_parsing_version === 3) { + return application_configuration_dir().'/'.$resource->uuid; + } + + return $resource->workdir(); +} + +/** + * Coolify writes the `content:` of a Compose bind volume to the host, so that file must be inside + * the resource directory. The source must be a `./` path that stays inside the resource directory, + * or an absolute path inside $resourceDirectory. Sources with `~`, `..` or variables are rejected, + * because their host path is not known before the write. Bind volumes without `content:` are not + * changed: an administrator can mount any host path. + * + * @param array $volume A long-syntax Compose volume + * + * @throws Exception If Coolify would write the content outside the resource directory + */ +function validateComposeContentVolumeSource(array $volume, ?string $resourceDirectory = null): void +{ + if (! array_key_exists('content', $volume) || ($volume['type'] ?? null) !== 'bind') { + return; + } + + $source = $volume['source'] ?? null; + $displaySource = is_scalar($source) && (string) $source !== '' ? (string) $source : '(empty)'; + $error = new Exception( + "Volume source {$displaySource} with content must be inside the resource directory. Use a relative path such as ./config/app.conf." + ); + + if (! is_string($source) || str_contains($source, '$') || str_contains($source, '\\')) { + throw $error; + } + if (in_array('..', explode('/', $source), true)) { + throw $error; + } + + if (str_starts_with($source, './')) { + $baseDirectory = $resourceDirectory ?? '/coolify-resource-directory'; + $path = $baseDirectory.'/'.substr($source, 2); + } elseif (str_starts_with($source, '/') && $resourceDirectory !== null) { + $baseDirectory = $resourceDirectory; + $path = $source; + } else { + throw $error; + } + + try { + $baseDirectory = normalizeUnixPath($baseDirectory); + $path = normalizeUnixPath($path); + } catch (Exception) { + throw $error; + } + + if (! str_starts_with($path, $baseDirectory.'/')) { + throw $error; + } +} + /** * Keep the existing array-source forms, but inspect the default that was previously skipped. */ @@ -982,6 +1049,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($source !== null && ! empty($source->value())) { validateComposeArrayVolumeSource($source->value()); } + validateComposeContentVolumeSource($volume, composeResourceDirectory($resource)); if ($target !== null && ! empty($target->value())) { try { validateShellSafePath($target->value(), 'volume target'); @@ -1026,7 +1094,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true) : true; if ($isPullRequest && $isPreviewSuffixEnabled) { - $source = addPreviewDeploymentSuffix($source, $pull_request_id); + $source = str(addPreviewDeploymentSuffix($source, $pull_request_id)); } LocalFileVolume::updateOrCreate( [ @@ -2341,6 +2409,7 @@ function serviceParser(Service $resource): Collection if ($source !== null && ! empty($source->value())) { validateComposeArrayVolumeSource($source->value()); } + validateComposeContentVolumeSource($volume, composeResourceDirectory($resource)); if ($target !== null && ! empty($target->value())) { try { validateShellSafePath($target->value(), 'volume target'); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 8e56f06fcb..4273115ed5 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -230,14 +230,16 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $fileVolume->is_directory = true; $fileVolume->save(); } elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && $content) { - // Does not exists (no dir or file), not flagged as directory, is init, has content + // Does not exists (no dir or file), not flagged as directory, is init, has content. + // Content is written only inside the resource directory, as a literal path. + $escapedContentLocation = escapeshellarg($fileVolume->confinedContentPath((string) $fileLocation, $server)); $fileVolume->content = $content; $fileVolume->is_directory = false; $fileVolume->save(); $content = base64_encode($content); instant_remote_process([ - 'mkdir -p -- "$(dirname -- '.$escapedFileLocation.')"', - "echo '$content' | base64 -d | tee -- {$escapedFileLocation}", + 'mkdir -p -- "$(dirname -- '.$escapedContentLocation.')"', + "echo '$content' | base64 -d | tee -- {$escapedContentLocation}", ], $server); } elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) { // Does not exists (no dir or file), flagged as directory, is init diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2c5a878261..e8b69a53b6 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2799,6 +2799,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $target = data_get_str($volume, 'target'); $content = data_get($volume, 'content'); $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); + validateComposeContentVolumeSource($volume, $savedService->service->workdir()); $foundConfig = $savedService->fileStorages()->whereMountPath($target)->first(); if ($foundConfig) { $contentNotNull = data_get($foundConfig, 'content'); diff --git a/tests/Feature/ComposeContentVolumeConfinementTest.php b/tests/Feature/ComposeContentVolumeConfinementTest.php new file mode 100644 index 0000000000..10b805764e --- /dev/null +++ b/tests/Feature/ComposeContentVolumeConfinementTest.php @@ -0,0 +1,361 @@ + 0, 'is_api_enabled' => true]); + config([ + 'app.maintenance.store' => 'array', + 'constants.ssh.mux_enabled' => false, + ]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +function contentConfinementService(string $compose): Service +{ + return Service::factory()->create([ + 'environment_id' => test()->environment->id, + 'server_id' => test()->server->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'docker_compose_raw' => $compose, + ]); +} + +function contentConfinementApplication(string $compose): Application +{ + return Application::factory()->create([ + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'build_pack' => 'dockercompose', + 'git_repository' => 'https://github.com/coollabsio/compose-app', + 'git_branch' => 'main', + 'base_directory' => '/', + 'docker_compose_location' => '/docker-compose.yml', + 'docker_compose_raw' => $compose, + ]); +} + +/** + * A file volume row that skipped the Compose validation, for example because it was stored before + * the validation existed or because the content came from the UI. + */ +function contentConfinementFileVolume(Service $service, string $fsPath, ?string $content, bool $isDirectory = false): LocalFileVolume +{ + $serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]); + + // Bus::fake() keeps the ServerStorageSaveJob of the `created` hook from running. + return LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => '/keys', + 'content' => $content, + 'is_directory' => $isDirectory, + 'resource_id' => $serviceApplication->id, + 'resource_type' => $serviceApplication->getMorphClass(), + ])->fresh(); +} + +/** + * Fakes the server. The symlink check prints $confinement, `test -f`/`test -d` print NOK. + */ +function contentConfinementFakeServer(string $confinement = 'OK'): void +{ + Process::fake(fn ($process) => Process::result(output: match (true) { + str_contains($process->command, 'readlink -f') => $confinement, + str_contains($process->command, 'test -') => 'NOK', + default => '', + })); +} + +function contentConfinementAssertNoContentWrite(): void +{ + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'base64 -d')); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'touch ')); +} + +test('the service parser rejects a content volume outside the service directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_OUTSIDE_COMPOSE); + + expect(fn () => serviceParser($service)) + ->toThrow(Exception::class, 'Volume source /root/.ssh/authorized_keys with content must be inside the resource directory.'); + + expect(LocalFileVolume::query()->count())->toBe(0); + Bus::assertNotDispatched(ServerStorageSaveJob::class); +}); + +test('the application parser rejects a content volume outside the application directory', function () { + $application = contentConfinementApplication(CONTENT_CONFINEMENT_OUTSIDE_COMPOSE); + + expect(fn () => applicationParser($application)) + ->toThrow(Exception::class, 'Volume source /root/.ssh/authorized_keys with content must be inside the resource directory.'); + + expect(LocalFileVolume::query()->count())->toBe(0); + Bus::assertNotDispatched(ServerStorageSaveJob::class); +}); + +test('the parsers reject traversal, home and variable sources with content', function (string $source) { + $compose = str_replace('/root/.ssh/authorized_keys', $source, CONTENT_CONFINEMENT_OUTSIDE_COMPOSE); + + expect(fn () => serviceParser(contentConfinementService($compose))) + ->toThrow(Exception::class, 'with content must be inside the resource directory.') + ->and(fn () => applicationParser(contentConfinementApplication($compose))) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); + + expect(LocalFileVolume::query()->count())->toBe(0); +})->with([ + './../../../root/.ssh/authorized_keys', + '../authorized_keys', + '~/.ssh/authorized_keys', + '${HOME}/.ssh/authorized_keys', +]); + +test('a relative content volume is stored and written inside the service directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_RELATIVE_COMPOSE); + serviceParser($service); + + $fileVolume = LocalFileVolume::query()->sole(); + $expectedPath = $service->workdir().'/config/app.conf'; + expect($fileVolume->fs_path)->toBe($expectedPath) + ->and($fileVolume->content)->toBe('listen 8080;') + ->and($fileVolume->is_directory)->toBeFalse(); + + contentConfinementFakeServer(); + $fileVolume->saveStorageOnServer(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'readlink -f') + && str_contains($process->command, "'{$service->workdir()}' '{$expectedPath}'")); + Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$service->workdir()}/config'")); + Process::assertRan(fn ($process) => str_contains($process->command, "| base64 -d | tee '{$expectedPath}' > /dev/null")); +}); + +test('a relative content volume is stored inside the application directory', function () { + $application = contentConfinementApplication(CONTENT_CONFINEMENT_RELATIVE_COMPOSE); + applicationParser($application); + + expect(LocalFileVolume::query()->sole()->fs_path)->toBe($application->workdir().'/config/app.conf'); +}); + +test('the write path refuses content outside the resource directory for a Compose bind mount', function () { + // The Compose file no longer has `content:` (the parser removes it), so the mount is administrator-controlled. + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: /root/.ssh/authorized_keys\n target: /keys\n"; + $service = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, '/root/.ssh/authorized_keys', 'ssh-ed25519 AAAA attacker'); + + contentConfinementFakeServer(); + + expect(fn () => $fileVolume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); +}); + +test('the write path refuses ../ traversal out of the resource directory', function () { + $source = './../../../root/.ssh/authorized_keys'; + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: {$source}\n target: /keys\n"; + $service = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, $service->workdir().'/../../../root/.ssh/authorized_keys', 'attacker'); + + contentConfinementFakeServer(); + + expect(fn () => $fileVolume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); +}); + +test('the write path refuses a symlink that leaves the resource directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_RELATIVE_COMPOSE); + $fileVolume = contentConfinementFileVolume($service, $service->workdir().'/config/app.conf', 'listen 8080;'); + + // The server resolves the symlinked config directory to a path outside the resource directory. + contentConfinementFakeServer('NOK'); + + expect(fn () => $fileVolume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); +}); + +test('the write path refuses to write content to the resource directory itself', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_SAFE_COMPOSE); + $fileVolume = contentConfinementFileVolume($service, $service->workdir(), 'content'); + + contentConfinementFakeServer(); + + expect(fn () => $fileVolume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -fr')); +}); + +test('a bind mount without content keeps its administrator-selected host path', function () { + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - /srv/shared:/keys\n"; + $service = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, '/srv/shared', null, isDirectory: true); + + contentConfinementFakeServer(); + $fileVolume->saveStorageOnServer(); + + Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/srv/shared'")); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'readlink -f')); +}); + +test('a host file bind mount without content is still touched in place', function () { + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - /etc/localtime:/keys:ro\n"; + $service = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, '/etc/localtime', null); + + contentConfinementFakeServer(); + $fileVolume->saveStorageOnServer(); + + Process::assertRan(fn ($process) => str_contains($process->command, "touch '/etc/localtime'")); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'readlink -f')); +}); + +test('loading files for a service does not write content outside the resource directory', function () { + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: /etc/cron.d/coolify\n target: /keys\n"; + $service = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, '/etc/cron.d/coolify', '* * * * * root id'); + + contentConfinementFakeServer(); + + expect(fn () => getFilesystemVolumesFromServer($fileVolume->resource, true)) + ->toThrow(Exception::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); +}); + +test('loading files for a service writes relative content inside the resource directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_RELATIVE_COMPOSE); + $expectedPath = $service->workdir().'/config/app.conf'; + $fileVolume = contentConfinementFileVolume($service, $expectedPath, 'listen 8080;'); + + contentConfinementFakeServer(); + getFilesystemVolumesFromServer($fileVolume->resource, true); + + Process::assertRan(fn ($process) => str_contains($process->command, "base64 -d | tee -- '{$expectedPath}'")); +}); + +test('the service stack form rejects a content volume outside the service directory', function () { + $this->actingAs($this->user); + $service = contentConfinementService(CONTENT_CONFINEMENT_SAFE_COMPOSE); + + Livewire::test(StackForm::class, ['service' => $service]) + ->set('dockerComposeRaw', CONTENT_CONFINEMENT_OUTSIDE_COMPOSE) + ->call('submit') + ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'with content must be inside the resource directory')); + + expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + ->and(LocalFileVolume::query()->count())->toBe(0); +}); + +test('the application General form rejects a content volume outside the application directory', function () { + $this->actingAs($this->user); + $application = contentConfinementApplication(CONTENT_CONFINEMENT_SAFE_COMPOSE); + Process::fake(); + + Livewire::test(General::class, ['application' => $application->fresh()]) + ->set('dockerComposeRaw', CONTENT_CONFINEMENT_OUTSIDE_COMPOSE) + ->call('submit') + ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'with content must be inside the resource directory')) + ->assertNotDispatched('success'); + + expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + ->and(LocalFileVolume::query()->count())->toBe(0); +}); + +test('loading a Git Compose file rejects a content volume outside the application directory', function () { + $application = contentConfinementApplication(CONTENT_CONFINEMENT_SAFE_COMPOSE); + Process::fake(function ($process) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return Process::result(output: match (true) { + str_contains($command, 'git --version') => 'git version 2.43.0', + str_contains($command, 'head -c') => CONTENT_CONFINEMENT_OUTSIDE_COMPOSE, + default => '', + }); + }); + + expect(fn () => $application->loadComposeFile()) + ->toThrow(Exception::class, 'with content must be inside the resource directory'); + + expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + ->and(LocalFileVolume::query()->count())->toBe(0); +}); + +test('the service API rejects a content volume outside the service directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_SAFE_COMPOSE); + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'content-confinement', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'docker_compose_raw' => base64_encode(CONTENT_CONFINEMENT_OUTSIDE_COMPOSE), + ]) + ->assertStatus(422) + ->assertJsonPath('errors.docker_compose_raw', 'Volume source /root/.ssh/authorized_keys with content must be inside the resource directory. Use a relative path such as ./config/app.conf.'); + + expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + ->and(LocalFileVolume::query()->count())->toBe(0); +}); diff --git a/tests/Feature/ContainerNameShellQuotingTest.php b/tests/Feature/ContainerNameShellQuotingTest.php new file mode 100644 index 0000000000..0fa8a6c6eb --- /dev/null +++ b/tests/Feature/ContainerNameShellQuotingTest.php @@ -0,0 +1,240 @@ + 0]); + config([ + 'app.maintenance.store' => 'array', + 'constants.ssh.mux_enabled' => false, + ]); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, 'docker_version' => '28.0.0']); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n app:\n image: nginx\n", + ]); + $this->serviceApplication = ServiceApplication::create([ + 'name' => QUOTING_HOSTILE_NAME, + 'service_id' => $this->service->id, + 'status' => 'running:healthy', + ]); + $this->containerName = escapeshellarg(QUOTING_HOSTILE_NAME.'-'.$this->service->uuid); + + Process::fake(fn () => Process::result(output: 'done')); +}); + +function quotingUseNonRootUser(): void +{ + test()->server->update(['user' => 'ubuntu']); + test()->server->refresh(); + test()->service->refresh(); + test()->serviceApplication->refresh(); +} + +function quotingAssertCommandRan(string $command): void +{ + Process::assertRan(fn ($process) => str_contains($process->command, $command)); +} + +function quotingAssertNoUnquotedName(): void +{ + Process::assertDidntRun(fn ($process) => str_contains($process->command, ' app;touch')); +} + +test('stopping a service application quotes its container name', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + + StopServiceApplication::run($this->serviceApplication); + StopServiceApplication::run($this->serviceApplication->refresh(), removeContainer: true); + + quotingAssertCommandRan("\n{$sudo}docker stop {$this->containerName}\n"); + quotingAssertCommandRan("\n{$sudo}docker rm -f {$this->containerName}\n"); + quotingAssertNoUnquotedName(); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +test('restarting a service application quotes its container name', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + + RestartServiceApplication::run($this->serviceApplication); + + quotingAssertCommandRan("\n{$sudo}docker restart {$this->containerName}\n"); + quotingAssertNoUnquotedName(); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +test('stopping a service quotes every container name', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + ServiceDatabase::create(['name' => 'db', 'service_id' => $this->service->id]); + $databaseContainer = escapeshellarg('db-'.$this->service->uuid); + + StopService::run($this->service, dockerCleanup: false); + + quotingAssertCommandRan("{$sudo}docker stop --timeout=30 {$this->containerName} {$databaseContainer}\n"); + quotingAssertCommandRan("\n{$sudo}docker rm -f {$this->containerName} {$databaseContainer}\n"); + quotingAssertNoUnquotedName(); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +test('a scheduled task in a service quotes the container name', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + 'service_id' => $this->service->id, + 'container' => QUOTING_HOSTILE_NAME, + 'command' => "echo 'hello'", + ]); + + (new ScheduledTaskJob($task))->handle(); + + quotingAssertCommandRan("{$sudo}docker exec {$this->containerName} sh -c 'echo '\\''hello'\\''' 2>&1 |"); + quotingAssertNoUnquotedName(); + expect($task->executions()->sole()->status)->toBe('success'); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +/** + * `docker ps` returns one container for the application. + */ +function quotingFakeApplicationContainer(string $name): void +{ + Process::fake(function ($process) use ($name) { + if (str_contains($process->command, 'docker ps -a --filter')) { + return Process::result(output: json_encode(['Names' => $name, 'Labels' => 'coolify.applicationId=1', 'State' => 'running'])); + } + + return Process::result(output: ''); + }); +} + +function quotingApplication(): Application +{ + return Application::factory()->create([ + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ]); +} + +test('stopping an application quotes the container names from docker ps', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + $application = quotingApplication(); + quotingFakeApplicationContainer(QUOTING_HOSTILE_NAME); + $quoted = escapeshellarg(QUOTING_HOSTILE_NAME); + + StopApplication::run($application, dockerCleanup: false); + + quotingAssertCommandRan("{$sudo}docker stop --timeout="); + Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1); + quotingAssertCommandRan("\n{$sudo}docker rm -f {$quoted}\n"); + quotingAssertNoUnquotedName(); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +test('stopping an application preview quotes the container names from docker ps', function () { + $application = quotingApplication(); + $preview = ApplicationPreview::forceCreate([ + 'application_id' => $application->id, + 'pull_request_id' => 1, + 'pull_request_html_url' => 'https://example.com/pull/1', + ]); + Process::fake(function ($process) { + if (str_contains($process->command, 'docker ps -a --filter')) { + return Process::result(output: json_encode(['Names' => QUOTING_HOSTILE_NAME, 'Labels' => 'coolify.applicationId=1,coolify.pullRequestId=1'])); + } + + return Process::result(output: ''); + }); + $quoted = escapeshellarg(QUOTING_HOSTILE_NAME); + + StopApplicationPreview::run($preview); + + Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1); + quotingAssertCommandRan("\ndocker rm -f {$quoted}\n"); + quotingAssertNoUnquotedName(); +}); + +test('stopping an application on one server quotes the container names from docker ps', function () { + $application = quotingApplication(); + quotingFakeApplicationContainer(QUOTING_HOSTILE_NAME); + $quoted = escapeshellarg(QUOTING_HOSTILE_NAME); + + StopApplicationOneServer::run($application, $this->server); + + Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1); + quotingAssertNoUnquotedName(); +}); + +test('the non-root sudo parser keeps quoted container names as one argument', function () { + $server = new Server(['user' => 'ubuntu']); + $name = escapeshellarg(QUOTING_HOSTILE_NAME.'-uuid'); + + expect(parseCommandsByLineForSudo(collect([ + "docker stop {$name}", + "docker restart {$name}", + "docker rm -f {$name} 'db-uuid'", + dockerStopCommand(30, "{$name} 'db-uuid'", '28.0.0'), + ]), $server))->toBe([ + "sudo docker stop {$name}", + "sudo docker restart {$name}", + "sudo docker rm -f {$name} 'db-uuid'", + "sudo docker stop --timeout=30 {$name} 'db-uuid'", + ]); +}); diff --git a/tests/Unit/ComposeContentVolumeSourceValidationTest.php b/tests/Unit/ComposeContentVolumeSourceValidationTest.php new file mode 100644 index 0000000000..f59ebfc3b3 --- /dev/null +++ b/tests/Unit/ComposeContentVolumeSourceValidationTest.php @@ -0,0 +1,192 @@ + "key=value\n"]): string +{ + return Yaml::dump([ + 'services' => [ + 'app' => [ + 'image' => 'nginx:alpine', + 'volumes' => [ + array_merge(['type' => 'bind', 'source' => $source, 'target' => '/etc/app.conf'], $extra), + ], + ], + ], + ], 10, 2); +} + +/** + * @return array + */ +function contentVolumeSourcesOutsideTheResourceDirectory(): array +{ + return [ + 'absolute host file' => ['/root/.ssh/authorized_keys'], + 'absolute cron file' => ['/etc/cron.d/coolify'], + 'absolute path in another resource directory' => ['/data/coolify/services/other-uuid/app.conf'], + 'home directory' => ['~/app.conf'], + 'home directory of another user' => ['~root/.ssh/authorized_keys'], + 'parent directory' => ['../app.conf'], + 'traversal after ./' => ['./../../../root/.ssh/authorized_keys'], + 'traversal inside the path' => ['./config/../../outside.conf'], + 'dot segment in the path' => ['./config/../app.conf'], + 'braced variable' => ['${HOME}/.ssh/authorized_keys'], + 'variable with default' => ['${DATA:-/etc/cron.d/coolify}'], + 'plain variable' => ['$HOME/.ssh/authorized_keys'], + 'variable after ./' => ['./$HOME/app.conf'], + 'resource directory itself' => ['./'], + 'current directory' => ['.'], + 'hidden sibling' => ['.ssh/authorized_keys'], + 'bare relative path' => ['config/app.conf'], + 'backslash' => ['./config\\app.conf'], + ]; +} + +it('rejects content volumes outside the resource directory', function (string $source) { + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($source))) + ->toThrow(Exception::class, 'with content must be inside the resource directory. Use a relative path such as ./config/app.conf.'); +})->with(contentVolumeSourcesOutsideTheResourceDirectory()); + +it('rejects content volumes outside the resource directory of an existing resource', function (string $source) { + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($source), CONTENT_VOLUME_RESOURCE_DIRECTORY)) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); +})->with(contentVolumeSourcesOutsideTheResourceDirectory()); + +it('shows the source in the error message', function () { + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose('/root/.ssh/authorized_keys'))) + ->toThrow(Exception::class, 'Volume source /root/.ssh/authorized_keys with content must be inside the resource directory. Use a relative path such as ./config/app.conf.'); +}); + +it('rejects an outside content volume that is also marked as a directory', function (string $flag) { + $compose = contentVolumeCompose('/etc/cron.d', ['content' => '', $flag => true]); + + expect(fn () => validateDockerComposeForInjection($compose)) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); +})->with(['is_directory', 'isDirectory']); + +it('rejects an empty or missing content value on an outside source', function (mixed $content) { + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose('/etc/nginx', ['content' => $content]))) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); +})->with(['empty string' => '', 'null' => null]); + +it('rejects a content volume without a source', function () { + $compose = "services:\n app:\n image: nginx\n volumes:\n - type: bind\n target: /etc/app.conf\n content: x\n"; + + expect(fn () => validateDockerComposeForInjection($compose)) + ->toThrow(Exception::class, 'Volume source (empty) with content must be inside the resource directory.'); +}); + +it('accepts relative content volumes inside the resource directory', function (string $source) { + validateDockerComposeForInjection(contentVolumeCompose($source)); + validateDockerComposeForInjection(contentVolumeCompose($source), CONTENT_VOLUME_RESOURCE_DIRECTORY); + + expect(true)->toBeTrue(); +})->with(['./app.conf', './config/app.conf', './config/nested/app.conf', './config/', './.env.local']); + +it('accepts an absolute content source only inside the directory of an existing resource', function () { + $inside = CONTENT_VOLUME_RESOURCE_DIRECTORY.'/config/app.conf'; + + validateDockerComposeForInjection(contentVolumeCompose($inside), CONTENT_VOLUME_RESOURCE_DIRECTORY); + + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($inside))) + ->toThrow(Exception::class, 'with content must be inside the resource directory.') + ->and(fn () => validateDockerComposeForInjection(contentVolumeCompose(CONTENT_VOLUME_RESOURCE_DIRECTORY), CONTENT_VOLUME_RESOURCE_DIRECTORY)) + ->toThrow(Exception::class, 'with content must be inside the resource directory.') + ->and(fn () => validateDockerComposeForInjection(contentVolumeCompose(CONTENT_VOLUME_RESOURCE_DIRECTORY.'-sibling/app.conf'), CONTENT_VOLUME_RESOURCE_DIRECTORY)) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); +}); + +it('does not change bind volumes without content', function (string $compose) { + validateDockerComposeForInjection($compose); + validateDockerComposeForInjection($compose, CONTENT_VOLUME_RESOURCE_DIRECTORY); + + expect(true)->toBeTrue(); +})->with([ + 'docker socket short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n"], + 'docker socket long syntax' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /var/run/docker.sock\n target: /var/run/docker.sock\n"], + 'absolute host directory' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /srv/shared\n target: /shared\n is_directory: true\n"], + 'home directory' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: ~/booklore\n target: /bookdrop\n is_directory: true\n"], + 'variable with default' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: \${FOUNDRY_DATA:-/data/foundryvtt}\n target: /data\n is_directory: true\n"], + 'parent directory short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - ../shared:/shared\n"], +]); + +it('ignores content on named volumes because Coolify does not write it', function () { + validateDockerComposeForInjection(contentVolumeCompose('app-data', ['type' => 'volume', 'content' => 'x'])); + + expect(true)->toBeTrue(); +}); + +it('resolves the directory the parsers use for each resource', function () { + $service = new Service; + $service->uuid = 'service-uuid'; + $service->compose_parsing_version = '5'; + $legacyService = new Service; + $legacyService->uuid = 'legacy-uuid'; + $legacyService->compose_parsing_version = '3'; + $application = new Application; + $application->uuid = 'application-uuid'; + + expect(composeResourceDirectory($service))->toBe('/data/coolify/services/service-uuid') + ->and(composeResourceDirectory($legacyService))->toBe('/data/coolify/applications/legacy-uuid') + ->and(composeResourceDirectory($application))->toBe('/data/coolify/applications/application-uuid'); +}); + +/** + * @return array + */ +function composeTemplatesWithContentVolumes(): array +{ + $templates = []; + foreach (glob(dirname(__DIR__, 2).'/templates/compose/*.{yaml,yml}', GLOB_BRACE) as $file) { + if (preg_match('/^\s+content:/m', file_get_contents($file))) { + $templates[basename($file)] = [$file]; + } + } + + return $templates; +} + +it('still accepts every service template with content volumes', function (string $file) { + $compose = file_get_contents($file); + validateDockerComposeForInjection($compose); + validateDockerComposeForInjection($compose, CONTENT_VOLUME_RESOURCE_DIRECTORY); + + $contentVolumes = 0; + foreach (Yaml::parse($compose)['services'] as $service) { + foreach ($service['volumes'] ?? [] as $volume) { + if (! is_array($volume) || ! array_key_exists('content', $volume) || ($volume['type'] ?? null) !== 'bind') { + continue; + } + $contentVolumes++; + $resolved = replaceLocalSource(str($volume['source']), str(CONTENT_VOLUME_RESOURCE_DIRECTORY))->value(); + + expect(confinePathToBase(CONTENT_VOLUME_RESOURCE_DIRECTORY, $resolved))->toStartWith(CONTENT_VOLUME_RESOURCE_DIRECTORY.'/'); + } + } + + expect($contentVolumes)->toBeGreaterThan(0); +})->with(composeTemplatesWithContentVolumes()); + +it('checks the complete content volume template corpus', function () { + $contentVolumes = 0; + foreach (composeTemplatesWithContentVolumes() as [$file]) { + foreach (Yaml::parse(file_get_contents($file))['services'] as $service) { + foreach ($service['volumes'] ?? [] as $volume) { + if (is_array($volume) && array_key_exists('content', $volume)) { + $contentVolumes++; + } + } + } + } + + expect($contentVolumes)->toBeGreaterThanOrEqual(94); +}); diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index abd03c32d3..949767cf9a 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -181,7 +181,7 @@ it('quotes literal file-storage paths and safely expands persisted expressions', $literal->is_directory = true; $literal->shouldReceive('save')->once(); $file = Mockery::mock(LocalFileVolume::class)->makePartial(); - $file->fs_path = '/data/my files/settings.json'; + $file->fs_path = '/data/application/my files/settings.json'; $file->content = '{}'; $file->is_directory = false; $file->shouldReceive('save')->once(); @@ -195,17 +195,22 @@ it('quotes literal file-storage paths and safely expands persisted expressions', $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('getMorphClass')->andReturn(Application::class); - $application->shouldReceive('workdir')->once()->andReturn('/data/application'); + $application->shouldReceive('workdir')->andReturn('/data/application'); $application->shouldReceive('fileStorages')->once()->andReturn($fileStorages); $application->setRelation('destination', (object) ['server' => $server]); + $file->setRelation('resource', $application); - Process::fake(fn ($process) => Process::result(output: str_contains($process->command, 'test -') ? 'NOK' : '')); + Process::fake(fn ($process) => Process::result(output: match (true) { + str_contains($process->command, 'readlink -f') => 'OK', + str_contains($process->command, 'test -') => 'NOK', + default => '', + })); getFilesystemVolumesFromServer($application, true); Process::assertRan(fn ($process) => str_contains($process->command, "test -f '/data/my files/config.yaml'")); Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p -- '/data/my files/config.yaml'")); - Process::assertRan(fn ($process) => str_contains($process->command, "dirname -- '/data/my files/settings.json'")); - Process::assertRan(fn ($process) => str_contains($process->command, "tee -- '/data/my files/settings.json'")); + Process::assertRan(fn ($process) => str_contains($process->command, "dirname -- '/data/application/my files/settings.json'")); + Process::assertRan(fn ($process) => str_contains($process->command, "tee -- '/data/application/my files/settings.json'")); Process::assertRan(fn ($process) => str_contains($process->command, '${DATA_PATH:-/srv/app/config.yaml}')); });