1use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17use super::receipt::{SessionReceipt, RECEIPT_TYPE};
18use crate::statements::{
19 ApprovalRevocation, ApprovalUse, JournalCheckpoint,
20 ReplayCheck, ReplayCheckLevel,
21 approval_revocation_record_digest, approval_use_record_digest,
22 journal_checkpoint_record_digest,
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 { Self::Io(e) }
46}
47impl From<serde_json::Error> for PackageError {
48 fn from(e: serde_json::Error) -> Self { Self::Json(e) }
49}
50
51const RECEIPT_FILE: &str = "receipt.json";
53const MERKLE_FILE: &str = "merkle.json";
54const RENDER_FILE: &str = "render.json";
55const ARTIFACTS_DIR: &str = "artifacts";
56const PROOFS_DIR: &str = "proofs";
57const PREVIEW_FILE: &str = "preview.html";
58
59const APPROVALS_DIR: &str = "approvals";
72const APPROVALS_GRANTS: &str = "approvals/grants";
73const APPROVALS_USES: &str = "approvals/uses";
74const APPROVALS_CHECKPOINTS:&str = "approvals/checkpoints";
75const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
76
77#[derive(Debug, Clone, Default)]
87pub struct ApprovalsBundle {
88 pub grants: Vec<(String, Vec<u8>)>,
93 pub uses: Vec<ApprovalUse>,
97 pub checkpoints: Vec<JournalCheckpoint>,
100 pub revocations: Vec<ApprovalRevocation>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ApprovalsIndex {
112 #[serde(rename = "type")]
114 pub type_: String,
115 pub schema_version: u32,
116 pub grants: Vec<String>,
119 pub uses: Vec<String>,
121 pub checkpoints: Vec<String>,
122 pub revocations: Vec<String>,
123}
124
125impl ApprovalsIndex {
126 pub fn type_string() -> &'static str { "treeship/approvals-index/v1" }
127}
128
129pub struct PackageOutput {
131 pub path: PathBuf,
133 pub receipt_digest: String,
135 pub merkle_root: Option<String>,
137 pub file_count: usize,
139}
140
141pub fn build_package(
151 receipt: &SessionReceipt,
152 output_dir: &Path,
153) -> Result<PackageOutput, PackageError> {
154 build_package_with_approvals(receipt, output_dir, None)
155}
156
157pub fn build_package_with_approvals(
161 receipt: &SessionReceipt,
162 output_dir: &Path,
163 bundle: Option<&ApprovalsBundle>,
164) -> Result<PackageOutput, PackageError> {
165 let session_id = &receipt.session.id;
166 let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
167
168 std::fs::create_dir_all(&pkg_dir)?;
169 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
170 std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
171
172 let mut file_count = 0usize;
173
174 let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
176 std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
177 file_count += 1;
178
179 let receipt_hash = Sha256::digest(&receipt_bytes);
180 let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
181
182 let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
184 std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
185 file_count += 1;
186
187 let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
189 std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
190 file_count += 1;
191
192 for proof_entry in &receipt.merkle.inclusion_proofs {
194 let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
195 let filename = format!("{}.proof.json", proof_entry.artifact_id);
196 std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
197 file_count += 1;
198 }
199
200 if receipt.render.generate_preview {
202 let preview = generate_preview_html(receipt);
203 std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
204 file_count += 1;
205 }
206
207 if let Some(b) = bundle {
212 if !b.grants.is_empty() || !b.uses.is_empty() || !b.checkpoints.is_empty() || !b.revocations.is_empty() {
213 std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
214 std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
215 std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
216
217 let mut grant_ids = Vec::with_capacity(b.grants.len());
218 for (grant_id, envelope_bytes) in &b.grants {
219 let safe = sanitize_filename(grant_id);
220 std::fs::write(
221 pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
222 envelope_bytes,
223 )?;
224 grant_ids.push(grant_id.clone());
225 file_count += 1;
226 }
227
228 let mut use_ids = Vec::with_capacity(b.uses.len());
229 for u in &b.uses {
230 let safe = sanitize_filename(&u.use_id);
231 let bytes = serde_json::to_vec_pretty(u)?;
232 std::fs::write(
233 pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
234 &bytes,
235 )?;
236 use_ids.push(u.use_id.clone());
237 file_count += 1;
238 }
239
240 let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
241 for cp in &b.checkpoints {
242 let safe = sanitize_filename(&cp.checkpoint_id);
243 let bytes = serde_json::to_vec_pretty(cp)?;
244 std::fs::write(
245 pkg_dir.join(APPROVALS_CHECKPOINTS).join(format!("{safe}.json")),
246 &bytes,
247 )?;
248 checkpoint_ids.push(cp.checkpoint_id.clone());
249 file_count += 1;
250 }
251
252 let mut revocation_ids = Vec::with_capacity(b.revocations.len());
253 for rev in &b.revocations {
254 let safe = sanitize_filename(&rev.revocation_id);
255 let bytes = serde_json::to_vec_pretty(rev)?;
256 std::fs::write(
257 pkg_dir.join(APPROVALS_DIR).join(format!("revocations-{safe}.json")),
258 &bytes,
259 )?;
260 revocation_ids.push(rev.revocation_id.clone());
261 file_count += 1;
262 }
263
264 let index = ApprovalsIndex {
265 type_: ApprovalsIndex::type_string().into(),
266 schema_version: 1,
267 grants: grant_ids,
268 uses: use_ids,
269 checkpoints: checkpoint_ids,
270 revocations: revocation_ids,
271 };
272 let index_bytes = serde_json::to_vec_pretty(&index)?;
273 std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
274 file_count += 1;
275 }
276 }
277
278 Ok(PackageOutput {
279 path: pkg_dir,
280 receipt_digest,
281 merkle_root: receipt.merkle.root.clone(),
282 file_count,
283 })
284}
285
286fn sanitize_filename(s: &str) -> String {
290 s.chars()
291 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' { c } else { '_' })
292 .collect()
293}
294
295pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
305 let approvals_dir = pkg_dir.join(APPROVALS_DIR);
306 if !approvals_dir.is_dir() {
307 return Ok(ApprovalsBundle::default());
308 }
309
310 let mut bundle = ApprovalsBundle::default();
311
312 let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
315 if grants_dir.is_dir() {
316 for entry in std::fs::read_dir(&grants_dir)? {
317 let entry = entry?;
318 let path = entry.path();
319 if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
320 let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
321 let bytes = std::fs::read(&path)?;
322 bundle.grants.push((id, bytes));
323 }
324 }
325
326 let uses_dir = pkg_dir.join(APPROVALS_USES);
327 if uses_dir.is_dir() {
328 for entry in std::fs::read_dir(&uses_dir)? {
329 let entry = entry?;
330 let path = entry.path();
331 if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
332 let bytes = std::fs::read(&path)?;
333 let u: ApprovalUse = serde_json::from_slice(&bytes)?;
334 bundle.uses.push(u);
335 }
336 }
337
338 let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
339 if cps_dir.is_dir() {
340 for entry in std::fs::read_dir(&cps_dir)? {
341 let entry = entry?;
342 let path = entry.path();
343 if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
344 let bytes = std::fs::read(&path)?;
345 let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
346 bundle.checkpoints.push(cp);
347 }
348 }
349
350 Ok(bundle)
351}
352
353pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
355 let receipt_path = pkg_dir.join(RECEIPT_FILE);
356 if !receipt_path.exists() {
357 return Err(PackageError::InvalidPackage(
358 format!("missing {RECEIPT_FILE} in {}", pkg_dir.display()),
359 ));
360 }
361 let bytes = std::fs::read(&receipt_path)?;
362 let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
363
364 if receipt.type_ != RECEIPT_TYPE {
365 return Err(PackageError::InvalidPackage(
366 format!("unexpected type: {} (expected {RECEIPT_TYPE})", receipt.type_),
367 ));
368 }
369
370 Ok(receipt)
371}
372
373pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
377 let mut checks = Vec::new();
378
379 let receipt = match read_package(pkg_dir) {
381 Ok(r) => {
382 checks.push(VerifyCheck::pass("receipt.json", "Parses as valid Session Receipt"));
383 r
384 }
385 Err(e) => {
386 checks.push(VerifyCheck::fail("receipt.json", &format!("Failed to parse: {e}")));
387 return Ok(checks);
388 }
389 };
390
391 if receipt.type_ == RECEIPT_TYPE {
393 checks.push(VerifyCheck::pass("type", "Correct receipt type"));
394 } else {
395 checks.push(VerifyCheck::fail("type", &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_)));
396 }
397
398 let receipt_path = pkg_dir.join(RECEIPT_FILE);
400 let on_disk = std::fs::read(&receipt_path)?;
401 let re_serialized = serde_json::to_vec_pretty(&receipt)?;
402 if on_disk == re_serialized {
403 checks.push(VerifyCheck::pass("determinism", "receipt.json round-trips identically"));
404 } else {
405 checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
407 }
408
409 if !receipt.artifacts.is_empty() {
411 let mut tree = crate::merkle::MerkleTree::new();
412 for art in &receipt.artifacts {
413 tree.append(&art.artifact_id);
414 }
415 let root_bytes = tree.root();
416 let recomputed_root = root_bytes
417 .map(|r| format!("mroot_{}", hex::encode(r)));
418 let root_hex = root_bytes
419 .map(|r| hex::encode(r))
420 .unwrap_or_default();
421
422 if recomputed_root == receipt.merkle.root {
423 checks.push(VerifyCheck::pass("merkle_root", "Merkle root matches recomputed value"));
424 } else {
425 checks.push(VerifyCheck::fail(
426 "merkle_root",
427 &format!(
428 "Mismatch: on-disk {:?} vs recomputed {:?}",
429 receipt.merkle.root, recomputed_root
430 ),
431 ));
432 }
433
434 for proof_entry in &receipt.merkle.inclusion_proofs {
436 let verified = crate::merkle::MerkleTree::verify_proof(
437 &root_hex,
438 &proof_entry.artifact_id,
439 &proof_entry.proof,
440 );
441 if verified {
442 checks.push(VerifyCheck::pass(
443 &format!("inclusion:{}", proof_entry.artifact_id),
444 "Inclusion proof valid",
445 ));
446 } else {
447 checks.push(VerifyCheck::fail(
448 &format!("inclusion:{}", proof_entry.artifact_id),
449 "Inclusion proof failed verification",
450 ));
451 }
452 }
453 } else {
454 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
455 }
456
457 if receipt.merkle.leaf_count == receipt.artifacts.len() {
459 checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
460 } else {
461 checks.push(VerifyCheck::fail(
462 "leaf_count",
463 &format!("leaf_count {} != artifact count {}", receipt.merkle.leaf_count, receipt.artifacts.len()),
464 ));
465 }
466
467 let ordered = receipt.timeline.windows(2).all(|w| {
469 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
470 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
471 });
472 if ordered {
473 checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
474 } else {
475 checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
476 }
477
478 if receipt.proofs.event_log_skipped > 0 {
486 checks.push(VerifyCheck::warn(
487 "event_log_completeness",
488 &format!(
489 "{} event(s) skipped during close (malformed lines in events.jsonl). \
490 Receipt is cryptographically valid but does not represent the full event stream. \
491 Inspect close-time stderr or the events.jsonl directly to investigate.",
492 receipt.proofs.event_log_skipped,
493 ),
494 ));
495 }
496
497 let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
509 add_approval_evidence_checks(&mut checks, &bundle);
510
511 Ok(checks)
512}
513
514pub(crate) fn add_approval_evidence_checks(
525 checks: &mut Vec<VerifyCheck>,
526 bundle: &ApprovalsBundle,
527) {
528 if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
529 return;
533 }
534
535 use std::collections::HashMap;
544 let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
545 let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
546 for u in &bundle.uses {
547 by_nonce
548 .entry((u.grant_id.clone(), u.nonce_digest.clone()))
549 .or_default()
550 .push(u);
551 by_use_id.entry(&u.use_id).or_default().push(u);
552 }
553 let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
554 .iter()
555 .filter_map(|(key, uses)| {
556 let max = uses.iter().filter_map(|u| u.max_uses).next()?;
557 if (uses.len() as u32) > max {
558 Some((key.clone(), uses.iter().map(|u| *u).collect(), max))
559 } else {
560 None
561 }
562 })
563 .collect();
564 let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
565 by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
566
567 if over_max.is_empty() && dup_use_ids.is_empty() {
568 checks.push(VerifyCheck::pass(
569 "replay-package-local",
570 &format!("no duplicate approval use inside package ({} uses scanned)", bundle.uses.len()),
571 ));
572 } else {
573 let mut detail = String::from("package-local replay violation:");
574 for ((grant_id, _nd), uses, max) in &over_max {
575 detail.push_str(&format!(
576 " grant {grant_id} consumed {} times in this package (max_uses={max});",
577 uses.len(),
578 ));
579 }
580 for (uid, uses) in &dup_use_ids {
581 detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
582 }
583 checks.push(VerifyCheck::fail("replay-package-local", &detail));
584 }
585
586 if !bundle.checkpoints.is_empty() {
591 let mut tampered = Vec::new();
592 for cp in &bundle.checkpoints {
593 let recomputed = journal_checkpoint_record_digest(cp);
594 if recomputed != cp.record_digest {
595 tampered.push((cp.checkpoint_id.clone(), cp.record_digest.clone(), recomputed));
596 }
597 }
598 if tampered.is_empty() {
599 checks.push(VerifyCheck::pass(
600 "replay-included-checkpoint",
601 &format!("{} included journal checkpoint(s) verify offline", bundle.checkpoints.len()),
602 ));
603 } else {
604 let detail = tampered.iter()
605 .map(|(id, expected, actual)| {
606 format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
607 })
608 .collect::<Vec<_>>()
609 .join("; ");
610 checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
611 }
612 }
613
614 let mut tampered_uses = Vec::new();
618 for u in &bundle.uses {
619 let recomputed = approval_use_record_digest(u);
620 if recomputed != u.record_digest {
621 tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
622 }
623 }
624 if !tampered_uses.is_empty() {
625 let detail = tampered_uses.iter()
626 .map(|(id, expected, actual)| {
627 format!("use {id} tampered (stored {expected}, recomputed {actual})")
628 })
629 .collect::<Vec<_>>()
630 .join("; ");
631 checks.push(VerifyCheck::fail("approval-use-integrity", &detail));
632 }
633
634 let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
648 .checkpoints
649 .iter()
650 .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
651 .collect();
652 if !hub_checkpoints.is_empty() {
653 let mut all_ok = true;
654 let mut details: Vec<String> = Vec::new();
655 let mut have_valid_signature = false;
656
657 for cp in &hub_checkpoints {
658 match crate::statements::verify_hub_checkpoint_signature(cp) {
659 crate::statements::HubCheckpointVerification::Valid => {
660 have_valid_signature = true;
661 let covered: std::collections::HashSet<&String> =
666 cp.covered_use_ids.iter().collect();
667 let missing: Vec<String> = bundle
668 .uses
669 .iter()
670 .filter(|u| !covered.contains(&u.use_id))
671 .map(|u| u.use_id.clone())
672 .collect();
673 if missing.is_empty() {
674 details.push(format!(
675 "{} signed by {} verifies; covers {} use(s)",
676 cp.checkpoint_id,
677 cp.hub_id,
678 cp.covered_use_ids.len(),
679 ));
680 } else {
681 all_ok = false;
682 details.push(format!(
683 "{} verifies but does not cover {} use(s): {}",
684 cp.checkpoint_id,
685 missing.len(),
686 missing.join(", "),
687 ));
688 }
689 }
690 crate::statements::HubCheckpointVerification::MissingFields(field) => {
691 all_ok = false;
692 details.push(format!(
693 "{} declares kind=hub-org but field `{}` is missing",
694 cp.checkpoint_id, field,
695 ));
696 }
697 crate::statements::HubCheckpointVerification::Tampered => {
698 all_ok = false;
699 details.push(format!(
700 "{} hub signature failed verification (tampered or wrong key)",
701 cp.checkpoint_id,
702 ));
703 }
704 crate::statements::HubCheckpointVerification::NotHubKind => {
705 all_ok = false;
709 details.push(format!(
710 "{} kind toggled out of hub-org during verify",
711 cp.checkpoint_id,
712 ));
713 }
714 }
715 }
716 if all_ok && have_valid_signature {
717 checks.push(VerifyCheck::pass(
718 "replay-hub-org",
719 &details.join("; "),
720 ));
721 } else {
722 checks.push(VerifyCheck::warn(
726 "replay-hub-org",
727 &details.join("; "),
728 ));
729 }
730 }
731 let _ = ReplayCheckLevel::HubOrg;
735 let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
736 let _ = ReplayCheck::not_performed;
737}
738
739#[derive(Debug, Clone)]
741pub struct VerifyCheck {
742 pub name: String,
743 pub status: VerifyStatus,
744 pub detail: String,
745}
746
747#[derive(Debug, Clone, PartialEq, Eq)]
749pub enum VerifyStatus {
750 Pass,
751 Fail,
752 Warn,
753}
754
755impl VerifyCheck {
756 pub fn pass(name: &str, detail: &str) -> Self {
757 Self { name: name.into(), status: VerifyStatus::Pass, detail: detail.into() }
758 }
759 pub fn fail(name: &str, detail: &str) -> Self {
760 Self { name: name.into(), status: VerifyStatus::Fail, detail: detail.into() }
761 }
762 pub fn warn(name: &str, detail: &str) -> Self {
763 Self { name: name.into(), status: VerifyStatus::Warn, detail: detail.into() }
764 }
765}
766
767impl VerifyCheck {
768 pub fn passed(&self) -> bool {
769 self.status == VerifyStatus::Pass
770 }
771}
772
773const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
776
777fn generate_preview_html(receipt: &SessionReceipt) -> String {
784 let receipt_json = serde_json::to_string_pretty(receipt)
785 .unwrap_or_else(|_| "{}".to_string());
786 let safe_json = receipt_json.replace('<', r"\u003c");
794
795 PREVIEW_TEMPLATE
799 .replace("__RECEIPT_JSON__", &safe_json)
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805 use crate::session::event::*;
806 use crate::session::manifest::SessionManifest;
807 use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
808
809 fn make_receipt() -> SessionReceipt {
810 let manifest = SessionManifest::new(
811 "ssn_pkg_test".into(),
812 "agent://test".into(),
813 "2026-04-05T08:00:00Z".into(),
814 1743843600000,
815 );
816
817 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
818 SessionEvent {
819 session_id: "ssn_pkg_test".into(),
820 event_id: format!("evt_{:016x}", seq),
821 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
822 sequence_no: seq,
823 trace_id: "trace_1".into(),
824 span_id: format!("span_{seq}"),
825 parent_span_id: None,
826 agent_id: format!("agent://{inst}"),
827 agent_instance_id: inst.into(),
828 agent_name: inst.into(),
829 agent_role: None,
830 host_id: "host_1".into(),
831 tool_runtime_id: None,
832 event_type: et,
833 artifact_ref: None,
834 meta: None,
835 }
836 };
837
838 let events = vec![
839 mk(0, "root", EventType::SessionStarted),
840 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
841 mk(2, "root", EventType::AgentCalledTool {
842 tool_name: "read_file".into(),
843 tool_input_digest: None,
844 tool_output_digest: None,
845 duration_ms: Some(10),
846 }),
847 mk(3, "root", EventType::AgentCompleted { termination_reason: None }),
848 mk(4, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(60000) }),
849 ];
850
851 let artifacts = vec![
852 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
853 ];
854
855 ReceiptComposer::compose(&manifest, &events, artifacts)
856 }
857
858 #[test]
859 fn build_and_read_package() {
860 let receipt = make_receipt();
861 let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
862
863 let output = build_package(&receipt, &tmp).unwrap();
864 assert!(output.path.exists());
865 assert!(output.path.join("receipt.json").exists());
866 assert!(output.path.join("merkle.json").exists());
867 assert!(output.path.join("render.json").exists());
868 assert!(output.path.join("preview.html").exists());
869 assert!(output.receipt_digest.starts_with("sha256:"));
870 assert!(output.file_count >= 4);
871
872 let read_back = read_package(&output.path).unwrap();
874 assert_eq!(read_back.session.id, "ssn_pkg_test");
875 assert_eq!(read_back.type_, RECEIPT_TYPE);
876
877 let _ = std::fs::remove_dir_all(&tmp);
878 }
879
880 #[test]
881 fn verify_valid_package() {
882 let receipt = make_receipt();
883 let tmp = std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
884
885 let output = build_package(&receipt, &tmp).unwrap();
886 let checks = verify_package(&output.path).unwrap();
887
888 let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
889 assert!(fails.is_empty(), "unexpected failures: {fails:?}");
890
891 let passes: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Pass).collect();
892 assert!(passes.len() >= 5, "expected at least 5 pass checks, got {}", passes.len());
893
894 let _ = std::fs::remove_dir_all(&tmp);
895 }
896
897 #[test]
898 fn verify_detects_missing_receipt() {
899 let tmp = std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
900 std::fs::create_dir_all(&tmp).unwrap();
901
902 let err = read_package(&tmp);
903 assert!(err.is_err());
904
905 let _ = std::fs::remove_dir_all(&tmp);
906 }
907
908 #[test]
909 fn preview_html_contains_session_info() {
910 let receipt = make_receipt();
911 let html = generate_preview_html(&receipt);
912 assert!(html.contains("ssn_pkg_test"));
913 assert!(html.contains("treeship.dev"));
914 assert!(html.contains("Timeline"));
915 }
916}