1use 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
37pub struct ImportRequest<'a> {
39 pub bundle: &'a Path,
40 pub policy: &'a TrustPolicy,
41 pub db: &'a LocalIntelDb,
42 pub first_import: bool,
45}
46
47#[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 pub outcome: String,
59}
60
61impl ImportRecord {
62 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
76pub trait ImportAudit {
79 fn ready(&self) -> Result<()>;
82 fn record(&self, record: &ImportRecord) -> Result<()>;
84}
85
86#[derive(Debug, Clone)]
88pub struct ImportOutcome {
89 pub record: ImportRecord,
90 pub installed: InstalledIntel,
91 pub stored_bundle: PathBuf,
92 pub receipt: PathBuf,
93 pub checks: Vec<String>,
95}
96
97struct Lock(PathBuf);
99
100impl Lock {
101 fn acquire(path: PathBuf) -> Result<Self> {
102 match std::fs::OpenOptions::new()
103 .write(true)
104 .create_new(true)
105 .open(&path)
106 {
107 Ok(mut f) => {
108 use std::io::Write;
109 let _ = writeln!(f, "pid={}", std::process::id());
110 Ok(Lock(path))
111 }
112 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => bail!(
113 "intel import refused: {} exists, so another import is running or one was \
114 killed. Check no import runs, then remove the lock.",
115 path.display()
116 ),
117 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
118 }
119 }
120}
121
122impl Drop for Lock {
123 fn drop(&mut self) {
124 let _ = std::fs::remove_file(&self.0);
125 }
126}
127
128fn safe_name(s: &str) -> String {
129 let out: String = s
130 .chars()
131 .map(|c| {
132 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
133 c
134 } else {
135 '_'
136 }
137 })
138 .collect();
139 if out.is_empty() || out == "." || out == ".." {
140 "_".into()
141 } else {
142 out
143 }
144}
145
146pub const PREPARED_SCHEMA: &str = "holger.intel-prepared/v1";
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct Prepared {
157 pub schema: String,
158 pub record: ImportRecord,
160 pub product: String,
161 pub release_version: String,
162 pub release_signer_fingerprint: String,
163 pub advisories_sha256: String,
165 pub checks: Vec<String>,
167}
168
169#[derive(Debug, Clone)]
171pub struct PreparedImport {
172 pub prepared: Prepared,
173 pub advisories: Vec<u8>,
175 pub bundle: Vec<u8>,
177}
178
179pub fn import(
182 req: &ImportRequest<'_>,
183 time: &dyn TrustedTime,
184 audit: &dyn ImportAudit,
185) -> Result<ImportOutcome> {
186 let db = req.db;
187 std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
188 let _lock = Lock::acquire(db.lock_path())?;
189 let p = prepare(
190 req.bundle,
191 req.policy,
192 &db.state_dir(),
193 req.first_import,
194 time,
195 )?;
196 commit_locked(db, &p, time, audit)
197}
198
199pub fn prepare(
203 bundle: &Path,
204 policy: &TrustPolicy,
205 state_dir: &Path,
206 first_import: bool,
207 time: &dyn TrustedTime,
208) -> Result<PreparedImport> {
209 let raw = std::fs::read(bundle).with_context(|| format!("read bundle {}", bundle.display()))?;
210 let members = load_bundle_verified(bundle, policy)
211 .map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
212 let again =
214 std::fs::read(bundle).with_context(|| format!("read bundle {}", bundle.display()))?;
215 if sha256_hex(&again) != sha256_hex(&raw) {
216 bail!("intel import refused: the bundle changed while it was verified");
217 }
218
219 let core = BundleCore::from_json_bytes(
220 members
221 .get(CORE_JSON)
222 .ok_or_else(|| anyhow::anyhow!("intel bundle has no core.json"))?,
223 )?;
224 if core.product != INTEL_PRODUCT {
225 bail!(
226 "intel import refused: the bundle's product is {:?}, not {INTEL_PRODUCT:?}. Import \
227 update bundles with `bundle import`.",
228 core.product
229 );
230 }
231
232 let rollback = RollbackStore::open(state_dir);
233 let floor = rollback.floor(&core.supplier, &core.product)?;
234 let state = floor
235 .accepted()
236 .cloned()
237 .unwrap_or_else(AcceptedState::empty);
238 let target = TargetSpec {
239 product: Some(INTEL_PRODUCT.to_string()),
240 model: None,
241 revision: None,
242 };
243 let report = verify_member_map_with_time(&members, policy, &target, Some(&state), time)
244 .map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
245 let admissibility = floor
246 .check_admissible(&core, &report.candidate_digest, first_import)
247 .map_err(|e| anyhow::anyhow!("intel import refused: {e}"))?;
248
249 let payload_path = format!("{PAYLOAD_PREFIX}{ADVISORIES_REL}");
250 let payload = members
251 .get(&payload_path)
252 .ok_or_else(|| anyhow::anyhow!("intel bundle has no {payload_path}"))?;
253 let payload_sha = sha256_hex(payload);
254 if core.policy_result.advisory_snapshot_digest.as_deref() != Some(payload_sha.as_str()) {
255 bail!("intel bundle's advisory_snapshot_digest does not name its own advisory set");
256 }
257 let set = AdvisorySet::from_json_bytes(payload)?;
258 let outcome = match admissibility {
259 Admissibility::New => "new",
260 Admissibility::IdempotentReimport => "idempotent-reimport",
261 };
262 Ok(PreparedImport {
263 prepared: Prepared {
264 schema: PREPARED_SCHEMA.to_string(),
265 record: ImportRecord {
266 bundle_id: core.bundle_id.clone(),
267 sequence: core.sequence,
268 supplier: core.supplier.clone(),
269 snapshot_at: core.created_at,
270 candidate_digest: report.candidate_digest.clone(),
271 bundle_sha256: sha256_hex(&raw),
272 advisories: set.advisories.len() as u64,
273 outcome: outcome.to_string(),
274 },
275 product: core.product.clone(),
276 release_version: core.release_version.clone(),
277 release_signer_fingerprint: report.release_signer_fingerprint.clone(),
278 advisories_sha256: payload_sha,
279 checks: report.checks,
280 },
281 advisories: payload.clone(),
282 bundle: raw,
283 })
284}
285
286pub fn commit(
291 db: &LocalIntelDb,
292 p: &PreparedImport,
293 time: &dyn TrustedTime,
294 audit: &dyn ImportAudit,
295) -> Result<ImportOutcome> {
296 std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
297 let _lock = Lock::acquire(db.lock_path())?;
298 commit_locked(db, p, time, audit)
299}
300
301fn commit_locked(
302 db: &LocalIntelDb,
303 p: &PreparedImport,
304 time: &dyn TrustedTime,
305 audit: &dyn ImportAudit,
306) -> Result<ImportOutcome> {
307 let (prep, payload, raw) = (&p.prepared, &p.advisories, &p.bundle);
308 if prep.schema != PREPARED_SCHEMA {
309 bail!(
310 "intel import refused: the prepared import has schema {:?}, not {PREPARED_SCHEMA:?}",
311 prep.schema
312 );
313 }
314 if prep.product != INTEL_PRODUCT {
315 bail!(
316 "intel import refused: the prepared import is for {:?}, not {INTEL_PRODUCT:?}",
317 prep.product
318 );
319 }
320 if sha256_hex(payload) != prep.advisories_sha256 {
321 bail!("intel import refused: the advisory set is not the one the verified bundle names");
322 }
323 if sha256_hex(raw) != prep.record.bundle_sha256 {
324 bail!("intel import refused: the bundle's bytes are not the ones that verified");
325 }
326 let record = prep.record.clone();
327 let id = &record.bundle_id;
328
329 audit.ready().map_err(|e| {
330 anyhow::anyhow!(
331 "intel import refused: the audit log is off or could not be opened ({e:#}), and an \
332 import that leaves no audit record is not allowed"
333 )
334 })?;
335
336 let live_before = db.installed().ok().flatten();
337 let gen_file = LocalIntelDb::generation_file(record.sequence);
338 let gen_path = db.stage_generation(record.sequence, payload)?;
339 let stored = db
340 .bundles_dir()
341 .join(format!("{}.holger.znippy", safe_name(id)));
342 let bundle_was_there = stored.exists();
343 if !bundle_was_there {
344 atomic_write(&stored, raw).map_err(|e| anyhow::anyhow!("store intel bundle: {e}"))?;
345 }
346 let undo = || {
347 if live_before.as_ref().map(|m| m.advisories_file.as_str()) != Some(gen_file.as_str()) {
348 let _ = std::fs::remove_file(&gen_path);
349 }
350 if !bundle_was_there {
351 let _ = std::fs::remove_file(&stored);
352 }
353 };
354
355 if let Err(e) = audit.record(&record) {
356 undo();
357 bail!(
358 "intel import refused: its audit record could not be written ({e:#}). No audit record \
359 means no import; the advisory database and the anti-rollback floor are unchanged."
360 );
361 }
362
363 let now = time.now().unwrap_or_else(|_| Utc::now());
364 let rollback = RollbackStore::open(db.state_dir());
365 rollback.record(
366 &record.supplier,
367 &prep.product,
368 &AcceptedEntry {
369 sequence: record.sequence,
370 bundle_id: id.clone(),
371 core_digest: record.candidate_digest.clone(),
372 accepted_at: now,
373 },
374 )?;
375 let installed = InstalledIntel {
376 schema: INSTALLED_SCHEMA.to_string(),
377 bundle_id: id.clone(),
378 sequence: record.sequence,
379 supplier: record.supplier.clone(),
380 snapshot_at: record.snapshot_at,
381 imported_at: now,
382 candidate_digest: record.candidate_digest.clone(),
383 release_signer_fingerprint: prep.release_signer_fingerprint.clone(),
384 advisories_file: gen_file,
385 advisories_sha256: prep.advisories_sha256.clone(),
386 advisories: record.advisories,
387 };
388 db.activate(&installed)?;
389
390 let receipt = ImportReceipt {
391 bundle_id: id.clone(),
392 sequence: record.sequence,
393 supplier: record.supplier.clone(),
394 product: prep.product.clone(),
395 release_version: prep.release_version.clone(),
396 candidate_digest: record.candidate_digest.clone(),
397 release_signer_fingerprint: prep.release_signer_fingerprint.clone(),
398 imported_at: now,
399 outcome: record.outcome.clone(),
400 bundle_sha256: record.bundle_sha256.clone(),
401 };
402 std::fs::create_dir_all(db.receipts_dir()).ok();
403 let receipt_path = db.receipts_dir().join(format!("{}.json", safe_name(id)));
404 std::fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt)?)?;
405
406 Ok(ImportOutcome {
407 record,
408 installed,
409 stored_bundle: stored,
410 receipt: receipt_path,
411 checks: prep.checks.clone(),
412 })
413}