diff --git a/docs/install/local.md b/docs/install/local.md index bf46b48a..56562284 100644 --- a/docs/install/local.md +++ b/docs/install/local.md @@ -245,90 +245,158 @@ selected by the durable, installation-specific `current-image.yaml` after every Therefore rebuilding `thothii-core:local` followed by `update --check-only` does not reconcile a previous `pi update`: the old promoted core would remain selected. -Do not delete or edit the selector. The supported source-update path is a transactional -`pi update --source build` from a clean pulled checkout whose `docker/core.Dockerfile` pins a -different Pi version than the running installation. `thothctl` currently treats the same requested -Pi version as a no-op. The explicit comparison below therefore stops instead of silently deploying -only part of a revision. If it stops, keep the current installation running and wait for a release -with a new Pi pin or a future supported reconciliation command; there is no supported manual -same-version selector-removal procedure. +Do not delete or edit the selector. `thothctl status` is the installation-aware selector test. If +the running core image is the base `thothii-core:local` image, no Pi update has promoted a durable +lifecycle image and an ordinary same-Pi-version source rebuild/start is supported. If status shows +a lifecycle image and the pulled Pi pin is unchanged, `pi update` would be a no-op and the procedure +must stop. A changed Pi pin uses transactional `pi update --source build` in either case. macOS, Linux, and WSL2: ```sh -git status --short -git diff --quiet -git diff --cached --quiet -git pull --ff-only -git config --local core.autocrlf false -bash scripts/verify-line-endings.sh -SOURCE_REVISION="$(git rev-parse HEAD)" -NEXT_PI_VERSION="$(sed -n 's/^ARG PI_VERSION=//p' docker/core.Dockerfile)" -RUNNING_PI_VERSION="$("$THTCTL" --installation "$INSTALLATION" pi status)" -RUNNING_PI_VERSION="${RUNNING_PI_VERSION#Pi version: }" -if [[ -z "$NEXT_PI_VERSION" || "$NEXT_PI_VERSION" == "$RUNNING_PI_VERSION" ]]; then - echo "Source update stopped: the pulled revision must pin a new Pi version." >&2 - exit 1 +set -euo pipefail + +abort_update() { printf 'Source update stopped: %s\n' "$1" >&2; exit 1; } +require_clean_source() { + local source_state + if ! source_state="$(git status --porcelain --untracked-files=all)"; then + abort_update "git status failed" + fi + [[ -z "$source_state" ]] || abort_update "commit, remove, or back up every tracked/untracked source change" +} + +require_clean_source +if ! git pull --ff-only; then abort_update "git pull --ff-only failed"; fi +require_clean_source +if ! git config --local core.autocrlf false; then abort_update "could not set repository LF policy"; fi +if ! bash scripts/verify-line-endings.sh; then abort_update "the pulled checkout contains CRLF files"; fi +if ! SOURCE_REVISION="$(git rev-parse HEAD)"; then abort_update "could not record the pulled revision"; fi +if ! NEXT_PI_VERSION="$(sed -n 's/^ARG PI_VERSION=//p' docker/core.Dockerfile)"; then + abort_update "could not read the pulled Pi pin" fi -bash scripts/build-local.sh -bash scripts/build-thothctl.sh -"$THTCTL" --installation "$INSTALLATION" update --check-only -"$THTCTL" --installation "$INSTALLATION" pi update \ - --version "$NEXT_PI_VERSION" --source build --yes --drain -"$THTCTL" --installation "$INSTALLATION" start -curl --fail http://127.0.0.1:8080/health -curl --fail http://127.0.0.1:8787/health -printf 'Built source revision: %s\n' "$SOURCE_REVISION" -"$THTCTL" --installation "$INSTALLATION" status -"$THTCTL" --installation "$INSTALLATION" pi status -"$THTCTL" --installation "$INSTALLATION" doctor +[[ -n "$NEXT_PI_VERSION" && "$NEXT_PI_VERSION" != *$'\n'* ]] || abort_update "expected one pinned default PI_VERSION" +if ! INSTALLATION_STATUS="$("$THTCTL" --installation "$INSTALLATION" status)"; then + abort_update "thothctl status failed" +fi +if ! RUNNING_PI_VERSION="$("$THTCTL" --installation "$INSTALLATION" pi status)"; then + abort_update "thothctl pi status failed" +fi +RUNNING_PI_VERSION="${RUNNING_PI_VERSION#Pi version: }" +[[ -n "$RUNNING_PI_VERSION" ]] || abort_update "thothctl pi status returned no version" + +COMPACT_STATUS="${INSTALLATION_STATUS//[[:space:]]/}" +USES_BASE_CORE=false +if [[ "$COMPACT_STATUS" == *'"Image":"thothii-core:local"'* ]]; then + USES_BASE_CORE=true +fi +TRANSACTIONAL_PI_UPDATE=true +if [[ "$NEXT_PI_VERSION" == "$RUNNING_PI_VERSION" ]]; then + [[ "$USES_BASE_CORE" == true ]] || abort_update "same Pi version is selected by a durable lifecycle image" + TRANSACTIONAL_PI_UPDATE=false +fi + +if ! bash scripts/build-local.sh; then abort_update "the local image build failed"; fi +if ! bash scripts/build-thothctl.sh; then abort_update "the thothctl build failed"; fi +if ! "$THTCTL" --installation "$INSTALLATION" update --check-only; then + abort_update "the installation render check failed" +fi +if [[ "$TRANSACTIONAL_PI_UPDATE" == true ]]; then + if ! "$THTCTL" --installation "$INSTALLATION" pi update \ + --version "$NEXT_PI_VERSION" --source build --yes --drain; then + abort_update "the transactional core update failed" + fi +fi +if ! "$THTCTL" --installation "$INSTALLATION" start; then abort_update "installation start failed"; fi +if ! curl --fail http://127.0.0.1:8080/health; then abort_update "frontend health check failed"; fi +if ! curl --fail http://127.0.0.1:8787/health; then abort_update "core health check failed"; fi +if ! FINAL_STATUS="$("$THTCTL" --installation "$INSTALLATION" status)"; then abort_update "final status failed"; fi +if ! FINAL_PI_STATUS="$("$THTCTL" --installation "$INSTALLATION" pi status)"; then abort_update "final pi status failed"; fi +[[ "${FINAL_PI_STATUS#Pi version: }" == "$NEXT_PI_VERSION" ]] || abort_update "running Pi version does not match the pulled pin" +if ! "$THTCTL" --installation "$INSTALLATION" doctor; then abort_update "final doctor failed"; fi +require_clean_source +printf 'Built source revision: %s\n%s\n%s\n' "$SOURCE_REVISION" "$FINAL_STATUS" "$FINAL_PI_STATUS" ``` Native Windows PowerShell uses the same fail-closed version comparison and transactional promotion: ```powershell -git status --short -git diff --quiet -if ($LASTEXITCODE -ne 0) { throw 'Commit or back up tracked source changes before update.' } -git diff --cached --quiet -if ($LASTEXITCODE -ne 0) { throw 'Commit or back up staged source changes before update.' } +$ErrorActionPreference = 'Stop' +function Assert-NativeSuccess([string]$Step) { + if ($LASTEXITCODE -ne 0) { throw "$Step failed with exit code $LASTEXITCODE." } +} +function Assert-CleanSource { + $SourceState = @(git status --porcelain --untracked-files=all) + Assert-NativeSuccess 'git status' + if ($SourceState.Count -ne 0) { + throw 'Commit, remove, or back up every tracked/untracked source change.' + } +} + +Assert-CleanSource git pull --ff-only -if ($LASTEXITCODE -ne 0) { throw 'The source pull failed.' } +Assert-NativeSuccess 'source pull' +Assert-CleanSource git config --local core.autocrlf false +Assert-NativeSuccess 'repository LF policy' & "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh -if ($LASTEXITCODE -ne 0) { throw 'The pulled checkout contains CRLF files.' } +Assert-NativeSuccess 'pulled checkout LF verification' $SourceRevision = git rev-parse HEAD +Assert-NativeSuccess 'source revision read' $VersionLine = @(Select-String -Path docker/core.Dockerfile -Pattern '^ARG PI_VERSION=(.+)$') if ($VersionLine.Count -ne 1) { throw 'Expected exactly one pinned default PI_VERSION.' } $NextPiVersion = $VersionLine.Matches[0].Groups[1].Value -$RunningPiVersion = (& $THTCTL --installation $INSTALLATION pi status) ` - -replace '^Pi version:\s*', '' -if ([string]::IsNullOrWhiteSpace($NextPiVersion) -or $NextPiVersion -eq $RunningPiVersion) { - throw 'Source update stopped: the pulled revision must pin a new Pi version.' +$InstallationStatus = @(& $THTCTL --installation $INSTALLATION status) +Assert-NativeSuccess 'installation status' +$RunningPiStatus = (& $THTCTL --installation $INSTALLATION pi status) +Assert-NativeSuccess 'Pi status' +$RunningPiVersion = $RunningPiStatus -replace '^Pi version:\s*', '' +if ([string]::IsNullOrWhiteSpace($RunningPiVersion)) { throw 'Pi status returned no version.' } +$Services = $InstallationStatus | ConvertFrom-Json +$CoreServices = @($Services | Where-Object { $_.Service -eq 'core' }) +if ($CoreServices.Count -ne 1) { throw 'Installation status did not identify exactly one core service.' } +$UsesBaseCore = $CoreServices[0].Image -eq 'thothii-core:local' +$TransactionalPiUpdate = $true +if ($NextPiVersion -eq $RunningPiVersion) { + if (-not $UsesBaseCore) { throw 'Same Pi version is selected by a durable lifecycle image.' } + $TransactionalPiUpdate = $false } powershell -ExecutionPolicy Bypass -File scripts/build-local.ps1 -if ($LASTEXITCODE -ne 0) { throw 'The local image build failed.' } +Assert-NativeSuccess 'local image build' & "C:\Program Files\Git\bin\bash.exe" scripts/build-thothctl.sh -if ($LASTEXITCODE -ne 0) { throw 'The thothctl build failed.' } +Assert-NativeSuccess 'thothctl build' & $THTCTL --installation $INSTALLATION update --check-only -if ($LASTEXITCODE -ne 0) { throw 'The installation render check failed.' } -& $THTCTL --installation $INSTALLATION pi update ` - --version $NextPiVersion --source build --yes --drain -if ($LASTEXITCODE -ne 0) { throw 'The transactional core update failed.' } +Assert-NativeSuccess 'installation render check' +if ($TransactionalPiUpdate) { + & $THTCTL --installation $INSTALLATION pi update ` + --version $NextPiVersion --source build --yes --drain + Assert-NativeSuccess 'transactional core update' +} & $THTCTL --installation $INSTALLATION start -if ($LASTEXITCODE -ne 0) { throw 'The installation start failed.' } +Assert-NativeSuccess 'installation start' curl.exe --fail --silent --show-error http://127.0.0.1:8080/health +Assert-NativeSuccess 'frontend health check' curl.exe --fail --silent --show-error http://127.0.0.1:8787/health -Write-Output "Built source revision: $SourceRevision" -& $THTCTL --installation $INSTALLATION status -& $THTCTL --installation $INSTALLATION pi status +Assert-NativeSuccess 'core health check' +$FinalStatus = @(& $THTCTL --installation $INSTALLATION status) +Assert-NativeSuccess 'final installation status' +$FinalPiStatus = (& $THTCTL --installation $INSTALLATION pi status) +Assert-NativeSuccess 'final Pi status' +if (($FinalPiStatus -replace '^Pi version:\s*', '') -ne $NextPiVersion) { + throw 'Running Pi version does not match the pulled pin.' +} & $THTCTL --installation $INSTALLATION doctor +Assert-NativeSuccess 'final doctor' +Assert-CleanSource +Write-Output "Built source revision: $SourceRevision" +Write-Output $FinalStatus +Write-Output $FinalPiStatus ``` -The recorded Git revision identifies the clean worktree used for the candidate build. In -`thothctl status`, confirm that `core` reports the installation lifecycle candidate image, then -require `pi status` to equal the new pin and `doctor` to pass. This is the supported running-image -and source-revision evidence; `update --check-only` alone proves only that Compose renders. +The revision is printed only after every source/build/start/health/installation-aware check passes +and a final porcelain check still reports no tracked or untracked source changes. For a changed Pi +pin, status reports the promoted lifecycle candidate; for a same-version installation with no +selector, status reports the rebuilt base core. `update --check-only` alone proves only that Compose +renders. Review release notes before updating. See [Pi management](pi-management.md) for rollback; never install a package in the running container. diff --git a/docs/install/windows-line-endings.md b/docs/install/windows-line-endings.md index 4a1582c0..78edbcbe 100644 --- a/docs/install/windows-line-endings.md +++ b/docs/install/windows-line-endings.md @@ -77,53 +77,174 @@ worktree bytes. From WSL2, Git Bash, macOS, or Linux: ```sh -git status --short -git config --local core.autocrlf false -git add --renormalize . -git diff --cached --check -git diff --cached +set -euo pipefail + +abort_repair() { printf 'CRLF repair stopped: %s\n' "$1" >&2; exit 1; } +validate_index_export() { + git ls-files -s -z | while IFS= read -r -d '' entry; do + metadata="${entry%%$'\t'*}" + path="${entry#*$'\t'}" + mode="${metadata%% *}" + [[ "$path" != "$entry" ]] || exit 1 + case "$mode" in + 100644|100755) [[ -f "$REPAIR_DIR/$path" && ! -L "$REPAIR_DIR/$path" ]] || exit 1 ;; + 120000) [[ -L "$REPAIR_DIR/$path" ]] && readlink "$REPAIR_DIR/$path" >/dev/null || exit 1 ;; + *) printf 'Unsupported Git mode %s: %s\n' "$mode" "$path" >&2; exit 1 ;; + esac + done +} +validate_worktree_modes() { + git ls-files -s -z | while IFS= read -r -d '' entry; do + metadata="${entry%%$'\t'*}" + path="${entry#*$'\t'}" + mode="${metadata%% *}" + case "$mode" in + 100644|100755) [[ -f "$path" && ! -L "$path" ]] || exit 1 ;; + 120000) [[ -L "$path" ]] && readlink "$path" >/dev/null || exit 1 ;; + *) exit 1 ;; + esac + done +} +rewrite_index_entry() { + local mode="$1" path="$2" target temporary_link + case "$mode" in + 100644) + cp "$REPAIR_DIR/$path" "$path" && chmod a-x "$path" + ;; + 100755) + cp "$REPAIR_DIR/$path" "$path" && chmod a+x "$path" + ;; + 120000) + target="$(readlink "$REPAIR_DIR/$path")" || return 1 + temporary_link="${path}.thoth-lf-repair-link" + [[ ! -e "$temporary_link" && ! -L "$temporary_link" ]] || return 1 + ln -s "$target" "$temporary_link" || return 1 + rm -f "$path" || { rm -f "$temporary_link"; return 1; } + mv "$temporary_link" "$path" + ;; + *) return 1 ;; + esac +} + +if ! git status --short; then abort_repair "git status failed"; fi +if ! git config --local core.autocrlf false; then abort_repair "could not set repository LF policy"; fi +if ! git add --renormalize .; then abort_repair "index renormalization failed"; fi +if ! git diff --cached --check; then abort_repair "normalized index check failed"; fi +if ! git diff --cached; then abort_repair "normalized index review failed"; fi REPAIR_DIR="$(cd .. && pwd -P)/ThothII-lf-repair" if [[ -e "$REPAIR_DIR" ]]; then - echo "Choose a new empty LF repair directory: $REPAIR_DIR" >&2 - exit 1 + abort_repair "choose a new empty LF repair directory: $REPAIR_DIR" fi -mkdir -p "$REPAIR_DIR" +if ! mkdir -p "$REPAIR_DIR"; then abort_repair "could not create LF repair directory"; fi REPAIR_PREFIX="$REPAIR_DIR/" -git checkout-index --all --force --prefix="$REPAIR_PREFIX" -bash scripts/verify-line-endings.sh "$REPAIR_DIR" +if ! git checkout-index --all --force --prefix="$REPAIR_PREFIX"; then abort_repair "index export failed"; fi +if ! validate_index_export; then abort_repair "index export is missing entries or Git modes"; fi +if ! bash scripts/verify-line-endings.sh "$REPAIR_DIR"; then abort_repair "exported bytes failed LF verification"; fi # WARNING: destructive copy; make a backup or commit wanted changes before this command. -git ls-files -z | while IFS= read -r -d '' path; do - cp "$REPAIR_DIR/$path" "$path" -done -bash scripts/verify-line-endings.sh +if ! git ls-files -s -z | while IFS= read -r -d '' entry; do + metadata="${entry%%$'\t'*}" + path="${entry#*$'\t'}" + mode="${metadata%% *}" + rewrite_index_entry "$mode" "$path" || exit 1 +done; then + abort_repair "tracked-file rewrite failed; do not build from this worktree" +fi +if ! validate_worktree_modes; then abort_repair "repaired worktree does not match Git index modes"; fi +if ! bash scripts/verify-line-endings.sh; then abort_repair "repaired worktree failed LF verification"; fi +if ! git diff --cached --check; then abort_repair "repaired index check failed"; fi ``` Native Windows PowerShell runs the same Git operations and invokes the byte verifier through Git for Windows: ```powershell +$ErrorActionPreference = 'Stop' +function Assert-NativeSuccess([string]$Step) { + if ($LASTEXITCODE -ne 0) { throw "$Step failed with exit code $LASTEXITCODE." } +} +function ConvertFrom-IndexEntry([string]$Entry) { + if ($Entry -notmatch '^([0-9]{6}) [0-9a-f]+ [0-3]\t(.+)$') { + throw "Invalid Git index entry: $Entry" + } + [pscustomobject]@{ Mode = $Matches[1]; Path = $Matches[2] } +} + git status --short +Assert-NativeSuccess 'git status' git config --local core.autocrlf false +Assert-NativeSuccess 'repository LF policy' git add --renormalize . +Assert-NativeSuccess 'index renormalization' git diff --cached --check +Assert-NativeSuccess 'normalized index check' git diff --cached +Assert-NativeSuccess 'normalized index review' $RepairDir = Join-Path (Split-Path -Parent (Get-Location).Path) 'ThothII-lf-repair' if (Test-Path $RepairDir) { throw 'Choose a new empty LF repair directory.' } New-Item -ItemType Directory -Path $RepairDir | Out-Null $RepairPrefix = $RepairDir.Replace('\', '/') + '/' -git checkout-index --all --force --prefix=$RepairPrefix +git -c core.symlinks=true checkout-index --all --force --prefix=$RepairPrefix +Assert-NativeSuccess 'index export' +$RawIndexEntries = @(git ls-files -s) +Assert-NativeSuccess 'index inventory' +$IndexEntries = @($RawIndexEntries | ForEach-Object { ConvertFrom-IndexEntry $_ }) +foreach ($Entry in $IndexEntries) { + $ExportPath = Join-Path $RepairDir $Entry.Path + $ExportItem = Get-Item -LiteralPath $ExportPath -Force -ErrorAction Stop + switch ($Entry.Mode) { + { $_ -in '100644', '100755' } { + if ($ExportItem.LinkType -eq 'SymbolicLink') { throw "Regular export became a symlink: $($Entry.Path)" } + } + '120000' { + if ($ExportItem.LinkType -ne 'SymbolicLink') { throw "Symlink export is not mode 120000: $($Entry.Path)" } + if ([string]::IsNullOrWhiteSpace([string]$ExportItem.Target)) { throw "Symlink target is empty: $($Entry.Path)" } + } + default { throw "Unsupported Git mode $($Entry.Mode): $($Entry.Path)" } + } +} & "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh $RepairDir -if ($LASTEXITCODE -ne 0) { throw 'The staged index export does not satisfy the LF policy.' } +Assert-NativeSuccess 'exported byte LF verification' # WARNING: destructive copy; make a backup or commit wanted changes before this command. -git ls-files | ForEach-Object { - Copy-Item -LiteralPath (Join-Path $RepairDir $_) -Destination $_ -Force +foreach ($Entry in $IndexEntries) { + $ExportPath = Join-Path $RepairDir $Entry.Path + switch ($Entry.Mode) { + { $_ -in '100644', '100755' } { + Copy-Item -LiteralPath $ExportPath -Destination $Entry.Path -Force -ErrorAction Stop + } + '120000' { + $LinkTarget = [string](Get-Item -LiteralPath $ExportPath -Force -ErrorAction Stop).Target + $TemporaryLink = "$($Entry.Path).thoth-lf-repair-link" + if (Test-Path -LiteralPath $TemporaryLink) { throw "Temporary symlink path exists: $TemporaryLink" } + New-Item -ItemType SymbolicLink -Path $TemporaryLink -Target $LinkTarget -ErrorAction Stop | Out-Null + Remove-Item -LiteralPath $Entry.Path -Force -ErrorAction Stop + Move-Item -LiteralPath $TemporaryLink -Destination $Entry.Path -ErrorAction Stop + } + default { throw "Unsupported Git mode $($Entry.Mode): $($Entry.Path)" } + } +} +foreach ($Entry in $IndexEntries) { + $WorktreeItem = Get-Item -LiteralPath $Entry.Path -Force -ErrorAction Stop + switch ($Entry.Mode) { + { $_ -in '100644', '100755' } { + if ($WorktreeItem.LinkType -eq 'SymbolicLink') { throw "Regular worktree entry became a symlink: $($Entry.Path)" } + } + '120000' { + if ($WorktreeItem.LinkType -ne 'SymbolicLink') { throw "Repaired worktree symlink is not mode 120000: $($Entry.Path)" } + if ([string]::IsNullOrWhiteSpace([string]$WorktreeItem.Target)) { throw "Repaired symlink target is empty: $($Entry.Path)" } + } + default { throw "Unsupported Git mode $($Entry.Mode): $($Entry.Path)" } + } } & "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh -if ($LASTEXITCODE -ne 0) { throw 'Tracked worktree bytes were not repaired to the LF policy.' } +Assert-NativeSuccess 'repaired worktree LF verification' +git diff --cached --check +Assert-NativeSuccess 'repaired index check' ``` -The first verifier proves the exported index bytes before any overwrite; the final verifier -examines the repaired worktree bytes and must also exit `0`. Review the staged diff again before -committing, then remove the separate repair directory only after inspecting it. The procedure -intentionally avoids `git reset --hard`; replacing the clone is easier to audit and much safer for -uncommitted work. +The export inventory must contain every regular mode (`100644`/`100755`) and recreate every tracked +workspace compatibility symlink (`120000`). The first verifier proves the complete +export before any overwrite; every copy/link operation is fail-closed; the final verifier examines +the repaired worktree bytes. On native Windows, creating symlinks requires Developer Mode or an +elevated account; failure stops the rewrite. Review the staged diff again before committing, then +remove the separate repair directory only after inspecting it. The procedure intentionally avoids +`git reset --hard`; replacing the clone is easier to audit and safer for uncommitted work. diff --git a/scripts/test-verify-workspace-install-docs.sh b/scripts/test-verify-workspace-install-docs.sh index 1e09adc4..29ff5692 100755 --- a/scripts/test-verify-workspace-install-docs.sh +++ b/scripts/test-verify-workspace-install-docs.sh @@ -12,6 +12,7 @@ trap 'rm -f "$output" "$verifier_functions"; rm -rf "$negative_root"' EXIT HUP I for fixture in \ "local installation guide contract" \ + "source update fail-closed semantics" \ "Windows line-ending recovery guide contract" \ "Pi management guide contract" \ "local installation example rendered from path with spaces" \ @@ -20,7 +21,7 @@ for fixture in \ "canonical local base+override fixture" \ "canonical server base+override fixture" \ "relative secret-source fixture rejected" \ - "CRLF recovery rewrites worktree bytes"; do + "CRLF recovery rewrites bytes and preserves mode-120000 symlinks"; do grep -Fqx "$fixture passed" "$output" >/dev/null || { echo "missing fixture verification: $fixture" >&2 cat "$output" >&2 @@ -66,6 +67,10 @@ expect_guide_rejected() { local fixture_output="$fixture_root/output" mkdir -p "$fixture_root/$(dirname "$relative_path")" cp "$source_guide" "$fixture_root/$relative_path" + if [[ "$validator" == verify_windows_line_endings_guide ]]; then + mkdir -p "$fixture_root/scripts" + cp "$root/scripts/verify-line-endings.sh" "$fixture_root/scripts/verify-line-endings.sh" + fi node - "$fixture_root/$relative_path" "$mutation" <<'NODE' const fs = require("fs"); const [path, mutation] = process.argv.slice(2); @@ -87,6 +92,36 @@ switch (mutation) { case "raw-pi": changed += "\n```sh\ndocker compose exec core pi --version\n```\n"; break; + case "dirty-source": + changed = original.replaceAll("git status --porcelain --untracked-files=all", "git status --short"); + break; + case "failed-pull": + changed = original.replace("if ! git pull --ff-only; then abort_update", "if git pull --ff-only; then abort_update"); + break; + case "failed-status": + changed = original.replace("if ! RUNNING_PI_VERSION=", "if RUNNING_PI_VERSION="); + break; + case "failed-build": + changed = original.replace("if ! bash scripts/build-local.sh; then", "if bash scripts/build-local.sh; then"); + break; + case "same-version-no-selector": + changed = original.replace("TRANSACTIONAL_PI_UPDATE=false", "TRANSACTIONAL_PI_UPDATE=true # unsafe same-version no-op"); + break; + case "powershell-source-failure": + changed = original.replace("Assert-NativeSuccess 'Pi status'", "Write-Output 'Pi status unchecked'"); + break; + case "failed-export": + changed = original.replace("if ! git checkout-index --all --force", "if git checkout-index --all --force"); + break; + case "partial-export": + changed = original.replace("if ! validate_index_export; then", "if validate_index_export; then"); + break; + case "mode-120000": + changed = original.replaceAll("120000", "100644-no-symlink-mode"); + break; + case "powershell-crlf-failure": + changed = original.replace("Assert-NativeSuccess 'index export'", "Write-Output 'index export unchecked'"); + break; default: throw new Error(`unknown negative-fixture mutation: ${mutation}`); } @@ -129,6 +164,47 @@ expect_guide_rejected \ "$root/docs/install/pi-management.md" docs/install/pi-management.md raw-pi \ "raw non-installation-aware Compose Pi access is forbidden" +expect_guide_rejected \ + "dirty or untracked source tree" verify_local_guide \ + "$root/docs/install/local.md" docs/install/local.md dirty-source \ + "installation-aware source update lacks structural token: git status --porcelain --untracked-files=all" +expect_guide_rejected \ + "failed source pull" verify_local_guide \ + "$root/docs/install/local.md" docs/install/local.md failed-pull \ + "POSIX source update does not fail closed: source pull" +expect_guide_rejected \ + "failed thothctl Pi status" verify_local_guide \ + "$root/docs/install/local.md" docs/install/local.md failed-status \ + "POSIX source update does not fail closed: Pi status" +expect_guide_rejected \ + "failed local build" verify_local_guide \ + "$root/docs/install/local.md" docs/install/local.md failed-build \ + "POSIX source update does not fail closed: local build" +expect_guide_rejected \ + "same Pi version without durable selector" verify_local_guide \ + "$root/docs/install/local.md" docs/install/local.md same-version-no-selector \ + "POSIX source update lacks the same-version/no-selector path" +expect_guide_rejected \ + "PowerShell source command failure propagation" verify_local_guide \ + "$root/docs/install/local.md" docs/install/local.md powershell-source-failure \ + "PowerShell source update does not propagate failure: Pi status" +expect_guide_rejected \ + "failed index export" verify_windows_line_endings_guide \ + "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md failed-export \ + "POSIX CRLF repair lacks fail-closed semantic: if ! git checkout-index" +expect_guide_rejected \ + "partial index export" verify_windows_line_endings_guide \ + "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md partial-export \ + "POSIX CRLF repair does not prove a complete export before destructive rewrite" +expect_guide_rejected \ + "mode 120000 symlink preservation" verify_windows_line_endings_guide \ + "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md mode-120000 \ + "POSIX CRLF repair lacks fail-closed semantic: 120000" +expect_guide_rejected \ + "PowerShell CRLF command failure propagation" verify_windows_line_endings_guide \ + "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md powershell-crlf-failure \ + "PowerShell CRLF repair lacks failure propagation: Assert-NativeSuccess 'index export'" + if (( negative_failures != 0 )); then echo "$negative_failures unsafe installation-document fixtures were accepted" >&2 exit 1 diff --git a/scripts/verify-workspace-install-docs.sh b/scripts/verify-workspace-install-docs.sh index dce822c1..7bb9ed68 100755 --- a/scripts/verify-workspace-install-docs.sh +++ b/scripts/verify-workspace-install-docs.sh @@ -123,6 +123,10 @@ function requireTokens(label, text, tokens) { } } +function requirePattern(label, text, pattern) { + if (!pattern.test(text)) throw new Error(label); +} + let inCodeFence = false; for (const line of source.split(/\n/)) { if (line.trimStart().startsWith("```")) { @@ -151,18 +155,62 @@ requireTokens("native PowerShell health", healthPowerShell, [ const updateShell = blocks("Update an installation", "sh").join("\n"); requireTokens("installation-aware source update", updateShell, [ "NEXT_PI_VERSION", "RUNNING_PI_VERSION", "--source build", "git rev-parse HEAD", - "pi status", "status", "doctor", "curl --fail", + "pi status", "status", "doctor", "curl --fail", "set -euo pipefail", + "git status --porcelain --untracked-files=all", "USES_BASE_CORE", "thothii-core:local", ]); -if (!updateShell.includes("NEXT_PI_VERSION\" == \"$RUNNING_PI_VERSION") || - !updateShell.includes("exit 1")) { - throw new Error("source update must fail closed when thothctl would no-op on the current Pi version"); +if (/\|\|\s*true|;\s*true\b/.test(updateShell)) throw new Error("POSIX source update contains a failure-bypass command"); +requirePattern("POSIX source update does not fail closed: source pull", updateShell, + /if ! git pull --ff-only; then abort_update/); +requirePattern("POSIX source update does not fail closed: installation status", updateShell, + /if ! INSTALLATION_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" status\)"; then/); +requirePattern("POSIX source update does not fail closed: Pi status", updateShell, + /if ! RUNNING_PI_VERSION="\$\("\$THTCTL" --installation "\$INSTALLATION" pi status\)"; then/); +requirePattern("POSIX source update does not fail closed: local build", updateShell, + /if ! bash scripts\/build-local\.sh; then/); +requirePattern("POSIX source update does not fail closed: thothctl build", updateShell, + /if ! bash scripts\/build-thothctl\.sh; then/); +requirePattern("POSIX source update lacks the same-version/no-selector path", updateShell, + /if \[\[ "\$NEXT_PI_VERSION" == "\$RUNNING_PI_VERSION" \]\]; then[\s\S]*"\$USES_BASE_CORE" == true[\s\S]*TRANSACTIONAL_PI_UPDATE=false/); +for (const [label, pattern] of [ + ["installation start", /if ! "\$THTCTL" --installation "\$INSTALLATION" start; then/], + ["frontend health", /if ! curl --fail http:\/\/127\.0\.0\.1:8080\/health; then/], + ["core health", /if ! curl --fail http:\/\/127\.0\.0\.1:8787\/health; then/], + ["final status", /if ! FINAL_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" status\)"; then/], + ["final Pi status", /if ! FINAL_PI_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" pi status\)"; then/], + ["final doctor", /if ! "\$THTCTL" --installation "\$INSTALLATION" doctor; then/], +]) requirePattern(`POSIX source update does not fail closed: ${label}`, updateShell, pattern); +const provenance = updateShell.indexOf("printf 'Built source revision:"); +if (provenance < updateShell.lastIndexOf("require_clean_source") || + provenance < updateShell.indexOf('abort_update "final doctor failed"')) { + throw new Error("POSIX source revision provenance is printed before final checks"); } const updatePowerShell = blocks("Update an installation", "powershell").join("\n"); requireTokens("native PowerShell source update", updatePowerShell, [ "$NextPiVersion", "$RunningPiVersion", "--source build", "git rev-parse HEAD", - "pi status", "status", "doctor", "curl.exe --fail", "throw", + "pi status", "status", "doctor", "curl.exe --fail", "throw", "$ErrorActionPreference = 'Stop'", + "git status --porcelain --untracked-files=all", "$UsesBaseCore", "thothii-core:local", + "$TransactionalPiUpdate = $false", ]); +for (const [command, step] of [ + ["git pull --ff-only", "source pull"], + ["$InstallationStatus = @(& $THTCTL --installation $INSTALLATION status)", "installation status"], + ["$RunningPiStatus = (& $THTCTL --installation $INSTALLATION pi status)", "Pi status"], + ["powershell -ExecutionPolicy Bypass -File scripts/build-local.ps1", "local image build"], + ["& \"C:\\Program Files\\Git\\bin\\bash.exe\" scripts/build-thothctl.sh", "thothctl build"], + ["curl.exe --fail --silent --show-error http://127.0.0.1:8080/health", "frontend health check"], + ["curl.exe --fail --silent --show-error http://127.0.0.1:8787/health", "core health check"], + ["$FinalPiStatus = (& $THTCTL --installation $INSTALLATION pi status)", "final Pi status"], + ["& $THTCTL --installation $INSTALLATION doctor", "final doctor"], +]) { + const commandAt = updatePowerShell.indexOf(command); + const checkAt = updatePowerShell.indexOf(`Assert-NativeSuccess '${step}'`, commandAt); + if (commandAt < 0 || checkAt < commandAt || checkAt - commandAt > 220) { + throw new Error(`PowerShell source update does not propagate failure: ${step}`); + } +} +requirePattern("PowerShell source update lacks the same-version/no-selector path", updatePowerShell, + /if \(\$NextPiVersion -eq \$RunningPiVersion\) \{[\s\S]*-not \$UsesBaseCore[\s\S]*\$TransactionalPiUpdate = \$false/); const backupPowerShell = blocks("Back up and restore", "powershell").join("\n"); requireTokens("native PowerShell backup/restore", backupPowerShell, [ @@ -176,6 +224,81 @@ for (const block of [setupPowerShell, healthPowerShell, updatePowerShell, backup } } NODE + local update_fixture update_script fake_bin calls output status + update_fixture="$(mktemp -d "${TMPDIR%/}/thoth-source-update.XXXXXX")" + trap 'rm -rf "$update_fixture"' RETURN + update_script="$update_fixture/update.sh" + awk ' + /^## Update an installation$/ { in_section=1; next } + in_section && /^```sh$/ { in_code=1; next } + in_code && /^```$/ { exit } + in_code { print } + ' "$guide" >"$update_script" + chmod 0700 "$update_script" + mkdir -p "$update_fixture/project/docker" "$update_fixture/project/scripts" "$update_fixture/bin" + printf 'ARG PI_VERSION=0.80.3\n' >"$update_fixture/project/docker/core.Dockerfile" + printf '%s\n' \ + '#!/bin/sh' \ + 'printf "git %s\n" "$*" >>"$CALLS"' \ + 'case "$1" in' \ + ' status) if [ "$FAIL_STEP" = dirty ]; then printf "?? untracked-build-context\n"; fi ;;' \ + ' pull) [ "$FAIL_STEP" != pull ] || exit 9 ;;' \ + ' rev-parse) printf "0123456789abcdef\n" ;;' \ + 'esac' \ + 'exit 0' >"$update_fixture/bin/git" + printf '%s\n' \ + '#!/bin/sh' \ + 'printf "bash %s\n" "$*" >>"$CALLS"' \ + 'if [ "$1" = scripts/build-local.sh ] && [ "$FAIL_STEP" = build ]; then exit 8; fi' \ + 'exit 0' >"$update_fixture/bin/bash" + printf '%s\n' \ + '#!/bin/sh' \ + 'printf "thothctl %s\n" "$*" >>"$CALLS"' \ + 'case " $* " in' \ + ' *" pi status "*) [ "$FAIL_STEP" != status ] || exit 7; printf "Pi version: 0.80.3\n" ;;' \ + ' *" status "*) printf "[{\"Service\":\"core\",\"Image\":\"thothii-core:local\"}]\n" ;;' \ + 'esac' \ + 'exit 0' >"$update_fixture/bin/thothctl" + printf '%s\n' \ + '#!/bin/sh' \ + 'printf "curl %s\n" "$*" >>"$CALLS"' \ + 'exit 0' >"$update_fixture/bin/curl" + chmod 0700 "$update_fixture/bin/git" "$update_fixture/bin/bash" \ + "$update_fixture/bin/thothctl" "$update_fixture/bin/curl" + + for fixture_step in clean dirty pull status build; do + calls="$update_fixture/calls-$fixture_step" + output="$update_fixture/output-$fixture_step" + : >"$calls" + set +e + ( + cd "$update_fixture/project" + env PATH="$update_fixture/bin:$PATH" CALLS="$calls" FAIL_STEP="$fixture_step" \ + THTCTL="$update_fixture/bin/thothctl" INSTALLATION="$update_fixture/installation.yaml" \ + /bin/bash "$update_script" + ) >"$output" 2>&1 + status=$? + set -e + if [[ "$fixture_step" == clean ]]; then + [[ $status -eq 0 ]] || { echo "same-version/no-selector source fixture failed" >&2; return 1; } + grep -Fq 'Built source revision: 0123456789abcdef' "$output" || { + echo "successful source fixture did not report revision provenance" >&2; return 1; + } + if grep -Fq ' pi update ' "$calls"; then + echo "same-version/no-selector source fixture incorrectly invoked pi update" >&2 + return 1 + fi + grep -Fq 'bash scripts/build-local.sh' "$calls" || return 1 + grep -Fq 'thothctl --installation ' "$calls" || return 1 + else + [[ $status -ne 0 ]] || { echo "$fixture_step source failure fixture was accepted" >&2; return 1; } + if grep -Fq 'Built source revision:' "$output"; then + echo "$fixture_step source failure fixture claimed revision provenance" >&2 + return 1 + fi + fi + done + echo "source update fail-closed semantics passed" echo "local installation guide contract passed" } @@ -199,7 +322,12 @@ verify_windows_line_endings_guide() { "reclone" node - "$guide" <<'NODE' const fs = require("fs"); -const lines = fs.readFileSync(process.argv[2], "utf8").split(/\n/); +const source = fs.readFileSync(process.argv[2], "utf8"); +const lines = source.split(/\n/); +const sectionStart = source.indexOf("## Recover an existing CRLF clone"); +const recovery = source.slice(sectionStart); +const shell = [...recovery.matchAll(/```sh\n([\s\S]*?)```/g)].map((match) => match[1]).join("\n"); +const powershell = [...recovery.matchAll(/```powershell\n([\s\S]*?)```/g)].map((match) => match[1]).join("\n"); const commands = [ "git add --renormalize .", "git checkout-index --all --force --prefix=", @@ -211,35 +339,104 @@ for (const command of commands) { if (index < 0) throw new Error(`CRLF recovery lacks ordered command: ${command}`); prior = index; } -for (let index = 0; index < lines.length; index += 1) { - const command = lines[index].trim(); - if (command !== 'cp "$REPAIR_DIR/$path" "$path"' && - !command.startsWith("Copy-Item -LiteralPath") && - command !== "git reset --hard") continue; - const warning = lines.slice(Math.max(0, index - 4), index).join(" ").toLowerCase(); - if (!warning.includes("warning") || !warning.includes("destructive") || - !warning.includes("backup") || !warning.includes("commit")) { - throw new Error("worktree rewrite lacks an immediate destructive warning requiring backup/commit"); - } +for (const token of [ + "set -euo pipefail", "validate_index_export", "validate_worktree_modes", "rewrite_index_entry", "git ls-files -s -z", + "100644", "100755", "120000", "readlink", "ln -s", "if ! git checkout-index", +]) { + if (!shell.includes(token)) throw new Error(`POSIX CRLF repair lacks fail-closed semantic: ${token}`); +} +if (/\|\|\s*true|;\s*true\b/.test(shell)) throw new Error("POSIX CRLF repair contains a failure-bypass command"); +const exportAt = shell.indexOf("if ! git checkout-index"); +const validationAt = shell.indexOf("if ! validate_index_export", exportAt); +const exportedBytesAt = shell.indexOf('if ! bash scripts/verify-line-endings.sh "$REPAIR_DIR"', validationAt); +const rewriteAt = shell.indexOf("if ! git ls-files -s -z", exportedBytesAt); +const finalModesAt = shell.indexOf("if ! validate_worktree_modes; then", rewriteAt); +const finalAt = shell.indexOf("if ! bash scripts/verify-line-endings.sh; then", finalModesAt); +if ([exportAt, validationAt, exportedBytesAt, rewriteAt, finalModesAt, finalAt].some((index) => index < 0) || + !(exportAt < validationAt && validationAt < exportedBytesAt && exportedBytesAt < rewriteAt && rewriteAt < finalModesAt && finalModesAt < finalAt)) { + throw new Error("POSIX CRLF repair does not prove a complete export before destructive rewrite"); +} +for (const token of [ + "$ErrorActionPreference = 'Stop'", "Assert-NativeSuccess 'index renormalization'", + "Assert-NativeSuccess 'normalized index check'", "Assert-NativeSuccess 'index export'", + "Assert-NativeSuccess 'index inventory'", "100644", "100755", "120000", "SymbolicLink", + "-ErrorAction Stop", "$WorktreeItem", "Assert-NativeSuccess 'repaired worktree LF verification'", +]) { + if (!powershell.includes(token)) throw new Error(`PowerShell CRLF repair lacks failure propagation: ${token}`); +} +const warningPattern = /WARNING[^\n]*destructive[^\n]*(backup|commit)/i; +const powerShellWarningAt = powershell.indexOf("# WARNING: destructive copy"); +const powerShellRewriteAt = powershell.indexOf("foreach ($Entry in $IndexEntries)", powerShellWarningAt); +if (!warningPattern.test(shell.slice(Math.max(0, rewriteAt - 180), rewriteAt)) || + powerShellRewriteAt < 0 || + !warningPattern.test(powershell.slice(Math.max(0, powerShellRewriteAt - 180), powerShellRewriteAt))) { + throw new Error("worktree rewrite lacks an immediate destructive warning requiring backup/commit"); } NODE - local fixture - fixture="$(mktemp -d "${TMPDIR%/}/thoth-crlf-repair.XXXXXX")" - trap 'rm -rf "$fixture"' RETURN - git -C "$fixture" init -q - printf '*.sh text eol=lf\n' >"$fixture/.gitattributes" - printf '#!/bin/sh\r\nexit 0\r\n' >"$fixture/repair.sh" - git -C "$fixture" add .gitattributes repair.sh 2>/dev/null - git -C "$fixture" config --local core.autocrlf false - git -C "$fixture" add --renormalize . - local export_dir="$fixture-export" - mkdir -p "$export_dir" - git -C "$fixture" checkout-index --all --force --prefix="$export_dir/" - "$root/scripts/verify-line-endings.sh" "$export_dir" - cp "$export_dir/repair.sh" "$fixture/repair.sh" - "$root/scripts/verify-line-endings.sh" "$fixture" - rm -rf "$export_dir" - echo "CRLF recovery rewrites worktree bytes passed" + local repair_root repair_script real_git partial_repo clean_repo partial_output repair_status + repair_root="$(mktemp -d "${TMPDIR%/}/thoth-crlf-repair.XXXXXX")" + trap 'rm -rf "$repair_root"' RETURN + repair_script="$repair_root/repair.sh" + awk ' + /^## Recover an existing CRLF clone$/ { in_section=1; next } + in_section && /^```sh$/ { in_code=1; next } + in_code && /^```$/ { exit } + in_code { print } + ' "$guide" >"$repair_script" + chmod 0700 "$repair_script" + + prepare_crlf_fixture() { + local repository="$1" + mkdir -p "$repository/scripts" + git -C "$repository" init -q + printf '*.sh text eol=lf\n' >"$repository/.gitattributes" + printf '#!/bin/sh\nexit 0\n' >"$repository/repair.sh" + printf 'target\n' >"$repository/target.txt" + cp "$root/scripts/verify-line-endings.sh" "$repository/scripts/verify-line-endings.sh" + ln -s target.txt "$repository/workspace-link" + git -C "$repository" add .gitattributes repair.sh target.txt workspace-link \ + scripts/verify-line-endings.sh 2>/dev/null + printf '#!/bin/sh\r\nexit 0\r\n' >"$repository/repair.sh" + } + + partial_repo="$repair_root/partial/worktree" + mkdir -p "$partial_repo" "$repair_root/partial/bin" + prepare_crlf_fixture "$partial_repo" + real_git="$(command -v git)" + printf '%s\n' \ + '#!/bin/sh' \ + '"$REAL_GIT" "$@"' \ + 'status=$?' \ + 'if [ $status -eq 0 ] && [ "$1" = checkout-index ]; then rm -f "$PARTIAL_EXPORT_PATH"; fi' \ + 'exit $status' >"$repair_root/partial/bin/git" + chmod 0700 "$repair_root/partial/bin/git" + partial_output="$repair_root/partial/output" + set +e + ( + cd "$partial_repo" + env PATH="$repair_root/partial/bin:$PATH" REAL_GIT="$real_git" \ + PARTIAL_EXPORT_PATH="$repair_root/partial/ThothII-lf-repair/repair.sh" \ + /bin/bash "$repair_script" + ) >"$partial_output" 2>&1 + repair_status=$? + set -e + [[ $repair_status -ne 0 ]] || { echo "partial CRLF export fixture was accepted" >&2; return 1; } + LC_ALL=C grep -q $'\r' "$partial_repo/repair.sh" || { + echo "partial CRLF export fixture rewrote bytes before complete validation" >&2; return 1; + } + [[ -L "$partial_repo/workspace-link" && "$(readlink "$partial_repo/workspace-link")" == target.txt ]] || { + echo "partial CRLF export fixture changed the tracked symlink" >&2; return 1; + } + + clean_repo="$repair_root/clean/worktree" + mkdir -p "$clean_repo" + prepare_crlf_fixture "$clean_repo" + (cd "$clean_repo" && /bin/bash "$repair_script") >/dev/null + "$root/scripts/verify-line-endings.sh" "$clean_repo" + [[ -L "$clean_repo/workspace-link" && "$(readlink "$clean_repo/workspace-link")" == target.txt ]] || { + echo "successful CRLF repair did not preserve the mode-120000 symlink" >&2; return 1; + } + echo "CRLF recovery rewrites bytes and preserves mode-120000 symlinks passed" echo "Windows line-ending recovery guide contract passed" }