From f2b3362baa6279f6c9c2019da3db3593730c3971 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Thu, 13 Aug 2026 20:43:40 +1000 Subject: [PATCH 1/5] consolidate install identity schema contract --- ...anically-verify-install-identity-schema.md | 47 +++++++++++ install.ps1 | 1 + install.sh | 6 +- scripts/lib/install-state-schema.json | 64 +++++++++++++++ scripts/verify-install-state-schema.py | 69 ++++++++++++++++ tests/fixtures/install-state-cases.json | 20 +++++ tests/test-install-state-fixtures.sh | 81 +++++++++++++++++++ tests/test-lifecycle-ownership.sh | 2 + tests/test-lifecycle-powershell.ps1 | 61 +++++++++++++- tests/test-lifecycle-static.sh | 23 +----- uninstall.ps1 | 1 + uninstall.sh | 6 +- 12 files changed, 356 insertions(+), 25 deletions(-) create mode 100644 docs/adr/0007-mechanically-verify-install-identity-schema.md create mode 100644 scripts/lib/install-state-schema.json create mode 100755 scripts/verify-install-state-schema.py create mode 100644 tests/fixtures/install-state-cases.json create mode 100755 tests/test-install-state-fixtures.sh diff --git a/docs/adr/0007-mechanically-verify-install-identity-schema.md b/docs/adr/0007-mechanically-verify-install-identity-schema.md new file mode 100644 index 0000000..de8d771 --- /dev/null +++ b/docs/adr/0007-mechanically-verify-install-identity-schema.md @@ -0,0 +1,47 @@ +# Mechanically verify one Install identity schema + +## Context + +The four lifecycle adapters must read the same closed `FORMAT=1` field set and +enforce equivalent shared constraints. They run in different bootstrap +environments: Bash and native PowerShell must validate existing state before a +checkout or optional parser is available. Their path and profile values are +adapter-native and, under format 1, only the creating adapter may consume them. + +Duplicating native parsers is therefore necessary, but manually duplicating the +contract is not. Field-list checks alone previously missed semantic drift: Bash +accepted repeated path separators that native PowerShell rejected as +non-normalized. + +## Decision + +`scripts/lib/install-state-schema.json` is the authoritative format-1 contract. +It owns canonical field order and names the shared semantic rules. The install +and uninstall adapters retain self-contained native readers and validators; no +runtime lifecycle operation depends on Python, JSON tooling, a checkout, or +generated code. + +`scripts/verify-install-state-schema.py` mechanically verifies all four native +readers, both writers, and the implementation anchors for shared rules. The +lifecycle fixture suites exercise valid and adversarial state through the real +adapters. CI must run both the verifier and those fixtures, so changing the +schema, a writer, or a native validator independently fails closed. + +Format 1 remains a data-only `KEY=VALUE` record. Readers reject unknown, +missing, duplicate, malformed, unsafe, or semantically inconsistent data and +never source or evaluate it. CRLF input remains readable. Field names and shared +constraints are cross-adapter contracts; path syntax, path case comparison, and +shell-profile values remain creator-adapter contracts. + +Before adding any field or changing its meaning, maintainers must introduce a +new format, document its migration and ownership rules in an ADR, add +cross-language fixtures, and preserve explicit format-1 compatibility. A +format-1 reader must reject an unrecognized future format rather than infer it. + +## Consequences + +The bootstrap adapters stay portable and reviewable in their native languages. +The repository gains one review surface for schema evolution and CI detects +hand-diverged field sets, writer order, and shared invariant implementations. +Semantic behavior still needs executable fixtures; static verification is not +treated as proof that a native parser behaves correctly. diff --git a/install.ps1 b/install.ps1 index 669a8a9..ff1df1d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -205,6 +205,7 @@ function Read-InstallState([string]$Path, [string]$ExpectedInstallDir) { Assert-InstallState $state $Path $ExpectedInstallDir return $state } +if ($env:SQUAREBOX_LIFECYCLE_FUNCTIONS_ONLY -eq '1') { return } function Test-Origin([string]$Origin) { return @( 'https://github.com/SquareWaveSystems/squarebox', diff --git a/install.sh b/install.sh index 063cdf8..38d8138 100755 --- a/install.sh +++ b/install.sh @@ -101,7 +101,7 @@ is_absolute_state_path() { [A-Za-z]:/*) [ "$WINDOWS_BASH" = 1 ] || return 1 ;; *) return 1 ;; esac - case "$1" in */../*|*/..|*/./*|*/.|*[$'\001'-$'\037'$'\177']*) return 1 ;; esac + case "$1" in *//*|*/../*|*/..|*/./*|*/.|*[$'\001'-$'\037'$'\177']*) return 1 ;; esac } is_root_state_path() { case "$1" in @@ -221,6 +221,10 @@ load_state() { validate_state_schema "$file" } +if [ "${SQUAREBOX_LIFECYCLE_FUNCTIONS_ONLY:-0}" = 1 ]; then + return 0 2>/dev/null || exit 0 +fi + HAD_STATE=0 if [ -f "$STATE_FILE" ]; then load_state "$STATE_FILE"; HAD_STATE=1; fi diff --git a/scripts/lib/install-state-schema.json b/scripts/lib/install-state-schema.json new file mode 100644 index 0000000..63ba7ff --- /dev/null +++ b/scripts/lib/install-state-schema.json @@ -0,0 +1,64 @@ +{ + "format": 1, + "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" + ], + "rules": [ + { + "id": "closed-field-set", + "contract": "Every field appears exactly once; unknown, duplicate, and missing fields fail closed." + }, + { + "id": "data-only", + "contract": "State is parsed as KEY=VALUE data and is never sourced or evaluated." + }, + { + "id": "format-1-only", + "contract": "FORMAT must equal 1; future formats fail closed." + }, + { + "id": "normalized-absolute-paths", + "contract": "Recorded paths are adapter-native, absolute, normalized, control-free, and constrained to their recorded roles." + }, + { + "id": "resource-identities", + "contract": "Install, runtime-resource, image, source, and numeric host identities use their closed lexical forms." + }, + { + "id": "boolean-flags", + "contract": "BUILD, EDGE, and HOME_VOLUME_ADOPTED are 0 or 1, and EDGE requires BUILD." + }, + { + "id": "source-image-coherence", + "contract": "Stable, prerelease, legacy, edge, and source-build fields form one coherent source and image identity." + }, + { + "id": "adapter-ownership", + "contract": "FORMAT=1 field names and shared constraints match, while path/profile values remain adapter-native and creator-owned." + } + ] +} diff --git a/scripts/verify-install-state-schema.py b/scripts/verify-install-state-schema.py new file mode 100755 index 0000000..e3dd672 --- /dev/null +++ b/scripts/verify-install-state-schema.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Fail when a FORMAT=1 lifecycle adapter drifts from its schema contract.""" + +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA = json.loads((ROOT / "scripts/lib/install-state-schema.json").read_text()) +EXPECTED = SCHEMA["fields"] + + +def require(condition: bool, message: str) -> None: + if not condition: + raise SystemExit(f"install-state schema drift: {message}") + + +def powershell_fields(text: str, name: str) -> None: + match = re.search(r"\$StateFields = @\((.*?)\n\)", text, re.S) + require(match is not None, f"{name} has no closed field set") + actual = re.findall(r"'([A-Z_]+)'", match.group(1)) + require(actual == EXPECTED, f"{name} field order is {actual}") + + +def bash_fields(text: str, name: str) -> None: + match = re.search(r'^STATE_KEYS="([A-Z_ ]+)"$', text, re.M) + require(match is not None, f"{name} has no closed field set") + require(match.group(1).split() == EXPECTED, f"{name} field order differs") + load = text.split("load_state() {", 1)[1].split("\n}", 1)[0] + whitelist = re.search(r'case "\$key" in\n\s*([A-Z_|]+)\)', load) + require(whitelist is not None, f"{name} has no parser field whitelist") + require(whitelist.group(1).split("|") == EXPECTED, f"{name} parser whitelist differs") + + +texts = {name: (ROOT / name).read_text() for name in ( + "install.sh", "uninstall.sh", "install.ps1", "uninstall.ps1" +)} +for name in ("install.sh", "uninstall.sh"): + bash_fields(texts[name], name) +for name in ("install.ps1", "uninstall.ps1"): + powershell_fields(texts[name], name) + +bash_writer = texts["install.sh"].split("write_state() {", 1)[1].split("\n}", 1)[0] +require(re.findall(r"([A-Z_]+)=", bash_writer) == EXPECTED, + "install.sh writer order differs") +ps_writer = texts["install.ps1"].split("$stateLines = @(", 1)[1].split("\n)", 1)[0] +require(re.findall(r'["\']([A-Z_]+)=', ps_writer) == EXPECTED, + "install.ps1 writer order differs") + +for name, text in texts.items(): + require("install-state" in text, f"{name} does not identify the state artifact") + require(not re.search(r'^\s*(?:source|\.)\s+[^\n]*install-state', text, re.M), + f"{name} executes Install identity data") + +for name in ("install.sh", "uninstall.sh"): + text = texts[name] + require("*//*|*/../*|*/..|*/./*|*/." in text, + f"{name} does not reject non-normalized paths") + require("0:0:0|0:0:1|1:0:0|1:0:1|1:1:0|1:1:1" in text, + f"{name} does not enforce EDGE requires BUILD") +for name in ("install.ps1", "uninstall.ps1"): + text = texts[name] + require("[IO.Path]::GetFullPath($Value)" in text and "$Value -ceq $full" in text, + f"{name} does not enforce normalized paths") + require("$State.EDGE -eq '1' -and $State.BUILD -ne '1'" in text, + f"{name} does not enforce EDGE requires BUILD") + +print("ok - Install identity adapters match the authoritative FORMAT=1 schema") diff --git a/tests/fixtures/install-state-cases.json b/tests/fixtures/install-state-cases.json new file mode 100644 index 0000000..38206b3 --- /dev/null +++ b/tests/fixtures/install-state-cases.json @@ -0,0 +1,20 @@ +[ + {"name": "stable", "accept": true}, + {"name": "crlf", "accept": true, "encoding": "crlf"}, + {"name": "non_ascii_workspace", "accept": true, "set": {"WORKSPACE_DIR": "{ROOT}/wörk-工作"}}, + {"name": "source_build", "accept": true, "set": {"BUILD": "1", "IMAGE_REF": "squarebox"}}, + {"name": "edge_build", "accept": true, "set": {"BUILD": "1", "EDGE": "1", "IMAGE_REF": "squarebox", "SOURCE_REF": "refs/remotes/origin/main", "RELEASE_TAG": "", "REQUESTED_TAG": ""}}, + {"name": "adopted_home", "accept": true, "set": {"HOME_VOLUME_ADOPTED": "1"}}, + {"name": "future_format", "accept": false, "set": {"FORMAT": "2"}}, + {"name": "unknown_field", "accept": false, "append": ["DELETE_THIS=/"]}, + {"name": "duplicate_field", "accept": false, "append": ["HOME_VOLUME=other"]}, + {"name": "missing_field", "accept": false, "remove": ["WORKSPACE_DIR"]}, + {"name": "malformed_line", "accept": false, "append": ["NOT_A_FIELD"]}, + {"name": "unsafe_workspace", "accept": false, "set": {"WORKSPACE_DIR": "/"}}, + {"name": "unnormalized_workspace", "accept": false, "set": {"WORKSPACE_DIR": "{ROOT}//workspace"}}, + {"name": "edge_without_build", "accept": false, "set": {"EDGE": "1", "SOURCE_REF": "refs/remotes/origin/main", "RELEASE_TAG": "", "REQUESTED_TAG": ""}}, + {"name": "invalid_install_id", "accept": false, "set": {"INSTALL_ID": "short"}}, + {"name": "invalid_uid", "accept": false, "set": {"PUID": "0"}}, + {"name": "mismatched_release_source", "accept": false, "set": {"SOURCE_REF": "v1.2.2"}}, + {"name": "mismatched_release_image", "accept": false, "set": {"IMAGE_REF": "ghcr.io/squarewavesystems/squarebox@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}} +] diff --git a/tests/test-install-state-fixtures.sh b/tests/test-install-state-fixtures.sh new file mode 100755 index 0000000..8c750cc --- /dev/null +++ b/tests/test-install-state-fixtures.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +python3 - "$ROOT" <<'PY' +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +root = Path(sys.argv[1]) +cases = json.loads((root / "tests/fixtures/install-state-cases.json").read_text()) +fields = json.loads((root / "scripts/lib/install-state-schema.json").read_text())["fields"] + +with tempfile.TemporaryDirectory() as temporary: + temp = Path(temporary) + home = temp / "home" + install_dir = temp / "squarebox" + state_dir = install_dir / ".squarebox" + home.mkdir() + state_dir.mkdir(parents=True) + base = { + "FORMAT": "1", + "INSTALL_ID": "test-install-123", + "RUNTIME": "docker", + "INSTALL_DIR": str(install_dir), + "WORKSPACE_DIR": str(temp / "workspace"), + "GIT_CONFIG_DIR": str(install_dir / ".squarebox/identity/git"), + "HOME_VOLUME": "squarebox-home", + "CONTAINER_NAME": "squarebox", + "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": str(home / ".squarebox-shell-init"), + "SHELL_RC": str(home / ".bashrc"), + "ORIGIN": "https://github.com/SquareWaveSystems/squarebox.git", + "HOME_VOLUME_ADOPTED": "0", + } + env = os.environ.copy() + env.update({ + "HOME": str(home), + "SQUAREBOX_DIR": str(install_dir), + "SQUAREBOX_LIFECYCLE_FUNCTIONS_ONLY": "1", + }) + for case in cases: + values = base.copy() + for key, value in case.get("set", {}).items(): + values[key] = value.replace("{ROOT}", str(temp)) + removed = set(case.get("remove", [])) + lines = [f"{field}={values[field]}" for field in fields if field not in removed] + lines.extend(case.get("append", [])) + newline = "\r\n" if case.get("encoding") == "crlf" else "\n" + state_file = state_dir / "install-state" + state_file.write_bytes((newline.join(lines) + newline).encode()) + for adapter in ("install.sh", "uninstall.sh"): + load = 'load_state "$STATE_FILE"' if adapter == "install.sh" else 'load_state' + command = f'adapter=$1; set --; source "$adapter"; {load}' + result = subprocess.run( + ["bash", "-c", command, "fixture", str(root / adapter)], + env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + accepted = result.returncode == 0 + if accepted != case["accept"]: + outcome = "accepted" if accepted else "rejected" + detail = result.stderr.strip() or result.stdout.strip() + raise SystemExit(f"{adapter} {outcome} {case['name']}: {detail}") + +print(f"ok - Bash lifecycle adapters agree on {len(cases)} shared Install identity fixtures") +PY diff --git a/tests/test-lifecycle-ownership.sh b/tests/test-lifecycle-ownership.sh index 8fec5f3..be45da6 100755 --- a/tests/test-lifecycle-ownership.sh +++ b/tests/test-lifecycle-ownership.sh @@ -108,6 +108,8 @@ assert_uninstall_rejects_state duplicate "printf 'HOME_VOLUME=other\\n' >>\"\$TM assert_uninstall_rejects_state unknown "printf 'DELETE_THIS=/\\n' >>\"\$TMP/custom/.squarebox/install-state\"" assert_uninstall_rejects_state missing "sed -i '/^WORKSPACE_DIR=/d' \"\$TMP/custom/.squarebox/install-state\"" assert_uninstall_rejects_state unsafe_workspace "sed -i 's#^WORKSPACE_DIR=.*#WORKSPACE_DIR=/#' \"\$TMP/custom/.squarebox/install-state\"" +assert_uninstall_rejects_state unnormalized_workspace "sed -i 's#^WORKSPACE_DIR=/#WORKSPACE_DIR=//#' \"\$TMP/custom/.squarebox/install-state\"" +assert_uninstall_rejects_state edge_without_build "sed -i 's/^EDGE=0\$/EDGE=1/; s/^RELEASE_TAG=.*\$/RELEASE_TAG=/; s/^SOURCE_REF=.*\$/SOURCE_REF=refs\\/remotes\\/origin\\/main/' \"\$TMP/custom/.squarebox/install-state\"" export CONTAINER_OWNER=some-other-install if "$ROOT/uninstall.sh" --yes >"$TMP/owner.out" 2>&1; then diff --git a/tests/test-lifecycle-powershell.ps1 b/tests/test-lifecycle-powershell.ps1 index 303fa58..2b6e0e2 100755 --- a/tests/test-lifecycle-powershell.ps1 +++ b/tests/test-lifecycle-powershell.ps1 @@ -64,4 +64,63 @@ Assert-True ($install.Contains('{{range .RepoDigests}}{{println .}}{{end}}') -an Assert-True ($install -match '\$repoDigestOutput = @\(\)\s+if \(-not \$Build\)') 'local builds still derive identity from unordered RepoDigests' Assert-True ($install.Contains('$SelectionStateFiles') -and $install.Contains('Selection state file must not be a reparse point or symlink')) 'PowerShell seeding can follow Workspace Selection links' -Write-Output 'ok - native PowerShell lifecycle syntax and safety contracts' +$schema = Get-Content -Raw (Join-Path $Root 'scripts/lib/install-state-schema.json') | ConvertFrom-Json +$cases = Get-Content -Raw (Join-Path $Root 'tests/fixtures/install-state-cases.json') | ConvertFrom-Json +$StateFields = @($schema.fields) +$Repo = 'https://github.com/SquareWaveSystems/squarebox.git' +$fixtureRoot = Join-Path ([IO.Path]::GetTempPath()) "squarebox-state-$([guid]::NewGuid().ToString('N'))" +$UserHome = Join-Path $fixtureRoot 'home' +$fixtureInstall = Join-Path $fixtureRoot 'squarebox' +$stateDir = Join-Path $fixtureInstall '.squarebox' +[void](New-Item -ItemType Directory -Force $UserHome, $stateDir) + +function Abort([string]$Message) { throw $Message } +try { + foreach ($adapter in @('install.ps1', 'uninstall.ps1')) { + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Join-Path $Root $adapter), [ref]$tokens, [ref]$errors) + Assert-True ($errors.Count -eq 0) "$adapter has parser errors" + foreach ($functionName in @('Test-ReleaseTag', 'Test-StatePath', 'Test-SamePath', 'Test-StateId', 'Assert-InstallState', 'Read-InstallState')) { + $definition = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $functionName + }, $true) + Assert-True ($null -ne $definition) "$adapter has no $functionName function" + Invoke-Expression $definition.Extent.Text + } + foreach ($case in $cases) { + $values = [ordered]@{ + FORMAT = '1'; INSTALL_ID = 'test-install-123'; RUNTIME = 'docker' + INSTALL_DIR = $fixtureInstall; WORKSPACE_DIR = (Join-Path $fixtureRoot 'workspace') + GIT_CONFIG_DIR = (Join-Path $fixtureInstall '.squarebox/identity/git') + HOME_VOLUME = 'squarebox-home'; CONTAINER_NAME = 'squarebox'; 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 = $PROFILE.CurrentUserAllHosts; SHELL_RC = $PROFILE.CurrentUserAllHosts + ORIGIN = $Repo; HOME_VOLUME_ADOPTED = '0' + } + foreach ($property in $case.set.PSObject.Properties) { + $values[$property.Name] = ([string]$property.Value).Replace('{ROOT}', $fixtureRoot) + } + $removed = @($case.remove) + $lines = @($StateFields | Where-Object { $_ -cnotin $removed } | ForEach-Object { "$_=$($values[$_])" }) + $lines += @($case.append) + $separator = if ($case.encoding -ceq 'crlf') { "`r`n" } else { [Environment]::NewLine } + $stateFile = Join-Path $stateDir 'install-state' + [IO.File]::WriteAllText($stateFile, (($lines -join $separator) + $separator), [Text.UTF8Encoding]::new($false)) + $accepted = $true + try { [void](Read-InstallState $stateFile $fixtureInstall) } catch { $accepted = $false } + Assert-True ($accepted -eq [bool]$case.accept) "$adapter fixture '$($case.name)' had unexpected result" + } + } +} finally { + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Output "ok - native PowerShell lifecycle syntax, safety, and $($cases.Count) shared state fixtures" diff --git a/tests/test-lifecycle-static.sh b/tests/test-lifecycle-static.sh index a1ce98e..334d522 100755 --- a/tests/test-lifecycle-static.sh +++ b/tests/test-lifecycle-static.sh @@ -96,28 +96,7 @@ grep -q "Read-Host 'Continue? \[y/N\]'" uninstall.ps1 grep -q 'ReadAllLines' install.ps1 grep -Fq 'line="${line%$'"'"'\r'"'"'}"' install.sh -python3 - <<'PY' -import re -from pathlib import Path - -expected = [ - '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', -] -for name in ('install.ps1', 'uninstall.ps1'): - text = Path(name).read_text() - match = re.search(r'\$StateFields = @\((.*?)\n\)', text, re.S) - assert match, f'{name}: no closed state schema' - fields = re.findall(r"'([A-Z_]+)'", match.group(1)) - assert fields == expected, f'{name}: state schema differs: {fields}' - -writer = Path('install.ps1').read_text().split('$stateLines = @(', 1)[1].split('\n)', 1)[0] -emitted = re.findall(r'["\']([A-Z_]+)=', writer) -assert emitted == expected, f'install.ps1: emitted state differs: {emitted}' -PY +scripts/verify-install-state-schema.py if command -v pwsh >/dev/null 2>&1; then pwsh -NoProfile -NonInteractive -Command \ diff --git a/uninstall.ps1 b/uninstall.ps1 index 8e423ca..4302386 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -123,6 +123,7 @@ function Read-InstallState([string]$Path, [string]$ExpectedInstallDir) { Assert-InstallState $state $Path $ExpectedInstallDir return $state } +if ($env:SQUAREBOX_LIFECYCLE_FUNCTIONS_ONLY -eq '1') { return } function Test-Origin([string]$Origin) { return @( 'https://github.com/SquareWaveSystems/squarebox', diff --git a/uninstall.sh b/uninstall.sh index 097d651..e0ecc6c 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -75,7 +75,7 @@ is_absolute_state_path() { [A-Za-z]:/*) [ "$WINDOWS_BASH" = 1 ] || return 1 ;; *) return 1 ;; esac - case "$1" in */../*|*/..|*/./*|*/.|*[$'\001'-$'\037'$'\177']*) return 1 ;; esac + case "$1" in *//*|*/../*|*/..|*/./*|*/.|*[$'\001'-$'\037'$'\177']*) return 1 ;; esac } is_root_state_path() { case "$1" in @@ -182,6 +182,10 @@ load_state() { validate_state_schema } +if [ "${SQUAREBOX_LIFECYCLE_FUNCTIONS_ONLY:-0}" = 1 ]; then + return 0 2>/dev/null || exit 0 +fi + HAD_STATE=0 if [ -f "$STATE_FILE" ]; then load_state || { echo "Error: invalid Install identity: $STATE_FILE" >&2; exit 1; } From 995309ace42b8dd3cba885ea6087d55888838523 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Thu, 13 Aug 2026 20:45:23 +1000 Subject: [PATCH 2/5] test lifecycle schema on Windows PRs --- .github/workflows/build.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e7c3fc..e6f9b6f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,8 +70,16 @@ jobs: "$test_file" done + lifecycle-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Native PowerShell lifecycle contracts + shell: pwsh + run: ./tests/test-lifecycle-powershell.ps1 + build: - needs: repository-tests + needs: [repository-tests, lifecycle-windows] runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -175,7 +183,7 @@ jobs: # Dockerfile / tool-asset breakage at PR time (via QEMU) rather than only on # the release tag. Build-only; behavioural arm64 tests run in e2e on tags. build-arm64: - needs: repository-tests + needs: [repository-tests, lifecycle-windows] runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From f7e0a11124389426631c08dc06e563a6c52e7c0f Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Thu, 13 Aug 2026 20:47:31 +1000 Subject: [PATCH 3/5] fix native Windows fixture paths --- tests/test-lifecycle-powershell.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test-lifecycle-powershell.ps1 b/tests/test-lifecycle-powershell.ps1 index 2b6e0e2..861c830 100755 --- a/tests/test-lifecycle-powershell.ps1 +++ b/tests/test-lifecycle-powershell.ps1 @@ -106,7 +106,13 @@ try { ORIGIN = $Repo; HOME_VOLUME_ADOPTED = '0' } foreach ($property in $case.set.PSObject.Properties) { - $values[$property.Name] = ([string]$property.Value).Replace('{ROOT}', $fixtureRoot) + $fixtureValue = [string]$property.Value + if ($fixtureValue.StartsWith('{ROOT}/', [StringComparison]::Ordinal)) { + $values[$property.Name] = [IO.Path]::Combine( + $fixtureRoot, $fixtureValue.Substring('{ROOT}/'.Length).Replace('/', [IO.Path]::DirectorySeparatorChar)) + } else { + $values[$property.Name] = $fixtureValue.Replace('{ROOT}', $fixtureRoot) + } } $removed = @($case.remove) $lines = @($StateFields | Where-Object { $_ -cnotin $removed } | ForEach-Object { "$_=$($values[$_])" }) From f55274a9536e2c5f84a0bf5889fbf1b6cb76f8cd Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Thu, 13 Aug 2026 20:49:55 +1000 Subject: [PATCH 4/5] normalize PowerShell path validation --- install.ps1 | 2 ++ scripts/verify-install-state-schema.py | 2 ++ uninstall.ps1 | 2 ++ 3 files changed, 6 insertions(+) diff --git a/install.ps1 b/install.ps1 index ff1df1d..3be6430 100644 --- a/install.ps1 +++ b/install.ps1 @@ -109,6 +109,8 @@ function Test-ReparsePoint([string]$Path) { } function Test-StatePath([string]$Value) { if ([string]::IsNullOrEmpty($Value) -or $Value -match '[\x00-\x1f\x7f]' -or -not [IO.Path]::IsPathFullyQualified($Value)) { return $false } + $pathTail = if ($IsWindows -and $Value.StartsWith('\\', [StringComparison]::Ordinal)) { $Value.Substring(2) } else { $Value } + if ($pathTail -match '[\\/]{2,}') { return $false } try { $full = [IO.Path]::GetFullPath($Value) } catch { return $false } return $Value -ceq $full } diff --git a/scripts/verify-install-state-schema.py b/scripts/verify-install-state-schema.py index e3dd672..b044d6c 100755 --- a/scripts/verify-install-state-schema.py +++ b/scripts/verify-install-state-schema.py @@ -63,6 +63,8 @@ def bash_fields(text: str, name: str) -> None: text = texts[name] require("[IO.Path]::GetFullPath($Value)" in text and "$Value -ceq $full" in text, f"{name} does not enforce normalized paths") + require("$pathTail -match '[\\\\/]{2,}'" in text, + f"{name} does not reject repeated path separators consistently") require("$State.EDGE -eq '1' -and $State.BUILD -ne '1'" in text, f"{name} does not enforce EDGE requires BUILD") diff --git a/uninstall.ps1 b/uninstall.ps1 index 4302386..bd7b532 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -31,6 +31,8 @@ function Test-ReparsePoint([string]$Path) { } function Test-StatePath([string]$Value) { if ([string]::IsNullOrEmpty($Value) -or $Value -match '[\x00-\x1f\x7f]' -or -not [IO.Path]::IsPathFullyQualified($Value)) { return $false } + $pathTail = if ($IsWindows -and $Value.StartsWith('\\', [StringComparison]::Ordinal)) { $Value.Substring(2) } else { $Value } + if ($pathTail -match '[\\/]{2,}') { return $false } try { $full = [IO.Path]::GetFullPath($Value) } catch { return $false } return $Value -ceq $full } From 12a82324916420b6d73dab9bb6244d601ee6e367 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Thu, 13 Aug 2026 20:52:18 +1000 Subject: [PATCH 5/5] preserve malformed fixture separators --- tests/test-lifecycle-powershell.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test-lifecycle-powershell.ps1 b/tests/test-lifecycle-powershell.ps1 index 861c830..a9f7326 100755 --- a/tests/test-lifecycle-powershell.ps1 +++ b/tests/test-lifecycle-powershell.ps1 @@ -108,8 +108,8 @@ try { foreach ($property in $case.set.PSObject.Properties) { $fixtureValue = [string]$property.Value if ($fixtureValue.StartsWith('{ROOT}/', [StringComparison]::Ordinal)) { - $values[$property.Name] = [IO.Path]::Combine( - $fixtureRoot, $fixtureValue.Substring('{ROOT}/'.Length).Replace('/', [IO.Path]::DirectorySeparatorChar)) + $values[$property.Name] = $fixtureRoot + $fixtureValue.Substring('{ROOT}'.Length).Replace( + '/', [IO.Path]::DirectorySeparatorChar) } else { $values[$property.Name] = $fixtureValue.Replace('{ROOT}', $fixtureRoot) }