use std::path::Path;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProvenanceMark {
pub resource: String,
pub git: GitMark,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchor: Option<BlockTrailAnchor>,
pub agent_did: String,
pub created: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitMark {
pub commit_sha: String,
pub repo: String,
pub branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockTrailAnchor {
pub ticker: String,
pub state_hash: String,
pub txid: String,
pub vout: u32,
pub address: String,
pub network: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blockheight: Option<u64>,
#[serde(default)]
pub state_strings: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pubkey: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProvenanceError {
Git(String),
Anchor(String),
InvalidPath(String),
Store(String),
}
impl std::fmt::Display for ProvenanceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProvenanceError::Git(m) => write!(f, "git-mark: {m}"),
ProvenanceError::Anchor(m) => write!(f, "block-anchor: {m}"),
ProvenanceError::InvalidPath(m) => write!(f, "invalid provenance path: {m}"),
ProvenanceError::Store(m) => write!(f, "provenance store: {m}"),
}
}
}
impl std::error::Error for ProvenanceError {}
#[async_trait::async_trait(?Send)]
pub trait GitMarker: Send + Sync {
async fn mark_write(
&self,
repo: &Path,
path: &str,
agent_did: &str,
message: &str,
) -> Result<GitMark, ProvenanceError>;
async fn head(&self, repo: &Path) -> Result<Option<String>, ProvenanceError>;
}
#[async_trait::async_trait(?Send)]
pub trait BlockAnchorer: Send + Sync {
async fn anchor(
&self,
ticker: &str,
state_hash: &str,
network: &str,
) -> Result<BlockTrailAnchor, ProvenanceError>;
async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AnchorPolicy {
#[default]
Never,
Always,
HighValue,
Epoch,
}
impl AnchorPolicy {
#[must_use]
pub fn anchors_inline(self, high_value: bool) -> bool {
match self {
AnchorPolicy::Never | AnchorPolicy::Epoch => false,
AnchorPolicy::Always => true,
AnchorPolicy::HighValue => high_value,
}
}
}
#[derive(Clone)]
pub struct ProvenanceLog {
pub marker: Arc<dyn GitMarker>,
pub anchorer: Option<Arc<dyn BlockAnchorer>>,
}
impl std::fmt::Debug for ProvenanceLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProvenanceLog")
.field("marker", &"Arc<dyn GitMarker>")
.field("anchorer", &self.anchorer.as_ref().map(|_| "Arc<dyn BlockAnchorer>"))
.finish()
}
}
#[derive(Debug, Clone, Copy)]
pub struct WriteRecord<'a> {
pub repo: &'a Path,
pub path: &'a str,
pub agent_did: &'a str,
pub message: &'a str,
pub policy: AnchorPolicy,
pub high_value: bool,
pub ticker: &'a str,
pub network: &'a str,
pub created: u64,
}
impl ProvenanceLog {
#[must_use]
pub fn new(marker: Arc<dyn GitMarker>) -> Self {
Self { marker, anchorer: None }
}
#[must_use]
pub fn with_anchorer(marker: Arc<dyn GitMarker>, anchorer: Arc<dyn BlockAnchorer>) -> Self {
Self {
marker,
anchorer: Some(anchorer),
}
}
pub async fn record(&self, rec: WriteRecord<'_>) -> Result<ProvenanceMark, ProvenanceError> {
let git = self
.marker
.mark_write(rec.repo, rec.path, rec.agent_did, rec.message)
.await?;
let anchor = if rec.policy.anchors_inline(rec.high_value) {
match &self.anchorer {
Some(a) => Some(a.anchor(rec.ticker, &git.commit_sha, rec.network).await?),
None => None,
}
} else {
None
};
Ok(ProvenanceMark {
resource: path_to_resource(rec.path),
git,
anchor,
agent_did: rec.agent_did.to_string(),
created: rec.created,
})
}
}
fn path_to_resource(path: &str) -> String {
if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
}
}
fn merkle_root(leaves: &[[u8; 32]]) -> [u8; 32] {
if leaves.is_empty() {
return [0u8; 32];
}
let mut level: Vec<[u8; 32]> = leaves.to_vec();
while level.len() > 1 {
let mut next = Vec::with_capacity(level.len().div_ceil(2));
let mut i = 0;
while i < level.len() {
let left = level[i];
let right = if i + 1 < level.len() { level[i + 1] } else { left };
let mut h = Sha256::new();
h.update(left);
h.update(right);
next.push(h.finalize().into());
i += 2;
}
level = next;
}
level[0]
}
fn merkle_leaf(commit_sha: &str) -> [u8; 32] {
Sha256::digest(commit_sha.as_bytes()).into()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MerkleProof {
pub leaf: String,
pub siblings: Vec<(String, bool)>,
}
#[derive(Debug, Clone)]
pub struct EpochAccumulator {
commits: Vec<String>,
threshold: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClosedEpoch {
pub root: String,
pub commits: Vec<String>,
}
impl EpochAccumulator {
#[must_use]
pub fn new(threshold: usize) -> Self {
Self {
commits: Vec::new(),
threshold: threshold.max(1),
}
}
pub fn push(&mut self, commit_sha: impl Into<String>) {
self.commits.push(commit_sha.into());
}
#[must_use]
pub fn len(&self) -> usize {
self.commits.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.commits.is_empty()
}
#[must_use]
pub fn threshold(&self) -> usize {
self.threshold
}
#[must_use]
pub fn is_full(&self) -> bool {
self.commits.len() >= self.threshold
}
#[must_use]
pub fn root(&self) -> Option<String> {
if self.commits.is_empty() {
return None;
}
let leaves: Vec<[u8; 32]> = self.commits.iter().map(|c| merkle_leaf(c)).collect();
Some(hex::encode(merkle_root(&leaves)))
}
pub fn close(&mut self) -> Option<ClosedEpoch> {
if self.commits.is_empty() {
return None;
}
let commits = std::mem::take(&mut self.commits);
let leaves: Vec<[u8; 32]> = commits.iter().map(|c| merkle_leaf(c)).collect();
let root = hex::encode(merkle_root(&leaves));
Some(ClosedEpoch { root, commits })
}
#[must_use]
pub fn inclusion_proof(&self, index: usize) -> Option<MerkleProof> {
let n = self.commits.len();
if index >= n {
return None;
}
let mut level: Vec<[u8; 32]> = self.commits.iter().map(|c| merkle_leaf(c)).collect();
let leaf_hex = hex::encode(level[index]);
let mut idx = index;
let mut siblings: Vec<(String, bool)> = Vec::new();
while level.len() > 1 {
let sibling_idx = if idx % 2 == 0 { idx + 1 } else { idx - 1 };
let sib = if sibling_idx < level.len() {
level[sibling_idx]
} else {
level[idx]
};
let sibling_is_right = idx % 2 == 0;
siblings.push((hex::encode(sib), sibling_is_right));
let mut next = Vec::with_capacity(level.len().div_ceil(2));
let mut i = 0;
while i < level.len() {
let left = level[i];
let right = if i + 1 < level.len() { level[i + 1] } else { left };
let mut h = Sha256::new();
h.update(left);
h.update(right);
next.push(h.finalize().into());
i += 2;
}
level = next;
idx /= 2;
}
Some(MerkleProof {
leaf: leaf_hex,
siblings,
})
}
#[must_use]
pub fn verify_inclusion(proof: &MerkleProof, root_hex: &str) -> bool {
let Ok(mut acc) = hex::decode(&proof.leaf) else {
return false;
};
if acc.len() != 32 {
return false;
}
for (sib_hex, sib_is_right) in &proof.siblings {
let Ok(sib) = hex::decode(sib_hex) else {
return false;
};
if sib.len() != 32 {
return false;
}
let mut h = Sha256::new();
if *sib_is_right {
h.update(&acc);
h.update(&sib);
} else {
h.update(&sib);
h.update(&acc);
}
acc = h.finalize().to_vec();
}
hex::encode(acc) == root_hex
}
}
fn ttl_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out
}
fn xsd_datetime(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = (secs % 86_400) as i64;
let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
}
pub fn prov_ttl(mark: &ProvenanceMark) -> String {
let sha = &mark.git.commit_sha;
let resource = ttl_escape(&mark.resource);
let agent = ttl_escape(&mark.agent_did);
let branch = ttl_escape(&mark.git.branch);
let repo = ttl_escape(&mark.git.repo);
let when = xsd_datetime(mark.created);
let mut ttl = String::new();
ttl.push_str("@prefix prov: <http://www.w3.org/ns/prov#> .\n");
ttl.push_str("@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n");
ttl.push_str("@prefix git: <https://w3id.org/git#> .\n");
ttl.push_str("@prefix bt: <https://blocktrails.org/ns#> .\n\n");
ttl.push_str(&format!("<urn:git:commit:{sha}> a prov:Activity ;\n"));
ttl.push_str(&format!(" prov:generated <{resource}> ;\n"));
ttl.push_str(&format!(" prov:wasAssociatedWith <{agent}> ;\n"));
ttl.push_str(&format!(" prov:endedAtTime \"{when}\"^^xsd:dateTime ;\n"));
ttl.push_str(&format!(" git:commit \"{sha}\" ;\n"));
ttl.push_str(&format!(" git:branch \"{branch}\" ;\n"));
ttl.push_str(&format!(" git:repo \"{repo}\" "));
if let Some(parent) = &mark.git.parent {
let parent = ttl_escape(parent);
ttl.push_str(&format!(";\n git:parent \"{parent}\" .\n"));
} else {
ttl.push_str(".\n");
}
ttl.push('\n');
ttl.push_str(&format!("<{resource}> a prov:Entity ;\n"));
ttl.push_str(&format!(
" prov:wasGeneratedBy <urn:git:commit:{sha}> ;\n"
));
ttl.push_str(&format!(
" prov:wasAttributedTo <{agent}> .\n"
));
ttl.push('\n');
ttl.push_str(&format!("<{agent}> a prov:Agent .\n"));
if let Some(a) = &mark.anchor {
let txid = ttl_escape(&a.txid);
let ticker = ttl_escape(&a.ticker);
let state_hash = ttl_escape(&a.state_hash);
let network = ttl_escape(&a.network);
ttl.push('\n');
ttl.push_str(&format!("<urn:bt:tx:{txid}:{}> a prov:Entity ;\n", a.vout));
ttl.push_str(&format!(
" prov:wasDerivedFrom <urn:git:commit:{sha}> ;\n"
));
ttl.push_str(&format!(" bt:ticker \"{ticker}\" ;\n"));
ttl.push_str(&format!(" bt:stateHash \"{state_hash}\" ;\n"));
ttl.push_str(&format!(" bt:network \"{network}\" ;\n"));
ttl.push_str(&format!(" bt:txid \"{txid}\" ;\n"));
ttl.push_str(&format!(" bt:vout \"{}\"^^xsd:integer ", a.vout));
if let Some(h) = a.blockheight {
ttl.push_str(&format!(";\n bt:blockheight \"{h}\"^^xsd:integer .\n"));
} else {
ttl.push_str(".\n");
}
}
ttl
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_git() -> GitMark {
GitMark {
commit_sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334".into(),
repo: "deadbeef".into(),
branch: "main".into(),
parent: Some("00112233445566778899aabbccddeeff00112233".into()),
}
}
fn sample_mark() -> ProvenanceMark {
ProvenanceMark {
resource: "/notes/hello.ttl".into(),
git: sample_git(),
anchor: None,
agent_did: "did:nostr:abcdef".into(),
created: 1_750_000_000,
}
}
#[test]
fn git_mark_round_trips() {
let g = sample_git();
let json = serde_json::to_string(&g).unwrap();
let back: GitMark = serde_json::from_str(&json).unwrap();
assert_eq!(g, back);
}
#[test]
fn provenance_mark_round_trips_without_anchor() {
let m = sample_mark();
let json = serde_json::to_string(&m).unwrap();
assert!(!json.contains("anchor"));
let back: ProvenanceMark = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
#[test]
fn provenance_mark_round_trips_with_anchor() {
let mut m = sample_mark();
m.anchor = Some(BlockTrailAnchor {
ticker: "PROV".into(),
state_hash: "ff".repeat(32),
txid: "ab".repeat(32),
vout: 1,
address: "tb1pexample".into(),
network: "testnet4".into(),
blockheight: Some(840_000),
state_strings: vec!["{\"seq\":0}".into(), "{\"seq\":1}".into()],
pubkey: Some("02".to_string() + &"ab".repeat(32)),
});
let json = serde_json::to_string(&m).unwrap();
let back: ProvenanceMark = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
#[test]
fn block_trail_anchor_defaults_state_strings() {
let json = r#"{
"ticker":"PROV","state_hash":"00","txid":"00","vout":0,
"address":"tb1p","network":"testnet4"
}"#;
let a: BlockTrailAnchor = serde_json::from_str(json).unwrap();
assert!(a.state_strings.is_empty());
assert!(a.blockheight.is_none());
}
#[test]
fn prov_ttl_contains_core_triples() {
let ttl = prov_ttl(&sample_mark());
assert!(ttl.contains("@prefix prov: <http://www.w3.org/ns/prov#> ."));
assert!(ttl.contains("a prov:Activity"));
assert!(ttl.contains("prov:wasGeneratedBy"));
assert!(ttl.contains("prov:wasAssociatedWith <did:nostr:abcdef>"));
assert!(ttl.contains("a prov:Agent"));
assert!(ttl.contains("<urn:git:commit:a1b2c3d4e5f60718293a4b5c6d7e8f9001122334>"));
assert!(ttl.contains("git:commit \"a1b2c3d4e5f60718293a4b5c6d7e8f9001122334\""));
assert!(ttl.contains("git:branch \"main\""));
assert!(ttl.contains("git:parent \"00112233445566778899aabbccddeeff00112233\""));
assert!(ttl.contains("<urn:git:commit:a1b2c3d4e5f60718293a4b5c6d7e8f9001122334> a prov:Activity"));
assert!(ttl.contains("prov:generated </notes/hello.ttl>"));
}
#[test]
fn prov_ttl_omits_parent_when_absent() {
let mut m = sample_mark();
m.git.parent = None;
let ttl = prov_ttl(&m);
assert!(!ttl.contains("git:parent"));
assert!(ttl.contains("git:repo \"deadbeef\" .\n"));
}
#[test]
fn prov_ttl_emits_anchor_block_when_present() {
let mut m = sample_mark();
m.anchor = Some(BlockTrailAnchor {
ticker: "PROV".into(),
state_hash: "deadbeef".into(),
txid: "cafebabe".into(),
vout: 2,
address: "tb1pexample".into(),
network: "testnet4".into(),
blockheight: Some(840_000),
state_strings: vec![],
pubkey: None,
});
let ttl = prov_ttl(&m);
assert!(ttl.contains("<urn:bt:tx:cafebabe:2> a prov:Entity"));
assert!(ttl.contains("bt:ticker \"PROV\""));
assert!(ttl.contains("bt:stateHash \"deadbeef\""));
assert!(ttl.contains("bt:blockheight \"840000\"^^xsd:integer"));
assert!(ttl.contains("prov:wasDerivedFrom <urn:git:commit:"));
}
#[test]
fn prov_ttl_escapes_quotes_and_backslashes() {
let mut m = sample_mark();
m.agent_did = "did:nostr:\"weird\\did".into();
let ttl = prov_ttl(&m);
assert!(ttl.contains("did:nostr:\\\"weird\\\\did"));
}
#[test]
fn xsd_datetime_known_epoch() {
assert_eq!(xsd_datetime(1_750_000_000), "2025-06-15T15:06:40Z");
assert_eq!(xsd_datetime(0), "1970-01-01T00:00:00Z");
}
#[test]
fn provenance_error_display() {
assert_eq!(
ProvenanceError::Git("boom".into()).to_string(),
"git-mark: boom"
);
assert_eq!(
ProvenanceError::InvalidPath("/x.acl".into()).to_string(),
"invalid provenance path: /x.acl"
);
}
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Default)]
struct MockMarker {
calls: AtomicUsize,
}
#[async_trait::async_trait(?Send)]
impl GitMarker for MockMarker {
async fn mark_write(
&self,
_repo: &Path,
path: &str,
_agent_did: &str,
_message: &str,
) -> Result<GitMark, ProvenanceError> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
let sha = hex::encode(Sha256::digest(format!("{n}:{path}").as_bytes()))[..40].to_string();
Ok(GitMark {
commit_sha: sha,
repo: "mockpod".into(),
branch: "main".into(),
parent: None,
})
}
async fn head(&self, _repo: &Path) -> Result<Option<String>, ProvenanceError> {
Ok(None)
}
}
#[derive(Default)]
struct MockAnchorer {
calls: AtomicUsize,
last_state_hash: std::sync::Mutex<Option<String>>,
}
#[async_trait::async_trait(?Send)]
impl BlockAnchorer for MockAnchorer {
async fn anchor(
&self,
ticker: &str,
state_hash: &str,
network: &str,
) -> Result<BlockTrailAnchor, ProvenanceError> {
self.calls.fetch_add(1, Ordering::SeqCst);
*self.last_state_hash.lock().unwrap() = Some(state_hash.to_string());
Ok(BlockTrailAnchor {
ticker: ticker.into(),
state_hash: state_hash.into(),
txid: "ab".repeat(32),
vout: 0,
address: "tb1pmock".into(),
network: network.into(),
blockheight: None,
state_strings: vec!["{\"seq\":0}".into()],
pubkey: Some("02".to_string() + &"ab".repeat(32)),
})
}
async fn verify(&self, _anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
Ok(true)
}
}
fn repo() -> &'static Path {
Path::new("/tmp/mockpod")
}
fn rec<'a>(
path: &'a str,
policy: AnchorPolicy,
high_value: bool,
created: u64,
) -> WriteRecord<'a> {
WriteRecord {
repo: repo(),
path,
agent_did: "did:nostr:a",
message: "PUT",
policy,
high_value,
ticker: "PROV",
network: "testnet4",
created,
}
}
#[test]
fn anchor_policy_inline_matrix() {
assert!(!AnchorPolicy::Never.anchors_inline(true));
assert!(!AnchorPolicy::Never.anchors_inline(false));
assert!(AnchorPolicy::Always.anchors_inline(false));
assert!(AnchorPolicy::Always.anchors_inline(true));
assert!(AnchorPolicy::HighValue.anchors_inline(true));
assert!(!AnchorPolicy::HighValue.anchors_inline(false));
assert!(!AnchorPolicy::Epoch.anchors_inline(true));
assert_eq!(AnchorPolicy::default(), AnchorPolicy::Never);
}
#[tokio::test]
async fn record_cheap_write_is_git_mark_only() {
let marker = Arc::new(MockMarker::default());
let anchorer = Arc::new(MockAnchorer::default());
let log = ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone());
let mark = log
.record(rec("notes/a.ttl", AnchorPolicy::Never, false, 1_750_000_000))
.await
.unwrap();
assert!(mark.anchor.is_none(), "cheap write must carry no anchor");
assert_eq!(mark.resource, "/notes/a.ttl");
assert_eq!(marker.calls.load(Ordering::SeqCst), 1, "git-mark always runs");
assert_eq!(anchorer.calls.load(Ordering::SeqCst), 0, "anchorer must NOT be called");
}
#[tokio::test]
async fn record_high_value_write_carries_git_mark_and_anchor() {
let marker = Arc::new(MockMarker::default());
let anchorer = Arc::new(MockAnchorer::default());
let log = ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone());
let mark = log
.record(rec("receipts/r1.ttl", AnchorPolicy::HighValue, true, 1_750_000_000))
.await
.unwrap();
let anchor = mark.anchor.expect("high-value write must carry an anchor");
assert_eq!(
anchor.state_hash, mark.git.commit_sha,
"anchor must commit to the git SHA (binds the two tiers — §2.3)"
);
assert_eq!(anchorer.calls.load(Ordering::SeqCst), 1);
assert_eq!(
anchorer.last_state_hash.lock().unwrap().as_deref(),
Some(mark.git.commit_sha.as_str())
);
}
#[tokio::test]
async fn record_high_value_flag_false_is_git_only() {
let marker = Arc::new(MockMarker::default());
let anchorer = Arc::new(MockAnchorer::default());
let log = ProvenanceLog::with_anchorer(marker, anchorer.clone());
let mark = log
.record(rec("notes/x.ttl", AnchorPolicy::HighValue, false, 1))
.await
.unwrap();
assert!(mark.anchor.is_none());
assert_eq!(anchorer.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn record_always_anchors_every_write() {
let marker = Arc::new(MockMarker::default());
let anchorer = Arc::new(MockAnchorer::default());
let log = ProvenanceLog::with_anchorer(marker, anchorer.clone());
for i in 0..3 {
let m = log
.record(rec(&format!("s/{i}.ttl"), AnchorPolicy::Always, false, 1))
.await
.unwrap();
assert!(m.anchor.is_some());
}
assert_eq!(anchorer.calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn record_without_anchorer_degrades_to_git_only() {
let marker = Arc::new(MockMarker::default());
let log = ProvenanceLog::new(marker.clone());
assert!(log.anchorer.is_none());
let mark = log
.record(rec("notes/a.ttl", AnchorPolicy::Always, true, 1))
.await
.unwrap();
assert!(mark.anchor.is_none(), "no anchorer ⇒ no anchor regardless of policy");
assert_eq!(marker.calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn record_epoch_defers_anchoring_to_accumulator() {
let marker = Arc::new(MockMarker::default());
let anchorer = Arc::new(MockAnchorer::default());
let log = ProvenanceLog::with_anchorer(marker, anchorer.clone());
let mut epoch = EpochAccumulator::new(3);
let mut shas = Vec::new();
for i in 0..3 {
let m = log
.record(rec(&format!("e/{i}.ttl"), AnchorPolicy::Epoch, true, 1))
.await
.unwrap();
assert!(m.anchor.is_none(), "epoch writes never anchor inline");
epoch.push(m.git.commit_sha.clone());
shas.push(m.git.commit_sha);
}
assert_eq!(anchorer.calls.load(Ordering::SeqCst), 0);
assert!(epoch.is_full());
let closed = epoch.close().expect("non-empty epoch closes");
assert_eq!(closed.commits, shas);
let anchor = anchorer.anchor("PROV", &closed.root, "testnet4").await.unwrap();
assert_eq!(anchorer.calls.load(Ordering::SeqCst), 1, "ONE anchor notarises N commits");
assert_eq!(anchor.state_hash, closed.root);
assert!(epoch.is_empty());
}
#[test]
fn merkle_root_empty_and_single() {
assert_eq!(merkle_root(&[]), [0u8; 32]);
let leaf = merkle_leaf("deadbeef");
assert_eq!(merkle_root(&[leaf]), leaf);
}
#[test]
fn merkle_root_is_deterministic_and_order_sensitive() {
let a = merkle_leaf("aaa");
let b = merkle_leaf("bbb");
let r1 = merkle_root(&[a, b]);
let r2 = merkle_root(&[a, b]);
assert_eq!(r1, r2, "deterministic");
let swapped = merkle_root(&[b, a]);
assert_ne!(r1, swapped, "leaf order changes the root");
}
#[test]
fn epoch_root_matches_close_root() {
let mut e = EpochAccumulator::new(10);
for i in 0..5 {
e.push(format!("commit{i:040}"));
}
let peeked = e.root().unwrap();
let closed = e.close().unwrap();
assert_eq!(peeked, closed.root, "root() peek == close() root");
}
#[test]
fn epoch_inclusion_proof_verifies_for_every_leaf() {
let n = 7; let mut e = EpochAccumulator::new(n);
for i in 0..n {
e.push(format!("c{i:039}")); }
let root = e.root().unwrap();
for i in 0..n {
let proof = e.inclusion_proof(i).expect("proof for in-range leaf");
assert!(
EpochAccumulator::verify_inclusion(&proof, &root),
"leaf {i} must verify against the anchored root"
);
}
assert!(e.inclusion_proof(n).is_none());
}
#[test]
fn epoch_inclusion_proof_rejects_wrong_root_and_tampered_leaf() {
let mut e = EpochAccumulator::new(4);
for i in 0..4 {
e.push(format!("c{i:039}"));
}
let root = e.root().unwrap();
let mut proof = e.inclusion_proof(1).unwrap();
assert!(!EpochAccumulator::verify_inclusion(&proof, &"00".repeat(32)));
proof.leaf = hex::encode(merkle_leaf("forged"));
assert!(!EpochAccumulator::verify_inclusion(&proof, &root));
}
#[test]
fn epoch_threshold_and_len_tracking() {
let mut e = EpochAccumulator::new(2);
assert_eq!(e.threshold(), 2);
assert!(e.is_empty() && !e.is_full());
e.push("a");
assert_eq!(e.len(), 1);
assert!(!e.is_full());
e.push("b");
assert!(e.is_full(), "reaching threshold ⇒ full");
assert_eq!(EpochAccumulator::new(0).threshold(), 1);
}
#[test]
fn empty_epoch_close_is_none() {
let mut e = EpochAccumulator::new(3);
assert!(e.close().is_none());
assert!(e.root().is_none());
}
#[test]
fn merkle_proof_round_trips() {
let p = MerkleProof {
leaf: hex::encode(merkle_leaf("x")),
siblings: vec![("ab".repeat(32), true), ("cd".repeat(32), false)],
};
let json = serde_json::to_string(&p).unwrap();
let back: MerkleProof = serde_json::from_str(&json).unwrap();
assert_eq!(p, back);
}
#[test]
fn anchor_policy_round_trips() {
for p in [AnchorPolicy::Never, AnchorPolicy::Always, AnchorPolicy::HighValue, AnchorPolicy::Epoch] {
let json = serde_json::to_string(&p).unwrap();
let back: AnchorPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(p, back);
}
}
}