diff --git a/scripts/lib/host-native-launcher.ps1 b/scripts/lib/host-native-launcher.ps1 index 956a6c776..91ccf5967 100644 --- a/scripts/lib/host-native-launcher.ps1 +++ b/scripts/lib/host-native-launcher.ps1 @@ -368,8 +368,23 @@ function Invoke-KitRepoBuild { } if ($effectiveCommand -eq './repo.sh build') { $repoShPath = Join-Path $workingDirectory 'repo.sh' - $bashCommand = "`"$repoShPath`" build > `"$logPath`" 2>&1" - return Start-Process -FilePath 'bash' -ArgumentList @('-c', $bashCommand) ` + # Start-Process 會把 ArgumentList join 成單一 Arguments 字串再 + # 重新斷詞;含空白與引號的 -c 命令字串經這層重組後被切碎,bash + # 實際收到的命令只剩 repo.sh 路徑本身 — repo.sh 以無參數印 + # usage 後 exit 0,build 參數與 log 重導向全數丟失(2026-08-11 + # 對 canonical Linux 部署機實測)。把整條命令寫進 wrapper 腳本, + # 由 stdin 餵給 bash:命令列上一個參數都不剩,任何平台的引號 + # 重組(含 deploy_root 帶空白的情況)都碰不到它。 + $wrapperPath = Join-Path (Split-Path -Parent $logPath) 'kit-repo-build-launch.sh' + # registry 允許 deploy_root 含 $、backtick 等字元;路徑內插進 + # wrapper 的 shell 雙引號前,先跳脫雙引號語境內僅有的四個特殊 + # 字元(\ $ ` "),否則 sh 會對路徑做參數/命令替換,執行或重導向 + # 到錯誤的位置。 + $shQuote = { param($s) $s -replace '([\\$`"])', '\$1' } + $repoShQuoted = & $shQuote $repoShPath + $logQuoted = & $shQuote $logPath + [IO.File]::WriteAllText($wrapperPath, "#!/bin/sh`nexec `"$repoShQuoted`" build > `"$logQuoted`" 2>&1`n") + return Start-Process -FilePath 'bash' -RedirectStandardInput $wrapperPath ` -WorkingDirectory $workingDirectory -NoNewWindow -PassThru } throw "Unsupported Kit build command '$effectiveCommand'; expected the validated registry command for this platform." @@ -385,13 +400,31 @@ function Invoke-KitRepoBuild { ) $pidFile = Join-Path $RunDir 'kit-repo-build.pid' + # 舊 build 留下的 log 會讓「exit 0 必須有 log」的 fail-closed 檢查誤把 + # stale log 當成本次啟動的證據;先刪掉,log 的存在就專屬於這一次 build。 + if (Test-Path -LiteralPath $LogPath -ErrorAction Stop) { + if (-not (Test-Path -LiteralPath $LogPath -PathType Leaf -ErrorAction Stop)) { + throw "Kit build log path '$LogPath' exists but is not a file." + } + Remove-Item -LiteralPath $LogPath -Force -ErrorAction Stop + if (Test-Path -LiteralPath $LogPath -ErrorAction Stop) { + throw "Failed to remove stale Kit build log '$LogPath'." + } + } $proc = & $StartProcessFn $WorkingDirectory $LogPath $BuildCommand $proc.Id | Set-Content -LiteralPath $pidFile -Encoding ascii $exited = & $WaitForExitFn $proc ($TimeoutSec * 1000) if ($exited) { Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue - return [pscustomobject]@{ TimedOut = $false; ExitCode = [int]$proc.ExitCode; ProcessId = [int]$proc.Id } + $exitCode = [int]$proc.ExitCode + if ($exitCode -eq 0 -and -not (Test-Path -LiteralPath $LogPath -PathType Leaf -ErrorAction Stop)) { + # exit 0 卻連 log 檔都沒建,代表啟動列被引號重組弄壞、重導向沒有 + # 生效(build 根本沒跑);此時相信 exit code 會讓 deploy 把假 build + # 當成功,直到 artifacts 檢查才以更難診斷的方式失敗。 + $exitCode = 1 + } + return [pscustomobject]@{ TimedOut = $false; ExitCode = $exitCode; ProcessId = [int]$proc.Id } } & $StopTreeFn 'kit-repo-build' $RunDir diff --git a/scripts/tests/test-host-native-launcher.ps1 b/scripts/tests/test-host-native-launcher.ps1 index 4c64ff9f1..fbdd4f0c9 100644 --- a/scripts/tests/test-host-native-launcher.ps1 +++ b/scripts/tests/test-host-native-launcher.ps1 @@ -127,7 +127,7 @@ try { New-Item -ItemType Directory -Path $runDir -Force | Out-Null $logPath = Join-Path $runDir 'kit-repo-build.log' $result = Invoke-KitRepoBuild -WorkingDirectory $sb -LogPath $logPath -RunDir $runDir ` - -StartProcessFn { param($workingDirectory, $logPath) [pscustomobject]@{ Id = 4242; ExitCode = 0 } } ` + -StartProcessFn { param($workingDirectory, $logPath) Set-Content -LiteralPath $logPath -Value 'build ok'; [pscustomobject]@{ Id = 4242; ExitCode = 0 } } ` -WaitForExitFn { param($proc, $timeoutMs) $true } ` -StopTreeFn { param($name, $runDir) throw 'StopTreeFn must not run on success' } Assert-True ($result.TimedOut -eq $false) 'success path is not TimedOut' @@ -174,6 +174,190 @@ try { } finally { Remove-TestSandbox -Path $sb } +# Test 15b: exit 0 without a log file is treated as a failed build +# (2026-08-11 canonical rebuild regression: bash 收到被重組壞掉的命令列,repo.sh +# 無參數印 usage 後 exit 0、重導向沒生效 — exit code 因此不可單獨採信) +$sb = New-TestSandbox -Prefix 'kit-repo-build-nolog' +try { + $runDir = Join-Path $sb 'scripts\.run' + New-Item -ItemType Directory -Path $runDir -Force | Out-Null + $logPath = Join-Path $runDir 'kit-repo-build.log' + $result = Invoke-KitRepoBuild -WorkingDirectory $sb -LogPath $logPath -RunDir $runDir ` + -StartProcessFn { param($workingDirectory, $logPath) [pscustomobject]@{ Id = 4245; ExitCode = 0 } } ` + -WaitForExitFn { param($proc, $timeoutMs) $true } ` + -StopTreeFn { param($name, $runDir) throw 'StopTreeFn must not run' } + Assert-True ($result.TimedOut -eq $false) 'no-log path is not TimedOut' + Assert-Equal 1 $result.ExitCode 'exit 0 without a log file fails closed' + Write-TestPass 'Invoke-KitRepoBuild refuses silent no-log success' +} +finally { Remove-TestSandbox -Path $sb } + +# Test 15c: the real bash launch path keeps the build argument and the log redirect +# (dynamic regression guard for the same 2026-08-11 incident: Start-Process 對含 +# 內嵌引號的 -c 字串重組後,bash 丟失 build 參數與重導向;修正把命令寫進 wrapper +# 並由 stdin 餵給 bash。sandbox 刻意帶空白+$+backtick 目錄:registry 允許這些 +# 字元,空白證明引號重組碰不到 stdin wrapper,$ 與 backtick 證明 wrapper 內插 +# 的路徑有跳脫、不會被 sh 做參數/命令替換) +$sbRoot = New-TestSandbox -Prefix 'kit-repo-build-bash' +$originalPath = $env:PATH +try { + # Windows 的裸 `bash` 可能先解析到 System32\bash.exe (WSL)。WSL 需要 + # /mnt/c/... 路徑,無法直接執行這個 Win32 sandbox 裡的 C:/... wrapper; + # production 的 Linux 路徑則由 native bash 執行。Windows regression 優先 + # 使用隨 Git 安裝的 MSYS bash,讓測試真的覆蓋 production 的 argv/stdin + # 行為,而不是因 host path dialect 不相容而誤判 launcher。 + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { + $gitCommand = Get-Command git -ErrorAction SilentlyContinue + if ($null -ne $gitCommand) { + $gitRoot = Split-Path -Parent (Split-Path -Parent $gitCommand.Source) + $gitBash = Join-Path $gitRoot 'bin\bash.exe' + if (Test-Path -LiteralPath $gitBash -PathType Leaf) { + $env:PATH = "$(Split-Path -Parent $gitBash)$([IO.Path]::PathSeparator)$originalPath" + } + } + } + $bashCommand = Get-Command bash -ErrorAction SilentlyContinue + $sb = (Join-Path $sbRoot 'deploy root with $paces and `tick') -replace '\\', '/' + $runDir = Join-Path $sb 'scripts/.run' + New-Item -ItemType Directory -Path $runDir -Force | Out-Null + $logPath = (Join-Path $runDir 'kit-repo-build.log') -replace '\\', '/' + $argsPath = (Join-Path $sb 'seen-args.txt') -replace '\\', '/' + $fakeRepoSh = Join-Path $sb 'repo.sh' + [IO.File]::WriteAllText($fakeRepoSh, "#!/bin/sh`nprintf '%s\n' `"`$@`" > '$argsPath'`necho build-ran`nexit 0`n") + if ($null -eq $bashCommand) { + Write-TestPass 'Invoke-KitRepoBuild real bash launch (skipped: no bash on this host)' + } else { + # POSIX 上 WriteAllText 不帶 exec bit,exec "$0" 會 Permission denied; + # MSYS bash 忽略 exec bit,所以只在 native POSIX host 執行並驗證 chmod。 + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + & $bashCommand.Source -c "chmod +x '$($fakeRepoSh -replace '\\', '/')'" + Assert-Equal 0 $LASTEXITCODE 'fake repo.sh chmod succeeds before the launch' + } + $result = Invoke-KitRepoBuild -WorkingDirectory $sb -LogPath $logPath -RunDir $runDir ` + -BuildCommand './repo.sh build' -TimeoutSec 60 + Assert-True ($result.TimedOut -eq $false) 'real bash launch is not TimedOut' + Assert-Equal 0 $result.ExitCode 'real bash launch exits 0' + Assert-True (Test-Path -LiteralPath $argsPath -PathType Leaf) 'fake repo.sh actually ran' + Assert-Equal 'build' ((Get-Content -LiteralPath $argsPath -Raw).Trim()) 'repo.sh received exactly the build argument' + Assert-True (Test-Path -LiteralPath $logPath -PathType Leaf) 'stdout redirect created the log file' + Assert-True ((Get-Content -LiteralPath $logPath -Raw) -match 'build-ran') 'repo.sh stdout reached the log file' + } +} +finally { + $env:PATH = $originalPath + Remove-TestSandbox -Path $sbRoot +} + +# Test 15d: a stale log from a previous build is removed before the launch +# (otherwise the exit-0-must-have-a-log guard would accept the previous run's +# log as evidence and the shredded-launch signature would slip through again) +$sb = New-TestSandbox -Prefix 'kit-repo-build-stale' +try { + $runDir = Join-Path $sb 'scripts\.run' + New-Item -ItemType Directory -Path $runDir -Force | Out-Null + $logPath = Join-Path $runDir 'kit-repo-build.log' + Set-Content -LiteralPath $logPath -Value 'stale log from an earlier build' + $result = Invoke-KitRepoBuild -WorkingDirectory $sb -LogPath $logPath -RunDir $runDir ` + -StartProcessFn { param($workingDirectory, $logPath) [pscustomobject]@{ Id = 4246; ExitCode = 0 } } ` + -WaitForExitFn { param($proc, $timeoutMs) $true } ` + -StopTreeFn { param($name, $runDir) throw 'StopTreeFn must not run' } + Assert-Equal 1 $result.ExitCode 'stale log does not satisfy the log-existence guard' + Assert-True (-not (Test-Path -LiteralPath $logPath)) 'stale log was removed before the launch' + Write-TestPass 'Invoke-KitRepoBuild removes stale logs before launching' +} +finally { Remove-TestSandbox -Path $sb } + +# Test 15e: failure to remove locked stale log evidence aborts before launch +Assert-True ($moduleContent -match 'Remove-Item -LiteralPath \$LogPath -Force -ErrorAction Stop') 'stale-log cleanup errors are terminating' +if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { + $sb = New-TestSandbox -Prefix 'kit-repo-build-stale-cleanup' + $lockedLog = $null + try { + $runDir = Join-Path $sb 'scripts\.run' + New-Item -ItemType Directory -Path $runDir -Force | Out-Null + $logPath = Join-Path $runDir 'kit-repo-build.log' + Set-Content -LiteralPath $logPath -Value 'locked stale evidence' + $lockedLog = [IO.File]::Open($logPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) + $started = $false + $cleanupError = $null + try { + Invoke-KitRepoBuild -WorkingDirectory $sb -LogPath $logPath -RunDir $runDir ` + -StartProcessFn { param($workingDirectory, $logPath) $script:started = $true; [pscustomobject]@{ Id = 4247; ExitCode = 0 } } ` + -WaitForExitFn { param($proc, $timeoutMs) $true } ` + -StopTreeFn { param($name, $runDir) throw 'StopTreeFn must not run' } | Out-Null + } + catch { $cleanupError = $_ } + Assert-True ($null -ne $cleanupError) 'locked stale-log cleanup failure aborts the build' + Assert-True (-not $started) 'build process is not launched after locked stale-log cleanup failure' + Write-TestPass 'Invoke-KitRepoBuild fails closed when stale log cleanup fails' + } + finally { + if ($null -ne $lockedLog) { $lockedLog.Dispose() } + Remove-TestSandbox -Path $sb + } +} +else { + Write-TestPass 'Invoke-KitRepoBuild locked stale-log cleanup (skipped: POSIX permits unlinking open files)' +} + +# Test 15f: a container at the log-file path is rejected and preserved +$sb = New-TestSandbox -Prefix 'kit-repo-build-log-container' +try { + $runDir = Join-Path $sb 'scripts\.run' + New-Item -ItemType Directory -Path $runDir -Force | Out-Null + $logPath = Join-Path $runDir 'kit-repo-build.log' + New-Item -ItemType Directory -Path $logPath -Force | Out-Null + $started = $false + $cleanupError = $null + try { + Invoke-KitRepoBuild -WorkingDirectory $sb -LogPath $logPath -RunDir $runDir ` + -StartProcessFn { param($workingDirectory, $logPath) $script:started = $true; [pscustomobject]@{ Id = 4248; ExitCode = 0 } } ` + -WaitForExitFn { param($proc, $timeoutMs) $true } ` + -StopTreeFn { param($name, $runDir) throw 'StopTreeFn must not run' } | Out-Null + } + catch { $cleanupError = $_ } + Assert-True ($null -ne $cleanupError) 'container at the log path aborts the build' + Assert-True (-not $started) 'build process is not launched for a container log path' + Assert-True (Test-Path -LiteralPath $logPath -PathType Container) 'container at the log path is preserved' + Write-TestPass 'Invoke-KitRepoBuild rejects a container log path' +} +finally { Remove-TestSandbox -Path $sb } + +# Test 15g: metadata lookup errors abort before stale evidence can be trusted +$sb = New-TestSandbox -Prefix 'kit-repo-build-log-metadata' +try { + $runDir = Join-Path $sb 'scripts\.run' + New-Item -ItemType Directory -Path $runDir -Force | Out-Null + $logPath = Join-Path $runDir 'kit-repo-build.log' + $started = $false + $cleanupError = $null + $previousErrorActionPreference = $ErrorActionPreference + try { + function Test-Path { + [CmdletBinding()] + param([string] $LiteralPath, [object] $PathType) + Write-Error 'simulated log metadata access failure' + return $false + } + $ErrorActionPreference = 'Continue' + try { + Invoke-KitRepoBuild -WorkingDirectory $sb -LogPath $logPath -RunDir $runDir ` + -StartProcessFn { param($workingDirectory, $logPath) $script:started = $true; [pscustomobject]@{ Id = 4249; ExitCode = 0 } } ` + -WaitForExitFn { param($proc, $timeoutMs) $true } ` + -StopTreeFn { param($name, $runDir) throw 'StopTreeFn must not run' } | Out-Null + } + catch { $cleanupError = $_ } + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + Remove-Item -LiteralPath Function:\Test-Path -Force -ErrorAction SilentlyContinue + } + Assert-True ($null -ne $cleanupError) 'log metadata access failure aborts the build' + Assert-True (-not $started) 'build process is not launched after log metadata access failure' + Write-TestPass 'Invoke-KitRepoBuild fails closed on log metadata errors' +} +finally { Remove-TestSandbox -Path $sb } + # Test 16: Invoke-KitRepoBuild writes the started process id to a pid file before waiting, # so an external killer can target it even ahead of the timeout deadline $sb = New-TestSandbox -Prefix 'kit-repo-build' @@ -286,7 +470,10 @@ try { -KitControlUrl '' ` -ImportProbeFn { param($PythonExe) return 0 } ` -LocalAddressProbeFn { param($HostName) throw 'empty control URL must not probe an address' } | Out-Null - Assert-Equal '' $capturedKitManagerEnvironment.KIT_CONTROL_URL 'unconfigured Kit control remains an honest empty child value' + # .NET Framework removes a process env var when it is set to '', while modern + # .NET preserves the empty string. Both represent the same unconfigured child + # authority, so normalize the host-specific null/empty representation. + Assert-Equal '' ([string]$capturedKitManagerEnvironment.KIT_CONTROL_URL) 'unconfigured Kit control remains an honest empty child value' foreach ($name in $kitManagerEnvironmentNames) { Assert-Equal "parent-$name" ([Environment]::GetEnvironmentVariable($name, 'Process')) "parent env restores $name after empty control authority" } @@ -356,9 +543,18 @@ $ipv6ControlUrl = Resolve-HostNativeKitControlUrl ` -KitControlUrl 'HTTP://[::1]:49101/' ` -LocalAddressProbeFn { param($HostName) - return ($HostName.Trim([char[]]'[]') -eq '::1') + # .NET Framework expands ::1 to 0000:...:0001 while modern .NET keeps + # the compressed form. Check the address semantics, not its rendering. + return [System.Net.IPAddress]::IsLoopback( + [System.Net.IPAddress]::Parse($HostName.Trim([char[]]'[]')) + ) } -Assert-Equal 'http://[::1]:49101' $ipv6ControlUrl 'resolver canonicalizes a bracketed IPv6 loopback authority' +$ipv6ControlUri = [Uri]$ipv6ControlUrl +Assert-Equal 'http' $ipv6ControlUri.Scheme 'resolver preserves the HTTP scheme for IPv6 loopback authority' +Assert-Equal 49101 $ipv6ControlUri.Port 'resolver preserves the IPv6 loopback authority port' +Assert-True ([System.Net.IPAddress]::IsLoopback( + [System.Net.IPAddress]::Parse($ipv6ControlUri.Host.Trim([char[]]'[]')) +)) 'resolver preserves the IPv6 loopback authority address semantics' $rejectedControlUrls = @( @{ Name = 'HTTPS'; Url = 'https://localhost:49101' },