harden Phase 2B Stage 1 installation state
This commit is contained in:
parent
82e64cec43
commit
df6b53e639
10 changed files with 1062 additions and 20 deletions
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
Every numbered boundary requires review and explicit approval. Nothing here authorizes execution.
|
Every numbered boundary requires review and explicit approval. Nothing here authorizes execution.
|
||||||
|
|
||||||
1. From this directory, run only `tests/00-root-preinstall-validate.sh` as root and require zero failures. It atomically checks both manifests, staged nftables/systemd/shell/JSON, exact Phase 1 ownership/modes, absent approval marker/installed payload/manager/process/socket/linger, and public/NAT address drift. Save output outside production.
|
1. Review the passing non-mutating `tests/32-stage1-state-tests.sh` results and the shared state implementation in `tests/stage1-state-lib.sh`. Then, from this directory, run only `tests/30-install-stage1.sh` as root and require zero failures. It invokes `tests/00-root-preinstall-validate.sh`, checks both manifests, requires all 16 destination files absent, validates shared parents, installs only static Stage 1 files, and atomically advances immutable checksummed state generations from `IN_PROGRESS` to `COMPLETE`. It does not create the approval marker or perform any reload/start/network action.
|
||||||
2. Preserve the approved Quad9 DNS pair `9.9.9.9` and `149.112.112.112`, observed public/NAT address denial `74.67.173.56`, and verified root inventory. Quad9 requires no LAN exception; `192.168.10.1` remains denied. Any different value or public/NAT drift requires artifact edits, a new manifest, and another review.
|
2. Preserve the approved Quad9 DNS pair `9.9.9.9` and `149.112.112.112`, observed public/NAT address denial `74.67.173.56`, and verified root inventory. Quad9 requires no LAN exception; `192.168.10.1` remains denied. Any different value or public/NAT drift requires artifact edits, a new manifest, and another review.
|
||||||
3. Copy only the manifest-listed launcher to `/usr/local/libexec`, mode `0755`, `root:root`; rehash the installed file.
|
3. Resolve `/var/lib/le-app-codex-phase2b/stage1/current` as a strict local generation name and review that generation's `manifest` and `manifest.sha256`; require `COMPLETE`, source commit, artifact-manifest digest, hostname, timestamp, 16 completed file records, no pending records, and every reached directory disposition to match the installer output.
|
||||||
4. Copy only the slice and `user@1200.service` drop-ins, resolver file, namespace helper/unit, and nftables fragment to their manifest paths with `root:root`, directories/files `0755/0644`, helper `0755`. After exact-value approval, create root-owned mode `0644` `/etc/le-app-codex-runtime/OPERATOR-INPUTS-APPROVED` containing the artifact-manifest checksum and approval timestamp. Do not start anything. Run `systemd-analyze verify` and `nft --check -f`; inspect the merged `systemctl cat`/properties.
|
4. Do not create `/etc/le-app-codex-runtime/OPERATOR-INPUTS-APPROVED` during Stage 1. Do not run `systemctl daemon-reload`, load nftables, create the namespace, or start/enable anything.
|
||||||
5. Copy the Docker user unit/config/context and tests below `/srv/le-app-codex`; set directories `0750`, config/unit/tests `0640` except tests `0750`, all owned `1200:1200`. Do not create credentials, checkout, or source copies.
|
5. Review installed owner/mode/size/hash evidence. Docker user configuration and the five acceptance/preflight scripts must be owned by `1200:1200`; tests are `0750`, configs/unit `0640`. No credentials, checkout, or source copies are installed.
|
||||||
6. Run `systemctl daemon-reload`. Confirm the user manager remains inactive, lingering absent, and socket/process absent.
|
6. Run `systemctl daemon-reload`. Confirm the user manager remains inactive, lingering absent, and socket/process absent.
|
||||||
7. Start only `le-app-codex-netns.service`. Inspect namespace identity, IPv4-only addresses/routes, nftables counters/rules, host routing, and production Docker health. Stop on any drift.
|
7. Start only `le-app-codex-netns.service`. Inspect namespace identity, IPv4-only addresses/routes, nftables counters/rules, host routing, and production Docker health. Stop on any drift.
|
||||||
8. Run `tests/run-inert-without-user-manager.sh`. This starts only a finite transient test process, never `user@1200.service`, Docker, or Codex. Require every filesystem/process/cgroup/device/socket/network assertion to pass.
|
8. Run `tests/run-inert-without-user-manager.sh`. This starts only a finite transient test process, never `user@1200.service`, Docker, or Codex. Require every filesystem/process/cgroup/device/socket/network assertion to pass.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
f5bc35fd3767c0cbc72495b1dea283e8237789dd383ed754bf629ca09379ab97 .gitattributes
|
f5bc35fd3767c0cbc72495b1dea283e8237789dd383ed754bf629ca09379ab97 .gitattributes
|
||||||
e4c381697c727f364c2a212fede2f703cfab8e0a57aaa4fa98550478a76eef55 INSTALL-ORDER.md
|
b0ee162a228f128774fdefcbbb603c98aadc145fbe2b5e9ee8fd0c1a2ba444b2 INSTALL-ORDER.md
|
||||||
a62100ff7046a6e7d89adb8d2ab6bdf4ef7a72153df7cc439c8b00b42c7df347 README.md
|
e0182ff047fe6f8d71fc0a21fd73be3ced33b3f9fed990b902dd5f76a2f0a53a README.md
|
||||||
b72075836eddd338eb1402beab775b4dc21a61d315e78cd8385f719578924293 ROLLBACK.md
|
4f3f30e0f4c45f7ecfc6c6e169fda6b53b9eaa694f3ed26f818342fc80e7a446 ROLLBACK.md
|
||||||
b8a5f38f26fa76db6281a14de2baae3505cd78428b4c81a27a23ea89e256f321 STATIC-SAFETY-AUDIT.md
|
2a2aed8538b36afeed20bb58ed4168b3f2d55b0ed20e28f6860a16714a6df979 STATIC-SAFETY-AUDIT.md
|
||||||
05bdf987ad0049118acf9371c6affae0d7bf65bfcb869d297986860ee20dfac4 inventory/SHA256SUMS
|
05bdf987ad0049118acf9371c6affae0d7bf65bfcb869d297986860ee20dfac4 inventory/SHA256SUMS
|
||||||
e6f6da94601927a23d252f32de93e92c99d31fb2d2cbd9b00f2b88224dbb684e inventory/docker-networks.json
|
e6f6da94601927a23d252f32de93e92c99d31fb2d2cbd9b00f2b88224dbb684e inventory/docker-networks.json
|
||||||
9a5d343092c8cf473f0e4d06cb6848f1788aa8bc41b1b3fb7412faea7ea7b48c inventory/docker-networks.psv
|
9a5d343092c8cf473f0e4d06cb6848f1788aa8bc41b1b3fb7412faea7ea7b48c inventory/docker-networks.psv
|
||||||
|
|
@ -25,7 +25,11 @@ ea0c280203614c3e308013a8d51271bfa8adafd517f65cdf3224d2385244af6d payload/etc/sy
|
||||||
904c9b9e35f6927c0a5e65afb4d35b6bc9eb1278c878044501281fc728c9be46 payload/usr/local/libexec/dockerd-rootless-29.6.1.sh
|
904c9b9e35f6927c0a5e65afb4d35b6bc9eb1278c878044501281fc728c9be46 payload/usr/local/libexec/dockerd-rootless-29.6.1.sh
|
||||||
9aed833454a78dc99af6c18da1cf09660ef717964adfd309f61f043b53c05464 payload/usr/local/libexec/le-app-codex-netns
|
9aed833454a78dc99af6c18da1cf09660ef717964adfd309f61f043b53c05464 payload/usr/local/libexec/le-app-codex-netns
|
||||||
4f94d88283207b2bfa849f4e5b126efa8872be97e59edd1f700b1b0a80623035 tests/00-read-only-preflight.sh
|
4f94d88283207b2bfa849f4e5b126efa8872be97e59edd1f700b1b0a80623035 tests/00-read-only-preflight.sh
|
||||||
f7629ad1178720c6046bf73d2babdcf7f8db6398ded172c5a088340f38d077a2 tests/00-root-preinstall-validate.sh
|
c540a7b6c3cdd545e8cf66deb848f79bd2b1cf0c0c83728aa9f37548289fe94f tests/00-root-preinstall-validate.sh
|
||||||
9a573d539da46fbd683da60920596118174fdcf053f79f037ba21aee2deab82d tests/10-inert-containment.sh
|
9a573d539da46fbd683da60920596118174fdcf053f79f037ba21aee2deab82d tests/10-inert-containment.sh
|
||||||
0ec4d5d955e55fa8eb053fe2efa9a60509ada31afa48b71b29652dfacbf4d1c2 tests/20-rootless-docker.sh
|
0ec4d5d955e55fa8eb053fe2efa9a60509ada31afa48b71b29652dfacbf4d1c2 tests/20-rootless-docker.sh
|
||||||
|
bfacb009619c43b03e57834715fa625ce00c13a5b431396d68401916c31cb3a8 tests/30-install-stage1.sh
|
||||||
|
954979e35b6f39531ce4d8395d55f011cbcfec16eaeae941b6a73b732569e029 tests/31-rollback-stage1.sh
|
||||||
|
a9d235f4f5c1dd36ce50579344d099b5a41bf11514888de8ae8d35875f741aa4 tests/32-stage1-state-tests.sh
|
||||||
1cf5dfa80b8d00de3ef1a47d84c30a891f4c6192345d5fc9247c4ab7d4f40f31 tests/run-inert-without-user-manager.sh
|
1cf5dfa80b8d00de3ef1a47d84c30a891f4c6192345d5fc9247c4ab7d4f40f31 tests/run-inert-without-user-manager.sh
|
||||||
|
e8d7b15d10e66fc050eabc758338ec928d0f82af39ee075810865435f726db74 tests/stage1-state-lib.sh
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ There are no remaining documentation sentinels. The operator approved Quad9's ma
|
||||||
|
|
||||||
## Validation status
|
## Validation status
|
||||||
|
|
||||||
The root pre-install validator passed on TITANSERVER with zero failures: both manifests, staged nftables syntax, staged systemd units/drop-ins, shell/JSON syntax, Phase 1 ownership/modes, mount absence, installed-path absence, inactive user manager, absent linger/process/socket, and unchanged public/NAT IPv4 all passed. Missing `/usr/local/libexec` warnings were expected for the uninstalled payload.
|
The committed review set's root pre-install validator passed on TITANSERVER with zero failures: both manifests, staged nftables syntax, staged systemd units/drop-ins, shell/JSON syntax, Phase 1 ownership/modes, mount absence, installed-path absence, inactive user manager, absent linger/process/socket, and unchanged public/NAT IPv4 all passed. Missing `/usr/local/libexec` warnings were expected for the uninstalled payload. That host run predates the uncommitted Stage 1 recovery amendments; the future installer reruns the root validator against the then-current committed bytes before copying anything.
|
||||||
|
|
||||||
|
Stage 1 installation and rollback are repository-native scripts, not pasted heredocs. `tests/30-install-stage1.sh` installs exactly the 16 approved static files and validates shared parents without changing them. State uses immutable, verified generation directories under `/var/lib/le-app-codex-phase2b/stage1/generations`; a root-owned regular `current` pointer is the single atomic commit point. `IN_PROGRESS` is durable before payload copying and after every directory/file transition; `COMPLETE` requires exactly all 16 files and no pending record. `tests/31-rollback-stage1.sh` uses the strict parser in `tests/stage1-state-lib.sh`, supports both statuses, journals rollback with an atomic transaction-directory tombstone, removes only recorded matching objects, and preserves retryable state on payload or directory failure. The non-mutating `tests/32-stage1-state-tests.sh` exercises the shared parser, publication failpoints, pointer/generation rejection, partial and complete rollback simulations, and failure preservation in a temporary fake filesystem. Neither operational script creates the approval marker, reloads systemd, loads nftables, creates a namespace, or starts/enables anything.
|
||||||
|
|
||||||
See `STATIC-SAFETY-AUDIT.md`, `INSTALL-ORDER.md`, `ROLLBACK.md`, `tests/`, `inventory/`, and `MANIFEST.sha256`. Any edit invalidates the manifest and requires another review.
|
See `STATIC-SAFETY-AUDIT.md`, `INSTALL-ORDER.md`, `ROLLBACK.md`, `tests/`, `inventory/`, and `MANIFEST.sha256`. Any edit invalidates the manifest and requires another review.
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,8 @@
|
||||||
|
|
||||||
Do not remove Phase 1 identity/workspace, Phase 2A packages, kernels, snapshots, production resources, or unrelated nftables objects.
|
Do not remove Phase 1 identity/workspace, Phase 2A packages, kernels, snapshots, production resources, or unrelated nftables objects.
|
||||||
|
|
||||||
1. Stop `user@1200.service`; confirm UID 1200 has no processes. This is the only user-manager stop in scope.
|
For Stage 1 rollback, run only `tests/31-rollback-stage1.sh` from a clean artifact worktree whose `MANIFEST.sha256` digest matches the authoritative state generation. The current branch may have later unrelated commits. Rollback accepts verified `IN_PROGRESS` or `COMPLETE`, uses the allowlist parser shared through `tests/stage1-state-lib.sh`, and refuses operation unless the approval marker, user manager, UID-1200 processes, socket, namespace, and project nftables tables are absent. Before removal it atomically renames the whole Stage 1 transaction subtree to a retryable cleanup tombstone. A retry accepts already-removed entries and re-verifies and removes any matching entries that remain. It removes only recorded matching payload/temporary files and created or pending directories; a `COMPLETE` record requires all 16 files. Shared parents recorded as pre-existing are never removed or altered. Immutable authoritative state remains intact through all payload and directory removals and while unreferenced state is cleaned; its manifest and checksum are removed only in the final cleanup phase.
|
||||||
2. Stop `le-app-codex-netns.service`. Its `ExecStop` removes only `lecodex-host` and `/run/netns/le-app-codex` and deletes only the two `le_app_codex*` nftables tables.
|
|
||||||
3. Remove only the Phase 2B files enumerated in `MANIFEST.sha256` and `/etc/le-app-codex-runtime/OPERATOR-INPUTS-APPROVED`, preserving the pre-existing `/srv/le-app-codex` skeleton.
|
|
||||||
4. Run `systemctl daemon-reload` and `systemctl reset-failed le-app-codex-netns.service`.
|
|
||||||
5. Confirm there is no UID 1200 process, socket, namespace, veth, project nftables table, containment/resource drop-in, launcher, Docker user unit, daemon/client config, or Phase 2B test copy.
|
|
||||||
6. Confirm lingering is still disabled and production/rootful Docker remains healthy and unchanged.
|
|
||||||
|
|
||||||
If a file existed before Phase 2B, stop: this rollback package intentionally has no overwrite/restore path because installation must have failed closed on pre-existence.
|
Later-stage rollback, after explicit runtime activation, is separate: stop the user manager and namespace first, then verify the runtime is inert before using a stage-appropriate reviewed rollback. Stage 1 rollback must never be used after the approval marker is created or any namespace/firewall/runtime component has been activated.
|
||||||
|
|
||||||
|
If a destination file existed before Phase 2B, stop: Stage 1 has no overwrite/restore path and fails closed on pre-existence.
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,8 @@ Status: pass for final review; execution remains approval-gated.
|
||||||
- **Existing nftables:** the verified ruleset has no `le_app_codex` objects. Dedicated tables avoid editing Docker-owned chains. Interface-scoped terminal drops remain effective alongside existing Docker base chains.
|
- **Existing nftables:** the verified ruleset has no `le_app_codex` objects. Dedicated tables avoid editing Docker-owned chains. Interface-scoped terminal drops remain effective alongside existing Docker base chains.
|
||||||
- **Inventory:** the root record checksums pass. UID 1200 is inactive, not lingering, has no process/runtime/socket; project ACLs and modes match Phase 1; no project-rooted mounts exist; all 41 Docker networks fall within denied IPv4/IPv6 space.
|
- **Inventory:** the root record checksums pass. UID 1200 is inactive, not lingering, has no process/runtime/socket; project ACLs and modes match Phase 1; no project-rooted mounts exist; all 41 Docker networks fall within denied IPv4/IPv6 space.
|
||||||
- **Rollback:** names and paths are Phase 2B-specific; teardown deletes only the project tables, namespace/veth, marker, and manifest-listed files.
|
- **Rollback:** names and paths are Phase 2B-specific; teardown deletes only the project tables, namespace/veth, marker, and manifest-listed files.
|
||||||
|
- **Stage 1 installer:** installs exactly 16 allowlisted files; long Docker-context paths are single-line variables; existing shared parents are accepted only when real, root-owned, and not group/other writable; existing shared parent metadata is never changed.
|
||||||
|
- **Stage 1 state/rollback:** immutable mode `0600` manifest/checksum generations record commit, artifact-manifest digest, hashes, pending/completed files, pending/created/pre-existing directories, time, and host. A verified regular `current` pointer is switched by one atomic rename only after the new generation is synced and semantically validated. Rollback accepts `IN_PROGRESS` subsets or an exact 16-file `COMPLETE`, atomically tombstones the transaction before removal, re-verifies remaining objects on retry, and calls `rmdir` only for allowlisted recorded directories.
|
||||||
|
- **Crash recovery:** deterministic `.phase2b-new` payload temporaries are covered by `FILE_PENDING`; hidden incomplete state generations never become authoritative; old generations are retained. The parser shared by installer, rollback, and `tests/32-stage1-state-tests.sh` rejects blank, duplicate, unknown, malformed, unapproved, or escaping records. The state resolver separately rejects symlinked or escaping pointers/generations and unverified generation content. Publication failpoints cover initial and updated `IN_PROGRESS`, final `COMPLETE`, and both sides of the atomic pointer switch.
|
||||||
|
|
||||||
Hard stops remain: changed Docker version/launcher hash, changed inventory, public/NAT address drift, missing approval marker at namespace-start time, pre-existing project tables/namespace, any acceptance failure, or any endpoint other than the single rootless Unix socket. `ProtectProc=invisible` remains an unproven runtime acceptance candidate.
|
Hard stops remain: changed Docker version/launcher hash, changed inventory, public/NAT address drift, missing approval marker at namespace-start time, pre-existing project tables/namespace, any acceptance failure, or any endpoint other than the single rootless Unix socket. `ProtectProc=invisible` remains an unproven runtime acceptance candidate.
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@ if [ "$user_systemd_rc" -eq 0 ] || [ -z "$user_systemd_unexpected" ]; then pass
|
||||||
|
|
||||||
shell_failures=0
|
shell_failures=0
|
||||||
for file in "$artifact_root"/tests/*.sh "$artifact_root"/payload/usr/local/libexec/*; do
|
for file in "$artifact_root"/tests/*.sh "$artifact_root"/payload/usr/local/libexec/*; do
|
||||||
/bin/sh -n "$file" || shell_failures=$((shell_failures + 1))
|
case "$file" in
|
||||||
|
*/30-install-stage1.sh|*/31-rollback-stage1.sh|*/32-stage1-state-tests.sh|*/stage1-state-lib.sh) /bin/bash -n "$file" || shell_failures=$((shell_failures + 1)) ;;
|
||||||
|
*) /bin/sh -n "$file" || shell_failures=$((shell_failures + 1)) ;;
|
||||||
|
esac
|
||||||
done
|
done
|
||||||
if [ "$shell_failures" -eq 0 ]; then pass 'shell syntax'; else fail 'shell syntax'; fi
|
if [ "$shell_failures" -eq 0 ]; then pass 'shell syntax'; else fail 'shell syntax'; fi
|
||||||
|
|
||||||
|
|
@ -89,10 +92,10 @@ expect_absent /srv/le-app-codex/phase2b-tests/run-inert-without-user-manager.sh
|
||||||
|
|
||||||
active_state=$(/usr/bin/systemctl show user@1200.service -p ActiveState --value 2>/dev/null)
|
active_state=$(/usr/bin/systemctl show user@1200.service -p ActiveState --value 2>/dev/null)
|
||||||
if [ "$active_state" = inactive ]; then pass 'user@1200.service inactive'; else fail "user@1200.service state is ${active_state:-unknown}"; fi
|
if [ "$active_state" = inactive ]; then pass 'user@1200.service inactive'; else fail "user@1200.service state is ${active_state:-unknown}"; fi
|
||||||
if [ ! -e /var/lib/systemd/linger/le_app_codex ] && [ ! -e /var/lib/systemd/linger/1200 ]; then pass 'linger absent'; else fail 'linger present'; fi
|
if [ ! -e /var/lib/systemd/linger/le_app_codex ] && [ ! -L /var/lib/systemd/linger/le_app_codex ] && [ ! -e /var/lib/systemd/linger/1200 ] && [ ! -L /var/lib/systemd/linger/1200 ]; then pass 'linger absent'; else fail 'linger present'; fi
|
||||||
uid_processes=$(/usr/bin/pgrep -u 1200 2>/dev/null)
|
uid_processes=$(/usr/bin/pgrep -u 1200 2>/dev/null)
|
||||||
if [ -z "$uid_processes" ]; then pass 'UID 1200 has no processes'; else fail "UID 1200 processes found: $uid_processes"; fi
|
if [ -z "$uid_processes" ]; then pass 'UID 1200 has no processes'; else fail "UID 1200 processes found: $uid_processes"; fi
|
||||||
if [ ! -S /run/user/1200/docker.sock ]; then pass 'rootless Docker socket absent'; else fail 'rootless Docker socket exists'; fi
|
if [ ! -e /run/user/1200/docker.sock ] && [ ! -L /run/user/1200/docker.sock ]; then pass 'rootless Docker socket absent'; else fail 'rootless Docker socket path exists'; fi
|
||||||
|
|
||||||
public_ipv4=$(/usr/bin/curl -4fsS --max-time 10 https://api.ipify.org 2>/dev/null)
|
public_ipv4=$(/usr/bin/curl -4fsS --max-time 10 https://api.ipify.org 2>/dev/null)
|
||||||
if [ "$public_ipv4" = 74.67.173.56 ]; then pass 'public/NAT IPv4 unchanged'; else fail "public/NAT IPv4 drift or lookup failure: ${public_ipv4:-unavailable}"; fi
|
if [ "$public_ipv4" = 74.67.173.56 ]; then pass 'public/NAT IPv4 unchanged'; else fail "public/NAT IPv4 drift or lookup failure: ${public_ipv4:-unavailable}"; fi
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
installed_count=0
|
||||||
|
artifact_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd)
|
||||||
|
repo_root=$(CDPATH= cd -- "$artifact_root/../../.." 2>/dev/null && pwd)
|
||||||
|
payload_root=$artifact_root/payload
|
||||||
|
state_dir=/var/lib/le-app-codex-phase2b
|
||||||
|
state_transaction=/var/lib/le-app-codex-phase2b/stage1
|
||||||
|
state_current=/var/lib/le-app-codex-phase2b/stage1/current
|
||||||
|
context_hash=1b645e55bd6af77d420732b143423e7a27b12fbb5a71497e5d89c52481b6a535
|
||||||
|
context_source=$payload_root/srv/le-app-codex/home/.docker/contexts/meta/$context_hash/meta.json
|
||||||
|
context_destination=/srv/le-app-codex/home/.docker/contexts/meta/$context_hash/meta.json
|
||||||
|
source "$artifact_root/tests/stage1-state-lib.sh"
|
||||||
|
stage1_records=()
|
||||||
|
|
||||||
|
pass() { printf 'PASS: %s\n' "$1"; }
|
||||||
|
fail() { printf 'FAIL: %s\n' "$1" >&2; failures=$((failures + 1)); }
|
||||||
|
|
||||||
|
safe_shared_directory() {
|
||||||
|
local path=$1 actual_uid actual_gid actual_mode
|
||||||
|
if [ ! -d "$path" ] || [ -L "$path" ]; then
|
||||||
|
fail "$path must be a real directory"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
actual_uid=$(/usr/bin/stat -c '%u' "$path" 2>/dev/null)
|
||||||
|
actual_gid=$(/usr/bin/stat -c '%g' "$path" 2>/dev/null)
|
||||||
|
actual_mode=$(/usr/bin/stat -c '%a' "$path" 2>/dev/null)
|
||||||
|
if [ "$actual_uid" != 0 ] || [ "$actual_gid" != 0 ]; then
|
||||||
|
fail "$path must be root:root"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ $((8#$actual_mode & 0022)) -ne 0 ]; then
|
||||||
|
fail "$path must not be group/other writable"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
pass "$path safe shared parent"
|
||||||
|
}
|
||||||
|
|
||||||
|
exact_directory() {
|
||||||
|
local path=$1 uid=$2 gid=$3 mode=$4 kind=$5 actual
|
||||||
|
if [ -e "$path" ] || [ -L "$path" ]; then
|
||||||
|
if [ ! -d "$path" ] || [ -L "$path" ]; then
|
||||||
|
fail "$path exists but is not a real directory"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
actual=$(/usr/bin/stat -c '%u:%g:%a:%F' "$path" 2>/dev/null)
|
||||||
|
if [ "$actual" != "$uid:$gid:${mode#0}:directory" ]; then
|
||||||
|
fail "$path expected $uid:$gid:${mode#0}:directory, got ${actual:-missing}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
stage1_records+=("DIR_EXISTING|$path|$uid|$gid|$mode|$kind")
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_transaction" 0 0 0600 || fail "publish state after accepting $path"
|
||||||
|
pass "$path existing directory accepted"
|
||||||
|
else
|
||||||
|
stage1_records+=("DIR_PENDING|$path|$uid|$gid|$mode|$kind")
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_transaction" 0 0 0600 || fail "publish state before creating $path"
|
||||||
|
if [ "$failures" -ne 0 ]; then return 1; fi
|
||||||
|
if /usr/bin/install -d -o "$uid" -g "$gid" -m "$mode" "$path"; then
|
||||||
|
stage1_records[${#stage1_records[@]}-1]="DIR_CREATED|$path|$uid|$gid|$mode|$kind"
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_transaction" 0 0 0600 || fail "publish state after creating $path"
|
||||||
|
pass "$path created"
|
||||||
|
else
|
||||||
|
fail "$path creation"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_absent_file() {
|
||||||
|
local path=$1
|
||||||
|
if [ ! -e "$path" ] && [ ! -L "$path" ]; then
|
||||||
|
pass "$path absent"
|
||||||
|
else
|
||||||
|
fail "$path unexpectedly exists"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
record_existing_base() {
|
||||||
|
local path=$1 kind=$2 uid gid mode
|
||||||
|
uid=$(/usr/bin/stat -c '%u' "$path" 2>/dev/null)
|
||||||
|
gid=$(/usr/bin/stat -c '%g' "$path" 2>/dev/null)
|
||||||
|
mode=$(/usr/bin/stat -c '%a' "$path" 2>/dev/null)
|
||||||
|
stage1_records+=("DIR_EXISTING|$path|$uid|$gid|0$mode|$kind")
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_transaction" 0 0 0600 || fail "publish state after recording $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_file() {
|
||||||
|
local source_file=$1 destination=$2 uid=$3 gid=$4 mode=$5
|
||||||
|
local source_hash destination_hash source_size destination_size destination_stat item_ok=1
|
||||||
|
source_hash=$(/usr/bin/sha256sum "$source_file" 2>/dev/null | /usr/bin/cut -d' ' -f1)
|
||||||
|
destination_hash=$(/usr/bin/sha256sum "$destination" 2>/dev/null | /usr/bin/cut -d' ' -f1)
|
||||||
|
source_size=$(/usr/bin/stat -c '%s' "$source_file" 2>/dev/null)
|
||||||
|
destination_size=$(/usr/bin/stat -c '%s' "$destination" 2>/dev/null)
|
||||||
|
destination_stat=$(/usr/bin/stat -c '%u:%g:%a:%F' "$destination" 2>/dev/null)
|
||||||
|
[ -n "$source_hash" ] && [ "$destination_hash" = "$source_hash" ] || item_ok=0
|
||||||
|
[ -n "$source_size" ] && [ "$destination_size" = "$source_size" ] || item_ok=0
|
||||||
|
[ "$destination_stat" = "$uid:$gid:${mode#0}:regular file" ] || item_ok=0
|
||||||
|
if [ "$item_ok" -eq 1 ]; then
|
||||||
|
pass "$destination verified"
|
||||||
|
else
|
||||||
|
fail "$destination hash, size, or metadata mismatch"
|
||||||
|
fi
|
||||||
|
[ "$item_ok" -eq 1 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic_install() {
|
||||||
|
local source_file=$1 destination=$2 uid=$3 gid=$4 mode=$5
|
||||||
|
local temporary=${destination}.phase2b-new install_ok=1 source_hash source_size
|
||||||
|
if [ -e "$destination" ] || [ -L "$destination" ] || [ -e "$temporary" ] || [ -L "$temporary" ]; then
|
||||||
|
fail "$destination or its temporary path exists"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
source_hash=$(/usr/bin/sha256sum "$source_file" | /usr/bin/cut -d' ' -f1)
|
||||||
|
source_size=$(/usr/bin/stat -c '%s' "$source_file")
|
||||||
|
stage1_records+=("FILE_PENDING|$destination|$source_hash|$source_size|$uid|$gid|$mode")
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_transaction" 0 0 0600 || install_ok=0
|
||||||
|
if [ "$install_ok" -eq 1 ]; then /usr/bin/install -o "$uid" -g "$gid" -m "$mode" -T "$source_file" "$temporary" || install_ok=0; fi
|
||||||
|
if [ "$install_ok" -eq 1 ]; then verify_file "$source_file" "$temporary" "$uid" "$gid" "$mode" || install_ok=0; fi
|
||||||
|
if [ "$install_ok" -eq 1 ]; then { /usr/bin/sync -f "$temporary" 2>/dev/null || /usr/bin/sync; } || install_ok=0; fi
|
||||||
|
if [ "$install_ok" -eq 1 ]; then /usr/bin/ln -- "$temporary" "$destination" || install_ok=0; fi
|
||||||
|
if [ "$install_ok" -eq 1 ]; then { /usr/bin/sync -f "$destination" 2>/dev/null || /usr/bin/sync; } || install_ok=0; fi
|
||||||
|
if [ "$install_ok" -eq 1 ]; then { /usr/bin/sync -f "$(dirname -- "$destination")" 2>/dev/null || /usr/bin/sync; } || install_ok=0; fi
|
||||||
|
if [ -e "$temporary" ] && ! /usr/bin/rm -- "$temporary"; then install_ok=0; fi
|
||||||
|
if [ "$install_ok" -eq 1 ]; then verify_file "$source_file" "$destination" "$uid" "$gid" "$mode" || install_ok=0; fi
|
||||||
|
if [ "$install_ok" -eq 1 ]; then
|
||||||
|
stage1_records[${#stage1_records[@]}-1]="FILE|$destination|$source_hash|$source_size|$uid|$gid|$mode"
|
||||||
|
installed_count=$((installed_count + 1))
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_transaction" 0 0 0600 || install_ok=0
|
||||||
|
fi
|
||||||
|
if [ "$install_ok" -ne 1 ]; then fail "$destination installation or state publication"; fi
|
||||||
|
[ "$install_ok" -eq 1 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$(id -u)" = 0 ]; then pass 'running as root'; else fail 'must run as root'; fi
|
||||||
|
source_commit=$(/usr/bin/git -c safe.directory="$repo_root" -C "$repo_root" rev-parse HEAD 2>/dev/null)
|
||||||
|
source_commit_rc=$?
|
||||||
|
source_status=$(/usr/bin/git -c safe.directory="$repo_root" -C "$repo_root" status --porcelain 2>/dev/null)
|
||||||
|
source_status_rc=$?
|
||||||
|
if [ "$source_commit_rc" -eq 0 ] && [[ "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then pass "source commit $source_commit"; else fail 'source commit unavailable'; fi
|
||||||
|
if [ "$source_status_rc" -eq 0 ] && [ -z "$source_status" ]; then pass 'source tree clean'; else fail 'source tree is dirty or unreadable'; fi
|
||||||
|
if (cd "$artifact_root" && /usr/bin/sha256sum -c MANIFEST.sha256); then pass 'artifact manifest'; else fail 'artifact manifest'; fi
|
||||||
|
if (cd "$artifact_root/inventory" && /usr/bin/sha256sum -c SHA256SUMS); then pass 'root inventory'; else fail 'root inventory'; fi
|
||||||
|
|
||||||
|
if [ "$failures" -eq 0 ]; then
|
||||||
|
/bin/sh "$artifact_root/tests/00-root-preinstall-validate.sh"
|
||||||
|
validator_rc=$?
|
||||||
|
if [ "$validator_rc" -eq 0 ]; then pass 'root pre-install validator'; else fail "root pre-install validator rc=$validator_rc"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
safe_shared_directory /etc || true
|
||||||
|
safe_shared_directory /etc/systemd/system || true
|
||||||
|
safe_shared_directory /usr/local || true
|
||||||
|
safe_shared_directory /var || true
|
||||||
|
safe_shared_directory /var/lib || true
|
||||||
|
if [ ! -d /srv/le-app-codex ] || [ -L /srv/le-app-codex ]; then fail '/srv/le-app-codex boundary'; fi
|
||||||
|
if [ ! -d /srv/le-app-codex/home ] || [ -L /srv/le-app-codex/home ]; then fail '/srv/le-app-codex/home boundary'; fi
|
||||||
|
|
||||||
|
expect_absent_file "$state_current"
|
||||||
|
expect_absent_file /etc/le-app-codex-runtime/OPERATOR-INPUTS-APPROVED
|
||||||
|
|
||||||
|
destinations=(
|
||||||
|
/usr/local/libexec/dockerd-rootless-29.6.1.sh
|
||||||
|
/usr/local/libexec/le-app-codex-netns
|
||||||
|
/etc/le-app-codex-runtime/resolv.conf
|
||||||
|
/etc/nftables.d/50-le-app-codex.nft
|
||||||
|
/etc/systemd/system/le-app-codex-netns.service
|
||||||
|
/etc/systemd/system/user-1200.slice.d/50-le-app-codex-resources.conf
|
||||||
|
/etc/systemd/system/user@1200.service.d/50-le-app-codex-containment.conf
|
||||||
|
/srv/le-app-codex/home/.config/systemd/user/docker.service
|
||||||
|
/srv/le-app-codex/home/.config/docker/daemon.json
|
||||||
|
/srv/le-app-codex/home/.docker/config.json
|
||||||
|
"$context_destination"
|
||||||
|
/srv/le-app-codex/phase2b-tests/00-read-only-preflight.sh
|
||||||
|
/srv/le-app-codex/phase2b-tests/00-root-preinstall-validate.sh
|
||||||
|
/srv/le-app-codex/phase2b-tests/10-inert-containment.sh
|
||||||
|
/srv/le-app-codex/phase2b-tests/20-rootless-docker.sh
|
||||||
|
/srv/le-app-codex/phase2b-tests/run-inert-without-user-manager.sh
|
||||||
|
)
|
||||||
|
sources=(
|
||||||
|
"$payload_root/usr/local/libexec/dockerd-rootless-29.6.1.sh" "$payload_root/usr/local/libexec/le-app-codex-netns" "$payload_root/etc/le-app-codex-runtime/resolv.conf" "$payload_root/etc/nftables.d/50-le-app-codex.nft"
|
||||||
|
"$payload_root/etc/systemd/system/le-app-codex-netns.service" "$payload_root/etc/systemd/system/user-1200.slice.d/50-le-app-codex-resources.conf" "$payload_root/etc/systemd/system/user@1200.service.d/50-le-app-codex-containment.conf"
|
||||||
|
"$payload_root/srv/le-app-codex/home/.config/systemd/user/docker.service" "$payload_root/srv/le-app-codex/home/.config/docker/daemon.json" "$payload_root/srv/le-app-codex/home/.docker/config.json" "$context_source"
|
||||||
|
"$artifact_root/tests/00-read-only-preflight.sh" "$artifact_root/tests/00-root-preinstall-validate.sh" "$artifact_root/tests/10-inert-containment.sh" "$artifact_root/tests/20-rootless-docker.sh" "$artifact_root/tests/run-inert-without-user-manager.sh"
|
||||||
|
)
|
||||||
|
uids=(0 0 0 0 0 0 0 1200 1200 1200 1200 1200 1200 1200 1200 1200)
|
||||||
|
gids=(0 0 0 0 0 0 0 1200 1200 1200 1200 1200 1200 1200 1200 1200)
|
||||||
|
modes=(0755 0755 0644 0644 0644 0644 0644 0640 0640 0640 0640 0750 0750 0750 0750 0750)
|
||||||
|
allowed_directories=(
|
||||||
|
/srv/le-app-codex/home/.docker/contexts/meta/$context_hash /srv/le-app-codex/home/.docker/contexts/meta /srv/le-app-codex/home/.docker/contexts /srv/le-app-codex/home/.docker
|
||||||
|
/srv/le-app-codex/home/.config/systemd/user /srv/le-app-codex/home/.config/systemd /srv/le-app-codex/home/.config/docker /srv/le-app-codex/home/.config /srv/le-app-codex/phase2b-tests
|
||||||
|
/etc/systemd/system/user@1200.service.d /etc/systemd/system/user-1200.slice.d /etc/le-app-codex-runtime /etc/nftables.d /usr/local/libexec "$state_dir"
|
||||||
|
/srv/le-app-codex/home /srv/le-app-codex /usr/local /etc/systemd/system /etc
|
||||||
|
)
|
||||||
|
directory_uids=(1200 1200 1200 1200 1200 1200 1200 1200 1200 0 0 0 0 0 0 1200 0 0 0 0)
|
||||||
|
directory_gids=(1200 1200 1200 1200 1200 1200 1200 1200 1200 0 0 0 0 0 0 1200 1200 0 0 0)
|
||||||
|
directory_modes=(0750 0750 0750 0750 0750 0750 0750 0750 0750 0755 0755 0755 0755 0755 0700 0700 0750 0755 0755 0755)
|
||||||
|
directory_kinds=(project project project project project project project project project project project project shared shared state project-boundary project-boundary shared-base shared-base shared-base)
|
||||||
|
declare -A stage1_allowed_file_specs=() stage1_allowed_directory_specs=() stage1_never_created_directories=()
|
||||||
|
for index in "${!destinations[@]}"; do source_hash=$(/usr/bin/sha256sum "${sources[$index]}" 2>/dev/null | /usr/bin/cut -d' ' -f1); source_size=$(/usr/bin/stat -c '%s' "${sources[$index]}" 2>/dev/null); stage1_allowed_file_specs["${destinations[$index]}"]="$source_hash|$source_size|${uids[$index]}|${gids[$index]}|${modes[$index]}"; done
|
||||||
|
for index in "${!allowed_directories[@]}"; do stage1_allowed_directory_specs["${allowed_directories[$index]}"]="${directory_uids[$index]}|${directory_gids[$index]}|${directory_modes[$index]}|${directory_kinds[$index]}"; done
|
||||||
|
for directory in /srv/le-app-codex/home /srv/le-app-codex /usr/local /etc/systemd/system /etc; do stage1_never_created_directories["$directory"]=1; done
|
||||||
|
for destination in "${destinations[@]}"; do expect_absent_file "$destination"; done
|
||||||
|
|
||||||
|
state_parent_preexisting=0
|
||||||
|
if [ -e "$state_dir" ] || [ -L "$state_dir" ]; then
|
||||||
|
state_parent_preexisting=1
|
||||||
|
state_parent_stat=$(/usr/bin/stat -c '%u:%g:%a:%F' "$state_dir" 2>/dev/null)
|
||||||
|
if [ -L "$state_dir" ] || [ "$state_parent_stat" != '0:0:700:directory' ]; then fail "$state_dir must be a real root:root mode 0700 directory"; fi
|
||||||
|
if ! state_existing_content=$(/usr/bin/find "$state_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null); then fail "$state_dir inspection"; fi
|
||||||
|
if [ -n "$state_existing_content" ]; then fail "$state_dir is not empty; recover or review existing state before retry"; fi
|
||||||
|
fi
|
||||||
|
if [ "$failures" -eq 0 ] && [ "$state_parent_preexisting" -eq 0 ]; then /usr/bin/install -d -o 0 -g 0 -m 0700 "$state_dir" || fail "$state_dir creation"; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then /usr/bin/install -d -o 0 -g 0 -m 0700 "$state_transaction" || fail "$state_transaction creation"; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then
|
||||||
|
umask 077
|
||||||
|
stage1_source_commit=$source_commit
|
||||||
|
stage1_artifact_digest=$(/usr/bin/sha256sum "$artifact_root/MANIFEST.sha256" | /usr/bin/cut -d' ' -f1)
|
||||||
|
stage1_timestamp=$(/usr/bin/date --iso-8601=seconds)
|
||||||
|
stage1_hostname=$(/usr/bin/hostname)
|
||||||
|
if [ "$state_parent_preexisting" -eq 1 ]; then stage1_records+=("DIR_EXISTING|$state_dir|0|0|0700|state"); else stage1_records+=("DIR_CREATED|$state_dir|0|0|0700|state"); fi
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_transaction" 0 0 0600 || fail 'publish initial IN_PROGRESS state'
|
||||||
|
fi
|
||||||
|
if [ "$failures" -eq 0 ]; then record_existing_base /etc shared-base; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then record_existing_base /etc/systemd/system shared-base; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then record_existing_base /usr/local shared-base; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then record_existing_base /srv/le-app-codex project-boundary; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then record_existing_base /srv/le-app-codex/home project-boundary; fi
|
||||||
|
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /usr/local/libexec 0 0 0755 shared || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /etc/nftables.d 0 0 0755 shared || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /etc/le-app-codex-runtime 0 0 0755 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /etc/systemd/system/user-1200.slice.d 0 0 0755 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /etc/systemd/system/user@1200.service.d 0 0 0755 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /srv/le-app-codex/home/.config 1200 1200 0750 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /srv/le-app-codex/home/.config/systemd 1200 1200 0750 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /srv/le-app-codex/home/.config/systemd/user 1200 1200 0750 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /srv/le-app-codex/home/.config/docker 1200 1200 0750 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /srv/le-app-codex/home/.docker 1200 1200 0750 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /srv/le-app-codex/home/.docker/contexts 1200 1200 0750 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /srv/le-app-codex/home/.docker/contexts/meta 1200 1200 0750 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory "/srv/le-app-codex/home/.docker/contexts/meta/$context_hash" 1200 1200 0750 project || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then exact_directory /srv/le-app-codex/phase2b-tests 1200 1200 0750 project || true; fi
|
||||||
|
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/usr/local/libexec/dockerd-rootless-29.6.1.sh" /usr/local/libexec/dockerd-rootless-29.6.1.sh 0 0 0755 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/usr/local/libexec/le-app-codex-netns" /usr/local/libexec/le-app-codex-netns 0 0 0755 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/etc/le-app-codex-runtime/resolv.conf" /etc/le-app-codex-runtime/resolv.conf 0 0 0644 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/etc/nftables.d/50-le-app-codex.nft" /etc/nftables.d/50-le-app-codex.nft 0 0 0644 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/etc/systemd/system/le-app-codex-netns.service" /etc/systemd/system/le-app-codex-netns.service 0 0 0644 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/etc/systemd/system/user-1200.slice.d/50-le-app-codex-resources.conf" /etc/systemd/system/user-1200.slice.d/50-le-app-codex-resources.conf 0 0 0644 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/etc/systemd/system/user@1200.service.d/50-le-app-codex-containment.conf" /etc/systemd/system/user@1200.service.d/50-le-app-codex-containment.conf 0 0 0644 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/srv/le-app-codex/home/.config/systemd/user/docker.service" /srv/le-app-codex/home/.config/systemd/user/docker.service 1200 1200 0640 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/srv/le-app-codex/home/.config/docker/daemon.json" /srv/le-app-codex/home/.config/docker/daemon.json 1200 1200 0640 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$payload_root/srv/le-app-codex/home/.docker/config.json" /srv/le-app-codex/home/.docker/config.json 1200 1200 0640 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$context_source" "$context_destination" 1200 1200 0640 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$artifact_root/tests/00-read-only-preflight.sh" /srv/le-app-codex/phase2b-tests/00-read-only-preflight.sh 1200 1200 0750 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$artifact_root/tests/00-root-preinstall-validate.sh" /srv/le-app-codex/phase2b-tests/00-root-preinstall-validate.sh 1200 1200 0750 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$artifact_root/tests/10-inert-containment.sh" /srv/le-app-codex/phase2b-tests/10-inert-containment.sh 1200 1200 0750 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$artifact_root/tests/20-rootless-docker.sh" /srv/le-app-codex/phase2b-tests/20-rootless-docker.sh 1200 1200 0750 || true; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then atomic_install "$artifact_root/tests/run-inert-without-user-manager.sh" /srv/le-app-codex/phase2b-tests/run-inert-without-user-manager.sh 1200 1200 0750 || true; fi
|
||||||
|
|
||||||
|
if [ "$failures" -eq 0 ] && [ "$installed_count" -eq 16 ]; then stage1_state_publish COMPLETE "$state_transaction" 0 0 0600 || fail 'publish final COMPLETE state'; fi
|
||||||
|
|
||||||
|
if [ ! -e /etc/le-app-codex-runtime/OPERATOR-INPUTS-APPROVED ] && [ ! -L /etc/le-app-codex-runtime/OPERATOR-INPUTS-APPROVED ]; then pass 'approval marker absent'; else fail 'approval marker exists'; fi
|
||||||
|
manager_state=$(/usr/bin/systemctl show user@1200.service -p ActiveState --value 2>/dev/null)
|
||||||
|
if [ "$manager_state" = inactive ]; then pass 'user manager inactive'; else fail "user manager state ${manager_state:-unknown}"; fi
|
||||||
|
if [ ! -e /var/lib/systemd/linger/le_app_codex ] && [ ! -L /var/lib/systemd/linger/le_app_codex ] && [ ! -e /var/lib/systemd/linger/1200 ] && [ ! -L /var/lib/systemd/linger/1200 ]; then pass 'linger absent'; else fail 'linger present'; fi
|
||||||
|
uid_processes=$(/usr/bin/pgrep -u 1200 2>/dev/null)
|
||||||
|
if [ -z "$uid_processes" ]; then pass 'UID 1200 processes absent'; else fail 'UID 1200 processes present'; fi
|
||||||
|
if [ ! -e /run/user/1200/docker.sock ] && [ ! -L /run/user/1200/docker.sock ]; then pass 'Docker socket absent'; else fail 'Docker socket path present'; fi
|
||||||
|
namespace=$(/usr/bin/ip netns list 2>/dev/null | /usr/bin/awk '$1 == "le-app-codex" { print $1 }')
|
||||||
|
if [ -z "$namespace" ] && [ ! -e /run/netns/le-app-codex ] && [ ! -L /run/netns/le-app-codex ]; then pass 'namespace absent'; else fail 'namespace present'; fi
|
||||||
|
if /usr/bin/nft list table inet le_app_codex >/dev/null 2>&1; then fail 'inet policy loaded'; else pass 'inet policy absent'; fi
|
||||||
|
if /usr/bin/nft list table ip le_app_codex_nat >/dev/null 2>&1; then fail 'NAT policy loaded'; else pass 'NAT policy absent'; fi
|
||||||
|
|
||||||
|
printf 'Phase 2B Stage 1 installed=%s state=%s failures=%s\n' "$installed_count" "$state_current" "$failures"
|
||||||
|
if [ "$failures" -ne 0 ] && [ -f "$state_current" ]; then printf 'Rollback: /bin/bash %s/tests/31-rollback-stage1.sh\n' "$artifact_root" >&2; fi
|
||||||
|
if [ "$failures" -eq 0 ] && [ "$installed_count" -eq 16 ]; then stage1_state_resolve_current "$state_transaction" 0 0 0600 || failures=$((failures + 1)); fi
|
||||||
|
current_status=$(/usr/bin/awk -F'|' '$1 == "STATUS" { print $2 }' "${stage1_current_manifest:-/nonexistent}" 2>/dev/null)
|
||||||
|
[ "$failures" -eq 0 ] && [ "$installed_count" -eq 16 ] && [ "$current_status" = COMPLETE ]
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
removed_count=0
|
||||||
|
artifact_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd)
|
||||||
|
repo_root=$(CDPATH= cd -- "$artifact_root/../../.." 2>/dev/null && pwd)
|
||||||
|
payload_root=$artifact_root/payload
|
||||||
|
state_dir=/var/lib/le-app-codex-phase2b
|
||||||
|
state_transaction=/var/lib/le-app-codex-phase2b/stage1
|
||||||
|
context_hash=1b645e55bd6af77d420732b143423e7a27b12fbb5a71497e5d89c52481b6a535
|
||||||
|
context_source=$payload_root/srv/le-app-codex/home/.docker/contexts/meta/$context_hash/meta.json
|
||||||
|
context_destination=/srv/le-app-codex/home/.docker/contexts/meta/$context_hash/meta.json
|
||||||
|
source "$artifact_root/tests/stage1-state-lib.sh"
|
||||||
|
cleanup_resume=0
|
||||||
|
|
||||||
|
pass() { printf 'PASS: %s\n' "$1"; }
|
||||||
|
fail() { printf 'FAIL: %s\n' "$1" >&2; failures=$((failures + 1)); }
|
||||||
|
|
||||||
|
validate_state_tree() {
|
||||||
|
local root=$1 entry name generation child child_name child_count generation_kind pointer_temp_value
|
||||||
|
local top_current=0 top_generations=0 checksum_record expected_checksum_record checksum_lines
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
name=${entry##*/}
|
||||||
|
case "$name" in
|
||||||
|
current)
|
||||||
|
stage1_state_verify_file "$entry" 0 0 0600 || fail "unsafe state pointer $entry"
|
||||||
|
top_current=$((top_current + 1))
|
||||||
|
;;
|
||||||
|
generations)
|
||||||
|
stage1_state_path_is_real_directory "$entry" && [ "$(/usr/bin/stat -c '%u:%g:%a' "$entry" 2>/dev/null)" = '0:0:700' ] || fail "unsafe generations directory $entry"
|
||||||
|
top_generations=$((top_generations + 1))
|
||||||
|
;;
|
||||||
|
.current.new.[0-9]*)
|
||||||
|
[[ "$name" =~ ^[.]current[.]new[.][0-9]+$ ]] || fail "invalid state pointer temporary $entry"
|
||||||
|
stage1_state_verify_file "$entry" 0 0 0600 || fail "unsafe state pointer temporary $entry"
|
||||||
|
pointer_temp_value=$(<"$entry")
|
||||||
|
[[ "$pointer_temp_value" =~ ^gen-[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+$ ]] || fail "invalid state pointer temporary content $entry"
|
||||||
|
;;
|
||||||
|
*) fail "unexpected state transaction entry $entry" ;;
|
||||||
|
esac
|
||||||
|
done < <(/usr/bin/find "$root" -mindepth 1 -maxdepth 1 -print0 2>/dev/null)
|
||||||
|
[ "$top_current" -eq 1 ] || fail 'state transaction must contain exactly one current pointer'
|
||||||
|
[ "$top_generations" -eq 1 ] || fail 'state transaction must contain exactly one generations directory'
|
||||||
|
while IFS= read -r -d '' generation; do
|
||||||
|
name=${generation##*/}
|
||||||
|
generation_kind=
|
||||||
|
if [[ "$name" =~ ^gen-[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+$ ]]; then generation_kind=published; fi
|
||||||
|
if [[ "$name" =~ ^[.]gen-[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+[.]new$ ]]; then generation_kind=incomplete; fi
|
||||||
|
[ -n "$generation_kind" ] || fail "invalid generation name $name"
|
||||||
|
stage1_state_path_is_real_directory "$generation" && [ "$(/usr/bin/stat -c '%u:%g:%a' "$generation" 2>/dev/null)" = '0:0:700' ] || fail "unsafe generation directory $generation"
|
||||||
|
child_count=0
|
||||||
|
while IFS= read -r -d '' child; do
|
||||||
|
child_name=${child##*/}
|
||||||
|
if [ "$child_name" != manifest ] && [ "$child_name" != manifest.sha256 ]; then fail "unexpected generation content $child"; fi
|
||||||
|
stage1_state_verify_file "$child" 0 0 0600 || fail "unsafe generation content $child"
|
||||||
|
child_count=$((child_count + 1))
|
||||||
|
done < <(/usr/bin/find "$generation" -mindepth 1 -maxdepth 1 -print0 2>/dev/null)
|
||||||
|
if [ "$generation_kind" = published ] && { [ "$cleanup_resume" -eq 0 ] || [ "$name" = "$stage1_current_generation" ]; }; then [ "$child_count" -eq 2 ] || fail "published generation is incomplete $generation"; fi
|
||||||
|
if [ -f "$generation/manifest.sha256" ]; then
|
||||||
|
[ -f "$generation/manifest" ] || fail "generation checksum lacks manifest $generation"
|
||||||
|
checksum_lines=$(/usr/bin/awk 'END { print NR+0 }' "$generation/manifest.sha256" 2>/dev/null)
|
||||||
|
checksum_record=$(<"$generation/manifest.sha256")
|
||||||
|
expected_checksum_record="$(/usr/bin/sha256sum "$generation/manifest" | /usr/bin/cut -d' ' -f1) manifest"
|
||||||
|
if [ "$checksum_lines" -ne 1 ] || [ "$checksum_record" != "$expected_checksum_record" ] || ! (cd "$generation" && /usr/bin/sha256sum -c manifest.sha256 >/dev/null 2>&1); then fail "generation checksum mismatch $generation"; fi
|
||||||
|
fi
|
||||||
|
done < <(/usr/bin/find "$root/generations" -mindepth 1 -maxdepth 1 -print0 2>/dev/null)
|
||||||
|
[ "$failures" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
sources=(
|
||||||
|
"$payload_root/usr/local/libexec/dockerd-rootless-29.6.1.sh"
|
||||||
|
"$payload_root/usr/local/libexec/le-app-codex-netns"
|
||||||
|
"$payload_root/etc/le-app-codex-runtime/resolv.conf"
|
||||||
|
"$payload_root/etc/nftables.d/50-le-app-codex.nft"
|
||||||
|
"$payload_root/etc/systemd/system/le-app-codex-netns.service"
|
||||||
|
"$payload_root/etc/systemd/system/user-1200.slice.d/50-le-app-codex-resources.conf"
|
||||||
|
"$payload_root/etc/systemd/system/user@1200.service.d/50-le-app-codex-containment.conf"
|
||||||
|
"$payload_root/srv/le-app-codex/home/.config/systemd/user/docker.service"
|
||||||
|
"$payload_root/srv/le-app-codex/home/.config/docker/daemon.json"
|
||||||
|
"$payload_root/srv/le-app-codex/home/.docker/config.json"
|
||||||
|
"$context_source"
|
||||||
|
"$artifact_root/tests/00-read-only-preflight.sh"
|
||||||
|
"$artifact_root/tests/00-root-preinstall-validate.sh"
|
||||||
|
"$artifact_root/tests/10-inert-containment.sh"
|
||||||
|
"$artifact_root/tests/20-rootless-docker.sh"
|
||||||
|
"$artifact_root/tests/run-inert-without-user-manager.sh"
|
||||||
|
)
|
||||||
|
destinations=(
|
||||||
|
/usr/local/libexec/dockerd-rootless-29.6.1.sh
|
||||||
|
/usr/local/libexec/le-app-codex-netns
|
||||||
|
/etc/le-app-codex-runtime/resolv.conf
|
||||||
|
/etc/nftables.d/50-le-app-codex.nft
|
||||||
|
/etc/systemd/system/le-app-codex-netns.service
|
||||||
|
/etc/systemd/system/user-1200.slice.d/50-le-app-codex-resources.conf
|
||||||
|
/etc/systemd/system/user@1200.service.d/50-le-app-codex-containment.conf
|
||||||
|
/srv/le-app-codex/home/.config/systemd/user/docker.service
|
||||||
|
/srv/le-app-codex/home/.config/docker/daemon.json
|
||||||
|
/srv/le-app-codex/home/.docker/config.json
|
||||||
|
"$context_destination"
|
||||||
|
/srv/le-app-codex/phase2b-tests/00-read-only-preflight.sh
|
||||||
|
/srv/le-app-codex/phase2b-tests/00-root-preinstall-validate.sh
|
||||||
|
/srv/le-app-codex/phase2b-tests/10-inert-containment.sh
|
||||||
|
/srv/le-app-codex/phase2b-tests/20-rootless-docker.sh
|
||||||
|
/srv/le-app-codex/phase2b-tests/run-inert-without-user-manager.sh
|
||||||
|
)
|
||||||
|
uids=(0 0 0 0 0 0 0 1200 1200 1200 1200 1200 1200 1200 1200 1200)
|
||||||
|
gids=(0 0 0 0 0 0 0 1200 1200 1200 1200 1200 1200 1200 1200 1200)
|
||||||
|
modes=(0755 0755 0644 0644 0644 0644 0644 0640 0640 0640 0640 0750 0750 0750 0750 0750)
|
||||||
|
|
||||||
|
allowed_directories=(
|
||||||
|
/srv/le-app-codex/home/.docker/contexts/meta/$context_hash
|
||||||
|
/srv/le-app-codex/home/.docker/contexts/meta
|
||||||
|
/srv/le-app-codex/home/.docker/contexts
|
||||||
|
/srv/le-app-codex/home/.docker
|
||||||
|
/srv/le-app-codex/home/.config/systemd/user
|
||||||
|
/srv/le-app-codex/home/.config/systemd
|
||||||
|
/srv/le-app-codex/home/.config/docker
|
||||||
|
/srv/le-app-codex/home/.config
|
||||||
|
/srv/le-app-codex/phase2b-tests
|
||||||
|
/etc/systemd/system/user@1200.service.d
|
||||||
|
/etc/systemd/system/user-1200.slice.d
|
||||||
|
/etc/le-app-codex-runtime
|
||||||
|
/etc/nftables.d
|
||||||
|
/usr/local/libexec
|
||||||
|
"$state_dir"
|
||||||
|
/srv/le-app-codex/home
|
||||||
|
/srv/le-app-codex
|
||||||
|
/usr/local
|
||||||
|
/etc/systemd/system
|
||||||
|
/etc
|
||||||
|
)
|
||||||
|
directory_uids=(1200 1200 1200 1200 1200 1200 1200 1200 1200 0 0 0 0 0 0 1200 0 0 0 0)
|
||||||
|
directory_gids=(1200 1200 1200 1200 1200 1200 1200 1200 1200 0 0 0 0 0 0 1200 1200 0 0 0)
|
||||||
|
directory_modes=(0750 0750 0750 0750 0750 0750 0750 0750 0750 0755 0755 0755 0755 0755 0700 0700 0750 0755 0755 0755)
|
||||||
|
directory_kinds=(project project project project project project project project project project project project shared shared state project-boundary project-boundary shared-base shared-base shared-base)
|
||||||
|
|
||||||
|
declare -A stage1_allowed_file_specs=() stage1_allowed_directory_specs=() stage1_never_created_directories=()
|
||||||
|
for index in "${!destinations[@]}"; do
|
||||||
|
source_hash=$(/usr/bin/sha256sum "${sources[$index]}" 2>/dev/null | /usr/bin/cut -d' ' -f1)
|
||||||
|
source_size=$(/usr/bin/stat -c '%s' "${sources[$index]}" 2>/dev/null)
|
||||||
|
stage1_allowed_file_specs["${destinations[$index]}"]="$source_hash|$source_size|${uids[$index]}|${gids[$index]}|${modes[$index]}"
|
||||||
|
done
|
||||||
|
for index in "${!allowed_directories[@]}"; do stage1_allowed_directory_specs["${allowed_directories[$index]}"]="${directory_uids[$index]}|${directory_gids[$index]}|${directory_modes[$index]}|${directory_kinds[$index]}"; done
|
||||||
|
for directory in /srv/le-app-codex/home /srv/le-app-codex /usr/local /etc/systemd/system /etc; do stage1_never_created_directories["$directory"]=1; done
|
||||||
|
|
||||||
|
if [ "$(id -u)" = 0 ]; then pass 'running as root'; else fail 'must run as root'; fi
|
||||||
|
source_status=$(/usr/bin/git -c safe.directory="$repo_root" -C "$repo_root" status --porcelain 2>/dev/null)
|
||||||
|
source_status_rc=$?
|
||||||
|
if [ "$source_status_rc" -eq 0 ] && [ -z "$source_status" ]; then pass 'artifact worktree clean'; else fail 'artifact worktree is dirty or unreadable'; fi
|
||||||
|
if (cd "$artifact_root" && /usr/bin/sha256sum -c MANIFEST.sha256); then pass 'artifact manifest'; else fail 'artifact manifest'; fi
|
||||||
|
current_artifact_digest=$(/usr/bin/sha256sum "$artifact_root/MANIFEST.sha256" 2>/dev/null | /usr/bin/cut -d' ' -f1)
|
||||||
|
if stage1_state_path_is_real_directory "$state_dir" && [ "$(/usr/bin/stat -c '%u:%g:%a' "$state_dir" 2>/dev/null)" = '0:0:700' ]; then pass 'state parent metadata'; else fail 'state parent must be a real root:root mode 0700 directory'; fi
|
||||||
|
cleanup_candidates=$(/usr/bin/find "$state_dir" -mindepth 1 -maxdepth 1 -name '.stage1.cleanup.*' -print 2>/dev/null)
|
||||||
|
cleanup_count=$(printf '%s\n' "$cleanup_candidates" | /usr/bin/sed '/^$/d' | /usr/bin/wc -l)
|
||||||
|
active_transaction=0
|
||||||
|
if [ -e "$state_transaction" ] || [ -L "$state_transaction" ]; then active_transaction=1; stage1_state_path_is_real_directory "$state_transaction" || fail 'active state transaction must be a real directory'; fi
|
||||||
|
if [ "$active_transaction" -eq 0 ] && [ "$cleanup_count" -eq 1 ]; then
|
||||||
|
cleanup_name=${cleanup_candidates##*/}
|
||||||
|
[[ "$cleanup_name" =~ ^[.]stage1[.]cleanup[.]gen-[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+$ ]] || fail 'cleanup state transaction name'
|
||||||
|
stage1_state_path_is_real_directory "$cleanup_candidates" || fail 'cleanup state transaction must be a real directory'
|
||||||
|
state_transaction=$cleanup_candidates
|
||||||
|
cleanup_resume=1
|
||||||
|
fi
|
||||||
|
if [ "$active_transaction" -eq 1 ] && [ "$cleanup_count" -ne 0 ]; then fail 'both active and cleanup state transactions exist'; fi
|
||||||
|
if [ "$active_transaction" -eq 0 ] && [ "$cleanup_count" -ne 1 ]; then fail 'exactly one active or cleanup state transaction is required'; fi
|
||||||
|
stage1_state_resolve_current "$state_transaction" 0 0 0600 || fail 'authoritative state generation'
|
||||||
|
if [ "$failures" -eq 0 ]; then stage1_state_validate_records "$stage1_current_manifest" "$current_artifact_digest" "$(/usr/bin/hostname)" 16 || fail 'authoritative state records'; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then validate_state_tree "$state_transaction" || true; fi
|
||||||
|
|
||||||
|
if [ ! -e /etc/le-app-codex-runtime/OPERATOR-INPUTS-APPROVED ] && [ ! -L /etc/le-app-codex-runtime/OPERATOR-INPUTS-APPROVED ]; then pass 'approval marker absent'; else fail 'approval marker exists'; fi
|
||||||
|
manager_state=$(/usr/bin/systemctl show user@1200.service -p ActiveState --value 2>/dev/null)
|
||||||
|
if [ "$manager_state" = inactive ]; then pass 'user manager inactive'; else fail 'user manager active or unknown'; fi
|
||||||
|
if [ ! -e /var/lib/systemd/linger/le_app_codex ] && [ ! -L /var/lib/systemd/linger/le_app_codex ] && [ ! -e /var/lib/systemd/linger/1200 ] && [ ! -L /var/lib/systemd/linger/1200 ]; then pass 'linger absent'; else fail 'linger present'; fi
|
||||||
|
uid_processes=$(/usr/bin/pgrep -u 1200 2>/dev/null)
|
||||||
|
if [ -z "$uid_processes" ]; then pass 'UID 1200 processes absent'; else fail 'UID 1200 processes present'; fi
|
||||||
|
if [ ! -e /run/user/1200/docker.sock ] && [ ! -L /run/user/1200/docker.sock ]; then pass 'Docker socket absent'; else fail 'Docker socket path present'; fi
|
||||||
|
namespace=$(/usr/bin/ip netns list 2>/dev/null | /usr/bin/awk '$1 == "le-app-codex" { print $1 }')
|
||||||
|
if [ -z "$namespace" ] && [ ! -e /run/netns/le-app-codex ] && [ ! -L /run/netns/le-app-codex ]; then pass 'namespace absent'; else fail 'namespace present'; fi
|
||||||
|
if /usr/bin/nft list table inet le_app_codex >/dev/null 2>&1; then fail 'inet policy loaded'; else pass 'inet policy absent'; fi
|
||||||
|
if /usr/bin/nft list table ip le_app_codex_nat >/dev/null 2>&1; then fail 'NAT policy loaded'; else pass 'NAT policy absent'; fi
|
||||||
|
|
||||||
|
declare -A remove_files=() created_directories=()
|
||||||
|
for path in "${stage1_validated_file_paths[@]}" "${stage1_validated_pending_paths[@]}"; do
|
||||||
|
[ -n "$path" ] || continue
|
||||||
|
spec=${stage1_allowed_file_specs[$path]}
|
||||||
|
IFS='|' read -r expected_hash expected_size expected_uid expected_gid expected_mode <<< "$spec"
|
||||||
|
temporary=${path}.phase2b-new
|
||||||
|
if [ ! -e "$path" ] && [ ! -L "$path" ]; then
|
||||||
|
if [ "$cleanup_resume" -eq 1 ]; then
|
||||||
|
pass "$path already removed before rollback retry"
|
||||||
|
elif [[ " ${stage1_validated_pending_paths[*]} " = *" $path "* ]]; then
|
||||||
|
pass "$path pending and absent"
|
||||||
|
else
|
||||||
|
fail "$path recorded complete but absent"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
actual_hash=$(/usr/bin/sha256sum "$path" 2>/dev/null | /usr/bin/cut -d' ' -f1)
|
||||||
|
actual_size=$(/usr/bin/stat -c '%s' "$path" 2>/dev/null)
|
||||||
|
actual_stat=$(/usr/bin/stat -c '%u:%g:%a:%F' "$path" 2>/dev/null)
|
||||||
|
if [ "$actual_hash" = "$expected_hash" ] && [ "$actual_size" = "$expected_size" ] && [ "$actual_stat" = "$expected_uid:$expected_gid:${expected_mode#0}:regular file" ]; then remove_files["$path"]=1; pass "$path verified"; else fail "$path differs from committed artifact"; fi
|
||||||
|
fi
|
||||||
|
if [ -e "$temporary" ] || [ -L "$temporary" ]; then
|
||||||
|
temp_hash=$(/usr/bin/sha256sum "$temporary" 2>/dev/null | /usr/bin/cut -d' ' -f1)
|
||||||
|
temp_size=$(/usr/bin/stat -c '%s' "$temporary" 2>/dev/null)
|
||||||
|
temp_stat=$(/usr/bin/stat -c '%u:%g:%a:%F' "$temporary" 2>/dev/null)
|
||||||
|
if [ "$temp_hash" = "$expected_hash" ] && [ "$temp_size" = "$expected_size" ] && [ "$temp_stat" = "$expected_uid:$expected_gid:${expected_mode#0}:regular file" ]; then remove_files["$temporary"]=1; else fail "$temporary differs from pending committed artifact"; fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for directory in "${stage1_validated_created_directories[@]}"; do
|
||||||
|
[ -n "$directory" ] || continue
|
||||||
|
spec=${stage1_allowed_directory_specs[$directory]}
|
||||||
|
IFS='|' read -r expected_uid expected_gid expected_mode expected_kind <<< "$spec"
|
||||||
|
if [ ! -e "$directory" ] && [ ! -L "$directory" ]; then
|
||||||
|
if [ "$cleanup_resume" -eq 1 ]; then pass "$directory already removed before rollback retry"; else fail "$directory recorded created but absent"; fi
|
||||||
|
else
|
||||||
|
actual_stat=$(/usr/bin/stat -c '%u:%g:%a:%F' "$directory" 2>/dev/null)
|
||||||
|
if [ ! -L "$directory" ] && [ "$actual_stat" = "$expected_uid:$expected_gid:${expected_mode#0}:directory" ]; then created_directories["$directory"]=1; else fail "$directory created metadata mismatch"; fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for directory in "${stage1_validated_pending_directories[@]}"; do
|
||||||
|
[ -n "$directory" ] || continue
|
||||||
|
spec=${stage1_allowed_directory_specs[$directory]}
|
||||||
|
IFS='|' read -r expected_uid expected_gid expected_mode expected_kind <<< "$spec"
|
||||||
|
if [ ! -e "$directory" ] && [ ! -L "$directory" ]; then
|
||||||
|
pass "$directory pending and absent"
|
||||||
|
else
|
||||||
|
actual_stat=$(/usr/bin/stat -c '%u:%g:%a:%F' "$directory" 2>/dev/null)
|
||||||
|
if [ ! -L "$directory" ] && [ "$actual_stat" = "$expected_uid:$expected_gid:${expected_mode#0}:directory" ]; then created_directories["$directory"]=1; else fail "$directory pending metadata mismatch"; fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$failures" -eq 0 ] && [ "$cleanup_resume" -eq 0 ]; then
|
||||||
|
tombstone=$state_dir/.stage1.cleanup.$stage1_current_generation
|
||||||
|
[ ! -e "$tombstone" ] && [ ! -L "$tombstone" ] || fail 'cleanup tombstone already exists'
|
||||||
|
if [ "$failures" -eq 0 ]; then /usr/bin/mv -T -- "$state_transaction" "$tombstone" || fail 'atomic rollback transition'; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then { /usr/bin/sync -f "$state_dir" 2>/dev/null || /usr/bin/sync; } || fail 'rollback transition sync'; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then state_transaction=$tombstone; cleanup_resume=1; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$failures" -eq 0 ]; then
|
||||||
|
for destination in "${destinations[@]}"; do
|
||||||
|
temporary=${destination}.phase2b-new
|
||||||
|
if [ "${remove_files[$temporary]:-0}" -eq 1 ]; then /usr/bin/rm -- "$temporary" || fail "$temporary removal"; fi
|
||||||
|
if [ "${remove_files[$destination]:-0}" -eq 1 ]; then /usr/bin/rm -- "$destination" && removed_count=$((removed_count + 1)) || fail "$destination removal"; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ "$failures" -eq 0 ]; then
|
||||||
|
for directory in "${allowed_directories[@]}"; do
|
||||||
|
if [ "$directory" != "$state_dir" ] && [ "${created_directories[$directory]:-0}" -eq 1 ]; then /usr/bin/rmdir -- "$directory" || fail "$directory removal"; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$failures" -eq 0 ]; then
|
||||||
|
generations=$state_transaction/generations
|
||||||
|
current_id=$stage1_current_generation
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
generation_name=${entry##*/}
|
||||||
|
if [ "$generation_name" != "$current_id" ]; then
|
||||||
|
if [ -e "$entry/manifest.sha256" ] && ! /usr/bin/rm -- "$entry/manifest.sha256"; then fail "$entry checksum removal"; fi
|
||||||
|
if [ -e "$entry/manifest" ] && ! /usr/bin/rm -- "$entry/manifest"; then fail "$entry manifest removal"; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then /usr/bin/rmdir -- "$entry" || fail "$entry removal"; fi
|
||||||
|
fi
|
||||||
|
done < <(/usr/bin/find "$state_transaction/generations" -mindepth 1 -maxdepth 1 -print0)
|
||||||
|
fi
|
||||||
|
if [ "$failures" -eq 0 ]; then
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
name=${entry##*/}
|
||||||
|
if [[ "$name" =~ ^[.]current[.]new[.][0-9]+$ ]]; then /usr/bin/rm -- "$entry" || fail "$entry removal"; fi
|
||||||
|
done < <(/usr/bin/find "$state_transaction" -mindepth 1 -maxdepth 1 -name '.current.new.*' -print0)
|
||||||
|
fi
|
||||||
|
if [ "$failures" -eq 0 ]; then
|
||||||
|
/usr/bin/rm -- "$state_transaction/current" || fail 'current pointer removal'
|
||||||
|
if [ "$failures" -eq 0 ]; then /usr/bin/rm -- "$state_transaction/generations/$current_id/manifest.sha256" || fail 'authoritative checksum removal'; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then /usr/bin/rm -- "$state_transaction/generations/$current_id/manifest" || fail 'authoritative manifest removal'; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then /usr/bin/rmdir -- "$state_transaction/generations/$current_id" || fail 'authoritative generation removal'; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then /usr/bin/rmdir -- "$state_transaction/generations" || fail 'generations directory removal'; fi
|
||||||
|
if [ "$failures" -eq 0 ]; then /usr/bin/rmdir -- "$state_transaction" || fail 'state transaction removal'; fi
|
||||||
|
fi
|
||||||
|
if [ "$failures" -eq 0 ] && [ "${created_directories[$state_dir]:-0}" -eq 1 ]; then /usr/bin/rmdir -- "$state_dir" || fail 'state directory removal'; fi
|
||||||
|
if [ "$failures" -eq 0 ] && [ "${created_directories[$state_dir]:-0}" -eq 0 ]; then pass 'pre-existing state directory preserved'; fi
|
||||||
|
|
||||||
|
printf 'Phase 2B Stage 1 rollback status=%s removed=%s failures=%s\n' "${stage1_validated_status:-UNKNOWN}" "$removed_count" "$failures"
|
||||||
|
[ "$failures" -eq 0 ]
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
tests=0
|
||||||
|
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd)
|
||||||
|
source "$root/tests/stage1-state-lib.sh"
|
||||||
|
test_root=$(/usr/bin/mktemp -d /tmp/le-phase2b-state-test.XXXXXX)
|
||||||
|
uid=$(id -u)
|
||||||
|
gid=$(id -g)
|
||||||
|
|
||||||
|
ok() { tests=$((tests + 1)); printf 'PASS: %s\n' "$1"; }
|
||||||
|
bad() { failures=$((failures + 1)); printf 'FAIL: %s\n' "$1" >&2; }
|
||||||
|
assert() { local label=$1; shift; if "$@"; then ok "$label"; else bad "$label"; fi; }
|
||||||
|
reject() { local label=$1; shift; if "$@"; then bad "$label"; else ok "$label"; fi; }
|
||||||
|
|
||||||
|
new_case() {
|
||||||
|
local name=$1 index file hash size
|
||||||
|
case_dir=$test_root/$name
|
||||||
|
state_root=$case_dir/state
|
||||||
|
/usr/bin/install -d -m 0700 "$state_root" "$case_dir/fs"
|
||||||
|
stage1_source_commit=1111111111111111111111111111111111111111
|
||||||
|
stage1_artifact_digest=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
stage1_timestamp=2026-07-12T00:00:00-04:00
|
||||||
|
stage1_hostname=TITANSERVER
|
||||||
|
stage1_generation_counter=0
|
||||||
|
stage1_records=("DIR_EXISTING|$state_root|$uid|$gid|0700|state")
|
||||||
|
declare -gA stage1_allowed_file_specs=() stage1_allowed_directory_specs=() stage1_never_created_directories=()
|
||||||
|
stage1_allowed_directory_specs["$state_root"]="$uid|$gid|0700|state"
|
||||||
|
for index in $(/usr/bin/seq 1 16); do
|
||||||
|
file=$case_dir/fs/file$index
|
||||||
|
printf 'file-%s\n' "$index" > "$file.source"
|
||||||
|
hash=$(/usr/bin/sha256sum "$file.source" | /usr/bin/cut -d' ' -f1)
|
||||||
|
size=$(/usr/bin/stat -c '%s' "$file.source")
|
||||||
|
stage1_allowed_file_specs["$file"]="$hash|$size|$uid|$gid|0600"
|
||||||
|
done
|
||||||
|
unset PHASE2B_FAILPOINT
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_current() {
|
||||||
|
stage1_state_resolve_current "$state_root" "$uid" "$gid" 0600 || return 1
|
||||||
|
stage1_state_validate_records "$stage1_current_manifest" "$stage1_artifact_digest" "$stage1_hostname" 16
|
||||||
|
}
|
||||||
|
|
||||||
|
add_file_record() {
|
||||||
|
local index=$1 type=${2:-FILE} path spec
|
||||||
|
path=$case_dir/fs/file$index
|
||||||
|
spec=${stage1_allowed_file_specs[$path]}
|
||||||
|
stage1_records+=("$type|$path|$spec")
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_crafted_records() {
|
||||||
|
local crafted=$case_dir/crafted.manifest
|
||||||
|
{
|
||||||
|
printf 'FORMAT|le-app-codex-phase2b-stage1|3\n'
|
||||||
|
printf 'STATUS|IN_PROGRESS\n'
|
||||||
|
printf 'SOURCE_COMMIT|%s\n' "$stage1_source_commit"
|
||||||
|
printf 'ARTIFACT_MANIFEST_SHA256|%s\n' "$stage1_artifact_digest"
|
||||||
|
printf 'TIMESTAMP|%s\n' "$stage1_timestamp"
|
||||||
|
printf 'HOSTNAME|%s\n' "$stage1_hostname"
|
||||||
|
if [ "${#stage1_records[@]}" -gt 0 ]; then printf '%s\n' "${stage1_records[@]}"; fi
|
||||||
|
} > "$crafted"
|
||||||
|
stage1_state_validate_records "$crafted" "$stage1_artifact_digest" "$stage1_hostname" 16
|
||||||
|
}
|
||||||
|
|
||||||
|
simulate_validated_rollback() {
|
||||||
|
local force_directory_failure=${1:-} path spec hash size
|
||||||
|
validate_current || return 1
|
||||||
|
for path in "${stage1_validated_file_paths[@]}" "${stage1_validated_pending_paths[@]}"; do
|
||||||
|
[ -n "$path" ] || continue
|
||||||
|
if [ -e "$path" ]; then
|
||||||
|
spec=${stage1_allowed_file_specs[$path]}
|
||||||
|
IFS='|' read -r hash size _ <<< "$spec"
|
||||||
|
[ "$(/usr/bin/sha256sum "$path" | /usr/bin/cut -d' ' -f1)" = "$hash" ] || return 1
|
||||||
|
/usr/bin/rm -- "$path" || return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for path in "${stage1_validated_created_directories[@]}" "${stage1_validated_pending_directories[@]}"; do
|
||||||
|
[ -n "$path" ] || continue
|
||||||
|
[ "$path" != "$state_root" ] || continue
|
||||||
|
if [ "$path" = "$force_directory_failure" ]; then return 1; fi
|
||||||
|
if [ -d "$path" ]; then /usr/bin/rmdir -- "$path" || return 1; fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
new_case empty_array
|
||||||
|
stage1_records=()
|
||||||
|
assert 'empty record array publishes' stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert 'empty record array emits no blank line' bash -c '! grep -n "^$" "$1"' _ "$state_root/generations/$(<"$state_root/current")/manifest"
|
||||||
|
assert 'empty record array passes real rollback record validator' validate_current
|
||||||
|
|
||||||
|
new_case initial
|
||||||
|
assert 'initial publication with directory record' stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert 'initial generation resolves and verifies' validate_current
|
||||||
|
assert 'initial state contains only headers and directory records' bash -c '! grep -Ev "^(FORMAT|STATUS|SOURCE_COMMIT|ARTIFACT_MANIFEST_SHA256|TIMESTAMP|HOSTNAME|DIR_EXISTING)\\|" "$1"' _ "$stage1_current_manifest"
|
||||||
|
|
||||||
|
for point in after_manifest after_checksum before_commit; do
|
||||||
|
new_case initial_$point
|
||||||
|
PHASE2B_FAILPOINT=$point
|
||||||
|
reject "initial interruption $point" stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
reject "initial interruption $point has no authoritative pointer" stage1_state_resolve_current "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert "initial interruption $point leaves unreferenced generation data" bash -c 'find "$1/generations" -mindepth 1 -print -quit | grep -q .' _ "$state_root"
|
||||||
|
done
|
||||||
|
|
||||||
|
new_case initial_after_commit
|
||||||
|
PHASE2B_FAILPOINT=after_commit
|
||||||
|
reject 'initial interruption immediately after commit point reports failure' stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert 'initial post-commit pointer resolves a complete generation' validate_current
|
||||||
|
assert 'initial post-commit generation remains IN_PROGRESS' test "$stage1_validated_status" = IN_PROGRESS
|
||||||
|
|
||||||
|
new_case existing_update
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
previous_generation=$(<"$state_root/current")
|
||||||
|
add_file_record 1 FILE
|
||||||
|
PHASE2B_FAILPOINT=before_commit
|
||||||
|
reject 'update interruption before atomic pointer switch' stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert 'previous IN_PROGRESS generation remains authoritative' test "$(<"$state_root/current")" = "$previous_generation"
|
||||||
|
assert 'previous generation still validates' validate_current
|
||||||
|
|
||||||
|
new_case after_commit
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
previous_generation=$(<"$state_root/current")
|
||||||
|
add_file_record 1 FILE
|
||||||
|
PHASE2B_FAILPOINT=after_commit
|
||||||
|
reject 'interruption immediately after commit point reports failure' stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert 'after-commit pointer exposes new generation' test "$(<"$state_root/current")" != "$previous_generation"
|
||||||
|
assert 'after-commit generation is complete and verified' validate_current
|
||||||
|
|
||||||
|
new_case pointer_escape
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
printf '../outside\n' > "$state_root/.bad-current"
|
||||||
|
/usr/bin/chmod 0600 "$state_root/.bad-current"
|
||||||
|
/usr/bin/mv -T -- "$state_root/.bad-current" "$state_root/current"
|
||||||
|
reject 'escaping current pointer target is rejected' stage1_state_resolve_current "$state_root" "$uid" "$gid" 0600
|
||||||
|
|
||||||
|
new_case pointer_symlink
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
pointer_generation=$(<"$state_root/current")
|
||||||
|
/usr/bin/rm -- "$state_root/current"
|
||||||
|
/usr/bin/ln -s -- "$pointer_generation" "$state_root/current"
|
||||||
|
reject 'symlink current pointer is rejected' stage1_state_resolve_current "$state_root" "$uid" "$gid" 0600
|
||||||
|
|
||||||
|
new_case generation_symlink
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
pointer_generation=$(<"$state_root/current")
|
||||||
|
/usr/bin/mv -- "$state_root/generations/$pointer_generation" "$state_root/generations/${pointer_generation}.real"
|
||||||
|
/usr/bin/ln -s -- "${pointer_generation}.real" "$state_root/generations/$pointer_generation"
|
||||||
|
reject 'symlink generation target is rejected' stage1_state_resolve_current "$state_root" "$uid" "$gid" 0600
|
||||||
|
|
||||||
|
new_case generation_extra
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
pointer_generation=$(<"$state_root/current")
|
||||||
|
printf 'unexpected\n' > "$state_root/generations/$pointer_generation/extra"
|
||||||
|
reject 'generation with unverified extra content is rejected' stage1_state_resolve_current "$state_root" "$uid" "$gid" 0600
|
||||||
|
|
||||||
|
new_case complete_before_commit
|
||||||
|
for index in $(/usr/bin/seq 1 16); do add_file_record "$index" FILE; done
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
previous_generation=$(<"$state_root/current")
|
||||||
|
PHASE2B_FAILPOINT=before_commit
|
||||||
|
reject 'final COMPLETE interruption before commit point' stage1_state_publish COMPLETE "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert 'final COMPLETE pre-commit interruption preserves IN_PROGRESS generation' test "$(<"$state_root/current")" = "$previous_generation"
|
||||||
|
assert 'preserved final pre-commit generation validates' validate_current
|
||||||
|
assert 'preserved final pre-commit generation remains IN_PROGRESS' test "$stage1_validated_status" = IN_PROGRESS
|
||||||
|
|
||||||
|
new_case complete_after_commit
|
||||||
|
for index in $(/usr/bin/seq 1 16); do add_file_record "$index" FILE; done
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
previous_generation=$(<"$state_root/current")
|
||||||
|
PHASE2B_FAILPOINT=after_commit
|
||||||
|
reject 'final COMPLETE interruption immediately after commit point' stage1_state_publish COMPLETE "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert 'final COMPLETE post-commit interruption switches generation' test "$(<"$state_root/current")" != "$previous_generation"
|
||||||
|
assert 'final COMPLETE post-commit generation validates' validate_current
|
||||||
|
assert 'final COMPLETE post-commit status is COMPLETE' test "$stage1_validated_status" = COMPLETE
|
||||||
|
|
||||||
|
new_case complete
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
for index in $(/usr/bin/seq 1 16); do add_file_record "$index" FILE; done
|
||||||
|
assert 'transition IN_PROGRESS to COMPLETE' stage1_state_publish COMPLETE "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert 'COMPLETE validates with exactly 16 files' validate_current
|
||||||
|
assert 'real validator reports COMPLETE' test "$stage1_validated_status" = COMPLETE
|
||||||
|
for index in $(/usr/bin/seq 1 16); do cp "$case_dir/fs/file$index.source" "$case_dir/fs/file$index"; done
|
||||||
|
assert 'rollback simulation from COMPLETE uses real validator' simulate_validated_rollback
|
||||||
|
|
||||||
|
new_case partial
|
||||||
|
for index in 1 2 3; do add_file_record "$index" FILE; cp "$case_dir/fs/file$index.source" "$case_dir/fs/file$index"; done
|
||||||
|
add_file_record 4 FILE_PENDING
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
assert 'IN_PROGRESS subset validates' validate_current
|
||||||
|
assert 'real validator exposes several completed files' test "${#stage1_validated_file_paths[@]}" -eq 3
|
||||||
|
assert 'real validator exposes pending subset' test "${#stage1_validated_pending_paths[@]}" -eq 1
|
||||||
|
assert 'rollback simulation from IN_PROGRESS uses real validator' simulate_validated_rollback
|
||||||
|
|
||||||
|
new_case directory_failure
|
||||||
|
created_dir=$case_dir/fs/created
|
||||||
|
/usr/bin/mkdir "$created_dir"
|
||||||
|
printf 'blocker\n' > "$created_dir/blocker"
|
||||||
|
stage1_allowed_directory_specs["$created_dir"]="$uid|$gid|0755|project"
|
||||||
|
stage1_records+=("DIR_CREATED|$created_dir|$uid|$gid|0755|project")
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
reject 'directory-removal failure is detected' simulate_validated_rollback "$created_dir"
|
||||||
|
assert 'directory-removal failure preserves authoritative current pointer' test -f "$state_root/current"
|
||||||
|
assert 'directory-removal failure preserves verified generation' validate_current
|
||||||
|
|
||||||
|
new_case malformed_blank
|
||||||
|
stage1_records+=("")
|
||||||
|
reject 'blank record rejected by shared validator' validate_crafted_records
|
||||||
|
|
||||||
|
new_case malformed_duplicate
|
||||||
|
add_file_record 1 FILE
|
||||||
|
add_file_record 1 FILE
|
||||||
|
reject 'duplicate file record rejected by shared validator' validate_crafted_records
|
||||||
|
|
||||||
|
new_case malformed_unknown
|
||||||
|
stage1_records+=("UNKNOWN|value")
|
||||||
|
reject 'unknown record rejected by shared validator' validate_crafted_records
|
||||||
|
|
||||||
|
new_case malformed_unapproved_file
|
||||||
|
stage1_records+=("FILE|$case_dir/fs/not-approved|deadbeef|1|$uid|$gid|0600")
|
||||||
|
reject 'unapproved file record rejected by shared validator' validate_crafted_records
|
||||||
|
|
||||||
|
new_case malformed_escape
|
||||||
|
escape_file=$case_dir/fs/../escape
|
||||||
|
stage1_allowed_file_specs["$escape_file"]="deadbeef|1|$uid|$gid|0600"
|
||||||
|
stage1_records+=("FILE|$escape_file|deadbeef|1|$uid|$gid|0600")
|
||||||
|
reject 'escaping path record rejected by shared validator' validate_crafted_records
|
||||||
|
|
||||||
|
new_case malformed_trailing_delimiter
|
||||||
|
add_file_record 1 FILE
|
||||||
|
stage1_records[${#stage1_records[@]}-1]="${stage1_records[${#stage1_records[@]}-1]}|"
|
||||||
|
reject 'trailing record delimiter rejected by shared validator' validate_crafted_records
|
||||||
|
|
||||||
|
new_case malformed_pending_complete
|
||||||
|
for index in $(/usr/bin/seq 1 15); do add_file_record "$index" FILE; done
|
||||||
|
add_file_record 16 FILE_PENDING
|
||||||
|
reject 'COMPLETE with pending record rejected by publication validator' stage1_state_publish COMPLETE "$state_root" "$uid" "$gid" 0600
|
||||||
|
|
||||||
|
new_case malformed_directory_duplicate
|
||||||
|
stage1_records+=("DIR_EXISTING|$state_root|$uid|$gid|0700|state")
|
||||||
|
reject 'duplicate directory record rejected by shared validator' validate_crafted_records
|
||||||
|
|
||||||
|
new_case malformed_directory_escape
|
||||||
|
escape_directory=$case_dir/../escape
|
||||||
|
stage1_allowed_directory_specs["$escape_directory"]="$uid|$gid|0700|project"
|
||||||
|
stage1_records+=("DIR_CREATED|$escape_directory|$uid|$gid|0700|project")
|
||||||
|
reject 'unapproved or escaping directory rejected by shared validator' validate_crafted_records
|
||||||
|
|
||||||
|
new_case malformed_unapproved_directory
|
||||||
|
stage1_records+=("DIR_CREATED|$case_dir/fs/not-approved|$uid|$gid|0700|project")
|
||||||
|
reject 'unapproved directory record rejected by shared validator' validate_crafted_records
|
||||||
|
|
||||||
|
new_case manifest_mismatch
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
reject 'mismatched artifact digest refuses validation' stage1_state_validate_records "$stage1_current_manifest" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb "$stage1_hostname" 16
|
||||||
|
|
||||||
|
new_case unrelated_commit
|
||||||
|
stage1_source_commit=2222222222222222222222222222222222222222
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
git_case=$case_dir/git
|
||||||
|
/usr/bin/git init -q "$git_case"
|
||||||
|
printf 'artifact\n' > "$git_case/artifact"
|
||||||
|
/usr/bin/git -C "$git_case" -c user.name=test -c user.email=test@example.invalid add artifact
|
||||||
|
/usr/bin/git -C "$git_case" -c user.name=test -c user.email=test@example.invalid commit -q -m artifact
|
||||||
|
install_commit=$(/usr/bin/git -C "$git_case" rev-parse HEAD)
|
||||||
|
printf 'unrelated\n' > "$git_case/unrelated"
|
||||||
|
/usr/bin/git -C "$git_case" -c user.name=test -c user.email=test@example.invalid add unrelated
|
||||||
|
/usr/bin/git -C "$git_case" -c user.name=test -c user.email=test@example.invalid commit -q -m unrelated
|
||||||
|
current_unrelated_commit=$(/usr/bin/git -C "$git_case" rev-parse HEAD)
|
||||||
|
assert 'test repository advanced to unrelated commit' test "$install_commit" != "$current_unrelated_commit"
|
||||||
|
assert 'later unrelated commit does not block matching artifact validation' validate_current
|
||||||
|
assert 'recorded installation commit is preserved' test "$stage1_validated_source_commit" = 2222222222222222222222222222222222222222
|
||||||
|
|
||||||
|
new_case unreferenced
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600
|
||||||
|
authoritative=$(<"$state_root/current")
|
||||||
|
add_file_record 1 FILE
|
||||||
|
PHASE2B_FAILPOINT=after_checksum
|
||||||
|
stage1_state_publish IN_PROGRESS "$state_root" "$uid" "$gid" 0600 || true
|
||||||
|
assert 'unreferenced incomplete generation does not replace current' test "$(<"$state_root/current")" = "$authoritative"
|
||||||
|
assert 'authoritative generation remains valid with unreferenced data present' validate_current
|
||||||
|
|
||||||
|
/usr/bin/rm -rf -- "$test_root"
|
||||||
|
printf 'Stage 1 state tests=%s failures=%s\n' "$tests" "$failures"
|
||||||
|
[ "$failures" -eq 0 ]
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
stage1_state_path_is_regular_or_absent() {
|
||||||
|
local path=$1
|
||||||
|
[ ! -L "$path" ] || return 1
|
||||||
|
[ ! -e "$path" ] || [ -f "$path" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
stage1_state_path_is_real_directory() {
|
||||||
|
local path=$1
|
||||||
|
[ -d "$path" ] && [ ! -L "$path" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
stage1_state_verify_file() {
|
||||||
|
local path=$1 uid=$2 gid=$3 mode=$4 actual
|
||||||
|
stage1_state_path_is_regular_or_absent "$path" || return 1
|
||||||
|
[ -f "$path" ] || return 1
|
||||||
|
actual=$(/usr/bin/stat -c '%u:%g:%a' "$path" 2>/dev/null)
|
||||||
|
[ "$actual" = "$uid:$gid:${mode#0}" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
stage1_state_resolve_current() {
|
||||||
|
local root=$1 uid=$2 gid=$3 mode=$4 pointer generations generation_id generation_dir manifest checksum pointer_stat line_count checksum_lines checksum_record expected_checksum_record entry entry_name entry_count=0
|
||||||
|
pointer=$root/current
|
||||||
|
generations=$root/generations
|
||||||
|
stage1_state_path_is_real_directory "$root" || return 1
|
||||||
|
stage1_state_path_is_real_directory "$generations" || return 1
|
||||||
|
[ "$(/usr/bin/stat -c '%u:%g:%a' "$root" 2>/dev/null)" = "$uid:$gid:700" ] || return 1
|
||||||
|
[ "$(/usr/bin/stat -c '%u:%g:%a' "$generations" 2>/dev/null)" = "$uid:$gid:700" ] || return 1
|
||||||
|
stage1_state_verify_file "$pointer" "$uid" "$gid" "$mode" || return 1
|
||||||
|
pointer_stat=$(/usr/bin/stat -c '%u:%g:%a' "$pointer" 2>/dev/null)
|
||||||
|
[ "$pointer_stat" = "$uid:$gid:${mode#0}" ] || return 1
|
||||||
|
line_count=$(/usr/bin/awk 'END { print NR+0 }' "$pointer" 2>/dev/null)
|
||||||
|
[ "$line_count" -eq 1 ] || return 1
|
||||||
|
IFS= read -r generation_id < "$pointer"
|
||||||
|
[[ "$generation_id" =~ ^gen-[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+$ ]] || return 1
|
||||||
|
[[ "$generation_id" != */* ]] || return 1
|
||||||
|
generation_dir=$generations/$generation_id
|
||||||
|
stage1_state_path_is_real_directory "$generation_dir" || return 1
|
||||||
|
[ "$(/usr/bin/stat -c '%u:%g:%a' "$generation_dir" 2>/dev/null)" = "$uid:$gid:700" ] || return 1
|
||||||
|
[ "$(/usr/bin/readlink -f -- "$generation_dir")" = "$(/usr/bin/readlink -f -- "$generations")/$generation_id" ] || return 1
|
||||||
|
manifest=$generation_dir/manifest
|
||||||
|
checksum=$generation_dir/manifest.sha256
|
||||||
|
stage1_state_verify_file "$manifest" "$uid" "$gid" "$mode" || return 1
|
||||||
|
stage1_state_verify_file "$checksum" "$uid" "$gid" "$mode" || return 1
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
entry_name=${entry##*/}
|
||||||
|
{ [ "$entry_name" = manifest ] || [ "$entry_name" = manifest.sha256 ]; } || return 1
|
||||||
|
[ -f "$entry" ] && [ ! -L "$entry" ] || return 1
|
||||||
|
entry_count=$((entry_count + 1))
|
||||||
|
done < <(/usr/bin/find "$generation_dir" -mindepth 1 -maxdepth 1 -print0 2>/dev/null)
|
||||||
|
[ "$entry_count" -eq 2 ] || return 1
|
||||||
|
[ "$(/usr/bin/stat -c '%h' "$pointer" 2>/dev/null)" -eq 1 ] || return 1
|
||||||
|
[ "$(/usr/bin/stat -c '%h' "$manifest" 2>/dev/null)" -eq 1 ] || return 1
|
||||||
|
[ "$(/usr/bin/stat -c '%h' "$checksum" 2>/dev/null)" -eq 1 ] || return 1
|
||||||
|
checksum_lines=$(/usr/bin/awk 'END { print NR+0 }' "$checksum" 2>/dev/null)
|
||||||
|
checksum_record=$(<"$checksum")
|
||||||
|
expected_checksum_record="$(/usr/bin/sha256sum "$manifest" | /usr/bin/cut -d' ' -f1) manifest"
|
||||||
|
[ "$checksum_lines" -eq 1 ] && [ "$checksum_record" = "$expected_checksum_record" ] || return 1
|
||||||
|
(cd "$generation_dir" && /usr/bin/sha256sum -c manifest.sha256 >/dev/null 2>&1) || return 1
|
||||||
|
stage1_current_generation=$generation_id
|
||||||
|
stage1_current_generation_dir=$generation_dir
|
||||||
|
stage1_current_manifest=$manifest
|
||||||
|
stage1_current_checksum=$checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
stage1_state_publish() {
|
||||||
|
local status=$1 root=$2 uid=$3 gid=$4 mode=$5
|
||||||
|
local generations generation_id generation_dir generation_tmp manifest checksum pointer pointer_tmp digest publish_ok=1 committed=0
|
||||||
|
generations=$root/generations
|
||||||
|
pointer=$root/current
|
||||||
|
stage1_state_path_is_real_directory "$root" || return 1
|
||||||
|
if [ -e "$generations" ] || [ -L "$generations" ]; then stage1_state_path_is_real_directory "$generations" || return 1; else /usr/bin/install -d -o "$uid" -g "$gid" -m 0700 "$generations" || return 1; { /usr/bin/sync -f "$root" 2>/dev/null || /usr/bin/sync; } || return 1; fi
|
||||||
|
stage1_state_path_is_regular_or_absent "$pointer" || return 1
|
||||||
|
stage1_generation_counter=$((${stage1_generation_counter:-0} + 1))
|
||||||
|
generation_id=gen-$(/usr/bin/date -u +%Y%m%dT%H%M%SZ)-$$-$stage1_generation_counter
|
||||||
|
generation_dir=$generations/$generation_id
|
||||||
|
generation_tmp=$generations/.$generation_id.new
|
||||||
|
manifest=$generation_tmp/manifest
|
||||||
|
checksum=$generation_tmp/manifest.sha256
|
||||||
|
pointer_tmp=$root/.current.new.$$
|
||||||
|
[ ! -e "$generation_dir" ] && [ ! -L "$generation_dir" ] && [ ! -e "$generation_tmp" ] && [ ! -L "$generation_tmp" ] || return 1
|
||||||
|
[ ! -e "$pointer_tmp" ] && [ ! -L "$pointer_tmp" ] || return 1
|
||||||
|
/usr/bin/install -d -o "$uid" -g "$gid" -m 0700 "$generation_tmp" || return 1
|
||||||
|
{
|
||||||
|
printf 'FORMAT|le-app-codex-phase2b-stage1|3\n'
|
||||||
|
printf 'STATUS|%s\n' "$status"
|
||||||
|
printf 'SOURCE_COMMIT|%s\n' "$stage1_source_commit"
|
||||||
|
printf 'ARTIFACT_MANIFEST_SHA256|%s\n' "$stage1_artifact_digest"
|
||||||
|
printf 'TIMESTAMP|%s\n' "$stage1_timestamp"
|
||||||
|
printf 'HOSTNAME|%s\n' "$stage1_hostname"
|
||||||
|
if [ "${#stage1_records[@]}" -gt 0 ]; then printf '%s\n' "${stage1_records[@]}"; fi
|
||||||
|
} > "$manifest" || publish_ok=0
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then /usr/bin/chown "$uid:$gid" "$manifest" && /usr/bin/chmod "$mode" "$manifest" || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then { /usr/bin/sync -f "$manifest" 2>/dev/null || /usr/bin/sync; } || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ] && [ "${PHASE2B_FAILPOINT:-}" = after_manifest ]; then publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then digest=$(/usr/bin/sha256sum "$manifest" | /usr/bin/cut -d' ' -f1); printf '%s manifest\n' "$digest" > "$checksum" || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then /usr/bin/chown "$uid:$gid" "$checksum" && /usr/bin/chmod "$mode" "$checksum" || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then { /usr/bin/sync -f "$checksum" 2>/dev/null || /usr/bin/sync; } || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then (cd "$generation_tmp" && /usr/bin/sha256sum -c manifest.sha256 >/dev/null 2>&1) || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then declare -p stage1_allowed_file_specs stage1_allowed_directory_specs stage1_never_created_directories >/dev/null 2>&1 || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then stage1_state_validate_records "$manifest" "$stage1_artifact_digest" "$stage1_hostname" 16 || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ] && [ "${PHASE2B_FAILPOINT:-}" = after_checksum ]; then publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then { /usr/bin/sync -f "$generation_tmp" 2>/dev/null || /usr/bin/sync; } || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then /usr/bin/mv -T -- "$generation_tmp" "$generation_dir" || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then { /usr/bin/sync -f "$generations" 2>/dev/null || /usr/bin/sync; } || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then printf '%s\n' "$generation_id" > "$pointer_tmp" || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then /usr/bin/chown "$uid:$gid" "$pointer_tmp" && /usr/bin/chmod "$mode" "$pointer_tmp" || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then { /usr/bin/sync -f "$pointer_tmp" 2>/dev/null || /usr/bin/sync; } || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ] && [ "${PHASE2B_FAILPOINT:-}" = before_commit ]; then publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then /usr/bin/mv -T -- "$pointer_tmp" "$pointer" || publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then committed=1; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ] && [ "${PHASE2B_FAILPOINT:-}" = after_commit ]; then publish_ok=0; fi
|
||||||
|
if [ "$publish_ok" -eq 1 ]; then { /usr/bin/sync -f "$root" 2>/dev/null || /usr/bin/sync; } || publish_ok=0; fi
|
||||||
|
if [ "$committed" -eq 1 ]; then stage1_state_resolve_current "$root" "$uid" "$gid" "$mode" || publish_ok=0; fi
|
||||||
|
/usr/bin/rm -f -- "$pointer_tmp"
|
||||||
|
[ "$publish_ok" -eq 1 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
stage1_state_validate_records() {
|
||||||
|
local manifest=$1 expected_artifact_digest=$2 expected_hostname=$3 expected_complete_count=$4
|
||||||
|
local line number=0 type f1 f2 f3 f4 f5 f6 extra delimiter_text delimiter_count status= source_commit= artifact_digest= timestamp= hostname=
|
||||||
|
local format_count=0 status_count=0 commit_count=0 digest_count=0 timestamp_count=0 hostname_count=0 completed=0 pending=0 directory_pending=0
|
||||||
|
declare -A seen_file_paths=() seen_directory_paths=()
|
||||||
|
stage1_validated_file_paths=()
|
||||||
|
stage1_validated_pending_paths=()
|
||||||
|
stage1_validated_created_directories=()
|
||||||
|
stage1_validated_pending_directories=()
|
||||||
|
stage1_validated_existing_directories=()
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
number=$((number + 1))
|
||||||
|
[ -n "$line" ] || return 1
|
||||||
|
IFS='|' read -r type f1 f2 f3 f4 f5 f6 extra <<< "$line"
|
||||||
|
[ -z "$extra" ] || return 1
|
||||||
|
delimiter_text=${line//[^|]/}
|
||||||
|
delimiter_count=${#delimiter_text}
|
||||||
|
case "$type" in
|
||||||
|
FORMAT) [ "$delimiter_count" -eq 2 ] || return 1 ;;
|
||||||
|
STATUS|SOURCE_COMMIT|ARTIFACT_MANIFEST_SHA256|TIMESTAMP|HOSTNAME) [ "$delimiter_count" -eq 1 ] || return 1 ;;
|
||||||
|
FILE|FILE_PENDING) [ "$delimiter_count" -eq 6 ] || return 1 ;;
|
||||||
|
DIR_PENDING|DIR_CREATED|DIR_EXISTING) [ "$delimiter_count" -eq 5 ] || return 1 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$number" -eq 1 ] && [ "$type" != FORMAT ]; then return 1; fi
|
||||||
|
if [ "$number" -eq 2 ] && [ "$type" != STATUS ]; then return 1; fi
|
||||||
|
if [ "$number" -eq 3 ] && [ "$type" != SOURCE_COMMIT ]; then return 1; fi
|
||||||
|
if [ "$number" -eq 4 ] && [ "$type" != ARTIFACT_MANIFEST_SHA256 ]; then return 1; fi
|
||||||
|
if [ "$number" -eq 5 ] && [ "$type" != TIMESTAMP ]; then return 1; fi
|
||||||
|
if [ "$number" -eq 6 ] && [ "$type" != HOSTNAME ]; then return 1; fi
|
||||||
|
if [ "$number" -gt 6 ] && { [ "$type" = FORMAT ] || [ "$type" = STATUS ] || [ "$type" = SOURCE_COMMIT ] || [ "$type" = ARTIFACT_MANIFEST_SHA256 ] || [ "$type" = TIMESTAMP ] || [ "$type" = HOSTNAME ]; }; then return 1; fi
|
||||||
|
case "$type" in
|
||||||
|
FORMAT) [ "$f1" = le-app-codex-phase2b-stage1 ] && [ "$f2" = 3 ] && [ -z "$f3$f4$f5$f6" ] || return 1; format_count=$((format_count + 1)) ;;
|
||||||
|
STATUS) { [ "$f1" = IN_PROGRESS ] || [ "$f1" = COMPLETE ]; } && [ -z "$f2$f3$f4$f5$f6" ] || return 1; status=$f1; status_count=$((status_count + 1)) ;;
|
||||||
|
SOURCE_COMMIT) [[ "$f1" =~ ^[0-9a-f]{40}$ ]] && [ -z "$f2$f3$f4$f5$f6" ] || return 1; source_commit=$f1; commit_count=$((commit_count + 1)) ;;
|
||||||
|
ARTIFACT_MANIFEST_SHA256) [[ "$f1" =~ ^[0-9a-f]{64}$ ]] && [ -z "$f2$f3$f4$f5$f6" ] || return 1; artifact_digest=$f1; digest_count=$((digest_count + 1)) ;;
|
||||||
|
TIMESTAMP) [ -n "$f1" ] && [ -z "$f2$f3$f4$f5$f6" ] || return 1; timestamp=$f1; timestamp_count=$((timestamp_count + 1)) ;;
|
||||||
|
HOSTNAME) [ "$f1" = "$expected_hostname" ] && [ -z "$f2$f3$f4$f5$f6" ] || return 1; hostname=$f1; hostname_count=$((hostname_count + 1)) ;;
|
||||||
|
FILE|FILE_PENDING)
|
||||||
|
[ -n "${stage1_allowed_file_specs[$f1]:-}" ] || return 1
|
||||||
|
[[ "$f1" = /* ]] && [[ "$f1" != *'/../'* ]] && [[ "$f1" != */.. ]] || return 1
|
||||||
|
[ -z "${seen_file_paths[$f1]:-}" ] && [ -z "${seen_directory_paths[$f1]:-}" ] || return 1
|
||||||
|
[ "${stage1_allowed_file_specs[$f1]}" = "$f2|$f3|$f4|$f5|$f6" ] || return 1
|
||||||
|
seen_file_paths[$f1]=1
|
||||||
|
if [ "$type" = FILE ]; then stage1_validated_file_paths+=("$f1"); completed=$((completed + 1)); else stage1_validated_pending_paths+=("$f1"); pending=$((pending + 1)); fi
|
||||||
|
;;
|
||||||
|
DIR_PENDING|DIR_CREATED|DIR_EXISTING)
|
||||||
|
[ -n "${stage1_allowed_directory_specs[$f1]:-}" ] || return 1
|
||||||
|
[[ "$f1" = /* ]] && [[ "$f1" != *'/../'* ]] && [[ "$f1" != */.. ]] || return 1
|
||||||
|
[ -z "${seen_directory_paths[$f1]:-}" ] && [ -z "${seen_file_paths[$f1]:-}" ] || return 1
|
||||||
|
[ "${stage1_allowed_directory_specs[$f1]}" = "$f2|$f3|$f4|$f5" ] || return 1
|
||||||
|
if { [ "$type" = DIR_PENDING ] || [ "$type" = DIR_CREATED ]; } && [ "${stage1_never_created_directories[$f1]:-0}" -eq 1 ]; then return 1; fi
|
||||||
|
seen_directory_paths[$f1]=1
|
||||||
|
if [ "$type" = DIR_PENDING ]; then stage1_validated_pending_directories+=("$f1"); directory_pending=$((directory_pending + 1)); elif [ "$type" = DIR_CREATED ]; then stage1_validated_created_directories+=("$f1"); else stage1_validated_existing_directories+=("$f1"); fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < "$manifest"
|
||||||
|
[ "$format_count" -eq 1 ] && [ "$status_count" -eq 1 ] && [ "$commit_count" -eq 1 ] && [ "$digest_count" -eq 1 ] && [ "$timestamp_count" -eq 1 ] && [ "$hostname_count" -eq 1 ] || return 1
|
||||||
|
[ "$artifact_digest" = "$expected_artifact_digest" ] || return 1
|
||||||
|
[ "${#stage1_allowed_file_specs[@]}" -eq "$expected_complete_count" ] || return 1
|
||||||
|
if [ "$status" = COMPLETE ]; then [ "$completed" -eq "$expected_complete_count" ] && [ "$pending" -eq 0 ] && [ "$directory_pending" -eq 0 ] || return 1; fi
|
||||||
|
if [ "$status" = IN_PROGRESS ]; then [ $((completed + pending)) -le "$expected_complete_count" ] || return 1; fi
|
||||||
|
stage1_validated_status=$status
|
||||||
|
stage1_validated_source_commit=$source_commit
|
||||||
|
stage1_validated_artifact_digest=$artifact_digest
|
||||||
|
stage1_validated_timestamp=$timestamp
|
||||||
|
stage1_validated_hostname=$hostname
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue