Prepare Forgejo-image-tested portable scanner rollout revision 4

This commit is contained in:
codex 2026-11-02 02:53:50 +01:00
parent 719a7f4164
commit a4cfd2cecf
22 changed files with 20328 additions and 18 deletions

View file

@ -27,3 +27,9 @@ The first diagnostic was returned: node/deployment/pod identity, readiness, old
The founder's condition report found exactly one missing scanner fragment among171 repositories, at `coulomb/markitect-main.git`. This explains the safe revision2 refusal. [Revision3](native-r3/PLAN.md), packet SHA256 `df1cb48259ec5411a3f8cd24d436078daf09d241c39448454526f0a796798c34`, restores only that old-policy fragment, verifies its dispatcher and all171 active hook sets, preserves the failed receipt, then reuses the sealed unchanged JSON rollout and six source changes. Seven controller and six real filesystem cases passed; the original scanner accepts this preparation and all source preimages remain unchanged. Await exact founder review/execution via `/tmp/forge-scanner-recover-and-rollout.sh`. This new repair is not covered by an inferred retry permission; it is concretely included in the reviewed revision3 action. Repair protection remains installed on later JSON rollback. Existing-history admission and the cause of missing adoption are not established by this repair.
Founder approval received for exact revision3 packet `df1cb48259ec5411a3f8cd24d436078daf09d241c39448454526f0a796798c34` (User: Approve exact revision 3; request_user_input_async call_d07a070d30a54a538c872888bd5b0e30 item0, 2026-11-02). Attended execution is pending via `bash /tmp/forge-scanner-recover-and-rollout.sh`; the approval receipt is `native-r3-approval.json`. No new review is requested within this approved scope.
## Revision3 result and proposed revision4
The founder executed approved revision3: the missing-fragment repair and171-repository coverage verification succeeded; candidate receiver probes passed; hook migration failed before deployment patch. The old policy/template/probes and protected metadata were verified by rollback. The repair remains installed. Exact failed receipts are preserved in `native-r4/`. The pinned Forgejo userspace reproduces the unsupported `chmod --reference` defect; workstation tests had missed it.
[Revision4](native-r4/PLAN.md), packet SHA256 `64c73100f2347470fda1b8b3194dbf6a8009291d9d80223af463b3c5ecab52fd`, corrects numeric mode handling and exact known group-write restoration, reuses the existing verified immutable ConfigMap, and has a fresh execution/source gate. Eight shell image cases, a four-case171-repository/1539-hook controller run, three controller checks and runtime self-test passed. Candidate scanner/profile and six owner source hashes are unchanged. Await exact revision4 review; no native revision4 action has run. The proposed founder launcher is `/tmp/forge-scanner-rollout-r4.sh`.

View file

@ -0,0 +1,39 @@
# Portable scanner rollout, revision 4
Stable proposal `SYNC-SCANNER-JSON-DIGEST-20261101`, revision 4. Revisions 1–3, exact revision-3 approval and both failed executions remain preserved. Revision 4 has not run natively and requires exact founder review.
## Verified starting state and defect
The approved revision-3 run restored the missing old-policy scanner fragment at markitect-main. Its real dispatcher passed clean/private-key/history/quarantine/unavailable-scanner/recovery probes. All 171 fragments and unchanged protected hook metadata were verified. The JSON candidate then passed its native receive probe, but hook migration failed before the deployment patch. Guarded rollback verified the old template/policy, all 171 fragments and old-policy receive probes. The restored protection remains installed.
A reproducible compatibility defect exists in the reviewed migration script: `chmod --reference` is unavailable in Forgejo 11.0.3-rootless. The exact old migration fails in the published image pinned at `code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba`, also the pinned guard image in owner source. Workstation tests used a different userspace and missed this defect. Failed native receipts are preserved in this folder; local image tests retain the old-script counterexample.
The previous attempt left the public candidate binaries and immutable ConfigMap `forgejo-secret-template-619d94208139` staged. They are inert under the currently selected old template. Revision 4 verifies/reuses them rather than creating that existing ConfigMap again.
## Exact revised action
Founder launcher: `/tmp/forge-scanner-rollout-r4.sh`, selecting the attended admin context and this packet's exact SHA256 acknowledgement. Agent credentials/authority do not change. Fresh receipt: `/tmp/forge-scanner-rollout-r4-receipt.json`; an existing receipt refuses execution. Earlier receipts are never removed or overwritten.
1. Verify this sealed packet and unchanged source/binary/probe/input artifacts in revision 2. Keep the exact same six owner-source candidates and candidate adapter SHA256 `d7dbd0ef57312ab9507874bb5fd67c49bef637b6d7dee121e526227307af4bf0`. Recheck node/deployment/pod identity, old guard/template, resources/strategy and all source preimages. Inventory every current repository (maximum 500); all fragments must already have the known old bytes and custody. The completed repair is a required starting state; there is no uncovered-repository exception.
2. Snapshot active hook hashes, owners, groups and modes. Supported baseline is uid/gid 1000 and mode 0755 or 0775, as observed in all 1,539 hooks from the failed native execution. Verify the existing target ConfigMap is immutable and has exactly the reviewed hook data; pin its UID against recreation. Do not mutate/create the ConfigMap. Server dry-run the same conditional two-field deployment patch and capture the client ConfigMap diff; raw objects/errors stay withheld.
3. Run `runtime_self_test.sh` in private tmpfs. It verifies numeric chmod, stable descriptor-based permission restoration, stat/hash and atomic rename, and cleans up its public fixture. Require success before production hook/deployment changes. This checks actual live userspace, supplementing the pinned-image tests.
4. Verify/reuse the previously staged candidate directory with its original marker and exact adapter/Gitleaks hashes. Run the existing native JSON/private-key/history/quarantine/unavailable-scanner/recovery probe before activation.
5. Migrate only scanner fragments with the original all-scope prevalidation, known bytes and partial-failure recovery. The two `chmod --reference` calls are replaced by `chmod "$(stat -c %a …)"`, preserving numeric modes in the container's supported syntax. Paths remain bounded to repository scanner fragments. Unknown bytes, symlinks or custody refuse.
6. Verify/restore exact baseline hook modes after migration and after pod recreation, including generated/protected hooks whose contents are never overwritten. Kubernetes volume processing may add group-write permission. The only accepted mode difference is baseline 0755 becoming 0775; remove that added bit to restore 0755. A 0775 baseline stays 0775. Require unchanged content hash, uid/gid, regular non-symlink shape and safe parents for every file before the first restoration. For changed modes, open the verified inode, recheck identity/hash/owner, chmod through `/proc/self/fd`, and verify the same final inode/mode. Unknown content, ownership, mode or hook-set changes refuse; never rewrite generated dispatchers or authorization-hook content. The controller also prevalidates protected content before any scanner rewrite.
7. Apply the original UID/resourceVersion/before-value JSON patch to append the exact candidate artifact guard and select the new hook ConfigMap. No image, resource, strategy, security-context or credential changes. Wait up to 180 seconds for the correct deployment's Ready pod; repeat migration/inventory to cover known old fragments on repositories created during transition, restore only known group-write additions, then require original protected metadata equality. Stream mode records through stdin rather than placing the large inventory in command arguments.
8. Verify all artifact guards, mounted new hook hash, full receiver inventory and post-activation JSON/private-key/history/quarantine/unavailable-scanner/recovery probe. Emit `activation-verified` only after those checks pass.
9. After native verification, the agent applies the same six exact owner-source candidates via this folder's `source_followthrough.py`, requiring the approved revision-4 packet and verified revision-4 receipt. The candidates and resulting six hashes are unchanged from revision 2; only the receipt gate changes. Commit/publish through normal hooks, retry eligible held work and continue the CUST-WP-0083 failure audit. UUID/fingerprint/prose findings remain separate adjudication; the scanner exception is not broadened.
## Recovery and tests
The original guarded rollback restores old deployment fields/template and known old scanner bytes, with the portable migration and exact baseline mode restoration now used during recovery too. It verifies old artifacts/template, protected metadata and the old-policy real receiver probe. Retain the repaired markitect-main fragment, staged public binaries/ConfigMap and every failed receipt. Unknown concurrent changes are retained and reported as unverified. A failed rollback still requires founder recovery; this is not a guarantee of recovery.
Tests were run in the pinned Forgejo userspace with no network, host credentials or writable source mounts. Eight shell cases cover the old-script counterexample, apply/rollback with mixed modes, complete-scope refusal, partial failure, symlinks, simulated pod permission additions, unexpected modes and content drift. The actual controller migration/stream framing passed a separate 171-repository/1,539-hook run: apply, post-recreation mode restoration (19 known group-write additions), rollback and protected-content refusal before writes. Three controller tests verify immutable ConfigMap reuse/UID pinning, changed/mutable data refusal and all six original source candidates. The new runtime self-test passed in that image. Original real-Gitleaks source/receiver proof and successful native candidate probe remain applicable because the scanner binary/profile are unchanged. Local tests are not native activation evidence.
Historical admission, why markitect-main adoption missed its fragment, and complete Forgejo new-repository/write lifecycle coverage remain unproved by this repair. Preserve those limits in the forge-owned class-level followup; no clean-history claim is made.
## Cost, interruption and authority
No new infrastructure, service, image or resource commitment. Incremental infrastructure cost EUR 0; founder time/existing compute unpriced. Estimate 20–35 attended minutes. Expected Recreate interruption remains 1–3 minutes; rollback readiness up to another 180 seconds plus bounded commands/probes. Numeric mode restoration takes bounded extra inventory/metadata work (180-second operation limit); interruption estimate is not an availability guarantee.
Illustrative recovery effort EUR 100 at EUR 100/hour. Complete security/recovery loss remains unpriced and requires explicit founder judgement. Existing filename/digest exception limitations remain; no autopilot authority, risk budget, promotion or unchanged-success credit is implied. Exact review is needed because executable migration/recovery, existing-ConfigMap handling, protected-hook mode restoration and the source receipt gate change. Governing instruction: `docs/agent-autonomy-decision.md`, "Approval is bound to the reviewed proposal revision; changing that revision requires review again."

View file

@ -0,0 +1,37 @@
{
"result": "passed",
"image": "code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba",
"repositories": 171,
"hooks": 1539,
"cases": [
"initial-migration",
"post-recreate-mode-restoration",
"rollback-mode-restoration",
"protected-drift-before-write"
],
"group_write_additions_reversed": 19,
"events": [
{
"event": "hooks-apply",
"repositories": 171,
"portable_modes": true,
"baseline_modes_restored": true,
"group_write_additions_reversed": 0
},
{
"event": "hooks-apply",
"repositories": 171,
"portable_modes": true,
"baseline_modes_restored": true,
"group_write_additions_reversed": 19
},
{
"event": "hooks-rollback",
"repositories": 171,
"portable_modes": true,
"baseline_modes_restored": true,
"group_write_additions_reversed": 19
}
],
"native_mutation": false
}

View file

@ -0,0 +1,66 @@
#!/bin/sh
# Caller supplies a sealed, exact path list and known public before/after bytes.
set +vx
set -eu
PATH=/usr/bin:/bin
export PATH
area=$1 direction=$2 before_sum=$3 after_sum=$4
test "$(id -u)" = 1000
for file in before after paths; do test -f "$area/$file" && test ! -L "$area/$file"; done
test "$(sha256sum "$area/before" | cut -d ' ' -f 1)" = "$before_sum"
test "$(sha256sum "$area/after" | cut -d ' ' -f 1)" = "$after_sum"
case "$direction" in apply) ;; rollback) ;; *) exit 35;; esac
validate_path() {
path=$1
case "$path" in /data/git/gitea-repositories/*/*.git/hooks/pre-receive.d/10-custodian-secret-scan) ;; *) exit 36;; esac
parent=${path%/*}
while test "$parent" != /data; do
test -d "$parent" && test ! -L "$parent"
parent=${parent%/*}
done
test -f "$path" && test ! -L "$path"
test "$(stat -c %u "$path")" = 1000
case "$(stat -c %a "$path")" in 755|775) ;; *) exit 37;; esac
current=$(sha256sum "$path" | cut -d ' ' -f 1)
if test "$direction" = apply; then test "$current" = "$before_sum"
else test "$current" = "$before_sum" || test "$current" = "$after_sum"; fi
}
# Validate the complete scope before the first write.
count=0
while IFS= read -r path; do validate_path "$path"; count=$((count+1)); done < "$area/paths"
test "$count" -gt 0
temporary=
restore_partial() {
code=$?
trap - EXIT HUP INT TERM
if test -n "$temporary"; then rm -f -- "$temporary" || code=38; fi
# Only our exact replacement may be restored. Unknown drift is retained.
if test "$direction" = apply && test "$code" != 0; then
while IFS= read -r path; do
if test -f "$path" && test ! -L "$path" &&
test "$(sha256sum "$path" | cut -d ' ' -f 1)" = "$after_sum"; then
staged=$(mktemp "${path%/*}/.custodian-restore-XXXXXX") || { code=38; continue; }
if cp "$area/before" "$staged" && chmod "$(stat -c %a "$path")" "$staged" && mv -T -- "$staged" "$path"; then :
else rm -f -- "$staged"; code=38; fi
fi
done < "$area/paths"
fi
exit "$code"
}
trap restore_partial EXIT
trap 'exit 39' HUP INT TERM
while IFS= read -r path; do
validate_path "$path"
if test "$direction" = rollback; then source="$area/before"; sum=$before_sum; mode=775
else source="$area/after"; sum=$after_sum; mode=755; fi
if test "$(sha256sum "$path" | cut -d ' ' -f 1)" != "$sum"; then
temporary=$(mktemp "${path%/*}/.custodian-replace-XXXXXX")
cp "$source" "$temporary"
chmod "$(stat -c %a "$path")" "$temporary"
test "$(sha256sum "$temporary" | cut -d ' ' -f 1)" = "$sum"
mv -T -- "$temporary" "$path"
temporary=
fi
test "$(sha256sum "$path" | cut -d ' ' -f 1)" = "$sum"
done < "$area/paths"
printf '{"hook_files_verified":%s,"direction":"%s"}\n' "$count" "$direction"

View file

@ -0,0 +1,39 @@
{
"image": "code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba",
"result": "passed",
"cases": [
{
"case": "old-counterexample",
"returncode": 0
},
{
"case": "roundtrip",
"returncode": 0
},
{
"case": "complete-scope-refusal",
"returncode": 0
},
{
"case": "partial-failure",
"returncode": 0
},
{
"case": "symlink-refusal",
"returncode": 0
},
{
"case": "fsgroup-modes",
"returncode": 0
},
{
"case": "mode-drift",
"returncode": 0
},
{
"case": "hash-drift",
"returncode": 0
}
],
"native_mutation": false
}

View file

@ -0,0 +1,24 @@
{
"proposal_id": "SYNC-SCANNER-JSON-DIGEST-20261101",
"revision": 4,
"reused_r2_packet_sha256": "d22b1f555af96c203b7d68efd25e80815ce18ca4e3bf0dd3ab96b6f699d50237",
"files": {
"PLAN.md": "0800de1fe3307613071d736be8536fa047e8894923a6bea13daee85dd5254c02",
"controller-image-tests.json": "4b478fd0651550b2719a0d23e37e1c77d561482941cea18ef364ffe4b6a7e553",
"failed-r3-native-receipt.json": "10b0992da2e659cc8adb197fb0ec926702d84000b8fd4ce18c7fae58ddccd38b",
"failed-r3-recovery-receipt.json": "b4a17af4a7419e83edbc38295d734d4e662243e7182a8c4039fe6307182185a8",
"hooks.sh": "d207b005989b3e1c8205cd0e72490952bfbef634c0f26af15c3cbabdb610c082",
"image-tests.json": "f07e340381ac848a67eac789946db7f348aab2e08cb034fab6206723087435e7",
"prior-execution-outcome.json": "9af96e2af8e8715b2f542dfea532c714d626dff761a78aba039b5f2d0a273865",
"proposal.json": "7e5c1c33e6519abacc84e4420c2a9ad70a84a96fa4c9578e48dbc45322d5055b",
"restore_modes.sh": "06dfb60bb2d33d3b5cef0ff5e9298c89328d6bbf6ebd2c98ae78cefeb45910b7",
"rollout.py": "e654911c38154fc803f7abb01f410940c9b34627436716b34cea2f8544760889",
"run_image_tests.py": "626f8083d726101c7b88a673a608d83cca7cfe8bbac430b2ce58bb96af974254",
"runtime-image-test.json": "aeff32499bafb4bacca16d2ad72b824453734c1812cdbd2c0a24be29f8f66721",
"runtime_self_test.sh": "8d68268a3f8d6cfce07e723893de7657e431f19000d915403a5d7218d284f269",
"source_followthrough.py": "053d1758a788c40612677e8d1dc93e6aefda04d339f1b161d6527ba6a71cfd92",
"test_controller.py": "ec8ee5c68b772851a44e0b59b5bca9af6159a1420ecac4f9aa60f2cef7b8de8d",
"test_controller_image.py": "88eea8f204987d0b1452b4af66ee2609bf02d2dee9c847ebaee7a0fc944bef84",
"test_image.sh": "f82fe8b830cc4cc341d8b50e18e034415a7ad2b77140c7e7dea8a0f56c468f41"
}
}

View file

@ -0,0 +1,13 @@
{
"proposal_id": "SYNC-SCANNER-JSON-DIGEST-20261101",
"executed_revision": 3,
"packet_sha256": "df1cb48259ec5411a3f8cd24d436078daf09d241c39448454526f0a796798c34",
"outcome": "failed",
"partial_result": "old-policy fragment repair verified across171; candidate receiver probe passed; hook migration failed before deployment patch",
"recovery": "rollback verified old policy/metadata/probes across171; repaired fragment retained",
"compatibility_defect": "chmod --reference unsupported in pinned Forgejo image; original migration counterexample reproduced there",
"failed_recovery_receipt_sha256": "b4a17af4a7419e83edbc38295d734d4e662243e7182a8c4039fe6307182185a8",
"failed_native_receipt_sha256": "10b0992da2e659cc8adb197fb0ec926702d84000b8fd4ce18c7fae58ddccd38b",
"new_policy_activated": false,
"six_owner_sources_applied": false
}

View file

@ -0,0 +1,26 @@
{
"proposal_id": "SYNC-SCANNER-JSON-DIGEST-20261101",
"revision": 4,
"original_revision": 1,
"reused_r2_packet_sha256": "d22b1f555af96c203b7d68efd25e80815ce18ca4e3bf0dd3ab96b6f699d50237",
"target": "railiance01 forgejo/forge; already-covered scanner fragments; same six owner source candidates",
"expected_result": "Portable JSON-profile activation with exact protected hook bytes/metadata preserved; completed coverage repair retained",
"verification_ref": "PLAN.md, image-tests.json, controller-image-tests.json, runtime-image-test.json and3 controller tests; native runtime self-test/dry-run/probes mandatory",
"rollback_ref": "PLAN.md; original guarded rollback using portable migration and exact mode restoration; all old artifacts and failed receipts retained",
"cost_estimate_eur": {
"additional_infrastructure": 0,
"attended_minutes": [
20,
35
],
"founder_time": "unpriced"
},
"risk_estimate_eur": {
"complete_credible_loss_bound": null,
"illustrative_recovery": 100,
"basis": "one recovery hour at EUR100/hour; full security/recovery loss unpriced, supervision required"
},
"authority": "pending exact founder revision4 review; not natively executed",
"same_candidate_binary_sha256": "d7dbd0ef57312ab9507874bb5fd67c49bef637b6d7dee121e526227307af4bf0",
"source_candidates": "unchanged six exact candidates from revision2; revision4 receipt gate"
}

View file

@ -0,0 +1,48 @@
#!/bin/sh
# Restore only known group-write additions on unchanged, owned hook inodes.
set +vx
set -eu
export PATH=/usr/bin:/bin
records=$1
test "$(id -u)" = 1000
fail() { printf '{"mode_check":"%s","result":"refused"}\n' "$1"; exit 61; }
tab=$(printf '\t')
validate() {
mode=$1 sum=$2 path=$3
case "$mode" in 755|775) ;; *) fail unsupported-baseline-mode;; esac
case "$path" in /data/git/gitea-repositories/*/*.git/hooks/*) ;; *) fail path-scope;; esac
parent=${path%/*}
while test "$parent" != /data; do
test -d "$parent" && test ! -L "$parent" || fail parent-shape
parent=${parent%/*}
done
test -f "$path" && test ! -L "$path" || fail file-shape
test "$(stat -c %u "$path")" = 1000 && test "$(stat -c %g "$path")" = 1000 || fail file-owner
test "$(sha256sum "$path" | cut -d ' ' -f 1)" = "$sum" || fail file-hash
current=$(stat -c %a "$path")
test "$current" = "$mode" || { test "$mode" = 755 && test "$current" = 775; } || fail unexpected-mode
}
count=0
while IFS="$tab" read -r mode sum path; do validate "$mode" "$sum" "$path"; count=$((count+1)); done < "$records"
test "$count" -gt 0 || fail empty-scope
changed=0
while IFS="$tab" read -r mode sum path; do
validate "$mode" "$sum" "$path"
if test "$current" != "$mode"; then
inode=$(stat -c '%d:%i' "$path")
exec 7< "$path"
fd=/proc/self/fd/7
test "$(stat -L -c '%d:%i' "$fd")" = "$inode" || fail opened-inode
test "$(stat -L -c %u "$fd")" = 1000 && test "$(stat -L -c %g "$fd")" = 1000 || fail opened-owner
test "$(stat -L -c %a "$fd")" = 775 || fail opened-mode
test "$(sha256sum "$fd" | cut -d ' ' -f 1)" = "$sum" || fail opened-hash
chmod "$mode" "$fd"
test "$(stat -L -c %a "$fd")" = "$mode" || fail restored-mode
test -f "$path" && test ! -L "$path" && test "$(stat -c '%d:%i' "$path")" = "$inode" || fail final-inode
exec 7<&-
changed=$((changed+1))
fi
validate "$mode" "$sum" "$path"
test "$current" = "$mode" || fail final-mode
done < "$records"
printf '{"hook_modes_verified":%s,"group_write_additions_reversed":%s}\n' "$count" "$changed"

View file

@ -0,0 +1,194 @@
"""Founder-run r4: portable hook migration, exact mode restoration, existing CM.
Original r2/r3 code and failed receipts are preserved. Same scanner binary,
profile, deployment fields and six owner-source candidates. Acknowledgement
binds reviewed bytes; it is not a credential or runtime approval service.
"""
from pathlib import Path
import argparse
import base64
import copy
import hashlib
import importlib.util
import json
import re
import shlex
import subprocess
HERE = Path(__file__).resolve().parent
ORIGINAL = HERE.parent / 'native-r2'
OUTPUT = Path('/tmp/forge-scanner-rollout-r4-receipt.json')
R2 = 'd22b1f555af96c203b7d68efd25e80815ce18ca4e3bf0dd3ab96b6f699d50237'
def sha(raw): return hashlib.sha256(raw).hexdigest()
def ensure(ok, reason):
if not ok: raise RuntimeError(reason)
def support():
spec = importlib.util.spec_from_file_location('original_rollout', ORIGINAL / 'rollout.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
ensure(m.verify_packet() == R2, 'original-reviewed-packet-drift')
m.RECEIPT = OUTPUT
return m
def verify_packet():
raw = (HERE / 'packet.json').read_bytes(); p = json.loads(raw)
for name, digest in p['files'].items():
ensure(Path(name).name == name and sha((HERE / name).read_bytes()) == digest, 'r4-packet-drift')
support()
return sha(raw)
def operation(args):
if 'exec' in args:
for source, label in [(HERE/'runtime_self_test.sh','runtime-tool-self-test'), (HERE/'hooks.sh','hook-migration'), (HERE/'restore_modes.sh','hook-mode-restoration'),
(ORIGINAL/'stage.sh','artifact-staging'), (ORIGINAL/'probe.sh','candidate-receive-probe'),
(ORIGINAL/'rollback-probe.sh','old-policy-receive-probe')]:
if source.read_text() in args: return label
if any(isinstance(a,str) and 'custodian-replace-' in a for a in args): return 'hook-migration'
if any(isinstance(a,str) and 'opened-inode' in a for a in args): return 'hook-mode-restoration'
if any(isinstance(a,str) and 'for repo in /data/git/gitea-repositories/' in a for a in args): return 'hook-inventory'
return 'public-artifact-or-template-check'
for verb in ('get','patch'):
if verb in args: return verb + ':' + args[args.index(verb)+1]
return 'reviewed-kubernetes-operation'
def make_runner(m):
class Rollout(m.Rollout):
def k(self, *args, data=None, timeout=60):
label = operation(args)
try: q = subprocess.run(['kubectl',*args], input=data, capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
self.event('command-failed', operation=label, category='timeout')
raise RuntimeError('kubernetes-timeout:' + label)
if q.returncode:
category = 'remote-check-nonzero' if b'command terminated with exit code' in q.stderr else 'command-failed'
for needle,name in [(b'Forbidden','forbidden'),(b'Unauthorized','unauthorized'),(b'NotFound','not-found')]:
if needle in q.stderr: category=name
self.event('command-failed', operation=label, category=category)
raise RuntimeError('kubernetes-command-failed:' + label)
return q.stdout
def existing_cm(self):
cm = json.loads(self.k('-n','forgejo','get','configmap',self.c['new_configmap'],'-o','json'))
wanted = json.loads((ORIGINAL/'configmap.json').read_bytes())
ensure(cm.get('immutable') is True and cm.get('data') == wanted['data'] and
cm['metadata']['name'] == self.c['new_configmap'] and cm['metadata']['namespace'] == 'forgejo', 'existing-public-configmap-drift')
uid = cm['metadata']['uid']
ensure(not hasattr(self,'cm_uid') or self.cm_uid == uid, 'existing-configmap-identity-drift')
self.cm_uid = uid
def dry_run(self,d):
self.existing_cm()
patch = m.patch_for(d,self.c)
self.k('-n','forgejo','patch','deployment','forge','--type=json','--dry-run=server','-p',json.dumps(patch),'-o','name')
diff = subprocess.run(['kubectl','diff','-f','-'], input=(ORIGINAL/'configmap.json').read_bytes(), capture_output=True, timeout=60)
ensure(diff.returncode in (0,1),'client-diff-failed; raw diagnostics withheld')
self.event('server-dry-run-passed', reused_immutable_configmap=self.c['new_configmap'], configmap_uid=self.cm_uid,
client_configmap_diff_exit=diff.returncode, deployment_changes=2, strategy='Recreate', resources_unchanged=True)
def preflight(self):
result = super().preflight()
for files in self.receipt['before_hooks'].values():
for f in files.values():
ensure(f['uid'] == 1000 and f['gid'] == 1000 and f['mode'] in ('755','775'),'unsupported-hook-custody-or-mode')
return result
def migrate(self,pod,direction):
c=self.c
before=self.inventory(pod,{c['old_hook_sha256'],c['new_hook_sha256']})
baseline=self.receipt['before_hooks']
reference=copy.deepcopy(before)
records=[]
expected=c['old_hook_sha256'] if direction=='rollback' else c['new_hook_sha256']
for repo,files in baseline.items():
ensure(repo in before and set(before[repo]) == set(files),'hook-set-drift')
for name,f in files.items():
now=before[repo][name]
ensure((now['uid'],now['gid'])==(f['uid'],f['gid']),'hook-owner-drift')
ensure(now['mode']==f['mode'] or (f['mode']=='755' and now['mode']=='775'),'unexpected-hook-mode-drift')
if name!=m.HOOK: ensure(now['sha256']==f['sha256'],'protected-hook-content-drift')
reference[repo][name]=dict(f)
digest=expected if name==m.HOOK else f['sha256']
records.append(f['mode']+'\t'+digest+'\t'+repo+'/hooks/'+name+'\n')
paths=''.join(repo+'/hooks/'+m.HOOK+'\n' for repo,files in sorted(before.items())
if direction=='rollback' or files[m.HOOK]['sha256']==c['old_hook_sha256'])
# One private tmpfs control area; public hooks/paths/modes only.
wrapper='''set +vx
set -eu
umask 077
test "$(stat -f -c %t /dev/shm)" = 1021994
area=$(mktemp -d /dev/shm/forge-r4-hooks-XXXXXXXX)
cleanup() { code=$?; trap - EXIT; rm -f -- "$area/before" "$area/after" "$area/paths" "$area/modes"; rmdir "$area" || code=70; exit "$code"; }
trap cleanup EXIT
'''
for name in ('before','after'):
b64=base64.b64encode((ORIGINAL/(name+'-hook')).read_bytes()).decode()
wrapper+='printf %s '+shlex.quote(b64)+' | base64 -d > "$area/'+name+'"\n'
# Stream records through stdin; never put a large inventory in argv.
wrapper+=': > "$area/paths"\nwhile IFS= read -r line; do\n test "$line" = __FORGE_R4_MODE_RECORDS__ && break\n printf "%s\\n" "$line" >> "$area/paths"\ndone\ncat > "$area/modes"\n'
if paths:
wrapper+='sh -c '+shlex.quote((HERE/'hooks.sh').read_text())+' sh "$area" "$1" "$2" "$3"\n'
wrapper+='sh -c '+shlex.quote((HERE/'restore_modes.sh').read_text())+' sh "$area/modes"\n'
# shell output contains two JSON lines; only report vetted counters.
raw=self.shell(pod,wrapper,(direction,c['old_hook_sha256'],c['new_hook_sha256']),data=(paths+'__FORGE_R4_MODE_RECORDS__\n'+''.join(records)).encode(),timeout=180)
counters=[json.loads(line) for line in raw.splitlines() if line]
after=self.inventory(pod,{expected})
m.protected_equal(reference,after)
self.event('hooks-'+direction,repositories=len(after),portable_modes=True,baseline_modes_restored=True,
group_write_additions_reversed=sum(x.get('group_write_additions_reversed',0) for x in counters))
return after
def apply(self,binary):
d,pod,before=self.preflight()
raw=binary.read_bytes();ensure(sha(raw)==self.c['new_adapter_sha256'],'candidate-binary-drift')
tools=json.loads(self.shell(pod,(HERE/'runtime_self_test.sh').read_text()))
ensure(all(tools.get(k) for k in ('numeric_modes','descriptor_mode_restore','atomic_rename','temporary_test_removed')),'runtime-tool-self-test-failed')
self.event('runtime-tool-self-test-passed',checks=tools)
self.receipt['native_mutation']=True;self.event('staging-started')
try:
self.shell(pod,(ORIGINAL/'stage.sh').read_text(),(self.c['new_dir'],self.c['old_dir'],self.c['new_adapter_sha256'],self.c['scanner_sha256'],'SYNC-SCANNER-JSON-DIGEST-20261101-R2'),data=raw)
self.shell(pod,self.c['new_guard_command'][2],('check',))
self.probe(pod,self.c['new_dir'],before)
self.existing_cm()
self.migrate(pod,'apply')
patch=m.patch_for(self.deployment(),self.c)
self.k('-n','forgejo','patch','deployment','forge','--type=json','-p',json.dumps(patch),'-o','name')
self.event('deployment-patched')
pod=self.ready_pod(self.deployment())
after=self.migrate(pod,'apply')
m.protected_equal(before,after)
self.shell(pod,self.c['new_guard_command'][2],('check',))
mounted=self.shell(pod,'cat /opt/forge-secret-template/hooks/pre-receive.d/10-custodian-secret-scan')
ensure(sha(mounted)==self.c['new_hook_sha256'],'mounted-template-drift')
self.probe(pod,self.c['new_dir'],after)
self.event('activation-verified',repositories=len(after),hook_sha256=self.c['new_hook_sha256'],baseline_hook_metadata_preserved=True)
except Exception as exc:
self.event('activation-failed',reason=str(exc) if isinstance(exc,RuntimeError) else type(exc).__name__)
try:self.rollback(before)
except Exception as recovery:self.event('rollback-unverified',reason=str(recovery) if isinstance(recovery,RuntimeError) else type(recovery).__name__)
raise
return Rollout
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--approved-digest',required=True)
p.add_argument('--binary',type=Path,default=Path('/tmp/sync-scanner-candidate/receive-secret-scan-candidate'));args=p.parse_args()
digest=verify_packet();ensure(args.approved_digest==digest,'exact-founder-acknowledgement-required')
m=support();c=json.loads((ORIGINAL/'inputs.json').read_bytes());runner=make_runner(m)(c)
runner.receipt.update(revision=4,packet_sha256=digest,reused_r2_packet_sha256=R2,mode='apply')
with OUTPUT.open('x') as stream:stream.write(json.dumps(runner.receipt)+'\n')
runner.apply(args.binary)
if __name__=='__main__':
try:main()
except Exception as exc:
print(json.dumps({'result':'refused-or-unverified','reason':str(exc) if isinstance(exc,RuntimeError) else type(exc).__name__}))
raise SystemExit(1)

View file

@ -0,0 +1,13 @@
from pathlib import Path
import subprocess,json,time
p=Path(__file__).resolve().parent
image='code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba'
prefix=['docker','run','--rm','--network=none','--user=1000:1000','--read-only','--cap-drop=ALL','--security-opt=no-new-privileges:true','--tmpfs','/tmp:rw,size=64m,uid=1000,gid=1000','--tmpfs','/data:rw,size=64m,uid=1000,gid=1000','--mount','type=bind,src='+str(p)+',dst=/scripts,readonly','--mount','type=bind,src='+str(p.parent/'native-r2')+',dst=/original,readonly','--entrypoint=/bin/sh',image,'/scripts/test_image.sh']
results=[]
for case in ['old-counterexample','roundtrip','complete-scope-refusal','partial-failure','symlink-refusal','fsgroup-modes','mode-drift','hash-drift']:
q=subprocess.run(prefix+[case],capture_output=True,timeout=60)
results.append({'case':case,'returncode':q.returncode})
(p/'image-tests.json').write_text(json.dumps({'image':image,'result':'passed' if len(results)==8 and all(x['returncode']==0 for x in results) else 'unverified','cases':results,'native_mutation':False},indent=2)+'\n')
print(json.dumps(results[-1]),flush=True)
if q.returncode:
print('test failed; raw output withheld');raise SystemExit(1)

View file

@ -0,0 +1,9 @@
{
"numeric_modes": true,
"descriptor_mode_restore": true,
"atomic_rename": true,
"temporary_test_removed": true,
"reference_option_supported": false,
"image": "code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba",
"native_mutation": false
}

View file

@ -0,0 +1,41 @@
#!/bin/sh
# Disposable public tool fixture in tmpfs, before hosted-hook/deployment changes.
set +vx
set -eu
export PATH=/usr/bin:/bin
test "$(id -u)" = 1000
test "$(stat -f -c %t /dev/shm)" = 1021994
umask 077
area=$(mktemp -d /dev/shm/forge-r4-tools-XXXXXXXX)
inode=$(stat -c '%d:%i' "$area")
cleanup() {
code=$?
trap - EXIT HUP INT TERM
if test -d "$area" && test ! -L "$area" && test "$(stat -c '%d:%i' "$area")" = "$inode"; then
rm -f -- "$area/file" "$area/replacement" || code=71
rmdir "$area" || code=71
else code=71; fi
exit "$code"
}
trap cleanup EXIT
trap 'exit 72' HUP INT TERM
printf 'public numeric-mode fixture\n' > "$area/file"
chmod 775 "$area/file"
file_inode=$(stat -c '%d:%i' "$area/file")
exec 7< "$area/file"
test "$(stat -L -c '%d:%i' /proc/self/fd/7)" = "$file_inode"
chmod 755 /proc/self/fd/7
test "$(stat -L -c %a /proc/self/fd/7)" = 755
test "$(stat -c %a "$area/file")" = 755
test "$(sha256sum /proc/self/fd/7 | cut -d ' ' -f 1)" = "$(sha256sum "$area/file" | cut -d ' ' -f 1)"
exec 7<&-
cp "$area/file" "$area/replacement"
mv -T -- "$area/replacement" "$area/file"
test -f "$area/file" && test ! -L "$area/file"
reference=false
if chmod --reference=/dev/null --help >/dev/null 2>&1; then reference=true; fi
rm -f -- "$area/file"
rmdir "$area"
test ! -e "$area"
trap - EXIT HUP INT TERM
printf '{"numeric_modes":true,"descriptor_mode_restore":true,"atomic_rename":true,"temporary_test_removed":true,"reference_option_supported":%s}\n' "$reference"

View file

@ -0,0 +1,87 @@
"""Apply exactly reviewed paired scanner and Apps source after native verification.
No Git operations or native actions. Requires founder acknowledgement of packet.
All six original hashes are checked before writing; differing owner work is refused.
"""
from pathlib import Path
import argparse
import hashlib
import importlib.util
import json
import os
import re
import tempfile
HERE = Path(__file__).resolve().parent
SOURCE = HERE.parent / 'native-r2'
def sha(raw): return hashlib.sha256(raw).hexdigest()
def patched(original, diff):
before = original.decode().splitlines(keepends=True)
result = []; cursor = 0; lines = diff.splitlines(keepends=True); i = 2
while i < len(lines):
match = re.fullmatch(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\n]*\n?', lines[i])
if not match: raise ValueError('malformed-hunk')
start = int(match[1]) - 1
if start < cursor: raise ValueError('overlapping-hunk')
result.extend(before[cursor:start]); cursor = start; i += 1
while i < len(lines) and not lines[i].startswith('@@ '):
line = lines[i]; tag, content = line[0], line[1:]
if tag in (' ', '-'):
if cursor >= len(before) or before[cursor] != content: raise ValueError('hunk-preimage-drift')
cursor += 1
if tag in (' ', '+'): result.append(content)
elif tag != '-': raise ValueError('unsupported-patch-line')
i += 1
result.extend(before[cursor:])
return ''.join(result).encode()
def candidates():
changes = json.loads((SOURCE / 'scanner-source-changes.json').read_bytes())
changes += json.loads((SOURCE / 'apps-source-changes.json').read_bytes())
result = []
for change in changes:
target = Path(change['target']); raw = target.read_bytes()
if sha(raw) != change['original_sha256']: raise RuntimeError('owner-source-drift:' + str(target))
candidate = ((SOURCE / change['candidate_file']).read_bytes() if 'candidate_file' in change else patched(raw, change['patch']))
if sha(candidate) != change['candidate_sha256']: raise RuntimeError('candidate-hash-mismatch')
result.append((target, raw, candidate))
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--apply', action='store_true')
parser.add_argument('--approved-digest')
args = parser.parse_args()
spec = importlib.util.spec_from_file_location('rollout', HERE / 'rollout.py')
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
digest = module.verify_packet()
prepared = candidates()
if args.apply:
if args.approved_digest != digest: raise RuntimeError('exact-founder-acknowledgement-required')
receipt = json.loads(Path('/tmp/forge-scanner-rollout-r4-receipt.json').read_bytes())
if receipt.get('packet_sha256') != digest or receipt['events'][-1]['event'] != 'activation-verified':
raise RuntimeError('native-activation-not-verified')
for target, original, candidate in prepared:
if target.read_bytes() != original: raise RuntimeError('concurrent-owner-source-drift')
mode = target.stat().st_mode & 0o777
fd, name = tempfile.mkstemp(prefix='.scanner-reviewed-', dir=target.parent)
try:
with os.fdopen(fd, 'wb') as stream: stream.write(candidate); stream.flush(); os.fsync(stream.fileno())
os.chmod(name, mode)
os.replace(name, target)
finally:
if os.path.exists(name): os.unlink(name)
print(json.dumps({'result': 'applied' if args.apply else 'preimages-and-candidates-verified', 'files': len(prepared), 'packet_sha256': digest}))
if __name__ == '__main__':
try: main()
except Exception as error:
print(json.dumps({'result': 'refused-or-unverified', 'reason': str(error) if isinstance(error, RuntimeError) else type(error).__name__}))
raise SystemExit(1)

View file

@ -0,0 +1,40 @@
"""Existing immutable ConfigMap and unchanged source preimages; no cluster access."""
from pathlib import Path
import copy
import importlib.util
import json
import unittest
HERE=Path(__file__).resolve().parent
s=importlib.util.spec_from_file_location('r4',HERE/'rollout.py');r=importlib.util.module_from_spec(s);s.loader.exec_module(r)
m=r.support();C=json.loads((r.ORIGINAL/'inputs.json').read_bytes())
class Tests(unittest.TestCase):
def runner(self,cm):
class Fake(r.make_runner(m)):
def k(self,*args,**kw):
self.last_command=args
return json.dumps(cm).encode()
return Fake(C)
def cm(self):
cm=json.loads((r.ORIGINAL/'configmap.json').read_bytes());cm['metadata']['uid']='known-public-cm-uid';return cm
def test_identical_existing_immutable_cm_is_reused_with_uid_pin(self):
cm=self.cm();runner=self.runner(cm);runner.existing_cm();runner.existing_cm()
self.assertEqual(runner.cm_uid,'known-public-cm-uid')
self.assertIn('get',runner.last_command);self.assertNotIn('create',runner.last_command)
cm['metadata']['uid']='recreated'
with self.assertRaisesRegex(RuntimeError,'identity-drift'):runner.existing_cm()
def test_mutable_or_changed_cm_data_refused(self):
for key in ('mutable','changed-data','extra-data'):
cm=self.cm()
if key=='mutable':cm['immutable']=False
elif key=='changed-data':cm['data']['hook']='changed'
else:cm['data']['unexpected']='public-sentinel'
with self.subTest(key=key),self.assertRaisesRegex(RuntimeError,'configmap-drift'):self.runner(cm).existing_cm()
def test_same_six_source_candidates_still_match(self):
s=importlib.util.spec_from_file_location('follow',HERE/'source_followthrough.py');f=importlib.util.module_from_spec(s);s.loader.exec_module(f)
self.assertEqual(len(f.candidates()),6)
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,70 @@
"""Run the actual controller migration/stream framing in pinned Forgejo userspace."""
from pathlib import Path
import importlib.util
import json
import subprocess
HERE=Path(__file__).resolve().parent
IMAGE='code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba'
spec=importlib.util.spec_from_file_location('r4',HERE/'rollout.py');r=importlib.util.module_from_spec(spec);spec.loader.exec_module(r)
m=r.support();C=json.loads((r.ORIGINAL/'inputs.json').read_bytes())
def main():
argv=['docker','run','-d','--rm','--network=none','--user=1000:1000','--read-only','--cap-drop=ALL','--security-opt=no-new-privileges:true',
'--tmpfs','/tmp:rw,size=64m,uid=1000,gid=1000','--tmpfs','/data:rw,size=64m,uid=1000,gid=1000',
'--mount','type=bind,src='+str(r.ORIGINAL)+',dst=/original,readonly','--entrypoint=/bin/sh',IMAGE,'-c','tail -f /dev/null']
created=subprocess.run(argv,capture_output=True,timeout=30)
if created.returncode:raise RuntimeError('fixture-container-start-failed')
cid=created.stdout.decode().strip();events=[];phase='setup'
def shell(script,args=(),data=None,timeout=180):
q=subprocess.run(['docker','exec','-i',cid,'/bin/sh','-c',script,'sh',*args],input=data,capture_output=True,timeout=timeout)
if q.returncode:raise RuntimeError('fixture-command-failed:'+phase)
return q.stdout
try:
script=r'''set -eu
n=0
while test "$n" -lt 171; do
name=$(printf test%03d "$n")
repo=/data/git/gitea-repositories/coulomb/$name.git
for hook in pre-receive post-receive update pre-receive.d/gitea post-receive.d/gitea update.d/gitea pre-receive.d/20-authorize post-receive.d/20-notify; do
path="$repo/hooks/$hook";mkdir -p "${path%/*}"
printf 'unchanged generated hook\n' > "$path";chmod 775 "$path"
done
file="$repo/hooks/pre-receive.d/10-custodian-secret-scan"
cp /original/before-hook "$file";chmod 775 "$file"
if test "$n" -lt 3; then chmod 755 "$file"; fi
if test "$n" -lt 16; then chmod 755 "$repo/hooks/pre-receive"; fi
n=$((n+1))
done
'''
shell(script)
class TestRunner(r.make_runner(m)):
def shell(self,pod,script,args=(),data=None,timeout=90):return shell(script,args,data,max(timeout,180))
def event(self,name,**fields):events.append({'event':name,**fields})
runner=TestRunner(C);before=runner.inventory('fixture',{C['old_hook_sha256']});runner.receipt['before_hooks']=before
phase='initial-migration';runner.migrate('fixture','apply')
phase='simulate-fsgroup';shell("find /data -type f -exec chmod g+w {} +")
phase='post-recreate-migration';runner.migrate('fixture','apply')
assert events[-1]['group_write_additions_reversed']==19
phase='simulate-second-fsgroup';shell("find /data -type f -exec chmod g+w {} +")
phase='rollback-migration';runner.migrate('fixture','rollback')
after=runner.inventory('fixture',{C['old_hook_sha256']});assert after==before
# Unknown protected data must stop before any scanner rewrite.
phase='protected-drift';shell("printf 'owner content drift\\n' > /data/git/gitea-repositories/coulomb/test000.git/hooks/pre-receive")
try:runner.migrate('fixture','apply')
except RuntimeError as exc:assert str(exc)=='protected-hook-content-drift'
else:raise AssertionError('protected-drift-not-refused')
still=runner.inventory('fixture',{C['old_hook_sha256']});assert len(still)==171
result={'result':'passed','image':IMAGE,'repositories':171,'hooks':sum(len(x) for x in before.values()),
'cases':['initial-migration','post-recreate-mode-restoration','rollback-mode-restoration','protected-drift-before-write'],
'group_write_additions_reversed':19,'events':events,'native_mutation':False}
(HERE/'controller-image-tests.json').write_text(json.dumps(result,indent=2)+'\n');print(json.dumps(result))
except Exception as exc:
(HERE/'controller-image-tests-failed.json').write_text(json.dumps({'result':'unverified','phase':phase,'error':type(exc).__name__,'image':IMAGE,'native_mutation':False},indent=2)+'\n')
raise
finally:
subprocess.run(['docker','stop','--time=0',cid],capture_output=True,timeout=30)
if __name__=='__main__':main()

View file

@ -0,0 +1,88 @@
#!/bin/sh
# Actual pinned Forgejo image, no host data/config mounts or network.
set +vx
set -eu
case=$1
area=/tmp/control
mkdir -p "$area"
cp /original/before-hook "$area/before"
cp /original/after-hook "$area/after"
before=$(sha256sum "$area/before" | cut -d ' ' -f 1)
after=$(sha256sum "$area/after" | cut -d ' ' -f 1)
first=/data/git/gitea-repositories/coulomb/first.git/hooks/pre-receive.d/10-custodian-secret-scan
second=/data/git/gitea-repositories/coulomb/second.git/hooks/pre-receive.d/10-custodian-secret-scan
for path in "$first" "$second"; do
mkdir -p "${path%/*}"
cp "$area/before" "$path"
chmod 755 "$path"
printf '%s\n' "$path" >> "$area/paths"
printf 'unchanged generated dispatcher\n' > "${path%/pre-receive.d/*}/pre-receive"
chmod 755 "${path%/pre-receive.d/*}/pre-receive"
done
chmod 775 "$second"
protected=${first%/pre-receive.d/*}/pre-receive
protected_sum=$(sha256sum "$protected" | cut -d ' ' -f 1)
run_hooks() { sh /scripts/hooks.sh "$area" "$1" "$before" "$after"; }
if test "$case" = old-counterexample; then
if sh /original/hooks.sh "$area" apply "$before" "$after" > /tmp/old.out 2>/tmp/old.err; then exit 80; fi
grep -qi 'reference' /tmp/old.err
printf '{"case":"old-counterexample","unsupported_reference_reproduced":true}\n'
exit 0
fi
case "$case" in
roundtrip)
run_hooks apply
test "$(stat -c %a "$first")" = 755
test "$(stat -c %a "$second")" = 775
run_hooks rollback
test "$(sha256sum "$first" | cut -d ' ' -f 1)" = "$before"
test "$(stat -c %a "$first")" = 755
test "$(stat -c %a "$second")" = 775
;;
complete-scope-refusal)
printf 'owner drift\n' > "$second"
if run_hooks apply >/dev/null 2>&1; then exit 81; fi
test "$(sha256sum "$first" | cut -d ' ' -f 1)" = "$before"
;;
partial-failure)
chmod 555 "${second%/*}"
if run_hooks apply >/dev/null 2>&1; then exit 82; fi
test "$(sha256sum "$first" | cut -d ' ' -f 1)" = "$before"
test "$(stat -c %a "$first")" = 755
;;
symlink-refusal)
rm "$second";ln -s "$first" "$second"
if run_hooks apply >/dev/null 2>&1; then exit 83; fi
test "$(sha256sum "$first" | cut -d ' ' -f 1)" = "$before"
;;
fsgroup-modes|mode-drift|hash-drift)
run_hooks apply >/dev/null
printf '755\t%s\t%s\n' "$after" "$first" > "$area/modes"
printf '775\t%s\t%s\n' "$after" "$second" >> "$area/modes"
printf '755\t%s\t%s\n' "$protected_sum" "$protected" >> "$area/modes"
chmod g+w "$first" "$protected"
if test "$case" = mode-drift; then
chmod 777 "$second"
if sh /scripts/restore_modes.sh "$area/modes" >/dev/null 2>&1; then exit 84; fi
test "$(stat -c %a "$first")" = 775
elif test "$case" = hash-drift; then
printf 'owner content drift\n' > "$protected"
if sh /scripts/restore_modes.sh "$area/modes" >/dev/null 2>&1; then exit 85; fi
test "$(stat -c %a "$first")" = 775
else
sh /scripts/restore_modes.sh "$area/modes"
test "$(stat -c %a "$first")" = 755
test "$(stat -c %a "$second")" = 775
test "$(stat -c %a "$protected")" = 755
test "$(sha256sum "$protected" | cut -d ' ' -f 1)" = "$protected_sum"
run_hooks rollback >/dev/null
fi
;;
*) exit 86;;
esac
# Hash drift fixture deliberately changes protected content; all others preserve it.
if test "$case" != hash-drift; then
test "$(sha256sum "$protected" | cut -d ' ' -f 1)" = "$protected_sum"
fi
test -z "$(find /data -name '.custodian-*' -print -quit)"
printf '{"case":"%s","passed":true}\n' "$case"