|
| 1 | +# ============================================================================= |
| 2 | +# NFTBan — CI: Restore Canonization Gate (v1.100 PR-24 slice) |
| 3 | +# ============================================================================= |
| 4 | +# SPDX-License-Identifier: MPL-2.0 |
| 5 | +# Purpose: Enforce v1.100 PR-24 scope lock on the authority restoration |
| 6 | +# policy decision engine. PR-24 is POLICY ONLY — no execution. |
| 7 | +# |
| 8 | +# G4-RESTORE-DECISION-CORRECTNESS — fixture rule-path coverage |
| 9 | +# (every rule declared in engine.go |
| 10 | +# must have a fixture, every fixture |
| 11 | +# must point to a declared rule) |
| 12 | +# G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs spawn zero |
| 13 | +# external processes and reach zero |
| 14 | +# execution branches |
| 15 | +# G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package |
| 16 | +# for kernel / service / filesystem |
| 17 | +# mutation symbols |
| 18 | +# G4-RESTORE-DETERMINISM — back-to-back fixture runs yield |
| 19 | +# identical output, rule, and reason |
| 20 | +# |
| 21 | +# Contract: internal/installer/restore/contract.md (merged 2026-04-20) |
| 22 | +# ============================================================================= |
| 23 | + |
| 24 | +name: Restore Canonization Gate |
| 25 | + |
| 26 | +on: |
| 27 | + pull_request: |
| 28 | + branches: [main, master] |
| 29 | + paths: |
| 30 | + - 'cmd/nftban-installer/**' |
| 31 | + - 'internal/installer/restore/**' |
| 32 | + - 'internal/installer/state/**' |
| 33 | + - 'internal/installer/uninstall/**' |
| 34 | + - '.github/workflows/ci-restore-canonization.yml' |
| 35 | + push: |
| 36 | + branches: [main] |
| 37 | + workflow_dispatch: |
| 38 | + |
| 39 | +concurrency: |
| 40 | + group: ci-restore-canonization-${{ github.ref }} |
| 41 | + cancel-in-progress: true |
| 42 | + |
| 43 | +permissions: |
| 44 | + contents: read |
| 45 | + |
| 46 | +jobs: |
| 47 | + restore-canonization: |
| 48 | + name: Restore Canonization (${{ matrix.label }}) |
| 49 | + strategy: |
| 50 | + fail-fast: false |
| 51 | + matrix: |
| 52 | + include: |
| 53 | + - label: ubuntu-24.04 |
| 54 | + runner: ubuntu-24.04 |
| 55 | + container: '' |
| 56 | + - label: almalinux-9 |
| 57 | + runner: ubuntu-24.04 |
| 58 | + container: almalinux:9 |
| 59 | + runs-on: ${{ matrix.runner }} |
| 60 | + container: ${{ matrix.container }} |
| 61 | + steps: |
| 62 | + - name: Checkout |
| 63 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 |
| 64 | + |
| 65 | + - name: Install system dependencies (DEB) |
| 66 | + if: matrix.label == 'ubuntu-24.04' |
| 67 | + run: | |
| 68 | + sudo apt-get update -qq |
| 69 | + sudo apt-get install -y jq |
| 70 | +
|
| 71 | + - name: Install system dependencies (RPM) |
| 72 | + if: matrix.label == 'almalinux-9' |
| 73 | + run: | |
| 74 | + dnf -y install epel-release |
| 75 | + dnf -y install jq git tar gzip procps-ng findutils sudo golang |
| 76 | +
|
| 77 | + - name: Set up Go (DEB only) |
| 78 | + if: matrix.label == 'ubuntu-24.04' |
| 79 | + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.6.0 |
| 80 | + with: |
| 81 | + go-version: '1.25' |
| 82 | + |
| 83 | + # ------------------------------------------------------------------ |
| 84 | + # G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package |
| 85 | + # for any symbol that could cause kernel / service / filesystem |
| 86 | + # mutation. Per contract seed §10, the restore package must contain |
| 87 | + # zero such symbols. Fires BEFORE tests so a reviewer sees the |
| 88 | + # scope violation immediately. |
| 89 | + # ------------------------------------------------------------------ |
| 90 | + - name: G4-RESTORE-NO-IMPLICIT-EXEC — static scan of restore package |
| 91 | + shell: bash |
| 92 | + run: | |
| 93 | + set -Eeuo pipefail |
| 94 | +
|
| 95 | + # Scope: restore package, non-test Go files only. Test files |
| 96 | + # may reference these symbols as fixtures / negative-test |
| 97 | + # coverage, which is legitimate. |
| 98 | + files=$(find internal/installer/restore -type f -name '*.go' 2>/dev/null | grep -vE '_test\.go$' || true) |
| 99 | + if [[ -z "$files" ]]; then |
| 100 | + echo "::error::G4-RESTORE-NO-IMPLICIT-EXEC: restore package files not found" |
| 101 | + exit 1 |
| 102 | + fi |
| 103 | +
|
| 104 | + # Forbidden symbols per contract seed §10 "Forbidden surfaces" |
| 105 | + # and §12 G4-RESTORE-NO-IMPLICIT-EXEC assertion. |
| 106 | + forbidden_patterns=( |
| 107 | + # Process spawn paths |
| 108 | + 'exec\.Run\(' |
| 109 | + 'exec\.RunWithStderr\(' |
| 110 | + 'exec\.CommandContext\b' |
| 111 | + '"os/exec"' |
| 112 | + # Direct mutation commands by name |
| 113 | + 'exec\.Run\("nft"' |
| 114 | + 'exec\.Run\("iptables"' |
| 115 | + 'exec\.Run\("ip6tables"' |
| 116 | + 'exec\.Run\("systemctl"' |
| 117 | + 'exec\.Run\("ufw"' |
| 118 | + 'exec\.Run\("firewall-cmd"' |
| 119 | + # Service lifecycle helpers |
| 120 | + 'exec\.ServiceStop\(' |
| 121 | + 'exec\.ServiceStart\(' |
| 122 | + 'exec\.ServiceRestart\(' |
| 123 | + 'exec\.ServiceEnable\(' |
| 124 | + 'exec\.ServiceDisable\(' |
| 125 | + 'exec\.ServiceMask\(' |
| 126 | + 'exec\.ServiceUnmask\(' |
| 127 | + # Filesystem write APIs |
| 128 | + 'exec\.WriteFileAtomic\(' |
| 129 | + 'os\.Create\(' |
| 130 | + 'os\.WriteFile\(' |
| 131 | + 'os\.OpenFile\(' |
| 132 | + 'os\.Rename\(' |
| 133 | + 'os\.Remove\(' |
| 134 | + 'os\.RemoveAll\(' |
| 135 | + 'os\.MkdirAll\(' |
| 136 | + 'os\.Mkdir\(' |
| 137 | + 'ioutil\.WriteFile\(' |
| 138 | + # Rebuild / restoration execution helpers |
| 139 | + 'rebuild\.Run\(' |
| 140 | + 'rebuild\.Apply\(' |
| 141 | + 'switchop\.' |
| 142 | + 'services\.Enable' |
| 143 | + 'services\.Disable' |
| 144 | + 'services\.Mask' |
| 145 | + 'services\.Unmask' |
| 146 | + ) |
| 147 | +
|
| 148 | + fail=0 |
| 149 | + for pat in "${forbidden_patterns[@]}"; do |
| 150 | + if grep -nE "$pat" $files 2>/dev/null; then |
| 151 | + echo "::error::G4-RESTORE-NO-IMPLICIT-EXEC: forbidden symbol '$pat' found in restore package" |
| 152 | + fail=1 |
| 153 | + fi |
| 154 | + done |
| 155 | +
|
| 156 | + if [[ "$fail" -ne 0 ]]; then |
| 157 | + echo "::error::PR-24 scope violation — restore package must be pure decision; no kernel / service / filesystem mutation symbols permitted." |
| 158 | + echo "::error::See internal/installer/restore/contract.md §10 for the full forbidden list." |
| 159 | + exit 1 |
| 160 | + fi |
| 161 | +
|
| 162 | + echo "G4-RESTORE-NO-IMPLICIT-EXEC PASS — restore package is symbolically pure" |
| 163 | +
|
| 164 | + # ------------------------------------------------------------------ |
| 165 | + # G4-RESTORE-DECISION-CORRECTNESS — rule-path coverage. |
| 166 | + # |
| 167 | + # The restore engine test TestRuleCoverage_EveryRuleExercised |
| 168 | + # asserts: every Rule* constant declared in engine.go MUST be hit |
| 169 | + # by at least one fixture. A new rule without a fixture, or a |
| 170 | + # fixture referencing a non-declared rule, fails this test. |
| 171 | + # ------------------------------------------------------------------ |
| 172 | + - name: G4-RESTORE-DECISION-CORRECTNESS — rule-path coverage |
| 173 | + shell: bash |
| 174 | + env: |
| 175 | + TMPDIR: /var/tmp |
| 176 | + run: | |
| 177 | + set -Eeuo pipefail |
| 178 | + mkdir -p /var/tmp |
| 179 | + go test -v -count=1 -run 'TestDecide_FixtureMatrix|TestRuleCoverage_EveryRuleExercised|TestDecide_OutputClosedEnum|TestDecide_LockedAmendment|TestDecide_HardStopsDominateAnyFlag|TestDecide_OrphanPanelAutoRefused' ./internal/installer/restore/... |
| 180 | +
|
| 181 | + # ------------------------------------------------------------------ |
| 182 | + # G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs reach no |
| 183 | + # execution branch. |
| 184 | + # |
| 185 | + # Implementation: the Decide() function is pure (no executor |
| 186 | + # argument). Any execution path would require the dispatcher to |
| 187 | + # invoke additional code after the Decide call. This gate asserts |
| 188 | + # structurally that the restore package has no executor dependency |
| 189 | + # AND that the dispatcher does not call into mutation-capable |
| 190 | + # packages. Combined with G4-RESTORE-NO-IMPLICIT-EXEC, this closes |
| 191 | + # the refusal-integrity claim. |
| 192 | + # ------------------------------------------------------------------ |
| 193 | + - name: G4-RESTORE-REFUSAL-INTEGRITY — no executor dependency in restore package |
| 194 | + shell: bash |
| 195 | + run: | |
| 196 | + set -Eeuo pipefail |
| 197 | +
|
| 198 | + non_test_files=$(find internal/installer/restore -type f -name '*.go' 2>/dev/null | grep -vE '_test\.go$' || true) |
| 199 | + if [[ -z "$non_test_files" ]]; then |
| 200 | + echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore package files not found" |
| 201 | + exit 1 |
| 202 | + fi |
| 203 | +
|
| 204 | + # The restore package must not import the executor package in |
| 205 | + # non-test code. If it did, the engine could grow a side-effect |
| 206 | + # path. Fixtures / tests may reference it for simulation |
| 207 | + # purposes, so test files are excluded from this check. |
| 208 | + if grep -nE '"github.com/itcmsgr/nftban/internal/installer/executor"' $non_test_files 2>/dev/null; then |
| 209 | + echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore package imports executor — Decide must remain pure" |
| 210 | + exit 1 |
| 211 | + fi |
| 212 | +
|
| 213 | + # The dispatcher (restore_decide.go) uses the executor to |
| 214 | + # gather inputs (Classify / Probe / DetectPanel) — that is |
| 215 | + # permitted. But after the Decide() call returns a non-PROCEED |
| 216 | + # output, the dispatcher must not call any mutation helper. |
| 217 | + # This check enforces structural layering: mutation helpers |
| 218 | + # are imported only by uninstall_apply.go, never by |
| 219 | + # restore_decide.go. |
| 220 | + 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 |
| 221 | + echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore dispatcher imports mutation-capable package" |
| 222 | + exit 1 |
| 223 | + fi |
| 224 | +
|
| 225 | + echo "G4-RESTORE-REFUSAL-INTEGRITY PASS — restore engine is pure + dispatcher is layering-clean" |
| 226 | +
|
| 227 | + # ------------------------------------------------------------------ |
| 228 | + # G4-RESTORE-DETERMINISM — same inputs → same outputs across runs. |
| 229 | + # TestDecide_Determinism runs every fixture twice via reflect.DeepEqual. |
| 230 | + # ------------------------------------------------------------------ |
| 231 | + - name: G4-RESTORE-DETERMINISM — repeated evaluation equality |
| 232 | + shell: bash |
| 233 | + env: |
| 234 | + TMPDIR: /var/tmp |
| 235 | + run: | |
| 236 | + set -Eeuo pipefail |
| 237 | + mkdir -p /var/tmp |
| 238 | + go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/... |
| 239 | + # Run a second time from scratch and diff nothing — the |
| 240 | + # test itself asserts determinism within one run; this |
| 241 | + # outer loop asserts determinism ACROSS runs (no cached |
| 242 | + # state, no env reliance). |
| 243 | + go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/... 2>&1 | tee /tmp/run1.log |
| 244 | + go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/... 2>&1 | tee /tmp/run2.log |
| 245 | + # Strip timing lines (ok line carries elapsed seconds) and diff. |
| 246 | + grep -vE '^ok\s|^FAIL\s' /tmp/run1.log > /tmp/run1.norm |
| 247 | + grep -vE '^ok\s|^FAIL\s' /tmp/run2.log > /tmp/run2.norm |
| 248 | + if ! diff -q /tmp/run1.norm /tmp/run2.norm >/dev/null; then |
| 249 | + echo "::error::G4-RESTORE-DETERMINISM: test output differs across runs" |
| 250 | + diff /tmp/run1.norm /tmp/run2.norm || true |
| 251 | + exit 1 |
| 252 | + fi |
| 253 | + echo "G4-RESTORE-DETERMINISM PASS — identical across two independent runs" |
| 254 | +
|
| 255 | + restore-canonization-summary: |
| 256 | + name: Restore Canonization summary |
| 257 | + runs-on: ubuntu-24.04 |
| 258 | + needs: [restore-canonization] |
| 259 | + if: always() |
| 260 | + steps: |
| 261 | + - name: Summarize |
| 262 | + run: | |
| 263 | + if [[ "${{ needs.restore-canonization.result }}" != "success" ]]; then |
| 264 | + echo "::error::Restore Canonization FAILED" |
| 265 | + exit 1 |
| 266 | + fi |
| 267 | + echo "Restore Canonization: all G4 gates passed across matrix" |
0 commit comments