use std::collections::HashMap;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
#[derive(Debug, Default)]
pub(crate) struct IntegrityRegistry {
entries: HashMap<String, String>,
}
impl IntegrityRegistry {
pub(crate) fn default_path() -> PathBuf {
zeph_config::default_integrity_registry_path()
}
pub(crate) fn load(path: &Path) -> Self {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "integrity registry read failed; treating as empty");
return Self::default();
}
};
let Ok(text) = String::from_utf8(bytes) else {
tracing::warn!(path = %path.display(), "integrity registry invalid UTF-8; treating as empty");
return Self::default();
};
let table: toml::Value = match toml::from_str(&text) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "integrity registry unparseable; treating as empty");
return Self::default();
}
};
let entries = table
.as_table()
.map(|t| {
t.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect()
})
.unwrap_or_default();
Self { entries }
}
pub(crate) fn save(&self, path: &Path) -> anyhow::Result<()> {
let parent = path.parent().unwrap_or(std::path::Path::new("."));
std::fs::create_dir_all(parent)?;
let mut table = toml::value::Table::new();
for (k, v) in &self.entries {
table.insert(k.clone(), toml::Value::String(v.clone()));
}
let text = toml::to_string(&toml::Value::Table(table))?;
let tmp_path = path.with_extension("toml.tmp");
std::fs::write(&tmp_path, &text)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp_path, path)?;
Ok(())
}
pub(crate) fn record(&mut self, plugin_name: &str, toml_path: &Path) -> anyhow::Result<()> {
let bytes = std::fs::read(toml_path)?;
let digest = sha256_hex(&bytes);
self.entries.insert(plugin_name.to_owned(), digest);
Ok(())
}
pub(crate) fn remove(&mut self, plugin_name: &str) {
self.entries.remove(plugin_name);
}
pub(crate) fn verify(
&self,
plugin_name: &str,
toml_path: &Path,
) -> anyhow::Result<VerifyResult> {
let Some(expected) = self.entries.get(plugin_name) else {
tracing::debug!(
plugin = %plugin_name,
"no integrity record; manifest not verified (pre-feature install or interrupted install)"
);
return Ok(VerifyResult::Missing);
};
let bytes = std::fs::read(toml_path)?;
let actual = sha256_hex(&bytes);
if &actual == expected {
Ok(VerifyResult::Match)
} else {
Ok(VerifyResult::Mismatch {
expected: expected.clone(),
actual,
})
}
}
}
#[derive(Debug)]
pub(crate) enum VerifyResult {
Missing,
Match,
Mismatch { expected: String, actual: String },
}
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
fn tmp_registry(dir: &TempDir) -> PathBuf {
dir.path().join("registry.toml")
}
#[test]
fn integrity_registry_round_trip() {
let dir = TempDir::new().unwrap();
let reg_path = tmp_registry(&dir);
let toml_path = dir.path().join("plugin.toml");
std::fs::write(&toml_path, b"[plugin]\nname = \"test\"\n").unwrap();
let mut reg = IntegrityRegistry::load(®_path);
reg.record("test-plugin", &toml_path).unwrap();
reg.save(®_path).unwrap();
let reg2 = IntegrityRegistry::load(®_path);
assert!(matches!(
reg2.verify("test-plugin", &toml_path).unwrap(),
VerifyResult::Match
));
}
#[test]
fn integrity_registry_mismatch_detected() {
let dir = TempDir::new().unwrap();
let reg_path = tmp_registry(&dir);
let toml_path = dir.path().join("plugin.toml");
std::fs::write(&toml_path, b"[plugin]\nname = \"original\"\n").unwrap();
let mut reg = IntegrityRegistry::load(®_path);
reg.record("test-plugin", &toml_path).unwrap();
reg.save(®_path).unwrap();
std::fs::write(&toml_path, b"[plugin]\nname = \"tampered\"\n").unwrap();
let reg2 = IntegrityRegistry::load(®_path);
assert!(matches!(
reg2.verify("test-plugin", &toml_path).unwrap(),
VerifyResult::Mismatch { .. }
));
}
#[test]
fn integrity_registry_missing_entry_allowed() {
let dir = TempDir::new().unwrap();
let reg_path = tmp_registry(&dir);
let toml_path = dir.path().join("plugin.toml");
std::fs::write(&toml_path, b"[plugin]\nname = \"test\"\n").unwrap();
let reg = IntegrityRegistry::load(®_path);
assert!(matches!(
reg.verify("unknown-plugin", &toml_path).unwrap(),
VerifyResult::Missing
));
}
#[test]
fn corrupt_integrity_registry_treated_as_empty() {
let dir = TempDir::new().unwrap();
let reg_path = tmp_registry(&dir);
std::fs::write(®_path, b"not valid toml !!!").unwrap();
let reg = IntegrityRegistry::load(®_path);
assert!(reg.entries.is_empty());
}
#[test]
fn integrity_registry_remove_clears_entry() {
let dir = TempDir::new().unwrap();
let reg_path = tmp_registry(&dir);
let toml_path = dir.path().join("plugin.toml");
std::fs::write(&toml_path, b"[plugin]\nname = \"test\"\n").unwrap();
let mut reg = IntegrityRegistry::load(®_path);
reg.record("test-plugin", &toml_path).unwrap();
reg.save(®_path).unwrap();
let mut reg2 = IntegrityRegistry::load(®_path);
reg2.remove("test-plugin");
reg2.save(®_path).unwrap();
let reg3 = IntegrityRegistry::load(®_path);
assert!(reg3.entries.is_empty());
}
#[test]
fn sha256_hex_stable() {
let digest = sha256_hex(b"");
assert_eq!(
digest,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}