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
107/// `approvals/index.json` -- top-level inventory of evidence in the
108/// package. Lets a consumer pre-flight what's there before opening
109/// every file; doubles as a stable shape for downstream tooling.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ApprovalsIndex {
112    /// Stable schema marker so future versions can fan out cleanly.
113    #[serde(rename = "type")]
114    pub type_: String,
115    pub schema_version: u32,
116    /// Stable kebab-case ids of grants present. Order matches
117    /// `grants/` filename order.
118    pub grants:      Vec<String>,
119    /// Use ids present.
120    pub uses:        Vec<String>,
121    pub checkpoints: Vec<String>,
122    pub revocations: Vec<String>,
123}
124
125impl ApprovalsIndex {
126    pub fn type_string() -> &'static str { "treeship/approvals-index/v1" }
127}
128
129/// Result of building a package.
130pub struct PackageOutput {
131    /// Path to the package directory.
132    pub path: PathBuf,
133    /// SHA-256 digest of the canonical receipt.json.
134    pub receipt_digest: String,
135    /// Merkle root hex (if present).
136    pub merkle_root: Option<String>,
137    /// Number of files in the package.
138    pub file_count: usize,
139}
140
141/// Build a `.treeship` package directory from a composed receipt.
142///
143/// Writes all package files into `output_dir/<session_id>.treeship/`.
144/// Returns metadata about the written package.
145///
146/// Backwards-compatible wrapper: callers that don't have approval
147/// evidence to export pass through here unchanged. Callers that do
148/// (`session::close` with consumed approvals) call
149/// `build_package_with_approvals` directly.
150pub fn build_package(
151    receipt: &SessionReceipt,
152    output_dir: &Path,
153) -> Result<PackageOutput, PackageError> {
154    build_package_with_approvals(receipt, output_dir, None)
155}
156
157/// Like `build_package` but also embeds approval evidence (PR 4 of v0.9.9).
158/// `bundle = None` is identical to `build_package`; the `approvals/`
159/// directory is omitted entirely so absence stays unambiguous.
160pub fn build_package_with_approvals(
161    receipt: &SessionReceipt,
162    output_dir: &Path,
163    bundle: Option<&ApprovalsBundle>,
164) -> Result<PackageOutput, PackageError> {
165    let session_id = &receipt.session.id;
166    let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
167
168    std::fs::create_dir_all(&pkg_dir)?;
169    std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
170    std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
171
172    let mut file_count = 0usize;
173
174    // 1. receipt.json -- canonical serialization
175    let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
176    std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
177    file_count += 1;
178
179    let receipt_hash = Sha256::digest(&receipt_bytes);
180    let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
181
182    // 2. merkle.json -- standalone copy of the Merkle section
183    let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
184    std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
185    file_count += 1;
186
187    // 3. render.json
188    let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
189    std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
190    file_count += 1;
191
192    // 4. Write inclusion proofs as individual files
193    for proof_entry in &receipt.merkle.inclusion_proofs {
194        let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
195        let filename = format!("{}.proof.json", proof_entry.artifact_id);
196        std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
197        file_count += 1;
198    }
199
200    // 5. preview.html stub
201    if receipt.render.generate_preview {
202        let preview = generate_preview_html(receipt);
203        std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
204        file_count += 1;
205    }
206
207    // 6. Approval evidence (v0.9.9 PR 4). Only writes when the caller
208    // supplied a bundle AND that bundle has at least one entry; an empty
209    // bundle behaves the same as None so a session with no consumed
210    // approvals doesn't leave behind an empty `approvals/` directory.
211    if let Some(b) = bundle {
212        if !b.grants.is_empty() || !b.uses.is_empty() || !b.checkpoints.is_empty() || !b.revocations.is_empty() {
213            std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
214            std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
215            std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
216
217            let mut grant_ids = Vec::with_capacity(b.grants.len());
218            for (grant_id, envelope_bytes) in &b.grants {
219                let safe = sanitize_filename(grant_id);
220                std::fs::write(
221                    pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
222                    envelope_bytes,
223                )?;
224                grant_ids.push(grant_id.clone());
225                file_count += 1;
226            }
227
228            let mut use_ids = Vec::with_capacity(b.uses.len());
229            for u in &b.uses {
230                let safe = sanitize_filename(&u.use_id);
231                let bytes = serde_json::to_vec_pretty(u)?;
232                std::fs::write(
233                    pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
234                    &bytes,
235                )?;
236                use_ids.push(u.use_id.clone());
237                file_count += 1;
238            }
239
240            let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
241            for cp in &b.checkpoints {
242                let safe = sanitize_filename(&cp.checkpoint_id);
243                let bytes = serde_json::to_vec_pretty(cp)?;
244                std::fs::write(
245                    pkg_dir.join(APPROVALS_CHECKPOINTS).join(format!("{safe}.json")),
246                    &bytes,
247                )?;
248                checkpoint_ids.push(cp.checkpoint_id.clone());
249                file_count += 1;
250            }
251
252            let mut revocation_ids = Vec::with_capacity(b.revocations.len());
253            for rev in &b.revocations {
254                let safe = sanitize_filename(&rev.revocation_id);
255                let bytes = serde_json::to_vec_pretty(rev)?;
256                std::fs::write(
257                    pkg_dir.join(APPROVALS_DIR).join(format!("revocations-{safe}.json")),
258                    &bytes,
259                )?;
260                revocation_ids.push(rev.revocation_id.clone());
261                file_count += 1;
262            }
263
264            let index = ApprovalsIndex {
265                type_:          ApprovalsIndex::type_string().into(),
266                schema_version: 1,
267                grants:         grant_ids,
268                uses:           use_ids,
269                checkpoints:    checkpoint_ids,
270                revocations:    revocation_ids,
271            };
272            let index_bytes = serde_json::to_vec_pretty(&index)?;
273            std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
274            file_count += 1;
275        }
276    }
277
278    Ok(PackageOutput {
279        path: pkg_dir,
280        receipt_digest,
281        merkle_root: receipt.merkle.root.clone(),
282        file_count,
283    })
284}
285
286/// Sanitize an id (artifact_id, use_id, checkpoint_id) into a filesystem-safe
287/// filename. Underscores everything that isn't alphanumeric, dash, or dot.
288/// Not a security boundary; the digest chain is the integrity check.
289fn sanitize_filename(s: &str) -> String {
290    s.chars()
291        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' { c } else { '_' })
292        .collect()
293}
294
295/// Read approval evidence embedded in a package, if any. Returns
296/// `Ok(ApprovalsBundle::default())` when the package has no `approvals/`
297/// directory (the typical case for sessions that didn't consume any
298/// scoped approvals). Errors only on malformed JSON inside files that
299/// the index claims exist.
300///
301/// Quiet on missing-directory by design: PR 4 packages and pre-PR-4
302/// packages should both round-trip through verify without spurious
303/// failures.
304pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
305    let approvals_dir = pkg_dir.join(APPROVALS_DIR);
306    if !approvals_dir.is_dir() {
307        return Ok(ApprovalsBundle::default());
308    }
309
310    let mut bundle = ApprovalsBundle::default();
311
312    // Grants are raw envelopes by file; we don't parse here, the
313    // verify layer can re-check the signature.
314    let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
315    if grants_dir.is_dir() {
316        for entry in std::fs::read_dir(&grants_dir)? {
317            let entry = entry?;
318            let path = entry.path();
319            if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
320            let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
321            let bytes = std::fs::read(&path)?;
322            bundle.grants.push((id, bytes));
323        }
324    }
325
326    let uses_dir = pkg_dir.join(APPROVALS_USES);
327    if uses_dir.is_dir() {
328        for entry in std::fs::read_dir(&uses_dir)? {
329            let entry = entry?;
330            let path = entry.path();
331            if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
332            let bytes = std::fs::read(&path)?;
333            let u: ApprovalUse = serde_json::from_slice(&bytes)?;
334            bundle.uses.push(u);
335        }
336    }
337
338    let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
339    if cps_dir.is_dir() {
340        for entry in std::fs::read_dir(&cps_dir)? {
341            let entry = entry?;
342            let path = entry.path();
343            if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
344            let bytes = std::fs::read(&path)?;
345            let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
346            bundle.checkpoints.push(cp);
347        }
348    }
349
350    Ok(bundle)
351}
352
353/// Read and parse a `.treeship` package from disk.
354pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
355    let receipt_path = pkg_dir.join(RECEIPT_FILE);
356    if !receipt_path.exists() {
357        return Err(PackageError::InvalidPackage(
358            format!("missing {RECEIPT_FILE} in {}", pkg_dir.display()),
359        ));
360    }
361    let bytes = std::fs::read(&receipt_path)?;
362    let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
363
364    if receipt.type_ != RECEIPT_TYPE {
365        return Err(PackageError::InvalidPackage(
366            format!("unexpected type: {} (expected {RECEIPT_TYPE})", receipt.type_),
367        ));
368    }
369
370    Ok(receipt)
371}
372
373/// Verify a `.treeship` package locally.
374///
375/// Returns a list of check results. All must pass for the package to be valid.
376pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
377    let mut checks = Vec::new();
378
379    // 1. receipt.json exists and parses
380    let receipt = match read_package(pkg_dir) {
381        Ok(r) => {
382            checks.push(VerifyCheck::pass("receipt.json", "Parses as valid Session Receipt"));
383            r
384        }
385        Err(e) => {
386            checks.push(VerifyCheck::fail("receipt.json", &format!("Failed to parse: {e}")));
387            return Ok(checks);
388        }
389    };
390
391    // 2. Type field
392    if receipt.type_ == RECEIPT_TYPE {
393        checks.push(VerifyCheck::pass("type", "Correct receipt type"));
394    } else {
395        checks.push(VerifyCheck::fail("type", &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_)));
396    }
397
398    // 3. Determinism: re-serialize and check digest matches
399    let receipt_path = pkg_dir.join(RECEIPT_FILE);
400    let on_disk = std::fs::read(&receipt_path)?;
401    let re_serialized = serde_json::to_vec_pretty(&receipt)?;
402    if on_disk == re_serialized {
403        checks.push(VerifyCheck::pass("determinism", "receipt.json round-trips identically"));
404    } else {
405        // Not a hard failure -- pretty-print whitespace may differ
406        checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
407    }
408
409    // 4. Merkle root re-computation
410    if !receipt.artifacts.is_empty() {
411        let mut tree = crate::merkle::MerkleTree::new();
412        for art in &receipt.artifacts {
413            tree.append(&art.artifact_id);
414        }
415        let root_bytes = tree.root();
416        let recomputed_root = root_bytes
417            .map(|r| format!("mroot_{}", hex::encode(r)));
418        let root_hex = root_bytes
419            .map(|r| hex::encode(r))
420            .unwrap_or_default();
421
422        if recomputed_root == receipt.merkle.root {
423            checks.push(VerifyCheck::pass("merkle_root", "Merkle root matches recomputed value"));
424        } else {
425            checks.push(VerifyCheck::fail(
426                "merkle_root",
427                &format!(
428                    "Mismatch: on-disk {:?} vs recomputed {:?}",
429                    receipt.merkle.root, recomputed_root
430                ),
431            ));
432        }
433
434        // 5. Verify each inclusion proof
435        for proof_entry in &receipt.merkle.inclusion_proofs {
436            let verified = crate::merkle::MerkleTree::verify_proof(
437                &root_hex,
438                &proof_entry.artifact_id,
439                &proof_entry.proof,
440            );
441            if verified {
442                checks.push(VerifyCheck::pass(
443                    &format!("inclusion:{}", proof_entry.artifact_id),
444                    "Inclusion proof valid",
445                ));
446            } else {
447                checks.push(VerifyCheck::fail(
448                    &format!("inclusion:{}", proof_entry.artifact_id),
449                    "Inclusion proof failed verification",
450                ));
451            }
452        }
453    } else {
454        checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
455    }
456
457    // 6. Leaf count matches artifacts
458    if receipt.merkle.leaf_count == receipt.artifacts.len() {
459        checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
460    } else {
461        checks.push(VerifyCheck::fail(
462            "leaf_count",
463            &format!("leaf_count {} != artifact count {}", receipt.merkle.leaf_count, receipt.artifacts.len()),
464        ));
465    }
466
467    // 7. Timeline ordering (determinism rule: timestamp, sequence_no, event_id)
468    let ordered = receipt.timeline.windows(2).all(|w| {
469        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
470            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
471    });
472    if ordered {
473        checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
474    } else {
475        checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
476    }
477
478    // event_log completeness: when session::close skipped malformed
479    // event log lines, the count is recorded on receipt.proofs.event_log_skipped.
480    // Surface as WARN (not FAIL) because the receipt is still
481    // cryptographically valid -- we just want a downstream verifier to
482    // know that some evidence was dropped before the receipt was sealed.
483    // A future --strict flag can promote this to FAIL.
484    // Codex adversarial review finding #8.
485    if receipt.proofs.event_log_skipped > 0 {
486        checks.push(VerifyCheck::warn(
487            "event_log_completeness",
488            &format!(
489                "{} event(s) skipped during close (malformed lines in events.jsonl). \
490                 Receipt is cryptographically valid but does not represent the full event stream. \
491                 Inspect close-time stderr or the events.jsonl directly to investigate.",
492                receipt.proofs.event_log_skipped,
493            ),
494        ));
495    }
496
497    // 8. Approval evidence -- v0.9.9 PR 4. Three independent replay
498    // checks, each emitted as its own VerifyCheck row so the printer
499    // (and downstream tooling) can render them separately.
500    //
501    //   replay-package-local      duplicate uses INSIDE this package
502    //   replay-included-checkpoint  embedded JournalCheckpoints verify standalone
503    //
504    // The local-journal level requires access to the workspace journal,
505    // which the package alone doesn't carry; that check runs in the CLI
506    // verify_package wrapper that has Ctx access. The hub-org level is
507    // reserved for PR 6 -- not claimed without a real Hub checkpoint.
508    let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
509    add_approval_evidence_checks(&mut checks, &bundle);
510
511    Ok(checks)
512}
513
514/// Emit the package-local + included-checkpoint replay checks. Both are
515/// fully offline: package-local scans the embedded uses for duplicates;
516/// included-checkpoint walks the embedded checkpoint records and
517/// re-derives each `record_digest` against its stored value.
518///
519/// The local-journal check is NOT here -- it requires workspace access
520/// and is added by the CLI wrapper in `commands/package.rs` that has the
521/// resolved config_path. Keeping these two pure means an offline tool
522/// (Hub-side validator, third-party verifier) can run the same checks
523/// without needing a Treeship workspace.
524pub(crate) fn add_approval_evidence_checks(
525    checks: &mut Vec<VerifyCheck>,
526    bundle: &ApprovalsBundle,
527) {
528    if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
529        // Nothing to assert. Stay quiet rather than emit a "skipped"
530        // row -- session packages without approvals shouldn't drag in
531        // approval rows by accident.
532        return;
533    }
534
535    // -- replay-package-local --
536    // Two distinct violation cases inside the package:
537    //   (a) uses sharing (grant_id, nonce_digest) EXCEED max_uses on
538    //       that grant. Two uses of a max_uses=2 grant is fine; three
539    //       is the violation. max_uses is read from the use record's
540    //       own `max_uses` field (a snapshot from consume time).
541    //   (b) two ApprovalUse records with the same use_id -- a copy
542    //       artifact from a corrupt build, never legitimate.
543    use std::collections::HashMap;
544    let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
545    let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
546    for u in &bundle.uses {
547        by_nonce
548            .entry((u.grant_id.clone(), u.nonce_digest.clone()))
549            .or_default()
550            .push(u);
551        by_use_id.entry(&u.use_id).or_default().push(u);
552    }
553    let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
554        .iter()
555        .filter_map(|(key, uses)| {
556            let max = uses.iter().filter_map(|u| u.max_uses).next()?;
557            if (uses.len() as u32) > max {
558                Some((key.clone(), uses.iter().map(|u| *u).collect(), max))
559            } else {
560                None
561            }
562        })
563        .collect();
564    let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
565        by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
566
567    if over_max.is_empty() && dup_use_ids.is_empty() {
568        checks.push(VerifyCheck::pass(
569            "replay-package-local",
570            &format!("no duplicate approval use inside package ({} uses scanned)", bundle.uses.len()),
571        ));
572    } else {
573        let mut detail = String::from("package-local replay violation:");
574        for ((grant_id, _nd), uses, max) in &over_max {
575            detail.push_str(&format!(
576                " grant {grant_id} consumed {} times in this package (max_uses={max});",
577                uses.len(),
578            ));
579        }
580        for (uid, uses) in &dup_use_ids {
581            detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
582        }
583        checks.push(VerifyCheck::fail("replay-package-local", &detail));
584    }
585
586    // -- replay-included-checkpoint --
587    // For each checkpoint, recompute its record_digest from canonical
588    // form. If the stored digest doesn't match, the checkpoint was
589    // tampered after sealing.
590    if !bundle.checkpoints.is_empty() {
591        let mut tampered = Vec::new();
592        for cp in &bundle.checkpoints {
593            let recomputed = journal_checkpoint_record_digest(cp);
594            if recomputed != cp.record_digest {
595                tampered.push((cp.checkpoint_id.clone(), cp.record_digest.clone(), recomputed));
596            }
597        }
598        if tampered.is_empty() {
599            checks.push(VerifyCheck::pass(
600                "replay-included-checkpoint",
601                &format!("{} included journal checkpoint(s) verify offline", bundle.checkpoints.len()),
602            ));
603        } else {
604            let detail = tampered.iter()
605                .map(|(id, expected, actual)| {
606                    format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
607                })
608                .collect::<Vec<_>>()
609                .join("; ");
610            checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
611        }
612    }
613
614    // -- per-use record-digest integrity --
615    // Even without a checkpoint, each ApprovalUse carries its own
616    // record_digest; tampering with any field changes the digest.
617    let mut tampered_uses = Vec::new();
618    for u in &bundle.uses {
619        let recomputed = approval_use_record_digest(u);
620        if recomputed != u.record_digest {
621            tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
622        }
623    }
624    if !tampered_uses.is_empty() {
625        let detail = tampered_uses.iter()
626            .map(|(id, expected, actual)| {
627                format!("use {id} tampered (stored {expected}, recomputed {actual})")
628            })
629            .collect::<Vec<_>>()
630            .join("; ");
631        checks.push(VerifyCheck::fail("approval-use-integrity", &detail));
632    }
633
634    // -- replay-hub-org -- v0.9.9 PR 6.
635    // The strongest level Treeship can speak to today. The release
636    // rule is non-negotiable: PASS only when (1) at least one embedded
637    // checkpoint declares kind=HubOrg, (2) every required Hub field is
638    // populated, (3) the signature verifies against the embedded
639    // public key, AND (4) the checkpoint covers every embedded
640    // ApprovalUse via covered_use_ids. Anything short of that means
641    // "no row" or "fail" -- never silent pass.
642    //
643    // No row at all when the package has no Hub-kind checkpoint:
644    // matches the v0.9.9 PR 4-5 behavior where the panel renders
645    // "- hub-org   not checked (no Hub checkpoint in package)" so a
646    // reader doesn't misread an absent row as a failure.
647    let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
648        .checkpoints
649        .iter()
650        .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
651        .collect();
652    if !hub_checkpoints.is_empty() {
653        let mut all_ok = true;
654        let mut details: Vec<String> = Vec::new();
655        let mut have_valid_signature = false;
656
657        for cp in &hub_checkpoints {
658            match crate::statements::verify_hub_checkpoint_signature(cp) {
659                crate::statements::HubCheckpointVerification::Valid => {
660                    have_valid_signature = true;
661                    // Coverage: every embedded use_id MUST appear in
662                    // this checkpoint's covered_use_ids. A checkpoint
663                    // that doesn't cover the package's uses cannot
664                    // promote replay-hub-org for those uses.
665                    let covered: std::collections::HashSet<&String> =
666                        cp.covered_use_ids.iter().collect();
667                    let missing: Vec<String> = bundle
668                        .uses
669                        .iter()
670                        .filter(|u| !covered.contains(&u.use_id))
671                        .map(|u| u.use_id.clone())
672                        .collect();
673                    if missing.is_empty() {
674                        details.push(format!(
675                            "{} signed by {} verifies; covers {} use(s)",
676                            cp.checkpoint_id,
677                            cp.hub_id,
678                            cp.covered_use_ids.len(),
679                        ));
680                    } else {
681                        all_ok = false;
682                        details.push(format!(
683                            "{} verifies but does not cover {} use(s): {}",
684                            cp.checkpoint_id,
685                            missing.len(),
686                            missing.join(", "),
687                        ));
688                    }
689                }
690                crate::statements::HubCheckpointVerification::MissingFields(field) => {
691                    all_ok = false;
692                    details.push(format!(
693                        "{} declares kind=hub-org but field `{}` is missing",
694                        cp.checkpoint_id, field,
695                    ));
696                }
697                crate::statements::HubCheckpointVerification::Tampered => {
698                    all_ok = false;
699                    details.push(format!(
700                        "{} hub signature failed verification (tampered or wrong key)",
701                        cp.checkpoint_id,
702                    ));
703                }
704                crate::statements::HubCheckpointVerification::NotHubKind => {
705                    // Filter ensures this is unreachable; keep the
706                    // arm so a future filter relaxation doesn't go
707                    // silent.
708                    all_ok = false;
709                    details.push(format!(
710                        "{} kind toggled out of hub-org during verify",
711                        cp.checkpoint_id,
712                    ));
713                }
714            }
715        }
716        if all_ok && have_valid_signature {
717            checks.push(VerifyCheck::pass(
718                "replay-hub-org",
719                &details.join("; "),
720            ));
721        } else {
722            // Hub checkpoint is present but does not satisfy every
723            // gate. Default mode warns; the CLI verify wrapper's
724            // --strict promotes to fail.
725            checks.push(VerifyCheck::warn(
726                "replay-hub-org",
727                &details.join("; "),
728            ));
729        }
730    }
731    // No hub-org checkpoints embedded -> no row. The Approval
732    // Authority panel still renders "- hub-org   not checked".
733
734    let _ = ReplayCheckLevel::HubOrg;
735    let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
736    let _ = ReplayCheck::not_performed;
737}
738
739/// A single verification check result.
740#[derive(Debug, Clone)]
741pub struct VerifyCheck {
742    pub name: String,
743    pub status: VerifyStatus,
744    pub detail: String,
745}
746
747/// Status of a verification check.
748#[derive(Debug, Clone, PartialEq, Eq)]
749pub enum VerifyStatus {
750    Pass,
751    Fail,
752    Warn,
753}
754
755impl VerifyCheck {
756    pub fn pass(name: &str, detail: &str) -> Self {
757        Self { name: name.into(), status: VerifyStatus::Pass, detail: detail.into() }
758    }
759    pub fn fail(name: &str, detail: &str) -> Self {
760        Self { name: name.into(), status: VerifyStatus::Fail, detail: detail.into() }
761    }
762    pub fn warn(name: &str, detail: &str) -> Self {
763        Self { name: name.into(), status: VerifyStatus::Warn, detail: detail.into() }
764    }
765}
766
767impl VerifyCheck {
768    pub fn passed(&self) -> bool {
769        self.status == VerifyStatus::Pass
770    }
771}
772
773/// HTML template for the self-contained verifier preview.
774/// Loaded at compile time so the binary carries no runtime file dependencies.
775const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
776
777/// Generate a self-contained preview.html that embeds the receipt JSON
778/// and runs Merkle verification client-side using Web Crypto API.
779///
780/// The HTML works fully air-gapped: no network calls, no CDN, no server.
781/// Open it in any modern browser and it automatically verifies the receipt
782/// and shows pass/fail for each check.
783fn generate_preview_html(receipt: &SessionReceipt) -> String {
784    let receipt_json = serde_json::to_string_pretty(receipt)
785        .unwrap_or_else(|_| "{}".to_string());
786    // Defense-in-depth: escape </script sequences so a malicious receipt
787    // field cannot break out of the JSON data block. The primary defense
788    // is type="application/json" which the HTML parser does not execute,
789    // but this escaping adds a second layer.
790    // Escape ALL '<' as '\u003c' in the JSON string to prevent any
791    // case-variant of </script> from breaking out of the data block.
792    // This is bulletproof: no HTML parser can see a tag open inside the JSON.
793    let safe_json = receipt_json.replace('<', r"\u003c");
794
795    // Only one placeholder: __RECEIPT_JSON__ inside the data block.
796    // The page title is set at runtime from the parsed JSON to avoid
797    // a second replacement pass that could re-inject content.
798    PREVIEW_TEMPLATE
799        .replace("__RECEIPT_JSON__", &safe_json)
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use crate::session::event::*;
806    use crate::session::manifest::SessionManifest;
807    use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
808
809    fn make_receipt() -> SessionReceipt {
810        let manifest = SessionManifest::new(
811            "ssn_pkg_test".into(),
812            "agent://test".into(),
813            "2026-04-05T08:00:00Z".into(),
814            1743843600000,
815        );
816
817        let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
818            SessionEvent {
819                session_id: "ssn_pkg_test".into(),
820                event_id: format!("evt_{:016x}", seq),
821                timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
822                sequence_no: seq,
823                trace_id: "trace_1".into(),
824                span_id: format!("span_{seq}"),
825                parent_span_id: None,
826                agent_id: format!("agent://{inst}"),
827                agent_instance_id: inst.into(),
828                agent_name: inst.into(),
829                agent_role: None,
830                host_id: "host_1".into(),
831                tool_runtime_id: None,
832                event_type: et,
833                artifact_ref: None,
834                meta: None,
835            }
836        };
837
838        let events = vec![
839            mk(0, "root", EventType::SessionStarted),
840            mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
841            mk(2, "root", EventType::AgentCalledTool {
842                tool_name: "read_file".into(),
843                tool_input_digest: None,
844                tool_output_digest: None,
845                duration_ms: Some(10),
846            }),
847            mk(3, "root", EventType::AgentCompleted { termination_reason: None }),
848            mk(4, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(60000) }),
849        ];
850
851        let artifacts = vec![
852            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
853        ];
854
855        ReceiptComposer::compose(&manifest, &events, artifacts)
856    }
857
858    #[test]
859    fn build_and_read_package() {
860        let receipt = make_receipt();
861        let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
862
863        let output = build_package(&receipt, &tmp).unwrap();
864        assert!(output.path.exists());
865        assert!(output.path.join("receipt.json").exists());
866        assert!(output.path.join("merkle.json").exists());
867        assert!(output.path.join("render.json").exists());
868        assert!(output.path.join("preview.html").exists());
869        assert!(output.receipt_digest.starts_with("sha256:"));
870        assert!(output.file_count >= 4);
871
872        // Read back
873        let read_back = read_package(&output.path).unwrap();
874        assert_eq!(read_back.session.id, "ssn_pkg_test");
875        assert_eq!(read_back.type_, RECEIPT_TYPE);
876
877        let _ = std::fs::remove_dir_all(&tmp);
878    }
879
880    #[test]
881    fn verify_valid_package() {
882        let receipt = make_receipt();
883        let tmp = std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
884
885        let output = build_package(&receipt, &tmp).unwrap();
886        let checks = verify_package(&output.path).unwrap();
887
888        let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
889        assert!(fails.is_empty(), "unexpected failures: {fails:?}");
890
891        let passes: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Pass).collect();
892        assert!(passes.len() >= 5, "expected at least 5 pass checks, got {}", passes.len());
893
894        let _ = std::fs::remove_dir_all(&tmp);
895    }
896
897    #[test]
898    fn verify_detects_missing_receipt() {
899        let tmp = std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
900        std::fs::create_dir_all(&tmp).unwrap();
901
902        let err = read_package(&tmp);
903        assert!(err.is_err());
904
905        let _ = std::fs::remove_dir_all(&tmp);
906    }
907
908    #[test]
909    fn preview_html_contains_session_info() {
910        let receipt = make_receipt();
911        let html = generate_preview_html(&receipt);
912        assert!(html.contains("ssn_pkg_test"));
913        assert!(html.contains("treeship.dev"));
914        assert!(html.contains("Timeline"));
915    }
916}