1use std::path::{Path, PathBuf};
13
14use crate::statements::ApprovalStatement;
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use super::receipt::{ArtifactEntry, SessionReceipt, RECEIPT_TYPE};
19use crate::statements::{
20 approval_revocation_record_digest, approval_use_record_digest,
21 journal_checkpoint_record_digest, ApprovalRevocation, ApprovalUse, JournalCheckpoint,
22 ReplayCheck, ReplayCheckLevel,
23};
24
25#[derive(Debug)]
27pub enum PackageError {
28 Io(std::io::Error),
29 Json(serde_json::Error),
30 InvalidPackage(String),
31}
32
33impl std::fmt::Display for PackageError {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 Self::Io(e) => write!(f, "package io: {e}"),
37 Self::Json(e) => write!(f, "package json: {e}"),
38 Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
39 }
40 }
41}
42
43impl std::error::Error for PackageError {}
44impl From<std::io::Error> for PackageError {
45 fn from(e: std::io::Error) -> Self {
46 Self::Io(e)
47 }
48}
49impl From<serde_json::Error> for PackageError {
50 fn from(e: serde_json::Error) -> Self {
51 Self::Json(e)
52 }
53}
54
55const RECEIPT_FILE: &str = "receipt.json";
57const MERKLE_FILE: &str = "merkle.json";
58const RENDER_FILE: &str = "render.json";
59const ARTIFACTS_DIR: &str = "artifacts";
60const PROOFS_DIR: &str = "proofs";
61const PREVIEW_FILE: &str = "preview.html";
62
63const APPROVALS_DIR: &str = "approvals";
76const APPROVALS_GRANTS: &str = "approvals/grants";
77const APPROVALS_USES: &str = "approvals/uses";
78const APPROVALS_CHECKPOINTS: &str = "approvals/checkpoints";
79const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
80
81#[derive(Debug, Clone, Default)]
91pub struct ApprovalsBundle {
92 pub grants: Vec<(String, Vec<u8>)>,
97 pub uses: Vec<ApprovalUse>,
101 pub checkpoints: Vec<JournalCheckpoint>,
104 pub revocations: Vec<ApprovalRevocation>,
109
110 pub action_envelopes: Vec<(String, Vec<u8>)>,
118
119 pub sealed_envelopes: Vec<(String, Vec<u8>)>,
125 pub signer_keys: Vec<(String, String)>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, Default)]
134pub struct PackageKeys {
135 pub schema: String,
136 pub keys: std::collections::BTreeMap<String, String>,
138}
139
140pub const KEYS_FILE: &str = "keys.json";
141pub const PACKAGE_KEYS_SCHEMA: &str = "treeship/package-keys/v1";
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ApprovalsIndex {
148 #[serde(rename = "type")]
150 pub type_: String,
151 pub schema_version: u32,
152 pub grants: Vec<String>,
155 pub uses: Vec<String>,
157 pub checkpoints: Vec<String>,
158 pub revocations: Vec<String>,
159}
160
161impl ApprovalsIndex {
162 pub fn type_string() -> &'static str {
163 "treeship/approvals-index/v1"
164 }
165}
166
167pub struct PackageOutput {
169 pub path: PathBuf,
171 pub receipt_digest: String,
173 pub merkle_root: Option<String>,
175 pub file_count: usize,
177}
178
179pub fn build_package(
189 receipt: &SessionReceipt,
190 output_dir: &Path,
191) -> Result<PackageOutput, PackageError> {
192 build_package_with_approvals(receipt, output_dir, None)
193}
194
195pub fn build_package_with_approvals(
199 receipt: &SessionReceipt,
200 output_dir: &Path,
201 bundle: Option<&ApprovalsBundle>,
202) -> Result<PackageOutput, PackageError> {
203 let session_id = &receipt.session.id;
204 let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
205
206 std::fs::create_dir_all(&pkg_dir)?;
207 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
208 std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
209
210 let mut file_count = 0usize;
211
212 let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
214 std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
215 file_count += 1;
216
217 let receipt_hash = Sha256::digest(&receipt_bytes);
218 let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
219
220 let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
222 std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
223 file_count += 1;
224
225 let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
227 std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
228 file_count += 1;
229
230 for proof_entry in &receipt.merkle.inclusion_proofs {
232 let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
233 let filename = format!("{}.proof.json", proof_entry.artifact_id);
234 std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
235 file_count += 1;
236 }
237
238 if receipt.render.generate_preview {
240 let preview = render_preview_html_with_approvals(receipt, bundle);
241 std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
242 file_count += 1;
243 }
244
245 if let Some(b) = bundle {
250 if !b.sealed_envelopes.is_empty() {
253 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
254 for (artifact_id, envelope_bytes) in &b.sealed_envelopes {
255 let safe = sanitize_filename(artifact_id);
256 let path = pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json"));
257 if !path.exists() {
258 std::fs::write(path, envelope_bytes)?;
259 file_count += 1;
260 }
261 }
262 }
263 if !b.signer_keys.is_empty() {
264 let keys = PackageKeys {
265 schema: PACKAGE_KEYS_SCHEMA.into(),
266 keys: b.signer_keys.iter().cloned().collect(),
267 };
268 std::fs::write(pkg_dir.join(KEYS_FILE), serde_json::to_vec_pretty(&keys)?)?;
269 file_count += 1;
270 }
271 if !b.grants.is_empty()
272 || !b.uses.is_empty()
273 || !b.checkpoints.is_empty()
274 || !b.revocations.is_empty()
275 || !b.action_envelopes.is_empty()
276 {
277 std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
278 std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
279 std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
280 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
286 for (artifact_id, envelope_bytes) in &b.action_envelopes {
287 let safe = sanitize_filename(artifact_id);
288 std::fs::write(
289 pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
290 envelope_bytes,
291 )?;
292 file_count += 1;
293 }
294
295 let mut grant_ids = Vec::with_capacity(b.grants.len());
296 for (grant_id, envelope_bytes) in &b.grants {
297 let safe = sanitize_filename(grant_id);
298 std::fs::write(
299 pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
300 envelope_bytes,
301 )?;
302 grant_ids.push(grant_id.clone());
303 file_count += 1;
304 }
305
306 let mut use_ids = Vec::with_capacity(b.uses.len());
307 for u in &b.uses {
308 let safe = sanitize_filename(&u.use_id);
309 let bytes = serde_json::to_vec_pretty(u)?;
310 std::fs::write(
311 pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
312 &bytes,
313 )?;
314 use_ids.push(u.use_id.clone());
315 file_count += 1;
316 }
317
318 let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
319 for cp in &b.checkpoints {
320 let safe = sanitize_filename(&cp.checkpoint_id);
321 let bytes = serde_json::to_vec_pretty(cp)?;
322 std::fs::write(
323 pkg_dir
324 .join(APPROVALS_CHECKPOINTS)
325 .join(format!("{safe}.json")),
326 &bytes,
327 )?;
328 checkpoint_ids.push(cp.checkpoint_id.clone());
329 file_count += 1;
330 }
331
332 let mut revocation_ids = Vec::with_capacity(b.revocations.len());
333 for rev in &b.revocations {
334 let safe = sanitize_filename(&rev.revocation_id);
335 let bytes = serde_json::to_vec_pretty(rev)?;
336 std::fs::write(
337 pkg_dir
338 .join(APPROVALS_DIR)
339 .join(format!("revocations-{safe}.json")),
340 &bytes,
341 )?;
342 revocation_ids.push(rev.revocation_id.clone());
343 file_count += 1;
344 }
345
346 let index = ApprovalsIndex {
347 type_: ApprovalsIndex::type_string().into(),
348 schema_version: 1,
349 grants: grant_ids,
350 uses: use_ids,
351 checkpoints: checkpoint_ids,
352 revocations: revocation_ids,
353 };
354 let index_bytes = serde_json::to_vec_pretty(&index)?;
355 std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
356 file_count += 1;
357 }
358 }
359
360 Ok(PackageOutput {
361 path: pkg_dir,
362 receipt_digest,
363 merkle_root: receipt.merkle.root.clone(),
364 file_count,
365 })
366}
367
368fn sanitize_filename(s: &str) -> String {
372 s.chars()
373 .map(|c| {
374 if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' {
375 c
376 } else {
377 '_'
378 }
379 })
380 .collect()
381}
382
383pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
393 let approvals_dir = pkg_dir.join(APPROVALS_DIR);
394 if !approvals_dir.is_dir() {
395 return Ok(ApprovalsBundle::default());
396 }
397
398 let mut bundle = ApprovalsBundle::default();
399
400 let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
403 if grants_dir.is_dir() {
404 for entry in std::fs::read_dir(&grants_dir)? {
405 let entry = entry?;
406 let path = entry.path();
407 if path.extension().and_then(|s| s.to_str()) != Some("json") {
408 continue;
409 }
410 let id = path
411 .file_stem()
412 .and_then(|s| s.to_str())
413 .unwrap_or("")
414 .to_string();
415 let bytes = std::fs::read(&path)?;
416 bundle.grants.push((id, bytes));
417 }
418 }
419
420 let uses_dir = pkg_dir.join(APPROVALS_USES);
421 if uses_dir.is_dir() {
422 for entry in std::fs::read_dir(&uses_dir)? {
423 let entry = entry?;
424 let path = entry.path();
425 if path.extension().and_then(|s| s.to_str()) != Some("json") {
426 continue;
427 }
428 let bytes = std::fs::read(&path)?;
429 let u: ApprovalUse = serde_json::from_slice(&bytes)?;
430 bundle.uses.push(u);
431 }
432 }
433
434 let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
435 if cps_dir.is_dir() {
436 for entry in std::fs::read_dir(&cps_dir)? {
437 let entry = entry?;
438 let path = entry.path();
439 if path.extension().and_then(|s| s.to_str()) != Some("json") {
440 continue;
441 }
442 let bytes = std::fs::read(&path)?;
443 let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
444 bundle.checkpoints.push(cp);
445 }
446 }
447
448 let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
455 if arts_dir.is_dir() {
456 for entry in std::fs::read_dir(&arts_dir)? {
457 let entry = entry?;
458 let path = entry.path();
459 if path.extension().and_then(|s| s.to_str()) != Some("json") {
460 continue;
461 }
462 let id = path
463 .file_stem()
464 .and_then(|s| s.to_str())
465 .unwrap_or("")
466 .to_string();
467 let bytes = std::fs::read(&path)?;
468 bundle.action_envelopes.push((id, bytes));
469 }
470 }
471
472 Ok(bundle)
473}
474
475pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
477 let receipt_path = pkg_dir.join(RECEIPT_FILE);
478 if !receipt_path.exists() {
479 return Err(PackageError::InvalidPackage(format!(
480 "missing {RECEIPT_FILE} in {}",
481 pkg_dir.display()
482 )));
483 }
484 let bytes = std::fs::read(&receipt_path)?;
485 let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
486
487 if receipt.type_ != RECEIPT_TYPE {
488 return Err(PackageError::InvalidPackage(format!(
489 "unexpected type: {} (expected {RECEIPT_TYPE})",
490 receipt.type_
491 )));
492 }
493
494 Ok(receipt)
495}
496
497pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
515 let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
516 Ok(t) => t,
517 Err(e) => {
518 return Ok(vec![VerifyCheck::fail(
522 "trust-root",
523 &format!("trust store unreadable: {e}"),
524 )]);
525 }
526 };
527 verify_package_with_trust(pkg_dir, &trust)
528}
529
530pub fn verify_package_with_trust(
534 pkg_dir: &Path,
535 trust: &crate::trust::TrustRootStore,
536) -> Result<Vec<VerifyCheck>, PackageError> {
537 verify_package_with_options(pkg_dir, trust, false)
538}
539
540pub fn verify_package_structural(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
547 let trust = crate::trust::TrustRootStore::open_default_or_empty()
548 .unwrap_or_else(|_| crate::trust::TrustRootStore::empty());
549 verify_package_with_options(pkg_dir, &trust, true)
550}
551
552pub fn verify_package_with_options(
553 pkg_dir: &Path,
554 trust: &crate::trust::TrustRootStore,
555 structural_only: bool,
556) -> Result<Vec<VerifyCheck>, PackageError> {
557 let mut checks = Vec::new();
558
559 let receipt = match read_package(pkg_dir) {
561 Ok(r) => {
562 checks.push(VerifyCheck::pass(
563 "receipt.json",
564 "Parses as valid Session Receipt",
565 ));
566 r
567 }
568 Err(e) => {
569 checks.push(VerifyCheck::fail(
570 "receipt.json",
571 &format!("Failed to parse: {e}"),
572 ));
573 return Ok(checks);
574 }
575 };
576
577 if receipt.type_ == RECEIPT_TYPE {
579 checks.push(VerifyCheck::pass("type", "Correct receipt type"));
580 } else {
581 checks.push(VerifyCheck::fail(
582 "type",
583 &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_),
584 ));
585 }
586
587 let receipt_path = pkg_dir.join(RECEIPT_FILE);
602 let on_disk = std::fs::read(&receipt_path)?;
603 let re_serialized = serde_json::to_vec_pretty(&receipt)?;
604 if on_disk == re_serialized {
605 checks.push(VerifyCheck::pass(
606 "determinism",
607 "receipt.json round-trips identically (structural, NOT a signature)",
608 ));
609 } else {
610 checks.push(VerifyCheck::warn(
612 "determinism",
613 "receipt.json does not byte-match after re-serialization",
614 ));
615 }
616
617 checks.push(VerifyCheck::warn(
624 "receipt_body_binding",
625 "timeline/side-effects/narrative are NOT signed in this package — only the artifacts and Merkle root are cryptographically bound. For an authenticated record of the session, verify the actor-signed session.v1 record (or the published report).",
626 ));
627
628 if !receipt.artifacts.is_empty() {
630 let version = receipt.merkle.merkle_version;
637 let mut tree = match crate::merkle::MerkleTree::with_version(version) {
638 Ok(t) => t,
639 Err(e) => {
640 checks.push(VerifyCheck::fail(
641 "merkle_root",
642 &format!("receipt declared unknown merkle_version: {e}"),
643 ));
644 return Ok(finish_package_checks(checks, &receipt));
647 }
648 };
649 for art in &receipt.artifacts {
650 tree.append(&art.artifact_id);
651 }
652 let root_bytes = tree.root();
653 let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
654 let root_hex = root_bytes.map(hex::encode).unwrap_or_default();
655
656 if recomputed_root == receipt.merkle.root {
657 checks.push(VerifyCheck::pass(
658 "merkle_root",
659 "Merkle root matches recomputed value",
660 ));
661 } else {
662 checks.push(VerifyCheck::fail(
663 "merkle_root",
664 &format!(
665 "Mismatch: on-disk {:?} vs recomputed {:?}",
666 receipt.merkle.root, recomputed_root
667 ),
668 ));
669 }
670
671 for proof_entry in &receipt.merkle.inclusion_proofs {
676 if proof_entry.proof.merkle_version != version {
677 checks.push(VerifyCheck::fail(
678 &format!("inclusion:{}", proof_entry.artifact_id),
679 &format!(
680 "proof merkle_version {} != receipt section v{}",
681 proof_entry.proof.merkle_version, version,
682 ),
683 ));
684 continue;
685 }
686 let verified = crate::merkle::MerkleTree::verify_proof(
687 version,
688 &root_hex,
689 &proof_entry.artifact_id,
690 &proof_entry.proof,
691 );
692 if verified {
693 checks.push(VerifyCheck::pass(
694 &format!("inclusion:{}", proof_entry.artifact_id),
695 "Inclusion proof valid",
696 ));
697 } else {
698 checks.push(VerifyCheck::fail(
699 &format!("inclusion:{}", proof_entry.artifact_id),
700 "Inclusion proof failed verification",
701 ));
702 }
703 }
704 } else {
705 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
706 }
707
708 verify_sealed_envelopes(pkg_dir, &receipt, trust, structural_only, &mut checks);
711
712 if receipt.merkle.leaf_count == receipt.artifacts.len() {
714 checks.push(VerifyCheck::pass(
715 "leaf_count",
716 "Leaf count matches artifact count",
717 ));
718 } else {
719 checks.push(VerifyCheck::fail(
720 "leaf_count",
721 &format!(
722 "leaf_count {} != artifact count {}",
723 receipt.merkle.leaf_count,
724 receipt.artifacts.len()
725 ),
726 ));
727 }
728
729 let ordered = receipt.timeline.windows(2).all(|w| {
731 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
732 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
733 });
734 if ordered {
735 checks.push(VerifyCheck::pass(
736 "timeline_order",
737 "Timeline is correctly ordered",
738 ));
739 } else {
740 checks.push(VerifyCheck::fail(
741 "timeline_order",
742 "Timeline entries are not in deterministic order",
743 ));
744 }
745
746 if receipt.proofs.event_log_skipped > 0 {
754 checks.push(VerifyCheck::warn(
755 "event_log_completeness",
756 &format!(
757 "{} event(s) skipped during close (malformed lines in events.jsonl). \
758 Receipt is cryptographically valid but does not represent the full event stream. \
759 Inspect close-time stderr or the events.jsonl directly to investigate.",
760 receipt.proofs.event_log_skipped,
761 ),
762 ));
763 }
764
765 if receipt.proofs.reconcile_untracked_truncated > 0 {
766 checks.push(VerifyCheck::warn(
767 "reconcile_completeness",
768 &format!(
769 "untracked git reconcile exceeded cap {} (saw at least {}). \
770 Per-file synthetic events were skipped and the receipt is bounded, not complete for untracked files.",
771 receipt.proofs.reconcile_untracked_cap,
772 receipt.proofs.reconcile_untracked_truncated,
773 ),
774 ));
775 }
776
777 if receipt.proofs.reconcile_degraded {
783 checks.push(VerifyCheck::warn(
784 "reconcile_degraded",
785 "the git reconcile backstop was UNAVAILABLE at session close although git worked at start \
786 (.git removed, corrupt index, or git not on PATH). Files changed outside a captured \
787 AgentWroteFile event may be MISSING from this receipt's file ledger — treat the \
788 \"Files changed\" list as incomplete.",
789 ));
790 }
791
792 let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
804 add_approval_evidence_checks(&mut checks, &bundle, trust);
805
806 Ok(checks)
807}
808
809const SIGNER_KINDS: &[crate::trust::TrustRootKind] = &[
815 crate::trust::TrustRootKind::CertIssuer,
816 crate::trust::TrustRootKind::AgentCert,
817 crate::trust::TrustRootKind::SessionHost,
818];
819
820fn read_package_keys(pkg_dir: &Path) -> Option<PackageKeys> {
821 let raw = std::fs::read(pkg_dir.join(KEYS_FILE)).ok()?;
822 serde_json::from_slice(&raw).ok()
823}
824
825fn verify_sealed_envelopes(
836 pkg_dir: &Path,
837 receipt: &SessionReceipt,
838 trust: &crate::trust::TrustRootStore,
839 structural_only: bool,
840 checks: &mut Vec<VerifyCheck>,
841) {
842 use std::collections::{BTreeMap, BTreeSet};
843
844 if receipt.artifacts.is_empty() {
845 return;
846 }
847 let art_dir = pkg_dir.join(ARTIFACTS_DIR);
848 let any_envelope = receipt.artifacts.iter().any(|a| {
849 art_dir
850 .join(format!("{}.json", sanitize_filename(&a.artifact_id)))
851 .exists()
852 });
853 if !any_envelope {
854 let detail = "the package carries no artifact envelopes (built before 0.31.2), so nothing here is signature-checked: the sealed set is structurally consistent and nothing more. Verify the artifacts from the producer's store, a bundle, or the hub with `treeship verify <id>`, or read structure only with --structural (verdict: structural-pass)";
855 checks.push(if structural_only {
856 VerifyCheck::warn("envelopes", detail)
857 } else {
858 VerifyCheck::fail("envelopes", detail)
859 });
860 return;
861 }
862
863 let mut keys: BTreeMap<String, ed25519_dalek::VerifyingKey> = BTreeMap::new();
866 match read_package_keys(pkg_dir) {
867 Some(pk) => {
868 for (id, encoded) in pk.keys {
869 match crate::trust::decode_ed25519_pubkey(&encoded) {
870 Ok(vk) => {
871 keys.insert(id, vk);
872 }
873 Err(e) => checks.push(VerifyCheck::fail(
874 "keys.json",
875 &format!("key {id} is not a valid ed25519 public key: {e}"),
876 )),
877 }
878 }
879 }
880 None => checks.push(VerifyCheck::fail(
881 "keys.json",
882 "package has artifact envelopes but no keys.json naming the signing keys",
883 )),
884 }
885
886 let mut parents: Vec<(String, Option<String>)> = Vec::new();
887 let mut signers: BTreeSet<String> = BTreeSet::new();
888 let mut ok_count = 0usize;
889 for entry in &receipt.artifacts {
890 let id = &entry.artifact_id;
891 let name = format!("signature:{id}");
892 let path = art_dir.join(format!("{}.json", sanitize_filename(id)));
893 let raw = match std::fs::read(&path) {
894 Ok(b) => b,
895 Err(_) => {
896 checks.push(VerifyCheck::fail(
897 &name,
898 "sealed in the Merkle tree but its signed envelope is not in the package",
899 ));
900 parents.push((id.clone(), None));
901 continue;
902 }
903 };
904 let envelope = match crate::attestation::Envelope::from_json(&raw) {
905 Ok(e) => e,
906 Err(e) => {
907 checks.push(VerifyCheck::fail(
908 &name,
909 &format!("envelope does not parse: {e}"),
910 ));
911 parents.push((id.clone(), None));
912 continue;
913 }
914 };
915 let Some(sig) = envelope.signatures.first() else {
916 checks.push(VerifyCheck::fail(&name, "envelope carries no signature"));
917 parents.push((id.clone(), None));
918 continue;
919 };
920 let Some(vk) = keys.get(&sig.keyid) else {
921 checks.push(VerifyCheck::fail(
922 &name,
923 &format!("signed by {}, a key the package does not carry", sig.keyid),
924 ));
925 parents.push((id.clone(), None));
926 continue;
927 };
928 match crate::attestation::verify_with_key(&envelope, &sig.keyid, *vk) {
929 Ok(res) => {
930 if res.artifact_id != *id {
931 checks.push(VerifyCheck::fail(
932 &name,
933 &format!(
934 "the signed bytes re-derive to {}, not the sealed id",
935 res.artifact_id
936 ),
937 ));
938 } else if entry
939 .digest
940 .as_deref()
941 .map(|d| d != res.digest)
942 .unwrap_or(false)
943 {
944 checks.push(VerifyCheck::fail(
945 &name,
946 &format!(
947 "receipt lists digest {} but the signed bytes digest to {}",
948 entry.digest.clone().unwrap_or_default(),
949 res.digest
950 ),
951 ));
952 } else {
953 ok_count += 1;
954 signers.insert(sig.keyid.clone());
955 checks.push(VerifyCheck::pass(&name, &format!("Ed25519 signature by {} verifies; id and digest re-derived from the signed bytes", sig.keyid)));
956 }
957 }
958 Err(e) => checks.push(VerifyCheck::fail(
959 &name,
960 &format!("invalid signature for key {}: {e}", sig.keyid),
961 )),
962 }
963 let parent = envelope
964 .payload_bytes()
965 .ok()
966 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
967 .and_then(|v| {
968 v.get("parentId")
969 .and_then(|p| p.as_str())
970 .map(str::to_string)
971 });
972 parents.push((id.clone(), parent));
973 }
974
975 let chained: Vec<(usize, &ArtifactEntry)> = receipt
979 .artifacts
980 .iter()
981 .enumerate()
982 .filter(|(_, a)| !a.unchained)
983 .collect();
984 let mut broken: Vec<String> = Vec::new();
985 for w in chained.windows(2) {
986 let (i_prev, prev) = w[0];
987 let (i_cur, cur) = w[1];
988 let _ = (i_prev, i_cur);
989 let signed_parent = parents
990 .iter()
991 .find(|(id, _)| *id == cur.artifact_id)
992 .and_then(|(_, p)| p.clone());
993 match signed_parent {
994 Some(p) if p == prev.artifact_id => {}
995 Some(p) => broken.push(format!(
996 "{} names parent {} but follows {}",
997 cur.artifact_id, p, prev.artifact_id
998 )),
999 None => broken.push(format!("{} has no readable parentId", cur.artifact_id)),
1000 }
1001 }
1002 if chained.len() >= 2 {
1003 if broken.is_empty() {
1004 checks.push(VerifyCheck::pass("chain_linkage", &format!("{} chained artifacts each name the previous one as parent, inside the signature", chained.len())));
1005 } else {
1006 checks.push(VerifyCheck::fail("chain_linkage", &broken.join("; ")));
1007 }
1008 }
1009 let unchained: Vec<&str> = receipt
1010 .artifacts
1011 .iter()
1012 .filter(|a| a.unchained)
1013 .map(|a| a.artifact_id.as_str())
1014 .collect();
1015 if !unchained.is_empty() {
1016 checks.push(VerifyCheck::warn("chain_completeness", &format!("{} sealed artifact(s) were signed during the session but never chained onto it ({}); signed and sealed, but their order relative to the chain is the signer's claim only", unchained.len(), unchained.join(", "))));
1017 }
1018
1019 if ok_count > 0 {
1021 let unpinned: Vec<String> = signers
1022 .iter()
1023 .filter(|k| {
1024 let vk = keys.get(*k).expect("signer seen in keys");
1025 !SIGNER_KINDS.iter().any(|kind| trust.contains(vk, *kind))
1026 })
1027 .cloned()
1028 .collect();
1029 if unpinned.is_empty() {
1030 checks.push(VerifyCheck::pass(
1031 "signer_trust",
1032 &format!(
1033 "all {} signing key(s) are pinned trust roots",
1034 signers.len()
1035 ),
1036 ));
1037 } else {
1038 let pins: Vec<String> = unpinned
1039 .iter()
1040 .map(|k| {
1041 let vk = keys.get(k).expect("key");
1042 format!(
1043 "treeship trust add {k} {} --kind cert_issuer",
1044 crate::trust::encode_ed25519_pubkey(vk)
1045 )
1046 })
1047 .collect();
1048 checks.push(VerifyCheck::warn("signer_trust", &format!("signature(s) verify for the key(s) the package names, but {} of them are not pinned trust roots here: {}. Pin what you have decided to trust: {}", unpinned.len(), unpinned.join(", "), pins.join("; "))));
1049 }
1050 }
1051}
1052
1053fn finish_package_checks(
1054 mut checks: Vec<VerifyCheck>,
1055 receipt: &SessionReceipt,
1056) -> Vec<VerifyCheck> {
1057 if receipt.merkle.leaf_count == receipt.artifacts.len() {
1058 checks.push(VerifyCheck::pass(
1059 "leaf_count",
1060 "Leaf count matches artifact count",
1061 ));
1062 } else {
1063 checks.push(VerifyCheck::fail(
1064 "leaf_count",
1065 &format!(
1066 "leaf_count {} != artifact count {}",
1067 receipt.merkle.leaf_count,
1068 receipt.artifacts.len(),
1069 ),
1070 ));
1071 }
1072
1073 let ordered = receipt.timeline.windows(2).all(|w| {
1074 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
1075 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
1076 });
1077 if ordered {
1078 checks.push(VerifyCheck::pass(
1079 "timeline_order",
1080 "Timeline is correctly ordered",
1081 ));
1082 } else {
1083 checks.push(VerifyCheck::fail(
1084 "timeline_order",
1085 "Timeline entries are not in deterministic order",
1086 ));
1087 }
1088
1089 checks
1090}
1091
1092pub(crate) fn add_approval_evidence_checks(
1103 checks: &mut Vec<VerifyCheck>,
1104 bundle: &ApprovalsBundle,
1105 trust: &crate::trust::TrustRootStore,
1106) {
1107 if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
1108 return;
1112 }
1113
1114 use std::collections::HashMap;
1123 let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
1124 let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
1125 for u in &bundle.uses {
1126 by_nonce
1127 .entry((u.grant_id.clone(), u.nonce_digest.clone()))
1128 .or_default()
1129 .push(u);
1130 by_use_id.entry(&u.use_id).or_default().push(u);
1131 }
1132 let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
1133 .iter()
1134 .filter_map(|(key, uses)| {
1135 let max = uses.iter().filter_map(|u| u.max_uses).next()?;
1136 if (uses.len() as u32) > max {
1137 Some((key.clone(), uses.to_vec(), max))
1138 } else {
1139 None
1140 }
1141 })
1142 .collect();
1143 let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
1144 by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
1145
1146 if over_max.is_empty() && dup_use_ids.is_empty() {
1147 checks.push(VerifyCheck::pass(
1148 "replay-package-local",
1149 &format!(
1150 "no duplicate approval use inside package ({} uses scanned)",
1151 bundle.uses.len()
1152 ),
1153 ));
1154 } else {
1155 let mut detail = String::from("package-local replay violation:");
1156 for ((grant_id, _nd), uses, max) in &over_max {
1157 detail.push_str(&format!(
1158 " grant {grant_id} consumed {} times in this package (max_uses={max});",
1159 uses.len(),
1160 ));
1161 }
1162 for (uid, uses) in &dup_use_ids {
1163 detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
1164 }
1165 checks.push(VerifyCheck::fail("replay-package-local", &detail));
1166 }
1167
1168 if !bundle.checkpoints.is_empty() {
1173 let mut tampered = Vec::new();
1174 for cp in &bundle.checkpoints {
1175 let recomputed = journal_checkpoint_record_digest(cp);
1176 if recomputed != cp.record_digest {
1177 tampered.push((
1178 cp.checkpoint_id.clone(),
1179 cp.record_digest.clone(),
1180 recomputed,
1181 ));
1182 }
1183 }
1184 if tampered.is_empty() {
1185 checks.push(VerifyCheck::pass(
1186 "replay-included-checkpoint",
1187 &format!(
1188 "{} included journal checkpoint(s) verify offline",
1189 bundle.checkpoints.len()
1190 ),
1191 ));
1192 } else {
1193 let detail = tampered
1194 .iter()
1195 .map(|(id, expected, actual)| {
1196 format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
1197 })
1198 .collect::<Vec<_>>()
1199 .join("; ");
1200 checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
1201 }
1202 }
1203
1204 let mut tampered_uses = Vec::new();
1214 for u in &bundle.uses {
1215 let recomputed = approval_use_record_digest(u);
1216 if recomputed != u.record_digest {
1217 tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
1218 }
1219 }
1220 if !bundle.uses.is_empty() {
1221 if tampered_uses.is_empty() {
1222 checks.push(VerifyCheck::pass(
1223 "approval-use-record-digest",
1224 &format!("{} use record(s) recompute identically", bundle.uses.len()),
1225 ));
1226 } else {
1227 let detail = tampered_uses
1228 .iter()
1229 .map(|(id, expected, actual)| {
1230 format!("use {id} tampered (stored {expected}, recomputed {actual})")
1231 })
1232 .collect::<Vec<_>>()
1233 .join("; ");
1234 checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
1235 }
1236 }
1237
1238 if !bundle.uses.is_empty() {
1256 use crate::attestation::envelope::Envelope;
1257 use crate::attestation::{artifact_id_from_pae, pae};
1258 use crate::statements::{nonce_digest, ApprovalStatement};
1259 let mut grant_nonce_digest: std::collections::HashMap<String, String> =
1260 std::collections::HashMap::new();
1261 let mut tampered_grants: Vec<String> = Vec::new();
1262 for (grant_id, env_bytes) in &bundle.grants {
1263 let env = match Envelope::from_json(env_bytes) {
1264 Ok(e) => e,
1265 Err(_) => {
1266 tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
1267 continue;
1268 }
1269 };
1270 let derived = match env.payload_bytes() {
1275 Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
1276 Err(_) => {
1277 tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
1278 continue;
1279 }
1280 };
1281 if &derived != grant_id {
1282 tampered_grants.push(format!(
1283 "grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
1284 ));
1285 continue;
1286 }
1287 let approval: ApprovalStatement = match env.unmarshal_statement() {
1288 Ok(a) => a,
1289 Err(_) => {
1290 tampered_grants
1291 .push(format!("grant {grant_id} payload not an ApprovalStatement"));
1292 continue;
1293 }
1294 };
1295 grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
1296 }
1297 let mut mismatches: Vec<String> = Vec::new();
1298 let mut missing_grants: Vec<String> = Vec::new();
1299 for u in &bundle.uses {
1300 match grant_nonce_digest.get(&u.grant_id) {
1301 Some(expected) => {
1302 if expected != &u.nonce_digest {
1303 mismatches.push(format!(
1304 "use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
1305 u.use_id, u.nonce_digest, u.grant_id, expected,
1306 ));
1307 }
1308 }
1309 None => {
1310 missing_grants.push(format!(
1311 "use {} references grant {} but no usable grant envelope is in the package",
1312 u.use_id, u.grant_id,
1313 ));
1314 }
1315 }
1316 }
1317 if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
1318 checks.push(VerifyCheck::pass(
1319 "approval-use-nonce-binding",
1320 &format!(
1321 "{} use record(s) bind to content-addressed grant signed nonces",
1322 bundle.uses.len(),
1323 ),
1324 ));
1325 } else {
1326 let mut parts: Vec<String> = Vec::new();
1327 if !tampered_grants.is_empty() {
1328 parts.push(tampered_grants.join("; "));
1329 }
1330 if !mismatches.is_empty() {
1331 parts.push(mismatches.join("; "));
1332 }
1333 if !missing_grants.is_empty() {
1334 parts.push(missing_grants.join("; "));
1335 }
1336 checks.push(VerifyCheck::fail(
1337 "approval-use-nonce-binding",
1338 &parts.join("; "),
1339 ));
1340 }
1341 }
1342
1343 if !bundle.uses.is_empty() {
1358 use crate::attestation::envelope::Envelope;
1359 use crate::attestation::{artifact_id_from_pae, pae};
1360 use crate::statements::{nonce_digest, ActionStatement};
1361 if bundle.action_envelopes.is_empty() {
1362 checks.push(VerifyCheck::warn(
1363 "approval-use-action-binding",
1364 "no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
1365 ));
1366 } else {
1367 let use_ids: std::collections::HashSet<&str> =
1368 bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
1369 let mut violations: Vec<String> = Vec::new();
1370 let mut bound_count = 0usize;
1371 for (artifact_id, env_bytes) in &bundle.action_envelopes {
1372 let env = match Envelope::from_json(env_bytes) {
1373 Ok(e) => e,
1374 Err(_) => {
1375 violations.push(format!("action {artifact_id} envelope unparseable"));
1376 continue;
1377 }
1378 };
1379 let derived = match env.payload_bytes() {
1387 Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
1388 Err(_) => {
1389 violations
1390 .push(format!("action {artifact_id} envelope payload undecodable"));
1391 continue;
1392 }
1393 };
1394 if &derived != artifact_id {
1395 violations.push(format!(
1396 "action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
1397 ));
1398 continue;
1399 }
1400 let action: ActionStatement = match env.unmarshal_statement() {
1401 Ok(a) => a,
1402 Err(_) => {
1403 violations.push(format!("action {artifact_id} not an ActionStatement"));
1404 continue;
1405 }
1406 };
1407 let raw_nonce = match action.approval_nonce.as_deref() {
1408 Some(n) => n,
1409 None => continue,
1410 };
1411 let claimed_use_id = action
1412 .meta
1413 .as_ref()
1414 .and_then(|m| m.get("approval_use_id"))
1415 .and_then(|v| v.as_str());
1416 let Some(claimed_use_id) = claimed_use_id else {
1417 violations.push(format!(
1418 "action {artifact_id} consumed an approval but its meta has no approval_use_id"
1419 ));
1420 continue;
1421 };
1422 if !use_ids.contains(claimed_use_id) {
1423 violations.push(format!(
1424 "action {artifact_id} claims approval_use_id={} but no such use is embedded",
1425 claimed_use_id,
1426 ));
1427 continue;
1428 }
1429 let expected = nonce_digest(raw_nonce);
1430 let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
1431 if let Some(u) = matched_use {
1432 if u.nonce_digest != expected {
1433 violations.push(format!(
1434 "action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
1435 expected, claimed_use_id, u.nonce_digest,
1436 ));
1437 continue;
1438 }
1439 }
1440 bound_count += 1;
1441 }
1442 if violations.is_empty() {
1443 checks.push(VerifyCheck::pass(
1444 "approval-use-action-binding",
1445 &format!(
1446 "{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
1447 ),
1448 ));
1449 } else {
1450 checks.push(VerifyCheck::fail(
1451 "approval-use-action-binding",
1452 &violations.join("; "),
1453 ));
1454 }
1455 }
1456 }
1457
1458 if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
1485 use std::collections::{HashMap, HashSet};
1486 struct Node<'a> {
1489 label: String,
1490 digest: &'a str,
1491 prev: &'a str,
1492 }
1493 let mut nodes: Vec<Node> = Vec::new();
1494 for u in &bundle.uses {
1495 nodes.push(Node {
1496 label: format!("use {}", u.use_id),
1497 digest: u.record_digest.as_str(),
1498 prev: u.previous_record_digest.as_str(),
1499 });
1500 }
1501 for cp in &bundle.checkpoints {
1502 nodes.push(Node {
1503 label: format!("checkpoint {}", cp.checkpoint_id),
1504 digest: cp.record_digest.as_str(),
1505 prev: cp.previous_record_digest.as_str(),
1506 });
1507 }
1508
1509 let owned: HashSet<&str> = std::iter::once("")
1510 .chain(nodes.iter().map(|n| n.digest))
1511 .collect();
1512
1513 let mut violations: Vec<String> = Vec::new();
1514 for n in &nodes {
1516 if !owned.contains(n.prev) {
1517 violations.push(format!(
1518 "{} previous_record_digest {} not anchored in package",
1519 n.label, n.prev,
1520 ));
1521 }
1522 }
1523 let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
1525 if genesis.len() > 1 {
1526 violations.push(format!(
1527 "{} records claim previous_record_digest='' (genesis): {}",
1528 genesis.len(),
1529 genesis
1530 .iter()
1531 .map(|n| n.label.clone())
1532 .collect::<Vec<_>>()
1533 .join(", "),
1534 ));
1535 }
1536 let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
1538 for n in &nodes {
1539 by_prev.entry(n.prev).or_default().push(n);
1540 }
1541 for (prev, group) in &by_prev {
1542 if group.len() > 1 && !prev.is_empty() {
1543 violations.push(format!(
1544 "fork: {} records share previous_record_digest {}: {}",
1545 group.len(),
1546 prev,
1547 group
1548 .iter()
1549 .map(|n| n.label.clone())
1550 .collect::<Vec<_>>()
1551 .join(", "),
1552 ));
1553 }
1554 }
1555
1556 if violations.is_empty() {
1559 let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
1560 let next_of: HashMap<&str, &Node> = nodes
1561 .iter()
1562 .filter(|n| !n.prev.is_empty())
1563 .map(|n| (n.prev, n))
1564 .collect();
1565 let start = genesis.first().copied();
1566 let mut visited: HashSet<&str> = HashSet::new();
1567 let mut current = start;
1568 while let Some(node) = current {
1569 if !visited.insert(node.digest) {
1570 violations.push(format!(
1571 "cycle detected at {} (record_digest {})",
1572 node.label, node.digest,
1573 ));
1574 break;
1575 }
1576 current = next_of.get(node.digest).copied();
1577 }
1578 if violations.is_empty() && visited.len() != nodes.len() {
1580 let unreached: Vec<String> = nodes
1581 .iter()
1582 .filter(|n| !visited.contains(n.digest))
1583 .map(|n| n.label.clone())
1584 .collect();
1585 if !unreached.is_empty() {
1586 violations.push(format!(
1587 "disconnected subchain: {} record(s) not reachable from genesis: {}",
1588 unreached.len(),
1589 unreached.join(", "),
1590 ));
1591 }
1592 }
1593 let _ = by_digest; }
1595
1596 if violations.is_empty() {
1597 checks.push(VerifyCheck::pass(
1598 "approval-use-chain-continuity",
1599 &format!(
1600 "{} record(s) form a single connected linked list from one genesis with no cycles or forks",
1601 nodes.len(),
1602 ),
1603 ));
1604 } else {
1605 checks.push(VerifyCheck::fail(
1606 "approval-use-chain-continuity",
1607 &violations.join("; "),
1608 ));
1609 }
1610 }
1611
1612 let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
1626 .checkpoints
1627 .iter()
1628 .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
1629 .collect();
1630 if !hub_checkpoints.is_empty() {
1631 let mut all_ok = true;
1632 let mut details: Vec<String> = Vec::new();
1633 let mut have_valid_signature = false;
1634 let mut security_fatal = false;
1643
1644 for cp in &hub_checkpoints {
1645 match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
1646 crate::statements::HubCheckpointVerification::Valid => {
1647 have_valid_signature = true;
1648 let covered: std::collections::HashSet<&String> =
1653 cp.covered_use_ids.iter().collect();
1654 let missing: Vec<String> = bundle
1655 .uses
1656 .iter()
1657 .filter(|u| !covered.contains(&u.use_id))
1658 .map(|u| u.use_id.clone())
1659 .collect();
1660 if missing.is_empty() {
1661 details.push(format!(
1662 "{} signed by {} verifies; covers {} use(s)",
1663 cp.checkpoint_id,
1664 cp.hub_id,
1665 cp.covered_use_ids.len(),
1666 ));
1667 } else {
1668 all_ok = false;
1669 details.push(format!(
1670 "{} verifies but does not cover {} use(s): {}",
1671 cp.checkpoint_id,
1672 missing.len(),
1673 missing.join(", "),
1674 ));
1675 }
1676 }
1677 crate::statements::HubCheckpointVerification::MissingFields(field) => {
1678 all_ok = false;
1679 details.push(format!(
1680 "{} declares kind=hub-org but field `{}` is missing",
1681 cp.checkpoint_id, field,
1682 ));
1683 }
1684 crate::statements::HubCheckpointVerification::Tampered => {
1685 all_ok = false;
1686 security_fatal = true;
1687 details.push(format!(
1688 "{} hub signature failed verification (tampered or wrong key)",
1689 cp.checkpoint_id,
1690 ));
1691 }
1692 crate::statements::HubCheckpointVerification::NotHubKind => {
1693 all_ok = false;
1697 security_fatal = true;
1698 details.push(format!(
1699 "{} kind toggled out of hub-org during verify",
1700 cp.checkpoint_id,
1701 ));
1702 }
1703 crate::statements::HubCheckpointVerification::UntrustedIssuer => {
1704 all_ok = false;
1705 security_fatal = true;
1706 details.push(format!(
1707 "{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
1708 cp.checkpoint_id,
1709 ));
1710 }
1711 }
1712 }
1713 if all_ok && have_valid_signature {
1714 checks.push(VerifyCheck::pass("replay-hub-org", &details.join("; ")));
1715 } else if security_fatal {
1716 checks.push(VerifyCheck::fail("replay-hub-org", &details.join("; ")));
1720 } else {
1721 checks.push(VerifyCheck::warn("replay-hub-org", &details.join("; ")));
1726 }
1727 }
1728 let _ = ReplayCheckLevel::HubOrg;
1732 let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
1733 let _ = ReplayCheck::not_performed;
1734}
1735
1736#[derive(Debug, Clone)]
1738pub struct VerifyCheck {
1739 pub name: String,
1740 pub status: VerifyStatus,
1741 pub detail: String,
1742}
1743
1744#[derive(Debug, Clone, PartialEq, Eq)]
1746pub enum VerifyStatus {
1747 Pass,
1748 Fail,
1749 Warn,
1750}
1751
1752impl VerifyCheck {
1753 pub fn pass(name: &str, detail: &str) -> Self {
1754 Self {
1755 name: name.into(),
1756 status: VerifyStatus::Pass,
1757 detail: detail.into(),
1758 }
1759 }
1760 pub fn fail(name: &str, detail: &str) -> Self {
1761 Self {
1762 name: name.into(),
1763 status: VerifyStatus::Fail,
1764 detail: detail.into(),
1765 }
1766 }
1767 pub fn warn(name: &str, detail: &str) -> Self {
1768 Self {
1769 name: name.into(),
1770 status: VerifyStatus::Warn,
1771 detail: detail.into(),
1772 }
1773 }
1774}
1775
1776impl VerifyCheck {
1777 pub fn passed(&self) -> bool {
1778 self.status == VerifyStatus::Pass
1779 }
1780}
1781
1782const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
1785
1786const FRAUNCES_WOFF2: &[u8] = include_bytes!("../../assets/fonts/fraunces-latin-var.woff2");
1797
1798fn fraunces_data_uri() -> String {
1802 use base64::engine::general_purpose::STANDARD;
1803 use base64::Engine;
1804 format!("data:font/woff2;base64,{}", STANDARD.encode(FRAUNCES_WOFF2))
1805}
1806
1807pub fn render_preview_html(receipt: &SessionReceipt) -> String {
1814 render_preview_html_with_approvals(receipt, None)
1815}
1816
1817pub fn preview_approvals_json(bundle: Option<&ApprovalsBundle>) -> serde_json::Value {
1829 let Some(b) = bundle else {
1830 return serde_json::Value::Null;
1831 };
1832 if b.grants.is_empty() && b.uses.is_empty() {
1833 return serde_json::Value::Null;
1834 }
1835 let grants: Vec<serde_json::Value> = b
1836 .grants
1837 .iter()
1838 .map(|(grant_id, bytes)| {
1839 let parsed = crate::attestation::Envelope::from_json(bytes)
1840 .ok()
1841 .and_then(|env| env.unmarshal_statement::<ApprovalStatement>().ok());
1842 match parsed {
1843 Some(st) => serde_json::json!({
1844 "grant_id": grant_id,
1845 "parsed": true,
1846 "approver": st.approver,
1847 "description": st.description,
1848 "timestamp": st.timestamp,
1849 "expires_at": st.expires_at,
1850 "scope": st.scope.as_ref().map(|sc| serde_json::json!({
1851 "allowed_actors": sc.allowed_actors,
1852 "allowed_actions": sc.allowed_actions,
1853 "allowed_subjects": sc.allowed_subjects,
1854 "max_uses": sc.max_actions,
1855 "valid_until": sc.valid_until,
1856 })),
1857 }),
1858 None => serde_json::json!({ "grant_id": grant_id, "parsed": false }),
1859 }
1860 })
1861 .collect();
1862 let uses: Vec<serde_json::Value> = b
1863 .uses
1864 .iter()
1865 .map(|u| serde_json::to_value(u).unwrap_or(serde_json::Value::Null))
1866 .collect();
1867 serde_json::json!({ "grants": grants, "uses": uses })
1868}
1869
1870pub fn render_preview_html_with_approvals(
1872 receipt: &SessionReceipt,
1873 bundle: Option<&ApprovalsBundle>,
1874) -> String {
1875 let approvals_json = preview_approvals_json(bundle).to_string();
1876 let safe_approvals = approvals_json.replace('<', r"\u003c");
1877 let receipt_json = serde_json::to_string_pretty(receipt).unwrap_or_else(|_| "{}".to_string());
1878 let safe_json = receipt_json.replace('<', r"\u003c");
1886
1887 PREVIEW_TEMPLATE
1894 .replacen("__RECEIPT_JSON__", &safe_json, 1)
1895 .replacen("__APPROVALS_JSON__", &safe_approvals, 1)
1896 .replace("__FONT_FRAUNCES__", &fraunces_data_uri())
1897}
1898
1899#[cfg(test)]
1900mod tests {
1901 use super::*;
1902 use crate::session::event::*;
1903 use crate::session::manifest::SessionManifest;
1904 use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
1905
1906 fn make_receipt() -> SessionReceipt {
1907 let manifest = SessionManifest::new(
1908 "ssn_pkg_test".into(),
1909 "agent://test".into(),
1910 "2026-04-05T08:00:00Z".into(),
1911 1743843600000,
1912 );
1913
1914 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
1915 SessionEvent {
1916 session_id: "ssn_pkg_test".into(),
1917 event_id: format!("evt_{:016x}", seq),
1918 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1919 sequence_no: seq,
1920 trace_id: "trace_1".into(),
1921 span_id: format!("span_{seq}"),
1922 parent_span_id: None,
1923 agent_id: format!("agent://{inst}"),
1924 agent_instance_id: inst.into(),
1925 agent_name: inst.into(),
1926 agent_role: None,
1927 host_id: "host_1".into(),
1928 tool_runtime_id: None,
1929 event_type: et,
1930 artifact_ref: None,
1931 meta: None,
1932 }
1933 };
1934
1935 let events = vec![
1936 mk(0, "root", EventType::SessionStarted),
1937 mk(
1938 1,
1939 "root",
1940 EventType::AgentStarted {
1941 parent_agent_instance_id: None,
1942 },
1943 ),
1944 mk(
1945 2,
1946 "root",
1947 EventType::AgentCalledTool {
1948 tool_name: "read_file".into(),
1949 tool_input_digest: None,
1950 tool_output_digest: None,
1951 duration_ms: Some(10),
1952 },
1953 ),
1954 mk(
1955 3,
1956 "root",
1957 EventType::AgentCompleted {
1958 termination_reason: None,
1959 },
1960 ),
1961 mk(
1962 4,
1963 "root",
1964 EventType::SessionClosed {
1965 summary: Some("Done".into()),
1966 duration_ms: Some(60000),
1967 },
1968 ),
1969 ];
1970
1971 let artifacts = vec![ArtifactEntry {
1972 artifact_id: "art_001".into(),
1973 payload_type: "action".into(),
1974 digest: None,
1975 signed_at: None,
1976 unchained: false,
1977 }];
1978
1979 ReceiptComposer::compose(&manifest, &events, artifacts)
1980 }
1981
1982 #[test]
1983 fn build_and_read_package() {
1984 let receipt = make_receipt();
1985 let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
1986
1987 let output = build_package(&receipt, &tmp).unwrap();
1988 assert!(output.path.exists());
1989 assert!(output.path.join("receipt.json").exists());
1990 assert!(output.path.join("merkle.json").exists());
1991 assert!(output.path.join("render.json").exists());
1992 assert!(output.path.join("preview.html").exists());
1993 assert!(output.receipt_digest.starts_with("sha256:"));
1994 assert!(output.file_count >= 4);
1995
1996 let read_back = read_package(&output.path).unwrap();
1998 assert_eq!(read_back.session.id, "ssn_pkg_test");
1999 assert_eq!(read_back.type_, RECEIPT_TYPE);
2000
2001 let _ = std::fs::remove_dir_all(&tmp);
2002 }
2003
2004 #[test]
2005 fn verify_valid_package() {
2006 let receipt = make_receipt();
2007 let tmp =
2008 std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
2009
2010 let output = build_package(&receipt, &tmp).unwrap();
2011 let checks = verify_package_structural(&output.path).unwrap();
2012
2013 let fails: Vec<_> = checks
2014 .iter()
2015 .filter(|c| c.status == VerifyStatus::Fail)
2016 .collect();
2017 assert!(fails.is_empty(), "unexpected failures: {fails:?}");
2018
2019 let passes: Vec<_> = checks
2020 .iter()
2021 .filter(|c| c.status == VerifyStatus::Pass)
2022 .collect();
2023 assert!(
2024 passes.len() >= 5,
2025 "expected at least 5 pass checks, got {}",
2026 passes.len()
2027 );
2028
2029 let _ = std::fs::remove_dir_all(&tmp);
2030 }
2031
2032 #[test]
2036 fn verify_warns_when_reconcile_degraded() {
2037 let mut receipt = make_receipt();
2038 receipt.proofs.reconcile_degraded = true;
2039 let tmp =
2040 std::env::temp_dir().join(format!("treeship-pkg-degraded-{}", rand::random::<u32>()));
2041
2042 let output = build_package(&receipt, &tmp).unwrap();
2043 let checks = verify_package_structural(&output.path).unwrap();
2044
2045 let warned = checks
2046 .iter()
2047 .any(|c| c.name == "reconcile_degraded" && c.status == VerifyStatus::Warn);
2048 assert!(warned, "expected a reconcile_degraded WARN, got {checks:?}");
2049 let fails: Vec<_> = checks
2051 .iter()
2052 .filter(|c| c.status == VerifyStatus::Fail)
2053 .collect();
2054 assert!(fails.is_empty(), "must not hard-fail: {fails:?}");
2055
2056 let _ = std::fs::remove_dir_all(&tmp);
2057 }
2058
2059 #[test]
2060 fn verify_no_degraded_warn_when_clean() {
2061 let receipt = make_receipt();
2063 let tmp =
2064 std::env::temp_dir().join(format!("treeship-pkg-clean-{}", rand::random::<u32>()));
2065 let output = build_package(&receipt, &tmp).unwrap();
2066 let checks = verify_package_structural(&output.path).unwrap();
2067 assert!(
2068 !checks.iter().any(|c| c.name == "reconcile_degraded"),
2069 "clean receipt must not emit a reconcile_degraded check"
2070 );
2071 let _ = std::fs::remove_dir_all(&tmp);
2072 }
2073
2074 #[test]
2075 fn verify_detects_missing_receipt() {
2076 let tmp =
2077 std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
2078 std::fs::create_dir_all(&tmp).unwrap();
2079
2080 let err = read_package(&tmp);
2081 assert!(err.is_err());
2082
2083 let _ = std::fs::remove_dir_all(&tmp);
2084 }
2085
2086 #[test]
2087 fn preview_html_renders_approval_evidence_from_the_bundle() {
2088 use crate::attestation::sign::sign;
2093 use crate::attestation::Ed25519Signer;
2094 use crate::statements::ApprovalScope;
2095 use crate::statements::TYPE_APPROVAL_USE;
2096
2097 let receipt = make_receipt();
2098 assert_eq!(preview_approvals_json(None), serde_json::Value::Null);
2099 assert_eq!(
2100 preview_approvals_json(Some(&ApprovalsBundle::default())),
2101 serde_json::Value::Null,
2102 "an empty bundle is the same as none"
2103 );
2104
2105 let signer = Ed25519Signer::generate("key_test_preview").unwrap();
2106 let mut grant = ApprovalStatement::new("human://operator", "nonce-preview-0001");
2107 grant.description = Some("apply change chg-0001: 3% clearance".into());
2108 grant.scope = Some(ApprovalScope {
2109 max_actions: Some(1),
2110 valid_until: None,
2111 allowed_actors: vec!["agent://merchant".into()],
2112 allowed_actions: vec!["commerce.tool.apply_change.intent".into()],
2113 allowed_subjects: vec!["change://chg-0001".into()],
2114 extra: None,
2115 });
2116 let signed = sign("application/vnd.treeship.approval.v1+json", &grant, &signer).unwrap();
2117 let grant_id = signed.artifact_id.to_string();
2118 let grant_bytes = serde_json::to_vec(&signed.envelope).unwrap();
2119
2120 let use_record = ApprovalUse {
2121 type_: TYPE_APPROVAL_USE.into(),
2122 use_id: "use_preview_0001".into(),
2123 grant_id: grant_id.clone(),
2124 grant_digest: signed.digest.clone(),
2125 nonce_digest: "sha256:00".into(),
2126 actor: "agent://merchant".into(),
2127 action: "commerce.tool.apply_change.intent".into(),
2128 subject: "change://chg-0001".into(),
2129 session_id: Some("ssn_pkg_test".into()),
2130 action_artifact_id: Some("art_apply_intent".into()),
2131 receipt_digest: None,
2132 use_number: 1,
2133 max_uses: Some(1),
2134 idempotency_key: None,
2135 created_at: "2026-09-07T10:45:49Z".into(),
2136 expires_at: None,
2137 previous_record_digest: String::new(),
2138 record_digest: String::new(),
2139 signature: None,
2140 signature_alg: None,
2141 signing_key_id: None,
2142 };
2143 let bundle = ApprovalsBundle {
2144 grants: vec![
2145 (grant_id.clone(), grant_bytes),
2146 ("art_garbage".into(), b"not json".to_vec()),
2147 ],
2148 uses: vec![use_record],
2149 ..Default::default()
2150 };
2151
2152 let summary = preview_approvals_json(Some(&bundle));
2153 let grants = summary["grants"].as_array().unwrap();
2154 assert_eq!(grants.len(), 2);
2155 assert_eq!(grants[0]["parsed"], true);
2156 assert_eq!(grants[0]["approver"], "human://operator");
2157 assert_eq!(grants[0]["scope"]["max_uses"], 1);
2158 assert_eq!(
2159 grants[0]["scope"]["allowed_subjects"][0],
2160 "change://chg-0001"
2161 );
2162 assert_eq!(grants[1]["parsed"], false);
2164 assert_eq!(grants[1]["grant_id"], "art_garbage");
2165 let uses = summary["uses"].as_array().unwrap();
2166 assert_eq!(uses[0]["use_number"], 1);
2167 assert_eq!(uses[0]["action_artifact_id"], "art_apply_intent");
2168
2169 let html = render_preview_html_with_approvals(&receipt, Some(&bundle));
2170 assert!(html.contains("id=\"approvals-data\""));
2171 assert!(html.contains("\"approver\":\"human://operator\""));
2172 assert!(html.contains("\"subject\":\"change://chg-0001\""));
2173 assert!(
2174 !html.contains("__APPROVALS_JSON__"),
2175 "placeholder must be substituted"
2176 );
2177 let plain = render_preview_html(&receipt);
2180 assert!(plain.contains("type=\"application/json\">null</script>"));
2181 }
2182
2183 #[test]
2184 fn preview_html_contains_session_info() {
2185 let receipt = make_receipt();
2186 let html = render_preview_html(&receipt);
2187 assert!(html.contains("ssn_pkg_test"));
2188 assert!(html.contains("treeship.dev"));
2189 assert!(html.contains("Timeline"));
2190
2191 assert!(
2201 html.contains("'__RECEIPT'+'_JSON__'"),
2202 "JS placeholder check was clobbered by the receipt substitution",
2203 );
2204 assert!(
2205 !html.contains("application/json\">__RECEIPT_JSON__</script>"),
2206 "data slot was not substituted with the receipt JSON",
2207 );
2208 assert_eq!(
2212 html.matches("__RECEIPT_JSON__").count(),
2213 0,
2214 "no raw placeholder token should remain after substitution",
2215 );
2216 }
2217}