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 Ok(checks)
248}
249
250#[derive(Debug, Clone)]
252pub struct VerifyCheck {
253 pub name: String,
254 pub status: VerifyStatus,
255 pub detail: String,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum VerifyStatus {
261 Pass,
262 Fail,
263 Warn,
264}
265
266impl VerifyCheck {
267 pub fn pass(name: &str, detail: &str) -> Self {
268 Self { name: name.into(), status: VerifyStatus::Pass, detail: detail.into() }
269 }
270 pub fn fail(name: &str, detail: &str) -> Self {
271 Self { name: name.into(), status: VerifyStatus::Fail, detail: detail.into() }
272 }
273 pub fn warn(name: &str, detail: &str) -> Self {
274 Self { name: name.into(), status: VerifyStatus::Warn, detail: detail.into() }
275 }
276}
277
278impl VerifyCheck {
279 pub fn passed(&self) -> bool {
280 self.status == VerifyStatus::Pass
281 }
282}
283
284const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
287
288fn generate_preview_html(receipt: &SessionReceipt) -> String {
295 let receipt_json = serde_json::to_string_pretty(receipt)
296 .unwrap_or_else(|_| "{}".to_string());
297 let safe_json = receipt_json.replace('<', r"\u003c");
305
306 PREVIEW_TEMPLATE
310 .replace("__RECEIPT_JSON__", &safe_json)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::session::event::*;
317 use crate::session::manifest::SessionManifest;
318 use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
319
320 fn make_receipt() -> SessionReceipt {
321 let manifest = SessionManifest::new(
322 "ssn_pkg_test".into(),
323 "agent://test".into(),
324 "2026-04-05T08:00:00Z".into(),
325 1743843600000,
326 );
327
328 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
329 SessionEvent {
330 session_id: "ssn_pkg_test".into(),
331 event_id: format!("evt_{:016x}", seq),
332 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
333 sequence_no: seq,
334 trace_id: "trace_1".into(),
335 span_id: format!("span_{seq}"),
336 parent_span_id: None,
337 agent_id: format!("agent://{inst}"),
338 agent_instance_id: inst.into(),
339 agent_name: inst.into(),
340 agent_role: None,
341 host_id: "host_1".into(),
342 tool_runtime_id: None,
343 event_type: et,
344 artifact_ref: None,
345 meta: None,
346 }
347 };
348
349 let events = vec![
350 mk(0, "root", EventType::SessionStarted),
351 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
352 mk(2, "root", EventType::AgentCalledTool {
353 tool_name: "read_file".into(),
354 tool_input_digest: None,
355 tool_output_digest: None,
356 duration_ms: Some(10),
357 }),
358 mk(3, "root", EventType::AgentCompleted { termination_reason: None }),
359 mk(4, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(60000) }),
360 ];
361
362 let artifacts = vec![
363 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
364 ];
365
366 ReceiptComposer::compose(&manifest, &events, artifacts)
367 }
368
369 #[test]
370 fn build_and_read_package() {
371 let receipt = make_receipt();
372 let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
373
374 let output = build_package(&receipt, &tmp).unwrap();
375 assert!(output.path.exists());
376 assert!(output.path.join("receipt.json").exists());
377 assert!(output.path.join("merkle.json").exists());
378 assert!(output.path.join("render.json").exists());
379 assert!(output.path.join("preview.html").exists());
380 assert!(output.receipt_digest.starts_with("sha256:"));
381 assert!(output.file_count >= 4);
382
383 let read_back = read_package(&output.path).unwrap();
385 assert_eq!(read_back.session.id, "ssn_pkg_test");
386 assert_eq!(read_back.type_, RECEIPT_TYPE);
387
388 let _ = std::fs::remove_dir_all(&tmp);
389 }
390
391 #[test]
392 fn verify_valid_package() {
393 let receipt = make_receipt();
394 let tmp = std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
395
396 let output = build_package(&receipt, &tmp).unwrap();
397 let checks = verify_package(&output.path).unwrap();
398
399 let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
400 assert!(fails.is_empty(), "unexpected failures: {fails:?}");
401
402 let passes: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Pass).collect();
403 assert!(passes.len() >= 5, "expected at least 5 pass checks, got {}", passes.len());
404
405 let _ = std::fs::remove_dir_all(&tmp);
406 }
407
408 #[test]
409 fn verify_detects_missing_receipt() {
410 let tmp = std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
411 std::fs::create_dir_all(&tmp).unwrap();
412
413 let err = read_package(&tmp);
414 assert!(err.is_err());
415
416 let _ = std::fs::remove_dir_all(&tmp);
417 }
418
419 #[test]
420 fn preview_html_contains_session_info() {
421 let receipt = make_receipt();
422 let html = generate_preview_html(&receipt);
423 assert!(html.contains("ssn_pkg_test"));
424 assert!(html.contains("treeship.dev"));
425 assert!(html.contains("Timeline"));
426 }
427}