1use 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
28pub const INSTALLED_SCHEMA: &str = "holger.intel-installed/v1";
30
31#[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 pub snapshot_at: DateTime<Utc>,
41 pub imported_at: DateTime<Utc>,
42 pub candidate_digest: String,
43 pub release_signer_fingerprint: String,
44 pub advisories_file: String,
46 pub advisories_sha256: String,
47 pub advisories: u64,
48}
49
50impl InstalledIntel {
51 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#[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 pub fn generation_file(sequence: u64) -> String {
95 format!("advisories-{sequence}.json")
96 }
97
98 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 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 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 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}