//! 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<HookSlot, String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
version: u16,
hooks: BTreeMap<HookSlot, String>,
}
fn collect(
root: &Path,
directory: &Path,
entries: &mut Vec<(String, Vec<u8>)>,
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<u8>)>, Manifest);
fn read(root: &Path) -> Result<BehaviorRead> {
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<Self> {
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<Path>) -> Result<Self> {
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(())
}