Skip to main content

keyhog_core/
guard_store.rs

1//! Durable guard state store: schema, tables, and memory-bounded LRU.
2//!
3//! This module owns the versioned non-secret guard state storage. It does not
4//! store credential values, raw file or blob payloads, matched excerpts,
5//! verification request material, or environment variable values.
6//!
7//! ## Storage engine
8//!
9//! The proposed durable backend is `redb` (pure-Rust, transactional, embedded).
10//! Before finalizing, a focused spike must prove Linux/macOS/Windows/musl
11//! builds, atomic transaction behavior under forced termination, file
12//! ownership/permission handling, bounded open descriptors, corruption
13//! detection, compaction, and deterministic migration failure for unsupported
14//! schema versions. Until that spike passes, this module defines the logical
15//! schema and the in-memory hot index only.
16//!
17//! ## Memory bounds
18//!
19//! The durable store is not loaded wholesale. A configurable in-memory LRU
20//! holds hot clean attestations with a default hard budget of 64 MiB. Budget
21//! accounting includes keys, paths, allocator overhead estimate, and values.
22//! Once full, evict least-recently-used entries from memory; durable entries
23//! remain available.
24
25use crate::guard_state::{
26    GitCleanAttestation, GitHashAlgorithm, GuardPolicyIdentity, GuardRootRecord, GuardRootState,
27    GUARD_SCHEMA_VERSION,
28};
29use lru::LruCache;
30use parking_lot::Mutex;
31use redb::ReadableTable;
32use std::num::NonZeroUsize;
33
34/// Default hard memory budget for the hot clean attestation index (64 MiB).
35pub const DEFAULT_HOT_INDEX_MEMORY: usize = 64 * 1024 * 1024;
36
37/// Estimated bytes per hot-index entry: key (hash algo + OID hex + policy
38/// short digest ≈ 80 bytes) + value (attestation ≈ 200 bytes) + allocator
39/// overhead estimate (32 bytes). Conservative: rounds up to 320.
40const ESTIMATED_BYTES_PER_ENTRY: usize = 320;
41
42/// Maximum number of hot-index entries under the configured memory budget.
43fn max_entries_for_budget(budget: usize) -> NonZeroUsize {
44    let n = budget / ESTIMATED_BYTES_PER_ENTRY;
45    // At least one entry; if budget is too small, one entry still fits.
46    NonZeroUsize::new(n.max(1)).unwrap_or(NonZeroUsize::MIN)
47}
48
49/// In-memory LRU cache for hot Git clean attestations.
50///
51/// This is the hot index: durable entries remain available even after
52/// eviction from this cache. The cache bounds memory, not correctness.
53pub struct HotAttestationIndex {
54    cache: Mutex<LruCache<HotKey, GitCleanAttestation>>,
55    budget: usize,
56}
57
58/// Key for the hot index: hash algorithm + blob OID + policy short digest.
59/// This mirrors the durable lookup key but owns its data.
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61struct HotKey {
62    hash_algorithm: GitHashAlgorithm,
63    blob_oid: String,
64    policy_short_digest: String,
65}
66
67impl HotAttestationIndex {
68    /// Create a hot index with the default 64 MiB memory budget.
69    pub fn new() -> Self {
70        Self::with_budget(DEFAULT_HOT_INDEX_MEMORY)
71    }
72
73    /// Create a hot index with a custom memory budget in bytes.
74    pub fn with_budget(budget: usize) -> Self {
75        let cap = max_entries_for_budget(budget);
76        Self {
77            cache: Mutex::new(LruCache::new(cap)),
78            budget,
79        }
80    }
81
82    /// Look up a clean attestation by key. A hit does not read blob payload.
83    pub fn get(
84        &self,
85        hash_algorithm: GitHashAlgorithm,
86        blob_oid: &str,
87        policy_short_digest: &str,
88    ) -> Option<GitCleanAttestation> {
89        let key = HotKey {
90            hash_algorithm,
91            blob_oid: blob_oid.to_string(),
92            policy_short_digest: policy_short_digest.to_string(),
93        };
94        self.cache.lock().get(&key).cloned()
95    }
96
97    /// Insert a clean attestation. Only complete clean outcomes are
98    /// insertable; the caller must not insert findings, gaps, panics, or
99    /// incomplete reports.
100    pub fn insert(&self, attestation: GitCleanAttestation) {
101        let key = HotKey {
102            hash_algorithm: attestation.hash_algorithm,
103            blob_oid: attestation.blob_oid.clone(),
104            policy_short_digest: attestation
105                .policy_identity
106                .short_digest()
107                .unwrap_or_default(),
108        };
109        self.cache.lock().put(key, attestation);
110    }
111
112    /// Remove a single attestation by key.
113    pub fn remove(
114        &self,
115        hash_algorithm: GitHashAlgorithm,
116        blob_oid: &str,
117        policy_short_digest: &str,
118    ) -> Option<GitCleanAttestation> {
119        let key = HotKey {
120            hash_algorithm,
121            blob_oid: blob_oid.to_string(),
122            policy_short_digest: policy_short_digest.to_string(),
123        };
124        self.cache.lock().pop(&key)
125    }
126
127    /// Invalidate all attestations whose policy identity is no longer
128    /// compatible with the current identity. Returns the count removed.
129    pub fn invalidate_for_policy(&self, current: &GuardPolicyIdentity) -> usize {
130        let current_short = current.short_digest().unwrap_or_default();
131        let mut removed = 0;
132        let mut to_remove = Vec::new();
133        {
134            let cache = self.cache.lock();
135            for (key, value) in cache.iter() {
136                let key_digest = &key.policy_short_digest;
137                let value_digest = value.policy_identity.short_digest().unwrap_or_default();
138                if key_digest != &current_short || value_digest != current_short {
139                    to_remove.push(key.clone());
140                }
141            }
142        }
143        let mut cache = self.cache.lock();
144        for key in to_remove {
145            if cache.pop(&key).is_some() {
146                removed += 1;
147            }
148        }
149        removed
150    }
151
152    /// Invalidate all attestations matching a specific policy short digest.
153    /// Returns the count removed.
154    pub fn invalidate_policy_digest(&self, stale_digest: &str) -> usize {
155        if stale_digest.is_empty() {
156            return 0;
157        }
158        let mut removed = 0;
159        let mut to_remove = Vec::new();
160        {
161            let cache = self.cache.lock();
162            for (key, _) in cache.iter() {
163                if key.policy_short_digest == stale_digest {
164                    to_remove.push(key.clone());
165                }
166            }
167        }
168        let mut cache = self.cache.lock();
169        for key in to_remove {
170            if cache.pop(&key).is_some() {
171                removed += 1;
172            }
173        }
174        removed
175    }
176
177    /// Current number of entries in the hot index.
178    pub fn len(&self) -> usize {
179        self.cache.lock().len()
180    }
181
182    /// Whether the hot index is empty.
183    pub fn is_empty(&self) -> bool {
184        self.cache.lock().is_empty()
185    }
186
187    /// Configured memory budget in bytes.
188    pub fn budget(&self) -> usize {
189        self.budget
190    }
191
192    /// Clear all entries from the hot index.
193    pub fn clear(&self) {
194        self.cache.lock().clear();
195    }
196}
197
198impl Default for HotAttestationIndex {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204// ── Store schema versioning ──────────────────────────────────────────────
205
206/// Metadata row in the durable store.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct StoreMeta {
209    /// Schema version of the store.
210    pub schema_version: u32,
211    /// Unique store identifier.
212    pub store_uuid: [u8; 16],
213    /// KeyHog version that created the store.
214    pub created_version: String,
215    /// Last schema version that was successfully migrated.
216    pub last_successful_migration: u32,
217}
218
219/// Error returned by the guard store.
220#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
221pub enum GuardStoreError {
222    /// The store schema version is newer than this binary supports.
223    #[error("guard store schema version {found} is newer than supported {supported}; upgrade keyhog or run `keyhog guard rebuild <root>`")]
224    SchemaTooNew {
225        /// Schema version found on disk.
226        found: u32,
227        /// Maximum schema version this binary supports.
228        supported: u32,
229    },
230    /// The store schema version is older than this binary supports and
231    /// cannot be migrated.
232    #[error("guard store schema version {found} is no longer supported; run `keyhog guard rebuild <root>` to recreate state")]
233    SchemaObsolete {
234        /// Schema version found on disk.
235        found: u32,
236    },
237    /// The store file is corrupt or truncated.
238    #[error("guard store is corrupt: {detail}; run `keyhog guard rebuild <root>`")]
239    Corrupt {
240        /// Human-readable corruption detail.
241        detail: String,
242    },
243    /// The store path has unsafe ownership or permissions.
244    #[error("guard store path is unsafe: {detail}; run `keyhog guard repair <root>` or fix directory permissions")]
245    UnsafePath {
246        /// Human-readable safety violation detail.
247        detail: String,
248    },
249    /// An I/O error occurred.
250    #[error("guard store I/O error: {0}; check disk space and permissions or run `keyhog guard repair <root>`")]
251    Io(String),
252    /// The store was not started cleanly (previous process may not have
253    /// flushed).
254    #[error("guard store was not closed cleanly; run `keyhog guard reconcile <root>`")]
255    UncleanShutdown,
256}
257
258/// Check whether a found schema version is compatible with this binary.
259pub fn check_schema_version(found: u32) -> Result<(), GuardStoreError> {
260    if found > GUARD_SCHEMA_VERSION {
261        return Err(GuardStoreError::SchemaTooNew {
262            found,
263            supported: GUARD_SCHEMA_VERSION,
264        });
265    }
266    if found < 1 {
267        return Err(GuardStoreError::SchemaObsolete { found });
268    }
269    // v1 is the initial schema; no migrations exist yet.
270    Ok(())
271}
272
273// ── Root registry ────────────────────────────────────────────────────────
274
275/// In-memory root registry. The durable store persists these records; this
276/// holds the live state for the daemon scheduler.
277#[derive(Debug, Default)]
278pub struct RootRegistry {
279    roots: std::collections::HashMap<Vec<u8>, GuardRootRecord>,
280}
281
282impl RootRegistry {
283    /// Create an empty registry.
284    pub fn new() -> Self {
285        Self::default()
286    }
287
288    /// Register a new root. Returns the initial record in `Stopped` state.
289    pub fn register(
290        &mut self,
291        canonical_path: Vec<u8>,
292        filesystem_identity: crate::guard_state::FilesystemIdentity,
293        filesystem_authority: crate::guard_state::FilesystemAuthority,
294        mode: crate::guard_state::GuardRootMode,
295    ) -> GuardRootRecord {
296        let record = GuardRootRecord {
297            canonical_path: canonical_path.clone(),
298            filesystem_identity,
299            filesystem_authority,
300            mode,
301            state: GuardRootState::Stopped,
302            terminal_sequence: 0,
303            accepted_event_sequence: 0,
304            completed_event_sequence: 0,
305            initial_reconciliation_time: None,
306            last_reconciliation_time: None,
307            backend_route_label: String::new(),
308            last_receipt: None,
309            recent_transitions: Vec::new(),
310        };
311        self.roots.insert(canonical_path, record.clone());
312        record
313    }
314
315    /// Insert a fully-formed root record. Used when restoring from
316    /// the durable store, where the complete record state must be
317    /// preserved rather than initialized to `Stopped`.
318    pub fn insert_record(&mut self, record: GuardRootRecord) {
319        self.roots.insert(record.canonical_path.clone(), record);
320    }
321
322    /// Look up a root by canonical path bytes.
323    pub fn get(&self, canonical_path: &[u8]) -> Option<&GuardRootRecord> {
324        self.roots.get(canonical_path)
325    }
326
327    /// Look up a root by canonical path bytes for mutation.
328    pub fn get_mut(&mut self, canonical_path: &[u8]) -> Option<&mut GuardRootRecord> {
329        self.roots.get_mut(canonical_path)
330    }
331
332    /// Remove a root from the registry.
333    pub fn remove(&mut self, canonical_path: &[u8]) -> Option<GuardRootRecord> {
334        self.roots.remove(canonical_path)
335    }
336
337    /// List all registered roots.
338    pub fn list(&self) -> Vec<&GuardRootRecord> {
339        self.roots.values().collect()
340    }
341
342    /// Number of registered roots.
343    pub fn len(&self) -> usize {
344        self.roots.len()
345    }
346
347    /// Whether the registry is empty.
348    pub fn is_empty(&self) -> bool {
349        self.roots.is_empty()
350    }
351
352    /// Count roots by state.
353    pub fn count_by_state(&self, state: GuardRootState) -> usize {
354        self.roots.values().filter(|r| r.state == state).count()
355    }
356}
357
358// ── Durable store (redb) ─────────────────────────────────────────────────
359
360/// redb table definition for the metadata singleton.
361const META_TABLE: redb::TableDefinition<&str, &[u8]> = redb::TableDefinition::new("meta");
362
363/// redb table definition for root records, keyed by canonical path bytes.
364const ROOTS_TABLE: redb::TableDefinition<&[u8], &[u8]> = redb::TableDefinition::new("roots");
365
366/// redb table definition for Git clean attestations, keyed by
367/// (hash_algorithm_label || blob_oid_hex || policy_short_digest).
368const ATTESTATIONS_TABLE: redb::TableDefinition<&[u8], &[u8]> =
369    redb::TableDefinition::new("git_clean_attestations");
370
371/// redb table definition for root coverage gaps, keyed by
372/// (canonical_path || 0x00 || blob_oid). Value is the gap description.
373const ROOT_GAPS_TABLE: redb::TableDefinition<&[u8], &[u8]> =
374    redb::TableDefinition::new("root_gaps");
375
376/// redb table definition for the service state singleton.
377/// Key is "clean_shutdown", value is 1 (clean) or 0 (unclean).
378const SERVICE_STATE_TABLE: redb::TableDefinition<&str, u8> =
379    redb::TableDefinition::new("service_state");
380
381/// Durable guard state store backed by redb.
382///
383/// Persists root records and clean attestations to a single file.
384/// The in-memory `RootRegistry` and `HotAttestationIndex` remain the
385/// hot path; this store provides crash recovery across daemon restarts.
386pub struct DurableGuardStore {
387    db: redb::Database,
388    path: std::path::PathBuf,
389}
390
391impl DurableGuardStore {
392    /// Open or create the durable store at the given path.
393    ///
394    /// The path is validated for safety: it must not be a symlink, and
395    /// the file (once created) is set to owner-only permissions (0600).
396    /// The parent directory is created with 0700 permissions if needed.
397    pub fn open(path: &std::path::Path) -> Result<Self, GuardStoreError> {
398        // Reject symlinked state paths. A symlink could point outside the
399        // intended state directory, leaking guard state to an attacker.
400        if path.exists() {
401            let meta = std::fs::symlink_metadata(path)
402                .map_err(|e| GuardStoreError::Io(format!("stat guard store path: {e}")))?;
403            if meta.file_type().is_symlink() {
404                return Err(GuardStoreError::Io(
405                    "guard store path is a symlink; refusing to open".to_string(),
406                ));
407            }
408        }
409        // Create parent directory with owner-only permissions.
410        if let Some(parent) = path.parent() {
411            if !parent.exists() {
412                std::fs::create_dir_all(parent)
413                    .map_err(|e| GuardStoreError::Io(format!("create guard store dir: {e}")))?;
414                #[cfg(unix)]
415                {
416                    use std::os::unix::fs::PermissionsExt;
417                    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
418                        .map_err(|e| {
419                            GuardStoreError::Io(format!("set guard store dir perms: {e}"))
420                        })?;
421                }
422            }
423        }
424        let db = redb::Database::create(path)
425            .map_err(|e| GuardStoreError::Io(format!("open guard store: {e}")))?;
426        // Enforce owner-only file permissions on the store file.
427        #[cfg(unix)]
428        {
429            use std::os::unix::fs::PermissionsExt;
430            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
431                .map_err(|e| GuardStoreError::Io(format!("set guard store perms: {e}")))?;
432        }
433        let store = Self {
434            db,
435            path: path.to_path_buf(),
436        };
437        store.ensure_schema()?;
438        Ok(store)
439    }
440    /// Open an existing durable store in read-only mode without mutating the file schema.
441    ///
442    /// Validates symlink safety and checks schema version compatibility.
443    ///
444    /// Note: `redb::Database::open` acquires an exclusive file lock, so this should only
445    /// be used in offline inspection paths when no live daemon process holds the database lock.
446    pub fn open_read_only(path: &std::path::Path) -> Result<Self, GuardStoreError> {
447        if !path.exists() {
448            return Err(GuardStoreError::Io(format!(
449                "guard store path '{}' does not exist",
450                path.display()
451            )));
452        }
453        let meta = std::fs::symlink_metadata(path)
454            .map_err(|e| GuardStoreError::Io(format!("stat guard store path: {e}")))?;
455        if meta.file_type().is_symlink() {
456            return Err(GuardStoreError::Io(
457                "guard store path is a symlink; refusing to open".to_string(),
458            ));
459        }
460        let db = redb::Database::open(path)
461            .map_err(|e| GuardStoreError::Io(format!("open guard store: {e}")))?;
462        let store = Self {
463            db,
464            path: path.to_path_buf(),
465        };
466        let txn = store
467            .db
468            .begin_read()
469            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
470        match txn.open_table(META_TABLE) {
471            Ok(meta_table) => {
472                let found_version: Option<u32> = meta_table
473                    .get("schema_version")
474                    .map_err(|e| GuardStoreError::Io(format!("read schema_version: {e}")))?
475                    .map(|guard| {
476                        let bytes: &[u8] = guard.value();
477                        u32::from_le_bytes(bytes.try_into().unwrap_or([0, 0, 0, 0]))
478                    });
479                match found_version {
480                    Some(version) => check_schema_version(version)?,
481                    None => {
482                        return Err(GuardStoreError::Corrupt {
483                            detail: "missing schema_version in meta table".to_string(),
484                        });
485                    }
486                }
487            }
488            Err(redb::TableError::TableDoesNotExist(_)) => {
489                return Err(GuardStoreError::Corrupt {
490                    detail: "meta table does not exist".to_string(),
491                });
492            }
493            Err(e) => return Err(GuardStoreError::Io(format!("open meta table: {e}"))),
494        }
495        Ok(store)
496    }
497
498    /// Return the store path.
499    pub fn path(&self) -> &std::path::Path {
500        &self.path
501    }
502
503    /// Initialize or verify the schema version in the meta table.
504    fn ensure_schema(&self) -> Result<(), GuardStoreError> {
505        let txn = self
506            .db
507            .begin_write()
508            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
509        {
510            let mut meta = txn
511                .open_table(META_TABLE)
512                .map_err(|e| GuardStoreError::Io(format!("open meta table: {e}")))?;
513            let found_version: Option<u32> = meta
514                .get("schema_version")
515                .map_err(|e| GuardStoreError::Io(format!("read schema_version: {e}")))?
516                .map(|guard| {
517                    let bytes: &[u8] = guard.value();
518                    u32::from_le_bytes(bytes.try_into().unwrap_or([0, 0, 0, 0]))
519                });
520            match found_version {
521                Some(version) => {
522                    check_schema_version(version)?;
523                }
524                None => {
525                    // First open: write schema version.
526                    let version_bytes = GUARD_SCHEMA_VERSION.to_le_bytes();
527                    meta.insert("schema_version", version_bytes.as_slice())
528                        .map_err(|e| GuardStoreError::Io(format!("write schema_version: {e}")))?;
529                }
530            }
531        }
532        // Create all tables so they exist for reads even before first write.
533        {
534            let _ = txn
535                .open_table(ROOTS_TABLE)
536                .map_err(|e| GuardStoreError::Io(format!("create roots table: {e}")))?;
537            let _ = txn
538                .open_table(ATTESTATIONS_TABLE)
539                .map_err(|e| GuardStoreError::Io(format!("create attestations table: {e}")))?;
540            let _ = txn
541                .open_table(ROOT_GAPS_TABLE)
542                .map_err(|e| GuardStoreError::Io(format!("create root_gaps table: {e}")))?;
543            let _ = txn
544                .open_table(SERVICE_STATE_TABLE)
545                .map_err(|e| GuardStoreError::Io(format!("create service_state table: {e}")))?;
546        }
547        txn.commit()
548            .map_err(|e| GuardStoreError::Io(format!("commit schema: {e}")))?;
549        Ok(())
550    }
551
552    /// Load all root records from the durable store into a registry.
553    pub fn load_roots(&self) -> Result<RootRegistry, GuardStoreError> {
554        let txn = self
555            .db
556            .begin_read()
557            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
558        let table = match txn.open_table(ROOTS_TABLE) {
559            Ok(t) => t,
560            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(RootRegistry::new()),
561            Err(e) => return Err(GuardStoreError::Io(format!("open roots table: {e}"))),
562        };
563        let mut registry = RootRegistry::new();
564        for entry in table
565            .range::<&[u8]>(..)
566            .map_err(|e| GuardStoreError::Io(format!("iterate roots: {e}")))?
567        {
568            let (key, value) =
569                entry.map_err(|e| GuardStoreError::Io(format!("read root entry: {e}")))?;
570            let record: GuardRootRecord =
571                serde_json::from_slice(value.value()).map_err(|e| GuardStoreError::Corrupt {
572                    detail: format!("deserialize root record: {e}"),
573                })?;
574            registry.roots.insert(key.value().to_vec(), record);
575        }
576        Ok(registry)
577    }
578    /// Load a single root record by canonical path from the durable store.
579    pub fn get_root(
580        &self,
581        canonical_path: &[u8],
582    ) -> Result<Option<GuardRootRecord>, GuardStoreError> {
583        let txn = self
584            .db
585            .begin_read()
586            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
587        let table = match txn.open_table(ROOTS_TABLE) {
588            Ok(t) => t,
589            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
590            Err(e) => return Err(GuardStoreError::Io(format!("open roots table: {e}"))),
591        };
592        let entry = table
593            .get(canonical_path)
594            .map_err(|e| GuardStoreError::Io(format!("read root entry: {e}")))?;
595        match entry {
596            Some(value) => {
597                let record: GuardRootRecord =
598                    serde_json::from_slice(value.value()).map_err(|e| {
599                        GuardStoreError::Corrupt {
600                            detail: format!("deserialize root record: {e}"),
601                        }
602                    })?;
603                Ok(Some(record))
604            }
605            None => Ok(None),
606        }
607    }
608
609    /// Save a single root record to the durable store.
610    pub fn save_root(&self, record: &GuardRootRecord) -> Result<(), GuardStoreError> {
611        let txn = self
612            .db
613            .begin_write()
614            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
615        {
616            let mut table = txn
617                .open_table(ROOTS_TABLE)
618                .map_err(|e| GuardStoreError::Io(format!("open roots table: {e}")))?;
619            let value = serde_json::to_vec(record)
620                .map_err(|e| GuardStoreError::Io(format!("serialize root record: {e}")))?;
621            table
622                .insert(record.canonical_path.as_slice(), value.as_slice())
623                .map_err(|e| GuardStoreError::Io(format!("insert root: {e}")))?;
624        }
625        txn.commit()
626            .map_err(|e| GuardStoreError::Io(format!("commit root: {e}")))?;
627        Ok(())
628    }
629
630    /// Remove a root record from the durable store.
631    pub fn remove_root(&self, canonical_path: &[u8]) -> Result<(), GuardStoreError> {
632        let txn = self
633            .db
634            .begin_write()
635            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
636        {
637            let mut table = txn
638                .open_table(ROOTS_TABLE)
639                .map_err(|e| GuardStoreError::Io(format!("open roots table: {e}")))?;
640            table
641                .remove(canonical_path)
642                .map_err(|e| GuardStoreError::Io(format!("remove root: {e}")))?;
643        }
644        txn.commit()
645            .map_err(|e| GuardStoreError::Io(format!("commit remove root: {e}")))?;
646        Ok(())
647    }
648
649    /// Load all clean attestations from the durable store.
650    pub fn load_attestations(&self) -> Result<Vec<GitCleanAttestation>, GuardStoreError> {
651        let txn = self
652            .db
653            .begin_read()
654            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
655        let table = match txn.open_table(ATTESTATIONS_TABLE) {
656            Ok(t) => t,
657            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
658            Err(e) => return Err(GuardStoreError::Io(format!("open attestations table: {e}"))),
659        };
660        let mut attestations = Vec::new();
661        for entry in table
662            .range::<&[u8]>(..)
663            .map_err(|e| GuardStoreError::Io(format!("iterate attestations: {e}")))?
664        {
665            let (_, value) =
666                entry.map_err(|e| GuardStoreError::Io(format!("read attestation entry: {e}")))?;
667            let att: GitCleanAttestation =
668                serde_json::from_slice(value.value()).map_err(|e| GuardStoreError::Corrupt {
669                    detail: format!("deserialize attestation: {e}"),
670                })?;
671            attestations.push(att);
672        }
673        Ok(attestations)
674    }
675
676    /// Save a clean attestation to the durable store.
677    pub fn save_attestation(&self, att: &GitCleanAttestation) -> Result<(), GuardStoreError> {
678        let key = attestation_key(att);
679        let txn = self
680            .db
681            .begin_write()
682            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
683        {
684            let mut table = txn
685                .open_table(ATTESTATIONS_TABLE)
686                .map_err(|e| GuardStoreError::Io(format!("open attestations table: {e}")))?;
687            let value = serde_json::to_vec(att)
688                .map_err(|e| GuardStoreError::Io(format!("serialize attestation: {e}")))?;
689            table
690                .insert(key.as_slice(), value.as_slice())
691                .map_err(|e| GuardStoreError::Io(format!("insert attestation: {e}")))?;
692        }
693        txn.commit()
694            .map_err(|e| GuardStoreError::Io(format!("commit attestation: {e}")))?;
695        Ok(())
696    }
697
698    /// Remove a clean attestation from the durable store.
699    pub fn remove_attestation(&self, att: &GitCleanAttestation) -> Result<(), GuardStoreError> {
700        let key = attestation_key(att);
701        let txn = self
702            .db
703            .begin_write()
704            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
705        {
706            let mut table = txn
707                .open_table(ATTESTATIONS_TABLE)
708                .map_err(|e| GuardStoreError::Io(format!("open attestations table: {e}")))?;
709            table
710                .remove(key.as_slice())
711                .map_err(|e| GuardStoreError::Io(format!("remove attestation: {e}")))?;
712        }
713        txn.commit()
714            .map_err(|e| GuardStoreError::Io(format!("commit remove attestation: {e}")))?;
715        Ok(())
716    }
717
718    /// Remove all attestations for a given detector digest.
719    pub fn clear_attestations_for_policy(
720        &self,
721        policy_short: &str,
722    ) -> Result<usize, GuardStoreError> {
723        let txn = self
724            .db
725            .begin_write()
726            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
727        let removed = {
728            let mut table = txn
729                .open_table(ATTESTATIONS_TABLE)
730                .map_err(|e| GuardStoreError::Io(format!("open attestations table: {e}")))?;
731            let prefix = policy_short.as_bytes();
732            let mut count = 0usize;
733            let keys_to_remove: Vec<Vec<u8>> = table
734                .range::<&[u8]>(..)
735                .map_err(|e| GuardStoreError::Io(format!("iterate attestations: {e}")))?
736                .filter_map(|entry| {
737                    let (key, _) = entry.ok()?;
738                    let k = key.value();
739                    // Key format: hash_algo_label || blob_oid || policy_short
740                    // Check if the key ends with the policy prefix.
741                    if k.ends_with(prefix) {
742                        Some(k.to_vec())
743                    } else {
744                        None
745                    }
746                })
747                .collect();
748            for key in keys_to_remove {
749                table
750                    .remove(key.as_slice())
751                    .map_err(|e| GuardStoreError::Io(format!("remove attestation: {e}")))?;
752                count += 1;
753            }
754            count
755        };
756        txn.commit()
757            .map_err(|e| GuardStoreError::Io(format!("commit clear attestations: {e}")))?;
758        Ok(removed)
759    }
760
761    /// Save a coverage gap for a root. The key is
762    /// canonical_path || 0x00 || blob_oid.
763    pub fn save_root_gap(
764        &self,
765        canonical_path: &[u8],
766        blob_oid: &str,
767        description: &str,
768    ) -> Result<(), GuardStoreError> {
769        let mut key = Vec::with_capacity(canonical_path.len() + 1 + blob_oid.len());
770        key.extend_from_slice(canonical_path);
771        key.push(0);
772        key.extend_from_slice(blob_oid.as_bytes());
773        let txn = self
774            .db
775            .begin_write()
776            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
777        {
778            let mut table = txn
779                .open_table(ROOT_GAPS_TABLE)
780                .map_err(|e| GuardStoreError::Io(format!("open root_gaps table: {e}")))?;
781            table
782                .insert(key.as_slice(), description.as_bytes())
783                .map_err(|e| GuardStoreError::Io(format!("insert root gap: {e}")))?;
784        }
785        txn.commit()
786            .map_err(|e| GuardStoreError::Io(format!("commit root gap: {e}")))?;
787        Ok(())
788    }
789
790    /// Load all coverage gaps for a root.
791    pub fn load_root_gaps(
792        &self,
793        canonical_path: &[u8],
794    ) -> Result<Vec<(String, String)>, GuardStoreError> {
795        let txn = self
796            .db
797            .begin_read()
798            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
799        let table = match txn.open_table(ROOT_GAPS_TABLE) {
800            Ok(t) => t,
801            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
802            Err(e) => return Err(GuardStoreError::Io(format!("open root_gaps table: {e}"))),
803        };
804        let prefix = canonical_path;
805        let mut gaps = Vec::new();
806        for entry in table
807            .range::<&[u8]>(..)
808            .map_err(|e| GuardStoreError::Io(format!("iterate root_gaps: {e}")))?
809        {
810            let (key, value) =
811                entry.map_err(|e| GuardStoreError::Io(format!("read root gap entry: {e}")))?;
812            let k = key.value();
813            if !k.starts_with(prefix) {
814                continue;
815            }
816            // Verify the null separator follows the prefix to avoid
817            // matching a root whose path is a prefix of another
818            // (e.g. /repo vs /repo/sub).
819            if k.len() <= prefix.len() || k[prefix.len()] != 0 {
820                continue;
821            }
822            // Extract blob_oid after the null separator.
823            let rest = &k[prefix.len() + 1..];
824            let blob_oid = String::from_utf8_lossy(rest).to_string();
825            let desc = String::from_utf8_lossy(value.value()).to_string();
826            gaps.push((blob_oid, desc));
827        }
828        Ok(gaps)
829    }
830
831    /// Remove all coverage gaps for a root.
832    pub fn clear_root_gaps(&self, canonical_path: &[u8]) -> Result<usize, GuardStoreError> {
833        let txn = self
834            .db
835            .begin_write()
836            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
837        let removed = {
838            let mut table = txn
839                .open_table(ROOT_GAPS_TABLE)
840                .map_err(|e| GuardStoreError::Io(format!("open root_gaps table: {e}")))?;
841            let prefix = canonical_path;
842            let keys_to_remove: Vec<Vec<u8>> = table
843                .range::<&[u8]>(..)
844                .map_err(|e| GuardStoreError::Io(format!("iterate root_gaps: {e}")))?
845                .filter_map(|entry| {
846                    let (key, _) = entry.ok()?;
847                    let k = key.value();
848                    if k.starts_with(prefix) && k.len() > prefix.len() && k[prefix.len()] == 0 {
849                        Some(k.to_vec())
850                    } else {
851                        None
852                    }
853                })
854                .collect();
855            let count = keys_to_remove.len();
856            for key in keys_to_remove {
857                table
858                    .remove(key.as_slice())
859                    .map_err(|e| GuardStoreError::Io(format!("remove root gap: {e}")))?;
860            }
861            count
862        };
863        txn.commit()
864            .map_err(|e| GuardStoreError::Io(format!("commit clear root gaps: {e}")))?;
865        Ok(removed)
866    }
867
868    /// Mark the service state as unclean (startup). This is set before
869    /// the daemon begins serving requests and cleared after all state
870    /// is flushed during a clean shutdown.
871    pub fn mark_unclean_shutdown(&self) -> Result<(), GuardStoreError> {
872        let txn = self
873            .db
874            .begin_write()
875            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
876        {
877            let mut table = txn
878                .open_table(SERVICE_STATE_TABLE)
879                .map_err(|e| GuardStoreError::Io(format!("open service_state table: {e}")))?;
880            table
881                .insert("clean_shutdown", 0u8)
882                .map_err(|e| GuardStoreError::Io(format!("write clean_shutdown: {e}")))?;
883        }
884        txn.commit()
885            .map_err(|e| GuardStoreError::Io(format!("commit service state: {e}")))?;
886        Ok(())
887    }
888
889    /// Mark the service state as clean (graceful shutdown). Called after
890    /// all root records and attestations have been flushed.
891    pub fn mark_clean_shutdown(&self) -> Result<(), GuardStoreError> {
892        let txn = self
893            .db
894            .begin_write()
895            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
896        {
897            let mut table = txn
898                .open_table(SERVICE_STATE_TABLE)
899                .map_err(|e| GuardStoreError::Io(format!("open service_state table: {e}")))?;
900            table
901                .insert("clean_shutdown", 1u8)
902                .map_err(|e| GuardStoreError::Io(format!("write clean_shutdown: {e}")))?;
903        }
904        txn.commit()
905            .map_err(|e| GuardStoreError::Io(format!("commit service state: {e}")))?;
906        Ok(())
907    }
908
909    /// Check whether the last shutdown was clean.
910    /// Returns `true` if the clean_shutdown marker is set to 1,
911    /// `false` if it is 0 or absent (treat absent as unclean).
912    pub fn was_clean_shutdown(&self) -> Result<bool, GuardStoreError> {
913        let txn = self
914            .db
915            .begin_read()
916            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
917        let table = match txn.open_table(SERVICE_STATE_TABLE) {
918            Ok(t) => t,
919            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(false),
920            Err(e) => {
921                return Err(GuardStoreError::Io(format!(
922                    "open service_state table: {e}"
923                )))
924            }
925        };
926        let value = table
927            .get("clean_shutdown")
928            .map_err(|e| GuardStoreError::Io(format!("read clean_shutdown: {e}")))?;
929        Ok(value.map(|v| v.value() == 1u8).unwrap_or(false))
930    }
931
932    /// Atomically save a root record and its coverage gaps in one
933    /// transaction. This ensures the root state and gap records are
934    /// consistent across crashes.
935    pub fn save_root_with_gaps(
936        &self,
937        record: &GuardRootRecord,
938        gaps: &[(String, String)],
939    ) -> Result<(), GuardStoreError> {
940        let txn = self
941            .db
942            .begin_write()
943            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
944        {
945            let mut roots = txn
946                .open_table(ROOTS_TABLE)
947                .map_err(|e| GuardStoreError::Io(format!("open roots table: {e}")))?;
948            let value = serde_json::to_vec(record)
949                .map_err(|e| GuardStoreError::Io(format!("serialize root record: {e}")))?;
950            roots
951                .insert(record.canonical_path.as_slice(), value.as_slice())
952                .map_err(|e| GuardStoreError::Io(format!("insert root: {e}")))?;
953
954            // Clear old gaps for this root, then insert new ones.
955            let mut gaps_table = txn
956                .open_table(ROOT_GAPS_TABLE)
957                .map_err(|e| GuardStoreError::Io(format!("open root_gaps table: {e}")))?;
958            let prefix = record.canonical_path.as_slice();
959            let keys_to_remove: Vec<Vec<u8>> = gaps_table
960                .range::<&[u8]>(..)
961                .map_err(|e| GuardStoreError::Io(format!("iterate root_gaps: {e}")))?
962                .filter_map(|entry| {
963                    let (key, _) = entry.ok()?;
964                    let k = key.value();
965                    if k.starts_with(prefix) && k.len() > prefix.len() && k[prefix.len()] == 0 {
966                        Some(k.to_vec())
967                    } else {
968                        None
969                    }
970                })
971                .collect();
972            for key in keys_to_remove {
973                gaps_table
974                    .remove(key.as_slice())
975                    .map_err(|e| GuardStoreError::Io(format!("remove old root gap: {e}")))?;
976            }
977            for (blob_oid, desc) in gaps {
978                let mut key = Vec::with_capacity(prefix.len() + 1 + blob_oid.len());
979                key.extend_from_slice(prefix);
980                key.push(0);
981                key.extend_from_slice(blob_oid.as_bytes());
982                gaps_table
983                    .insert(key.as_slice(), desc.as_bytes())
984                    .map_err(|e| GuardStoreError::Io(format!("insert root gap: {e}")))?;
985            }
986        }
987        txn.commit()
988            .map_err(|e| GuardStoreError::Io(format!("commit root with gaps: {e}")))?;
989        Ok(())
990    }
991}
992
993/// Build the durable key for a clean attestation:
994/// hash_algorithm_label || 0x00 || blob_oid_hex || 0x00 || detector_digest
995fn attestation_key(att: &GitCleanAttestation) -> Vec<u8> {
996    let label = match att.hash_algorithm {
997        GitHashAlgorithm::Sha1 => "sha1",
998        GitHashAlgorithm::Sha256 => "sha256",
999    };
1000    let mut key = Vec::with_capacity(label.len() + 1 + att.blob_oid.len() + 1 + 64);
1001    key.extend_from_slice(label.as_bytes());
1002    key.push(0);
1003    key.extend_from_slice(att.blob_oid.as_bytes());
1004    key.push(0);
1005    key.extend_from_slice(att.policy_identity.detector_digest.as_bytes());
1006    key
1007}