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}