Skip to main content

keyhog_core/
guard_state.rs

1//! Perpetual guard root state machine, policy identity, and receipt types.
2//!
3//! This module owns the versioned non-secret guard state types that every
4//! other lane builds on. It does not import CLI, transport, or presentation
5//! code (one-way dependency: core state is the bottom of the stack).
6//!
7//! ## State machine
8//!
9//! Every registered root has exactly one [`GuardRootState`]. Transitions are
10//! centralized in [`GuardRootState::transition`] so adding a state makes the
11//! state/exit/documentation matrix test fail until a decision is recorded.
12//!
13//! ## Policy identity
14//!
15//! [`GuardPolicyIdentity`] binds every input capable of changing findings or
16//! coverage. A mismatch makes existing attestations ineligible immediately.
17
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21// ── Schema ───────────────────────────────────────────────────────────────
22
23/// Current guard state schema version. Bumped on any incompatible change to
24/// the durable store layout or the serialized shapes in this module.
25pub const GUARD_SCHEMA_VERSION: u32 = 1;
26
27/// Git object hash algorithm supported by the guard.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "kebab-case")]
30pub enum GitHashAlgorithm {
31    /// Git's default object hash.
32    Sha1,
33    /// Git's SHA-256 object hash (requires `extensions.objectFormat`).
34    Sha256,
35}
36
37// ── Root mode ────────────────────────────────────────────────────────────
38
39/// Whether a guarded root is a Git repository or a plain filesystem root.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum GuardRootMode {
43    /// Repository mode: uses Git object IDs for exact staged-content identity.
44    Repo,
45    /// Filesystem mode: uses content hashes, not immutable Git OIDs.
46    Filesystem,
47}
48
49impl GuardRootMode {
50    /// Stable string label for status output and documentation.
51    pub fn label(self) -> &'static str {
52        match self {
53            GuardRootMode::Repo => "repo",
54            GuardRootMode::Filesystem => "filesystem",
55        }
56    }
57}
58
59// ── Root state ───────────────────────────────────────────────────────────
60
61/// The explicit state of one registered guard root.
62///
63/// Adding a variant here MUST be accompanied by:
64/// 1. A transition decision in [`GuardRootState::transition`].
65/// 2. An exit-code mapping in the CLI exit module.
66/// 3. A status/documentation table entry.
67///
68/// The state-matrix test fails until all three are recorded.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "kebab-case")]
71pub enum GuardRootState {
72    /// Initial reconciliation or requested full repair is running.
73    Indexing,
74    /// Baseline completed, all accepted events through the receipt sequence
75    /// were processed, policy identity matches, and no unresolved finding
76    /// exists. Background state alone is still insufficient to authorize a
77    /// commit; an exact staged transaction is required.
78    Current,
79    /// Accepted filesystem events are queued or scanning.
80    Dirty,
81    /// At least one current file or staged blob produced an unsuppressed
82    /// finding.
83    Blocked,
84    /// Event loss, source gap, unreadable input, queue overflow, root
85    /// replacement, mount discontinuity, or persistence failure prevents
86    /// complete coverage.
87    Degraded,
88    /// Binary, detector, suppression, preprocessing, schema, or resolved
89    /// configuration identity differs from persisted attestations.
90    StalePolicy,
91    /// Root is registered but not actively watched.
92    Stopped,
93}
94
95impl GuardRootState {
96    /// Whether this state permits a commit authorization.
97    ///
98    /// This is always `false` for background states. The exact staged
99    /// transaction is the only local commit authorization input.
100    pub fn may_authorize_commit(self) -> bool {
101        // Even `Current` returns false here: background state alone never
102        // authorizes a commit. The hook must always submit the exact staged
103        // manifest.
104        false
105    }
106
107    /// Whether this state indicates the root needs explicit repair action.
108    pub fn needs_repair(self) -> bool {
109        matches!(self, GuardRootState::Degraded | GuardRootState::StalePolicy)
110    }
111
112    /// All variants in declaration order, for exhaustive test derivation.
113    pub fn all() -> &'static [GuardRootState] {
114        &[
115            GuardRootState::Indexing,
116            GuardRootState::Current,
117            GuardRootState::Dirty,
118            GuardRootState::Blocked,
119            GuardRootState::Degraded,
120            GuardRootState::StalePolicy,
121            GuardRootState::Stopped,
122        ]
123    }
124
125    /// Stable string label for status output and documentation.
126    pub fn label(self) -> &'static str {
127        match self {
128            GuardRootState::Indexing => "indexing",
129            GuardRootState::Current => "current",
130            GuardRootState::Dirty => "dirty",
131            GuardRootState::Blocked => "blocked",
132            GuardRootState::Degraded => "degraded",
133            GuardRootState::StalePolicy => "stale-policy",
134            GuardRootState::Stopped => "stopped",
135        }
136    }
137}
138
139impl std::fmt::Display for GuardRootState {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        write!(f, "{}", self.label())
142    }
143}
144
145// ── Transition events ────────────────────────────────────────────────────
146
147/// Events that drive the root state machine.
148///
149/// Adding a variant here MUST be handled in [`GuardRootState::transition`].
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum GuardTransition {
152    /// Registration or explicit reconciliation started (stopped -> indexing).
153    ReconciliationStarted,
154    /// Initial reconciliation or repair completed with no findings.
155    ReconciliationClean,
156    /// Initial reconciliation or repair completed with unsuppressed findings.
157    ReconciliationFindings,
158    /// Initial reconciliation or repair completed but coverage is incomplete.
159    ReconciliationDegraded,
160    /// A filesystem event was accepted while current or blocked.
161    EventAccepted,
162    /// All queued events through the current sequence were processed with no
163    /// findings.
164    EventsClean,
165    /// All queued events were processed and at least one has a finding.
166    EventsFindings,
167    /// All queued events were processed but coverage is incomplete.
168    EventsDegraded,
169    /// Watcher overflow, queue overflow, unreadable input, lost root,
170    /// persistence failure, or journal discontinuity.
171    CoverageLost,
172    /// Binary, detector, suppression, preprocessing, schema, or config
173    /// identity changed.
174    PolicyChanged,
175    /// Explicit or automatic full reconciliation announced (from degraded or
176    /// stale-policy back to indexing).
177    RepairStarted,
178    /// Root removed from active watching.
179    Stopped,
180}
181
182/// Error returned when a transition is not legal from the current state.
183#[derive(Debug, Clone, PartialEq, Eq, Error)]
184pub enum TransitionError {
185    /// The transition event is not legal from the current state.
186    #[error("illegal guard transition: {event} from state {from}")]
187    Illegal {
188        /// The event that was rejected.
189        event: GuardTransition,
190        /// The state the root was in.
191        from: GuardRootState,
192    },
193}
194
195impl std::fmt::Display for GuardTransition {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            GuardTransition::ReconciliationStarted => write!(f, "reconciliation-started"),
199            GuardTransition::ReconciliationClean => write!(f, "reconciliation-clean"),
200            GuardTransition::ReconciliationFindings => write!(f, "reconciliation-findings"),
201            GuardTransition::ReconciliationDegraded => write!(f, "reconciliation-degraded"),
202            GuardTransition::EventAccepted => write!(f, "event-accepted"),
203            GuardTransition::EventsClean => write!(f, "events-clean"),
204            GuardTransition::EventsFindings => write!(f, "events-findings"),
205            GuardTransition::EventsDegraded => write!(f, "events-degraded"),
206            GuardTransition::CoverageLost => write!(f, "coverage-lost"),
207            GuardTransition::PolicyChanged => write!(f, "policy-changed"),
208            GuardTransition::RepairStarted => write!(f, "repair-started"),
209            GuardTransition::Stopped => write!(f, "stopped"),
210        }
211    }
212}
213
214impl GuardRootState {
215    /// Apply one transition event, returning the new state or an error.
216    ///
217    /// This is the SINGLE owner of the transition function. Every legal
218    /// transition is enumerated here; anything not listed is rejected. Adding
219    /// a state or event without updating this function and its tests is a
220    /// compile-time or test-time failure.
221    pub fn transition(self, event: &GuardTransition) -> Result<GuardRootState, TransitionError> {
222        use GuardRootState as S;
223        use GuardTransition as T;
224
225        let result = match (self, event) {
226            // stopped -> indexing
227            (S::Stopped, T::ReconciliationStarted) => Some(S::Indexing),
228            // indexing -> current | blocked | degraded
229            (S::Indexing, T::ReconciliationClean) => Some(S::Current),
230            (S::Indexing, T::ReconciliationFindings) => Some(S::Blocked),
231            (S::Indexing, T::ReconciliationDegraded) => Some(S::Degraded),
232            // current | blocked -> dirty
233            (S::Current, T::EventAccepted) => Some(S::Dirty),
234            (S::Blocked, T::EventAccepted) => Some(S::Dirty),
235            // dirty -> current | blocked | degraded
236            (S::Dirty, T::EventsClean) => Some(S::Current),
237            (S::Dirty, T::EventsFindings) => Some(S::Blocked),
238            (S::Dirty, T::EventsDegraded) => Some(S::Degraded),
239            // any active state -> degraded on coverage loss
240            (S::Indexing, T::CoverageLost) => Some(S::Degraded),
241            (S::Current, T::CoverageLost) => Some(S::Degraded),
242            (S::Dirty, T::CoverageLost) => Some(S::Degraded),
243            (S::Blocked, T::CoverageLost) => Some(S::Degraded),
244            // any active state -> stale-policy on identity change
245            (S::Indexing, T::PolicyChanged) => Some(S::StalePolicy),
246            (S::Current, T::PolicyChanged) => Some(S::StalePolicy),
247            (S::Dirty, T::PolicyChanged) => Some(S::StalePolicy),
248            (S::Blocked, T::PolicyChanged) => Some(S::StalePolicy),
249            // degraded | stale-policy -> indexing only through reconciliation
250            (S::Degraded, T::RepairStarted) => Some(S::Indexing),
251            (S::StalePolicy, T::RepairStarted) => Some(S::Indexing),
252            // any state -> stopped
253            (S::Stopped, T::Stopped) => Some(S::Stopped),
254            (S::Indexing, T::Stopped) => Some(S::Stopped),
255            (S::Current, T::Stopped) => Some(S::Stopped),
256            (S::Dirty, T::Stopped) => Some(S::Stopped),
257            (S::Blocked, T::Stopped) => Some(S::Stopped),
258            (S::Degraded, T::Stopped) => Some(S::Stopped),
259            (S::StalePolicy, T::Stopped) => Some(S::Stopped),
260            // degraded can also report coverage loss again (stays degraded)
261            (S::Degraded, T::CoverageLost) => Some(S::Degraded),
262            // stale-policy can report policy change again (stays stale-policy)
263            (S::StalePolicy, T::PolicyChanged) => Some(S::StalePolicy),
264            // everything else is illegal
265            _ => None,
266        };
267
268        result.ok_or(TransitionError::Illegal {
269            event: event.clone(),
270            from: self,
271        })
272    }
273}
274
275// ── Policy identity ──────────────────────────────────────────────────────
276
277/// Every input capable of changing findings or coverage, bound into one
278/// canonical identity. A mismatch makes existing attestations ineligible
279/// immediately and transitions the root to [`GuardRootState::StalePolicy`].
280///
281/// Adding a new behavior-affecting setting MUST add a field here or explicitly
282/// classify it as presentation-only. The identity coverage test fails until
283/// the field is added.
284#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
285pub struct GuardPolicyIdentity {
286    /// Git commit SHA the binary was built from, or `"unknown"`.
287    pub build_identity: String,
288    /// Effective detector corpus digest (BLAKE3, hex).
289    pub detector_digest: String,
290    /// Suppression file digest (BLAKE3, hex), or empty if no suppressions.
291    pub suppression_digest: String,
292    /// `.keyhogignore.toml` rule-filter digest (BLAKE3, hex), or empty.
293    pub keyhogignore_digest: String,
294    /// Resolved CLI/TOML/default scan configuration digest (BLAKE3, hex).
295    pub config_digest: String,
296    /// Decode/preprocessing policy version.
297    pub decode_policy_version: u32,
298    /// Maximum file size and source exclusion policy digest (BLAKE3, hex).
299    pub source_policy_digest: String,
300    /// Guard state schema version ([`GUARD_SCHEMA_VERSION`]).
301    pub guard_schema_version: u32,
302    /// Report semantics version where it affects reusable clean status.
303    pub report_semantics_version: u32,
304}
305
306impl GuardPolicyIdentity {
307    /// Short hex digest of the full identity for status display (first 12
308    /// hex chars of a BLAKE3 hash over the canonical serialization).
309    pub fn short_digest(&self) -> Result<String, serde_json::Error> {
310        let bytes = serde_json::to_vec(self)?;
311        let hash = blake3::hash(&bytes);
312        Ok(hex::encode(&hash.as_bytes()[..6]))
313    }
314
315    /// Whether two identities are compatible for attestation reuse.
316    pub fn is_compatible_with(&self, other: &GuardPolicyIdentity) -> bool {
317        self == other
318    }
319}
320
321// ── Clean attestation ────────────────────────────────────────────────────
322
323/// Summary of a complete clean scan outcome for one Git blob, suitable for
324/// durable reuse. Only complete clean outcomes are reusable: a blob that
325/// produced a finding, coverage gap, panic, persistence failure, or
326/// incomplete report is never inserted as clean.
327#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
328pub struct GitCleanAttestation {
329    /// Git object hash algorithm.
330    pub hash_algorithm: GitHashAlgorithm,
331    /// Staged blob object ID (hex).
332    pub blob_oid: String,
333    /// Exact object size in bytes.
334    pub object_size: u64,
335    /// Policy identity that produced the clean outcome.
336    pub policy_identity: GuardPolicyIdentity,
337    /// Monotonically increasing event sequence when the attestation was
338    /// recorded.
339    pub last_seen_sequence: u64,
340}
341
342/// Key for the clean attestation lookup: hash algorithm + blob OID + policy
343/// identity. Backend identity is recorded in the receipt but does not create
344/// different correctness outcomes.
345#[derive(Debug, Clone, PartialEq, Eq, Hash)]
346pub struct GitCleanAttestationKey<'a> {
347    /// Git object hash algorithm.
348    pub hash_algorithm: GitHashAlgorithm,
349    /// Staged blob object ID (hex).
350    pub blob_oid: &'a str,
351    /// Policy identity digest (short hex).
352    pub policy_short_digest: &'a str,
353}
354
355// ── Receipt ──────────────────────────────────────────────────────────────
356
357/// Terminal receipt for a guard commit transaction or background
358/// reconciliation. Carries exact byte and object totals so the client can
359/// validate conservation.
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
361pub struct GuardReceipt {
362    /// Number of objects requested in the transaction.
363    pub objects_requested: u64,
364    /// Number of objects served from the clean attestation cache (no payload
365    /// read).
366    pub objects_hit: u64,
367    /// Number of objects scanned.
368    pub objects_scanned: u64,
369    /// Number of objects skipped (deletions, symlinks, submodules).
370    pub objects_skipped: u64,
371    /// Total bytes requested.
372    pub bytes_requested: u64,
373    /// Total bytes served from cache.
374    pub bytes_hit: u64,
375    /// Total bytes scanned.
376    pub bytes_scanned: u64,
377    /// Number of unsuppressed findings (without secret values).
378    pub findings_count: u64,
379    /// Number of coverage gaps.
380    pub coverage_gaps: u64,
381    /// Terminal root state after the transaction.
382    pub terminal_state: GuardRootState,
383    /// Policy identity under which the receipt was produced.
384    pub policy_identity: GuardPolicyIdentity,
385    /// Monotonically increasing event sequence at completion.
386    pub terminal_sequence: u64,
387}
388
389impl GuardReceipt {
390    /// Validate conservation of object count and bytes.
391    ///
392    /// `objects_requested == objects_hit + objects_scanned + objects_skipped`
393    /// and `bytes_requested == bytes_hit + bytes_scanned` (skipped objects
394    /// contribute zero bytes).
395    pub fn validate_conservation(&self) -> Result<(), ReceiptError> {
396        let obj_sum = self.objects_hit + self.objects_scanned + self.objects_skipped;
397        if obj_sum != self.objects_requested {
398            return Err(ReceiptError::ObjectMismatch {
399                requested: self.objects_requested,
400                accounted: obj_sum,
401            });
402        }
403        let byte_sum = self.bytes_hit + self.bytes_scanned;
404        if byte_sum != self.bytes_requested {
405            return Err(ReceiptError::ByteMismatch {
406                requested: self.bytes_requested,
407                accounted: byte_sum,
408            });
409        }
410        Ok(())
411    }
412}
413
414/// Error returned when receipt conservation validation fails.
415#[derive(Debug, Clone, PartialEq, Eq, Error)]
416pub enum ReceiptError {
417    /// Object count does not conserve.
418    #[error("receipt object mismatch: requested {requested}, accounted {accounted}")]
419    ObjectMismatch {
420        /// Objects requested in the transaction.
421        requested: u64,
422        /// Hit + scanned + skipped.
423        accounted: u64,
424    },
425    /// Byte count does not conserve.
426    #[error("receipt byte mismatch: requested {requested}, accounted {accounted}")]
427    ByteMismatch {
428        /// Bytes requested in the transaction.
429        requested: u64,
430        /// Hit + scanned.
431        accounted: u64,
432    },
433}
434
435// ── Root registration ────────────────────────────────────────────────────
436
437/// Persistent record for one registered guard root.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub struct GuardRootRecord {
440    /// Canonical root path (bytes, no lossy Unicode conversion).
441    pub canonical_path: Vec<u8>,
442    /// Filesystem identity (device + inode on Unix, volume serial on Windows).
443    pub filesystem_identity: FilesystemIdentity,
444    /// Repository or filesystem mode.
445    pub mode: GuardRootMode,
446    /// Current root state.
447    pub state: GuardRootState,
448    /// Terminal event sequence.
449    pub terminal_sequence: u64,
450    /// Accepted event sequence (events received from the watcher).
451    pub accepted_event_sequence: u64,
452    /// Completed event sequence (events fully processed).
453    pub completed_event_sequence: u64,
454    /// Unix timestamp (seconds) of the initial reconciliation completion.
455    pub initial_reconciliation_time: Option<u64>,
456    /// Unix timestamp (seconds) of the last reconciliation completion.
457    pub last_reconciliation_time: Option<u64>,
458    /// Backend route label used for the last scan.
459    pub backend_route_label: String,
460    /// Last complete receipt summary (non-secret).
461    pub last_receipt: Option<GuardReceipt>,
462}
463
464/// Non-secret filesystem identity for root replacement detection.
465#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
466pub struct FilesystemIdentity {
467    /// Device ID (Unix `st_dev` or Windows volume serial).
468    pub device: u64,
469    /// Inode number (Unix `st_ino` or Windows file index).
470    pub inode: u64,
471}