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    /// Current number of entries in the hot index.
153    pub fn len(&self) -> usize {
154        self.cache.lock().len()
155    }
156
157    /// Whether the hot index is empty.
158    pub fn is_empty(&self) -> bool {
159        self.cache.lock().is_empty()
160    }
161
162    /// Configured memory budget in bytes.
163    pub fn budget(&self) -> usize {
164        self.budget
165    }
166
167    /// Clear all entries from the hot index.
168    pub fn clear(&self) {
169        self.cache.lock().clear();
170    }
171}
172
173impl Default for HotAttestationIndex {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179// ── Store schema versioning ──────────────────────────────────────────────
180
181/// Metadata row in the durable store.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct StoreMeta {
184    /// Schema version of the store.
185    pub schema_version: u32,
186    /// Unique store identifier.
187    pub store_uuid: [u8; 16],
188    /// KeyHog version that created the store.
189    pub created_version: String,
190    /// Last schema version that was successfully migrated.
191    pub last_successful_migration: u32,
192}
193
194/// Error returned by the guard store.
195#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
196pub enum GuardStoreError {
197    /// The store schema version is newer than this binary supports.
198    #[error("guard store schema version {found} is newer than supported {supported}; upgrade keyhog or run `keyhog guard rebuild <root>`")]
199    SchemaTooNew {
200        /// Schema version found on disk.
201        found: u32,
202        /// Maximum schema version this binary supports.
203        supported: u32,
204    },
205    /// The store schema version is older than this binary supports and
206    /// cannot be migrated.
207    #[error("guard store schema version {found} is no longer supported; run `keyhog guard rebuild <root>` to recreate state")]
208    SchemaObsolete {
209        /// Schema version found on disk.
210        found: u32,
211    },
212    /// The store file is corrupt or truncated.
213    #[error("guard store is corrupt: {detail}; run `keyhog guard rebuild <root>`")]
214    Corrupt {
215        /// Human-readable corruption detail.
216        detail: String,
217    },
218    /// The store path has unsafe ownership or permissions.
219    #[error("guard store path is unsafe: {detail}")]
220    UnsafePath {
221        /// Human-readable safety violation detail.
222        detail: String,
223    },
224    /// An I/O error occurred.
225    #[error("guard store I/O error: {0}")]
226    Io(String),
227    /// The store was not started cleanly (previous process may not have
228    /// flushed).
229    #[error("guard store was not closed cleanly; run `keyhog guard reconcile <root>`")]
230    UncleanShutdown,
231}
232
233/// Check whether a found schema version is compatible with this binary.
234pub fn check_schema_version(found: u32) -> Result<(), GuardStoreError> {
235    if found > GUARD_SCHEMA_VERSION {
236        return Err(GuardStoreError::SchemaTooNew {
237            found,
238            supported: GUARD_SCHEMA_VERSION,
239        });
240    }
241    if found < 1 {
242        return Err(GuardStoreError::SchemaObsolete { found });
243    }
244    // v1 is the initial schema; no migrations exist yet.
245    Ok(())
246}
247
248// ── Root registry ────────────────────────────────────────────────────────
249
250/// In-memory root registry. The durable store persists these records; this
251/// holds the live state for the daemon scheduler.
252#[derive(Debug, Default)]
253pub struct RootRegistry {
254    roots: std::collections::HashMap<Vec<u8>, GuardRootRecord>,
255}
256
257impl RootRegistry {
258    /// Create an empty registry.
259    pub fn new() -> Self {
260        Self::default()
261    }
262
263    /// Register a new root. Returns the initial record in `Stopped` state.
264    pub fn register(
265        &mut self,
266        canonical_path: Vec<u8>,
267        filesystem_identity: crate::guard_state::FilesystemIdentity,
268        mode: crate::guard_state::GuardRootMode,
269    ) -> GuardRootRecord {
270        let record = GuardRootRecord {
271            canonical_path: canonical_path.clone(),
272            filesystem_identity,
273            mode,
274            state: GuardRootState::Stopped,
275            terminal_sequence: 0,
276            accepted_event_sequence: 0,
277            completed_event_sequence: 0,
278            initial_reconciliation_time: None,
279            last_reconciliation_time: None,
280            backend_route_label: String::new(),
281            last_receipt: None,
282        };
283        self.roots.insert(canonical_path, record.clone());
284        record
285    }
286
287    /// Insert a fully-formed root record. Used when restoring from
288    /// the durable store, where the complete record state must be
289    /// preserved rather than initialized to `Stopped`.
290    pub fn insert_record(&mut self, record: GuardRootRecord) {
291        self.roots.insert(record.canonical_path.clone(), record);
292    }
293
294    /// Look up a root by canonical path bytes.
295    pub fn get(&self, canonical_path: &[u8]) -> Option<&GuardRootRecord> {
296        self.roots.get(canonical_path)
297    }
298
299    /// Look up a root by canonical path bytes for mutation.
300    pub fn get_mut(&mut self, canonical_path: &[u8]) -> Option<&mut GuardRootRecord> {
301        self.roots.get_mut(canonical_path)
302    }
303
304    /// Remove a root from the registry.
305    pub fn remove(&mut self, canonical_path: &[u8]) -> Option<GuardRootRecord> {
306        self.roots.remove(canonical_path)
307    }
308
309    /// List all registered roots.
310    pub fn list(&self) -> Vec<&GuardRootRecord> {
311        self.roots.values().collect()
312    }
313
314    /// Number of registered roots.
315    pub fn len(&self) -> usize {
316        self.roots.len()
317    }
318
319    /// Whether the registry is empty.
320    pub fn is_empty(&self) -> bool {
321        self.roots.is_empty()
322    }
323
324    /// Count roots by state.
325    pub fn count_by_state(&self, state: GuardRootState) -> usize {
326        self.roots.values().filter(|r| r.state == state).count()
327    }
328}
329
330// ── Durable store (redb) ─────────────────────────────────────────────────
331
332/// redb table definition for the metadata singleton.
333const META_TABLE: redb::TableDefinition<&str, &[u8]> = redb::TableDefinition::new("meta");
334
335/// redb table definition for root records, keyed by canonical path bytes.
336const ROOTS_TABLE: redb::TableDefinition<&[u8], &[u8]> = redb::TableDefinition::new("roots");
337
338/// redb table definition for Git clean attestations, keyed by
339/// (hash_algorithm_label || blob_oid_hex || policy_short_digest).
340const ATTESTATIONS_TABLE: redb::TableDefinition<&[u8], &[u8]> =
341    redb::TableDefinition::new("git_clean_attestations");
342
343/// redb table definition for root coverage gaps, keyed by
344/// (canonical_path || 0x00 || blob_oid). Value is the gap description.
345const ROOT_GAPS_TABLE: redb::TableDefinition<&[u8], &[u8]> =
346    redb::TableDefinition::new("root_gaps");
347
348/// redb table definition for the service state singleton.
349/// Key is "clean_shutdown", value is 1 (clean) or 0 (unclean).
350const SERVICE_STATE_TABLE: redb::TableDefinition<&str, u8> =
351    redb::TableDefinition::new("service_state");
352
353/// Durable guard state store backed by redb.
354///
355/// Persists root records and clean attestations to a single file.
356/// The in-memory `RootRegistry` and `HotAttestationIndex` remain the
357/// hot path; this store provides crash recovery across daemon restarts.
358pub struct DurableGuardStore {
359    db: redb::Database,
360    path: std::path::PathBuf,
361}
362
363impl DurableGuardStore {
364    /// Open or create the durable store at the given path.
365    ///
366    /// The path is validated for safety: it must not be a symlink, and
367    /// the file (once created) is set to owner-only permissions (0600).
368    /// The parent directory is created with 0700 permissions if needed.
369    pub fn open(path: &std::path::Path) -> Result<Self, GuardStoreError> {
370        // Reject symlinked state paths. A symlink could point outside the
371        // intended state directory, leaking guard state to an attacker.
372        if path.exists() {
373            let meta = std::fs::symlink_metadata(path)
374                .map_err(|e| GuardStoreError::Io(format!("stat guard store path: {e}")))?;
375            if meta.file_type().is_symlink() {
376                return Err(GuardStoreError::Io(
377                    "guard store path is a symlink; refusing to open".to_string(),
378                ));
379            }
380        }
381        // Create parent directory with owner-only permissions.
382        if let Some(parent) = path.parent() {
383            if !parent.exists() {
384                std::fs::create_dir_all(parent)
385                    .map_err(|e| GuardStoreError::Io(format!("create guard store dir: {e}")))?;
386                #[cfg(unix)]
387                {
388                    use std::os::unix::fs::PermissionsExt;
389                    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
390                        .map_err(|e| {
391                            GuardStoreError::Io(format!("set guard store dir perms: {e}"))
392                        })?;
393                }
394            }
395        }
396        let db = redb::Database::create(path)
397            .map_err(|e| GuardStoreError::Io(format!("open guard store: {e}")))?;
398        // Enforce owner-only file permissions on the store file.
399        #[cfg(unix)]
400        {
401            use std::os::unix::fs::PermissionsExt;
402            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
403                .map_err(|e| GuardStoreError::Io(format!("set guard store perms: {e}")))?;
404        }
405        let store = Self {
406            db,
407            path: path.to_path_buf(),
408        };
409        store.ensure_schema()?;
410        Ok(store)
411    }
412
413    /// Return the store path.
414    pub fn path(&self) -> &std::path::Path {
415        &self.path
416    }
417
418    /// Initialize or verify the schema version in the meta table.
419    fn ensure_schema(&self) -> Result<(), GuardStoreError> {
420        let txn = self
421            .db
422            .begin_write()
423            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
424        {
425            let mut meta = txn
426                .open_table(META_TABLE)
427                .map_err(|e| GuardStoreError::Io(format!("open meta table: {e}")))?;
428            let found_version: Option<u32> = meta
429                .get("schema_version")
430                .map_err(|e| GuardStoreError::Io(format!("read schema_version: {e}")))?
431                .map(|guard| {
432                    let bytes: &[u8] = guard.value();
433                    u32::from_le_bytes(bytes.try_into().unwrap_or([0, 0, 0, 0]))
434                });
435            match found_version {
436                Some(version) => {
437                    check_schema_version(version)?;
438                }
439                None => {
440                    // First open: write schema version.
441                    let version_bytes = GUARD_SCHEMA_VERSION.to_le_bytes();
442                    meta.insert("schema_version", version_bytes.as_slice())
443                        .map_err(|e| GuardStoreError::Io(format!("write schema_version: {e}")))?;
444                }
445            }
446        }
447        // Create all tables so they exist for reads even before first write.
448        {
449            let _ = txn
450                .open_table(ROOTS_TABLE)
451                .map_err(|e| GuardStoreError::Io(format!("create roots table: {e}")))?;
452            let _ = txn
453                .open_table(ATTESTATIONS_TABLE)
454                .map_err(|e| GuardStoreError::Io(format!("create attestations table: {e}")))?;
455            let _ = txn
456                .open_table(ROOT_GAPS_TABLE)
457                .map_err(|e| GuardStoreError::Io(format!("create root_gaps table: {e}")))?;
458            let _ = txn
459                .open_table(SERVICE_STATE_TABLE)
460                .map_err(|e| GuardStoreError::Io(format!("create service_state table: {e}")))?;
461        }
462        txn.commit()
463            .map_err(|e| GuardStoreError::Io(format!("commit schema: {e}")))?;
464        Ok(())
465    }
466
467    /// Load all root records from the durable store into a registry.
468    pub fn load_roots(&self) -> Result<RootRegistry, GuardStoreError> {
469        let txn = self
470            .db
471            .begin_read()
472            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
473        let table = txn
474            .open_table(ROOTS_TABLE)
475            .map_err(|e| GuardStoreError::Io(format!("open roots table: {e}")))?;
476        let mut registry = RootRegistry::new();
477        for entry in table
478            .range::<&[u8]>(..)
479            .map_err(|e| GuardStoreError::Io(format!("iterate roots: {e}")))?
480        {
481            let (key, value) =
482                entry.map_err(|e| GuardStoreError::Io(format!("read root entry: {e}")))?;
483            let record: GuardRootRecord =
484                serde_json::from_slice(value.value()).map_err(|e| GuardStoreError::Corrupt {
485                    detail: format!("deserialize root record: {e}"),
486                })?;
487            registry.roots.insert(key.value().to_vec(), record);
488        }
489        Ok(registry)
490    }
491
492    /// Save a single root record to the durable store.
493    pub fn save_root(&self, record: &GuardRootRecord) -> Result<(), GuardStoreError> {
494        let txn = self
495            .db
496            .begin_write()
497            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
498        {
499            let mut table = txn
500                .open_table(ROOTS_TABLE)
501                .map_err(|e| GuardStoreError::Io(format!("open roots table: {e}")))?;
502            let value = serde_json::to_vec(record)
503                .map_err(|e| GuardStoreError::Io(format!("serialize root record: {e}")))?;
504            table
505                .insert(record.canonical_path.as_slice(), value.as_slice())
506                .map_err(|e| GuardStoreError::Io(format!("insert root: {e}")))?;
507        }
508        txn.commit()
509            .map_err(|e| GuardStoreError::Io(format!("commit root: {e}")))?;
510        Ok(())
511    }
512
513    /// Remove a root record from the durable store.
514    pub fn remove_root(&self, canonical_path: &[u8]) -> Result<(), GuardStoreError> {
515        let txn = self
516            .db
517            .begin_write()
518            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
519        {
520            let mut table = txn
521                .open_table(ROOTS_TABLE)
522                .map_err(|e| GuardStoreError::Io(format!("open roots table: {e}")))?;
523            table
524                .remove(canonical_path)
525                .map_err(|e| GuardStoreError::Io(format!("remove root: {e}")))?;
526        }
527        txn.commit()
528            .map_err(|e| GuardStoreError::Io(format!("commit remove root: {e}")))?;
529        Ok(())
530    }
531
532    /// Load all clean attestations from the durable store.
533    pub fn load_attestations(&self) -> Result<Vec<GitCleanAttestation>, GuardStoreError> {
534        let txn = self
535            .db
536            .begin_read()
537            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
538        let table = txn
539            .open_table(ATTESTATIONS_TABLE)
540            .map_err(|e| GuardStoreError::Io(format!("open attestations table: {e}")))?;
541        let mut attestations = Vec::new();
542        for entry in table
543            .range::<&[u8]>(..)
544            .map_err(|e| GuardStoreError::Io(format!("iterate attestations: {e}")))?
545        {
546            let (_, value) =
547                entry.map_err(|e| GuardStoreError::Io(format!("read attestation entry: {e}")))?;
548            let att: GitCleanAttestation =
549                serde_json::from_slice(value.value()).map_err(|e| GuardStoreError::Corrupt {
550                    detail: format!("deserialize attestation: {e}"),
551                })?;
552            attestations.push(att);
553        }
554        Ok(attestations)
555    }
556
557    /// Save a clean attestation to the durable store.
558    pub fn save_attestation(&self, att: &GitCleanAttestation) -> Result<(), GuardStoreError> {
559        let key = attestation_key(att);
560        let txn = self
561            .db
562            .begin_write()
563            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
564        {
565            let mut table = txn
566                .open_table(ATTESTATIONS_TABLE)
567                .map_err(|e| GuardStoreError::Io(format!("open attestations table: {e}")))?;
568            let value = serde_json::to_vec(att)
569                .map_err(|e| GuardStoreError::Io(format!("serialize attestation: {e}")))?;
570            table
571                .insert(key.as_slice(), value.as_slice())
572                .map_err(|e| GuardStoreError::Io(format!("insert attestation: {e}")))?;
573        }
574        txn.commit()
575            .map_err(|e| GuardStoreError::Io(format!("commit attestation: {e}")))?;
576        Ok(())
577    }
578
579    /// Remove a clean attestation from the durable store.
580    pub fn remove_attestation(&self, att: &GitCleanAttestation) -> Result<(), GuardStoreError> {
581        let key = attestation_key(att);
582        let txn = self
583            .db
584            .begin_write()
585            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
586        {
587            let mut table = txn
588                .open_table(ATTESTATIONS_TABLE)
589                .map_err(|e| GuardStoreError::Io(format!("open attestations table: {e}")))?;
590            table
591                .remove(key.as_slice())
592                .map_err(|e| GuardStoreError::Io(format!("remove attestation: {e}")))?;
593        }
594        txn.commit()
595            .map_err(|e| GuardStoreError::Io(format!("commit remove attestation: {e}")))?;
596        Ok(())
597    }
598
599    /// Remove all attestations for a given detector digest.
600    pub fn clear_attestations_for_policy(
601        &self,
602        policy_short: &str,
603    ) -> Result<usize, GuardStoreError> {
604        let txn = self
605            .db
606            .begin_write()
607            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
608        let removed = {
609            let mut table = txn
610                .open_table(ATTESTATIONS_TABLE)
611                .map_err(|e| GuardStoreError::Io(format!("open attestations table: {e}")))?;
612            let prefix = policy_short.as_bytes();
613            let mut count = 0usize;
614            let keys_to_remove: Vec<Vec<u8>> = table
615                .range::<&[u8]>(..)
616                .map_err(|e| GuardStoreError::Io(format!("iterate attestations: {e}")))?
617                .filter_map(|entry| {
618                    let (key, _) = entry.ok()?;
619                    let k = key.value();
620                    // Key format: hash_algo_label || blob_oid || policy_short
621                    // Check if the key ends with the policy prefix.
622                    if k.ends_with(prefix) {
623                        Some(k.to_vec())
624                    } else {
625                        None
626                    }
627                })
628                .collect();
629            for key in keys_to_remove {
630                table
631                    .remove(key.as_slice())
632                    .map_err(|e| GuardStoreError::Io(format!("remove attestation: {e}")))?;
633                count += 1;
634            }
635            count
636        };
637        txn.commit()
638            .map_err(|e| GuardStoreError::Io(format!("commit clear attestations: {e}")))?;
639        Ok(removed)
640    }
641
642    /// Save a coverage gap for a root. The key is
643    /// canonical_path || 0x00 || blob_oid.
644    pub fn save_root_gap(
645        &self,
646        canonical_path: &[u8],
647        blob_oid: &str,
648        description: &str,
649    ) -> Result<(), GuardStoreError> {
650        let mut key = Vec::with_capacity(canonical_path.len() + 1 + blob_oid.len());
651        key.extend_from_slice(canonical_path);
652        key.push(0);
653        key.extend_from_slice(blob_oid.as_bytes());
654        let txn = self
655            .db
656            .begin_write()
657            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
658        {
659            let mut table = txn
660                .open_table(ROOT_GAPS_TABLE)
661                .map_err(|e| GuardStoreError::Io(format!("open root_gaps table: {e}")))?;
662            table
663                .insert(key.as_slice(), description.as_bytes())
664                .map_err(|e| GuardStoreError::Io(format!("insert root gap: {e}")))?;
665        }
666        txn.commit()
667            .map_err(|e| GuardStoreError::Io(format!("commit root gap: {e}")))?;
668        Ok(())
669    }
670
671    /// Load all coverage gaps for a root.
672    pub fn load_root_gaps(
673        &self,
674        canonical_path: &[u8],
675    ) -> Result<Vec<(String, String)>, GuardStoreError> {
676        let txn = self
677            .db
678            .begin_read()
679            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
680        let table = txn
681            .open_table(ROOT_GAPS_TABLE)
682            .map_err(|e| GuardStoreError::Io(format!("open root_gaps table: {e}")))?;
683        let prefix = canonical_path;
684        let mut gaps = Vec::new();
685        for entry in table
686            .range::<&[u8]>(..)
687            .map_err(|e| GuardStoreError::Io(format!("iterate root_gaps: {e}")))?
688        {
689            let (key, value) =
690                entry.map_err(|e| GuardStoreError::Io(format!("read root gap entry: {e}")))?;
691            let k = key.value();
692            if !k.starts_with(prefix) {
693                continue;
694            }
695            // Verify the null separator follows the prefix to avoid
696            // matching a root whose path is a prefix of another
697            // (e.g. /repo vs /repo/sub).
698            if k.len() <= prefix.len() || k[prefix.len()] != 0 {
699                continue;
700            }
701            // Extract blob_oid after the null separator.
702            let rest = &k[prefix.len() + 1..];
703            let blob_oid = String::from_utf8_lossy(rest).to_string();
704            let desc = String::from_utf8_lossy(value.value()).to_string();
705            gaps.push((blob_oid, desc));
706        }
707        Ok(gaps)
708    }
709
710    /// Remove all coverage gaps for a root.
711    pub fn clear_root_gaps(&self, canonical_path: &[u8]) -> Result<usize, GuardStoreError> {
712        let txn = self
713            .db
714            .begin_write()
715            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
716        let removed = {
717            let mut table = txn
718                .open_table(ROOT_GAPS_TABLE)
719                .map_err(|e| GuardStoreError::Io(format!("open root_gaps table: {e}")))?;
720            let prefix = canonical_path;
721            let keys_to_remove: Vec<Vec<u8>> = table
722                .range::<&[u8]>(..)
723                .map_err(|e| GuardStoreError::Io(format!("iterate root_gaps: {e}")))?
724                .filter_map(|entry| {
725                    let (key, _) = entry.ok()?;
726                    let k = key.value();
727                    if k.starts_with(prefix) && k.len() > prefix.len() && k[prefix.len()] == 0 {
728                        Some(k.to_vec())
729                    } else {
730                        None
731                    }
732                })
733                .collect();
734            let count = keys_to_remove.len();
735            for key in keys_to_remove {
736                table
737                    .remove(key.as_slice())
738                    .map_err(|e| GuardStoreError::Io(format!("remove root gap: {e}")))?;
739            }
740            count
741        };
742        txn.commit()
743            .map_err(|e| GuardStoreError::Io(format!("commit clear root gaps: {e}")))?;
744        Ok(removed)
745    }
746
747    /// Mark the service state as unclean (startup). This is set before
748    /// the daemon begins serving requests and cleared after all state
749    /// is flushed during a clean shutdown.
750    pub fn mark_unclean_shutdown(&self) -> Result<(), GuardStoreError> {
751        let txn = self
752            .db
753            .begin_write()
754            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
755        {
756            let mut table = txn
757                .open_table(SERVICE_STATE_TABLE)
758                .map_err(|e| GuardStoreError::Io(format!("open service_state table: {e}")))?;
759            table
760                .insert("clean_shutdown", 0u8)
761                .map_err(|e| GuardStoreError::Io(format!("write clean_shutdown: {e}")))?;
762        }
763        txn.commit()
764            .map_err(|e| GuardStoreError::Io(format!("commit service state: {e}")))?;
765        Ok(())
766    }
767
768    /// Mark the service state as clean (graceful shutdown). Called after
769    /// all root records and attestations have been flushed.
770    pub fn mark_clean_shutdown(&self) -> Result<(), GuardStoreError> {
771        let txn = self
772            .db
773            .begin_write()
774            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
775        {
776            let mut table = txn
777                .open_table(SERVICE_STATE_TABLE)
778                .map_err(|e| GuardStoreError::Io(format!("open service_state table: {e}")))?;
779            table
780                .insert("clean_shutdown", 1u8)
781                .map_err(|e| GuardStoreError::Io(format!("write clean_shutdown: {e}")))?;
782        }
783        txn.commit()
784            .map_err(|e| GuardStoreError::Io(format!("commit service state: {e}")))?;
785        Ok(())
786    }
787
788    /// Check whether the last shutdown was clean.
789    /// Returns `true` if the clean_shutdown marker is set to 1,
790    /// `false` if it is 0 or absent (treat absent as unclean).
791    pub fn was_clean_shutdown(&self) -> Result<bool, GuardStoreError> {
792        let txn = self
793            .db
794            .begin_read()
795            .map_err(|e| GuardStoreError::Io(format!("begin read: {e}")))?;
796        let table = txn
797            .open_table(SERVICE_STATE_TABLE)
798            .map_err(|e| GuardStoreError::Io(format!("open service_state table: {e}")))?;
799        let value = table
800            .get("clean_shutdown")
801            .map_err(|e| GuardStoreError::Io(format!("read clean_shutdown: {e}")))?;
802        Ok(value.map(|v| v.value() == 1u8).unwrap_or(false))
803    }
804
805    /// Atomically save a root record and its coverage gaps in one
806    /// transaction. This ensures the root state and gap records are
807    /// consistent across crashes.
808    pub fn save_root_with_gaps(
809        &self,
810        record: &GuardRootRecord,
811        gaps: &[(String, String)],
812    ) -> Result<(), GuardStoreError> {
813        let txn = self
814            .db
815            .begin_write()
816            .map_err(|e| GuardStoreError::Io(format!("begin write: {e}")))?;
817        {
818            let mut roots = txn
819                .open_table(ROOTS_TABLE)
820                .map_err(|e| GuardStoreError::Io(format!("open roots table: {e}")))?;
821            let value = serde_json::to_vec(record)
822                .map_err(|e| GuardStoreError::Io(format!("serialize root record: {e}")))?;
823            roots
824                .insert(record.canonical_path.as_slice(), value.as_slice())
825                .map_err(|e| GuardStoreError::Io(format!("insert root: {e}")))?;
826
827            // Clear old gaps for this root, then insert new ones.
828            let mut gaps_table = txn
829                .open_table(ROOT_GAPS_TABLE)
830                .map_err(|e| GuardStoreError::Io(format!("open root_gaps table: {e}")))?;
831            let prefix = record.canonical_path.as_slice();
832            let keys_to_remove: Vec<Vec<u8>> = gaps_table
833                .range::<&[u8]>(..)
834                .map_err(|e| GuardStoreError::Io(format!("iterate root_gaps: {e}")))?
835                .filter_map(|entry| {
836                    let (key, _) = entry.ok()?;
837                    let k = key.value();
838                    if k.starts_with(prefix) && k.len() > prefix.len() && k[prefix.len()] == 0 {
839                        Some(k.to_vec())
840                    } else {
841                        None
842                    }
843                })
844                .collect();
845            for key in keys_to_remove {
846                gaps_table
847                    .remove(key.as_slice())
848                    .map_err(|e| GuardStoreError::Io(format!("remove old root gap: {e}")))?;
849            }
850            for (blob_oid, desc) in gaps {
851                let mut key = Vec::with_capacity(prefix.len() + 1 + blob_oid.len());
852                key.extend_from_slice(prefix);
853                key.push(0);
854                key.extend_from_slice(blob_oid.as_bytes());
855                gaps_table
856                    .insert(key.as_slice(), desc.as_bytes())
857                    .map_err(|e| GuardStoreError::Io(format!("insert root gap: {e}")))?;
858            }
859        }
860        txn.commit()
861            .map_err(|e| GuardStoreError::Io(format!("commit root with gaps: {e}")))?;
862        Ok(())
863    }
864}
865
866/// Build the durable key for a clean attestation:
867/// hash_algorithm_label || 0x00 || blob_oid_hex || 0x00 || detector_digest
868fn attestation_key(att: &GitCleanAttestation) -> Vec<u8> {
869    let label = match att.hash_algorithm {
870        GitHashAlgorithm::Sha1 => "sha1",
871        GitHashAlgorithm::Sha256 => "sha256",
872    };
873    let mut key = Vec::with_capacity(label.len() + 1 + att.blob_oid.len() + 1 + 64);
874    key.extend_from_slice(label.as_bytes());
875    key.push(0);
876    key.extend_from_slice(att.blob_oid.as_bytes());
877    key.push(0);
878    key.extend_from_slice(att.policy_identity.detector_digest.as_bytes());
879    key
880}