Skip to main content

holger_intel/
import.rs

1//! The secure side's door: verify a carried intel bundle, hold it to the
2//! anti-rollback floor, record it in the audit log, and only then make its
3//! advisory set the live local database.
4//!
5//! The order is the one `holger-server bundle import` keeps (S0/B3), and for the
6//! same reasons:
7//!
8//! 1. the bundle file is read ONCE; the bytes that verify are the bytes kept;
9//! 2. the release signature is checked from the control members before any
10//!    payload is decompressed (`load_bundle_verified`);
11//! 3. the full verification runs against the trusted clock and the floor;
12//! 4. the floor decides: a lower sequence, a replay with other bytes, or an
13//!    absent floor without `first_import` is refused;
14//! 5. the audit sink must be ready, and the audit record is written BEFORE the
15//!    floor moves and before the new set goes live. A record that cannot be
16//!    written undoes what was staged, and the database stays as it was.
17
18use std::path::{Path, PathBuf};
19
20use anyhow::{bail, Context, Result};
21use chrono::{DateTime, Utc};
22use holger_update_bundle::container::load_bundle_verified;
23use holger_update_bundle::core::BundleCore;
24use holger_update_bundle::digest::sha256_hex;
25use holger_update_bundle::receipt::ImportReceipt;
26use holger_update_bundle::rollback::{AcceptedEntry, AcceptedState, Admissibility, RollbackStore};
27use holger_update_bundle::schema::{CORE_JSON, PAYLOAD_PREFIX};
28use holger_update_bundle::time::TrustedTime;
29use holger_update_bundle::verify::{verify_member_map_with_time, TargetSpec};
30use holger_update_bundle::{atomic_write, TrustPolicy};
31use serde::{Deserialize, Serialize};
32
33use crate::db::{InstalledIntel, LocalIntelDb, INSTALLED_SCHEMA};
34use crate::set::AdvisorySet;
35use crate::{ADVISORIES_REL, INTEL_PRODUCT};
36
37/// What to import, where, under which policy.
38pub struct ImportRequest<'a> {
39    pub bundle: &'a Path,
40    pub policy: &'a TrustPolicy,
41    pub db: &'a LocalIntelDb,
42    /// Establish the anti-rollback floor with this bundle because there is
43    /// none. No effect when a floor exists.
44    pub first_import: bool,
45}
46
47/// The audit record of one import.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct ImportRecord {
50    pub bundle_id: String,
51    pub sequence: u64,
52    pub supplier: String,
53    pub snapshot_at: DateTime<Utc>,
54    pub candidate_digest: String,
55    pub bundle_sha256: String,
56    pub advisories: u64,
57    /// `new` or `idempotent-reimport`.
58    pub outcome: String,
59}
60
61impl ImportRecord {
62    /// The detail line an audit event carries.
63    pub fn detail(&self) -> String {
64        format!(
65            "bundle_id={} seq={} snapshot={} digest={} advisories={} outcome={}",
66            self.bundle_id,
67            self.sequence,
68            self.snapshot_at.format("%Y-%m-%d"),
69            self.candidate_digest,
70            self.advisories,
71            self.outcome
72        )
73    }
74}
75
76/// The audit sink an import writes through. The server's audit log in
77/// production; a test double in tests.
78pub trait ImportAudit {
79    /// Refuse when the sink is off or could not be opened. Asked before anything
80    /// is written.
81    fn ready(&self) -> Result<()>;
82    /// Write and flush the record. An error refuses the import.
83    fn record(&self, record: &ImportRecord) -> Result<()>;
84}
85
86/// The result of an import.
87#[derive(Debug, Clone)]
88pub struct ImportOutcome {
89    pub record: ImportRecord,
90    pub installed: InstalledIntel,
91    pub stored_bundle: PathBuf,
92    pub receipt: PathBuf,
93    /// The verifier's checks, the time source first.
94    pub checks: Vec<String>,
95}
96
97/// An exclusive import lock: `create_new`, removed on drop.
98struct Lock(PathBuf);
99
100impl Lock {
101    fn acquire(path: PathBuf) -> Result<Self> {
102        match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
103            Ok(mut f) => {
104                use std::io::Write;
105                let _ = writeln!(f, "pid={}", std::process::id());
106                Ok(Lock(path))
107            }
108            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => bail!(
109                "intel import refused: {} exists, so another import is running or one was \
110                 killed. Check no import runs, then remove the lock.",
111                path.display()
112            ),
113            Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
114        }
115    }
116}
117
118impl Drop for Lock {
119    fn drop(&mut self) {
120        let _ = std::fs::remove_file(&self.0);
121    }
122}
123
124fn safe_name(s: &str) -> String {
125    let out: String = s
126        .chars()
127        .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
128        .collect();
129    if out.is_empty() || out == "." || out == ".." {
130        "_".into()
131    } else {
132        out
133    }
134}
135
136/// Import one intel bundle. See the module note for the order.
137pub fn import(req: &ImportRequest<'_>, time: &dyn TrustedTime, audit: &dyn ImportAudit) -> Result<ImportOutcome> {
138    let db = req.db;
139    std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
140    let _lock = Lock::acquire(db.lock_path())?;
141
142    let raw = std::fs::read(req.bundle).with_context(|| format!("read bundle {}", req.bundle.display()))?;
143    let staged = db.root().join(".intel-import.staged.znippy");
144    std::fs::write(&staged, &raw).with_context(|| format!("stage {}", staged.display()))?;
145    let loaded = load_bundle_verified(&staged, req.policy);
146    let _ = std::fs::remove_file(&staged);
147    let members = loaded.map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
148
149    let core = BundleCore::from_json_bytes(
150        members.get(CORE_JSON).ok_or_else(|| anyhow::anyhow!("intel bundle has no core.json"))?,
151    )?;
152    if core.product != INTEL_PRODUCT {
153        bail!(
154            "intel import refused: the bundle's product is {:?}, not {INTEL_PRODUCT:?}. Import \
155             update bundles with `bundle import`.",
156            core.product
157        );
158    }
159
160    let rollback = RollbackStore::open(db.state_dir());
161    let floor = rollback.floor(&core.supplier, &core.product)?;
162    let state = floor.accepted().cloned().unwrap_or_else(AcceptedState::empty);
163    let target = TargetSpec { product: Some(INTEL_PRODUCT.to_string()), model: None, revision: None };
164    let report = verify_member_map_with_time(&members, req.policy, &target, Some(&state), time)
165        .map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
166    let admissibility = floor
167        .check_admissible(&core, &report.candidate_digest, req.first_import)
168        .map_err(|e| anyhow::anyhow!("intel import refused: {e}"))?;
169
170    let payload_path = format!("{PAYLOAD_PREFIX}{ADVISORIES_REL}");
171    let payload = members
172        .get(&payload_path)
173        .ok_or_else(|| anyhow::anyhow!("intel bundle has no {payload_path}"))?;
174    let payload_sha = sha256_hex(payload);
175    if core.policy_result.advisory_snapshot_digest.as_deref() != Some(payload_sha.as_str()) {
176        bail!("intel bundle's advisory_snapshot_digest does not name its own advisory set");
177    }
178    let set = AdvisorySet::from_json_bytes(payload)?;
179
180    audit.ready().map_err(|e| {
181        anyhow::anyhow!(
182            "intel import refused: the audit log is off or could not be opened ({e:#}), and an \
183             import that leaves no audit record is not allowed"
184        )
185    })?;
186
187    let outcome = match admissibility {
188        Admissibility::New => "new",
189        Admissibility::IdempotentReimport => "idempotent-reimport",
190    };
191    let live_before = db.installed().ok().flatten();
192    let gen_file = LocalIntelDb::generation_file(core.sequence);
193    let gen_path = db.stage_generation(core.sequence, payload)?;
194    let stored = db.bundles_dir().join(format!("{}.holger.znippy", safe_name(&core.bundle_id)));
195    let bundle_was_there = stored.exists();
196    if !bundle_was_there {
197        atomic_write(&stored, &raw).map_err(|e| anyhow::anyhow!("store intel bundle: {e}"))?;
198    }
199    let undo = || {
200        if live_before.as_ref().map(|m| m.advisories_file.as_str()) != Some(gen_file.as_str()) {
201            let _ = std::fs::remove_file(&gen_path);
202        }
203        if !bundle_was_there {
204            let _ = std::fs::remove_file(&stored);
205        }
206    };
207
208    let record = ImportRecord {
209        bundle_id: core.bundle_id.clone(),
210        sequence: core.sequence,
211        supplier: core.supplier.clone(),
212        snapshot_at: core.created_at,
213        candidate_digest: report.candidate_digest.clone(),
214        bundle_sha256: sha256_hex(&raw),
215        advisories: set.advisories.len() as u64,
216        outcome: outcome.to_string(),
217    };
218    if let Err(e) = audit.record(&record) {
219        undo();
220        bail!(
221            "intel import refused: its audit record could not be written ({e:#}). No audit record \
222             means no import; the advisory database and the anti-rollback floor are unchanged."
223        );
224    }
225
226    let now = time.now().unwrap_or_else(|_| Utc::now());
227    rollback.record(
228        &core.supplier,
229        &core.product,
230        &AcceptedEntry {
231            sequence: core.sequence,
232            bundle_id: core.bundle_id.clone(),
233            core_digest: report.candidate_digest.clone(),
234            accepted_at: now,
235        },
236    )?;
237    let installed = InstalledIntel {
238        schema: INSTALLED_SCHEMA.to_string(),
239        bundle_id: core.bundle_id.clone(),
240        sequence: core.sequence,
241        supplier: core.supplier.clone(),
242        snapshot_at: core.created_at,
243        imported_at: now,
244        candidate_digest: report.candidate_digest.clone(),
245        release_signer_fingerprint: report.release_signer_fingerprint.clone(),
246        advisories_file: gen_file,
247        advisories_sha256: payload_sha,
248        advisories: set.advisories.len() as u64,
249    };
250    db.activate(&installed)?;
251
252    let receipt = ImportReceipt {
253        bundle_id: core.bundle_id.clone(),
254        sequence: core.sequence,
255        supplier: core.supplier.clone(),
256        product: core.product.clone(),
257        release_version: core.release_version.clone(),
258        candidate_digest: report.candidate_digest.clone(),
259        release_signer_fingerprint: report.release_signer_fingerprint.clone(),
260        imported_at: now,
261        outcome: outcome.to_string(),
262        bundle_sha256: record.bundle_sha256.clone(),
263    };
264    std::fs::create_dir_all(db.receipts_dir()).ok();
265    let receipt_path = db.receipts_dir().join(format!("{}.json", safe_name(&core.bundle_id)));
266    std::fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt)?)?;
267
268    Ok(ImportOutcome { record, installed, stored_bundle: stored, receipt: receipt_path, checks: report.checks })
269}