use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::core::error::{Error, Result};
pub const MANIFEST_FILE: &str = ".trusty-mpm-manifest.json";
#[derive(Debug)]
pub enum ManifestLoad {
Ok(AgentManifest),
Corrupt(String),
}
pub fn atomic_write(path: &std::path::Path, content: &str) -> Result<()> {
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn repair_stale_tmp(path: &std::path::Path) -> Result<()> {
let tmp = path.with_extension("tmp");
match std::fs::remove_file(&tmp) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::Io(e)),
}
}
const MANIFEST_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Origin {
Bundled,
Registry,
User,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestEntry {
pub source_chain: Vec<String>,
pub checksum: String,
pub deployed_at: String,
pub origin: Origin,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentManifest {
pub version: u32,
pub managed: HashMap<String, ManifestEntry>,
}
impl Default for AgentManifest {
fn default() -> Self {
Self {
version: MANIFEST_VERSION,
managed: HashMap::new(),
}
}
}
pub fn checksum(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
hex.push_str(&format!("{byte:02x}"));
}
hex
}
impl AgentManifest {
pub fn load_checked(target_dir: &Path) -> ManifestLoad {
let path = target_dir.join(MANIFEST_FILE);
match std::fs::read_to_string(&path) {
Ok(raw) => match serde_json::from_str::<AgentManifest>(&raw) {
Ok(m) => ManifestLoad::Ok(m),
Err(e) => ManifestLoad::Corrupt(format!("{path}: {e}", path = path.display())),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => ManifestLoad::Ok(Self::default()),
Err(_) => ManifestLoad::Ok(Self::default()),
}
}
pub fn load(target_dir: &Path) -> Self {
match Self::load_checked(target_dir) {
ManifestLoad::Ok(m) => m,
ManifestLoad::Corrupt(_) => Self::default(),
}
}
pub fn save(&self, target_dir: &Path) -> Result<()> {
std::fs::create_dir_all(target_dir)?;
let path = target_dir.join(MANIFEST_FILE);
let json = serde_json::to_string_pretty(self)?;
atomic_write(&path, &json)?;
Ok(())
}
pub fn is_managed(&self, filename: &str) -> bool {
self.managed.contains_key(filename)
}
pub fn checksum_matches(&self, filename: &str, content: &str) -> bool {
self.managed
.get(filename)
.is_some_and(|entry| entry.checksum == checksum(content))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn sample_entry() -> ManifestEntry {
ManifestEntry {
source_chain: vec!["base-agent".into(), "engineer".into()],
checksum: checksum("hello world"),
deployed_at: "2026-05-16T00:00:00Z".into(),
origin: Origin::Bundled,
}
}
#[test]
fn manifest_load_missing_returns_empty() {
let tmp = TempDir::new().unwrap();
let manifest = AgentManifest::load(tmp.path());
assert_eq!(manifest.version, MANIFEST_VERSION);
assert!(manifest.managed.is_empty());
}
#[test]
fn manifest_load_checked_missing_returns_ok() {
let tmp = TempDir::new().unwrap();
let result = AgentManifest::load_checked(tmp.path());
assert!(
matches!(result, ManifestLoad::Ok(m) if m.managed.is_empty()),
"expected Ok(empty) for missing manifest"
);
}
#[test]
fn manifest_load_corrupt_returns_corrupt() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(MANIFEST_FILE), b"not valid json{{{").unwrap();
let result = AgentManifest::load_checked(tmp.path());
assert!(
matches!(result, ManifestLoad::Corrupt(_)),
"expected Corrupt for malformed manifest"
);
}
#[test]
fn manifest_load_truncated_returns_corrupt() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(MANIFEST_FILE),
b"{\"version\":1,\"managed\":{",
)
.unwrap();
let result = AgentManifest::load_checked(tmp.path());
assert!(
matches!(result, ManifestLoad::Corrupt(_)),
"expected Corrupt for truncated manifest"
);
}
#[test]
fn manifest_round_trip() {
let tmp = TempDir::new().unwrap();
let mut manifest = AgentManifest::default();
manifest
.managed
.insert("engineer.md".into(), sample_entry());
manifest.save(tmp.path()).unwrap();
let loaded = AgentManifest::load(tmp.path());
assert_eq!(loaded, manifest);
assert!(tmp.path().join(MANIFEST_FILE).exists());
}
#[test]
fn manifest_save_is_atomic() {
let tmp = TempDir::new().unwrap();
let mut manifest = AgentManifest::default();
manifest
.managed
.insert("engineer.md".into(), sample_entry());
manifest.save(tmp.path()).unwrap();
let tmp_path = tmp.path().join(MANIFEST_FILE).with_extension("tmp");
assert!(
!tmp_path.exists(),
".tmp staging file must be removed after successful save"
);
}
#[test]
fn atomic_write_leaves_old_intact_on_interrupted_write() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("manifest.json");
fs::write(&path, "original content").unwrap();
let tmp_path = path.with_extension("tmp");
fs::write(&tmp_path, "incomplete new content").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "original content");
repair_stale_tmp(&path).unwrap();
assert!(
!tmp_path.exists(),
"stale .tmp must be removed by repair_stale_tmp"
);
assert_eq!(fs::read_to_string(&path).unwrap(), "original content");
}
#[test]
fn repair_stale_tmp_is_idempotent_when_no_tmp() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("manifest.json");
assert!(repair_stale_tmp(&path).is_ok());
}
#[test]
fn manifest_checksum_matches() {
let mut manifest = AgentManifest::default();
manifest
.managed
.insert("engineer.md".into(), sample_entry());
assert!(manifest.checksum_matches("engineer.md", "hello world"));
assert!(!manifest.checksum_matches("engineer.md", "hello world!"));
assert!(!manifest.checksum_matches("other.md", "hello world"));
}
#[test]
fn manifest_is_managed() {
let mut manifest = AgentManifest::default();
manifest
.managed
.insert("engineer.md".into(), sample_entry());
assert!(manifest.is_managed("engineer.md"));
assert!(!manifest.is_managed("user-agent.md"));
}
#[test]
fn checksum_is_stable_and_distinct() {
assert_eq!(checksum("abc"), checksum("abc"));
assert_ne!(checksum("abc"), checksum("abd"));
assert_eq!(checksum("anything").len(), 64);
}
}