Skip to main content

treeship_core/session/
package.rs

1//! `.treeship` package builder and reader.
2//!
3//! A `.treeship` package is a directory (or tar archive) containing:
4//!
5//! - `receipt.json`   -- the canonical Session Receipt
6//! - `merkle.json`    -- standalone Merkle tree data
7//! - `render.json`    -- Explorer render hints
8//! - `artifacts/`     -- referenced artifact payloads
9//! - `proofs/`        -- inclusion proofs and zk proofs
10//! - `preview.html`   -- static preview (optional)
11
12use std::path::{Path, PathBuf};
13
14use sha2::{Digest, Sha256};
15
16use super::receipt::{SessionReceipt, RECEIPT_TYPE};
17
18/// Errors from package operations.
19#[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
44/// Manifest file inside the package root.
45const 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
52/// Result of building a package.
53pub struct PackageOutput {
54    /// Path to the package directory.
55    pub path: PathBuf,
56    /// SHA-256 digest of the canonical receipt.json.
57    pub receipt_digest: String,
58    /// Merkle root hex (if present).
59    pub merkle_root: Option<String>,
60    /// Number of files in the package.
61    pub file_count: usize,
62}
63
64/// Build a `.treeship` package directory from a composed receipt.
65///
66/// Writes all package files into `output_dir/<session_id>.treeship/`.
67/// Returns metadata about the written package.
68pub 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    // 1. receipt.json -- canonical serialization
82    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    // 2. merkle.json -- standalone copy of the Merkle section
90    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    // 3. render.json
95    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    // 4. Write inclusion proofs as individual files
100    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    // 5. preview.html stub
108    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
122/// Read and parse a `.treeship` package from disk.
123pub 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
142/// Verify a `.treeship` package locally.
143///
144/// Returns a list of check results. All must pass for the package to be valid.
145pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
146    let mut checks = Vec::new();
147
148    // 1. receipt.json exists and parses
149    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    // 2. Type field
161    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    // 3. Determinism: re-serialize and check digest matches
168    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        // Not a hard failure -- pretty-print whitespace may differ
175        checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
176    }
177
178    // 4. Merkle root re-computation
179    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        // 5. Verify each inclusion proof
204        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    // 6. Leaf count matches artifacts
227    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    // 7. Timeline ordering (determinism rule: timestamp, sequence_no, event_id)
237    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/// A single verification check result.
251#[derive(Debug, Clone)]
252pub struct VerifyCheck {
253    pub name: String,
254    pub status: VerifyStatus,
255    pub detail: String,
256}
257
258/// Status of a verification check.
259#[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
284/// HTML template for the self-contained verifier preview.
285/// Loaded at compile time so the binary carries no runtime file dependencies.
286const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
287
288/// Generate a self-contained preview.html that embeds the receipt JSON
289/// and runs Merkle verification client-side using Web Crypto API.
290///
291/// The HTML works fully air-gapped: no network calls, no CDN, no server.
292/// Open it in any modern browser and it automatically verifies the receipt
293/// and shows pass/fail for each check.
294fn generate_preview_html(receipt: &SessionReceipt) -> String {
295    let receipt_json = serde_json::to_string_pretty(receipt)
296        .unwrap_or_else(|_| "{}".to_string());
297    // Defense-in-depth: escape </script sequences so a malicious receipt
298    // field cannot break out of the JSON data block. The primary defense
299    // is type="application/json" which the HTML parser does not execute,
300    // but this escaping adds a second layer.
301    // Escape ALL '<' as '\u003c' in the JSON string to prevent any
302    // case-variant of </script> from breaking out of the data block.
303    // This is bulletproof: no HTML parser can see a tag open inside the JSON.
304    let safe_json = receipt_json.replace('<', r"\u003c");
305
306    // Only one placeholder: __RECEIPT_JSON__ inside the data block.
307    // The page title is set at runtime from the parsed JSON to avoid
308    // a second replacement pass that could re-inject content.
309    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        // Read back
384        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}