//! Content-addressed behavior snapshots, separate from activation authority. use crate::protocol::HookSlot; use anyhow::{ensure, Context, Result}; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::{ collections::BTreeMap, fs, io::Write, path::{Path, PathBuf}, }; #[derive(Clone, Debug)] pub struct Release { pub id: String, pub path: PathBuf, pub hooks: BTreeMap, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Manifest { version: u16, hooks: BTreeMap, } fn collect( root: &Path, directory: &Path, entries: &mut Vec<(String, Vec)>, bytes: &mut usize, ) -> Result<()> { for entry in fs::read_dir(directory)? { let entry = entry?; let metadata = entry.file_type()?; ensure!( !metadata.is_symlink(), "behavior snapshots cannot contain symlinks" ); if metadata.is_dir() { collect(root, &entry.path(), entries, bytes)?; continue; } ensure!(metadata.is_file(), "behavior entry must be a regular file"); let path = entry.path(); ensure!( matches!( path.extension().and_then(|s| s.to_str()), Some("py" | "json" | "md") ), "unsupported behavior file" ); ensure!( entry.metadata()?.len() <= 2_097_152, "behavior file too large" ); let content = fs::read(&path)?; *bytes += content.len(); ensure!( *bytes <= 2_097_152 && entries.len() < 128, "behavior snapshot exceeds limits" ); let name = path .strip_prefix(root)? .to_str() .context("non-UTF8 behavior path")? .replace('\\', "/"); entries.push((name, content)); } Ok(()) } type BehaviorRead = (String, Vec<(String, Vec)>, Manifest); fn read(root: &Path) -> Result { ensure!( !fs::symlink_metadata(root)?.file_type().is_symlink(), "behavior root cannot be a symlink" ); let mut entries = Vec::new(); let mut total = 0; collect(root, root, &mut entries, &mut total)?; entries.sort_by(|a, b| a.0.cmp(&b.0)); let mut digest = Sha256::new(); for (name, content) in &entries { digest.update((name.len() as u64).to_be_bytes()); digest.update(name.as_bytes()); digest.update((content.len() as u64).to_be_bytes()); digest.update(content); } let manifest: Manifest = serde_json::from_slice( &entries .iter() .find(|(name, _)| name == "manifest.json") .context("missing manifest.json")? .1, )?; ensure!( manifest.version == 1, "unsupported behavior manifest version" ); ensure!( manifest.hooks.len() == 2 && manifest.hooks.contains_key(&HookSlot::Attention) && manifest.hooks.contains_key(&HookSlot::Delivery), "both implemented hook slots are required" ); Ok((format!("{:x}", digest.finalize()), entries, manifest)) } impl Release { /// Snapshot draft bytes once, then atomically publish their directory. No /// active pointer is changed here. Deployment must protect published paths. pub fn publish(source: &Path, releases: &Path) -> Result { let (id, entries, _) = read(source)?; fs::create_dir_all(releases)?; let destination = releases.join(&id); if !destination.exists() { let candidate = tempfile::Builder::new() .prefix(".candidate-") .tempdir_in(releases)?; for (name, content) in entries { let path = candidate.path().join(name); fs::create_dir_all(path.parent().context("file has no parent")?)?; let mut file = fs::File::create(&path)?; file.write_all(&content)?; file.sync_all()?; } sync_directories(candidate.path())?; // fs::rename is atomic within this directory. A competing publisher // may have won; verify its content rather than overwrite blindly. if let Err(error) = fs::rename(candidate.path(), &destination) { if !destination.is_dir() { return Err(error.into()); } } } fs::File::open(releases)?.sync_all()?; let release = Self::load(&destination)?; ensure!(release.id == id, "existing published snapshot has changed"); Ok(release) } pub fn load(path: impl AsRef) -> Result { let path = path.as_ref(); let (id, _, manifest) = read(path)?; if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { if dir_name.len() == 64 && dir_name.chars().all(|c| c.is_ascii_hexdigit()) { ensure!( dir_name == id, "tampered release bytes: directory name {dir_name} does not match computed hash {id}" ); } } Ok(Self { id, path: path.canonicalize()?, hooks: manifest.hooks, }) } } // Files were synced when written. Sync directory entries bottom-up before // publishing, then sync the release parent after rename. Actual crash durability // remains subject to the filesystem and storage stack. fn sync_directories(path: &Path) -> Result<()> { for entry in fs::read_dir(path)? { let entry = entry?; if entry.file_type()?.is_dir() { sync_directories(&entry.path())?; } } fs::File::open(path)?.sync_all()?; Ok(()) }