Skip to main content

holger_intel/
db.rs

1//! The persistent local advisory database on the secure side.
2//!
3//! ```text
4//! <db_dir>/
5//!   installed.json               what is live: sequence, snapshot date, digests
6//!   advisories-<sequence>.json   the advisory set of that generation
7//!   state/                       the anti-rollback floor (update-bundle RollbackStore)
8//!   bundles/<bundle_id>.holger.znippy   the verified bundle, kept for re-verify
9//!   receipts/<bundle_id>.json    the import receipt
10//! ```
11//!
12//! A new generation is written beside the live one and goes live in ONE atomic
13//! rename of `installed.json`, so a reader sees either the old set or the new
14//! one, never half of either. `installed.json` records the set's SHA-256 and a
15//! load refuses a set that no longer matches it: a database edited in place is
16//! an error, not an answer.
17
18use std::path::{Path, PathBuf};
19
20use anyhow::{bail, Context, Result};
21use chrono::{DateTime, Utc};
22use holger_update_bundle::atomic_write;
23use holger_update_bundle::digest::sha256_hex;
24use serde::{Deserialize, Serialize};
25
26use crate::set::AdvisorySet;
27
28/// The schema of `installed.json`.
29pub const INSTALLED_SCHEMA: &str = "holger.intel-installed/v1";
30
31/// What is live.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct InstalledIntel {
34    pub schema: String,
35    pub bundle_id: String,
36    pub sequence: u64,
37    pub supplier: String,
38    /// The bundle's `created_at`: the date the advisories are as of. Staleness
39    /// counts from here.
40    pub snapshot_at: DateTime<Utc>,
41    pub imported_at: DateTime<Utc>,
42    pub candidate_digest: String,
43    pub release_signer_fingerprint: String,
44    /// File name of the live set, inside the database directory.
45    pub advisories_file: String,
46    pub advisories_sha256: String,
47    pub advisories: u64,
48}
49
50impl InstalledIntel {
51    /// `seq 42 dated 2026-10-19`.
52    pub fn describe(&self) -> String {
53        format!(
54            "seq {} dated {}",
55            self.sequence,
56            self.snapshot_at.format("%Y-%m-%d")
57        )
58    }
59}
60
61/// The database directory.
62#[derive(Debug, Clone)]
63pub struct LocalIntelDb {
64    root: PathBuf,
65}
66
67impl LocalIntelDb {
68    pub fn open(root: impl AsRef<Path>) -> Self {
69        Self {
70            root: root.as_ref().to_path_buf(),
71        }
72    }
73
74    pub fn root(&self) -> &Path {
75        &self.root
76    }
77    pub fn state_dir(&self) -> PathBuf {
78        self.root.join("state")
79    }
80    pub fn bundles_dir(&self) -> PathBuf {
81        self.root.join("bundles")
82    }
83    pub fn receipts_dir(&self) -> PathBuf {
84        self.root.join("receipts")
85    }
86    pub fn lock_path(&self) -> PathBuf {
87        self.root.join(".intel-import.lock")
88    }
89    fn installed_path(&self) -> PathBuf {
90        self.root.join("installed.json")
91    }
92
93    /// The file a generation's set lives in.
94    pub fn generation_file(sequence: u64) -> String {
95        format!("advisories-{sequence}.json")
96    }
97
98    /// What is live, or `None` for a database nothing was ever imported into.
99    pub fn installed(&self) -> Result<Option<InstalledIntel>> {
100        let p = self.installed_path();
101        let bytes = match std::fs::read(&p) {
102            Ok(b) => b,
103            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
104            Err(e) => return Err(e).with_context(|| format!("read {}", p.display())),
105        };
106        let meta: InstalledIntel = serde_json::from_slice(&bytes)
107            .with_context(|| format!("{} is not an intel install record", p.display()))?;
108        if meta.schema != INSTALLED_SCHEMA {
109            bail!(
110                "{} has schema {:?}, expected {INSTALLED_SCHEMA:?}",
111                p.display(),
112                meta.schema
113            );
114        }
115        Ok(Some(meta))
116    }
117
118    /// The live set, checked against the digest `installed.json` recorded.
119    pub fn load(&self) -> Result<Option<(InstalledIntel, AdvisorySet)>> {
120        let Some(meta) = self.installed()? else {
121            return Ok(None);
122        };
123        if meta.advisories_file.contains('/') || meta.advisories_file.contains("..") {
124            bail!(
125                "intel install record names an unsafe file {:?}",
126                meta.advisories_file
127            );
128        }
129        let p = self.root.join(&meta.advisories_file);
130        let bytes = std::fs::read(&p).with_context(|| format!("read {}", p.display()))?;
131        let got = sha256_hex(&bytes);
132        if got != meta.advisories_sha256 {
133            bail!(
134                "the local advisory database at {} does not match its install record \
135                 (sha256 {got}, recorded {}): it was changed after import. Re-import a \
136                 signed intel bundle.",
137                p.display(),
138                meta.advisories_sha256
139            );
140        }
141        let set = AdvisorySet::from_json_bytes(&bytes)?;
142        Ok(Some((meta, set)))
143    }
144
145    /// Write a generation's set beside the live one. Not live yet.
146    pub fn stage_generation(&self, sequence: u64, bytes: &[u8]) -> Result<PathBuf> {
147        std::fs::create_dir_all(&self.root)
148            .with_context(|| format!("mkdir {}", self.root.display()))?;
149        let p = self.root.join(Self::generation_file(sequence));
150        atomic_write(&p, bytes).map_err(|e| anyhow::anyhow!("write {}: {e}", p.display()))?;
151        Ok(p)
152    }
153
154    /// Make `meta` live in one atomic rename, then drop generations that are
155    /// neither live nor the one before it.
156    pub fn activate(&self, meta: &InstalledIntel) -> Result<()> {
157        let bytes = serde_json::to_vec_pretty(meta)?;
158        let previous = self.installed().ok().flatten().map(|m| m.advisories_file);
159        atomic_write(&self.installed_path(), &bytes)
160            .map_err(|e| anyhow::anyhow!("activate intel generation: {e}"))?;
161        if let Ok(rd) = std::fs::read_dir(&self.root) {
162            for e in rd.flatten() {
163                let n = e.file_name().to_string_lossy().into_owned();
164                let is_gen = n.starts_with("advisories-") && n.ends_with(".json");
165                if is_gen && n != meta.advisories_file && Some(&n) != previous.as_ref() {
166                    let _ = std::fs::remove_file(e.path());
167                }
168            }
169        }
170        Ok(())
171    }
172}