use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::cas;
use crate::crypto;
use crate::error::{Error, Result};
use crate::etree::{self, ParseOps, TextNode, TextTree};
pub fn build_manifest(dir: &Path, casdir: &Path) -> Result<TextTree> {
if !dir.is_dir() {
return Err(Error::msg(format!("{} is not a directory", dir.display())));
}
let policy = crate::crypto::default_policy();
let mut paops = ParseOps::new(policy)?;
paops.io.casdir = casdir.to_path_buf();
paops.runtime.fname = dir.display().to_string();
let mut files = collect_files(dir)?;
files.sort();
let mut tree: TextTree = Vec::new();
tree.push(TextNode::Plain(format!(
"# provenance manifest for {}",
dir.display()
)));
for file in &files {
let rel = file
.strip_prefix(dir)
.map_err(|e| Error::msg(e.to_string()))?;
let bytes = std::fs::read(file)?;
let hash = cas::save(bytes, &mut paops)?;
tree.push(TextNode::Plain(format!("# path: {}", rel.display())));
tree.push(TextNode::Include { hash });
}
Ok(tree)
}
fn collect_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
walk(root, &mut out)?;
Ok(out)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries = std::fs::read_dir(dir)?;
for ent in entries {
let ent = ent?;
let path = ent.path();
let ft = match ent.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if ft.is_symlink() {
continue;
}
if ft.is_dir() {
walk(&path, out)?;
continue;
}
if ft.is_file() {
out.push(path);
}
}
Ok(())
}
pub fn attest(
tree: &TextTree,
signer_priv_pem: &str,
casdir: &Path,
mut words: Vec<String>,
) -> Result<TextTree> {
use crate::ledger;
use crate::pki::SigAlgKind;
let policy = crate::crypto::default_policy();
let mut paops = ParseOps::new(policy)?;
paops.io.casdir = casdir.to_path_buf();
paops.runtime.fname = "<provenance-attest>".into();
let botan_priv = botan::Privkey::load_pem(signer_priv_pem).map_err(Error::botan)?;
let botan_pub = botan_priv.pubkey().map_err(Error::botan)?;
let pub_pem = botan_pub.pem_encode().map_err(Error::botan)?;
let fp = crate::capability::KeyFp::from_pem(&pub_pem)?;
let blob = etree::tree_to_blob(tree, &mut paops)?;
let payload_hex = crypto::hexdigest("sha3-256", &blob, &*paops.crypto.policy)?;
let payload_hash = ledger::anchor::PayloadHash::from_hex(&payload_hex)?;
let signer_id = ledger::anchor::SignerId::new(SigAlgKind::Ed25519, fp);
let anchor = ledger::anchor::Anchor::builder(signer_id, payload_hash)
.with_mutations("provenance-attest")
.build();
let signed = anchor.sign(signer_priv_pem, &pub_pem, SigAlgKind::Ed25519)?;
let mut extfields: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in signed.to_extfields().into_iter() {
extfields.insert(k, v);
}
let mut out = tree.clone();
out.push(TextNode::Chain { extfields });
if !words.is_empty() {
words.sort();
let _ = words; }
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write(p: &Path, contents: &str) {
fs::write(p, contents).unwrap();
}
#[test]
fn manifest_lists_every_file_with_include() {
let proj = tempdir().unwrap();
let cas = tempdir().unwrap();
write(&proj.path().join("a.txt"), "alpha");
write(&proj.path().join("b.txt"), "beta");
std::fs::create_dir(proj.path().join("sub")).unwrap();
write(&proj.path().join("sub/c.txt"), "gamma");
let tree = build_manifest(proj.path(), cas.path()).unwrap();
let mut hashes = 0;
let mut paths = 0;
for node in &tree {
match node {
TextNode::Plain(s) if s.starts_with("# path: ") => paths += 1,
TextNode::Include { .. } => hashes += 1,
_ => {}
}
}
assert_eq!(paths, 3, "expected 3 path comments; got {paths}");
assert_eq!(hashes, 3, "expected 3 INCLUDE hashes; got {hashes}");
}
#[test]
fn manifest_is_byte_identical_for_same_tree() {
let proj1 = tempdir().unwrap();
let proj2 = tempdir().unwrap();
let cas1 = tempdir().unwrap();
let cas2 = tempdir().unwrap();
for (p, c) in &[("x.txt", "x"), ("y.txt", "y")] {
write(&proj1.path().join(p), c);
write(&proj2.path().join(p), c);
}
let t1 = build_manifest(proj1.path(), cas1.path()).unwrap();
let t2 = build_manifest(proj2.path(), cas2.path()).unwrap();
let h1: Vec<String> = t1
.iter()
.filter_map(|n| match n {
TextNode::Include { hash } => Some(hash.clone()),
_ => None,
})
.collect();
let h2: Vec<String> = t2
.iter()
.filter_map(|n| match n {
TextNode::Include { hash } => Some(hash.clone()),
_ => None,
})
.collect();
assert_eq!(h1, h2);
}
#[test]
fn manifest_rejects_non_directory_input() {
let tmp = tempdir().unwrap();
let f = tmp.path().join("not_a_dir");
write(&f, "x");
let cas = tempdir().unwrap();
let result = build_manifest(&f, cas.path());
assert!(result.is_err());
}
#[test]
fn manifest_skips_symlinks() {
let proj = tempdir().unwrap();
let cas = tempdir().unwrap();
write(&proj.path().join("real.txt"), "real");
#[cfg(unix)]
std::os::unix::fs::symlink(proj.path().join("real.txt"), proj.path().join("link.txt"))
.unwrap();
let tree = build_manifest(proj.path(), cas.path()).unwrap();
let count = tree
.iter()
.filter(|n| matches!(n, TextNode::Include { .. }))
.count();
assert_eq!(count, 1);
}
#[test]
fn attest_appends_chain_node_signed_by_builder() {
use crate::pki::{self, SigAlgKind};
let proj = tempdir().unwrap();
let cas = tempdir().unwrap();
write(&proj.path().join("a.txt"), "alpha");
let tree = build_manifest(proj.path(), cas.path()).unwrap();
let mut rng = botan::RandomNumberGenerator::new_system().unwrap();
let (priv_pem, pub_pem) = pki::keygen(SigAlgKind::Ed25519, &mut rng).unwrap();
let attested = attest(&tree, &priv_pem, cas.path(), Vec::new()).unwrap();
let chain_count = attested
.iter()
.filter(|n| matches!(n, TextNode::Chain { .. }))
.count();
assert_eq!(chain_count, 1);
let chain_node = attested
.iter()
.find_map(|n| match n {
TextNode::Chain { extfields } => Some(extfields),
_ => None,
})
.unwrap();
let signed = crate::ledger::anchor::SignedAnchor::from_extfields(chain_node).unwrap();
crate::ledger::verify_anchor(&signed, &pub_pem).unwrap();
}
}