fix: make local recovery fail closed

This commit is contained in:
2026-08-05 09:48:55 +02:00
parent 65aa52115f
commit df21046472
4 changed files with 577 additions and 115 deletions
+125 -57
View File
@@ -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 Therefore rebuilding `thothii-core:local` followed by `update --check-only` does not reconcile a
previous `pi update`: the old promoted core would remain selected. 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 Do not delete or edit the selector. `thothctl status` is the installation-aware selector test. If
`pi update --source build` from a clean pulled checkout whose `docker/core.Dockerfile` pins a the running core image is the base `thothii-core:local` image, no Pi update has promoted a durable
different Pi version than the running installation. `thothctl` currently treats the same requested lifecycle image and an ordinary same-Pi-version source rebuild/start is supported. If status shows
Pi version as a no-op. The explicit comparison below therefore stops instead of silently deploying a lifecycle image and the pulled Pi pin is unchanged, `pi update` would be a no-op and the procedure
only part of a revision. If it stops, keep the current installation running and wait for a release must stop. A changed Pi pin uses transactional `pi update --source build` in either case.
with a new Pi pin or a future supported reconciliation command; there is no supported manual
same-version selector-removal procedure.
macOS, Linux, and WSL2: macOS, Linux, and WSL2:
```sh ```sh
git status --short set -euo pipefail
git diff --quiet
git diff --cached --quiet abort_update() { printf 'Source update stopped: %s\n' "$1" >&2; exit 1; }
git pull --ff-only require_clean_source() {
git config --local core.autocrlf false local source_state
bash scripts/verify-line-endings.sh if ! source_state="$(git status --porcelain --untracked-files=all)"; then
SOURCE_REVISION="$(git rev-parse HEAD)" abort_update "git status failed"
NEXT_PI_VERSION="$(sed -n 's/^ARG PI_VERSION=//p' docker/core.Dockerfile)" fi
RUNNING_PI_VERSION="$("$THTCTL" --installation "$INSTALLATION" pi status)" [[ -z "$source_state" ]] || abort_update "commit, remove, or back up every tracked/untracked source change"
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 require_clean_source
exit 1 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 fi
bash scripts/build-local.sh [[ -n "$NEXT_PI_VERSION" && "$NEXT_PI_VERSION" != *$'\n'* ]] || abort_update "expected one pinned default PI_VERSION"
bash scripts/build-thothctl.sh if ! INSTALLATION_STATUS="$("$THTCTL" --installation "$INSTALLATION" status)"; then
"$THTCTL" --installation "$INSTALLATION" update --check-only abort_update "thothctl status failed"
"$THTCTL" --installation "$INSTALLATION" pi update \ fi
--version "$NEXT_PI_VERSION" --source build --yes --drain if ! RUNNING_PI_VERSION="$("$THTCTL" --installation "$INSTALLATION" pi status)"; then
"$THTCTL" --installation "$INSTALLATION" start abort_update "thothctl pi status failed"
curl --fail http://127.0.0.1:8080/health fi
curl --fail http://127.0.0.1:8787/health RUNNING_PI_VERSION="${RUNNING_PI_VERSION#Pi version: }"
printf 'Built source revision: %s\n' "$SOURCE_REVISION" [[ -n "$RUNNING_PI_VERSION" ]] || abort_update "thothctl pi status returned no version"
"$THTCTL" --installation "$INSTALLATION" status
"$THTCTL" --installation "$INSTALLATION" pi status COMPACT_STATUS="${INSTALLATION_STATUS//[[:space:]]/}"
"$THTCTL" --installation "$INSTALLATION" doctor 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: Native Windows PowerShell uses the same fail-closed version comparison and transactional promotion:
```powershell ```powershell
git status --short $ErrorActionPreference = 'Stop'
git diff --quiet function Assert-NativeSuccess([string]$Step) {
if ($LASTEXITCODE -ne 0) { throw 'Commit or back up tracked source changes before update.' } if ($LASTEXITCODE -ne 0) { throw "$Step failed with exit code $LASTEXITCODE." }
git diff --cached --quiet }
if ($LASTEXITCODE -ne 0) { throw 'Commit or back up staged source changes before update.' } 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 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 git config --local core.autocrlf false
Assert-NativeSuccess 'repository LF policy'
& "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh & "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 $SourceRevision = git rev-parse HEAD
Assert-NativeSuccess 'source revision read'
$VersionLine = @(Select-String -Path docker/core.Dockerfile -Pattern '^ARG PI_VERSION=(.+)$') $VersionLine = @(Select-String -Path docker/core.Dockerfile -Pattern '^ARG PI_VERSION=(.+)$')
if ($VersionLine.Count -ne 1) { throw 'Expected exactly one pinned default PI_VERSION.' } if ($VersionLine.Count -ne 1) { throw 'Expected exactly one pinned default PI_VERSION.' }
$NextPiVersion = $VersionLine.Matches[0].Groups[1].Value $NextPiVersion = $VersionLine.Matches[0].Groups[1].Value
$RunningPiVersion = (& $THTCTL --installation $INSTALLATION pi status) ` $InstallationStatus = @(& $THTCTL --installation $INSTALLATION status)
-replace '^Pi version:\s*', '' Assert-NativeSuccess 'installation status'
if ([string]::IsNullOrWhiteSpace($NextPiVersion) -or $NextPiVersion -eq $RunningPiVersion) { $RunningPiStatus = (& $THTCTL --installation $INSTALLATION pi status)
throw 'Source update stopped: the pulled revision must pin a new Pi version.' 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 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 & "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 & $THTCTL --installation $INSTALLATION update --check-only
if ($LASTEXITCODE -ne 0) { throw 'The installation render check failed.' } Assert-NativeSuccess 'installation render check'
& $THTCTL --installation $INSTALLATION pi update ` if ($TransactionalPiUpdate) {
--version $NextPiVersion --source build --yes --drain & $THTCTL --installation $INSTALLATION pi update `
if ($LASTEXITCODE -ne 0) { throw 'The transactional core update failed.' } --version $NextPiVersion --source build --yes --drain
Assert-NativeSuccess 'transactional core update'
}
& $THTCTL --installation $INSTALLATION start & $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 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 curl.exe --fail --silent --show-error http://127.0.0.1:8787/health
Write-Output "Built source revision: $SourceRevision" Assert-NativeSuccess 'core health check'
& $THTCTL --installation $INSTALLATION status $FinalStatus = @(& $THTCTL --installation $INSTALLATION status)
& $THTCTL --installation $INSTALLATION pi 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 & $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 The revision is printed only after every source/build/start/health/installation-aware check passes
`thothctl status`, confirm that `core` reports the installation lifecycle candidate image, then and a final porcelain check still reports no tracked or untracked source changes. For a changed Pi
require `pi status` to equal the new pin and `doctor` to pass. This is the supported running-image pin, status reports the promoted lifecycle candidate; for a same-version installation with no
and source-revision evidence; `update --check-only` alone proves only that Compose renders. 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 Review release notes before updating. See [Pi management](pi-management.md) for rollback; never
install a package in the running container. install a package in the running container.
+145 -24
View File
@@ -77,53 +77,174 @@ worktree bytes.
From WSL2, Git Bash, macOS, or Linux: From WSL2, Git Bash, macOS, or Linux:
```sh ```sh
git status --short set -euo pipefail
git config --local core.autocrlf false
git add --renormalize . abort_repair() { printf 'CRLF repair stopped: %s\n' "$1" >&2; exit 1; }
git diff --cached --check validate_index_export() {
git diff --cached 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" REPAIR_DIR="$(cd .. && pwd -P)/ThothII-lf-repair"
if [[ -e "$REPAIR_DIR" ]]; then if [[ -e "$REPAIR_DIR" ]]; then
echo "Choose a new empty LF repair directory: $REPAIR_DIR" >&2 abort_repair "choose a new empty LF repair directory: $REPAIR_DIR"
exit 1
fi fi
mkdir -p "$REPAIR_DIR" if ! mkdir -p "$REPAIR_DIR"; then abort_repair "could not create LF repair directory"; fi
REPAIR_PREFIX="$REPAIR_DIR/" REPAIR_PREFIX="$REPAIR_DIR/"
git checkout-index --all --force --prefix="$REPAIR_PREFIX" if ! git checkout-index --all --force --prefix="$REPAIR_PREFIX"; then abort_repair "index export failed"; fi
bash scripts/verify-line-endings.sh "$REPAIR_DIR" 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. # WARNING: destructive copy; make a backup or commit wanted changes before this command.
git ls-files -z | while IFS= read -r -d '' path; do if ! git ls-files -s -z | while IFS= read -r -d '' entry; do
cp "$REPAIR_DIR/$path" "$path" metadata="${entry%%$'\t'*}"
done path="${entry#*$'\t'}"
bash scripts/verify-line-endings.sh 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 Native Windows PowerShell runs the same Git operations and invokes the byte verifier through Git
for Windows: for Windows:
```powershell ```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 git status --short
Assert-NativeSuccess 'git status'
git config --local core.autocrlf false git config --local core.autocrlf false
Assert-NativeSuccess 'repository LF policy'
git add --renormalize . git add --renormalize .
Assert-NativeSuccess 'index renormalization'
git diff --cached --check git diff --cached --check
Assert-NativeSuccess 'normalized index check'
git diff --cached git diff --cached
Assert-NativeSuccess 'normalized index review'
$RepairDir = Join-Path (Split-Path -Parent (Get-Location).Path) 'ThothII-lf-repair' $RepairDir = Join-Path (Split-Path -Parent (Get-Location).Path) 'ThothII-lf-repair'
if (Test-Path $RepairDir) { throw 'Choose a new empty LF repair directory.' } if (Test-Path $RepairDir) { throw 'Choose a new empty LF repair directory.' }
New-Item -ItemType Directory -Path $RepairDir | Out-Null New-Item -ItemType Directory -Path $RepairDir | Out-Null
$RepairPrefix = $RepairDir.Replace('\', '/') + '/' $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 & "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. # WARNING: destructive copy; make a backup or commit wanted changes before this command.
git ls-files | ForEach-Object { foreach ($Entry in $IndexEntries) {
Copy-Item -LiteralPath (Join-Path $RepairDir $_) -Destination $_ -Force $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 & "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 The export inventory must contain every regular mode (`100644`/`100755`) and recreate every tracked
examines the repaired worktree bytes and must also exit `0`. Review the staged diff again before workspace compatibility symlink (`120000`). The first verifier proves the complete
committing, then remove the separate repair directory only after inspecting it. The procedure export before any overwrite; every copy/link operation is fail-closed; the final verifier examines
intentionally avoids `git reset --hard`; replacing the clone is easier to audit and much safer for the repaired worktree bytes. On native Windows, creating symlinks requires Developer Mode or an
uncommitted work. 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.
+77 -1
View File
@@ -12,6 +12,7 @@ trap 'rm -f "$output" "$verifier_functions"; rm -rf "$negative_root"' EXIT HUP I
for fixture in \ for fixture in \
"local installation guide contract" \ "local installation guide contract" \
"source update fail-closed semantics" \
"Windows line-ending recovery guide contract" \ "Windows line-ending recovery guide contract" \
"Pi management guide contract" \ "Pi management guide contract" \
"local installation example rendered from path with spaces" \ "local installation example rendered from path with spaces" \
@@ -20,7 +21,7 @@ for fixture in \
"canonical local base+override fixture" \ "canonical local base+override fixture" \
"canonical server base+override fixture" \ "canonical server base+override fixture" \
"relative secret-source fixture rejected" \ "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 || { grep -Fqx "$fixture passed" "$output" >/dev/null || {
echo "missing fixture verification: $fixture" >&2 echo "missing fixture verification: $fixture" >&2
cat "$output" >&2 cat "$output" >&2
@@ -66,6 +67,10 @@ expect_guide_rejected() {
local fixture_output="$fixture_root/output" local fixture_output="$fixture_root/output"
mkdir -p "$fixture_root/$(dirname "$relative_path")" mkdir -p "$fixture_root/$(dirname "$relative_path")"
cp "$source_guide" "$fixture_root/$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' node - "$fixture_root/$relative_path" "$mutation" <<'NODE'
const fs = require("fs"); const fs = require("fs");
const [path, mutation] = process.argv.slice(2); const [path, mutation] = process.argv.slice(2);
@@ -87,6 +92,36 @@ switch (mutation) {
case "raw-pi": case "raw-pi":
changed += "\n```sh\ndocker compose exec core pi --version\n```\n"; changed += "\n```sh\ndocker compose exec core pi --version\n```\n";
break; 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: default:
throw new Error(`unknown negative-fixture mutation: ${mutation}`); 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 \ "$root/docs/install/pi-management.md" docs/install/pi-management.md raw-pi \
"raw non-installation-aware Compose Pi access is forbidden" "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 if (( negative_failures != 0 )); then
echo "$negative_failures unsafe installation-document fixtures were accepted" >&2 echo "$negative_failures unsafe installation-document fixtures were accepted" >&2
exit 1 exit 1
+230 -33
View File
@@ -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; let inCodeFence = false;
for (const line of source.split(/\n/)) { for (const line of source.split(/\n/)) {
if (line.trimStart().startsWith("```")) { if (line.trimStart().startsWith("```")) {
@@ -151,18 +155,62 @@ requireTokens("native PowerShell health", healthPowerShell, [
const updateShell = blocks("Update an installation", "sh").join("\n"); const updateShell = blocks("Update an installation", "sh").join("\n");
requireTokens("installation-aware source update", updateShell, [ requireTokens("installation-aware source update", updateShell, [
"NEXT_PI_VERSION", "RUNNING_PI_VERSION", "--source build", "git rev-parse HEAD", "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") || if (/\|\|\s*true|;\s*true\b/.test(updateShell)) throw new Error("POSIX source update contains a failure-bypass command");
!updateShell.includes("exit 1")) { requirePattern("POSIX source update does not fail closed: source pull", updateShell,
throw new Error("source update must fail closed when thothctl would no-op on the current Pi version"); /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"); const updatePowerShell = blocks("Update an installation", "powershell").join("\n");
requireTokens("native PowerShell source update", updatePowerShell, [ requireTokens("native PowerShell source update", updatePowerShell, [
"$NextPiVersion", "$RunningPiVersion", "--source build", "git rev-parse HEAD", "$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"); const backupPowerShell = blocks("Back up and restore", "powershell").join("\n");
requireTokens("native PowerShell backup/restore", backupPowerShell, [ requireTokens("native PowerShell backup/restore", backupPowerShell, [
@@ -176,6 +224,81 @@ for (const block of [setupPowerShell, healthPowerShell, updatePowerShell, backup
} }
} }
NODE 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" echo "local installation guide contract passed"
} }
@@ -199,7 +322,12 @@ verify_windows_line_endings_guide() {
"reclone" "reclone"
node - "$guide" <<'NODE' node - "$guide" <<'NODE'
const fs = require("fs"); 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 = [ const commands = [
"git add --renormalize .", "git add --renormalize .",
"git checkout-index --all --force --prefix=", "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}`); if (index < 0) throw new Error(`CRLF recovery lacks ordered command: ${command}`);
prior = index; prior = index;
} }
for (let index = 0; index < lines.length; index += 1) { for (const token of [
const command = lines[index].trim(); "set -euo pipefail", "validate_index_export", "validate_worktree_modes", "rewrite_index_entry", "git ls-files -s -z",
if (command !== 'cp "$REPAIR_DIR/$path" "$path"' && "100644", "100755", "120000", "readlink", "ln -s", "if ! git checkout-index",
!command.startsWith("Copy-Item -LiteralPath") && ]) {
command !== "git reset --hard") continue; if (!shell.includes(token)) throw new Error(`POSIX CRLF repair lacks fail-closed semantic: ${token}`);
const warning = lines.slice(Math.max(0, index - 4), index).join(" ").toLowerCase(); }
if (!warning.includes("warning") || !warning.includes("destructive") || if (/\|\|\s*true|;\s*true\b/.test(shell)) throw new Error("POSIX CRLF repair contains a failure-bypass command");
!warning.includes("backup") || !warning.includes("commit")) { const exportAt = shell.indexOf("if ! git checkout-index");
throw new Error("worktree rewrite lacks an immediate destructive warning requiring backup/commit"); 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 NODE
local fixture local repair_root repair_script real_git partial_repo clean_repo partial_output repair_status
fixture="$(mktemp -d "${TMPDIR%/}/thoth-crlf-repair.XXXXXX")" repair_root="$(mktemp -d "${TMPDIR%/}/thoth-crlf-repair.XXXXXX")"
trap 'rm -rf "$fixture"' RETURN trap 'rm -rf "$repair_root"' RETURN
git -C "$fixture" init -q repair_script="$repair_root/repair.sh"
printf '*.sh text eol=lf\n' >"$fixture/.gitattributes" awk '
printf '#!/bin/sh\r\nexit 0\r\n' >"$fixture/repair.sh" /^## Recover an existing CRLF clone$/ { in_section=1; next }
git -C "$fixture" add .gitattributes repair.sh 2>/dev/null in_section && /^```sh$/ { in_code=1; next }
git -C "$fixture" config --local core.autocrlf false in_code && /^```$/ { exit }
git -C "$fixture" add --renormalize . in_code { print }
local export_dir="$fixture-export" ' "$guide" >"$repair_script"
mkdir -p "$export_dir" chmod 0700 "$repair_script"
git -C "$fixture" checkout-index --all --force --prefix="$export_dir/"
"$root/scripts/verify-line-endings.sh" "$export_dir" prepare_crlf_fixture() {
cp "$export_dir/repair.sh" "$fixture/repair.sh" local repository="$1"
"$root/scripts/verify-line-endings.sh" "$fixture" mkdir -p "$repository/scripts"
rm -rf "$export_dir" git -C "$repository" init -q
echo "CRLF recovery rewrites worktree bytes passed" 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" echo "Windows line-ending recovery guide contract passed"
} }