Skip to content

feat(v1.100 Amendment 2): code-A — G1/AuthorityNFTBan split + §54 evidence predicate #35

feat(v1.100 Amendment 2): code-A — G1/AuthorityNFTBan split + §54 evidence predicate

feat(v1.100 Amendment 2): code-A — G1/AuthorityNFTBan split + §54 evidence predicate #35

# =============================================================================
# NFTBan — CI: Restore Canonization Gate (v1.100 PR-24 + PR-25)
# =============================================================================
# SPDX-License-Identifier: MPL-2.0
# Purpose: Enforce v1.100 PR-24 (decision) + PR-25 (execution + Amendment 1
# CSF restore mutation) scope locks.
#
# G4-RESTORE-DECISION-CORRECTNESS — fixture rule-path coverage
# (every rule declared in engine.go
# must have a fixture, every fixture
# must point to a declared rule)
# G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs spawn zero
# external processes and reach zero
# execution branches
# G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package
# for kernel / service / filesystem
# mutation symbols
# G4-RESTORE-DETERMINISM — back-to-back fixture runs yield
# identical output, rule, and reason
# G4-RESTORE-EXEC-NO-OUT-OF-TARGET — closed mutation surface in
# cmd/nftban-installer/restore_deps_csf.go;
# only the §31 A.1-A.7 authorized
# operations are permitted
# (Amendment 1 §§30-36)
#
# Contracts:
# - internal/installer/restore/contract.md Part I (PR-24, merged 2026-04-20)
# - internal/installer/restore/contract.md Part II (PR-25, merged 2026-04-27)
# - internal/installer/restore/contract.md Part III (Amendment 1, merged 2026-04-28)
# =============================================================================
name: Restore Canonization Gate
on:
pull_request:
branches: [main, master]
paths:
- 'cmd/nftban-installer/**'
- 'internal/installer/restore/**'
- 'internal/installer/state/**'
- 'internal/installer/uninstall/**'
- '.github/workflows/ci-restore-canonization.yml'
push:
branches: [main]
workflow_dispatch:
concurrency:
group: ci-restore-canonization-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
restore-canonization:
name: Restore Canonization (${{ matrix.label }})
strategy:
fail-fast: false
matrix:
include:
- label: ubuntu-24.04
runner: ubuntu-24.04
container: ''
- label: almalinux-9
runner: ubuntu-24.04
container: almalinux:9
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Install system dependencies (DEB)
if: matrix.label == 'ubuntu-24.04'
run: |
sudo apt-get update -qq
sudo apt-get install -y jq
- name: Install system dependencies (RPM)
if: matrix.label == 'almalinux-9'
run: |
dnf -y install epel-release
dnf -y install jq git tar gzip procps-ng findutils sudo golang
- name: Set up Go (DEB only)
if: matrix.label == 'ubuntu-24.04'
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.6.0
with:
go-version: '1.25'
# ------------------------------------------------------------------
# G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package
# for any symbol that could cause kernel / service / filesystem
# mutation. Per contract seed §10, the restore package must contain
# zero such symbols. Fires BEFORE tests so a reviewer sees the
# scope violation immediately.
# ------------------------------------------------------------------
- name: G4-RESTORE-NO-IMPLICIT-EXEC — static scan of restore package
shell: bash
run: |
set -Eeuo pipefail
# Scope: restore package, non-test Go files only. Test files
# may reference these symbols as fixtures / negative-test
# coverage, which is legitimate.
files=$(find internal/installer/restore -type f -name '*.go' 2>/dev/null | grep -vE '_test\.go$' || true)
if [[ -z "$files" ]]; then
echo "::error::G4-RESTORE-NO-IMPLICIT-EXEC: restore package files not found"
exit 1
fi
# Forbidden symbols per contract seed §10 "Forbidden surfaces"
# and §12 G4-RESTORE-NO-IMPLICIT-EXEC assertion.
forbidden_patterns=(
# Process spawn paths
'exec\.Run\('
'exec\.RunWithStderr\('
'exec\.CommandContext\b'
'"os/exec"'
# Direct mutation commands by name
'exec\.Run\("nft"'
'exec\.Run\("iptables"'
'exec\.Run\("ip6tables"'
'exec\.Run\("systemctl"'
'exec\.Run\("ufw"'
'exec\.Run\("firewall-cmd"'
# Service lifecycle helpers
'exec\.ServiceStop\('
'exec\.ServiceStart\('
'exec\.ServiceRestart\('
'exec\.ServiceEnable\('
'exec\.ServiceDisable\('
'exec\.ServiceMask\('
'exec\.ServiceUnmask\('
# Filesystem write APIs
'exec\.WriteFileAtomic\('
'os\.Create\('
'os\.WriteFile\('
'os\.OpenFile\('
'os\.Rename\('
'os\.Remove\('
'os\.RemoveAll\('
'os\.MkdirAll\('
'os\.Mkdir\('
'ioutil\.WriteFile\('
# Rebuild / restoration execution helpers
'rebuild\.Run\('
'rebuild\.Apply\('
'switchop\.'
'services\.Enable'
'services\.Disable'
'services\.Mask'
'services\.Unmask'
)
fail=0
for pat in "${forbidden_patterns[@]}"; do
if grep -nE "$pat" $files 2>/dev/null; then
echo "::error::G4-RESTORE-NO-IMPLICIT-EXEC: forbidden symbol '$pat' found in restore package"
fail=1
fi
done
if [[ "$fail" -ne 0 ]]; then
echo "::error::PR-24 scope violation — restore package must be pure decision; no kernel / service / filesystem mutation symbols permitted."
echo "::error::See internal/installer/restore/contract.md §10 for the full forbidden list."
exit 1
fi
echo "G4-RESTORE-NO-IMPLICIT-EXEC PASS — restore package is symbolically pure"
# ------------------------------------------------------------------
# G4-RESTORE-DECISION-CORRECTNESS — rule-path coverage.
#
# The restore engine test TestRuleCoverage_EveryRuleExercised
# asserts: every Rule* constant declared in engine.go MUST be hit
# by at least one fixture. A new rule without a fixture, or a
# fixture referencing a non-declared rule, fails this test.
# ------------------------------------------------------------------
- name: G4-RESTORE-DECISION-CORRECTNESS — rule-path coverage
shell: bash
env:
TMPDIR: /var/tmp
run: |
set -Eeuo pipefail
mkdir -p /var/tmp
go test -v -count=1 -run 'TestDecide_FixtureMatrix|TestRuleCoverage_EveryRuleExercised|TestDecide_OutputClosedEnum|TestDecide_LockedAmendment|TestDecide_HardStopsDominateAnyFlag|TestDecide_OrphanPanelAutoRefused' ./internal/installer/restore/...
# ------------------------------------------------------------------
# G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs reach no
# execution branch.
#
# Implementation: the Decide() function is pure (no executor
# argument). Any execution path would require the dispatcher to
# invoke additional code after the Decide call. This gate asserts
# structurally that the restore package has no executor dependency
# AND that the dispatcher does not call into mutation-capable
# packages. Combined with G4-RESTORE-NO-IMPLICIT-EXEC, this closes
# the refusal-integrity claim.
# ------------------------------------------------------------------
- name: G4-RESTORE-REFUSAL-INTEGRITY — no executor dependency in restore package
shell: bash
run: |
set -Eeuo pipefail
non_test_files=$(find internal/installer/restore -type f -name '*.go' 2>/dev/null | grep -vE '_test\.go$' || true)
if [[ -z "$non_test_files" ]]; then
echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore package files not found"
exit 1
fi
# The restore package must not import the executor package in
# non-test code. If it did, the engine could grow a side-effect
# path. Fixtures / tests may reference it for simulation
# purposes, so test files are excluded from this check.
if grep -nE '"github.com/itcmsgr/nftban/internal/installer/executor"' $non_test_files 2>/dev/null; then
echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore package imports executor — Decide must remain pure"
exit 1
fi
# The dispatcher (restore_decide.go) uses the executor to
# gather inputs (Classify / Probe / DetectPanel) — that is
# permitted. But after the Decide() call returns a non-PROCEED
# output, the dispatcher must not call any mutation helper.
# This check enforces structural layering: mutation helpers
# are imported only by uninstall_apply.go, never by
# restore_decide.go.
if grep -nE '"github.com/itcmsgr/nftban/internal/installer/switchop"|"github.com/itcmsgr/nftban/internal/installer/services"' cmd/nftban-installer/restore_decide.go 2>/dev/null; then
echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore dispatcher imports mutation-capable package"
exit 1
fi
echo "G4-RESTORE-REFUSAL-INTEGRITY PASS — restore engine is pure + dispatcher is layering-clean"
# ------------------------------------------------------------------
# G4-RESTORE-DETERMINISM — same inputs → same outputs across runs.
# TestDecide_Determinism runs every fixture twice via reflect.DeepEqual.
# ------------------------------------------------------------------
- name: G4-RESTORE-DETERMINISM — repeated evaluation equality
shell: bash
env:
TMPDIR: /var/tmp
run: |
set -Eeuo pipefail
mkdir -p /var/tmp
go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/...
# Run a second time from scratch and diff nothing — the
# test itself asserts determinism within one run; this
# outer loop asserts determinism ACROSS runs (no cached
# state, no env reliance).
go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/... 2>&1 | tee /tmp/run1.log
go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/... 2>&1 | tee /tmp/run2.log
# Strip timing lines (ok line carries elapsed seconds) and diff.
grep -vE '^ok\s|^FAIL\s' /tmp/run1.log > /tmp/run1.norm
grep -vE '^ok\s|^FAIL\s' /tmp/run2.log > /tmp/run2.norm
if ! diff -q /tmp/run1.norm /tmp/run2.norm >/dev/null; then
echo "::error::G4-RESTORE-DETERMINISM: test output differs across runs"
diff /tmp/run1.norm /tmp/run2.norm || true
exit 1
fi
echo "G4-RESTORE-DETERMINISM PASS — identical across two independent runs"
# ------------------------------------------------------------------
# G4-RESTORE-EXEC-NO-OUT-OF-TARGET — closed mutation surface in
# the cmd/-side restore-execution code. Per Amendment 1 §31 / §32,
# the authorized mutations on the CSF restore path are:
#
# - exec.ServiceUnmask("csf.service") A.1 (typed; PR-26-code-B)
# - exec.ServiceEnable("csf.service") A.2
# - exec.Rename("/usr/sbin/csf.disabled",
# "/usr/sbin/csf") A.3 (typed; PR-26-code-B)
# - exec.ServiceStart("csf.service") A.5
# - exec.ServiceStop("nftband.service") A.6
# - exec.NftDeleteTable("ip", "nftban") A.7
# - exec.NftDeleteTable("ip6", "nftban") A.7
#
# PR-26-code-B (§43) promoted A.1 + A.3 from raw `Run("systemctl",
# "unmask", …)` and `Run("mv", …)` to the typed methods listed
# above. The gate's old `\bexec\.ServiceUnmask\(` forbid is
# therefore REMOVED, and explicit raw-Run forbids replace it.
#
# The gate's job is forbidden-symbol coverage. Per-call argument
# enforcement (which unit is started, which path is renamed) is
# delegated to the in-Go runtime tests in restore_deps_csf_test.go
# (TestCSFMutate_4B3csf_A1_NoUnmaskOfOtherServices,
# …_A2_EnableOnlyCSFService, …_A5_StartsOnlyCSFService,
# …_A6_StopsOnlyNftband_AfterCSFStarts, …_HappyPath_NoOutOfTargetMutation,
# TestCSFMutate_PR26B_A1_ServiceUnmaskOnlyCSFService,
# TestCSFMutate_PR26B_A3_RenameOnlyCSFBinaryRestore).
#
# §46.1 line-skipping discipline: the gate scans only the
# production file (no _test.go), and skips line-leading "//"
# comments to avoid the false-positive class that hit Policy
# Gates on PR #511.
# ------------------------------------------------------------------
- name: G4-RESTORE-EXEC-NO-OUT-OF-TARGET — restore_deps_csf.go closed mutation surface
shell: bash
run: |
set -Eeuo pipefail
target=cmd/nftban-installer/restore_deps_csf.go
if [[ ! -f "$target" ]]; then
echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: $target not found"
exit 1
fi
# §46.1 production-only scan: build a comment-stripped view
# so doc-comment lines never trip the forbidden-pattern grep.
stripped=$(grep -vE '^[[:space:]]*//' "$target" || true)
# Forbidden: out-of-Amendment-1 mutation surface. Each pattern
# is anchored to a call expression (\bexec\. or similar) so
# legitimate string literals don't false-match. Doc comments
# are already excluded by the line-leading // strip above.
#
# WriteFileAtomic is NOT globally forbidden here because
# PR-26-code-C authorizes A.4 cron restore writes (only to
# /etc/cron.d/csf-cron and /etc/cron.d/lfd-cron, gated by
# the §42.2 manifest). Those writes are constrained by the
# dedicated G4-RESTORE-CRON-MANIFEST-INTEGRITY gate below
# (writer + reader symbol pin + cron-target literal allow-
# list). Carving line-exceptions into this gate would recreate
# the regex-brittleness class the auditor flagged at PR #515.
forbidden_patterns=(
# Service-policy mutations (mask/disable/daemon-reload) are
# forbidden by Amendment 1 §34. ServiceUnmask is no longer
# forbidden — PR-26-code-B promoted it to authorized typed
# method for A.1.
'\bexec\.ServiceMask\('
'\bexec\.ServiceDisable\('
'\bexec\.DaemonReload\('
# Direct OS bypass (must route through the executor).
'"os/exec"'
'\bexec\.Command\('
'\bos\.Rename\('
'\bos\.Remove[All]*\('
'\bos\.WriteFile\('
'\bos\.Create\('
'\bsyscall\.'
# PR-26-code-B raw-Run policy tightening (§43.3): mutating
# systemctl verbs MUST go through their typed methods.
# Read-only `Run("systemctl", "is-enabled", …)` and similar
# probes remain authorized.
'Run\("systemctl",[[:space:]]*"start"'
'Run\("systemctl",[[:space:]]*"stop"'
'Run\("systemctl",[[:space:]]*"enable"'
'Run\("systemctl",[[:space:]]*"disable"'
'Run\("systemctl",[[:space:]]*"mask"'
'Run\("systemctl",[[:space:]]*"unmask"'
'Run\("systemctl",[[:space:]]*"restart"'
'Run\("systemctl",[[:space:]]*"reload"'
'Run\("systemctl",[[:space:]]*"daemon-reload"'
# Raw mv via Run is forbidden — typed exec.Rename is the
# only authorized atomic-rename surface.
'Run\("mv"\b'
# DirectAdmin custombuild rewrite forbidden (§34).
'\bcustombuild\b'
'"build set csf"'
# iptables / ip6tables manual rule restoration forbidden
# (§34: csf rebuilds its own iptables on start).
'iptables-restore'
'ip6tables-restore'
# Generic "fix everything" / "purge" / "rebuild" surface
# — Amendment 1 narrow scope forbids broad sweeps.
'\brebuild\.Run\('
'\brebuild\.Apply\('
'\bpurge\.'
'\bcleanup\.Apply\('
)
fail=0
for pat in "${forbidden_patterns[@]}"; do
if echo "$stripped" | grep -nE "$pat" >/dev/null 2>&1; then
# Re-grep against the original file (with line numbers)
# so the error message points at the right line.
echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: forbidden call expression matching '$pat' found in $target"
grep -nE "$pat" "$target" || true
fail=1
fi
done
# Allow-list pin: every NftDeleteTable call in $target MUST
# delete only ip:nftban or ip6:nftban — never any other table.
# NftDeleteTable's args in the production code ARE literal
# quoted strings (no constants), so this allow-list pin works
# cleanly without identifier resolution. Per-unit / per-path
# enforcement for systemctl + Rename is delegated to the Go
# runtime tests against MockExecutor.
while IFS= read -r line; do
args=$(echo "$line" | grep -oE 'NftDeleteTable\([^)]*\)' || true)
if [[ -n "$args" ]]; then
case "$args" in
'NftDeleteTable("ip", "nftban")'|'NftDeleteTable("ip6", "nftban")')
;;
*)
echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: unauthorized NftDeleteTable call: $args"
fail=1
;;
esac
fi
done < <(echo "$stripped" | grep -n 'NftDeleteTable(' || true)
if [[ "$fail" -ne 0 ]]; then
echo "::error::Amendment 1 §30 / §34 violation — restore_deps_csf.go must contain only the closed §31/§32 mutation surface."
echo "::error::See internal/installer/restore/contract.md §§30-36 for the full authorization."
echo "::error::Per-call argument enforcement is in restore_deps_csf_test.go runtime tests; this gate covers forbidden-symbol scope only."
exit 1
fi
echo "G4-RESTORE-EXEC-NO-OUT-OF-TARGET PASS — restore_deps_csf.go mutation surface is closed"
# ------------------------------------------------------------------
# G4-RESTORE-CRON-MANIFEST-INTEGRITY (PR-26-code-C / §46) —
# structural pin on the CSF/LFD cron-backup writer + reader.
#
# The §42.2 lock authorizes A.4 cron-restore ONLY when the
# install-time writer recorded the file content with sha256 and
# ONLY for the two locked source paths
# (/etc/cron.d/csf-cron, /etc/cron.d/lfd-cron). This gate
# structurally enforces:
#
# - the writer file (switchop/cron_manifest.go) declares the
# manifest-dir constant, the schema-version constant, and
# both source-path constants verbatim
# - the writer file uses the shared sha256 helper symbol
# ComputeCronBackupSHA256 to compute the manifest entry
# - the reader file (cmd/nftban-installer/restore_deps_csf.go)
# references both ReadCronBackupManifest and
# VerifyCronBackupEntry — i.e. the integrity check is
# consumed, not just imported
# - neither file references DirectAdmin custombuild,
# iptables-restore, or a broad /etc/cron.d/* glob
#
# Per §46.1 discipline: production-code-only, comment-stripped.
# ------------------------------------------------------------------
- name: G4-RESTORE-CRON-MANIFEST-INTEGRITY — writer + reader structural pin
shell: bash
run: |
set -Eeuo pipefail
writer=internal/installer/switchop/cron_manifest.go
reader=cmd/nftban-installer/restore_deps_csf.go
for f in "$writer" "$reader"; do
if [[ ! -f "$f" ]]; then
echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: $f not found"
exit 1
fi
done
# §46.1 production-only scan: strip line-leading // comments.
writer_src=$(grep -vE '^[[:space:]]*//' "$writer" || true)
reader_src=$(grep -vE '^[[:space:]]*//' "$reader" || true)
fail=0
# ---- WRITER required symbols -------------------------------
# Each pattern is a structural element the writer MUST contain.
# Whitespace-flexible matchers ([[:space:]]+) so the patterns
# don't break when gofmt re-aligns the const block.
writer_required=(
'CronManifestSchemaVersion[[:space:]]+=[[:space:]]+"1\.0\.0"'
'CronManifestDir[[:space:]]+=[[:space:]]+"/var/lib/nftban/state/csf-cron-backup"'
'CronManifestFile[[:space:]]+=[[:space:]]+"/var/lib/nftban/state/csf-cron-backup/manifest\.json"'
'CronCSFSrcPath[[:space:]]+=[[:space:]]+"/etc/cron\.d/csf-cron"'
'CronLFDSrcPath[[:space:]]+=[[:space:]]+"/etc/cron\.d/lfd-cron"'
'func ComputeCronBackupSHA256\(content \[\]byte\) string'
'func WriteCronBackupManifest\('
'func ReadCronBackupManifest\('
'func VerifyCronBackupEntry\('
'sha256\.Sum256'
)
for pat in "${writer_required[@]}"; do
if ! echo "$writer_src" | grep -qE "$pat"; then
echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: writer ($writer) missing required symbol matching '$pat'"
fail=1
fi
done
# ---- READER required symbols -------------------------------
# The A.4 reader path MUST consume both the manifest reader
# and the integrity-verifier — i.e. the sha256 check is
# actually performed before A.4 acts.
reader_required=(
'switchop\.ReadCronBackupManifest\('
'switchop\.VerifyCronBackupEntry\('
'ErrCSFRestoreCronManifestCorrupt'
)
for pat in "${reader_required[@]}"; do
if ! echo "$reader_src" | grep -qE "$pat"; then
echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: reader ($reader) missing required symbol matching '$pat'"
fail=1
fi
done
# ---- WRITER + READER forbidden symbols ---------------------
# Defense-in-depth: even though the strengthened
# G4-RESTORE-EXEC-NO-OUT-OF-TARGET already covers some of
# these, restate the cron-specific bans.
forbidden=(
'\bcustombuild\b'
'iptables-restore'
'"/etc/cron\.d/\*"'
'WriteFile.*"/etc/cron\.d/[^c]'
)
for pat in "${forbidden[@]}"; do
if echo "$writer_src" | grep -qE "$pat"; then
echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: writer ($writer) contains forbidden pattern '$pat'"
fail=1
fi
if echo "$reader_src" | grep -qE "$pat"; then
echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: reader ($reader) contains forbidden pattern '$pat'"
fail=1
fi
done
# ---- Reader's authorized target-path allow-list -----------
# The A.4 reader's WriteFileAtomic call MUST target only one
# of the two §42.2-locked paths. Structural check: every
# WriteFileAtomic call in restore_deps_csf.go that targets a
# /etc/cron.d/* path MUST use one of csfCronPath / lfdCronPath
# as the first argument. We grep for any /etc/cron.d/ literal
# in WriteFileAtomic args and require it equal one of the two
# locked literals. Path constants live in restore_deps_csf.go
# (csfCronPath = "/etc/cron.d/csf-cron";
# lfdCronPath = "/etc/cron.d/lfd-cron"), so the reader uses
# the constants — string-literal grep should find zero
# /etc/cron.d/ matches inside WriteFileAtomic argument lists.
while IFS= read -r line; do
# Capture WriteFileAtomic(... "literal" ...) pattern.
literal=$(echo "$line" | grep -oE 'WriteFileAtomic\([^)]*"/etc/cron\.d/[^"]*"' | grep -oE '"/etc/cron\.d/[^"]*"' || true)
if [[ -n "$literal" ]]; then
case "$literal" in
'"/etc/cron.d/csf-cron"'|'"/etc/cron.d/lfd-cron"')
;;
*)
echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: reader writes unauthorized cron literal: $literal"
fail=1
;;
esac
fi
done < <(echo "$reader_src" | grep -nE 'WriteFileAtomic\([^)]*"/etc/cron\.d/' || true)
if [[ "$fail" -ne 0 ]]; then
echo "::error::§42.2 / §46 violation — cron-backup manifest integrity not enforced."
echo "::error::See internal/installer/restore/contract.md §42 + §46 for the lock."
exit 1
fi
echo "G4-RESTORE-CRON-MANIFEST-INTEGRITY PASS — writer + reader structurally consume the shared sha256 + manifest API"
# ------------------------------------------------------------------
# G4-RESTORE-EVIDENCE-RECORD (PR-26-code-D / §46) — structural
# pin on the post-restore evidence-record writer.
#
# The §39.3 + §48.6 lock requires that ALL evidence-record file
# writes route through a SINGLE helper using a NAMED CONSTANT
# for the destination directory. This gate structurally
# enforces:
#
# - cmd/nftban-installer/restore_evidence.go declares the
# restoreEvidenceDir constant verbatim and sets its value
# to /var/lib/nftban/state/restore-evidence
# - the file declares the writeRestoreEvidenceRecord helper
# and the schema_version constant set to "1.0.0"
# - the file contains exactly ONE WriteFileAtomic call (the
# call inside the single helper)
# - the file does NOT reference update-history.json,
# uninstall.Probe, restore.Decide, restore.PlanFromDecision,
# detect.DetectPanel, or any mutation primitive
# (recording-only invariant per §39.3)
# - the dispatcher (cmd/nftban-installer/restore_decide.go)
# calls writeRestoreEvidenceRecord — i.e. evidence is
# actually consumed, not just imported
#
# §46.1 discipline: production-code-only, comment-stripped.
# ------------------------------------------------------------------
- name: G4-RESTORE-EVIDENCE-RECORD — single-helper structural pin
shell: bash
run: |
set -Eeuo pipefail
ev=cmd/nftban-installer/restore_evidence.go
dispatcher=cmd/nftban-installer/restore_decide.go
for f in "$ev" "$dispatcher"; do
if [[ ! -f "$f" ]]; then
echo "::error::G4-RESTORE-EVIDENCE-RECORD: $f not found"
exit 1
fi
done
ev_src=$(grep -vE '^[[:space:]]*//' "$ev" || true)
disp_src=$(grep -vE '^[[:space:]]*//' "$dispatcher" || true)
fail=0
# ---- Required symbols in the evidence module ---------------
ev_required=(
'restoreEvidenceDir[[:space:]]+=[[:space:]]+"/var/lib/nftban/state/restore-evidence"'
'restoreEvidenceSchemaVersion[[:space:]]+=[[:space:]]+"1\.0\.0"'
'restoreEvidenceFilenamePrefix[[:space:]]+=[[:space:]]+"restore-evidence-"'
'func writeRestoreEvidenceRecord\('
'func buildRestoreEvidenceRecord\('
'type RestoreEvidenceRecord struct'
)
for pat in "${ev_required[@]}"; do
if ! echo "$ev_src" | grep -qE "$pat"; then
echo "::error::G4-RESTORE-EVIDENCE-RECORD: $ev missing required symbol matching '$pat'"
fail=1
fi
done
# ---- Single-helper invariant -------------------------------
# Exactly one WriteFileAtomic call in the evidence module —
# the one inside writeRestoreEvidenceRecord. Anything else
# is a violation of the §46 single-helper lock.
wfa_count=$(echo "$ev_src" | grep -cE '\bWriteFileAtomic\(' || true)
if [[ "$wfa_count" -ne 1 ]]; then
echo "::error::G4-RESTORE-EVIDENCE-RECORD: $ev contains $wfa_count WriteFileAtomic calls; want exactly 1 (single-helper invariant)"
fail=1
fi
# ---- Forbidden recording-only-violation symbols -----------
ev_forbidden=(
'restore\.Decide\('
'restore\.PlanFromDecision\('
'uninstall\.Probe\('
'detect\.DetectPanel\('
'writeHistory\('
'update-history\.json'
'\bexec\.ServiceStart\('
'\bexec\.ServiceStop\('
'\bexec\.ServiceEnable\('
'\bexec\.ServiceDisable\('
'\bexec\.ServiceMask\('
'\bexec\.ServiceUnmask\('
'\bexec\.NftDeleteTable\('
'\bexec\.NftAddElement\('
'\bexec\.DaemonReload\('
'\bexec\.Rename\('
'"os/exec"'
'\bexec\.Command\('
'\bos\.Rename\('
'\bos\.Remove\('
'\bos\.WriteFile\('
'\bos\.Create\('
'\bsyscall\.'
)
for pat in "${ev_forbidden[@]}"; do
if echo "$ev_src" | grep -qE "$pat"; then
echo "::error::G4-RESTORE-EVIDENCE-RECORD: $ev contains forbidden pattern '$pat' (recording-only invariant)"
fail=1
fi
done
# ---- Dispatcher consumption pin ----------------------------
# The dispatcher MUST call writeRestoreEvidenceRecord — proves
# evidence is consumed, not just imported.
if ! echo "$disp_src" | grep -qE '\bwriteRestoreEvidenceRecord\('; then
echo "::error::G4-RESTORE-EVIDENCE-RECORD: $dispatcher does not call writeRestoreEvidenceRecord"
fail=1
fi
if ! echo "$disp_src" | grep -qE '\bbuildRestoreEvidenceRecord\('; then
echo "::error::G4-RESTORE-EVIDENCE-RECORD: $dispatcher does not call buildRestoreEvidenceRecord"
fail=1
fi
if [[ "$fail" -ne 0 ]]; then
echo "::error::§39.3 / §46 / §48.6 violation — restore evidence-record writer not structurally enforced."
exit 1
fi
echo "G4-RESTORE-EVIDENCE-RECORD PASS — writer + dispatcher structurally consume the single evidence helper"
restore-canonization-summary:
name: Restore Canonization summary
runs-on: ubuntu-24.04
needs: [restore-canonization]
if: always()
steps:
- name: Summarize
run: |
if [[ "${{ needs.restore-canonization.result }}" != "success" ]]; then
echo "::error::Restore Canonization FAILED"
exit 1
fi
echo "Restore Canonization: all G4 gates passed across matrix"