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 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/// Errors from package operations.
26#[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
51/// Manifest file inside the package root.
52const 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
59// Approval Authority package layout (v0.9.9 PR 4).
60// approvals/index.json -- top-level index of every approval evidence
61//                          file in this package
62// approvals/grants/<grant_id>.json    -- copy of the signed
63//                          ApprovalStatement envelope (already in
64//                          artifacts/ via the chain; mirrored here for
65//                          single-directory access during verify)
66// approvals/uses/<use_id>.json        -- ApprovalUse record from the
67//                          local journal at session-close time
68// approvals/checkpoints/<id>.json     -- JournalCheckpoint records that
69//                          cover the included uses (PR 6 Hub
70//                          checkpoint signing extends this)
71const 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/// Optional approval evidence to embed in the package alongside the
78/// receipt + artifacts. None means "no approvals consumed during this
79/// session, or none worth exporting." Empty vectors mean "we looked and
80/// found nothing"; the resulting package omits the `approvals/` dir
81/// entirely so absence is unambiguous.
82///
83/// Ownership of the evidence stays with the caller: `session::close`
84/// gathers the grant envelopes from the chain, the uses from the local
85/// journal, and any covering checkpoints, then hands them off here.
86#[derive(Debug, Clone, Default)]
87pub struct ApprovalsBundle {
88    /// Bytes of the signed ApprovalStatement envelopes that authorized
89    /// any consumed uses. Each entry is `(grant_id, raw_envelope_json)`.
90    /// Stored verbatim so the package's verifier can re-check the
91    /// signature without re-serializing.
92    pub grants:      Vec<(String, Vec<u8>)>,
93    /// ApprovalUse records pulled from the local journal at close time.
94    /// `action_artifact_id` should be backfilled before passing to
95    /// build_package (see `commands/session.rs`).
96    pub uses:        Vec<ApprovalUse>,
97    /// JournalCheckpoints that cover the included uses. Optional; may
98    /// be empty even when uses are present (PR 6 fills these in).
99    pub checkpoints: Vec<JournalCheckpoint>,
100    /// Explicit revocations we wanted to surface (e.g. a use whose
101    /// grant was revoked after consumption -- the package should still
102    /// show the consumed evidence and the revocation alongside).
103    /// Empty in PR 4; reserved.
104    pub revocations: Vec<ApprovalRevocation>,
105
106    /// Bytes of each action artifact's signed envelope that consumed an
107    /// approval. Each entry is `(action_artifact_id, raw_envelope_json)`.
108    /// v0.9.10 PR A: shipped to close the action↔use binding gap. The
109    /// verifier extracts `meta.approval_use_id` from each envelope and
110    /// cross-checks it against the package's use records. Empty in
111    /// pre-v0.9.10 packages; readers must treat absence as "binding
112    /// not asserted by package" rather than "binding present and OK."
113    pub action_envelopes: Vec<(String, Vec<u8>)>,
114}
115
116/// `approvals/index.json` -- top-level inventory of evidence in the
117/// package. Lets a consumer pre-flight what's there before opening
118/// every file; doubles as a stable shape for downstream tooling.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ApprovalsIndex {
121    /// Stable schema marker so future versions can fan out cleanly.
122    #[serde(rename = "type")]
123    pub type_: String,
124    pub schema_version: u32,
125    /// Stable kebab-case ids of grants present. Order matches
126    /// `grants/` filename order.
127    pub grants:      Vec<String>,
128    /// Use ids present.
129    pub uses:        Vec<String>,
130    pub checkpoints: Vec<String>,
131    pub revocations: Vec<String>,
132}
133
134impl ApprovalsIndex {
135    pub fn type_string() -> &'static str { "treeship/approvals-index/v1" }
136}
137
138/// Result of building a package.
139pub struct PackageOutput {
140    /// Path to the package directory.
141    pub path: PathBuf,
142    /// SHA-256 digest of the canonical receipt.json.
143    pub receipt_digest: String,
144    /// Merkle root hex (if present).
145    pub merkle_root: Option<String>,
146    /// Number of files in the package.
147    pub file_count: usize,
148}
149
150/// Build a `.treeship` package directory from a composed receipt.
151///
152/// Writes all package files into `output_dir/<session_id>.treeship/`.
153/// Returns metadata about the written package.
154///
155/// Backwards-compatible wrapper: callers that don't have approval
156/// evidence to export pass through here unchanged. Callers that do
157/// (`session::close` with consumed approvals) call
158/// `build_package_with_approvals` directly.
159pub fn build_package(
160    receipt: &SessionReceipt,
161    output_dir: &Path,
162) -> Result<PackageOutput, PackageError> {
163    build_package_with_approvals(receipt, output_dir, None)
164}
165
166/// Like `build_package` but also embeds approval evidence (PR 4 of v0.9.9).
167/// `bundle = None` is identical to `build_package`; the `approvals/`
168/// directory is omitted entirely so absence stays unambiguous.
169pub fn build_package_with_approvals(
170    receipt: &SessionReceipt,
171    output_dir: &Path,
172    bundle: Option<&ApprovalsBundle>,
173) -> Result<PackageOutput, PackageError> {
174    let session_id = &receipt.session.id;
175    let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
176
177    std::fs::create_dir_all(&pkg_dir)?;
178    std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
179    std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
180
181    let mut file_count = 0usize;
182
183    // 1. receipt.json -- canonical serialization
184    let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
185    std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
186    file_count += 1;
187
188    let receipt_hash = Sha256::digest(&receipt_bytes);
189    let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
190
191    // 2. merkle.json -- standalone copy of the Merkle section
192    let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
193    std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
194    file_count += 1;
195
196    // 3. render.json
197    let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
198    std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
199    file_count += 1;
200
201    // 4. Write inclusion proofs as individual files
202    for proof_entry in &receipt.merkle.inclusion_proofs {
203        let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
204        let filename = format!("{}.proof.json", proof_entry.artifact_id);
205        std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
206        file_count += 1;
207    }
208
209    // 5. preview.html stub
210    if receipt.render.generate_preview {
211        let preview = generate_preview_html(receipt);
212        std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
213        file_count += 1;
214    }
215
216    // 6. Approval evidence (v0.9.9 PR 4). Only writes when the caller
217    // supplied a bundle AND that bundle has at least one entry; an empty
218    // bundle behaves the same as None so a session with no consumed
219    // approvals doesn't leave behind an empty `approvals/` directory.
220    if let Some(b) = bundle {
221        if !b.grants.is_empty() || !b.uses.is_empty() || !b.checkpoints.is_empty() || !b.revocations.is_empty() || !b.action_envelopes.is_empty() {
222            std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
223            std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
224            std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
225            // v0.9.10 PR A: write action envelopes that consumed an
226            // approval. The artifacts/ directory was created earlier
227            // for the package layout but never populated; closing the
228            // action↔use binding gap requires the verifier to be able
229            // to read each consuming action's `meta.approval_use_id`.
230            std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
231            for (artifact_id, envelope_bytes) in &b.action_envelopes {
232                let safe = sanitize_filename(artifact_id);
233                std::fs::write(
234                    pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
235                    envelope_bytes,
236                )?;
237                file_count += 1;
238            }
239
240            let mut grant_ids = Vec::with_capacity(b.grants.len());
241            for (grant_id, envelope_bytes) in &b.grants {
242                let safe = sanitize_filename(grant_id);
243                std::fs::write(
244                    pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
245                    envelope_bytes,
246                )?;
247                grant_ids.push(grant_id.clone());
248                file_count += 1;
249            }
250
251            let mut use_ids = Vec::with_capacity(b.uses.len());
252            for u in &b.uses {
253                let safe = sanitize_filename(&u.use_id);
254                let bytes = serde_json::to_vec_pretty(u)?;
255                std::fs::write(
256                    pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
257                    &bytes,
258                )?;
259                use_ids.push(u.use_id.clone());
260                file_count += 1;
261            }
262
263            let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
264            for cp in &b.checkpoints {
265                let safe = sanitize_filename(&cp.checkpoint_id);
266                let bytes = serde_json::to_vec_pretty(cp)?;
267                std::fs::write(
268                    pkg_dir.join(APPROVALS_CHECKPOINTS).join(format!("{safe}.json")),
269                    &bytes,
270                )?;
271                checkpoint_ids.push(cp.checkpoint_id.clone());
272                file_count += 1;
273            }
274
275            let mut revocation_ids = Vec::with_capacity(b.revocations.len());
276            for rev in &b.revocations {
277                let safe = sanitize_filename(&rev.revocation_id);
278                let bytes = serde_json::to_vec_pretty(rev)?;
279                std::fs::write(
280                    pkg_dir.join(APPROVALS_DIR).join(format!("revocations-{safe}.json")),
281                    &bytes,
282                )?;
283                revocation_ids.push(rev.revocation_id.clone());
284                file_count += 1;
285            }
286
287            let index = ApprovalsIndex {
288                type_:          ApprovalsIndex::type_string().into(),
289                schema_version: 1,
290                grants:         grant_ids,
291                uses:           use_ids,
292                checkpoints:    checkpoint_ids,
293                revocations:    revocation_ids,
294            };
295            let index_bytes = serde_json::to_vec_pretty(&index)?;
296            std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
297            file_count += 1;
298        }
299    }
300
301    Ok(PackageOutput {
302        path: pkg_dir,
303        receipt_digest,
304        merkle_root: receipt.merkle.root.clone(),
305        file_count,
306    })
307}
308
309/// Sanitize an id (artifact_id, use_id, checkpoint_id) into a filesystem-safe
310/// filename. Underscores everything that isn't alphanumeric, dash, or dot.
311/// Not a security boundary; the digest chain is the integrity check.
312fn sanitize_filename(s: &str) -> String {
313    s.chars()
314        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' { c } else { '_' })
315        .collect()
316}
317
318/// Read approval evidence embedded in a package, if any. Returns
319/// `Ok(ApprovalsBundle::default())` when the package has no `approvals/`
320/// directory (the typical case for sessions that didn't consume any
321/// scoped approvals). Errors only on malformed JSON inside files that
322/// the index claims exist.
323///
324/// Quiet on missing-directory by design: PR 4 packages and pre-PR-4
325/// packages should both round-trip through verify without spurious
326/// failures.
327pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
328    let approvals_dir = pkg_dir.join(APPROVALS_DIR);
329    if !approvals_dir.is_dir() {
330        return Ok(ApprovalsBundle::default());
331    }
332
333    let mut bundle = ApprovalsBundle::default();
334
335    // Grants are raw envelopes by file; we don't parse here, the
336    // verify layer can re-check the signature.
337    let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
338    if grants_dir.is_dir() {
339        for entry in std::fs::read_dir(&grants_dir)? {
340            let entry = entry?;
341            let path = entry.path();
342            if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
343            let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
344            let bytes = std::fs::read(&path)?;
345            bundle.grants.push((id, bytes));
346        }
347    }
348
349    let uses_dir = pkg_dir.join(APPROVALS_USES);
350    if uses_dir.is_dir() {
351        for entry in std::fs::read_dir(&uses_dir)? {
352            let entry = entry?;
353            let path = entry.path();
354            if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
355            let bytes = std::fs::read(&path)?;
356            let u: ApprovalUse = serde_json::from_slice(&bytes)?;
357            bundle.uses.push(u);
358        }
359    }
360
361    let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
362    if cps_dir.is_dir() {
363        for entry in std::fs::read_dir(&cps_dir)? {
364            let entry = entry?;
365            let path = entry.path();
366            if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
367            let bytes = std::fs::read(&path)?;
368            let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
369            bundle.checkpoints.push(cp);
370        }
371    }
372
373    // v0.9.10 PR A: read action envelopes shipped to support the
374    // action↔use binding check. Pre-v0.9.10 packages have an empty
375    // artifacts/ dir (the dir was created but never populated); the
376    // bundle's `action_envelopes` stays empty in that case, and the
377    // verifier reports the binding row honestly as "not asserted by
378    // package" rather than silently passing.
379    let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
380    if arts_dir.is_dir() {
381        for entry in std::fs::read_dir(&arts_dir)? {
382            let entry = entry?;
383            let path = entry.path();
384            if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
385            let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
386            let bytes = std::fs::read(&path)?;
387            bundle.action_envelopes.push((id, bytes));
388        }
389    }
390
391    Ok(bundle)
392}
393
394/// Read and parse a `.treeship` package from disk.
395pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
396    let receipt_path = pkg_dir.join(RECEIPT_FILE);
397    if !receipt_path.exists() {
398        return Err(PackageError::InvalidPackage(
399            format!("missing {RECEIPT_FILE} in {}", pkg_dir.display()),
400        ));
401    }
402    let bytes = std::fs::read(&receipt_path)?;
403    let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
404
405    if receipt.type_ != RECEIPT_TYPE {
406        return Err(PackageError::InvalidPackage(
407            format!("unexpected type: {} (expected {RECEIPT_TYPE})", receipt.type_),
408        ));
409    }
410
411    Ok(receipt)
412}
413
414/// Verify a `.treeship` package locally.
415///
416/// Returns a list of check results. All must pass for the package to be valid.
417pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
418    let mut checks = Vec::new();
419
420    // 1. receipt.json exists and parses
421    let receipt = match read_package(pkg_dir) {
422        Ok(r) => {
423            checks.push(VerifyCheck::pass("receipt.json", "Parses as valid Session Receipt"));
424            r
425        }
426        Err(e) => {
427            checks.push(VerifyCheck::fail("receipt.json", &format!("Failed to parse: {e}")));
428            return Ok(checks);
429        }
430    };
431
432    // 2. Type field
433    if receipt.type_ == RECEIPT_TYPE {
434        checks.push(VerifyCheck::pass("type", "Correct receipt type"));
435    } else {
436        checks.push(VerifyCheck::fail("type", &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_)));
437    }
438
439    // 3. Determinism: re-serialize and check digest matches
440    let receipt_path = pkg_dir.join(RECEIPT_FILE);
441    let on_disk = std::fs::read(&receipt_path)?;
442    let re_serialized = serde_json::to_vec_pretty(&receipt)?;
443    if on_disk == re_serialized {
444        checks.push(VerifyCheck::pass("determinism", "receipt.json round-trips identically"));
445    } else {
446        // Not a hard failure -- pretty-print whitespace may differ
447        checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
448    }
449
450    // 4. Merkle root re-computation
451    if !receipt.artifacts.is_empty() {
452        let mut tree = crate::merkle::MerkleTree::new();
453        for art in &receipt.artifacts {
454            tree.append(&art.artifact_id);
455        }
456        let root_bytes = tree.root();
457        let recomputed_root = root_bytes
458            .map(|r| format!("mroot_{}", hex::encode(r)));
459        let root_hex = root_bytes
460            .map(|r| hex::encode(r))
461            .unwrap_or_default();
462
463        if recomputed_root == receipt.merkle.root {
464            checks.push(VerifyCheck::pass("merkle_root", "Merkle root matches recomputed value"));
465        } else {
466            checks.push(VerifyCheck::fail(
467                "merkle_root",
468                &format!(
469                    "Mismatch: on-disk {:?} vs recomputed {:?}",
470                    receipt.merkle.root, recomputed_root
471                ),
472            ));
473        }
474
475        // 5. Verify each inclusion proof
476        for proof_entry in &receipt.merkle.inclusion_proofs {
477            let verified = crate::merkle::MerkleTree::verify_proof(
478                &root_hex,
479                &proof_entry.artifact_id,
480                &proof_entry.proof,
481            );
482            if verified {
483                checks.push(VerifyCheck::pass(
484                    &format!("inclusion:{}", proof_entry.artifact_id),
485                    "Inclusion proof valid",
486                ));
487            } else {
488                checks.push(VerifyCheck::fail(
489                    &format!("inclusion:{}", proof_entry.artifact_id),
490                    "Inclusion proof failed verification",
491                ));
492            }
493        }
494    } else {
495        checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
496    }
497
498    // 6. Leaf count matches artifacts
499    if receipt.merkle.leaf_count == receipt.artifacts.len() {
500        checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
501    } else {
502        checks.push(VerifyCheck::fail(
503            "leaf_count",
504            &format!("leaf_count {} != artifact count {}", receipt.merkle.leaf_count, receipt.artifacts.len()),
505        ));
506    }
507
508    // 7. Timeline ordering (determinism rule: timestamp, sequence_no, event_id)
509    let ordered = receipt.timeline.windows(2).all(|w| {
510        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
511            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
512    });
513    if ordered {
514        checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
515    } else {
516        checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
517    }
518
519    // event_log completeness: when session::close skipped malformed
520    // event log lines, the count is recorded on receipt.proofs.event_log_skipped.
521    // Surface as WARN (not FAIL) because the receipt is still
522    // cryptographically valid -- we just want a downstream verifier to
523    // know that some evidence was dropped before the receipt was sealed.
524    // A future --strict flag can promote this to FAIL.
525    // Codex adversarial review finding #8.
526    if receipt.proofs.event_log_skipped > 0 {
527        checks.push(VerifyCheck::warn(
528            "event_log_completeness",
529            &format!(
530                "{} event(s) skipped during close (malformed lines in events.jsonl). \
531                 Receipt is cryptographically valid but does not represent the full event stream. \
532                 Inspect close-time stderr or the events.jsonl directly to investigate.",
533                receipt.proofs.event_log_skipped,
534            ),
535        ));
536    }
537
538    // 8. Approval evidence -- v0.9.9 PR 4. Three independent replay
539    // checks, each emitted as its own VerifyCheck row so the printer
540    // (and downstream tooling) can render them separately.
541    //
542    //   replay-package-local      duplicate uses INSIDE this package
543    //   replay-included-checkpoint  embedded JournalCheckpoints verify standalone
544    //
545    // The local-journal level requires access to the workspace journal,
546    // which the package alone doesn't carry; that check runs in the CLI
547    // verify_package wrapper that has Ctx access. The hub-org level is
548    // reserved for PR 6 -- not claimed without a real Hub checkpoint.
549    let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
550    add_approval_evidence_checks(&mut checks, &bundle);
551
552    Ok(checks)
553}
554
555/// Emit the package-local + included-checkpoint replay checks. Both are
556/// fully offline: package-local scans the embedded uses for duplicates;
557/// included-checkpoint walks the embedded checkpoint records and
558/// re-derives each `record_digest` against its stored value.
559///
560/// The local-journal check is NOT here -- it requires workspace access
561/// and is added by the CLI wrapper in `commands/package.rs` that has the
562/// resolved config_path. Keeping these two pure means an offline tool
563/// (Hub-side validator, third-party verifier) can run the same checks
564/// without needing a Treeship workspace.
565pub(crate) fn add_approval_evidence_checks(
566    checks: &mut Vec<VerifyCheck>,
567    bundle: &ApprovalsBundle,
568) {
569    if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
570        // Nothing to assert. Stay quiet rather than emit a "skipped"
571        // row -- session packages without approvals shouldn't drag in
572        // approval rows by accident.
573        return;
574    }
575
576    // -- replay-package-local --
577    // Two distinct violation cases inside the package:
578    //   (a) uses sharing (grant_id, nonce_digest) EXCEED max_uses on
579    //       that grant. Two uses of a max_uses=2 grant is fine; three
580    //       is the violation. max_uses is read from the use record's
581    //       own `max_uses` field (a snapshot from consume time).
582    //   (b) two ApprovalUse records with the same use_id -- a copy
583    //       artifact from a corrupt build, never legitimate.
584    use std::collections::HashMap;
585    let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
586    let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
587    for u in &bundle.uses {
588        by_nonce
589            .entry((u.grant_id.clone(), u.nonce_digest.clone()))
590            .or_default()
591            .push(u);
592        by_use_id.entry(&u.use_id).or_default().push(u);
593    }
594    let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
595        .iter()
596        .filter_map(|(key, uses)| {
597            let max = uses.iter().filter_map(|u| u.max_uses).next()?;
598            if (uses.len() as u32) > max {
599                Some((key.clone(), uses.iter().map(|u| *u).collect(), max))
600            } else {
601                None
602            }
603        })
604        .collect();
605    let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
606        by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
607
608    if over_max.is_empty() && dup_use_ids.is_empty() {
609        checks.push(VerifyCheck::pass(
610            "replay-package-local",
611            &format!("no duplicate approval use inside package ({} uses scanned)", bundle.uses.len()),
612        ));
613    } else {
614        let mut detail = String::from("package-local replay violation:");
615        for ((grant_id, _nd), uses, max) in &over_max {
616            detail.push_str(&format!(
617                " grant {grant_id} consumed {} times in this package (max_uses={max});",
618                uses.len(),
619            ));
620        }
621        for (uid, uses) in &dup_use_ids {
622            detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
623        }
624        checks.push(VerifyCheck::fail("replay-package-local", &detail));
625    }
626
627    // -- replay-included-checkpoint --
628    // For each checkpoint, recompute its record_digest from canonical
629    // form. If the stored digest doesn't match, the checkpoint was
630    // tampered after sealing.
631    if !bundle.checkpoints.is_empty() {
632        let mut tampered = Vec::new();
633        for cp in &bundle.checkpoints {
634            let recomputed = journal_checkpoint_record_digest(cp);
635            if recomputed != cp.record_digest {
636                tampered.push((cp.checkpoint_id.clone(), cp.record_digest.clone(), recomputed));
637            }
638        }
639        if tampered.is_empty() {
640            checks.push(VerifyCheck::pass(
641                "replay-included-checkpoint",
642                &format!("{} included journal checkpoint(s) verify offline", bundle.checkpoints.len()),
643            ));
644        } else {
645            let detail = tampered.iter()
646                .map(|(id, expected, actual)| {
647                    format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
648                })
649                .collect::<Vec<_>>()
650                .join("; ");
651            checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
652        }
653    }
654
655    // -- approval-use-record-digest --
656    // Each ApprovalUse carries its own record_digest computed over the
657    // canonical form of the record (minus the digest itself). Tampering
658    // any field changes the digest. v0.9.10 PR A renames this from the
659    // older `approval-use-integrity` because the prior label suggested
660    // it covered nonce/action binding -- it didn't, and Codex's v0.9.9
661    // adversarial review flagged the over-claim. The honest scope of
662    // this row is "each use's stored digest matches its canonical
663    // recompute"; the binding checks are now separate rows below.
664    let mut tampered_uses = Vec::new();
665    for u in &bundle.uses {
666        let recomputed = approval_use_record_digest(u);
667        if recomputed != u.record_digest {
668            tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
669        }
670    }
671    if !bundle.uses.is_empty() {
672        if tampered_uses.is_empty() {
673            checks.push(VerifyCheck::pass(
674                "approval-use-record-digest",
675                &format!("{} use record(s) recompute identically", bundle.uses.len()),
676            ));
677        } else {
678            let detail = tampered_uses.iter()
679                .map(|(id, expected, actual)| {
680                    format!("use {id} tampered (stored {expected}, recomputed {actual})")
681                })
682                .collect::<Vec<_>>()
683                .join("; ");
684            checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
685        }
686    }
687
688    // -- approval-use-nonce-binding --
689    // Cross-check each use's `nonce_digest` against the corresponding
690    // grant's *signed* nonce. v0.9.9 trusted the use's nonce_digest
691    // verbatim, which let an attacker who controls the package mutate
692    // it (and recompute record_digest) to claim consumption of a grant
693    // whose nonce was never actually used. This row closes that gap.
694    //
695    // Discipline: the grant envelope is the source of truth. Before
696    // pulling the raw `nonce` from the grant's payload we verify the
697    // envelope's *content addressing* -- recompute the artifact_id
698    // from the envelope's PAE bytes and confirm it equals the grant_id
699    // the package claims. v0.9.10 PR A round 1 only parsed the
700    // envelope without this check; that left a forgery window where
701    // an attacker could ship an arbitrary unsigned envelope under any
702    // grant_id filename. v0.9.10 PR A round 2 closes the window: only
703    // a bytes-identical envelope produces the same artifact_id under
704    // SHA-256.
705    if !bundle.uses.is_empty() {
706        use crate::attestation::envelope::Envelope;
707        use crate::attestation::{pae, artifact_id_from_pae};
708        use crate::statements::{nonce_digest, ApprovalStatement};
709        let mut grant_nonce_digest: std::collections::HashMap<String, String> = std::collections::HashMap::new();
710        let mut tampered_grants: Vec<String> = Vec::new();
711        for (grant_id, env_bytes) in &bundle.grants {
712            let env = match Envelope::from_json(env_bytes) {
713                Ok(e)  => e,
714                Err(_) => {
715                    tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
716                    continue;
717                }
718            };
719            // Content-addressing check: derive the artifact_id from
720            // the envelope's PAE bytes and confirm it matches the
721            // claimed grant_id. If they differ the envelope was
722            // substituted or its bytes were tampered post-sign.
723            let derived = match env.payload_bytes() {
724                Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
725                Err(_) => {
726                    tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
727                    continue;
728                }
729            };
730            if &derived != grant_id {
731                tampered_grants.push(format!(
732                    "grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
733                ));
734                continue;
735            }
736            let approval: ApprovalStatement = match env.unmarshal_statement() {
737                Ok(a)  => a,
738                Err(_) => {
739                    tampered_grants.push(format!("grant {grant_id} payload not an ApprovalStatement"));
740                    continue;
741                }
742            };
743            grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
744        }
745        let mut mismatches: Vec<String> = Vec::new();
746        let mut missing_grants: Vec<String> = Vec::new();
747        for u in &bundle.uses {
748            match grant_nonce_digest.get(&u.grant_id) {
749                Some(expected) => {
750                    if expected != &u.nonce_digest {
751                        mismatches.push(format!(
752                            "use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
753                            u.use_id, u.nonce_digest, u.grant_id, expected,
754                        ));
755                    }
756                }
757                None => {
758                    missing_grants.push(format!(
759                        "use {} references grant {} but no usable grant envelope is in the package",
760                        u.use_id, u.grant_id,
761                    ));
762                }
763            }
764        }
765        if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
766            checks.push(VerifyCheck::pass(
767                "approval-use-nonce-binding",
768                &format!(
769                    "{} use record(s) bind to content-addressed grant signed nonces",
770                    bundle.uses.len(),
771                ),
772            ));
773        } else {
774            let mut parts: Vec<String> = Vec::new();
775            if !tampered_grants.is_empty() { parts.push(tampered_grants.join("; ")); }
776            if !mismatches.is_empty()      { parts.push(mismatches.join("; ")); }
777            if !missing_grants.is_empty()  { parts.push(missing_grants.join("; ")); }
778            checks.push(VerifyCheck::fail("approval-use-nonce-binding", &parts.join("; ")));
779        }
780    }
781
782    // -- approval-use-action-binding --
783    // Cross-check each consuming action's `meta.approval_use_id`
784    // against the package's use records. v0.9.9 ignored this pointer
785    // entirely; the package didn't even ship action envelopes, so the
786    // verifier could not see the field. v0.9.10 PR A: action envelopes
787    // ride along in `artifacts/`, and this row pins that every action
788    // declaring it consumed an approval has a use record for that
789    // exact use_id, with matching grant_id and matching
790    // `nonce_digest(approval_nonce)`.
791    //
792    // Honesty rule: when bundle.action_envelopes is empty (pre-v0.9.10
793    // packages, or a v0.9.10 package with no consuming actions
794    // recorded), this row reports `not asserted by package` rather
795    // than silent PASS.
796    if !bundle.uses.is_empty() {
797        use crate::attestation::envelope::Envelope;
798        use crate::attestation::{pae, artifact_id_from_pae};
799        use crate::statements::{nonce_digest, ActionStatement};
800        if bundle.action_envelopes.is_empty() {
801            checks.push(VerifyCheck::warn(
802                "approval-use-action-binding",
803                "no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
804            ));
805        } else {
806            let use_ids: std::collections::HashSet<&str> = bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
807            let mut violations: Vec<String> = Vec::new();
808            let mut bound_count = 0usize;
809            for (artifact_id, env_bytes) in &bundle.action_envelopes {
810                let env = match Envelope::from_json(env_bytes) {
811                    Ok(e)  => e,
812                    Err(_) => {
813                        violations.push(format!("action {artifact_id} envelope unparseable"));
814                        continue;
815                    }
816                };
817                // Content-addressing gate: derive the artifact_id
818                // from the envelope's PAE bytes and require it to
819                // match the filename stem the package shipped this
820                // envelope under. Without this gate an attacker
821                // controlling the package can write any forged
822                // unsigned action JSON to artifacts/<id>.json and the
823                // binding rows would trust it.
824                let derived = match env.payload_bytes() {
825                    Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
826                    Err(_) => {
827                        violations.push(format!("action {artifact_id} envelope payload undecodable"));
828                        continue;
829                    }
830                };
831                if &derived != artifact_id {
832                    violations.push(format!(
833                        "action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
834                    ));
835                    continue;
836                }
837                let action: ActionStatement = match env.unmarshal_statement() {
838                    Ok(a)  => a,
839                    Err(_) => {
840                        violations.push(format!("action {artifact_id} not an ActionStatement"));
841                        continue;
842                    }
843                };
844                let raw_nonce = match action.approval_nonce.as_deref() {
845                    Some(n) => n,
846                    None    => continue,
847                };
848                let claimed_use_id = action
849                    .meta
850                    .as_ref()
851                    .and_then(|m| m.get("approval_use_id"))
852                    .and_then(|v| v.as_str());
853                let Some(claimed_use_id) = claimed_use_id else {
854                    violations.push(format!(
855                        "action {artifact_id} consumed an approval but its meta has no approval_use_id"
856                    ));
857                    continue;
858                };
859                if !use_ids.contains(claimed_use_id) {
860                    violations.push(format!(
861                        "action {artifact_id} claims approval_use_id={} but no such use is embedded",
862                        claimed_use_id,
863                    ));
864                    continue;
865                }
866                let expected = nonce_digest(raw_nonce);
867                let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
868                if let Some(u) = matched_use {
869                    if u.nonce_digest != expected {
870                        violations.push(format!(
871                            "action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
872                            expected, claimed_use_id, u.nonce_digest,
873                        ));
874                        continue;
875                    }
876                }
877                bound_count += 1;
878            }
879            if violations.is_empty() {
880                checks.push(VerifyCheck::pass(
881                    "approval-use-action-binding",
882                    &format!(
883                        "{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
884                    ),
885                ));
886            } else {
887                checks.push(VerifyCheck::fail(
888                    "approval-use-action-binding",
889                    &violations.join("; "),
890                ));
891            }
892        }
893    }
894
895    // -- approval-use-chain-continuity --
896    // v0.9.9 verified each use's individual record_digest but never
897    // walked the `previous_record_digest` chain across the embedded
898    // records. An attacker could rewrite an entire chain consistently
899    // (recomputing each digest along the way) and the per-record
900    // checks all passed.
901    //
902    // Algorithm (v0.9.10 PR A round 2): build a graph of embedded
903    // records keyed by record_digest, then require the embedded
904    // records to form a SINGLE linked list with exactly one genesis
905    // (previous_record_digest == "") and no cycles, forks, or
906    // disconnected subchains.
907    //
908    //   - Dangling prev pointer (not in `owned`) -> fail.
909    //   - More than one record with prev == ""    -> fail (mid-chain
910    //     genesis is a forgery primitive).
911    //   - Two records sharing the same prev       -> fail (fork).
912    //   - Cycle reached during the walk           -> fail.
913    //   - Walk doesn't reach every record         -> fail (disconnected
914    //     subchain).
915    //
916    // We can only check *internal* consistency offline -- the package
917    // doesn't ship the workspace journal's full history, so the chain
918    // we see may be a contiguous prefix or window. Anchoring against
919    // a Hub-signed checkpoint is replay-hub-org's job; here we report
920    // structural consistency only.
921    if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
922        use std::collections::{HashMap, HashSet};
923        // Each record carries a label for diagnostics + its own
924        // record_digest + previous_record_digest.
925        struct Node<'a> { label: String, digest: &'a str, prev: &'a str }
926        let mut nodes: Vec<Node> = Vec::new();
927        for u in &bundle.uses {
928            nodes.push(Node {
929                label: format!("use {}", u.use_id),
930                digest: u.record_digest.as_str(),
931                prev: u.previous_record_digest.as_str(),
932            });
933        }
934        for cp in &bundle.checkpoints {
935            nodes.push(Node {
936                label: format!("checkpoint {}", cp.checkpoint_id),
937                digest: cp.record_digest.as_str(),
938                prev: cp.previous_record_digest.as_str(),
939            });
940        }
941
942        let owned: HashSet<&str> = std::iter::once("")
943            .chain(nodes.iter().map(|n| n.digest))
944            .collect();
945
946        let mut violations: Vec<String> = Vec::new();
947        // Dangling prev: pointer not in owned set.
948        for n in &nodes {
949            if !owned.contains(n.prev) {
950                violations.push(format!(
951                    "{} previous_record_digest {} not anchored in package",
952                    n.label, n.prev,
953                ));
954            }
955        }
956        // Genesis count: only one record allowed to have prev == "".
957        let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
958        if genesis.len() > 1 {
959            violations.push(format!(
960                "{} records claim previous_record_digest='' (genesis): {}",
961                genesis.len(),
962                genesis.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join(", "),
963            ));
964        }
965        // Forks: two records sharing the same non-empty prev.
966        let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
967        for n in &nodes {
968            by_prev.entry(n.prev).or_default().push(n);
969        }
970        for (prev, group) in &by_prev {
971            if group.len() > 1 && !prev.is_empty() {
972                violations.push(format!(
973                    "fork: {} records share previous_record_digest {}: {}",
974                    group.len(),
975                    prev,
976                    group.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join(", "),
977                ));
978            }
979        }
980
981        // Walk from genesis (if exactly one) following digest-as-prev
982        // links. Detect cycles and unreachable records.
983        if violations.is_empty() {
984            let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
985            let next_of: HashMap<&str, &Node> = nodes
986                .iter()
987                .filter(|n| !n.prev.is_empty())
988                .map(|n| (n.prev, *(&n)))
989                .collect();
990            let start = match genesis.first() {
991                Some(g) => Some(*g),
992                None    => None,
993            };
994            let mut visited: HashSet<&str> = HashSet::new();
995            let mut current = start;
996            while let Some(node) = current {
997                if !visited.insert(node.digest) {
998                    violations.push(format!(
999                        "cycle detected at {} (record_digest {})",
1000                        node.label, node.digest,
1001                    ));
1002                    break;
1003                }
1004                current = next_of.get(node.digest).copied();
1005            }
1006            // Disconnected: walk didn't include every node.
1007            if violations.is_empty() && visited.len() != nodes.len() {
1008                let unreached: Vec<String> = nodes.iter()
1009                    .filter(|n| !visited.contains(n.digest))
1010                    .map(|n| n.label.clone())
1011                    .collect();
1012                if !unreached.is_empty() {
1013                    violations.push(format!(
1014                        "disconnected subchain: {} record(s) not reachable from genesis: {}",
1015                        unreached.len(),
1016                        unreached.join(", "),
1017                    ));
1018                }
1019            }
1020            let _ = by_digest; // reserved for future cross-checks
1021        }
1022
1023        if violations.is_empty() {
1024            checks.push(VerifyCheck::pass(
1025                "approval-use-chain-continuity",
1026                &format!(
1027                    "{} record(s) form a single connected linked list from one genesis with no cycles or forks",
1028                    nodes.len(),
1029                ),
1030            ));
1031        } else {
1032            checks.push(VerifyCheck::fail(
1033                "approval-use-chain-continuity",
1034                &violations.join("; "),
1035            ));
1036        }
1037    }
1038
1039    // -- replay-hub-org -- v0.9.9 PR 6.
1040    // The strongest level Treeship can speak to today. The release
1041    // rule is non-negotiable: PASS only when (1) at least one embedded
1042    // checkpoint declares kind=HubOrg, (2) every required Hub field is
1043    // populated, (3) the signature verifies against the embedded
1044    // public key, AND (4) the checkpoint covers every embedded
1045    // ApprovalUse via covered_use_ids. Anything short of that means
1046    // "no row" or "fail" -- never silent pass.
1047    //
1048    // No row at all when the package has no Hub-kind checkpoint:
1049    // matches the v0.9.9 PR 4-5 behavior where the panel renders
1050    // "- hub-org   not checked (no Hub checkpoint in package)" so a
1051    // reader doesn't misread an absent row as a failure.
1052    let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
1053        .checkpoints
1054        .iter()
1055        .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
1056        .collect();
1057    if !hub_checkpoints.is_empty() {
1058        let mut all_ok = true;
1059        let mut details: Vec<String> = Vec::new();
1060        let mut have_valid_signature = false;
1061
1062        for cp in &hub_checkpoints {
1063            match crate::statements::verify_hub_checkpoint_signature(cp) {
1064                crate::statements::HubCheckpointVerification::Valid => {
1065                    have_valid_signature = true;
1066                    // Coverage: every embedded use_id MUST appear in
1067                    // this checkpoint's covered_use_ids. A checkpoint
1068                    // that doesn't cover the package's uses cannot
1069                    // promote replay-hub-org for those uses.
1070                    let covered: std::collections::HashSet<&String> =
1071                        cp.covered_use_ids.iter().collect();
1072                    let missing: Vec<String> = bundle
1073                        .uses
1074                        .iter()
1075                        .filter(|u| !covered.contains(&u.use_id))
1076                        .map(|u| u.use_id.clone())
1077                        .collect();
1078                    if missing.is_empty() {
1079                        details.push(format!(
1080                            "{} signed by {} verifies; covers {} use(s)",
1081                            cp.checkpoint_id,
1082                            cp.hub_id,
1083                            cp.covered_use_ids.len(),
1084                        ));
1085                    } else {
1086                        all_ok = false;
1087                        details.push(format!(
1088                            "{} verifies but does not cover {} use(s): {}",
1089                            cp.checkpoint_id,
1090                            missing.len(),
1091                            missing.join(", "),
1092                        ));
1093                    }
1094                }
1095                crate::statements::HubCheckpointVerification::MissingFields(field) => {
1096                    all_ok = false;
1097                    details.push(format!(
1098                        "{} declares kind=hub-org but field `{}` is missing",
1099                        cp.checkpoint_id, field,
1100                    ));
1101                }
1102                crate::statements::HubCheckpointVerification::Tampered => {
1103                    all_ok = false;
1104                    details.push(format!(
1105                        "{} hub signature failed verification (tampered or wrong key)",
1106                        cp.checkpoint_id,
1107                    ));
1108                }
1109                crate::statements::HubCheckpointVerification::NotHubKind => {
1110                    // Filter ensures this is unreachable; keep the
1111                    // arm so a future filter relaxation doesn't go
1112                    // silent.
1113                    all_ok = false;
1114                    details.push(format!(
1115                        "{} kind toggled out of hub-org during verify",
1116                        cp.checkpoint_id,
1117                    ));
1118                }
1119            }
1120        }
1121        if all_ok && have_valid_signature {
1122            checks.push(VerifyCheck::pass(
1123                "replay-hub-org",
1124                &details.join("; "),
1125            ));
1126        } else {
1127            // Hub checkpoint is present but does not satisfy every
1128            // gate. Default mode warns; the CLI verify wrapper's
1129            // --strict promotes to fail.
1130            checks.push(VerifyCheck::warn(
1131                "replay-hub-org",
1132                &details.join("; "),
1133            ));
1134        }
1135    }
1136    // No hub-org checkpoints embedded -> no row. The Approval
1137    // Authority panel still renders "- hub-org   not checked".
1138
1139    let _ = ReplayCheckLevel::HubOrg;
1140    let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
1141    let _ = ReplayCheck::not_performed;
1142}
1143
1144/// A single verification check result.
1145#[derive(Debug, Clone)]
1146pub struct VerifyCheck {
1147    pub name: String,
1148    pub status: VerifyStatus,
1149    pub detail: String,
1150}
1151
1152/// Status of a verification check.
1153#[derive(Debug, Clone, PartialEq, Eq)]
1154pub enum VerifyStatus {
1155    Pass,
1156    Fail,
1157    Warn,
1158}
1159
1160impl VerifyCheck {
1161    pub fn pass(name: &str, detail: &str) -> Self {
1162        Self { name: name.into(), status: VerifyStatus::Pass, detail: detail.into() }
1163    }
1164    pub fn fail(name: &str, detail: &str) -> Self {
1165        Self { name: name.into(), status: VerifyStatus::Fail, detail: detail.into() }
1166    }
1167    pub fn warn(name: &str, detail: &str) -> Self {
1168        Self { name: name.into(), status: VerifyStatus::Warn, detail: detail.into() }
1169    }
1170}
1171
1172impl VerifyCheck {
1173    pub fn passed(&self) -> bool {
1174        self.status == VerifyStatus::Pass
1175    }
1176}
1177
1178/// HTML template for the self-contained verifier preview.
1179/// Loaded at compile time so the binary carries no runtime file dependencies.
1180const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
1181
1182/// Generate a self-contained preview.html that embeds the receipt JSON
1183/// and runs Merkle verification client-side using Web Crypto API.
1184///
1185/// The HTML works fully air-gapped: no network calls, no CDN, no server.
1186/// Open it in any modern browser and it automatically verifies the receipt
1187/// and shows pass/fail for each check.
1188fn generate_preview_html(receipt: &SessionReceipt) -> String {
1189    let receipt_json = serde_json::to_string_pretty(receipt)
1190        .unwrap_or_else(|_| "{}".to_string());
1191    // Defense-in-depth: escape </script sequences so a malicious receipt
1192    // field cannot break out of the JSON data block. The primary defense
1193    // is type="application/json" which the HTML parser does not execute,
1194    // but this escaping adds a second layer.
1195    // Escape ALL '<' as '\u003c' in the JSON string to prevent any
1196    // case-variant of </script> from breaking out of the data block.
1197    // This is bulletproof: no HTML parser can see a tag open inside the JSON.
1198    let safe_json = receipt_json.replace('<', r"\u003c");
1199
1200    // Only one placeholder: __RECEIPT_JSON__ inside the data block.
1201    // The page title is set at runtime from the parsed JSON to avoid
1202    // a second replacement pass that could re-inject content.
1203    PREVIEW_TEMPLATE
1204        .replace("__RECEIPT_JSON__", &safe_json)
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::*;
1210    use crate::session::event::*;
1211    use crate::session::manifest::SessionManifest;
1212    use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
1213
1214    fn make_receipt() -> SessionReceipt {
1215        let manifest = SessionManifest::new(
1216            "ssn_pkg_test".into(),
1217            "agent://test".into(),
1218            "2026-04-05T08:00:00Z".into(),
1219            1743843600000,
1220        );
1221
1222        let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
1223            SessionEvent {
1224                session_id: "ssn_pkg_test".into(),
1225                event_id: format!("evt_{:016x}", seq),
1226                timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1227                sequence_no: seq,
1228                trace_id: "trace_1".into(),
1229                span_id: format!("span_{seq}"),
1230                parent_span_id: None,
1231                agent_id: format!("agent://{inst}"),
1232                agent_instance_id: inst.into(),
1233                agent_name: inst.into(),
1234                agent_role: None,
1235                host_id: "host_1".into(),
1236                tool_runtime_id: None,
1237                event_type: et,
1238                artifact_ref: None,
1239                meta: None,
1240            }
1241        };
1242
1243        let events = vec![
1244            mk(0, "root", EventType::SessionStarted),
1245            mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
1246            mk(2, "root", EventType::AgentCalledTool {
1247                tool_name: "read_file".into(),
1248                tool_input_digest: None,
1249                tool_output_digest: None,
1250                duration_ms: Some(10),
1251            }),
1252            mk(3, "root", EventType::AgentCompleted { termination_reason: None }),
1253            mk(4, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(60000) }),
1254        ];
1255
1256        let artifacts = vec![
1257            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
1258        ];
1259
1260        ReceiptComposer::compose(&manifest, &events, artifacts)
1261    }
1262
1263    #[test]
1264    fn build_and_read_package() {
1265        let receipt = make_receipt();
1266        let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
1267
1268        let output = build_package(&receipt, &tmp).unwrap();
1269        assert!(output.path.exists());
1270        assert!(output.path.join("receipt.json").exists());
1271        assert!(output.path.join("merkle.json").exists());
1272        assert!(output.path.join("render.json").exists());
1273        assert!(output.path.join("preview.html").exists());
1274        assert!(output.receipt_digest.starts_with("sha256:"));
1275        assert!(output.file_count >= 4);
1276
1277        // Read back
1278        let read_back = read_package(&output.path).unwrap();
1279        assert_eq!(read_back.session.id, "ssn_pkg_test");
1280        assert_eq!(read_back.type_, RECEIPT_TYPE);
1281
1282        let _ = std::fs::remove_dir_all(&tmp);
1283    }
1284
1285    #[test]
1286    fn verify_valid_package() {
1287        let receipt = make_receipt();
1288        let tmp = std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
1289
1290        let output = build_package(&receipt, &tmp).unwrap();
1291        let checks = verify_package(&output.path).unwrap();
1292
1293        let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
1294        assert!(fails.is_empty(), "unexpected failures: {fails:?}");
1295
1296        let passes: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Pass).collect();
1297        assert!(passes.len() >= 5, "expected at least 5 pass checks, got {}", passes.len());
1298
1299        let _ = std::fs::remove_dir_all(&tmp);
1300    }
1301
1302    #[test]
1303    fn verify_detects_missing_receipt() {
1304        let tmp = std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
1305        std::fs::create_dir_all(&tmp).unwrap();
1306
1307        let err = read_package(&tmp);
1308        assert!(err.is_err());
1309
1310        let _ = std::fs::remove_dir_all(&tmp);
1311    }
1312
1313    #[test]
1314    fn preview_html_contains_session_info() {
1315        let receipt = make_receipt();
1316        let html = generate_preview_html(&receipt);
1317        assert!(html.contains("ssn_pkg_test"));
1318        assert!(html.contains("treeship.dev"));
1319        assert!(html.contains("Timeline"));
1320    }
1321}