use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use holger_update_bundle::atomic_write;
use holger_update_bundle::digest::sha256_hex;
use serde::{Deserialize, Serialize};
use crate::set::AdvisorySet;
pub const INSTALLED_SCHEMA: &str = "holger.intel-installed/v1";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InstalledIntel {
pub schema: String,
pub bundle_id: String,
pub sequence: u64,
pub supplier: String,
pub snapshot_at: DateTime<Utc>,
pub imported_at: DateTime<Utc>,
pub candidate_digest: String,
pub release_signer_fingerprint: String,
pub advisories_file: String,
pub advisories_sha256: String,
pub advisories: u64,
}
impl InstalledIntel {
pub fn describe(&self) -> String {
format!("seq {} dated {}", self.sequence, self.snapshot_at.format("%Y-%m-%d"))
}
}
#[derive(Debug, Clone)]
pub struct LocalIntelDb {
root: PathBuf,
}
impl LocalIntelDb {
pub fn open(root: impl AsRef<Path>) -> Self {
Self { root: root.as_ref().to_path_buf() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn state_dir(&self) -> PathBuf {
self.root.join("state")
}
pub fn bundles_dir(&self) -> PathBuf {
self.root.join("bundles")
}
pub fn receipts_dir(&self) -> PathBuf {
self.root.join("receipts")
}
pub fn lock_path(&self) -> PathBuf {
self.root.join(".intel-import.lock")
}
fn installed_path(&self) -> PathBuf {
self.root.join("installed.json")
}
pub fn generation_file(sequence: u64) -> String {
format!("advisories-{sequence}.json")
}
pub fn installed(&self) -> Result<Option<InstalledIntel>> {
let p = self.installed_path();
let bytes = match std::fs::read(&p) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).with_context(|| format!("read {}", p.display())),
};
let meta: InstalledIntel = serde_json::from_slice(&bytes)
.with_context(|| format!("{} is not an intel install record", p.display()))?;
if meta.schema != INSTALLED_SCHEMA {
bail!("{} has schema {:?}, expected {INSTALLED_SCHEMA:?}", p.display(), meta.schema);
}
Ok(Some(meta))
}
pub fn load(&self) -> Result<Option<(InstalledIntel, AdvisorySet)>> {
let Some(meta) = self.installed()? else {
return Ok(None);
};
if meta.advisories_file.contains('/') || meta.advisories_file.contains("..") {
bail!("intel install record names an unsafe file {:?}", meta.advisories_file);
}
let p = self.root.join(&meta.advisories_file);
let bytes = std::fs::read(&p).with_context(|| format!("read {}", p.display()))?;
let got = sha256_hex(&bytes);
if got != meta.advisories_sha256 {
bail!(
"the local advisory database at {} does not match its install record \
(sha256 {got}, recorded {}): it was changed after import. Re-import a \
signed intel bundle.",
p.display(),
meta.advisories_sha256
);
}
let set = AdvisorySet::from_json_bytes(&bytes)?;
Ok(Some((meta, set)))
}
pub fn stage_generation(&self, sequence: u64, bytes: &[u8]) -> Result<PathBuf> {
std::fs::create_dir_all(&self.root).with_context(|| format!("mkdir {}", self.root.display()))?;
let p = self.root.join(Self::generation_file(sequence));
atomic_write(&p, bytes).map_err(|e| anyhow::anyhow!("write {}: {e}", p.display()))?;
Ok(p)
}
pub fn activate(&self, meta: &InstalledIntel) -> Result<()> {
let bytes = serde_json::to_vec_pretty(meta)?;
let previous = self.installed().ok().flatten().map(|m| m.advisories_file);
atomic_write(&self.installed_path(), &bytes)
.map_err(|e| anyhow::anyhow!("activate intel generation: {e}"))?;
if let Ok(rd) = std::fs::read_dir(&self.root) {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
let is_gen = n.starts_with("advisories-") && n.ends_with(".json");
if is_gen && n != meta.advisories_file && Some(&n) != previous.as_ref() {
let _ = std::fs::remove_file(e.path());
}
}
}
Ok(())
}
}