From 51641c05b292ca6499a12f221e430c37c43b4b48 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 8 Oct 2026 18:30:44 +0530 Subject: [PATCH] feat: smart scan for git project creation (#8502) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> --- app/Data/RepositoryDetectionResult.php | 69 +++++ .../Project/New/GithubPrivateRepository.php | 37 ++- .../New/GithubPrivateRepositoryDeployKey.php | 19 ++ .../Project/New/PublicGitRepository.php | 26 +- app/Models/Application.php | 14 +- app/Services/RepositoryDetector.php | 129 +++++++++ app/Traits/HasRepositoryDetection.php | 273 ++++++++++++++++++ .../views/components/forms/listbox.blade.php | 30 +- resources/views/components/reicon.blade.php | 1 + ...ub-private-repository-deploy-key.blade.php | 19 ++ .../new/github-private-repository.blade.php | 19 ++ .../new/partials/env-import-modal.blade.php | 31 ++ .../new/partials/smart-scan-summary.blade.php | 73 +++++ .../new/public-git-repository.blade.php | 19 ++ tests/Feature/ListboxComponentTest.php | 18 ++ tests/Feature/RepositoryScanSecurityTest.php | 98 +++++++ tests/Unit/HasRepositoryDetectionTest.php | 152 ++++++++++ tests/Unit/RepositoryDetectionResultTest.php | 120 ++++++++ tests/Unit/RepositoryDetectorTest.php | 231 +++++++++++++++ 19 files changed, 1362 insertions(+), 16 deletions(-) create mode 100644 app/Data/RepositoryDetectionResult.php create mode 100644 app/Services/RepositoryDetector.php create mode 100644 app/Traits/HasRepositoryDetection.php create mode 100644 resources/views/livewire/project/new/partials/env-import-modal.blade.php create mode 100644 resources/views/livewire/project/new/partials/smart-scan-summary.blade.php create mode 100644 tests/Feature/RepositoryScanSecurityTest.php create mode 100644 tests/Unit/HasRepositoryDetectionTest.php create mode 100644 tests/Unit/RepositoryDetectionResultTest.php create mode 100644 tests/Unit/RepositoryDetectorTest.php diff --git a/app/Data/RepositoryDetectionResult.php b/app/Data/RepositoryDetectionResult.php new file mode 100644 index 0000000000..b0f47a0f5c --- /dev/null +++ b/app/Data/RepositoryDetectionResult.php @@ -0,0 +1,69 @@ + $dockerfiles e.g. ['Dockerfile', 'apps/api/Dockerfile'] + * @param array $dockerComposeFiles e.g. ['docker-compose.yml'] + * @param array $envFiles e.g. ['.env.example' => 'KEY=val...', '.env.dist' => null] + * @param array $dockerfilePorts e.g. ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080] + */ + public function __construct( + public array $dockerfiles = [], + public array $dockerComposeFiles = [], + public array $envFiles = [], + public array $dockerfilePorts = [], + ) {} + + public static function none(): self + { + return new self; + } + + /** + * Build pack for the files in the base directory. Files in subdirectories are often examples, + * tests, or other services, so they do not select a build pack. + */ + public function getSuggestedBuildPack(): ?BuildPackTypes + { + if ($this->baseDirectoryFile($this->dockerComposeFiles)) { + return BuildPackTypes::DOCKERCOMPOSE; + } + + if ($this->baseDirectoryFile($this->dockerfiles)) { + return BuildPackTypes::DOCKERFILE; + } + + return null; + } + + /** + * The first file that is directly in the base directory, not in a subdirectory. + * + * @param array $files + */ + public function baseDirectoryFile(array $files): ?string + { + return collect($files)->first(fn (string $file): bool => ! str_contains($file, '/')); + } + + public function hasDockerfile(): bool + { + return count($this->dockerfiles) > 0; + } + + public function hasDockerCompose(): bool + { + return count($this->dockerComposeFiles) > 0; + } + + public function hasEnvFiles(): bool + { + return count($this->envFiles) > 0; + } +} diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index cef0cf6ac7..59dce3cf59 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -7,6 +7,7 @@ use App\Models\GithubApp; use App\Models\Project; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; +use App\Traits\HasRepositoryDetection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; @@ -16,6 +17,7 @@ use Livewire\Component; class GithubPrivateRepository extends Component { use AuthorizesRequests; + use HasRepositoryDetection; public $current_step = 'github_apps'; @@ -151,6 +153,8 @@ class GithubPrivateRepository extends Component $this->selected_branch_name = $this->branches->contains('name', $defaultBranch) ? $defaultBranch : data_get($this->branches, '0.name', 'main'); + + $this->detectRepository(); } protected function loadBranchByPage() @@ -173,6 +177,15 @@ class GithubPrivateRepository extends Component $this->branches = $this->branches->concat(collect($json)); } + protected function applicationForDetection(): Application + { + return $this->unsavedApplicationForDetection( + str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), + $this->selected_branch_name, + $this->github_app, + ); + } + public function submit() { try { @@ -205,7 +218,7 @@ class GithubPrivateRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); - $application = new Application([ + $application_init = [ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), 'repository_project_id' => $this->selected_repository_id, 'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), @@ -219,23 +232,31 @@ class GithubPrivateRepository extends Component 'destination_type' => $destination_class, 'source_id' => $this->github_app->id, 'source_type' => $this->github_app->getMorphClass(), - ]); + ]; + + if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { + $application_init['health_check_enabled'] = false; + } + if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) { + $application_init['dockerfile_location'] = $dockerfileLocation; + } + if ($this->build_pack === 'dockercompose') { + $application_init['docker_compose_location'] = $this->docker_compose_location; + } + + $application = new Application($application_init); $application->save(); $application->settings->is_static = $this->is_static; $application->settings->save(); - if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { - $application->health_check_enabled = false; - } - if ($this->build_pack === 'dockercompose') { - $application['docker_compose_location'] = $this->docker_compose_location; - } $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid); $application->save(); + $this->importDetectedEnvironmentVariables($application); + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 6af782d1cc..d55972e97d 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -10,6 +10,7 @@ use App\Models\Project; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; +use App\Traits\HasRepositoryDetection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Str; use Livewire\Component; @@ -18,6 +19,7 @@ use Spatie\Url\Url; class GithubPrivateRepositoryDeployKey extends Component { use AuthorizesRequests; + use HasRepositoryDetection; public $current_step = 'private_keys'; @@ -132,6 +134,18 @@ class GithubPrivateRepositoryDeployKey extends Component $this->current_step = 'repository'; } + protected function applicationForDetection(): Application + { + $this->get_git_source(); + + return $this->unsavedApplicationForDetection( + $this->git_repository, + $this->branch ?? 'main', + $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null, + PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id), + ); + } + public function submit() { $this->authorize('create', Application::class); @@ -186,6 +200,9 @@ class GithubPrivateRepositoryDeployKey extends Component if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { $application_init['health_check_enabled'] = false; } + if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) { + $application_init['dockerfile_location'] = $dockerfileLocation; + } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; @@ -200,6 +217,8 @@ class GithubPrivateRepositoryDeployKey extends Component $application->name = generate_random_name($application->uuid); $application->save(); + $this->importDetectedEnvironmentVariables($application); + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a44aa050d9..c14c9dcd37 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -9,6 +9,7 @@ use App\Models\Project; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; +use App\Traits\HasRepositoryDetection; use Carbon\Carbon; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -17,6 +18,7 @@ use Spatie\Url\Url; class PublicGitRepository extends Component { use AuthorizesRequests; + use HasRepositoryDetection; public string $repository_url; @@ -167,17 +169,17 @@ class PublicGitRepository extends Component $this->git_branch = 'master'; } $this->selectedBranch = $this->git_branch; + } catch (\Throwable $e) { if ($this->rate_limit_remaining == 0) { $this->selectedBranch = $this->git_branch; $this->branchFound = true; - return; - } - if (! $this->branchFound && $this->git_branch === 'main') { + } elseif (! $this->branchFound && $this->git_branch === 'main') { try { $this->git_branch = 'master'; $this->getBranch(); + } catch (\Throwable $e) { return handleError($e, $this); } @@ -185,6 +187,19 @@ class PublicGitRepository extends Component return handleError($e, $this); } } + if ($this->branchFound) { + $this->selectedBranch = $this->git_branch; + $this->detectRepository(); + } + } + + protected function applicationForDetection(): Application + { + return $this->unsavedApplicationForDetection( + $this->git_repository, + $this->git_branch, + $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null, + ); } private function getGitSource() @@ -331,6 +346,9 @@ class PublicGitRepository extends Component if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { $application_init['health_check_enabled'] = false; } + if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) { + $application_init['dockerfile_location'] = $dockerfileLocation; + } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; @@ -344,6 +362,8 @@ class PublicGitRepository extends Component $application->fqdn = $fqdn; $application->save(); + $this->importDetectedEnvironmentVariables($application); + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Models/Application.php b/app/Models/Application.php index 3b0ac88ce5..e09f96a266 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2280,6 +2280,17 @@ class Application extends BaseModel return $reason ? '

Reason: '.e(str($reason)->limit(300)->value()) : ''; } + /** + * One shell command that clones the repository without a checkout into $checkoutDir on the server, + * with the same credentials (GitHub App token, GitLab token, or deploy key) as a deployment. + */ + public function serverCheckoutCommand(string $uuid, string $checkoutDir): string + { + ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir); + + return str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)); + } + /** * Commands that check out only the Compose file on the server and print it. They run on the * server itself, not in a helper container, so the checkout uses an absolute folder in /tmp. @@ -2289,8 +2300,7 @@ class Application extends BaseModel private function composeFileReadCommands(string $uuid, string $gitVersion): Collection { $checkoutDir = "/tmp/{$uuid}/checkout"; - ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir); - $cloneCommand = str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)); + $cloneCommand = $this->serverCheckoutCommand($uuid, $checkoutDir); $workdir = rtrim($this->base_directory, '/'); $fileList = collect([".{$workdir}{$this->docker_compose_location}"]); $composeFilePath = escapeshellarg(".{$workdir}{$this->docker_compose_location}"); diff --git a/app/Services/RepositoryDetector.php b/app/Services/RepositoryDetector.php new file mode 100644 index 0000000000..371f15908e --- /dev/null +++ b/app/Services/RepositoryDetector.php @@ -0,0 +1,129 @@ +scanCommands($uuid), $this->server, timeout: 60); + } finally { + instant_remote_process(["rm -rf /tmp/{$uuid}"], $this->server, false); + } + + return $this->parseOutput((string) str($output)->trim()->afterLast("\n")); + } + + /** + * Clones the repository without a checkout and lists the files with `git ls-tree`, so no file is + * written to the disk. Each command is one line because non-root servers send every line through + * parseCommandsByLineForSudo(). + * + * @return Collection + */ + protected function scanCommands(string $uuid): Collection + { + $checkoutDir = "/tmp/{$uuid}/checkout"; + $baseDir = trim($this->baseDirectory, '/'); + if (preg_match('~(^|/)\.\.?(/|$)~', $baseDir) || ! preg_match('~^[a-zA-Z0-9_./-]*$~', $baseDir)) { + throw new RuntimeException('Invalid repository base directory.'); + } + $tree = escapeshellarg($baseDir === '' ? 'HEAD' : "HEAD:{$baseDir}"); + $maxEnvBytes = self::MAX_ENV_FILE_BYTES; + + $classifyFiles = <<<'AWK' +{ split($1, meta, " "); if (meta[1] != "100644" && meta[1] != "100755") next; path = $2; lower = tolower(path); name = lower; sub(/.*\//, "", name); if (name ~ /^dockerfile(\.[a-z0-9_-]+)?$/) kind = "dockerfile"; else if (name ~ /^(docker-)?compose\.ya?ml$/) kind = "compose"; else if (lower ~ /^\.env\.(example|sample|template|dist|local\.example)$/) kind = "env"; else next; print kind "\t" meta[3] "\t" path } +AWK; + $exposedPort = <<<'AWK' +{ sub(/\r$/, "") } toupper($1) == "EXPOSE" { split($2, p, "/"); if (p[1] ~ /^[0-9]+$/ && p[1] + 0 > 0 && p[1] + 0 <= 65535) print p[1] + 0; exit } +AWK; + $summary = '{dockerfiles: map(select(.kind == "dockerfile") | .file), dockerComposeFiles: map(select(.kind == "compose") | .file), envFiles: (map(select(.kind == "env") | {(.file): .content}) | add // {}), dockerfilePorts: (map(select(.kind == "dockerfile") | {(.file): .port}) | add // {})}'; + + $scan = 'tab=$(printf "\t"); ' + ."git -c core.quotePath=false ls-tree -r {$tree} | awk -F '\\t' ".escapeshellarg($classifyFiles) + .' | while IFS="$tab" read -r kind object file; do case "$kind" in ' + .'dockerfile) port=$(git cat-file blob "$object" | awk '.escapeshellarg($exposedPort).'); ' + .'jq -nc --arg file "$file" --argjson port "${port:-null}" \'{kind: "dockerfile", file: $file, port: $port}\' ;; ' + .'compose) jq -nc --arg file "$file" \'{kind: "compose", file: $file}\' ;; ' + ."env) git cat-file blob \"\$object\" | head -c {$maxEnvBytes} | jq -Rsc --arg file \"\$file\" '{kind: \"env\", file: \$file, content: .}' ;; " + .'esac; done | jq -sc '.escapeshellarg($summary); + + return collect([ + "rm -rf /tmp/{$uuid}", + "mkdir -p /tmp/{$uuid}", + "cd /tmp/{$uuid}", + 'sh -c '.escapeshellarg($this->application->serverCheckoutCommand($uuid, $checkoutDir)), + "cd {$checkoutDir}", + 'sh -c '.escapeshellarg($scan), + ]); + } + + protected function parseOutput(string $output): RepositoryDetectionResult + { + $data = json_decode(trim($output), true); + + if (! is_array($data)) { + return RepositoryDetectionResult::none(); + } + + foreach (['dockerfiles', 'dockerComposeFiles', 'envFiles', 'dockerfilePorts'] as $key) { + if (isset($data[$key]) && ! is_array($data[$key])) { + return RepositoryDetectionResult::none(); + } + } + + $dockerfilePorts = []; + foreach ($data['dockerfilePorts'] ?? [] as $file => $port) { + $dockerfilePorts[$file] = (is_int($port) || (is_string($port) && ctype_digit($port))) && (int) $port > 0 && (int) $port <= 65535 ? (int) $port : null; + } + + return new RepositoryDetectionResult( + dockerfiles: $this->sortByPreference(array_filter($data['dockerfiles'] ?? [], 'is_string'), '/^dockerfile$/i'), + dockerComposeFiles: $this->sortByPreference(array_filter($data['dockerComposeFiles'] ?? [], 'is_string'), '/^docker-compose\.ya?ml$/i'), + envFiles: array_filter($data['envFiles'] ?? [], fn ($content) => is_string($content) || $content === null), + dockerfilePorts: $dockerfilePorts, + ); + } + + /** + * Puts the file nearest to the base directory first. At the same depth, a file with the default + * name (for example `Dockerfile`) comes before a variant. Other files keep the scan order. + * + * @param array $files + * @return array + */ + private function sortByPreference(array $files, string $defaultNamePattern): array + { + $files = array_values($files); + usort($files, fn (string $a, string $b): int => [substr_count($a, '/'), ! preg_match($defaultNamePattern, basename($a))] + <=> [substr_count($b, '/'), ! preg_match($defaultNamePattern, basename($b))]); + + return $files; + } +} diff --git a/app/Traits/HasRepositoryDetection.php b/app/Traits/HasRepositoryDetection.php new file mode 100644 index 0000000000..7a4fced3e4 --- /dev/null +++ b/app/Traits/HasRepositoryDetection.php @@ -0,0 +1,273 @@ +authorize('create', Application::class); + try { + $application = $this->applicationForDetection(); + } catch (\Throwable $e) { + handleError($e, $this); + + return; + } + Validator::make([ + 'branch' => $application->git_branch, + 'base_directory' => $this->base_directory ?? '/', + ], [ + 'branch' => ['required', new ValidGitBranch], + 'base_directory' => ['required', 'string', 'regex:~^/?(?:[a-zA-Z0-9_.-]+/)*[a-zA-Z0-9_.-]*$~', 'not_regex:~(^|/)\.\.?(/|$)~'], + ])->validate(); + + $destination = find_resource_destination_for_current_team(data_get($this->query, 'destination')); + if (! $destination) { + $this->dispatch('error', 'Destination not found.'); + + return; + } + $this->authorize('update', $destination->server); + + $this->detectionRan = false; + $this->detectionFailed = false; + $this->applyDetectionResult(RepositoryDetectionResult::none()); + try { + $detector = new RepositoryDetector($application, $this->base_directory ?? '/', $destination->server); + $this->applyDetectionResult($detector->detect()); + } catch (\Throwable $e) { + // The exception can contain the clone command with a Git token, so do not log the message. + Log::debug('Repository detection failed', ['exception' => $e::class]); + $this->detectionFailed = true; + $this->dispatch('error', 'Smart Scan could not read the repository. Check the repository access and the branch.'); + } + $this->detectionRan = true; + } + + /** + * Builds an application that is not saved, only to generate the clone command for the scan. + */ + protected function unsavedApplicationForDetection(string $repository, string $branch, GithubApp|GitlabApp|null $source = null, ?PrivateKey $privateKey = null): Application + { + $application = (new Application)->forceFill([ + 'git_repository' => $repository, + 'git_branch' => $branch, + 'git_commit_sha' => 'HEAD', + 'private_key_id' => $privateKey?->id, + ]); + $application->setRelation('settings', (new ApplicationSetting)->forceFill([ + 'is_git_shallow_clone_enabled' => true, + 'is_git_submodules_enabled' => false, + ])); + $application->setRelation('source', $source); + $application->setRelation('private_key', $privateKey); + + return $application; + } + + protected function importDetectedEnvironmentVariables(Application $application): void + { + if (! $this->envImported || empty($this->envExampleVars)) { + return; + } + Validator::make(['variables' => $this->envExampleVars], [ + 'variables' => ['array'], + 'variables.*' => ['nullable', 'string'], + ])->validate(); + $application->getConnection()->transaction(function () use ($application): void { + foreach ($this->envExampleVars as $key => $value) { + if (! preg_match('/^[A-Z_][A-Z0-9_]*$/i', (string) $key)) { + continue; + } + EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => $application->getMorphClass(), + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + } + }); + } + + protected function applyDetectionResult(RepositoryDetectionResult $result): void + { + $this->detectedDockerfiles = $result->dockerfiles; + $this->detectedDockerComposeFiles = $result->dockerComposeFiles; + $this->dockerfilePorts = $result->dockerfilePorts; + + $this->selectedDockerfile = null; + $this->selectedDockerComposeFile = null; + $this->detectedPort = null; + $this->detectedEnvFiles = []; + $this->parsedEnvFiles = []; + $this->selectedEnvFile = null; + $this->envExampleVars = []; + $this->envImported = false; + + $this->suggestedBuildPack = $result->getSuggestedBuildPack()?->value; + if ($this->suggestedBuildPack && $this->suggestedBuildPack !== $this->build_pack) { + $this->build_pack = $this->suggestedBuildPack; + $this->updatedBuildPack(); + } + + if ($result->hasDockerfile()) { + $this->selectedDockerfile = $result->dockerfiles[0]; + $port = $result->dockerfilePorts[$this->selectedDockerfile] ?? null; + $this->port = $port ?? 3000; + $this->detectedPort = $port; + } + + if ($result->hasDockerCompose()) { + $this->selectedDockerComposeFile = $result->dockerComposeFiles[0]; + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; + } + + if ($result->hasEnvFiles()) { + $this->detectedEnvFiles = array_keys($result->envFiles); + $this->parsedEnvFiles = []; + foreach ($result->envFiles as $filename => $content) { + $parsed = $content !== null ? parseEnvFormatToArray($content) : []; + // parseEnvFormatToArray returns ['KEY' => ['value' => ..., 'comment' => ...]]. + // Flatten to a plain ['KEY' => 'value'] map for the import form. + $this->parsedEnvFiles[$filename] = collect($parsed) + ->map(fn ($item) => is_array($item) ? (string) ($item['value'] ?? '') : (string) $item) + ->all(); + } + $this->selectedEnvFile = $this->detectedEnvFiles[0]; + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? []; + } + } + + /** + * Repository path without a leading slash, so a typed path matches the detected paths. + */ + protected function normalizeRepositoryFilePath(?string $path): ?string + { + $path = ltrim(trim((string) $path), '/'); + + return $path === '' ? null : $path; + } + + /** + * Dockerfile location for the new application: a detected file or a path the user typed. + */ + protected function selectedDockerfileLocation(): ?string + { + $path = $this->normalizeRepositoryFilePath($this->selectedDockerfile); + if ($path === null) { + return null; + } + $location = '/'.$path; + Validator::make( + ['selectedDockerfile' => $location], + ['selectedDockerfile' => ValidationPatterns::filePathRules()], + ValidationPatterns::filePathMessages('selectedDockerfile', 'Dockerfile'), + )->validate(); + + return $location; + } + + public function updatedSelectedDockerfile(): void + { + $this->selectedDockerfile = $this->normalizeRepositoryFilePath($this->selectedDockerfile); + $this->resetErrorBag('selectedDockerfile'); + $this->selectedDockerfileLocation(); + + $port = $this->dockerfilePorts[$this->selectedDockerfile ?? ''] ?? null; + $this->port = $port ?? 3000; + $this->detectedPort = $port; + } + + public function updatedSelectedDockerComposeFile(): void + { + $this->selectedDockerComposeFile = $this->normalizeRepositoryFilePath($this->selectedDockerComposeFile); + + if ($this->selectedDockerComposeFile) { + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; + } + } + + public function updatedSelectedEnvFile(): void + { + if ($this->selectedEnvFile && isset($this->parsedEnvFiles[$this->selectedEnvFile])) { + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile]; + $this->envImported = false; + } + } + + public function confirmEnvImport(): void + { + $this->envImported = true; + } + + public function clearEnvVars(): void + { + $this->envImported = false; + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? []; + } +} diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index 2f382fd1c2..58cb9b406b 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -23,6 +23,7 @@ 'searchable' => false, // true = a search field filters the options 'searchPlaceholder' => 'Search…', 'searchEmptyText' => 'No matching options', + 'allowCustom' => false, // true (with searchable) = the search text can be chosen as a custom value ]) @php @@ -92,9 +93,15 @@ return this.options.filter((option) => !option.header && [option.label, option.value, option.description] .some((text) => String(text ?? '').toLowerCase().includes(query))); }, + get customOption() { + const query = this.query.trim(); + if (!@js($allowCustom) || query === '' || this.options.some((option) => !option.header && String(option.value) === query)) return null; + return { value: query, label: query }; + }, chooseOnlyMatch() { const matches = this.visibleOptions.filter((option) => !option.disabled); if (matches.length === 1) this.choose(matches[0]); + else if (this.customOption) this.choose(this.customOption); }, @else get visibleOptions() { @@ -126,7 +133,8 @@ }, get current() { const found = this.options.find((option) => String(option.value) === String(this.value)); - return found ? found.label : @js($placeholder); + if (found) return found.label; + return @js($allowCustom) && this.value ? String(this.value) : @js($placeholder); }, get title() { return this.current; @@ -237,7 +245,7 @@
@endif -
@if ($searchable) @@ -272,6 +280,14 @@
+ @if ($searchable && $allowCustom) + + @endif @if ($searchable)
@endif @@ -296,7 +312,7 @@
@endif -
@if ($searchable) @@ -329,6 +345,14 @@
+ @if ($searchable && $allowCustom) + + @endif @if ($searchable)
@endif diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index a611869feb..71d89016c8 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -8,6 +8,7 @@ $icons = [ 'fire' => '', 'cloud' => '', + 'brain' => '', 'code' => '', 'analytics' => '', 'mail' => '', diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index b0dc41bb4d..eea738ef61 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -103,6 +103,25 @@ @endif + + @include('livewire.project.new.partials.smart-scan-summary') + + {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index 9ca9c2fec3..38bd93ded9 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -104,6 +104,8 @@ Continue
+ @include('livewire.project.new.partials.smart-scan-summary') +
+ {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif + @if ($build_pack === 'dockercompose')
+ + {{ $envImported ? 'Edit' : 'Review' }} + + +
+ @if (count($detectedEnvFiles) > 1) + + @endif +
+ @foreach ($envExampleVars as $key => $value) +
+ {{ $key }} + +
+ @endforeach +
+
+ + + @if ($envImported) + Remove import + @endif + + {{ $envImported ? 'Update' : 'Import' }} {{ count($envExampleVars) }} {{ Str::plural('variable', count($envExampleVars)) }} + + + diff --git a/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php b/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php new file mode 100644 index 0000000000..690bba4303 --- /dev/null +++ b/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php @@ -0,0 +1,73 @@ +{{-- + Smart Scan result inside the build configuration, as a compact divided list: the build pack, + the Dockerfile port, and the env file import. The scan button sends the current base directory, + so the user can scan a subdirectory after changing it. +--}} +@php + $buildPackLabels = ['dockercompose' => 'Docker Compose', 'dockerfile' => 'Dockerfile']; + $suggestedFile = match ($suggestedBuildPack) { + 'dockercompose' => $detectedDockerComposeFiles[0] ?? null, + 'dockerfile' => $detectedDockerfiles[0] ?? null, + default => null, + }; + $nestedFiles = collect([ + count($detectedDockerfiles) ? count($detectedDockerfiles).' '.Str::plural('Dockerfile', count($detectedDockerfiles)) : null, + count($detectedDockerComposeFiles) ? count($detectedDockerComposeFiles).' '.Str::plural('Compose file', count($detectedDockerComposeFiles)) : null, + ])->filter()->join(' and '); + $showPort = $detectionRan && $build_pack === 'dockerfile' && in_array($selectedDockerfile, $detectedDockerfiles, true); +@endphp + +
+
+

+ Smart Scan +

+ + + {{ $detectionRan ? 'Scan again' : 'Scan repository' }} + +
+ +
+ +
+ +
+ @if (! $detectionRan) + + @elseif ($detectionFailed) + + @elseif ($suggestedBuildPack) + + @elseif ($nestedFiles !== '') + + @else + + @endif + + @if ($showPort) + @if ($detectedPort) + + @else + + @endif + @endif + + @if ($detectionRan && count($envExampleVars) > 0) +
+ + @include('livewire.project.new.partials.env-import-modal') +
+ @endif +
+
diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 137d880eed..2d3d3ba596 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -56,6 +56,8 @@ @endif + @include('livewire.project.new.partials.smart-scan-summary') +
+ {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif + @if ($build_pack === 'dockercompose')
+ BLADE); + + expect($html) + ->toContain('x-if="customOption"') + ->toContain('!customOption'); +}); + +test('listbox without allowCustom offers no custom option', function () { + $html = Blade::render(<<<'BLADE' + + BLADE); + + expect($html)->not->toContain('x-if="customOption"'); +}); + test('listbox without searchable renders no search field', function () { $html = Blade::render(<<<'BLADE' diff --git a/tests/Feature/RepositoryScanSecurityTest.php b/tests/Feature/RepositoryScanSecurityTest.php new file mode 100644 index 0000000000..4d1d441736 --- /dev/null +++ b/tests/Feature/RepositoryScanSecurityTest.php @@ -0,0 +1,98 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); +}); + +test('members cannot start scans in any creation flow', function (string $componentClass) { + $this->team->members()->attach($this->user->id, ['role' => 'member']); + $component = new $componentClass; + expect(fn () => $component->detectRepository())->toThrow(AuthorizationException::class); +})->with([PublicGitRepository::class, GithubPrivateRepository::class, GithubPrivateRepositoryDeployKey::class]); + +test('owners cannot use scan base directory traversal', function () { + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $component = new PublicGitRepository; + $component->git_repository = 'https://github.com/test/repo'; + $component->base_directory = '/../outside'; + expect(fn () => $component->detectRepository())->toThrow(ValidationException::class); +}); + +test('scan does not run on a destination from another team', function () { + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); + $otherDestination = $otherServer->standaloneDockers()->firstOrFail(); + + Livewire\Livewire::test(RepositoryScanStateTestComponent::class) + ->set('git_repository', 'https://github.com/test/repo') + ->set('query', ['destination' => $otherDestination->uuid]) + ->call('detectRepository') + ->assertDispatched('error', 'Destination not found.') + ->assertSet('detectionRan', false); +}); + +test('environment import stores valid keys and skips malformed keys', function () { + $application = Application::factory()->create(['build_pack' => 'dockerfile']); + $component = new class extends PublicGitRepository + { + public function importInto(Application $application): void + { + $this->importDetectedEnvironmentVariables($application); + } + }; + $component->envImported = true; + $component->envExampleVars = ['VALID_KEY' => 'value', 'invalid.key' => 'ignored', '1BAD' => 'ignored']; + $component->importInto($application); + expect($application->environment_variables()->pluck('value', 'key')->all())->toBe(['VALID_KEY' => 'value']); +}); + +class RepositoryScanStateTestComponent extends PublicGitRepository +{ + public function mount() {} + + public function render() + { + return '
'; + } +} + +test('clients cannot change the detected Dockerfile allowlist', function () { + expect(fn () => Livewire\Livewire::test(RepositoryScanStateTestComponent::class) + ->set('detectedDockerfiles', ['../../outside/Dockerfile'])) + ->toThrow(CannotUpdateLockedPropertyException::class); +}); + +test('deploy key scans clone with the key, like a deployment', function () { + $application = (new Application)->forceFill(['git_repository' => 'git@github.com:test/private.git', 'git_branch' => 'main', 'git_commit_sha' => 'HEAD', 'private_key_id' => 5]); + $application->setRelation('settings', (new ApplicationSetting)->forceFill(['is_git_shallow_clone_enabled' => true, 'is_git_submodules_enabled' => false])); + $application->setRelation('source', null); + $application->setRelation('private_key', (new PrivateKey)->forceFill(['private_key' => 'test-key'])); + $detector = new RepositoryDetector($application, '/', new Server); + + $clone = (new ReflectionMethod($detector, 'scanCommands'))->invoke($detector, 'scan-test')->get(3); + + expect($clone)->toStartWith('sh -c ') + ->toContain(base64_encode('test-key')) + ->toContain('-i /root/.ssh/id_rsa_coolify_scan-test') + ->toContain('git@github.com:test/private.git'); +}); diff --git a/tests/Unit/HasRepositoryDetectionTest.php b/tests/Unit/HasRepositoryDetectionTest.php new file mode 100644 index 0000000000..a06c778c97 --- /dev/null +++ b/tests/Unit/HasRepositoryDetectionTest.php @@ -0,0 +1,152 @@ +unsavedApplicationForDetection('test/repo', 'main'); + } + + public function updatedBuildPack(): void {} + + public function resetErrorBag($field = null): void {} + + public function dockerfileLocation(): ?string + { + return $this->selectedDockerfileLocation(); + } + + public function apply(RepositoryDetectionResult $result): void + { + $this->applyDetectionResult($result); + } +} + +test('env vars are flattened to plain key value strings for the import form', function () { + $host = new RepositoryDetectionHost; + + $host->apply(new RepositoryDetectionResult( + envFiles: [ + '.env.example' => "APP_NAME=MyApp\nAPP_DEBUG=true\nAPP_URL=\"https://example.com\"", + ], + )); + + expect($host->envExampleVars)->toBe([ + 'APP_NAME' => 'MyApp', + 'APP_DEBUG' => 'true', + 'APP_URL' => 'https://example.com', + ]); + + // No value should be an array (which would render as "[object Object]" in the UI). + foreach ($host->envExampleVars as $value) { + expect($value)->toBeString(); + } +}); + +test('switching the selected env file keeps values flat', function () { + $host = new RepositoryDetectionHost; + + $host->apply(new RepositoryDetectionResult( + envFiles: [ + '.env.example' => 'FOO=bar', + '.env.sample' => 'BAZ=qux', + ], + )); + + $host->selectedEnvFile = '.env.sample'; + $host->updatedSelectedEnvFile(); + + expect($host->envExampleVars)->toBe(['BAZ' => 'qux']); +}); + +test('empty scan preserves the chosen build pack and clears previous results', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult(envFiles: ['.env.example' => 'FOO=bar'])); + $host->envImported = true; + $host->apply(RepositoryDetectionResult::none()); + + expect($host->build_pack)->toBe('railpack') + ->and($host->envExampleVars)->toBe([]) + ->and($host->detectedEnvFiles)->toBe([]) + ->and($host->selectedEnvFile)->toBeNull() + ->and($host->envImported)->toBeFalse(); +}); + +test('switching to a Dockerfile without EXPOSE resets the detected port', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult( + dockerfiles: ['Dockerfile', 'Dockerfile.worker'], + dockerfilePorts: ['Dockerfile' => 8080, 'Dockerfile.worker' => null], + )); + $host->selectedDockerfile = 'Dockerfile.worker'; + $host->updatedSelectedDockerfile(); + + expect($host->port)->toBe(3000)->and($host->detectedPort)->toBeNull(); +}); + +test('a typed Dockerfile path that was not detected is kept', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult(dockerfiles: ['Dockerfile'], dockerfilePorts: ['Dockerfile' => 8080])); + $host->selectedDockerfile = ' /docker/prod.Dockerfile '; + $host->updatedSelectedDockerfile(); + + expect($host->selectedDockerfile)->toBe('docker/prod.Dockerfile') + ->and($host->dockerfileLocation())->toBe('/docker/prod.Dockerfile') + ->and($host->port)->toBe(3000); +}); + +test('a typed Dockerfile path with unsafe characters is rejected', function () { + $host = new RepositoryDetectionHost; + $host->selectedDockerfile = 'Dockerfile; rm -rf /'; + + expect(fn () => $host->updatedSelectedDockerfile())->toThrow(ValidationException::class); +}); + +test('an empty Dockerfile selection keeps the default location', function () { + $host = new RepositoryDetectionHost; + $host->selectedDockerfile = ' '; + $host->updatedSelectedDockerfile(); + + expect($host->selectedDockerfile)->toBeNull()->and($host->dockerfileLocation())->toBeNull(); +}); + +test('a typed Docker Compose path sets the compose location', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult(dockerComposeFiles: ['docker-compose.yml'])); + $host->selectedDockerComposeFile = '/deploy/compose.prod.yaml'; + $host->updatedSelectedDockerComposeFile(); + + expect($host->selectedDockerComposeFile)->toBe('deploy/compose.prod.yaml') + ->and($host->docker_compose_location)->toBe('/deploy/compose.prod.yaml'); +}); + +test('nested build files do not change the build pack', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult( + dockerfiles: ['apps/api/Dockerfile'], + dockerComposeFiles: ['examples/demo/docker-compose.yml'], + )); + + expect($host->build_pack)->toBe('railpack') + ->and($host->suggestedBuildPack)->toBeNull() + ->and($host->detectedDockerComposeFiles)->toBe(['examples/demo/docker-compose.yml']); +}); diff --git a/tests/Unit/RepositoryDetectionResultTest.php b/tests/Unit/RepositoryDetectionResultTest.php new file mode 100644 index 0000000000..34bc2d189d --- /dev/null +++ b/tests/Unit/RepositoryDetectionResultTest.php @@ -0,0 +1,120 @@ +dockerfiles)->toBe([]) + ->and($result->dockerComposeFiles)->toBe([]) + ->and($result->envFiles)->toBe([]) + ->and($result->dockerfilePorts)->toBe([]); +}); + +test('getSuggestedBuildPack returns dockercompose when compose files found', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile'], + dockerComposeFiles: ['docker-compose.yml'], + ); + + expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE); +}); + +test('getSuggestedBuildPack returns dockerfile when only dockerfiles found', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile'], + ); + + expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE); +}); + +test('getSuggestedBuildPack returns null when nothing found', function () { + $result = RepositoryDetectionResult::none(); + + expect($result->getSuggestedBuildPack())->toBeNull(); +}); + +test('getSuggestedBuildPack ignores files in subdirectories', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'], + dockerComposeFiles: ['examples/demo/docker-compose.yml'], + ); + + expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE); + + $nestedOnly = new RepositoryDetectionResult( + dockerfiles: ['apps/api/Dockerfile'], + dockerComposeFiles: ['examples/demo/docker-compose.yml'], + ); + + expect($nestedOnly->getSuggestedBuildPack())->toBeNull(); +}); + +test('hasDockerfile returns true when dockerfiles present', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'], + ); + + expect($result->hasDockerfile())->toBeTrue(); +}); + +test('hasDockerfile returns false when no dockerfiles', function () { + $result = RepositoryDetectionResult::none(); + + expect($result->hasDockerfile())->toBeFalse(); +}); + +test('hasDockerCompose returns true when compose files present', function () { + $result = new RepositoryDetectionResult( + dockerComposeFiles: ['docker-compose.yml'], + ); + + expect($result->hasDockerCompose())->toBeTrue(); +}); + +test('hasDockerCompose returns false when no compose files', function () { + $result = RepositoryDetectionResult::none(); + + expect($result->hasDockerCompose())->toBeFalse(); +}); + +test('dockerfilePorts stores port mapping correctly', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'], + dockerfilePorts: ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080], + ); + + expect($result->dockerfilePorts)->toBe(['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080]) + ->and($result->dockerfilePorts['Dockerfile'])->toBe(3000) + ->and($result->dockerfilePorts['apps/api/Dockerfile'])->toBe(8080); +}); + +test('hasEnvFiles returns true when env files present', function () { + $result = new RepositoryDetectionResult( + envFiles: ['.env.example' => 'APP_KEY=secret'], + ); + + expect($result->hasEnvFiles())->toBeTrue(); +}); + +test('hasEnvFiles returns false when no env files', function () { + $result = RepositoryDetectionResult::none(); + + expect($result->hasEnvFiles())->toBeFalse(); +}); + +test('envFiles stores multiple files with content', function () { + $result = new RepositoryDetectionResult( + envFiles: [ + '.env.example' => 'APP_KEY=secret', + '.env.sample' => 'DB_HOST=localhost', + '.env.dist' => null, + ], + ); + + expect($result->envFiles)->toHaveCount(3) + ->and($result->envFiles['.env.example'])->toBe('APP_KEY=secret') + ->and($result->envFiles['.env.sample'])->toBe('DB_HOST=localhost') + ->and($result->envFiles['.env.dist'])->toBeNull(); +}); diff --git a/tests/Unit/RepositoryDetectorTest.php b/tests/Unit/RepositoryDetectorTest.php new file mode 100644 index 0000000000..0281ed5391 --- /dev/null +++ b/tests/Unit/RepositoryDetectorTest.php @@ -0,0 +1,231 @@ +forceFill(['git_repository' => $repository, 'git_branch' => 'main', 'git_commit_sha' => 'HEAD']); + $application->setRelation('settings', (new ApplicationSetting)->forceFill(['is_git_shallow_clone_enabled' => true, 'is_git_submodules_enabled' => false])); + $application->setRelation('source', null); + $application->setRelation('private_key', null); + + return new RepositoryDetector($application, $baseDirectory, new Server); +} + +/** + * Runs the scan commands on this machine against a local Git repository, optionally after the + * non-root sudo parser (with `sudo` removed, so the test does not need root). + */ +function runRepositoryScan(RepositoryDetector $detector, string $uuid, bool $throughSudoParser = false): Process +{ + $commands = (new ReflectionMethod($detector, 'scanCommands'))->invoke($detector, $uuid); + if ($throughSudoParser) { + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $commands = collect(parseCommandsByLineForSudo($commands, $server)) + ->map(fn (string $line) => preg_replace('/(^|\s)sudo /', '$1', $line)) + ->map(fn (string $line) => preg_replace('/ && chown .*$/', '', $line)); + } + $process = Process::fromShellCommandline("set -e\n".$commands->implode("\n")); + $process->run(); + + return $process; +} + +beforeEach(function () { + $this->detector = repositoryDetector(); + $this->parseOutputMethod = new ReflectionMethod($this->detector, 'parseOutput'); +}); + +test('parseOutput handles complete detection output', function () { + $output = json_encode([ + 'dockerfiles' => ['Dockerfile', 'apps/api/Dockerfile'], + 'dockerComposeFiles' => ['docker-compose.yml'], + 'envFiles' => ['.env.example' => "APP_NAME=MyApp\nAPP_ENV=production\nDB_HOST=localhost\nDB_PORT=5432"], + 'dockerfilePorts' => ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe(['Dockerfile', 'apps/api/Dockerfile']) + ->and($result->dockerComposeFiles)->toBe(['docker-compose.yml']) + ->and($result->envFiles)->toHaveKey('.env.example') + ->and($result->envFiles['.env.example'])->toContain('APP_NAME=MyApp') + ->and($result->dockerfilePorts)->toBe(['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080]) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE); +}); + +test('parseOutput handles empty repository', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => [], + 'envFiles' => (object) [], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe([]) + ->and($result->dockerComposeFiles)->toBe([]) + ->and($result->envFiles)->toBe([]) + ->and($result->dockerfilePorts)->toBe([]) + ->and($result->getSuggestedBuildPack())->toBeNull(); +}); + +test('parseOutput handles dockerfile without EXPOSE', function () { + $output = json_encode([ + 'dockerfiles' => ['Dockerfile'], + 'dockerComposeFiles' => [], + 'envFiles' => (object) [], + 'dockerfilePorts' => ['Dockerfile' => null], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe(['Dockerfile']) + ->and($result->dockerfilePorts)->toBe(['Dockerfile' => null]) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE); +}); + +test('parseOutput handles only env files without dockerfiles', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => [], + 'envFiles' => ['.env.example' => "SECRET_KEY=changeme\nDATABASE_URL=postgres://localhost/db"], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe([]) + ->and($result->envFiles)->toHaveKey('.env.example') + ->and($result->envFiles['.env.example'])->toContain('SECRET_KEY=changeme') + ->and($result->envFiles['.env.example'])->toContain('DATABASE_URL=postgres://localhost/db') + ->and($result->getSuggestedBuildPack())->toBeNull(); +}); + +test('parseOutput handles multiple compose files', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => ['docker-compose.yml', 'compose.yaml'], + 'envFiles' => (object) [], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerComposeFiles)->toBe(['docker-compose.yml', 'compose.yaml']) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE); +}); + +test('parseOutput puts the file nearest to the base directory first', function () { + $output = json_encode([ + 'dockerfiles' => ['apps/api/Dockerfile', 'Dockerfile.dev', 'apps/web/Dockerfile', 'Dockerfile'], + 'dockerComposeFiles' => ['examples/demo/compose.yaml', 'deploy/docker-compose.yml', 'compose.yaml'], + 'envFiles' => (object) [], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe(['Dockerfile', 'Dockerfile.dev', 'apps/api/Dockerfile', 'apps/web/Dockerfile']) + ->and($result->dockerComposeFiles)->toBe(['compose.yaml', 'deploy/docker-compose.yml', 'examples/demo/compose.yaml']); +}); + +test('parseOutput handles multiple env files', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => [], + 'envFiles' => [ + '.env.example' => "APP_KEY=base64:abc\nAPP_ENV=local", + '.env.sample' => "DB_HOST=127.0.0.1\nDB_PORT=5432", + '.env.dist' => 'REDIS_HOST=localhost', + ], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->envFiles)->toHaveCount(3) + ->and($result->envFiles['.env.example'])->toContain('APP_KEY=base64:abc') + ->and($result->envFiles['.env.sample'])->toContain('DB_HOST=127.0.0.1') + ->and($result->envFiles['.env.dist'])->toContain('REDIS_HOST=localhost') + ->and($result->hasEnvFiles())->toBeTrue(); +}); + +test('parseOutput handles string port values from JSON', function () { + $output = json_encode([ + 'dockerfiles' => ['Dockerfile'], + 'dockerComposeFiles' => [], + 'envFiles' => (object) [], + 'dockerfilePorts' => ['Dockerfile' => '3000'], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfilePorts)->toBe(['Dockerfile' => 3000]) + ->and($result->dockerfilePorts['Dockerfile'])->toBeInt(); +}); + +test('parseOutput returns none for invalid JSON', function () { + $result = $this->parseOutputMethod->invoke($this->detector, 'not valid json'); + + expect($result->dockerfiles)->toBe([]) + ->and($result->dockerComposeFiles)->toBe([]) + ->and($result->envFiles)->toBe([]) + ->and($result->dockerfilePorts)->toBe([]); +}); + +test('scanner rejects traversal before remote execution', function (string $baseDirectory) { + $detector = repositoryDetector(baseDirectory: $baseDirectory); + expect(fn () => (new ReflectionMethod($detector, 'scanCommands'))->invoke($detector, 'scan-test'))->toThrow(RuntimeException::class); +})->with(['/../outside', '/app/../..', '/./app', '/app;id']); + +test('scanner reads only regular Git blobs and honors build directory', function (bool $throughSudoParser) { + $repo = sys_get_temp_dir().'/scan-fixture-'.bin2hex(random_bytes(8)); + $uuid = 'scan-test-'.bin2hex(random_bytes(8)); + mkdir($repo); + mkdir($repo.'/app'); + mkdir($repo.'/app/services'); + file_put_contents($repo.'/Dockerfile', "FROM scratch\nEXPOSE 9000\n"); + file_put_contents($repo.'/app/Dockerfile', "FROM scratch\r\n expose 8080/tcp\r\n"); + file_put_contents($repo.'/app/services/Dockerfile.worker', "FROM scratch\nEXPOSE \${PORT}\n"); + file_put_contents($repo.'/app/compose.yaml', 'services: {}'); + file_put_contents($repo.'/app/.env.sample', "SAFE=value\nQUOTE='it''s'"); + file_put_contents($repo.'/app/services/.env.example', 'NESTED=ignored'); + file_put_contents($repo.'/app/my dockerfile notes.txt', 'not a Dockerfile'); + file_put_contents($repo.'/secret', 'HOST_SECRET=do-not-read'); + symlink($repo.'/secret', $repo.'/app/.env.example'); + $git = 'git -C '.escapeshellarg($repo); + Process::fromShellCommandline("{$git} init -q -b main && {$git} add . && {$git} -c user.name=Test -c user.email=test@example.com commit -qm fixture")->mustRun(); + try { + $detector = repositoryDetector($repo, '/app'); + $process = runRepositoryScan($detector, $uuid, $throughSudoParser); + expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()); + $result = (new ReflectionMethod($detector, 'parseOutput'))->invoke($detector, $process->getOutput()); + expect($result->dockerfiles)->toBe(['Dockerfile', 'services/Dockerfile.worker']) + ->and($result->dockerComposeFiles)->toBe(['compose.yaml']) + ->and($result->envFiles)->toBe(['.env.sample' => "SAFE=value\nQUOTE='it''s'"]) + ->and($result->dockerfilePorts)->toBe(['Dockerfile' => 8080, 'services/Dockerfile.worker' => null]); + + $failure = runRepositoryScan(repositoryDetector($repo.'-missing'), $uuid.'-missing', $throughSudoParser); + expect($failure->isSuccessful())->toBeFalse(); + } finally { + Process::fromShellCommandline('rm -rf -- '.escapeshellarg($repo).' /tmp/'.$uuid.' /tmp/'.$uuid.'-missing')->mustRun(); + } +})->with(['root server' => false, 'non-root server' => true]); + +test('invalid port values are not suggested', function (mixed $port) { + $result = $this->parseOutputMethod->invoke($this->detector, json_encode(['dockerfilePorts' => ['Dockerfile' => $port]])); + expect($result->dockerfilePorts['Dockerfile'])->toBeNull(); +})->with([0, -1, 65536, '3.5', '3e3', true]); + +test('malformed scan collections fall back safely', function () { + $result = $this->parseOutputMethod->invoke($this->detector, '{"dockerfiles":"Dockerfile"}'); + expect($result->dockerfiles)->toBe([]); +});