diff --git a/.github/workflows/ci-restore-canonization.yml b/.github/workflows/ci-restore-canonization.yml new file mode 100644 index 000000000..d1f899973 --- /dev/null +++ b/.github/workflows/ci-restore-canonization.yml @@ -0,0 +1,267 @@ +# ============================================================================= +# NFTBan — CI: Restore Canonization Gate (v1.100 PR-24 slice) +# ============================================================================= +# SPDX-License-Identifier: MPL-2.0 +# Purpose: Enforce v1.100 PR-24 scope lock on the authority restoration +# policy decision engine. PR-24 is POLICY ONLY — no execution. +# +# 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 +# +# Contract: internal/installer/restore/contract.md (merged 2026-04-20) +# ============================================================================= + +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" + + 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" diff --git a/cmd/nftban-installer/flags.go b/cmd/nftban-installer/flags.go index 364782e2c..c64284d9d 100644 --- a/cmd/nftban-installer/flags.go +++ b/cmd/nftban-installer/flags.go @@ -148,6 +148,42 @@ func parseFlags() *config { } if !cfg.showVersion && !cfg.repair { + // v1.100 PR-24: --mode=restore — authority restoration policy + // decision engine (pure, no mutation). Validation rules here + // exist to keep the invocation surface tight per seed §2: the + // engine consumes --restore-prior-authority / --panel-auto- + // takeover as decision inputs; other execution-oriented flags + // are rejected because they are meaningless (and risk misleading + // the operator). + if cfg.mode == "restore" { + if cfg.confirmMutation { + fmt.Fprintln(os.Stderr, "error: --confirm-mutation is not valid with --mode=restore") + fmt.Fprintln(os.Stderr, " --mode=restore invokes the PR-24 decision engine; it performs NO mutation.") + os.Exit(state.ExitFatal) + } + if cfg.dryRun { + fmt.Fprintln(os.Stderr, "error: --dry-run is not valid with --mode=restore") + fmt.Fprintln(os.Stderr, " --mode=restore is ALWAYS pure policy decision — no mutation path exists.") + os.Exit(state.ExitFatal) + } + if cfg.takeover { + fmt.Fprintln(os.Stderr, "error: --takeover is not valid with --mode=restore") + os.Exit(state.ExitFatal) + } + if cfg.force { + fmt.Fprintln(os.Stderr, "error: --force is not valid with --mode=restore") + os.Exit(state.ExitFatal) + } + if cfg.rpm || cfg.deb || cfg.source { + fmt.Fprintln(os.Stderr, "error: package-origin flags (--rpm/--deb/--source) are not valid with --mode=restore") + os.Exit(state.ExitFatal) + } + if cfg.purge || cfg.forceDeleteOperatorConfig { + fmt.Fprintln(os.Stderr, "error: uninstall mode flags (--purge/--force-delete-operator-config) are not valid with --mode=restore") + os.Exit(state.ExitFatal) + } + return cfg + } if cfg.mode == "uninstall" { // PR-22B: flag combos that are only meaningful for uninstall // are validated here, because the uninstall block early-returns @@ -185,8 +221,8 @@ func parseFlags() *config { return cfg } if cfg.mode != "install" && cfg.mode != "upgrade" { - fmt.Fprintf(os.Stderr, "error: --mode must be 'install' or 'upgrade' (got %q)\n", cfg.mode) - fmt.Fprintf(os.Stderr, "usage: nftban-installer --mode=install|upgrade [flags]\n") + fmt.Fprintf(os.Stderr, "error: --mode must be 'install', 'upgrade', 'uninstall', or 'restore' (got %q)\n", cfg.mode) + fmt.Fprintf(os.Stderr, "usage: nftban-installer --mode=install|upgrade|uninstall|restore [flags]\n") fmt.Fprintf(os.Stderr, " nftban-installer --repair [flags]\n") os.Exit(state.ExitFatal) } diff --git a/cmd/nftban-installer/main.go b/cmd/nftban-installer/main.go index ea04df39e..b6bec1d18 100644 --- a/cmd/nftban-installer/main.go +++ b/cmd/nftban-installer/main.go @@ -121,7 +121,15 @@ func main() { // is an explicit pre-PR-24 (or parallel) follow-up item; until // that lands, uninstall events are forensically visible only in // the installer log, and update-history.json stays clean of them. - if !cfg.dryRun && cfg.mode != "uninstall" && state.IsApplyTerminal(sf.State) { + // + // PR-24 extension: --mode=restore is ALSO excluded. The three + // restore states (RESTORE_DECIDED / RESTORE_REFUSED / + // RESTORE_INTENT_REQUIRED) all return IsApplyTerminal=false per + // contract seed §7, so the existing allowlist gate already blocks + // history writes for this mode. The explicit mode check here is + // belt-and-braces defense in case a future edit inadvertently + // marks a restore state apply-terminal. + if !cfg.dryRun && cfg.mode != "uninstall" && cfg.mode != "restore" && state.IsApplyTerminal(sf.State) { writeHistory(sf, cfg, previousVersion, hostname, log) } @@ -171,6 +179,12 @@ func run(ctx context.Context, exec executor.Executor, sf *state.StateFile, cfg * } return runUninstallDryRun(ctx, exec, sf, cfg, log) } + // v1.100 PR-24 restore-policy-engine dispatch. Pure decision only; + // performs NO kernel / service / filesystem mutation. flags.go + // validates that --mode=restore is not combined with mutation flags. + if cfg.mode == "restore" { + return runRestoreDecide(ctx, exec, sf, cfg, log) + } // v1.99 PR-16 (G3-U1/U2/U3/U4): update-mode dry-run short-circuits to // preflight + version-detect + plan render. No mutation — all apply // logic is deferred to PR-18 and reuses the rebuild pipeline per diff --git a/cmd/nftban-installer/restore_decide.go b/cmd/nftban-installer/restore_decide.go new file mode 100644 index 000000000..d33d25230 --- /dev/null +++ b/cmd/nftban-installer/restore_decide.go @@ -0,0 +1,205 @@ +// ============================================================================= +// NFTBan v1.100 PR-24 — Restore Policy Decision Dispatcher +// ============================================================================= +// SPDX-License-Identifier: MPL-2.0 +// meta:name="nftban-installer-restore-decide" +// meta:type="cmd" +// meta:owner="Antonios Voulvoulis " +// meta:created_date="2026-04-20" +// meta:description="--mode=restore dispatcher: gather inputs, call Decide, transition state" +// meta:inventory.files="cmd/nftban-installer/restore_decide.go" +// meta:inventory.binaries="" +// meta:inventory.env_vars="" +// meta:inventory.config_files="" +// meta:inventory.systemd_units="" +// meta:inventory.network="" +// meta:inventory.privileges="root" +// ============================================================================= +// +// This dispatcher is the ONLY entry into the PR-24 restoration policy +// decision engine. Reached only when cfg.mode == "restore" (flags.go +// rejects any incompatible flag combination at parse time). +// +// Responsibilities: +// +// 1. Classify current authority (via uninstall.Classify). +// 2. Probe prior-authority record (via uninstall.Probe). +// 3. Reduce probe result + freshness window into a restore.PriorState. +// 4. Detect panel context (via detect.DetectPanel). +// 5. Assemble restore.DecisionInput. +// 6. Call restore.Decide — pure, no side effects. +// 7. Log structured decision-path record. +// 8. Transition state file to the terminal (Refused / IntentRequired) +// or non-terminal handoff (Decided) state. +// 9. Return the correct exit code. +// +// Hard discipline: NO kernel / service / filesystem mutation beyond the +// state-file write that Transition() performs. NO history entry (Option +// A continues; main.go's writeHistory gate plus IsApplyTerminal=false +// for all three restore states closes the write path defensively). +// +// ============================================================================= +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/itcmsgr/nftban/internal/installer/detect" + "github.com/itcmsgr/nftban/internal/installer/executor" + "github.com/itcmsgr/nftban/internal/installer/logging" + "github.com/itcmsgr/nftban/internal/installer/restore" + "github.com/itcmsgr/nftban/internal/installer/state" + "github.com/itcmsgr/nftban/internal/installer/uninstall" +) + +// runRestoreDecide orchestrates the PR-24 decision engine invocation. +// Returns the process exit code derived from the decision output. +func runRestoreDecide(_ context.Context, exec executor.Executor, sf *state.StateFile, cfg *config, log *logging.Logger) int { + log.Info("restore decide starting (mode=restore)") + + // 1. Classify authority. + auth := uninstall.Classify(exec, log) + log.Info("restore decide: authority=%s ambiguity=%s external=%s", + auth.State, auth.Ambiguity, auth.External) + + // 2. Probe prior-record. + probe := uninstall.Probe(exec, log) + log.Info("restore decide: prior-record state=%s incomplete_reason=%s", + probe.State, probe.IncompleteReason) + + // 3. Preflight errors (seed §9): classifier invariant violations + // and record malformation short-circuit to ExitFatal WITHOUT + // emitting a lattice output. This keeps the three-output space + // closed. + if probe.State == uninstall.PriorRecordMalformed { + log.Error("restore decide: preflight FATAL — prior-record file malformed; lattice does not apply") + fmt.Fprintln(os.Stderr, "error: prior-authority record is malformed; manual inspection required") + fmt.Fprintln(os.Stderr, " see: "+uninstall.PriorAuthorityPath) + return state.ExitFatal + } + if auth.State == uninstall.AuthorityAmbiguous && auth.Ambiguity != uninstall.AmbiguityConflictExternal && auth.Ambiguity != uninstall.AmbiguityOrphanNFTBan { + log.Error("restore decide: preflight FATAL — classifier returned Ambiguous without a supported sub-kind (got %q)", + auth.Ambiguity) + fmt.Fprintln(os.Stderr, "error: classifier invariant violated — Ambiguous state without supported sub-kind") + return state.ExitFatal + } + + // 4. Reduce probe + freshness window into restore.PriorState. + priorState := reducePriorState(probe) + log.Info("restore decide: prior-state (reduced) = %s", priorState) + + // 5. Panel detection. + panel := detect.DetectPanel(exec, log) + panelPresent := detect.HasPanel(panel) + log.Info("restore decide: panel=%s present=%v", panel, panelPresent) + + // 6. Build the decision input. Note: --restore-prior-authority in + // the CLI surface maps to Flags.Restore in the engine. + input := restore.DecisionInput{ + Authority: auth.State, + Ambiguity: auth.Ambiguity, + Prior: priorState, + Flags: restore.Flags{ + Restore: cfg.restorePriorAuthority, + PanelAutoTakeover: cfg.panelAutoTakeover, + }, + PanelPresent: panelPresent, + } + + // 7. Evaluate — pure, deterministic, no side effects. + result := restore.Decide(input) + + // 8. Structured decision-path log record (seed §2 logging obligation). + log.Info("restore decide: result output=%s rule=%s reason=%q", + result.Output, result.Rule, result.Reason) + log.Info("restore decide: inputs authority=%s ambiguity=%s prior=%s restore=%v panel_auto=%v panel_present=%v", + input.Authority, input.Ambiguity, input.Prior, + input.Flags.Restore, input.Flags.PanelAutoTakeover, input.PanelPresent) + + // 9. Transition to terminal state + return exit code. + newState, phase := restoreStateForOutput(result.Output) + _ = sf.Transition(newState, phase, result.Reason) + + // Operator-facing output (stdout). + switch result.Output { + case restore.OutputProceed: + log.Result("[NFTBan] restore decision: PROCEED — %s", result.Reason) + log.Result("[NFTBan] note: PR-24 is the decision engine only; execution of restoration is deferred to a later PR.") + case restore.OutputRefuse: + log.Result("[NFTBan] restore decision: REFUSE — %s", result.Reason) + case restore.OutputRequireExplicitIntent: + log.Result("[NFTBan] restore decision: REQUIRE_EXPLICIT_INTENT — %s", result.Reason) + } + + return sf.State.ExitCode() +} + +// reducePriorState maps uninstall.ProbeResult onto the normalized +// restore.PriorState consumed by the engine, including the 365-day +// freshness-window check for Stale classification. +// +// The staleness window is fixed at restore.StalenessWindowDays per +// seed §3.B; configurability is a seed §15 follow-up item and is +// intentionally not implemented here. +func reducePriorState(probe *uninstall.ProbeResult) restore.PriorState { + switch probe.State { + case uninstall.PriorNoRecord: + return restore.PriorStateNoRecord + + case uninstall.PriorRecordIncomplete: + return restore.PriorStateIncomplete + + case uninstall.PriorRecordUsableActive: + if isStale(probe.Record) { + return restore.PriorStateStale + } + return restore.PriorStateCompleteActive + + case uninstall.PriorRecordUsableInactive: + if isStale(probe.Record) { + return restore.PriorStateStale + } + return restore.PriorStateCompleteInactive + } + + // PriorRecordMalformed is handled as a preflight error before this + // function is called. Any other value indicates an upstream schema + // addition without dispatcher update — treat as Incomplete (the + // conservative truthful classification). + return restore.PriorStateIncomplete +} + +// isStale returns true if the record's RecordedAt is older than the +// freshness window. A nil RecordedAt is treated as stale rather than +// fresh — uninstall.Probe already reclassifies records lacking the +// field as Incomplete, so reaching here with nil indicates an +// upstream invariant break and we fail closed. +func isStale(rec *uninstall.PriorRecord) bool { + if rec == nil || rec.RecordedAt == nil { + return true + } + age := time.Since(*rec.RecordedAt) + return age > time.Duration(restore.StalenessWindowDays)*24*time.Hour +} + +// restoreStateForOutput maps the decision output to the state-machine +// terminal (or non-terminal handoff) state. Phase is always +// PhaseDetect for this dispatcher — the engine's job IS detection + +// decision; there is no later phase to attribute to. +func restoreStateForOutput(out restore.Output) (state.InstallState, state.Phase) { + switch out { + case restore.OutputProceed: + return state.StateRestoreDecided, state.PhaseDetect + case restore.OutputRefuse: + return state.StateRestoreRefused, state.PhaseDetect + case restore.OutputRequireExplicitIntent: + return state.StateRestoreIntentRequired, state.PhaseDetect + } + // Unreachable — Decide's return type is a closed enum guarded by + // TestDecide_OutputClosedEnum. A fourth value here means a + // contract regression. + panic("restore dispatcher: unknown Output value — contract regression") +} diff --git a/internal/installer/restore/engine.go b/internal/installer/restore/engine.go new file mode 100644 index 000000000..dd7fc76c1 --- /dev/null +++ b/internal/installer/restore/engine.go @@ -0,0 +1,320 @@ +// ============================================================================= +// NFTBan v1.100 PR-24 — Authority Restoration Policy Decision Engine +// ============================================================================= +// SPDX-License-Identifier: MPL-2.0 +// meta:name="installer-restore-engine" +// meta:type="lib" +// meta:owner="Antonios Voulvoulis " +// meta:created_date="2026-04-20" +// meta:description="Pure decision engine for PR-24 restoration policy (lattice per seed §6)" +// meta:inventory.files="internal/installer/restore/engine.go" +// meta:inventory.binaries="" +// meta:inventory.env_vars="" +// meta:inventory.config_files="" +// meta:inventory.systemd_units="" +// meta:inventory.network="" +// meta:inventory.privileges="none" +// ============================================================================= +// +// PR-24 scope lock (per merged contract seed, PR #493): +// +// Pure decision. No side effects. No process spawn. No filesystem. +// No kernel. No service. No history. Implements exactly the lattice +// in seed §6 with the top-down precedence rule in §5. +// +// If a cell of the input space does not match a rule, Decide panics. +// This is by design: the lattice claims to cover every input, and a +// fallthrough is a contract violation that the G4-RESTORE- +// DECISION-CORRECTNESS CI gate catches via exhaustive fixtures. A +// silent default in this file would directly violate seed §5 +// ("no default fallthrough branch") — losing at compile-time / CI is +// strictly better than losing at runtime with the wrong answer. +// +// ============================================================================= +package restore + +import "github.com/itcmsgr/nftban/internal/installer/uninstall" + +// Rule identifiers. Stable strings; fixtures and CI gates assert on +// these. Keep in sync with seed §6 group numbering. +const ( + // Group 1 — classifier hard-stops + RuleG1AuthorityNFTBan = "G1/AuthorityNFTBan" + RuleG1AuthorityExternal = "G1/AuthorityExternal" + RuleG1AmbiguityConflictExt = "G1/AmbiguityConflictExternal" + + // Group 2 — input/flag validity + RuleG2PanelAutoWithoutPanel = "G2/PanelAutoTakeoverWithoutPanel" + RuleG2BothRestoreFlags = "G2/RestoreAndPanelAutoBothSet" + + // Group 3 — AuthorityNone + RuleG3_1StrongPriorNoFlag = "G3.1/StrongPrior+NoFlag" + RuleG3_1StrongPriorRestore = "G3.1/StrongPrior+Restore" + RuleG3_1StrongPriorPanelAuto = "G3.1/StrongPrior+PanelAuto" + RuleG3_2CompleteInactive = "G3.2/CompleteInactive" + RuleG3_3NoRecordNoFlag = "G3.3/NoRecord+NoFlag" + RuleG3_3NoRecordRestore = "G3.3/NoRecord+Restore" + RuleG3_3NoRecordPanelAuto = "G3.3/NoRecord+PanelAuto" + RuleG3_3Incomplete = "G3.3/Incomplete" + RuleG3_3Stale = "G3.3/Stale" + + // Group 4 — AmbiguityOrphanNFTBan + RuleG4_1OrphanStrongNoFlag = "G4.1/OrphanStrong+NoFlag" + RuleG4_1OrphanStrongRestore = "G4.1/OrphanStrong+Restore" + RuleG4_2OrphanCompleteInactive = "G4.2/OrphanCompleteInactive" + RuleG4_2OrphanNoRecordNoFlag = "G4.2/OrphanNoRecord+NoFlag" + RuleG4_2OrphanNoRecordRestore = "G4.2/OrphanNoRecord+Restore" + RuleG4_2OrphanIncomplete = "G4.2/OrphanIncomplete" + RuleG4_2OrphanStale = "G4.2/OrphanStale" + RuleG4_3OrphanPanelAuto = "G4.3/OrphanPanelAuto" +) + +// Decide evaluates the lattice (seed §6) with the top-down precedence +// rule (seed §5). Returns a DecisionResult whose Output is exactly one +// of OutputProceed / OutputRefuse / OutputRequireExplicitIntent. +// +// Precedence (evaluated in this order; earlier match wins and is +// final — no later rule may override): +// +// 1. Classifier hard-stops +// 2. Input / flag validity +// 3. Prior-record integrity gates +// 4. Panel context gates (handled inline in 3 and 4 per seed §6 G5) +// 5. Proceed decisions +// +// Every possible input falls into exactly one rule; see tests for +// exhaustive coverage. A panic means a contract regression. +func Decide(in DecisionInput) DecisionResult { + // ─── Group 1: Classifier hard-stops ────────────────────────────── + // + // These three states are absolute refusals: no flag and no panel + // context may override them. Per seed §5 invariant, these are + // evaluated first and cannot be weakened by later rules. + + switch in.Authority { + case uninstall.AuthorityNFTBan: + return DecisionResult{ + Output: OutputRefuse, + Rule: RuleG1AuthorityNFTBan, + Reason: "nftban is already authoritative on this host; no restore needed", + } + case uninstall.AuthorityExternal: + return DecisionResult{ + Output: OutputRefuse, + Rule: RuleG1AuthorityExternal, + Reason: "an external firewall is already authoritative; restore would silently override it", + } + case uninstall.AuthorityAmbiguous: + if in.Ambiguity == uninstall.AmbiguityConflictExternal { + return DecisionResult{ + Output: OutputRefuse, + Rule: RuleG1AmbiguityConflictExt, + Reason: "conflicting external authority detected; operator must resolve before any restoration", + } + } + // Fall through to Group 4 (AmbiguityOrphanNFTBan path). Any + // other Ambiguity sub-kind is a preflight invariant violation + // and is caught by the final panic. + } + + // ─── Group 2: Input / flag validity ────────────────────────────── + + if in.Flags.Restore && in.Flags.PanelAutoTakeover { + return DecisionResult{ + Output: OutputRefuse, + Rule: RuleG2BothRestoreFlags, + Reason: "--restore-prior-authority and --panel-auto-takeover are mutually exclusive; pick one", + } + } + + if in.Flags.PanelAutoTakeover && !in.PanelPresent { + return DecisionResult{ + Output: OutputRefuse, + Rule: RuleG2PanelAutoWithoutPanel, + Reason: "--panel-auto-takeover requires a control panel on the host; none detected", + } + } + + // ─── Group 3: AuthorityNone ────────────────────────────────────── + + if in.Authority == uninstall.AuthorityNone { + return decideAuthorityNone(in) + } + + // ─── Group 4: AmbiguityOrphanNFTBan ────────────────────────────── + + if in.Authority == uninstall.AuthorityAmbiguous && + in.Ambiguity == uninstall.AmbiguityOrphanNFTBan { + return decideAmbiguityOrphan(in) + } + + // Contract regression guard: no rule matched. Per seed §5, every + // input must land on exactly one rule. Reaching here means either + // a new Authority value was introduced without a corresponding + // rule, or the dispatcher passed an invariant-violating input + // (e.g. Authority != Ambiguous but Ambiguity != None). + panic("restore.Decide: input did not match any lattice rule — contract regression (add a rule or route to preflight error)") +} + +// decideAuthorityNone implements seed §6 Group 3 for +// Authority=AuthorityNone. Split out so the main Decide stays readable. +// +// Precedence within Group 3: +// 3.1 strong prior (Complete + ActiveAtInstall=true) +// 3.2 complete-but-inactive (Complete + ActiveAtInstall=false) +// 3.3 weak / absent prior (NoRecord / Incomplete / Stale) +// +// Each sub-group is exhaustive over the flag space {none, Restore, +// PanelAutoTakeover}. +func decideAuthorityNone(in DecisionInput) DecisionResult { + switch in.Prior { + case PriorStateCompleteActive: + // 3.1 Strong prior + switch { + case in.Flags.Restore: + return DecisionResult{ + Output: OutputProceed, + Rule: RuleG3_1StrongPriorRestore, + Reason: "restore requested against complete active prior-authority record", + } + case in.Flags.PanelAutoTakeover: + // Group 2 already refused PanelAutoTakeover without panel; + // reaching here means panel is present. + return DecisionResult{ + Output: OutputProceed, + Rule: RuleG3_1StrongPriorPanelAuto, + Reason: "panel-auto-takeover with panel present; target is panel-native", + } + default: + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG3_1StrongPriorNoFlag, + Reason: "complete active prior-authority record available; pass --restore-prior-authority to restore it", + } + } + + case PriorStateCompleteInactive: + // 3.2 Complete-but-inactive: always REQUIRE_EXPLICIT_INTENT, + // regardless of flags. Restoring a firewall the operator had + // deliberately disabled is an implicit re-enablement. + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG3_2CompleteInactive, + Reason: "prior firewall was not active at install time; restore semantics are ambiguous between preserve-inactive and activate", + } + + case PriorStateNoRecord: + // 3.3 NoRecord: --restore returns REQUIRE_EXPLICIT_INTENT per + // the locked amendment (no implicit target); panel-auto still + // proceeds because panel is its own target. + switch { + case in.Flags.Restore: + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG3_3NoRecordRestore, + Reason: "--restore-prior-authority requires a recorded prior firewall; no record on disk", + } + case in.Flags.PanelAutoTakeover: + return DecisionResult{ + Output: OutputProceed, + Rule: RuleG3_3NoRecordPanelAuto, + Reason: "panel-auto-takeover with panel present; panel-native target is independent of prior record", + } + default: + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG3_3NoRecordNoFlag, + Reason: "no prior-authority record and no operator intent flag; choose --restore-prior-authority or --panel-auto-takeover", + } + } + + case PriorStateIncomplete: + // 3.3 Incomplete: any flag → REQUIRE_EXPLICIT_INTENT. + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG3_3Incomplete, + Reason: "prior-authority record is incomplete (missing required field); cannot be trusted for automatic restoration", + } + + case PriorStateStale: + // 3.3 Stale: any flag → REQUIRE_EXPLICIT_INTENT. + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG3_3Stale, + Reason: "prior-authority record exceeds the 365-day freshness window; operator must confirm it still reflects intent", + } + } + + // Contract regression: a PriorState value exists with no rule. + panic("restore.decideAuthorityNone: unhandled PriorState — contract regression") +} + +// decideAmbiguityOrphan implements seed §6 Group 4 for orphan nftban +// ambiguity. Panel-auto-takeover is an absolute refusal here (4.3); +// otherwise the same prior-record strata as Group 3 apply, with --restore +// permitted only under strong prior (4.1). +func decideAmbiguityOrphan(in DecisionInput) DecisionResult { + // 4.3 Orphan + panel-auto: REFUSE regardless of prior. Panel-auto + // must never fire over nftban residue, recoverable or not. + if in.Flags.PanelAutoTakeover { + return DecisionResult{ + Output: OutputRefuse, + Rule: RuleG4_3OrphanPanelAuto, + Reason: "nftban residue present (orphan); panel-auto-takeover must not fire over residual nftban state", + } + } + + switch in.Prior { + case PriorStateCompleteActive: + // 4.1 Strong prior + if in.Flags.Restore { + return DecisionResult{ + Output: OutputProceed, + Rule: RuleG4_1OrphanStrongRestore, + Reason: "orphan ambiguity with complete active prior-record; restore requested", + } + } + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG4_1OrphanStrongNoFlag, + Reason: "orphan nftban residue plus complete active prior-record; operator must choose --restore-prior-authority", + } + + case PriorStateCompleteInactive: + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG4_2OrphanCompleteInactive, + Reason: "orphan ambiguity; prior firewall was not active at install time; restore semantics ambiguous", + } + + case PriorStateNoRecord: + if in.Flags.Restore { + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG4_2OrphanNoRecordRestore, + Reason: "--restore-prior-authority requires a recorded prior firewall; no record on disk (orphan path)", + } + } + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG4_2OrphanNoRecordNoFlag, + Reason: "orphan nftban residue and no prior-authority record; operator must supply intent", + } + + case PriorStateIncomplete: + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG4_2OrphanIncomplete, + Reason: "orphan ambiguity; prior-authority record is incomplete", + } + + case PriorStateStale: + return DecisionResult{ + Output: OutputRequireExplicitIntent, + Rule: RuleG4_2OrphanStale, + Reason: "orphan ambiguity; prior-authority record is stale", + } + } + + panic("restore.decideAmbiguityOrphan: unhandled PriorState — contract regression") +} diff --git a/internal/installer/restore/engine_test.go b/internal/installer/restore/engine_test.go new file mode 100644 index 000000000..06bbc17f6 --- /dev/null +++ b/internal/installer/restore/engine_test.go @@ -0,0 +1,554 @@ +// ============================================================================= +// NFTBan v1.100 PR-24 — Decision Engine Fixture Matrix + Determinism +// ============================================================================= +// SPDX-License-Identifier: MPL-2.0 +// meta:name="installer-restore-engine-test" +// meta:type="test" +// meta:owner="Antonios Voulvoulis " +// meta:created_date="2026-04-20" +// meta:description="Rule-path coverage for PR-24 decision engine + determinism" +// meta:inventory.files="internal/installer/restore/engine_test.go" +// meta:inventory.binaries="" +// meta:inventory.env_vars="" +// meta:inventory.config_files="" +// meta:inventory.systemd_units="" +// meta:inventory.network="" +// meta:inventory.privileges="none" +// ============================================================================= +package restore + +import ( + "reflect" + "sort" + "testing" + + "github.com/itcmsgr/nftban/internal/installer/uninstall" +) + +// fixture is one rule-path test case. Together, fixtures exhaustively +// cover every rule declared in engine.go (asserted by +// TestRuleCoverage_EveryRuleExercised). +type fixture struct { + name string + input DecisionInput + wantOut Output + wantRule string +} + +// allFixtures is the canonical rule-path matrix. Every Rule* constant +// declared in engine.go MUST appear in exactly one fixture's wantRule. +// The coverage test below enforces that invariant. +var allFixtures = []fixture{ + // ───────────────────────── Group 1 hard-stops ──────────────────── + { + name: "G1_AuthorityNFTBan_refuses_any_flag", + input: DecisionInput{ + Authority: uninstall.AuthorityNFTBan, + Prior: PriorStateNoRecord, + Flags: Flags{Restore: true, PanelAutoTakeover: false}, + }, + wantOut: OutputRefuse, + wantRule: RuleG1AuthorityNFTBan, + }, + { + name: "G1_AuthorityExternal_refuses_any_flag", + input: DecisionInput{ + Authority: uninstall.AuthorityExternal, + Prior: PriorStateCompleteActive, + Flags: Flags{Restore: true}, + }, + wantOut: OutputRefuse, + wantRule: RuleG1AuthorityExternal, + }, + { + name: "G1_AmbiguityConflictExternal_refuses_any_flag", + input: DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityConflictExternal, + Prior: PriorStateCompleteActive, + Flags: Flags{PanelAutoTakeover: true}, + PanelPresent: true, + }, + wantOut: OutputRefuse, + wantRule: RuleG1AmbiguityConflictExt, + }, + + // ───────────────────────── Group 2 input validity ──────────────── + { + name: "G2_BothFlags_refuses", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateCompleteActive, + Flags: Flags{Restore: true, PanelAutoTakeover: true}, + PanelPresent: true, + }, + wantOut: OutputRefuse, + wantRule: RuleG2BothRestoreFlags, + }, + { + name: "G2_PanelAutoWithoutPanel_refuses", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateCompleteActive, + Flags: Flags{PanelAutoTakeover: true}, + PanelPresent: false, + }, + wantOut: OutputRefuse, + wantRule: RuleG2PanelAutoWithoutPanel, + }, + + // ───────────────────────── Group 3.1 strong prior ──────────────── + { + name: "G3_1_StrongPrior_NoFlag_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateCompleteActive, + Flags: Flags{}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG3_1StrongPriorNoFlag, + }, + { + name: "G3_1_StrongPrior_Restore_proceeds", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateCompleteActive, + Flags: Flags{Restore: true}, + }, + wantOut: OutputProceed, + wantRule: RuleG3_1StrongPriorRestore, + }, + { + name: "G3_1_StrongPrior_PanelAuto_proceeds", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateCompleteActive, + Flags: Flags{PanelAutoTakeover: true}, + PanelPresent: true, + }, + wantOut: OutputProceed, + wantRule: RuleG3_1StrongPriorPanelAuto, + }, + + // ───────────────────────── Group 3.2 complete-inactive ─────────── + { + name: "G3_2_CompleteInactive_AnyFlag_requiresIntent_Restore", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateCompleteInactive, + Flags: Flags{Restore: true}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG3_2CompleteInactive, + }, + { + name: "G3_2_CompleteInactive_AnyFlag_requiresIntent_PanelAuto", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateCompleteInactive, + Flags: Flags{PanelAutoTakeover: true}, + PanelPresent: true, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG3_2CompleteInactive, + }, + { + name: "G3_2_CompleteInactive_NoFlag_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateCompleteInactive, + Flags: Flags{}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG3_2CompleteInactive, + }, + + // ───────────────────────── Group 3.3 weak / absent ─────────────── + { + name: "G3_3_NoRecord_NoFlag_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateNoRecord, + Flags: Flags{}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG3_3NoRecordNoFlag, + }, + { + // Locked amendment: NoRecord + --restore ≠ PROCEED. + name: "G3_3_NoRecord_Restore_requiresIntent_LOCKED_AMENDMENT", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateNoRecord, + Flags: Flags{Restore: true}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG3_3NoRecordRestore, + }, + { + name: "G3_3_NoRecord_PanelAutoWithPanel_proceeds", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateNoRecord, + Flags: Flags{PanelAutoTakeover: true}, + PanelPresent: true, + }, + wantOut: OutputProceed, + wantRule: RuleG3_3NoRecordPanelAuto, + }, + { + name: "G3_3_Incomplete_AnyFlag_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateIncomplete, + Flags: Flags{Restore: true}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG3_3Incomplete, + }, + { + // Legacy-record-without-ActiveAtInstall flows through as + // Incomplete (uninstall.Probe enforces this per PR-P2-1). This + // fixture documents the contract at the lattice layer. + name: "G3_3_Incomplete_LegacyMissingActiveAtInstall_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateIncomplete, + Flags: Flags{}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG3_3Incomplete, + }, + { + name: "G3_3_Stale_AnyFlag_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateStale, + Flags: Flags{Restore: true}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG3_3Stale, + }, + + // ───────────────────────── Group 4.1 orphan strong prior ───────── + { + name: "G4_1_OrphanStrong_NoFlag_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: PriorStateCompleteActive, + Flags: Flags{}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG4_1OrphanStrongNoFlag, + }, + { + name: "G4_1_OrphanStrong_Restore_proceeds", + input: DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: PriorStateCompleteActive, + Flags: Flags{Restore: true}, + }, + wantOut: OutputProceed, + wantRule: RuleG4_1OrphanStrongRestore, + }, + + // ───────────────────────── Group 4.2 orphan weak prior ─────────── + { + name: "G4_2_OrphanCompleteInactive_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: PriorStateCompleteInactive, + Flags: Flags{Restore: true}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG4_2OrphanCompleteInactive, + }, + { + name: "G4_2_OrphanNoRecord_NoFlag_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: PriorStateNoRecord, + Flags: Flags{}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG4_2OrphanNoRecordNoFlag, + }, + { + // Locked amendment mirror in orphan path. + name: "G4_2_OrphanNoRecord_Restore_requiresIntent_LOCKED_AMENDMENT", + input: DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: PriorStateNoRecord, + Flags: Flags{Restore: true}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG4_2OrphanNoRecordRestore, + }, + { + name: "G4_2_OrphanIncomplete_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: PriorStateIncomplete, + Flags: Flags{Restore: true}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG4_2OrphanIncomplete, + }, + { + name: "G4_2_OrphanStale_requiresIntent", + input: DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: PriorStateStale, + Flags: Flags{Restore: true}, + }, + wantOut: OutputRequireExplicitIntent, + wantRule: RuleG4_2OrphanStale, + }, + + // ───────────────────────── Group 4.3 orphan + panel-auto ───────── + { + name: "G4_3_OrphanPanelAutoWithPanel_refuses", + input: DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: PriorStateCompleteActive, + Flags: Flags{PanelAutoTakeover: true}, + PanelPresent: true, + }, + wantOut: OutputRefuse, + wantRule: RuleG4_3OrphanPanelAuto, + }, +} + +// TestDecide_FixtureMatrix drives every rule path. +func TestDecide_FixtureMatrix(t *testing.T) { + for _, fx := range allFixtures { + t.Run(fx.name, func(t *testing.T) { + got := Decide(fx.input) + if got.Output != fx.wantOut { + t.Errorf("Output: got=%s want=%s (rule=%s reason=%q)", + got.Output, fx.wantOut, got.Rule, got.Reason) + } + if got.Rule != fx.wantRule { + t.Errorf("Rule: got=%s want=%s", got.Rule, fx.wantRule) + } + if got.Output != OutputProceed && got.Output != OutputRefuse && got.Output != OutputRequireExplicitIntent { + t.Errorf("Output is not one of the 3 closed-enum values: %q", got.Output) + } + }) + } +} + +// TestRuleCoverage_EveryRuleExercised enforces that the fixture matrix +// covers every declared rule constant. Adding a new rule to engine.go +// without a fixture is a contract violation and fails this test. +func TestRuleCoverage_EveryRuleExercised(t *testing.T) { + declared := declaredRules() + sort.Strings(declared) + + covered := map[string]bool{} + for _, fx := range allFixtures { + covered[fx.wantRule] = true + } + + var missing []string + for _, r := range declared { + if !covered[r] { + missing = append(missing, r) + } + } + if len(missing) > 0 { + t.Errorf("rule-path coverage gap — %d declared rule(s) have no fixture: %v", + len(missing), missing) + } + + // Also check the inverse: every fixture points to a declared rule. + declaredSet := map[string]bool{} + for _, r := range declared { + declaredSet[r] = true + } + var undeclared []string + for _, fx := range allFixtures { + if !declaredSet[fx.wantRule] { + undeclared = append(undeclared, fx.wantRule) + } + } + if len(undeclared) > 0 { + t.Errorf("fixture(s) reference undeclared rule(s): %v", undeclared) + } +} + +// declaredRules returns every Rule* constant declared in engine.go. +// Keep in sync when new rules are added. +func declaredRules() []string { + return []string{ + RuleG1AuthorityNFTBan, + RuleG1AuthorityExternal, + RuleG1AmbiguityConflictExt, + + RuleG2PanelAutoWithoutPanel, + RuleG2BothRestoreFlags, + + RuleG3_1StrongPriorNoFlag, + RuleG3_1StrongPriorRestore, + RuleG3_1StrongPriorPanelAuto, + RuleG3_2CompleteInactive, + RuleG3_3NoRecordNoFlag, + RuleG3_3NoRecordRestore, + RuleG3_3NoRecordPanelAuto, + RuleG3_3Incomplete, + RuleG3_3Stale, + + RuleG4_1OrphanStrongNoFlag, + RuleG4_1OrphanStrongRestore, + RuleG4_2OrphanCompleteInactive, + RuleG4_2OrphanNoRecordNoFlag, + RuleG4_2OrphanNoRecordRestore, + RuleG4_2OrphanIncomplete, + RuleG4_2OrphanStale, + RuleG4_3OrphanPanelAuto, + } +} + +// TestDecide_Determinism — same input twice must yield identical +// result (Output, Rule, Reason). No hidden env-var, time-of-day, or +// random-seed dependency. +func TestDecide_Determinism(t *testing.T) { + for _, fx := range allFixtures { + t.Run(fx.name, func(t *testing.T) { + first := Decide(fx.input) + second := Decide(fx.input) + if !reflect.DeepEqual(first, second) { + t.Errorf("non-deterministic: first=%+v second=%+v", first, second) + } + }) + } +} + +// TestDecide_OutputClosedEnum — no rule path may emit a value outside +// the three locked outputs. Guards against a future edit that +// introduces a fourth state via a mis-spelled constant. +func TestDecide_OutputClosedEnum(t *testing.T) { + allowed := map[Output]bool{ + OutputProceed: true, + OutputRefuse: true, + OutputRequireExplicitIntent: true, + } + for _, fx := range allFixtures { + t.Run(fx.name, func(t *testing.T) { + got := Decide(fx.input) + if !allowed[got.Output] { + t.Errorf("invalid output value %q (not in closed enum)", got.Output) + } + }) + } +} + +// TestDecide_LockedAmendments — explicit guards for the two locked +// amendments documented in the seed (NoRecord+Restore → REQUIRE and +// legacy-missing-ActiveAtInstall → Incomplete → REQUIRE). These are +// separate from the fixture matrix so they fail loudly if a future +// change attempts to "optimize" either branch. +func TestDecide_LockedAmendment_NoRecordRestoreRequiresIntent(t *testing.T) { + // AuthorityNone path + got := Decide(DecisionInput{ + Authority: uninstall.AuthorityNone, + Prior: PriorStateNoRecord, + Flags: Flags{Restore: true}, + }) + if got.Output != OutputRequireExplicitIntent { + t.Errorf("NoRecord + --restore must be REQUIRE_EXPLICIT_INTENT (locked amendment); got %s", got.Output) + } + if got.Rule != RuleG3_3NoRecordRestore { + t.Errorf("rule mismatch: got %s want %s", got.Rule, RuleG3_3NoRecordRestore) + } + + // Orphan path mirror + got = Decide(DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: PriorStateNoRecord, + Flags: Flags{Restore: true}, + }) + if got.Output != OutputRequireExplicitIntent { + t.Errorf("Orphan + NoRecord + --restore must be REQUIRE_EXPLICIT_INTENT (locked amendment); got %s", got.Output) + } +} + +// TestDecide_HardStopsDominateAnyFlag — seed §5 invariant: Group 1 +// hard-stops cannot be overridden by any flag or panel context. +func TestDecide_HardStopsDominateAnyFlag(t *testing.T) { + hardStops := []struct { + name string + auth uninstall.CurrentAuthority + amb uninstall.AmbiguityKind + }{ + {"AuthorityNFTBan", uninstall.AuthorityNFTBan, uninstall.AmbiguityNone}, + {"AuthorityExternal", uninstall.AuthorityExternal, uninstall.AmbiguityNone}, + {"AmbiguityConflictExternal", uninstall.AuthorityAmbiguous, uninstall.AmbiguityConflictExternal}, + } + flagCombos := []Flags{ + {}, + {Restore: true}, + {PanelAutoTakeover: true}, + {Restore: true, PanelAutoTakeover: true}, + } + priors := []PriorState{ + PriorStateNoRecord, + PriorStateCompleteActive, + PriorStateCompleteInactive, + PriorStateIncomplete, + PriorStateStale, + } + for _, hs := range hardStops { + for _, f := range flagCombos { + for _, p := range priors { + for _, panel := range []bool{false, true} { + got := Decide(DecisionInput{ + Authority: hs.auth, + Ambiguity: hs.amb, + Prior: p, + Flags: f, + PanelPresent: panel, + }) + if got.Output != OutputRefuse { + t.Errorf("hard-stop %s must REFUSE under any flag/panel/prior; got %s (rule=%s) for flags=%+v prior=%s panel=%v", + hs.name, got.Output, got.Rule, f, p, panel) + } + } + } + } + } +} + +// TestDecide_OrphanPanelAutoRefused — seed §6 Group 4.3 invariant. +func TestDecide_OrphanPanelAutoRefused(t *testing.T) { + for _, p := range []PriorState{ + PriorStateNoRecord, + PriorStateCompleteActive, + PriorStateCompleteInactive, + PriorStateIncomplete, + PriorStateStale, + } { + got := Decide(DecisionInput{ + Authority: uninstall.AuthorityAmbiguous, + Ambiguity: uninstall.AmbiguityOrphanNFTBan, + Prior: p, + Flags: Flags{PanelAutoTakeover: true}, + PanelPresent: true, + }) + if got.Output != OutputRefuse { + t.Errorf("Orphan + --panel-auto-takeover must REFUSE under any prior; got %s (prior=%s)", got.Output, p) + } + if got.Rule != RuleG4_3OrphanPanelAuto { + t.Errorf("rule mismatch: got %s want %s (prior=%s)", got.Rule, RuleG4_3OrphanPanelAuto, p) + } + } +} diff --git a/internal/installer/restore/types.go b/internal/installer/restore/types.go new file mode 100644 index 000000000..65252a7b8 --- /dev/null +++ b/internal/installer/restore/types.go @@ -0,0 +1,148 @@ +// ============================================================================= +// NFTBan v1.100 PR-24 — Authority Restoration Policy Engine Types (Pure) +// ============================================================================= +// SPDX-License-Identifier: MPL-2.0 +// meta:name="installer-restore-types" +// meta:type="lib" +// meta:owner="Antonios Voulvoulis " +// meta:created_date="2026-04-20" +// meta:description="Closed enums + input/result types for PR-24 decision engine" +// meta:inventory.files="internal/installer/restore/types.go" +// meta:inventory.binaries="" +// meta:inventory.env_vars="" +// meta:inventory.config_files="" +// meta:inventory.systemd_units="" +// meta:inventory.network="" +// meta:inventory.privileges="none" +// ============================================================================= +// +// PR-24 scope lock (per merged contract seed, PR #493): +// +// This package implements the restoration POLICY DECISION ENGINE only. +// It spawns no external process, mutates no kernel / service / file, +// writes no history entry, and does not invoke any execution code. +// Output is a closed enum of three values — PROCEED / REFUSE / +// REQUIRE_EXPLICIT_INTENT — and no fourth output may be added. +// +// Execution of any PROCEED outcome belongs to PR-25+. +// +// ============================================================================= +package restore + +import "github.com/itcmsgr/nftban/internal/installer/uninstall" + +// Output is the closed enum of the three (and only three) outputs of +// the PR-24 decision engine. Per seed §4 (merged), no fourth value +// exists, and adding one is a contract violation caught by the +// G4-RESTORE-DECISION-CORRECTNESS CI gate. +type Output string + +const ( + // OutputProceed — policy permits restoration. PR-25+ may execute. + OutputProceed Output = "PROCEED" + // OutputRefuse — policy forbids restoration. No execution permitted. + OutputRefuse Output = "REFUSE" + // OutputRequireExplicitIntent — policy cannot decide. Operator must + // supply additional intent (e.g. switch the flag being used, or + // accept that there is no valid restoration target). + OutputRequireExplicitIntent Output = "REQUIRE_EXPLICIT_INTENT" +) + +// PriorState is the normalized prior-authority-record state consumed by +// the decision engine. It is derived from uninstall.ProbeResult + +// freshness window check in the dispatcher layer; the engine itself +// takes the reduced state only. +// +// Mapping from uninstall.PriorRecordState (see dispatcher): +// +// PriorNoRecord → PriorStateNoRecord +// PriorRecordMalformed → preflight error (NOT a lattice input) +// PriorRecordIncomplete → PriorStateIncomplete +// PriorRecordUsableActive + recorded_at ≤ 365d → PriorStateCompleteActive +// PriorRecordUsableActive + recorded_at > 365d → PriorStateStale +// PriorRecordUsableInactive + recorded_at ≤ 365d → PriorStateCompleteInactive +// PriorRecordUsableInactive + recorded_at > 365d → PriorStateStale +// +// Legacy records that lack the ActiveAtInstall field are classified by +// uninstall.Probe as PriorRecordIncomplete (see uninstall/prior.go +// PR-P2-1 hardening). They therefore flow through PriorStateIncomplete +// here → REQUIRE_EXPLICIT_INTENT, per seed §3.B. +type PriorState string + +const ( + PriorStateNoRecord PriorState = "no_record" + PriorStateCompleteActive PriorState = "complete_active" + PriorStateCompleteInactive PriorState = "complete_inactive" + PriorStateIncomplete PriorState = "incomplete" + PriorStateStale PriorState = "stale" +) + +// StalenessWindowDays locks the freshness window at 365 days per seed +// §3.B and amendment history (auditor-approved). Configurability is a +// seed §15 follow-up item; PR-24 has no configurability knob. +const StalenessWindowDays = 365 + +// Flags captures the two operator-intent flags the engine considers. +// Exactly one of Restore / PanelAutoTakeover may be set at lattice +// evaluation time; the "both set" case is an input-validity REFUSE and +// is caught by Group 2 of the lattice (never reaches the proceed +// decisions). +type Flags struct { + // Restore corresponds to --restore-prior-authority in the CLI + // surface. Semantic: "restore the recorded prior firewall." + Restore bool + // PanelAutoTakeover corresponds to --panel-auto-takeover. + // Semantic: "install the panel-native firewall." Target is the + // panel, not the prior record — this asymmetry is the policy + // hinge in seed §3.3 / §4.2. + PanelAutoTakeover bool +} + +// DecisionInput is the pure-function input to Decide. Every axis is +// pre-reduced (no raw probe results, no executor references, no file +// system). This keeps the engine side-effect-free by construction. +type DecisionInput struct { + // Authority is the classifier state. Taken verbatim from + // uninstall.ClassifyResult.State; AmbiguityKind is carried in the + // dedicated field below so the engine does not have to re-import + // or re-derive sub-classification. + Authority uninstall.CurrentAuthority + + // Ambiguity is the sub-classification of AuthorityAmbiguous. + // MUST be uninstall.AmbiguityNone for any Authority != + // AuthorityAmbiguous. Violations are treated as a preflight + // invariant error in the dispatcher, not a lattice output. + Ambiguity uninstall.AmbiguityKind + + // Prior is the normalized prior-record state (see PriorState). + Prior PriorState + + // Flags captures operator intent. + Flags Flags + + // PanelPresent is a single boolean rather than the raw panel type. + // The lattice treats panel context as "panel or no panel"; any + // specific panel (DA / cPanel / Plesk / Hestia / etc.) is + // equivalent for policy purposes. + PanelPresent bool +} + +// DecisionResult is the pure-function output of Decide. Contains the +// closed-enum output plus the matched rule identifier for logging, +// testing, and CI-gate coverage assertions. +type DecisionResult struct { + // Output is one of OutputProceed / OutputRefuse / + // OutputRequireExplicitIntent. No fourth value. + Output Output + + // Rule identifies the lattice rule that matched. The string is + // stable (contract-level); tests and CI gates assert on it. Format: + // "G{group}.{subgroup}/{flag-or-condition}", e.g. "G1/AuthorityNFTBan", + // "G3.3/NoRecord+Restore", "G4.3/PanelAutoOnOrphan". See engine.go + // for the canonical list. + Rule string + + // Reason is a short human-readable explanation of the output. Not + // contract-stable; kept for operator-facing output and logging. + Reason string +} diff --git a/internal/installer/state/machine.go b/internal/installer/state/machine.go index acc1bc674..7a13f78ca 100644 --- a/internal/installer/state/machine.go +++ b/internal/installer/state/machine.go @@ -74,6 +74,52 @@ const ( // resolve. IsApplyTerminal() returns true; ExitCode() maps to // ExitFailed (2). StateUninstallFailedRelease InstallState = "UNINSTALL_FAILED_RELEASE" + + // PR-24 authority restoration policy-engine states. + // + // These are produced ONLY by the pure decision engine in + // internal/installer/restore. The engine performs no kernel, + // service, or filesystem mutation; these states therefore represent + // a policy outcome, not an apply outcome. + // + // StateRestoreRefused is the terminal state produced when the + // lattice returns REFUSE. It is terminal, NOT failed (refusal is + // not failure — it is a correct policy outcome), and deliberately + // excluded from IsApplyTerminal so it does not flow through + // update-history.json under the install-centric schema. ExitCode() + // returns ExitRefused (5). + StateRestoreRefused InstallState = "RESTORE_REFUSED" + + // StateRestoreIntentRequired is the terminal state produced when + // the lattice returns REQUIRE_EXPLICIT_INTENT. Same discipline as + // StateRestoreRefused: terminal, not failed, not apply-terminal, + // not recorded in history. ExitCode() returns ExitIntentRequired + // (6), distinct from refusal so operators and automation can tell + // "engine said no" from "engine needs you to clarify". + StateRestoreIntentRequired InstallState = "RESTORE_INTENT_REQUIRED" + + // StateRestoreDecided is the NON-TERMINAL policy-handoff marker + // produced when the lattice returns PROCEED. Locked semantics per + // contract seed §7 (merged as PR #493): + // + // 1. Policy-only: records that the decision engine said PROCEED. + // 2. Non-terminal for apply semantics: IsApplyTerminal() and + // IsTerminal() both return false. + // 3. Excluded from update-history.json: Option A discipline + // continues; main.go already gates history on cfg.mode != + // "uninstall" AND state.IsApplyTerminal(). This state will + // eventually gain its own mode-guard if --mode=restore ever + // writes history; for now, IsApplyTerminal=false closes the + // write path defensively. + // 4. Not evidence that restoration happened: no kernel, service, + // or filesystem change is implied. PR-25+ execution would + // change state further; in PR-24, PROCEED is a handoff + // outcome only. + // + // ExitCode() returns ExitCommitted (0) — the operator got the + // decision they asked for, and the process exit code reflects + // decision success, not execution success. + StateRestoreDecided InstallState = "RESTORE_DECIDED" ) // Phase represents a named installer phase. @@ -92,17 +138,21 @@ const ( // // Contract (frozen): // -// 0 = COMMITTED — all phases passed, firewall running and verified -// 1 = DEGRADED — firewall running but some validation checks failed -// 2 = FAILED — a critical phase failed, firewall may not be running -// 3 = ABORTED — conflicting firewalls detected, no --takeover flag -// 4 = FATAL — unrecoverable error (binary not found, permission denied) +// 0 = COMMITTED — all phases passed, firewall running and verified +// 1 = DEGRADED — firewall running but some validation checks failed +// 2 = FAILED — a critical phase failed, firewall may not be running +// 3 = ABORTED — conflicting firewalls detected, no --takeover flag +// 4 = FATAL — unrecoverable error (binary not found, permission denied) +// 5 = REFUSED — PR-24 restore policy engine: policy forbids restoration +// 6 = INTENT_REQUIRED — PR-24 restore policy engine: operator must clarify intent const ( - ExitCommitted = 0 - ExitDegraded = 1 - ExitFailed = 2 - ExitAborted = 3 - ExitFatal = 4 + ExitCommitted = 0 + ExitDegraded = 1 + ExitFailed = 2 + ExitAborted = 3 + ExitFatal = 4 + ExitRefused = 5 + ExitIntentRequired = 6 ) // IsApplyTerminal reports whether a state represents the terminal @@ -139,6 +189,11 @@ func (s InstallState) IsApplyTerminal() bool { StateUninstallFailedRelease: return true } + // PR-24 restore policy-engine states are DELIBERATELY excluded. + // StateRestoreRefused and StateRestoreIntentRequired are terminal + // policy outcomes with no mutation; StateRestoreDecided is a + // non-terminal handoff marker. None of them represent an apply + // outcome and none should enter update-history.json. return false } @@ -149,6 +204,10 @@ func (s InstallState) IsApplyTerminal() bool { func IsApplyTerminal(s InstallState) bool { return s.IsApplyTerminal() } // IsFailed returns true if the state represents a failure. +// +// PR-24: restore policy-engine terminal states (StateRestoreRefused, +// StateRestoreIntentRequired) are NOT failures — refusal and +// intent-required are correct policy outcomes, not error conditions. func (s InstallState) IsFailed() bool { switch s { case StateFailedSSH, StateFailedAbort, StateFailedRender, @@ -161,8 +220,18 @@ func (s InstallState) IsFailed() bool { } // IsTerminal returns true if the state is a final state (no further transitions). +// +// PR-24: StateRestoreRefused and StateRestoreIntentRequired are +// terminal; StateRestoreDecided is NOT (it is a policy-handoff marker, +// per contract seed §7). func (s InstallState) IsTerminal() bool { - return s == StateCommitted || s == StateDegraded || s.IsFailed() + if s == StateCommitted || s == StateDegraded || s.IsFailed() { + return true + } + if s == StateRestoreRefused || s == StateRestoreIntentRequired { + return true + } + return false } // ExitCode returns the process exit code for this state. @@ -176,6 +245,17 @@ func (s InstallState) ExitCode() int { // semantics are kept distinct via the writeHistory mode guard. case StateUninstallReleased: return ExitCommitted + // PR-24: StateRestoreDecided is the PROCEED handoff. Decision + // succeeded; exit 0. + case StateRestoreDecided: + return ExitCommitted + // PR-24: distinct exit codes per seed §8 — operators and automation + // must distinguish "engine said no" from "engine said clarify" from + // "engine failed". + case StateRestoreRefused: + return ExitRefused + case StateRestoreIntentRequired: + return ExitIntentRequired case StateDegraded: return ExitDegraded case StateFailedAbort: