use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::receipt::{SessionReceipt, RECEIPT_TYPE};
use crate::statements::{
ApprovalRevocation, ApprovalUse, JournalCheckpoint,
ReplayCheck, ReplayCheckLevel,
approval_revocation_record_digest, approval_use_record_digest,
journal_checkpoint_record_digest,
};
#[derive(Debug)]
pub enum PackageError {
Io(std::io::Error),
Json(serde_json::Error),
InvalidPackage(String),
}
impl std::fmt::Display for PackageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "package io: {e}"),
Self::Json(e) => write!(f, "package json: {e}"),
Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
}
}
}
impl std::error::Error for PackageError {}
impl From<std::io::Error> for PackageError {
fn from(e: std::io::Error) -> Self { Self::Io(e) }
}
impl From<serde_json::Error> for PackageError {
fn from(e: serde_json::Error) -> Self { Self::Json(e) }
}
const RECEIPT_FILE: &str = "receipt.json";
const MERKLE_FILE: &str = "merkle.json";
const RENDER_FILE: &str = "render.json";
const ARTIFACTS_DIR: &str = "artifacts";
const PROOFS_DIR: &str = "proofs";
const PREVIEW_FILE: &str = "preview.html";
const APPROVALS_DIR: &str = "approvals";
const APPROVALS_GRANTS: &str = "approvals/grants";
const APPROVALS_USES: &str = "approvals/uses";
const APPROVALS_CHECKPOINTS:&str = "approvals/checkpoints";
const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
#[derive(Debug, Clone, Default)]
pub struct ApprovalsBundle {
pub grants: Vec<(String, Vec<u8>)>,
pub uses: Vec<ApprovalUse>,
pub checkpoints: Vec<JournalCheckpoint>,
pub revocations: Vec<ApprovalRevocation>,
pub action_envelopes: Vec<(String, Vec<u8>)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalsIndex {
#[serde(rename = "type")]
pub type_: String,
pub schema_version: u32,
pub grants: Vec<String>,
pub uses: Vec<String>,
pub checkpoints: Vec<String>,
pub revocations: Vec<String>,
}
impl ApprovalsIndex {
pub fn type_string() -> &'static str { "treeship/approvals-index/v1" }
}
pub struct PackageOutput {
pub path: PathBuf,
pub receipt_digest: String,
pub merkle_root: Option<String>,
pub file_count: usize,
}
pub fn build_package(
receipt: &SessionReceipt,
output_dir: &Path,
) -> Result<PackageOutput, PackageError> {
build_package_with_approvals(receipt, output_dir, None)
}
pub fn build_package_with_approvals(
receipt: &SessionReceipt,
output_dir: &Path,
bundle: Option<&ApprovalsBundle>,
) -> Result<PackageOutput, PackageError> {
let session_id = &receipt.session.id;
let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
std::fs::create_dir_all(&pkg_dir)?;
std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
let mut file_count = 0usize;
let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
file_count += 1;
let receipt_hash = Sha256::digest(&receipt_bytes);
let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
file_count += 1;
let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
file_count += 1;
for proof_entry in &receipt.merkle.inclusion_proofs {
let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
let filename = format!("{}.proof.json", proof_entry.artifact_id);
std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
file_count += 1;
}
if receipt.render.generate_preview {
let preview = render_preview_html(receipt);
std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
file_count += 1;
}
if let Some(b) = bundle {
if !b.grants.is_empty() || !b.uses.is_empty() || !b.checkpoints.is_empty() || !b.revocations.is_empty() || !b.action_envelopes.is_empty() {
std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
for (artifact_id, envelope_bytes) in &b.action_envelopes {
let safe = sanitize_filename(artifact_id);
std::fs::write(
pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
envelope_bytes,
)?;
file_count += 1;
}
let mut grant_ids = Vec::with_capacity(b.grants.len());
for (grant_id, envelope_bytes) in &b.grants {
let safe = sanitize_filename(grant_id);
std::fs::write(
pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
envelope_bytes,
)?;
grant_ids.push(grant_id.clone());
file_count += 1;
}
let mut use_ids = Vec::with_capacity(b.uses.len());
for u in &b.uses {
let safe = sanitize_filename(&u.use_id);
let bytes = serde_json::to_vec_pretty(u)?;
std::fs::write(
pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
&bytes,
)?;
use_ids.push(u.use_id.clone());
file_count += 1;
}
let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
for cp in &b.checkpoints {
let safe = sanitize_filename(&cp.checkpoint_id);
let bytes = serde_json::to_vec_pretty(cp)?;
std::fs::write(
pkg_dir.join(APPROVALS_CHECKPOINTS).join(format!("{safe}.json")),
&bytes,
)?;
checkpoint_ids.push(cp.checkpoint_id.clone());
file_count += 1;
}
let mut revocation_ids = Vec::with_capacity(b.revocations.len());
for rev in &b.revocations {
let safe = sanitize_filename(&rev.revocation_id);
let bytes = serde_json::to_vec_pretty(rev)?;
std::fs::write(
pkg_dir.join(APPROVALS_DIR).join(format!("revocations-{safe}.json")),
&bytes,
)?;
revocation_ids.push(rev.revocation_id.clone());
file_count += 1;
}
let index = ApprovalsIndex {
type_: ApprovalsIndex::type_string().into(),
schema_version: 1,
grants: grant_ids,
uses: use_ids,
checkpoints: checkpoint_ids,
revocations: revocation_ids,
};
let index_bytes = serde_json::to_vec_pretty(&index)?;
std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
file_count += 1;
}
}
Ok(PackageOutput {
path: pkg_dir,
receipt_digest,
merkle_root: receipt.merkle.root.clone(),
file_count,
})
}
fn sanitize_filename(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' { c } else { '_' })
.collect()
}
pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
let approvals_dir = pkg_dir.join(APPROVALS_DIR);
if !approvals_dir.is_dir() {
return Ok(ApprovalsBundle::default());
}
let mut bundle = ApprovalsBundle::default();
let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
if grants_dir.is_dir() {
for entry in std::fs::read_dir(&grants_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
let bytes = std::fs::read(&path)?;
bundle.grants.push((id, bytes));
}
}
let uses_dir = pkg_dir.join(APPROVALS_USES);
if uses_dir.is_dir() {
for entry in std::fs::read_dir(&uses_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
let bytes = std::fs::read(&path)?;
let u: ApprovalUse = serde_json::from_slice(&bytes)?;
bundle.uses.push(u);
}
}
let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
if cps_dir.is_dir() {
for entry in std::fs::read_dir(&cps_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
let bytes = std::fs::read(&path)?;
let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
bundle.checkpoints.push(cp);
}
}
let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
if arts_dir.is_dir() {
for entry in std::fs::read_dir(&arts_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
let bytes = std::fs::read(&path)?;
bundle.action_envelopes.push((id, bytes));
}
}
Ok(bundle)
}
pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
let receipt_path = pkg_dir.join(RECEIPT_FILE);
if !receipt_path.exists() {
return Err(PackageError::InvalidPackage(
format!("missing {RECEIPT_FILE} in {}", pkg_dir.display()),
));
}
let bytes = std::fs::read(&receipt_path)?;
let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
if receipt.type_ != RECEIPT_TYPE {
return Err(PackageError::InvalidPackage(
format!("unexpected type: {} (expected {RECEIPT_TYPE})", receipt.type_),
));
}
Ok(receipt)
}
pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
Ok(t) => t,
Err(e) => {
return Ok(vec![VerifyCheck::fail(
"trust-root",
&format!("trust store unreadable: {e}"),
)]);
}
};
verify_package_with_trust(pkg_dir, &trust)
}
pub fn verify_package_with_trust(
pkg_dir: &Path,
trust: &crate::trust::TrustRootStore,
) -> Result<Vec<VerifyCheck>, PackageError> {
let mut checks = Vec::new();
let receipt = match read_package(pkg_dir) {
Ok(r) => {
checks.push(VerifyCheck::pass("receipt.json", "Parses as valid Session Receipt"));
r
}
Err(e) => {
checks.push(VerifyCheck::fail("receipt.json", &format!("Failed to parse: {e}")));
return Ok(checks);
}
};
if receipt.type_ == RECEIPT_TYPE {
checks.push(VerifyCheck::pass("type", "Correct receipt type"));
} else {
checks.push(VerifyCheck::fail("type", &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_)));
}
let receipt_path = pkg_dir.join(RECEIPT_FILE);
let on_disk = std::fs::read(&receipt_path)?;
let re_serialized = serde_json::to_vec_pretty(&receipt)?;
if on_disk == re_serialized {
checks.push(VerifyCheck::pass("determinism", "receipt.json round-trips identically"));
} else {
checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
}
if !receipt.artifacts.is_empty() {
let version = receipt.merkle.merkle_version;
let mut tree = match crate::merkle::MerkleTree::with_version(version) {
Ok(t) => t,
Err(e) => {
checks.push(VerifyCheck::fail(
"merkle_root",
&format!("receipt declared unknown merkle_version: {e}"),
));
return Ok(finish_package_checks(checks, &receipt));
}
};
for art in &receipt.artifacts {
tree.append(&art.artifact_id);
}
let root_bytes = tree.root();
let recomputed_root = root_bytes
.map(|r| format!("mroot_{}", hex::encode(r)));
let root_hex = root_bytes
.map(|r| hex::encode(r))
.unwrap_or_default();
if recomputed_root == receipt.merkle.root {
checks.push(VerifyCheck::pass("merkle_root", "Merkle root matches recomputed value"));
} else {
checks.push(VerifyCheck::fail(
"merkle_root",
&format!(
"Mismatch: on-disk {:?} vs recomputed {:?}",
receipt.merkle.root, recomputed_root
),
));
}
for proof_entry in &receipt.merkle.inclusion_proofs {
if proof_entry.proof.merkle_version != version {
checks.push(VerifyCheck::fail(
&format!("inclusion:{}", proof_entry.artifact_id),
&format!(
"proof merkle_version {} != receipt section v{}",
proof_entry.proof.merkle_version, version,
),
));
continue;
}
let verified = crate::merkle::MerkleTree::verify_proof(
version,
&root_hex,
&proof_entry.artifact_id,
&proof_entry.proof,
);
if verified {
checks.push(VerifyCheck::pass(
&format!("inclusion:{}", proof_entry.artifact_id),
"Inclusion proof valid",
));
} else {
checks.push(VerifyCheck::fail(
&format!("inclusion:{}", proof_entry.artifact_id),
"Inclusion proof failed verification",
));
}
}
} else {
checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
}
if receipt.merkle.leaf_count == receipt.artifacts.len() {
checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
} else {
checks.push(VerifyCheck::fail(
"leaf_count",
&format!("leaf_count {} != artifact count {}", receipt.merkle.leaf_count, receipt.artifacts.len()),
));
}
let ordered = receipt.timeline.windows(2).all(|w| {
(&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
<= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
});
if ordered {
checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
} else {
checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
}
if receipt.proofs.event_log_skipped > 0 {
checks.push(VerifyCheck::warn(
"event_log_completeness",
&format!(
"{} event(s) skipped during close (malformed lines in events.jsonl). \
Receipt is cryptographically valid but does not represent the full event stream. \
Inspect close-time stderr or the events.jsonl directly to investigate.",
receipt.proofs.event_log_skipped,
),
));
}
if receipt.proofs.reconcile_untracked_truncated > 0 {
checks.push(VerifyCheck::warn(
"reconcile_completeness",
&format!(
"untracked git reconcile exceeded cap {} (saw at least {}). \
Per-file synthetic events were skipped and the receipt is bounded, not complete for untracked files.",
receipt.proofs.reconcile_untracked_cap,
receipt.proofs.reconcile_untracked_truncated,
),
));
}
let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
add_approval_evidence_checks(&mut checks, &bundle, trust);
Ok(checks)
}
fn finish_package_checks(
mut checks: Vec<VerifyCheck>,
receipt: &SessionReceipt,
) -> Vec<VerifyCheck> {
if receipt.merkle.leaf_count == receipt.artifacts.len() {
checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
} else {
checks.push(VerifyCheck::fail(
"leaf_count",
&format!(
"leaf_count {} != artifact count {}",
receipt.merkle.leaf_count, receipt.artifacts.len(),
),
));
}
let ordered = receipt.timeline.windows(2).all(|w| {
(&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
<= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
});
if ordered {
checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
} else {
checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
}
checks
}
pub(crate) fn add_approval_evidence_checks(
checks: &mut Vec<VerifyCheck>,
bundle: &ApprovalsBundle,
trust: &crate::trust::TrustRootStore,
) {
if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
return;
}
use std::collections::HashMap;
let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
for u in &bundle.uses {
by_nonce
.entry((u.grant_id.clone(), u.nonce_digest.clone()))
.or_default()
.push(u);
by_use_id.entry(&u.use_id).or_default().push(u);
}
let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
.iter()
.filter_map(|(key, uses)| {
let max = uses.iter().filter_map(|u| u.max_uses).next()?;
if (uses.len() as u32) > max {
Some((key.clone(), uses.iter().map(|u| *u).collect(), max))
} else {
None
}
})
.collect();
let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
if over_max.is_empty() && dup_use_ids.is_empty() {
checks.push(VerifyCheck::pass(
"replay-package-local",
&format!("no duplicate approval use inside package ({} uses scanned)", bundle.uses.len()),
));
} else {
let mut detail = String::from("package-local replay violation:");
for ((grant_id, _nd), uses, max) in &over_max {
detail.push_str(&format!(
" grant {grant_id} consumed {} times in this package (max_uses={max});",
uses.len(),
));
}
for (uid, uses) in &dup_use_ids {
detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
}
checks.push(VerifyCheck::fail("replay-package-local", &detail));
}
if !bundle.checkpoints.is_empty() {
let mut tampered = Vec::new();
for cp in &bundle.checkpoints {
let recomputed = journal_checkpoint_record_digest(cp);
if recomputed != cp.record_digest {
tampered.push((cp.checkpoint_id.clone(), cp.record_digest.clone(), recomputed));
}
}
if tampered.is_empty() {
checks.push(VerifyCheck::pass(
"replay-included-checkpoint",
&format!("{} included journal checkpoint(s) verify offline", bundle.checkpoints.len()),
));
} else {
let detail = tampered.iter()
.map(|(id, expected, actual)| {
format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
})
.collect::<Vec<_>>()
.join("; ");
checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
}
}
let mut tampered_uses = Vec::new();
for u in &bundle.uses {
let recomputed = approval_use_record_digest(u);
if recomputed != u.record_digest {
tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
}
}
if !bundle.uses.is_empty() {
if tampered_uses.is_empty() {
checks.push(VerifyCheck::pass(
"approval-use-record-digest",
&format!("{} use record(s) recompute identically", bundle.uses.len()),
));
} else {
let detail = tampered_uses.iter()
.map(|(id, expected, actual)| {
format!("use {id} tampered (stored {expected}, recomputed {actual})")
})
.collect::<Vec<_>>()
.join("; ");
checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
}
}
if !bundle.uses.is_empty() {
use crate::attestation::envelope::Envelope;
use crate::attestation::{pae, artifact_id_from_pae};
use crate::statements::{nonce_digest, ApprovalStatement};
let mut grant_nonce_digest: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut tampered_grants: Vec<String> = Vec::new();
for (grant_id, env_bytes) in &bundle.grants {
let env = match Envelope::from_json(env_bytes) {
Ok(e) => e,
Err(_) => {
tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
continue;
}
};
let derived = match env.payload_bytes() {
Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
Err(_) => {
tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
continue;
}
};
if &derived != grant_id {
tampered_grants.push(format!(
"grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
));
continue;
}
let approval: ApprovalStatement = match env.unmarshal_statement() {
Ok(a) => a,
Err(_) => {
tampered_grants.push(format!("grant {grant_id} payload not an ApprovalStatement"));
continue;
}
};
grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
}
let mut mismatches: Vec<String> = Vec::new();
let mut missing_grants: Vec<String> = Vec::new();
for u in &bundle.uses {
match grant_nonce_digest.get(&u.grant_id) {
Some(expected) => {
if expected != &u.nonce_digest {
mismatches.push(format!(
"use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
u.use_id, u.nonce_digest, u.grant_id, expected,
));
}
}
None => {
missing_grants.push(format!(
"use {} references grant {} but no usable grant envelope is in the package",
u.use_id, u.grant_id,
));
}
}
}
if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
checks.push(VerifyCheck::pass(
"approval-use-nonce-binding",
&format!(
"{} use record(s) bind to content-addressed grant signed nonces",
bundle.uses.len(),
),
));
} else {
let mut parts: Vec<String> = Vec::new();
if !tampered_grants.is_empty() { parts.push(tampered_grants.join("; ")); }
if !mismatches.is_empty() { parts.push(mismatches.join("; ")); }
if !missing_grants.is_empty() { parts.push(missing_grants.join("; ")); }
checks.push(VerifyCheck::fail("approval-use-nonce-binding", &parts.join("; ")));
}
}
if !bundle.uses.is_empty() {
use crate::attestation::envelope::Envelope;
use crate::attestation::{pae, artifact_id_from_pae};
use crate::statements::{nonce_digest, ActionStatement};
if bundle.action_envelopes.is_empty() {
checks.push(VerifyCheck::warn(
"approval-use-action-binding",
"no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
));
} else {
let use_ids: std::collections::HashSet<&str> = bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
let mut violations: Vec<String> = Vec::new();
let mut bound_count = 0usize;
for (artifact_id, env_bytes) in &bundle.action_envelopes {
let env = match Envelope::from_json(env_bytes) {
Ok(e) => e,
Err(_) => {
violations.push(format!("action {artifact_id} envelope unparseable"));
continue;
}
};
let derived = match env.payload_bytes() {
Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
Err(_) => {
violations.push(format!("action {artifact_id} envelope payload undecodable"));
continue;
}
};
if &derived != artifact_id {
violations.push(format!(
"action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
));
continue;
}
let action: ActionStatement = match env.unmarshal_statement() {
Ok(a) => a,
Err(_) => {
violations.push(format!("action {artifact_id} not an ActionStatement"));
continue;
}
};
let raw_nonce = match action.approval_nonce.as_deref() {
Some(n) => n,
None => continue,
};
let claimed_use_id = action
.meta
.as_ref()
.and_then(|m| m.get("approval_use_id"))
.and_then(|v| v.as_str());
let Some(claimed_use_id) = claimed_use_id else {
violations.push(format!(
"action {artifact_id} consumed an approval but its meta has no approval_use_id"
));
continue;
};
if !use_ids.contains(claimed_use_id) {
violations.push(format!(
"action {artifact_id} claims approval_use_id={} but no such use is embedded",
claimed_use_id,
));
continue;
}
let expected = nonce_digest(raw_nonce);
let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
if let Some(u) = matched_use {
if u.nonce_digest != expected {
violations.push(format!(
"action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
expected, claimed_use_id, u.nonce_digest,
));
continue;
}
}
bound_count += 1;
}
if violations.is_empty() {
checks.push(VerifyCheck::pass(
"approval-use-action-binding",
&format!(
"{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
),
));
} else {
checks.push(VerifyCheck::fail(
"approval-use-action-binding",
&violations.join("; "),
));
}
}
}
if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
use std::collections::{HashMap, HashSet};
struct Node<'a> { label: String, digest: &'a str, prev: &'a str }
let mut nodes: Vec<Node> = Vec::new();
for u in &bundle.uses {
nodes.push(Node {
label: format!("use {}", u.use_id),
digest: u.record_digest.as_str(),
prev: u.previous_record_digest.as_str(),
});
}
for cp in &bundle.checkpoints {
nodes.push(Node {
label: format!("checkpoint {}", cp.checkpoint_id),
digest: cp.record_digest.as_str(),
prev: cp.previous_record_digest.as_str(),
});
}
let owned: HashSet<&str> = std::iter::once("")
.chain(nodes.iter().map(|n| n.digest))
.collect();
let mut violations: Vec<String> = Vec::new();
for n in &nodes {
if !owned.contains(n.prev) {
violations.push(format!(
"{} previous_record_digest {} not anchored in package",
n.label, n.prev,
));
}
}
let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
if genesis.len() > 1 {
violations.push(format!(
"{} records claim previous_record_digest='' (genesis): {}",
genesis.len(),
genesis.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join(", "),
));
}
let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
for n in &nodes {
by_prev.entry(n.prev).or_default().push(n);
}
for (prev, group) in &by_prev {
if group.len() > 1 && !prev.is_empty() {
violations.push(format!(
"fork: {} records share previous_record_digest {}: {}",
group.len(),
prev,
group.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join(", "),
));
}
}
if violations.is_empty() {
let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
let next_of: HashMap<&str, &Node> = nodes
.iter()
.filter(|n| !n.prev.is_empty())
.map(|n| (n.prev, *(&n)))
.collect();
let start = match genesis.first() {
Some(g) => Some(*g),
None => None,
};
let mut visited: HashSet<&str> = HashSet::new();
let mut current = start;
while let Some(node) = current {
if !visited.insert(node.digest) {
violations.push(format!(
"cycle detected at {} (record_digest {})",
node.label, node.digest,
));
break;
}
current = next_of.get(node.digest).copied();
}
if violations.is_empty() && visited.len() != nodes.len() {
let unreached: Vec<String> = nodes.iter()
.filter(|n| !visited.contains(n.digest))
.map(|n| n.label.clone())
.collect();
if !unreached.is_empty() {
violations.push(format!(
"disconnected subchain: {} record(s) not reachable from genesis: {}",
unreached.len(),
unreached.join(", "),
));
}
}
let _ = by_digest; }
if violations.is_empty() {
checks.push(VerifyCheck::pass(
"approval-use-chain-continuity",
&format!(
"{} record(s) form a single connected linked list from one genesis with no cycles or forks",
nodes.len(),
),
));
} else {
checks.push(VerifyCheck::fail(
"approval-use-chain-continuity",
&violations.join("; "),
));
}
}
let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
.checkpoints
.iter()
.filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
.collect();
if !hub_checkpoints.is_empty() {
let mut all_ok = true;
let mut details: Vec<String> = Vec::new();
let mut have_valid_signature = false;
let mut security_fatal = false;
for cp in &hub_checkpoints {
match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
crate::statements::HubCheckpointVerification::Valid => {
have_valid_signature = true;
let covered: std::collections::HashSet<&String> =
cp.covered_use_ids.iter().collect();
let missing: Vec<String> = bundle
.uses
.iter()
.filter(|u| !covered.contains(&u.use_id))
.map(|u| u.use_id.clone())
.collect();
if missing.is_empty() {
details.push(format!(
"{} signed by {} verifies; covers {} use(s)",
cp.checkpoint_id,
cp.hub_id,
cp.covered_use_ids.len(),
));
} else {
all_ok = false;
details.push(format!(
"{} verifies but does not cover {} use(s): {}",
cp.checkpoint_id,
missing.len(),
missing.join(", "),
));
}
}
crate::statements::HubCheckpointVerification::MissingFields(field) => {
all_ok = false;
details.push(format!(
"{} declares kind=hub-org but field `{}` is missing",
cp.checkpoint_id, field,
));
}
crate::statements::HubCheckpointVerification::Tampered => {
all_ok = false;
security_fatal = true;
details.push(format!(
"{} hub signature failed verification (tampered or wrong key)",
cp.checkpoint_id,
));
}
crate::statements::HubCheckpointVerification::NotHubKind => {
all_ok = false;
security_fatal = true;
details.push(format!(
"{} kind toggled out of hub-org during verify",
cp.checkpoint_id,
));
}
crate::statements::HubCheckpointVerification::UntrustedIssuer => {
all_ok = false;
security_fatal = true;
details.push(format!(
"{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
cp.checkpoint_id,
));
}
}
}
if all_ok && have_valid_signature {
checks.push(VerifyCheck::pass(
"replay-hub-org",
&details.join("; "),
));
} else if security_fatal {
checks.push(VerifyCheck::fail(
"replay-hub-org",
&details.join("; "),
));
} else {
checks.push(VerifyCheck::warn(
"replay-hub-org",
&details.join("; "),
));
}
}
let _ = ReplayCheckLevel::HubOrg;
let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
let _ = ReplayCheck::not_performed;
}
#[derive(Debug, Clone)]
pub struct VerifyCheck {
pub name: String,
pub status: VerifyStatus,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyStatus {
Pass,
Fail,
Warn,
}
impl VerifyCheck {
pub fn pass(name: &str, detail: &str) -> Self {
Self { name: name.into(), status: VerifyStatus::Pass, detail: detail.into() }
}
pub fn fail(name: &str, detail: &str) -> Self {
Self { name: name.into(), status: VerifyStatus::Fail, detail: detail.into() }
}
pub fn warn(name: &str, detail: &str) -> Self {
Self { name: name.into(), status: VerifyStatus::Warn, detail: detail.into() }
}
}
impl VerifyCheck {
pub fn passed(&self) -> bool {
self.status == VerifyStatus::Pass
}
}
const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
pub fn render_preview_html(receipt: &SessionReceipt) -> String {
let receipt_json = serde_json::to_string_pretty(receipt)
.unwrap_or_else(|_| "{}".to_string());
let safe_json = receipt_json.replace('<', r"\u003c");
PREVIEW_TEMPLATE
.replacen("__RECEIPT_JSON__", &safe_json, 1)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::event::*;
use crate::session::manifest::SessionManifest;
use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
fn make_receipt() -> SessionReceipt {
let manifest = SessionManifest::new(
"ssn_pkg_test".into(),
"agent://test".into(),
"2026-04-05T08:00:00Z".into(),
1743843600000,
);
let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
SessionEvent {
session_id: "ssn_pkg_test".into(),
event_id: format!("evt_{:016x}", seq),
timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
sequence_no: seq,
trace_id: "trace_1".into(),
span_id: format!("span_{seq}"),
parent_span_id: None,
agent_id: format!("agent://{inst}"),
agent_instance_id: inst.into(),
agent_name: inst.into(),
agent_role: None,
host_id: "host_1".into(),
tool_runtime_id: None,
event_type: et,
artifact_ref: None,
meta: None,
}
};
let events = vec![
mk(0, "root", EventType::SessionStarted),
mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
mk(2, "root", EventType::AgentCalledTool {
tool_name: "read_file".into(),
tool_input_digest: None,
tool_output_digest: None,
duration_ms: Some(10),
}),
mk(3, "root", EventType::AgentCompleted { termination_reason: None }),
mk(4, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(60000) }),
];
let artifacts = vec![
ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
];
ReceiptComposer::compose(&manifest, &events, artifacts)
}
#[test]
fn build_and_read_package() {
let receipt = make_receipt();
let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
let output = build_package(&receipt, &tmp).unwrap();
assert!(output.path.exists());
assert!(output.path.join("receipt.json").exists());
assert!(output.path.join("merkle.json").exists());
assert!(output.path.join("render.json").exists());
assert!(output.path.join("preview.html").exists());
assert!(output.receipt_digest.starts_with("sha256:"));
assert!(output.file_count >= 4);
let read_back = read_package(&output.path).unwrap();
assert_eq!(read_back.session.id, "ssn_pkg_test");
assert_eq!(read_back.type_, RECEIPT_TYPE);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn verify_valid_package() {
let receipt = make_receipt();
let tmp = std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
let output = build_package(&receipt, &tmp).unwrap();
let checks = verify_package(&output.path).unwrap();
let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
assert!(fails.is_empty(), "unexpected failures: {fails:?}");
let passes: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Pass).collect();
assert!(passes.len() >= 5, "expected at least 5 pass checks, got {}", passes.len());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn verify_detects_missing_receipt() {
let tmp = std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
std::fs::create_dir_all(&tmp).unwrap();
let err = read_package(&tmp);
assert!(err.is_err());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn preview_html_contains_session_info() {
let receipt = make_receipt();
let html = render_preview_html(&receipt);
assert!(html.contains("ssn_pkg_test"));
assert!(html.contains("treeship.dev"));
assert!(html.contains("Timeline"));
assert!(
html.contains("'__RECEIPT'+'_JSON__'"),
"JS placeholder check was clobbered by the receipt substitution",
);
assert!(
!html.contains("application/json\">__RECEIPT_JSON__</script>"),
"data slot was not substituted with the receipt JSON",
);
assert_eq!(
html.matches("__RECEIPT_JSON__").count(),
0,
"no raw placeholder token should remain after substitution",
);
}
}