diff --git a/README.md b/README.md index 30f94e0..261e362 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,9 @@ Once installed, you can re-run or pass flags from the local copy: > Native PowerShell mounts `%USERPROFILE%\.ssh` read-only when it exists and > does not forward `SSH_AUTH_SOCK`. The separate Git Bash adapter supports SSH > agent-socket forwarding with its Bash lifecycle. +> Use `./scripts/migrate-windows-adapter.ps1 -Target PowerShell` or +> `-Target GitBash` from PowerShell 7 for an explicit cross-adapter migration. +> Normal installers and uninstallers continue to reject foreign adapter state. Start ----- diff --git a/SECURITY.md b/SECURITY.md index 7c9e209..5548913 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -310,7 +310,11 @@ authority to delete it. `FORMAT=1` versions each lifecycle adapter's native state contract; it does not make Bash/Git Bash and PowerShell Install-identity files interchangeable. Use -the matching adapter family for rebuild and uninstall operations. +the matching adapter family for rebuild and uninstall operations, or explicitly +convert it with `scripts/migrate-windows-adapter.ps1`. The converter parses the +closed field set as data, verifies Box and Managed-home ownership, and +atomically transfers profile ownership; it never evaluates state or copies +Workspace, Managed home, or private keys. If the runtime is unreachable, uninstall reports incomplete cleanup and returns nonzero instead of treating the engine as empty. Host-only shell cleanup and diff --git a/docs/adr/0009-explicitly-convert-windows-adapter-state.md b/docs/adr/0009-explicitly-convert-windows-adapter-state.md new file mode 100644 index 0000000..d3ebff9 --- /dev/null +++ b/docs/adr/0009-explicitly-convert-windows-adapter-state.md @@ -0,0 +1,39 @@ +# ADR 0009: Explicitly convert Windows adapter state + +Status: Accepted + +## Context + +Git Bash and native PowerShell share the closed `FORMAT=1` field set, but paths +and shell-profile ownership are adapter-native. Letting either normal lifecycle +reader reinterpret the other's state would weaken fail-closed uninstall and +profile ownership checks. + +Windows OpenSSH exposes a named pipe while Linux Boxes consume a Unix socket. +Neither Docker Desktop nor Podman documents one common supported bridge. Agent +forwarding is therefore tracked separately as an opt-in prototype rather than +being coupled to state migration. + +## Decision + +`scripts/migrate-windows-adapter.ps1` is the only cross-adapter conversion path. +It parses `FORMAT=1` as a closed data set, validates path/resource/source/image +identities, verifies the live Box and Managed-home ownership, and rejects +malformed, foreign, or already-target-native state. + +The command translates only Windows path spelling and shell ownership. It does +not recreate the Box, retag its image, or copy, delete, or reconstruct Workspace +or Managed home. It snapshots both adapters' profile files, installs the target +entrypoint, removes source-owned blocks, and atomically replaces Install state. +Any handled failure restores every snapshotted file. + +Normal install and uninstall readers remain unchanged and continue to reject +foreign adapter state. This preserves an explicit authority boundary and makes +migration auditable rather than implicit. + +## Consequences + +Migration requires PowerShell 7, even when Git Bash is the target. It is a local +metadata/profile transaction and does not require network access. Native Windows +tests cover parsing and the conversion safety contract; real Windows UAT covers +both directions, custom/non-ASCII paths, Docker Desktop, and Podman. diff --git a/docs/releases/v1.3.0.md b/docs/releases/v1.3.0.md new file mode 100644 index 0000000..0caa3e6 --- /dev/null +++ b/docs/releases/v1.3.0.md @@ -0,0 +1,22 @@ +# Squarebox v1.3.0 migration guide + +## Windows lifecycle adapter migration + +Git Bash and native PowerShell retain strict adapter-native `FORMAT=1` state. +Normal rebuild and uninstall commands do not reinterpret foreign profile paths. + +From PowerShell 7, convert an existing identity explicitly: + +```powershell +./scripts/migrate-windows-adapter.ps1 -Target PowerShell +./scripts/migrate-windows-adapter.ps1 -Target GitBash +``` + +Set `SQUAREBOX_DIR` or pass `-InstallDir` for a custom checkout. The converter +validates data-only state, verifies live runtime ownership, moves shell +integration transactionally, and retains the same Box, image, Workspace, and +Managed home. After success, use only the target adapter for lifecycle work. + +Native Windows OpenSSH-agent forwarding remains experimental. PowerShell keeps +the read-only SSH-directory fallback; Git Bash can forward an already available +Unix-compatible socket. Migration does not install a named-pipe relay. diff --git a/scripts/migrate-windows-adapter.ps1 b/scripts/migrate-windows-adapter.ps1 new file mode 100755 index 0000000..04f95a2 --- /dev/null +++ b/scripts/migrate-windows-adapter.ps1 @@ -0,0 +1,221 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)][ValidateSet('PowerShell', 'GitBash')][string]$Target, + [string]$InstallDir = $env:SQUAREBOX_DIR, + [string]$PowerShellProfile = $PROFILE.CurrentUserAllHosts, + [string]$PowerShellCurrentHostProfile = $PROFILE.CurrentUserCurrentHost, + [string]$UserHomePath = $(if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }), + [switch]$Yes +) + +$ErrorActionPreference = 'Stop' +$Fields = @( + 'FORMAT', 'INSTALL_ID', 'RUNTIME', 'INSTALL_DIR', 'WORKSPACE_DIR', 'GIT_CONFIG_DIR', + 'HOME_VOLUME', 'CONTAINER_NAME', 'IMAGE_ALIAS', 'IMAGE_REPOSITORY', 'IMAGE_REF', + 'IMAGE_ID', 'IMAGE_DIGEST', 'SOURCE_REF', 'SOURCE_COMMIT', 'RELEASE_TAG', + 'REQUESTED_TAG', 'PUID', 'PGID', 'BUILD', 'EDGE', 'SHELL_INIT', 'SHELL_RC', + 'ORIGIN', 'HOME_VOLUME_ADOPTED' +) +$UserHome = [IO.Path]::GetFullPath($UserHomePath) +if (-not $InstallDir) { $InstallDir = Join-Path $UserHome 'squarebox' } +$InstallDir = [IO.Path]::GetFullPath($InstallDir) +$StateFile = Join-Path $InstallDir '.squarebox\install-state' +$IdentityLabel = 'io.squarebox.install-id' + +function Fail([string]$Message) { throw "Windows adapter migration: $Message" } +function Read-State([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { Fail "Install identity not found: $Path" } + $state = [ordered]@{} + foreach ($line in [IO.File]::ReadAllLines($Path)) { + if (-not $line -or $line.StartsWith('#', [StringComparison]::Ordinal)) { continue } + $at = $line.IndexOf('=') + if ($at -lt 1) { Fail "malformed Install identity: $Path" } + $key = $line.Substring(0, $at); $value = $line.Substring($at + 1) + if ($Fields -cnotcontains $key) { Fail "unknown field '$key'" } + if ($state.Contains($key)) { Fail "duplicate field '$key'" } + if ($value -match '[\x00-\x1f\x7f]') { Fail "control character in '$key'" } + $state[$key] = $value + } + foreach ($field in $Fields) { if (-not $state.Contains($field)) { Fail "missing field '$field'" } } + if ($state.FORMAT -cne '1' -or $state.INSTALL_ID -cnotmatch '^[A-Za-z0-9._-]{8,128}$') { Fail 'invalid FORMAT or INSTALL_ID' } + if ($state.RUNTIME -cnotin @('docker', 'podman')) { Fail 'invalid runtime' } + if ($state.ORIGIN -cne 'https://github.com/SquareWaveSystems/squarebox.git') { Fail 'noncanonical origin' } + foreach ($name in @('HOME_VOLUME', 'CONTAINER_NAME')) { + if ($state[$name] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$') { Fail "invalid $name" } + } + if ($state.IMAGE_ALIAS -cnotmatch '^[a-z0-9][a-z0-9._/-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?$' -or + $state.IMAGE_REPOSITORY -cnotmatch '^[a-z0-9][a-z0-9._/-]*$' -or $state.IMAGE_ID -cnotmatch '^(sha256:)?[0-9a-f]{64}$' -or + ($state.IMAGE_DIGEST -and $state.IMAGE_DIGEST -cnotmatch '^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$') -or + $state.SOURCE_COMMIT -cnotmatch '^[0-9a-f]{40}$') { Fail 'invalid image or source identity' } + foreach ($name in @('PUID', 'PGID')) { + $number = 0L + if ($state[$name] -cnotmatch '^[0-9]{1,10}$' -or -not [long]::TryParse($state[$name], [ref]$number) -or $number -lt 1 -or $number -gt 2147483647) { Fail "invalid $name" } + } + if ($state.BUILD -cnotin @('0', '1') -or $state.EDGE -cnotin @('0', '1') -or $state.HOME_VOLUME_ADOPTED -cnotin @('0', '1') -or + ($state.EDGE -ceq '1' -and $state.BUILD -cne '1')) { Fail 'invalid lifecycle flags' } + foreach ($name in @('INSTALL_DIR', 'WORKSPACE_DIR', 'GIT_CONFIG_DIR', 'SHELL_INIT', 'SHELL_RC')) { + if (-not [IO.Path]::IsPathFullyQualified($state[$name])) { Fail "non-absolute $name" } + $full = [IO.Path]::GetFullPath($state[$name]) + $normalizedInput = if ($IsWindows) { $state[$name].Replace('/', '\') } else { $state[$name] } + if (-not [string]::Equals($full, $normalizedInput, [StringComparison]::OrdinalIgnoreCase)) { Fail "unnormalized $name" } + } + if (-not [string]::Equals([IO.Path]::GetFullPath($state.INSTALL_DIR), $InstallDir, [StringComparison]::OrdinalIgnoreCase)) { + Fail 'INSTALL_DIR does not identify this checkout' + } + if ([string]::Equals($InstallDir, [IO.Path]::GetPathRoot($InstallDir), [StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($InstallDir, $UserHome, [StringComparison]::OrdinalIgnoreCase)) { Fail 'unsafe INSTALL_DIR' } + $workspace = [IO.Path]::GetFullPath($state.WORKSPACE_DIR) + if ([string]::Equals($workspace, [IO.Path]::GetPathRoot($workspace), [StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($workspace, $InstallDir, [StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($workspace, $UserHome, [StringComparison]::OrdinalIgnoreCase)) { Fail 'unsafe WORKSPACE_DIR' } + $expectedGit = [IO.Path]::Combine($InstallDir, '.squarebox', 'identity', 'git') + if (-not [string]::Equals([IO.Path]::GetFullPath($state.GIT_CONFIG_DIR), [IO.Path]::GetFullPath($expectedGit), [StringComparison]::OrdinalIgnoreCase)) { Fail 'GIT_CONFIG_DIR escaped managed identity state' } + return $state +} +function Format-Path([string]$Path, [string]$Adapter) { + $native = [IO.Path]::GetFullPath($Path) + if ($Adapter -ceq 'GitBash') { return $native.Replace('\', '/') } + return $native +} +function Assert-SafeFile([string]$Path, [string]$Description) { + if (-not (Test-Path -LiteralPath $Path)) { return } + $item = Get-Item -LiteralPath $Path -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint -or $item.PSIsContainer) { Fail "$Description is not a regular file: $Path" } +} +function Test-Block([string]$Path, [string]$Start, [string]$End) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + Assert-SafeFile $Path 'profile' + $inside = $false; $count = 0 + foreach ($line in [IO.File]::ReadAllLines($Path)) { + if ($line -ceq $Start) { if ($inside -or $count) { Fail "malformed marker block in $Path" }; $inside = $true; $count++; continue } + if ($line -ceq $End) { if (-not $inside) { Fail "malformed marker block in $Path" }; $inside = $false } + } + if ($inside) { Fail "unterminated marker block in $Path" } + return $count -eq 1 +} +function Remove-Block([string]$Path, [string]$Start, [string]$End) { + if (-not (Test-Block $Path $Start $End)) { return } + $inside = $false + $lines = foreach ($line in [IO.File]::ReadAllLines($Path)) { + if ($line -ceq $Start) { $inside = $true; continue } + if ($line -ceq $End) { $inside = $false; continue } + if (-not $inside) { $line } + } + Write-Atomic $Path @($lines) +} +function Write-Atomic([string]$Path, [string[]]$Lines) { + [IO.Directory]::CreateDirectory((Split-Path $Path)) | Out-Null + $temp = Join-Path (Split-Path $Path) ".migration.$([guid]::NewGuid().ToString('N'))" + try { [IO.File]::WriteAllLines($temp, $Lines, [Text.UTF8Encoding]::new($false)); [IO.File]::Move($temp, $Path, $true) } + finally { Remove-Item -Force -LiteralPath $temp -ErrorAction SilentlyContinue } +} +function Add-Block([string]$Path, [string[]]$Block) { + Assert-SafeFile $Path 'profile' + $lines = if (Test-Path -LiteralPath $Path -PathType Leaf) { @([IO.File]::ReadAllLines($Path)) } else { @() } + Write-Atomic $Path @($lines + $Block) +} + +$State = Read-State $StateFile +$GitBashInit = Format-Path (Join-Path $UserHome '.squarebox-shell-init') GitBash +$GitBashRc = Format-Path (Join-Path $UserHome '.bashrc') GitBash +$PowerShellStatePath = Format-Path $PowerShellProfile PowerShell +$source = if ([string]::Equals([IO.Path]::GetFullPath($State.SHELL_INIT), [IO.Path]::GetFullPath($PowerShellProfile), [StringComparison]::OrdinalIgnoreCase)) { 'PowerShell' } + elseif ($State.SHELL_INIT.Replace('\', '/') -ceq $GitBashInit -and $State.SHELL_RC.Replace('\', '/') -ceq $GitBashRc) { 'GitBash' } + else { Fail 'state is foreign to both supported Windows adapters' } +if ($source -ceq $Target) { Fail "Install identity already belongs to $Target" } +if ($source -ceq 'GitBash') { + Assert-SafeFile $GitBashInit 'Git Bash adapter' + if (-not (Test-Path -LiteralPath $GitBashInit -PathType Leaf) -or + -not ([IO.File]::ReadAllLines($GitBashInit) -ccontains "# squarebox-install-id=$($State.INSTALL_ID)")) { Fail 'Git Bash adapter ownership check failed' } +} else { + if (-not (Test-Block $PowerShellProfile '# >>> squarebox >>>' '# <<< squarebox <<<') -or + -not ([IO.File]::ReadAllLines($PowerShellProfile) -ccontains "# squarebox-install-id=$($State.INSTALL_ID)")) { Fail 'PowerShell adapter ownership check failed' } +} +foreach ($path in @($PowerShellProfile, $PowerShellCurrentHostProfile)) { + if ((Test-Block $path '# >>> squarebox >>>' '# <<< squarebox <<<') -and + -not ([IO.File]::ReadAllLines($path) -ccontains "# squarebox-install-id=$($State.INSTALL_ID)")) { Fail "foreign PowerShell profile block: $path" } +} +if ((Test-Path -LiteralPath $GitBashInit -PathType Leaf) -and + -not ([IO.File]::ReadAllLines($GitBashInit) -ccontains "# squarebox-install-id=$($State.INSTALL_ID)")) { Fail 'foreign Git Bash adapter' } + +if (-not (Get-Command $State.RUNTIME -ErrorAction SilentlyContinue)) { Fail "runtime '$($State.RUNTIME)' is unavailable" } +$owner = (& $State.RUNTIME inspect -f '{{ index .Config.Labels "io.squarebox.install-id" }}' $State.CONTAINER_NAME 2>$null) +if ($LASTEXITCODE -ne 0 -or -not $owner -or $owner.Trim() -cne $State.INSTALL_ID) { Fail 'Box ownership check failed' } +$volumeOwner = (& $State.RUNTIME volume inspect -f '{{ index .Labels "io.squarebox.install-id" }}' $State.HOME_VOLUME 2>$null) +if ($LASTEXITCODE -ne 0) { Fail 'Managed-home ownership check failed' } +if ($volumeOwner) { $volumeOwner = $volumeOwner.Trim() } +if ($volumeOwner -cne $State.INSTALL_ID -and -not (-not $volumeOwner -and $State.HOME_VOLUME_ADOPTED -ceq '1')) { Fail 'Managed home is foreign' } + +$paths = @( + $StateFile, $PowerShellProfile, $PowerShellCurrentHostProfile, $GitBashInit, $GitBashRc, + (Format-Path (Join-Path $UserHome '.zshrc') GitBash), (Format-Path (Join-Path $UserHome '.bash_profile') GitBash) +) | Select-Object -Unique +if (-not $Yes -and -not $PSCmdlet.ShouldContinue("Convert $source Install identity '$($State.INSTALL_ID)' to $Target?", 'Squarebox adapter migration')) { exit 0 } +$backupRoot = Join-Path ([IO.Path]::GetTempPath()) "squarebox-migration-$([guid]::NewGuid().ToString('N'))" +[IO.Directory]::CreateDirectory($backupRoot) | Out-Null +$backups = @() +foreach ($path in $paths) { + Assert-SafeFile $path 'migration target' + $exists = Test-Path -LiteralPath $path -PathType Leaf + $backup = Join-Path $backupRoot ([string]$backups.Count) + if ($exists) { [IO.File]::Copy($path, $backup, $true) } + $backups += [pscustomobject]@{ Path = $path; Backup = $backup; Exists = $exists } +} +try { + if ($Target -ceq 'PowerShell') { + foreach ($path in @($GitBashRc, (Format-Path (Join-Path $UserHome '.zshrc') GitBash))) { Remove-Block $path '# >>> squarebox >>>' '# <<< squarebox <<<' } + Remove-Block (Format-Path (Join-Path $UserHome '.bash_profile') GitBash) '# >>> squarebox bashrc bridge >>>' '# <<< squarebox bashrc bridge <<<' + Remove-Item -Force -LiteralPath $GitBashInit -ErrorAction SilentlyContinue + $block = @( + '# >>> squarebox >>>', '# Managed by squarebox using the recorded Install identity.', "# squarebox-install-id=$($State.INSTALL_ID)", + 'function sqrbx {', + " if (`$args.Count -gt 0 -and `$args[0] -eq 'uninstall') { & '$($InstallDir.Replace("'", "''"))\uninstall.ps1' @(`$args | Select-Object -Skip 1); return }", + " `$owner = (& $($State.RUNTIME) inspect -f '{{ index .Config.Labels `"io.squarebox.install-id`" }}' '$($State.CONTAINER_NAME)' 2>`$null)", + " if (-not `$owner -or `$owner.Trim() -cne '$($State.INSTALL_ID)') { throw 'squarebox Install identity mismatch; refusing to start.' }", + " `$running = (& $($State.RUNTIME) inspect -f '{{.State.Running}}' '$($State.CONTAINER_NAME)' 2>`$null)", + " if (`$running -and `$running.Trim() -ceq 'true') { & $($State.RUNTIME) stop '$($State.CONTAINER_NAME)' | Out-Null }", + " & $($State.RUNTIME) start -ai '$($State.CONTAINER_NAME)'", '}', 'function squarebox { sqrbx @args }', + "function sqrbx-rebuild { & '$($InstallDir.Replace("'", "''"))\install.ps1' @args }", 'function squarebox-rebuild { sqrbx-rebuild @args }', + "function sqrbx-uninstall { & '$($InstallDir.Replace("'", "''"))\uninstall.ps1' @args }", 'function squarebox-uninstall { sqrbx-uninstall @args }', '# <<< squarebox <<<' + ) + Remove-Block $PowerShellProfile '# >>> squarebox >>>' '# <<< squarebox <<<' + Add-Block $PowerShellProfile $block + foreach ($name in @('INSTALL_DIR', 'WORKSPACE_DIR', 'GIT_CONFIG_DIR')) { $State[$name] = Format-Path $State[$name] PowerShell } + $State.SHELL_INIT = $PowerShellStatePath; $State.SHELL_RC = $PowerShellStatePath + } else { + foreach ($path in @($PowerShellProfile, $PowerShellCurrentHostProfile)) { Remove-Block $path '# >>> squarebox >>>' '# <<< squarebox <<<' } + $install = Format-Path $InstallDir GitBash + $bashSingleQuote = "'" + '"' + "'" + '"' + "'" + $quotedInstall = $install.Replace("'", $bashSingleQuote) + $quotedRuntime = $State.RUNTIME.Replace("'", $bashSingleQuote) + $quotedContainer = $State.CONTAINER_NAME.Replace("'", $bashSingleQuote) + $quotedInstallId = $State.INSTALL_ID.Replace("'", $bashSingleQuote) + Write-Atomic $GitBashInit @( + "# squarebox-install-id=$($State.INSTALL_ID)", "_sq_install='$quotedInstall'", + "_sq_runtime='$quotedRuntime'", "_sq_container='$quotedContainer'", "_sq_install_id='$quotedInstallId'", + 'sqrbx() {', ' if [ "${1:-}" = uninstall ]; then shift; "${_sq_install}/uninstall.sh" "$@"; return; fi', + ' _sq_owner="$("${_sq_runtime}" inspect -f ''{{ index .Config.Labels "io.squarebox.install-id" }}'' "${_sq_container}" 2>/dev/null || true)"', + ' [ "$_sq_owner" = "$_sq_install_id" ] || { echo "squarebox: Install identity mismatch; refusing to start" >&2; return 1; }', + ' if [ "$("${_sq_runtime}" inspect -f ''{{.State.Running}}'' "${_sq_container}" 2>/dev/null)" = true ]; then "${_sq_runtime}" stop "${_sq_container}" >/dev/null; fi', + ' "${_sq_runtime}" start -ai "${_sq_container}"', '}', + 'squarebox() { sqrbx "$@"; }', 'sqrbx-rebuild() { "${_sq_install}/install.sh" "$@"; }', 'squarebox-rebuild() { sqrbx-rebuild "$@"; }', + 'sqrbx-uninstall() { "${_sq_install}/uninstall.sh" "$@"; }', 'squarebox-uninstall() { sqrbx-uninstall "$@"; }' + ) + Remove-Block $GitBashRc '# >>> squarebox >>>' '# <<< squarebox <<<' + Add-Block $GitBashRc @('# >>> squarebox >>>', '[ -f "$HOME/.squarebox-shell-init" ] && . "$HOME/.squarebox-shell-init"', '# <<< squarebox <<<') + foreach ($name in @('INSTALL_DIR', 'WORKSPACE_DIR', 'GIT_CONFIG_DIR')) { $State[$name] = Format-Path $State[$name] GitBash } + $State.SHELL_INIT = $GitBashInit; $State.SHELL_RC = $GitBashRc + } + Write-Atomic $StateFile @($Fields | ForEach-Object { "$_=$($State[$_])" }) +} catch { + foreach ($backup in $backups) { + if ($backup.Exists) { [IO.Directory]::CreateDirectory((Split-Path $backup.Path)) | Out-Null; [IO.File]::Copy($backup.Backup, $backup.Path, $true) } + else { Remove-Item -Force -LiteralPath $backup.Path -ErrorAction SilentlyContinue } + } + throw +} finally { Remove-Item -Recurse -Force -LiteralPath $backupRoot -ErrorAction SilentlyContinue } + +Write-Output "Migrated Squarebox Install identity $($State.INSTALL_ID) from $source to $Target." diff --git a/tests/test-lifecycle-powershell.ps1 b/tests/test-lifecycle-powershell.ps1 index e3c7efa..9b5f544 100755 --- a/tests/test-lifecycle-powershell.ps1 +++ b/tests/test-lifecycle-powershell.ps1 @@ -8,12 +8,13 @@ function Assert-True([bool]$Condition, [string]$Message) { if (-not $Condition) { throw "lifecycle PowerShell regression: $Message" } } -foreach ($name in @('install.ps1', 'uninstall.ps1')) { +foreach ($name in @('install.ps1', 'uninstall.ps1', 'scripts/migrate-windows-adapter.ps1')) { $path = Join-Path $Root $name $tokens = $null; $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) Assert-True ($errors.Count -eq 0) "$name has parser errors: $($errors -join '; ')" + if ($name -ceq 'scripts/migrate-windows-adapter.ps1') { continue } $releaseFunction = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Test-ReleaseTag' @@ -27,6 +28,12 @@ foreach ($name in @('install.ps1', 'uninstall.ps1')) { Assert-True (-not (Test-ReleaseTag ("v1.1.0-" + ('a' * 122)))) "$name accepts a tag longer than 128 characters" } +$migration = [IO.File]::ReadAllText((Join-Path $Root 'scripts/migrate-windows-adapter.ps1')) +Assert-True ($migration.Contains("[ValidateSet('PowerShell', 'GitBash')]")) 'migration command has no closed target set' +Assert-True ($migration.Contains('Box ownership check failed') -and $migration.Contains('Managed-home ownership check failed')) 'migration skips runtime ownership checks' +Assert-True ($migration.Contains('[IO.File]::Move($temp, $Path, $true)')) 'migration does not publish state/profile files atomically' +Assert-True (-not ($migration -match 'Invoke-Expression|\biex\b')) 'migration evaluates Install identity data' + $installTokens = $null; $installErrors = $null $installAst = [System.Management.Automation.Language.Parser]::ParseFile( (Join-Path $Root 'install.ps1'), [ref]$installTokens, [ref]$installErrors) @@ -207,4 +214,114 @@ try { Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue } +# Execute both migration directions with native Windows paths, isolated profile +# files, and a mock runtime. State and profiles must be accepted after each +# conversion without touching a real Box or the runner's user profiles. +$migrationRoot = Join-Path ([IO.Path]::GetTempPath()) "squarebox-migration-test-$([guid]::NewGuid().ToString('N'))" +$migrationHome = Join-Path $migrationRoot 'Üser Home' +$migrationInstall = Join-Path $migrationHome 'Squarebox Space' +$migrationStateDir = Join-Path $migrationInstall '.squarebox' +$migrationState = Join-Path $migrationStateDir 'install-state' +$profileAll = Join-Path $migrationHome 'Documents\PowerShell\profile.ps1' +$profileHost = Join-Path $migrationHome 'Documents\PowerShell\host-profile.ps1' +$gitBashInit = (Join-Path $migrationHome '.squarebox-shell-init').Replace('\', '/') +$gitBashRc = (Join-Path $migrationHome '.bashrc').Replace('\', '/') +$mockBin = Join-Path $migrationRoot 'bin' +[IO.Directory]::CreateDirectory($migrationStateDir) | Out-Null +[IO.Directory]::CreateDirectory($mockBin) | Out-Null +$mockRuntime = Join-Path $mockBin 'mock-runtime.ps1' +[IO.File]::WriteAllText($mockRuntime, @' +$command = $args -join ' ' +if ($command.Contains('{{.Id}}')) { Write-Output ('sha256:' + ('c' * 64)) } +elseif ($command.Contains('io.squarebox.install-id')) { Write-Output 'test-install-123' } +exit 0 +'@, [Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path $mockBin 'docker.cmd'), "@echo off`r`npwsh -NoProfile -File `"%~dp0mock-runtime.ps1`" %*`r`nexit /b %ERRORLEVEL%`r`n", [Text.ASCIIEncoding]::new()) +if (-not $IsWindows) { + $unixRuntime = Join-Path $mockBin 'docker' + [IO.File]::WriteAllText($unixRuntime, @' +#!/usr/bin/env bash +exec pwsh -NoProfile -File "$(dirname "$0")/mock-runtime.ps1" "$@" +'@, [Text.UTF8Encoding]::new($false)) + [IO.File]::SetUnixFileMode($unixRuntime, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute) +} +[IO.File]::WriteAllLines($gitBashInit, @('# squarebox-install-id=test-install-123'), [Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllLines($gitBashRc, @('# >>> squarebox >>>', '[ -f "$HOME/.squarebox-shell-init" ] && . "$HOME/.squarebox-shell-init"', '# <<< squarebox <<<'), [Text.UTF8Encoding]::new($false)) +$migrationValues = [ordered]@{ + FORMAT='1'; INSTALL_ID='test-install-123'; RUNTIME='docker'; INSTALL_DIR=$migrationInstall.Replace('\', '/') + WORKSPACE_DIR=(Join-Path $migrationInstall 'Wørkspace').Replace('\', '/'); GIT_CONFIG_DIR=(Join-Path $migrationInstall '.squarebox\identity\git').Replace('\', '/') + HOME_VOLUME='custom-home'; CONTAINER_NAME='custom.box'; IMAGE_ALIAS='squarebox'; IMAGE_REPOSITORY='ghcr.io/squarewavesystems/squarebox' + IMAGE_REF='ghcr.io/squarewavesystems/squarebox@sha256:' + ('b' * 64); IMAGE_ID='sha256:' + ('c' * 64) + IMAGE_DIGEST='ghcr.io/squarewavesystems/squarebox@sha256:' + ('b' * 64); SOURCE_REF='v1.2.3'; SOURCE_COMMIT='a' * 40 + RELEASE_TAG='v1.2.3'; REQUESTED_TAG='latest'; PUID='1000'; PGID='1000'; BUILD='0'; EDGE='0' + SHELL_INIT=$gitBashInit; SHELL_RC=$gitBashRc; ORIGIN='https://github.com/SquareWaveSystems/squarebox.git'; HOME_VOLUME_ADOPTED='0' +} +[IO.File]::WriteAllLines($migrationState, @($StateFields | ForEach-Object { "$_=$($migrationValues[$_])" }), [Text.UTF8Encoding]::new($false)) +$oldPath = $env:PATH +try { + $env:PATH = "$mockBin$([IO.Path]::PathSeparator)$oldPath" + & pwsh -NoProfile -File (Join-Path $Root 'scripts/migrate-windows-adapter.ps1') -Target PowerShell -InstallDir $migrationInstall ` + -PowerShellProfile $profileAll -PowerShellCurrentHostProfile $profileHost -UserHomePath $migrationHome -Yes + Assert-True ($LASTEXITCODE -eq 0) 'Git Bash to PowerShell migration failed' + $powerState = @{}; Get-Content $migrationState | ForEach-Object { $key, $value = $_ -split '=', 2; $powerState[$key] = $value } + Assert-True ($powerState.SHELL_INIT -ceq $profileAll) 'PowerShell migration published the wrong profile identity' + Assert-True ($powerState.INSTALL_DIR -ceq [IO.Path]::GetFullPath($migrationInstall)) 'PowerShell migration did not normalize INSTALL_DIR' + Assert-True ((Get-Content $profileAll) -ccontains '# squarebox-install-id=test-install-123') 'PowerShell profile ownership was not installed' + Assert-True (-not (Test-Path $gitBashInit)) 'source Git Bash adapter survived migration' + + & pwsh -NoProfile -File (Join-Path $Root 'scripts/migrate-windows-adapter.ps1') -Target GitBash -InstallDir $migrationInstall ` + -PowerShellProfile $profileAll -PowerShellCurrentHostProfile $profileHost -UserHomePath $migrationHome -Yes + Assert-True ($LASTEXITCODE -eq 0) 'PowerShell to Git Bash migration failed' + $bashState = @{}; Get-Content $migrationState | ForEach-Object { $key, $value = $_ -split '=', 2; $bashState[$key] = $value } + Assert-True ($bashState.INSTALL_DIR -ceq $migrationInstall.Replace('\', '/')) 'Git Bash migration did not publish drive-form path spelling' + Assert-True ((Get-Content $gitBashInit) -ccontains '# squarebox-install-id=test-install-123') 'Git Bash adapter ownership was not installed' + + $beforeMalformed = [IO.File]::ReadAllBytes($migrationState) + [IO.File]::AppendAllText($migrationState, "UNKNOWN_FIELD=unsafe`n", [Text.UTF8Encoding]::new($false)) + $malformedState = [IO.File]::ReadAllBytes($migrationState) + & pwsh -NoProfile -File (Join-Path $Root 'scripts/migrate-windows-adapter.ps1') -Target PowerShell -InstallDir $migrationInstall ` + -PowerShellProfile $profileAll -PowerShellCurrentHostProfile $profileHost -UserHomePath $migrationHome -Yes 2>$null + Assert-True ($LASTEXITCODE -ne 0) 'migration accepted malformed Install state' + Assert-True ([Convert]::ToBase64String($malformedState) -ceq [Convert]::ToBase64String([IO.File]::ReadAllBytes($migrationState))) 'malformed migration changed Install state' + [IO.File]::WriteAllBytes($migrationState, $beforeMalformed) + + $beforeForeign = [IO.File]::ReadAllBytes($migrationState) + [IO.File]::WriteAllText($gitBashInit, "# squarebox-install-id=foreign-owner`n", [Text.UTF8Encoding]::new($false)) + & pwsh -NoProfile -File (Join-Path $Root 'scripts/migrate-windows-adapter.ps1') -Target PowerShell -InstallDir $migrationInstall ` + -PowerShellProfile $profileAll -PowerShellCurrentHostProfile $profileHost -UserHomePath $migrationHome -Yes 2>$null + Assert-True ($LASTEXITCODE -ne 0) 'migration accepted a foreign Git Bash adapter' + Assert-True ([Convert]::ToBase64String($beforeForeign) -ceq [Convert]::ToBase64String([IO.File]::ReadAllBytes($migrationState))) 'foreign migration changed Install state' + [IO.File]::WriteAllLines($gitBashInit, @('# squarebox-install-id=test-install-123'), [Text.UTF8Encoding]::new($false)) + + & pwsh -NoProfile -File (Join-Path $Root 'scripts/migrate-windows-adapter.ps1') -Target PowerShell -InstallDir $migrationInstall ` + -PowerShellProfile $profileAll -PowerShellCurrentHostProfile $profileHost -UserHomePath $migrationHome -Yes + Assert-True ($LASTEXITCODE -eq 0) 'Git Bash to PowerShell migration before target uninstall failed' + + $uninstallHarness = Join-Path $migrationRoot 'invoke-target-uninstall.ps1' + [IO.File]::WriteAllText($uninstallHarness, @' +$PROFILE.CurrentUserAllHosts = $env:SQUAREBOX_TEST_PROFILE_ALL +$PROFILE.CurrentUserCurrentHost = $env:SQUAREBOX_TEST_PROFILE_HOST +& $env:SQUAREBOX_TEST_UNINSTALL -InstallDir $env:SQUAREBOX_TEST_INSTALL_DIR -Yes +exit $LASTEXITCODE +'@, [Text.UTF8Encoding]::new($false)) + $oldUserProfile = $env:USERPROFILE + $env:USERPROFILE = $migrationHome + $env:SQUAREBOX_TEST_PROFILE_ALL = $profileAll + $env:SQUAREBOX_TEST_PROFILE_HOST = $profileHost + $env:SQUAREBOX_TEST_UNINSTALL = Join-Path $Root 'uninstall.ps1' + $env:SQUAREBOX_TEST_INSTALL_DIR = $migrationInstall + try { + & pwsh -NoProfile -File $uninstallHarness + Assert-True ($LASTEXITCODE -eq 0) 'PowerShell uninstaller rejected migrated Install state' + Assert-True (-not ((Get-Content $profileAll) -ccontains '# squarebox-install-id=test-install-123')) 'target uninstaller did not remove migrated adapter' + } finally { + $env:USERPROFILE = $oldUserProfile + Remove-Item Env:SQUAREBOX_TEST_PROFILE_ALL, Env:SQUAREBOX_TEST_PROFILE_HOST, Env:SQUAREBOX_TEST_UNINSTALL, Env:SQUAREBOX_TEST_INSTALL_DIR -ErrorAction SilentlyContinue + } + $global:LASTEXITCODE = 0 +} finally { + $env:PATH = $oldPath + Remove-Item -LiteralPath $migrationRoot -Recurse -Force -ErrorAction SilentlyContinue +} + Write-Output "ok - native PowerShell lifecycle syntax, safety, and $($cases.Count) shared state fixtures" diff --git a/uat-checklist.md b/uat-checklist.md index 7d557f4..7cd610a 100644 --- a/uat-checklist.md +++ b/uat-checklist.md @@ -8,6 +8,17 @@ qualification matrix is not required for this release. Native PowerShell remains a separate adapter and does not claim `SSH_AUTH_SOCK` forwarding; adapter boundaries are covered by automated/static checks. +## Windows adapter migration + +- [ ] Install with Git Bash under a custom path containing spaces and non-ASCII + characters; migrate to PowerShell, rebuild, start, and uninstall natively. +- [ ] Install with PowerShell; migrate to Git Bash, rebuild, start, and uninstall + there. Repeat both directions with Docker Desktop and Podman. +- [ ] Malformed, duplicate-field, foreign-owner, and unexpected-profile state + fails without changing state or either profile. +- [ ] Migration changes no Box/image identity or Workspace, Managed-home, or SSH + content. + Record the Candidate version, source SHA, image digest, and result for any optional follow-up run.