Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions docs/adr/0007-mechanically-verify-install-identity-schema.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -205,6 +207,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',
Expand Down
6 changes: 5 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions scripts/lib/install-state-schema.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
71 changes: 71 additions & 0 deletions scripts/verify-install-state-schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/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("$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")

print("ok - Install identity adapters match the authoritative FORMAT=1 schema")
20 changes: 20 additions & 0 deletions tests/fixtures/install-state-cases.json
Original file line number Diff line number Diff line change
@@ -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"}}
]
81 changes: 81 additions & 0 deletions tests/test-install-state-fixtures.sh
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions tests/test-lifecycle-ownership.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading