1use std::path::{Path, PathBuf};
13
14use sha2::{Digest, Sha256};
15
16use super::receipt::{SessionReceipt, RECEIPT_TYPE};
17
18#[derive(Debug)]
20pub enum PackageError {
21 Io(std::io::Error),
22 Json(serde_json::Error),
23 InvalidPackage(String),
24}
25
26impl std::fmt::Display for PackageError {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 Self::Io(e) => write!(f, "package io: {e}"),
30 Self::Json(e) => write!(f, "package json: {e}"),
31 Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
32 }
33 }
34}
35
36impl std::error::Error for PackageError {}
37impl From<std::io::Error> for PackageError {
38 fn from(e: std::io::Error) -> Self { Self::Io(e) }
39}
40impl From<serde_json::Error> for PackageError {
41 fn from(e: serde_json::Error) -> Self { Self::Json(e) }
42}
43
44const RECEIPT_FILE: &str = "receipt.json";
46const MERKLE_FILE: &str = "merkle.json";
47const RENDER_FILE: &str = "render.json";
48const ARTIFACTS_DIR: &str = "artifacts";
49const PROOFS_DIR: &str = "proofs";
50const PREVIEW_FILE: &str = "preview.html";
51
52pub struct PackageOutput {
54 pub path: PathBuf,
56 pub receipt_digest: String,
58 pub merkle_root: Option<String>,
60 pub file_count: usize,
62}
63
64pub fn build_package(
69 receipt: &SessionReceipt,
70 output_dir: &Path,
71) -> Result<PackageOutput, PackageError> {
72 let session_id = &receipt.session.id;
73 let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
74
75 std::fs::create_dir_all(&pkg_dir)?;
76 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
77 std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
78
79 let mut file_count = 0usize;
80
81 let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
83 std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
84 file_count += 1;
85
86 let receipt_hash = Sha256::digest(&receipt_bytes);
87 let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
88
89 let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
91 std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
92 file_count += 1;
93
94 let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
96 std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
97 file_count += 1;
98
99 for proof_entry in &receipt.merkle.inclusion_proofs {
101 let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
102 let filename = format!("{}.proof.json", proof_entry.artifact_id);
103 std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
104 file_count += 1;
105 }
106
107 if receipt.render.generate_preview {
109 let preview = generate_preview_html(receipt);
110 std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
111 file_count += 1;
112 }
113
114 Ok(PackageOutput {
115 path: pkg_dir,
116 receipt_digest,
117 merkle_root: receipt.merkle.root.clone(),
118 file_count,
119 })
120}
121
122pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
124 let receipt_path = pkg_dir.join(RECEIPT_FILE);
125 if !receipt_path.exists() {
126 return Err(PackageError::InvalidPackage(
127 format!("missing {RECEIPT_FILE} in {}", pkg_dir.display()),
128 ));
129 }
130 let bytes = std::fs::read(&receipt_path)?;
131 let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
132
133 if receipt.type_ != RECEIPT_TYPE {
134 return Err(PackageError::InvalidPackage(
135 format!("unexpected type: {} (expected {RECEIPT_TYPE})", receipt.type_),
136 ));
137 }
138
139 Ok(receipt)
140}
141
142pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
146 let mut checks = Vec::new();
147
148 let receipt = match read_package(pkg_dir) {
150 Ok(r) => {
151 checks.push(VerifyCheck::pass("receipt.json", "Parses as valid Session Receipt"));
152 r
153 }
154 Err(e) => {
155 checks.push(VerifyCheck::fail("receipt.json", &format!("Failed to parse: {e}")));
156 return Ok(checks);
157 }
158 };
159
160 if receipt.type_ == RECEIPT_TYPE {
162 checks.push(VerifyCheck::pass("type", "Correct receipt type"));
163 } else {
164 checks.push(VerifyCheck::fail("type", &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_)));
165 }
166
167 let receipt_path = pkg_dir.join(RECEIPT_FILE);
169 let on_disk = std::fs::read(&receipt_path)?;
170 let re_serialized = serde_json::to_vec_pretty(&receipt)?;
171 if on_disk == re_serialized {
172 checks.push(VerifyCheck::pass("determinism", "receipt.json round-trips identically"));
173 } else {
174 checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
176 }
177
178 if !receipt.artifacts.is_empty() {
180 let mut tree = crate::merkle::MerkleTree::new();
181 for art in &receipt.artifacts {
182 tree.append(&art.artifact_id);
183 }
184 let root_bytes = tree.root();
185 let recomputed_root = root_bytes
186 .map(|r| format!("mroot_{}", hex::encode(r)));
187 let root_hex = root_bytes
188 .map(|r| hex::encode(r))
189 .unwrap_or_default();
190
191 if recomputed_root == receipt.merkle.root {
192 checks.push(VerifyCheck::pass("merkle_root", "Merkle root matches recomputed value"));
193 } else {
194 checks.push(VerifyCheck::fail(
195 "merkle_root",
196 &format!(
197 "Mismatch: on-disk {:?} vs recomputed {:?}",
198 receipt.merkle.root, recomputed_root
199 ),
200 ));
201 }
202
203 for proof_entry in &receipt.merkle.inclusion_proofs {
205 let verified = crate::merkle::MerkleTree::verify_proof(
206 &root_hex,
207 &proof_entry.artifact_id,
208 &proof_entry.proof,
209 );
210 if verified {
211 checks.push(VerifyCheck::pass(
212 &format!("inclusion:{}", proof_entry.artifact_id),
213 "Inclusion proof valid",
214 ));
215 } else {
216 checks.push(VerifyCheck::fail(
217 &format!("inclusion:{}", proof_entry.artifact_id),
218 "Inclusion proof failed verification",
219 ));
220 }
221 }
222 } else {
223 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
224 }
225
226 if receipt.merkle.leaf_count == receipt.artifacts.len() {
228 checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
229 } else {
230 checks.push(VerifyCheck::fail(
231 "leaf_count",
232 &format!("leaf_count {} != artifact count {}", receipt.merkle.leaf_count, receipt.artifacts.len()),
233 ));
234 }
235
236 let ordered = receipt.timeline.windows(2).all(|w| {
238 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
239 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
240 });
241 if ordered {
242 checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
243 } else {
244 checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
245 }
246
247 if receipt.proofs.event_log_skipped > 0 {
255 checks.push(VerifyCheck::warn(
256 "event_log_completeness",
257 &format!(
258 "{} event(s) skipped during close (malformed lines in events.jsonl). \
259 Receipt is cryptographically valid but does not represent the full event stream. \
260 Inspect close-time stderr or the events.jsonl directly to investigate.",
261 receipt.proofs.event_log_skipped,
262 ),
263 ));
264 }
265
266 Ok(checks)
267}
268
269#[derive(Debug, Clone)]
271pub struct VerifyCheck {
272 pub name: String,
273 pub status: VerifyStatus,
274 pub detail: String,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum VerifyStatus {
280 Pass,
281 Fail,
282 Warn,
283}
284
285impl VerifyCheck {
286 pub fn pass(name: &str, detail: &str) -> Self {
287 Self { name: name.into(), status: VerifyStatus::Pass, detail: detail.into() }
288 }
289 pub fn fail(name: &str, detail: &str) -> Self {
290 Self { name: name.into(), status: VerifyStatus::Fail, detail: detail.into() }
291 }
292 pub fn warn(name: &str, detail: &str) -> Self {
293 Self { name: name.into(), status: VerifyStatus::Warn, detail: detail.into() }
294 }
295}
296
297impl VerifyCheck {
298 pub fn passed(&self) -> bool {
299 self.status == VerifyStatus::Pass
300 }
301}
302
303const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
306
307fn generate_preview_html(receipt: &SessionReceipt) -> String {
314 let receipt_json = serde_json::to_string_pretty(receipt)
315 .unwrap_or_else(|_| "{}".to_string());
316 let safe_json = receipt_json.replace('<', r"\u003c");
324
325 PREVIEW_TEMPLATE
329 .replace("__RECEIPT_JSON__", &safe_json)
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::session::event::*;
336 use crate::session::manifest::SessionManifest;
337 use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
338
339 fn make_receipt() -> SessionReceipt {
340 let manifest = SessionManifest::new(
341 "ssn_pkg_test".into(),
342 "agent://test".into(),
343 "2026-04-05T08:00:00Z".into(),
344 1743843600000,
345 );
346
347 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
348 SessionEvent {
349 session_id: "ssn_pkg_test".into(),
350 event_id: format!("evt_{:016x}", seq),
351 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
352 sequence_no: seq,
353 trace_id: "trace_1".into(),
354 span_id: format!("span_{seq}"),
355 parent_span_id: None,
356 agent_id: format!("agent://{inst}"),
357 agent_instance_id: inst.into(),
358 agent_name: inst.into(),
359 agent_role: None,
360 host_id: "host_1".into(),
361 tool_runtime_id: None,
362 event_type: et,
363 artifact_ref: None,
364 meta: None,
365 }
366 };
367
368 let events = vec![
369 mk(0, "root", EventType::SessionStarted),
370 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
371 mk(2, "root", EventType::AgentCalledTool {
372 tool_name: "read_file".into(),
373 tool_input_digest: None,
374 tool_output_digest: None,
375 duration_ms: Some(10),
376 }),
377 mk(3, "root", EventType::AgentCompleted { termination_reason: None }),
378 mk(4, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(60000) }),
379 ];
380
381 let artifacts = vec![
382 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
383 ];
384
385 ReceiptComposer::compose(&manifest, &events, artifacts)
386 }
387
388 #[test]
389 fn build_and_read_package() {
390 let receipt = make_receipt();
391 let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
392
393 let output = build_package(&receipt, &tmp).unwrap();
394 assert!(output.path.exists());
395 assert!(output.path.join("receipt.json").exists());
396 assert!(output.path.join("merkle.json").exists());
397 assert!(output.path.join("render.json").exists());
398 assert!(output.path.join("preview.html").exists());
399 assert!(output.receipt_digest.starts_with("sha256:"));
400 assert!(output.file_count >= 4);
401
402 let read_back = read_package(&output.path).unwrap();
404 assert_eq!(read_back.session.id, "ssn_pkg_test");
405 assert_eq!(read_back.type_, RECEIPT_TYPE);
406
407 let _ = std::fs::remove_dir_all(&tmp);
408 }
409
410 #[test]
411 fn verify_valid_package() {
412 let receipt = make_receipt();
413 let tmp = std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
414
415 let output = build_package(&receipt, &tmp).unwrap();
416 let checks = verify_package(&output.path).unwrap();
417
418 let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
419 assert!(fails.is_empty(), "unexpected failures: {fails:?}");
420
421 let passes: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Pass).collect();
422 assert!(passes.len() >= 5, "expected at least 5 pass checks, got {}", passes.len());
423
424 let _ = std::fs::remove_dir_all(&tmp);
425 }
426
427 #[test]
428 fn verify_detects_missing_receipt() {
429 let tmp = std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
430 std::fs::create_dir_all(&tmp).unwrap();
431
432 let err = read_package(&tmp);
433 assert!(err.is_err());
434
435 let _ = std::fs::remove_dir_all(&tmp);
436 }
437
438 #[test]
439 fn preview_html_contains_session_info() {
440 let receipt = make_receipt();
441 let html = generate_preview_html(&receipt);
442 assert!(html.contains("ssn_pkg_test"));
443 assert!(html.contains("treeship.dev"));
444 assert!(html.contains("Timeline"));
445 }
446}