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.
417///
418/// Auto-loads the operator's trust roots from
419/// `TrustRootStore::default_path()`. Use
420/// [`verify_package_with_trust`] when the trust store is already in
421/// hand (CLI paths that take a `Ctx`, or tests).
422///
423/// Audit lane J fix-up: `open_default_or_empty` propagates `Malformed`
424/// and `PermissionsTooOpen` errors -- those are operator
425/// misconfiguration that must NOT be silently downgraded to an empty
426/// trust store (an empty store fails verification of any hub-org
427/// checkpoint, which is the right end-state, but the operator needs a
428/// clear "your trust file is broken" diagnostic instead of a misleading
429/// "untrusted issuer" message). Surface the error as a `trust-root`
430/// fail row and stop before doing real work that depends on trust.
431pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
432    let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
433        Ok(t) => t,
434        Err(e) => {
435            // Build a minimal check list so the caller's printer still
436            // renders a coherent failure rather than silently routing
437            // through a fake empty store.
438            return Ok(vec![VerifyCheck::fail(
439                "trust-root",
440                &format!("trust store unreadable: {e}"),
441            )]);
442        }
443    };
444    verify_package_with_trust(pkg_dir, &trust)
445}
446
447/// Like `verify_package` but takes an explicit `TrustRootStore` so the
448/// caller can verify with a constructed-in-memory trust set (tests) or
449/// a non-default location (CLI `--trust-roots`).
450pub fn verify_package_with_trust(
451    pkg_dir: &Path,
452    trust: &crate::trust::TrustRootStore,
453) -> Result<Vec<VerifyCheck>, PackageError> {
454    let mut checks = Vec::new();
455
456    // 1. receipt.json exists and parses
457    let receipt = match read_package(pkg_dir) {
458        Ok(r) => {
459            checks.push(VerifyCheck::pass("receipt.json", "Parses as valid Session Receipt"));
460            r
461        }
462        Err(e) => {
463            checks.push(VerifyCheck::fail("receipt.json", &format!("Failed to parse: {e}")));
464            return Ok(checks);
465        }
466    };
467
468    // 2. Type field
469    if receipt.type_ == RECEIPT_TYPE {
470        checks.push(VerifyCheck::pass("type", "Correct receipt type"));
471    } else {
472        checks.push(VerifyCheck::fail("type", &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_)));
473    }
474
475    // 3. Determinism: re-serialize and check digest matches
476    let receipt_path = pkg_dir.join(RECEIPT_FILE);
477    let on_disk = std::fs::read(&receipt_path)?;
478    let re_serialized = serde_json::to_vec_pretty(&receipt)?;
479    if on_disk == re_serialized {
480        checks.push(VerifyCheck::pass("determinism", "receipt.json round-trips identically"));
481    } else {
482        // Not a hard failure -- pretty-print whitespace may differ
483        checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
484    }
485
486    // 4. Merkle root re-computation
487    if !receipt.artifacts.is_empty() {
488        // Recompute under the receipt's declared merkle version so
489        // legacy (v0.10.2 and earlier, version=1, no domain separation)
490        // receipts continue to verify. New receipts always emit v2.
491        // Construct through the validating `with_version` so an unknown
492        // version surfaces as a hard fail rather than silently falling
493        // back to v1.
494        let version = receipt.merkle.merkle_version;
495        let mut tree = match crate::merkle::MerkleTree::with_version(version) {
496            Ok(t) => t,
497            Err(e) => {
498                checks.push(VerifyCheck::fail(
499                    "merkle_root",
500                    &format!("receipt declared unknown merkle_version: {e}"),
501                ));
502                // Skip the remaining merkle/inclusion work; emit the
503                // leaf_count + timeline tail and return.
504                return Ok(finish_package_checks(checks, &receipt));
505            }
506        };
507        for art in &receipt.artifacts {
508            tree.append(&art.artifact_id);
509        }
510        let root_bytes = tree.root();
511        let recomputed_root = root_bytes
512            .map(|r| format!("mroot_{}", hex::encode(r)));
513        let root_hex = root_bytes
514            .map(|r| hex::encode(r))
515            .unwrap_or_default();
516
517        if recomputed_root == receipt.merkle.root {
518            checks.push(VerifyCheck::pass("merkle_root", "Merkle root matches recomputed value"));
519        } else {
520            checks.push(VerifyCheck::fail(
521                "merkle_root",
522                &format!(
523                    "Mismatch: on-disk {:?} vs recomputed {:?}",
524                    receipt.merkle.root, recomputed_root
525                ),
526            ));
527        }
528
529        // 5. Verify each inclusion proof. Per-proof merkle_version must
530        // match the receipt section's declared version — drift is a
531        // hard fail (smuggled v1 proof inside a v2 receipt would
532        // otherwise dispatch through the weaker hashing path).
533        for proof_entry in &receipt.merkle.inclusion_proofs {
534            if proof_entry.proof.merkle_version != version {
535                checks.push(VerifyCheck::fail(
536                    &format!("inclusion:{}", proof_entry.artifact_id),
537                    &format!(
538                        "proof merkle_version {} != receipt section v{}",
539                        proof_entry.proof.merkle_version, version,
540                    ),
541                ));
542                continue;
543            }
544            let verified = crate::merkle::MerkleTree::verify_proof(
545                version,
546                &root_hex,
547                &proof_entry.artifact_id,
548                &proof_entry.proof,
549            );
550            if verified {
551                checks.push(VerifyCheck::pass(
552                    &format!("inclusion:{}", proof_entry.artifact_id),
553                    "Inclusion proof valid",
554                ));
555            } else {
556                checks.push(VerifyCheck::fail(
557                    &format!("inclusion:{}", proof_entry.artifact_id),
558                    "Inclusion proof failed verification",
559                ));
560            }
561        }
562    } else {
563        checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
564    }
565
566    // 6. Leaf count matches artifacts
567    if receipt.merkle.leaf_count == receipt.artifacts.len() {
568        checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
569    } else {
570        checks.push(VerifyCheck::fail(
571            "leaf_count",
572            &format!("leaf_count {} != artifact count {}", receipt.merkle.leaf_count, receipt.artifacts.len()),
573        ));
574    }
575
576    // 7. Timeline ordering (determinism rule: timestamp, sequence_no, event_id)
577    let ordered = receipt.timeline.windows(2).all(|w| {
578        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
579            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
580    });
581    if ordered {
582        checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
583    } else {
584        checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
585    }
586
587    // event_log completeness: when session::close skipped malformed
588    // event log lines, the count is recorded on receipt.proofs.event_log_skipped.
589    // Surface as WARN (not FAIL) because the receipt is still
590    // cryptographically valid -- we just want a downstream verifier to
591    // know that some evidence was dropped before the receipt was sealed.
592    // A future --strict flag can promote this to FAIL.
593    // Codex adversarial review finding #8.
594    if receipt.proofs.event_log_skipped > 0 {
595        checks.push(VerifyCheck::warn(
596            "event_log_completeness",
597            &format!(
598                "{} event(s) skipped during close (malformed lines in events.jsonl). \
599                 Receipt is cryptographically valid but does not represent the full event stream. \
600                 Inspect close-time stderr or the events.jsonl directly to investigate.",
601                receipt.proofs.event_log_skipped,
602            ),
603        ));
604    }
605
606    // 8. Approval evidence -- v0.9.9 PR 4. Three independent replay
607    // checks, each emitted as its own VerifyCheck row so the printer
608    // (and downstream tooling) can render them separately.
609    //
610    //   replay-package-local      duplicate uses INSIDE this package
611    //   replay-included-checkpoint  embedded JournalCheckpoints verify standalone
612    //
613    // The local-journal level requires access to the workspace journal,
614    // which the package alone doesn't carry; that check runs in the CLI
615    // verify_package wrapper that has Ctx access. The hub-org level is
616    // reserved for PR 6 -- not claimed without a real Hub checkpoint.
617    let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
618    add_approval_evidence_checks(&mut checks, &bundle, trust);
619
620    Ok(checks)
621}
622
623/// Tail of `verify_package`: emit leaf_count and timeline-order checks.
624/// Used by the early-return path when an unknown merkle version aborts
625/// Merkle recomputation — those two checks are independent of the tree
626/// version and still meaningful to surface.
627fn finish_package_checks(
628    mut checks: Vec<VerifyCheck>,
629    receipt: &SessionReceipt,
630) -> Vec<VerifyCheck> {
631    if receipt.merkle.leaf_count == receipt.artifacts.len() {
632        checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
633    } else {
634        checks.push(VerifyCheck::fail(
635            "leaf_count",
636            &format!(
637                "leaf_count {} != artifact count {}",
638                receipt.merkle.leaf_count, receipt.artifacts.len(),
639            ),
640        ));
641    }
642
643    let ordered = receipt.timeline.windows(2).all(|w| {
644        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
645            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
646    });
647    if ordered {
648        checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
649    } else {
650        checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
651    }
652
653    checks
654}
655
656/// Emit the package-local + included-checkpoint replay checks. Both are
657/// fully offline: package-local scans the embedded uses for duplicates;
658/// included-checkpoint walks the embedded checkpoint records and
659/// re-derives each `record_digest` against its stored value.
660///
661/// The local-journal check is NOT here -- it requires workspace access
662/// and is added by the CLI wrapper in `commands/package.rs` that has the
663/// resolved config_path. Keeping these two pure means an offline tool
664/// (Hub-side validator, third-party verifier) can run the same checks
665/// without needing a Treeship workspace.
666pub(crate) fn add_approval_evidence_checks(
667    checks: &mut Vec<VerifyCheck>,
668    bundle: &ApprovalsBundle,
669    trust: &crate::trust::TrustRootStore,
670) {
671    if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
672        // Nothing to assert. Stay quiet rather than emit a "skipped"
673        // row -- session packages without approvals shouldn't drag in
674        // approval rows by accident.
675        return;
676    }
677
678    // -- replay-package-local --
679    // Two distinct violation cases inside the package:
680    //   (a) uses sharing (grant_id, nonce_digest) EXCEED max_uses on
681    //       that grant. Two uses of a max_uses=2 grant is fine; three
682    //       is the violation. max_uses is read from the use record's
683    //       own `max_uses` field (a snapshot from consume time).
684    //   (b) two ApprovalUse records with the same use_id -- a copy
685    //       artifact from a corrupt build, never legitimate.
686    use std::collections::HashMap;
687    let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
688    let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
689    for u in &bundle.uses {
690        by_nonce
691            .entry((u.grant_id.clone(), u.nonce_digest.clone()))
692            .or_default()
693            .push(u);
694        by_use_id.entry(&u.use_id).or_default().push(u);
695    }
696    let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
697        .iter()
698        .filter_map(|(key, uses)| {
699            let max = uses.iter().filter_map(|u| u.max_uses).next()?;
700            if (uses.len() as u32) > max {
701                Some((key.clone(), uses.iter().map(|u| *u).collect(), max))
702            } else {
703                None
704            }
705        })
706        .collect();
707    let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
708        by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
709
710    if over_max.is_empty() && dup_use_ids.is_empty() {
711        checks.push(VerifyCheck::pass(
712            "replay-package-local",
713            &format!("no duplicate approval use inside package ({} uses scanned)", bundle.uses.len()),
714        ));
715    } else {
716        let mut detail = String::from("package-local replay violation:");
717        for ((grant_id, _nd), uses, max) in &over_max {
718            detail.push_str(&format!(
719                " grant {grant_id} consumed {} times in this package (max_uses={max});",
720                uses.len(),
721            ));
722        }
723        for (uid, uses) in &dup_use_ids {
724            detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
725        }
726        checks.push(VerifyCheck::fail("replay-package-local", &detail));
727    }
728
729    // -- replay-included-checkpoint --
730    // For each checkpoint, recompute its record_digest from canonical
731    // form. If the stored digest doesn't match, the checkpoint was
732    // tampered after sealing.
733    if !bundle.checkpoints.is_empty() {
734        let mut tampered = Vec::new();
735        for cp in &bundle.checkpoints {
736            let recomputed = journal_checkpoint_record_digest(cp);
737            if recomputed != cp.record_digest {
738                tampered.push((cp.checkpoint_id.clone(), cp.record_digest.clone(), recomputed));
739            }
740        }
741        if tampered.is_empty() {
742            checks.push(VerifyCheck::pass(
743                "replay-included-checkpoint",
744                &format!("{} included journal checkpoint(s) verify offline", bundle.checkpoints.len()),
745            ));
746        } else {
747            let detail = tampered.iter()
748                .map(|(id, expected, actual)| {
749                    format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
750                })
751                .collect::<Vec<_>>()
752                .join("; ");
753            checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
754        }
755    }
756
757    // -- approval-use-record-digest --
758    // Each ApprovalUse carries its own record_digest computed over the
759    // canonical form of the record (minus the digest itself). Tampering
760    // any field changes the digest. v0.9.10 PR A renames this from the
761    // older `approval-use-integrity` because the prior label suggested
762    // it covered nonce/action binding -- it didn't, and Codex's v0.9.9
763    // adversarial review flagged the over-claim. The honest scope of
764    // this row is "each use's stored digest matches its canonical
765    // recompute"; the binding checks are now separate rows below.
766    let mut tampered_uses = Vec::new();
767    for u in &bundle.uses {
768        let recomputed = approval_use_record_digest(u);
769        if recomputed != u.record_digest {
770            tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
771        }
772    }
773    if !bundle.uses.is_empty() {
774        if tampered_uses.is_empty() {
775            checks.push(VerifyCheck::pass(
776                "approval-use-record-digest",
777                &format!("{} use record(s) recompute identically", bundle.uses.len()),
778            ));
779        } else {
780            let detail = tampered_uses.iter()
781                .map(|(id, expected, actual)| {
782                    format!("use {id} tampered (stored {expected}, recomputed {actual})")
783                })
784                .collect::<Vec<_>>()
785                .join("; ");
786            checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
787        }
788    }
789
790    // -- approval-use-nonce-binding --
791    // Cross-check each use's `nonce_digest` against the corresponding
792    // grant's *signed* nonce. v0.9.9 trusted the use's nonce_digest
793    // verbatim, which let an attacker who controls the package mutate
794    // it (and recompute record_digest) to claim consumption of a grant
795    // whose nonce was never actually used. This row closes that gap.
796    //
797    // Discipline: the grant envelope is the source of truth. Before
798    // pulling the raw `nonce` from the grant's payload we verify the
799    // envelope's *content addressing* -- recompute the artifact_id
800    // from the envelope's PAE bytes and confirm it equals the grant_id
801    // the package claims. v0.9.10 PR A round 1 only parsed the
802    // envelope without this check; that left a forgery window where
803    // an attacker could ship an arbitrary unsigned envelope under any
804    // grant_id filename. v0.9.10 PR A round 2 closes the window: only
805    // a bytes-identical envelope produces the same artifact_id under
806    // SHA-256.
807    if !bundle.uses.is_empty() {
808        use crate::attestation::envelope::Envelope;
809        use crate::attestation::{pae, artifact_id_from_pae};
810        use crate::statements::{nonce_digest, ApprovalStatement};
811        let mut grant_nonce_digest: std::collections::HashMap<String, String> = std::collections::HashMap::new();
812        let mut tampered_grants: Vec<String> = Vec::new();
813        for (grant_id, env_bytes) in &bundle.grants {
814            let env = match Envelope::from_json(env_bytes) {
815                Ok(e)  => e,
816                Err(_) => {
817                    tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
818                    continue;
819                }
820            };
821            // Content-addressing check: derive the artifact_id from
822            // the envelope's PAE bytes and confirm it matches the
823            // claimed grant_id. If they differ the envelope was
824            // substituted or its bytes were tampered post-sign.
825            let derived = match env.payload_bytes() {
826                Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
827                Err(_) => {
828                    tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
829                    continue;
830                }
831            };
832            if &derived != grant_id {
833                tampered_grants.push(format!(
834                    "grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
835                ));
836                continue;
837            }
838            let approval: ApprovalStatement = match env.unmarshal_statement() {
839                Ok(a)  => a,
840                Err(_) => {
841                    tampered_grants.push(format!("grant {grant_id} payload not an ApprovalStatement"));
842                    continue;
843                }
844            };
845            grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
846        }
847        let mut mismatches: Vec<String> = Vec::new();
848        let mut missing_grants: Vec<String> = Vec::new();
849        for u in &bundle.uses {
850            match grant_nonce_digest.get(&u.grant_id) {
851                Some(expected) => {
852                    if expected != &u.nonce_digest {
853                        mismatches.push(format!(
854                            "use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
855                            u.use_id, u.nonce_digest, u.grant_id, expected,
856                        ));
857                    }
858                }
859                None => {
860                    missing_grants.push(format!(
861                        "use {} references grant {} but no usable grant envelope is in the package",
862                        u.use_id, u.grant_id,
863                    ));
864                }
865            }
866        }
867        if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
868            checks.push(VerifyCheck::pass(
869                "approval-use-nonce-binding",
870                &format!(
871                    "{} use record(s) bind to content-addressed grant signed nonces",
872                    bundle.uses.len(),
873                ),
874            ));
875        } else {
876            let mut parts: Vec<String> = Vec::new();
877            if !tampered_grants.is_empty() { parts.push(tampered_grants.join("; ")); }
878            if !mismatches.is_empty()      { parts.push(mismatches.join("; ")); }
879            if !missing_grants.is_empty()  { parts.push(missing_grants.join("; ")); }
880            checks.push(VerifyCheck::fail("approval-use-nonce-binding", &parts.join("; ")));
881        }
882    }
883
884    // -- approval-use-action-binding --
885    // Cross-check each consuming action's `meta.approval_use_id`
886    // against the package's use records. v0.9.9 ignored this pointer
887    // entirely; the package didn't even ship action envelopes, so the
888    // verifier could not see the field. v0.9.10 PR A: action envelopes
889    // ride along in `artifacts/`, and this row pins that every action
890    // declaring it consumed an approval has a use record for that
891    // exact use_id, with matching grant_id and matching
892    // `nonce_digest(approval_nonce)`.
893    //
894    // Honesty rule: when bundle.action_envelopes is empty (pre-v0.9.10
895    // packages, or a v0.9.10 package with no consuming actions
896    // recorded), this row reports `not asserted by package` rather
897    // than silent PASS.
898    if !bundle.uses.is_empty() {
899        use crate::attestation::envelope::Envelope;
900        use crate::attestation::{pae, artifact_id_from_pae};
901        use crate::statements::{nonce_digest, ActionStatement};
902        if bundle.action_envelopes.is_empty() {
903            checks.push(VerifyCheck::warn(
904                "approval-use-action-binding",
905                "no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
906            ));
907        } else {
908            let use_ids: std::collections::HashSet<&str> = bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
909            let mut violations: Vec<String> = Vec::new();
910            let mut bound_count = 0usize;
911            for (artifact_id, env_bytes) in &bundle.action_envelopes {
912                let env = match Envelope::from_json(env_bytes) {
913                    Ok(e)  => e,
914                    Err(_) => {
915                        violations.push(format!("action {artifact_id} envelope unparseable"));
916                        continue;
917                    }
918                };
919                // Content-addressing gate: derive the artifact_id
920                // from the envelope's PAE bytes and require it to
921                // match the filename stem the package shipped this
922                // envelope under. Without this gate an attacker
923                // controlling the package can write any forged
924                // unsigned action JSON to artifacts/<id>.json and the
925                // binding rows would trust it.
926                let derived = match env.payload_bytes() {
927                    Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
928                    Err(_) => {
929                        violations.push(format!("action {artifact_id} envelope payload undecodable"));
930                        continue;
931                    }
932                };
933                if &derived != artifact_id {
934                    violations.push(format!(
935                        "action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
936                    ));
937                    continue;
938                }
939                let action: ActionStatement = match env.unmarshal_statement() {
940                    Ok(a)  => a,
941                    Err(_) => {
942                        violations.push(format!("action {artifact_id} not an ActionStatement"));
943                        continue;
944                    }
945                };
946                let raw_nonce = match action.approval_nonce.as_deref() {
947                    Some(n) => n,
948                    None    => continue,
949                };
950                let claimed_use_id = action
951                    .meta
952                    .as_ref()
953                    .and_then(|m| m.get("approval_use_id"))
954                    .and_then(|v| v.as_str());
955                let Some(claimed_use_id) = claimed_use_id else {
956                    violations.push(format!(
957                        "action {artifact_id} consumed an approval but its meta has no approval_use_id"
958                    ));
959                    continue;
960                };
961                if !use_ids.contains(claimed_use_id) {
962                    violations.push(format!(
963                        "action {artifact_id} claims approval_use_id={} but no such use is embedded",
964                        claimed_use_id,
965                    ));
966                    continue;
967                }
968                let expected = nonce_digest(raw_nonce);
969                let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
970                if let Some(u) = matched_use {
971                    if u.nonce_digest != expected {
972                        violations.push(format!(
973                            "action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
974                            expected, claimed_use_id, u.nonce_digest,
975                        ));
976                        continue;
977                    }
978                }
979                bound_count += 1;
980            }
981            if violations.is_empty() {
982                checks.push(VerifyCheck::pass(
983                    "approval-use-action-binding",
984                    &format!(
985                        "{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
986                    ),
987                ));
988            } else {
989                checks.push(VerifyCheck::fail(
990                    "approval-use-action-binding",
991                    &violations.join("; "),
992                ));
993            }
994        }
995    }
996
997    // -- approval-use-chain-continuity --
998    // v0.9.9 verified each use's individual record_digest but never
999    // walked the `previous_record_digest` chain across the embedded
1000    // records. An attacker could rewrite an entire chain consistently
1001    // (recomputing each digest along the way) and the per-record
1002    // checks all passed.
1003    //
1004    // Algorithm (v0.9.10 PR A round 2): build a graph of embedded
1005    // records keyed by record_digest, then require the embedded
1006    // records to form a SINGLE linked list with exactly one genesis
1007    // (previous_record_digest == "") and no cycles, forks, or
1008    // disconnected subchains.
1009    //
1010    //   - Dangling prev pointer (not in `owned`) -> fail.
1011    //   - More than one record with prev == ""    -> fail (mid-chain
1012    //     genesis is a forgery primitive).
1013    //   - Two records sharing the same prev       -> fail (fork).
1014    //   - Cycle reached during the walk           -> fail.
1015    //   - Walk doesn't reach every record         -> fail (disconnected
1016    //     subchain).
1017    //
1018    // We can only check *internal* consistency offline -- the package
1019    // doesn't ship the workspace journal's full history, so the chain
1020    // we see may be a contiguous prefix or window. Anchoring against
1021    // a Hub-signed checkpoint is replay-hub-org's job; here we report
1022    // structural consistency only.
1023    if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
1024        use std::collections::{HashMap, HashSet};
1025        // Each record carries a label for diagnostics + its own
1026        // record_digest + previous_record_digest.
1027        struct Node<'a> { label: String, digest: &'a str, prev: &'a str }
1028        let mut nodes: Vec<Node> = Vec::new();
1029        for u in &bundle.uses {
1030            nodes.push(Node {
1031                label: format!("use {}", u.use_id),
1032                digest: u.record_digest.as_str(),
1033                prev: u.previous_record_digest.as_str(),
1034            });
1035        }
1036        for cp in &bundle.checkpoints {
1037            nodes.push(Node {
1038                label: format!("checkpoint {}", cp.checkpoint_id),
1039                digest: cp.record_digest.as_str(),
1040                prev: cp.previous_record_digest.as_str(),
1041            });
1042        }
1043
1044        let owned: HashSet<&str> = std::iter::once("")
1045            .chain(nodes.iter().map(|n| n.digest))
1046            .collect();
1047
1048        let mut violations: Vec<String> = Vec::new();
1049        // Dangling prev: pointer not in owned set.
1050        for n in &nodes {
1051            if !owned.contains(n.prev) {
1052                violations.push(format!(
1053                    "{} previous_record_digest {} not anchored in package",
1054                    n.label, n.prev,
1055                ));
1056            }
1057        }
1058        // Genesis count: only one record allowed to have prev == "".
1059        let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
1060        if genesis.len() > 1 {
1061            violations.push(format!(
1062                "{} records claim previous_record_digest='' (genesis): {}",
1063                genesis.len(),
1064                genesis.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join(", "),
1065            ));
1066        }
1067        // Forks: two records sharing the same non-empty prev.
1068        let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
1069        for n in &nodes {
1070            by_prev.entry(n.prev).or_default().push(n);
1071        }
1072        for (prev, group) in &by_prev {
1073            if group.len() > 1 && !prev.is_empty() {
1074                violations.push(format!(
1075                    "fork: {} records share previous_record_digest {}: {}",
1076                    group.len(),
1077                    prev,
1078                    group.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join(", "),
1079                ));
1080            }
1081        }
1082
1083        // Walk from genesis (if exactly one) following digest-as-prev
1084        // links. Detect cycles and unreachable records.
1085        if violations.is_empty() {
1086            let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
1087            let next_of: HashMap<&str, &Node> = nodes
1088                .iter()
1089                .filter(|n| !n.prev.is_empty())
1090                .map(|n| (n.prev, *(&n)))
1091                .collect();
1092            let start = match genesis.first() {
1093                Some(g) => Some(*g),
1094                None    => None,
1095            };
1096            let mut visited: HashSet<&str> = HashSet::new();
1097            let mut current = start;
1098            while let Some(node) = current {
1099                if !visited.insert(node.digest) {
1100                    violations.push(format!(
1101                        "cycle detected at {} (record_digest {})",
1102                        node.label, node.digest,
1103                    ));
1104                    break;
1105                }
1106                current = next_of.get(node.digest).copied();
1107            }
1108            // Disconnected: walk didn't include every node.
1109            if violations.is_empty() && visited.len() != nodes.len() {
1110                let unreached: Vec<String> = nodes.iter()
1111                    .filter(|n| !visited.contains(n.digest))
1112                    .map(|n| n.label.clone())
1113                    .collect();
1114                if !unreached.is_empty() {
1115                    violations.push(format!(
1116                        "disconnected subchain: {} record(s) not reachable from genesis: {}",
1117                        unreached.len(),
1118                        unreached.join(", "),
1119                    ));
1120                }
1121            }
1122            let _ = by_digest; // reserved for future cross-checks
1123        }
1124
1125        if violations.is_empty() {
1126            checks.push(VerifyCheck::pass(
1127                "approval-use-chain-continuity",
1128                &format!(
1129                    "{} record(s) form a single connected linked list from one genesis with no cycles or forks",
1130                    nodes.len(),
1131                ),
1132            ));
1133        } else {
1134            checks.push(VerifyCheck::fail(
1135                "approval-use-chain-continuity",
1136                &violations.join("; "),
1137            ));
1138        }
1139    }
1140
1141    // -- replay-hub-org -- v0.9.9 PR 6.
1142    // The strongest level Treeship can speak to today. The release
1143    // rule is non-negotiable: PASS only when (1) at least one embedded
1144    // checkpoint declares kind=HubOrg, (2) every required Hub field is
1145    // populated, (3) the signature verifies against the embedded
1146    // public key, AND (4) the checkpoint covers every embedded
1147    // ApprovalUse via covered_use_ids. Anything short of that means
1148    // "no row" or "fail" -- never silent pass.
1149    //
1150    // No row at all when the package has no Hub-kind checkpoint:
1151    // matches the v0.9.9 PR 4-5 behavior where the panel renders
1152    // "- hub-org   not checked (no Hub checkpoint in package)" so a
1153    // reader doesn't misread an absent row as a failure.
1154    let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
1155        .checkpoints
1156        .iter()
1157        .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
1158        .collect();
1159    if !hub_checkpoints.is_empty() {
1160        let mut all_ok = true;
1161        let mut details: Vec<String> = Vec::new();
1162        let mut have_valid_signature = false;
1163        // Security-critical failures (untrusted-issuer / tampered /
1164        // not-hub-kind) must FAIL unconditionally, not warn. Audit
1165        // lane J fix-up: previously these emitted WARN and the CLI
1166        // wrapper's --strict promoted to FAIL, which meant the
1167        // headline audit case (self-signed hub-org forgery) passed
1168        // green-but-yellow in default mode. The release rule is
1169        // "trust pinning is on by default"; expressed in this row
1170        // as "any signature/issuer failure is a hard fail."
1171        let mut security_fatal = false;
1172
1173        for cp in &hub_checkpoints {
1174            match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
1175                crate::statements::HubCheckpointVerification::Valid => {
1176                    have_valid_signature = true;
1177                    // Coverage: every embedded use_id MUST appear in
1178                    // this checkpoint's covered_use_ids. A checkpoint
1179                    // that doesn't cover the package's uses cannot
1180                    // promote replay-hub-org for those uses.
1181                    let covered: std::collections::HashSet<&String> =
1182                        cp.covered_use_ids.iter().collect();
1183                    let missing: Vec<String> = bundle
1184                        .uses
1185                        .iter()
1186                        .filter(|u| !covered.contains(&u.use_id))
1187                        .map(|u| u.use_id.clone())
1188                        .collect();
1189                    if missing.is_empty() {
1190                        details.push(format!(
1191                            "{} signed by {} verifies; covers {} use(s)",
1192                            cp.checkpoint_id,
1193                            cp.hub_id,
1194                            cp.covered_use_ids.len(),
1195                        ));
1196                    } else {
1197                        all_ok = false;
1198                        details.push(format!(
1199                            "{} verifies but does not cover {} use(s): {}",
1200                            cp.checkpoint_id,
1201                            missing.len(),
1202                            missing.join(", "),
1203                        ));
1204                    }
1205                }
1206                crate::statements::HubCheckpointVerification::MissingFields(field) => {
1207                    all_ok = false;
1208                    details.push(format!(
1209                        "{} declares kind=hub-org but field `{}` is missing",
1210                        cp.checkpoint_id, field,
1211                    ));
1212                }
1213                crate::statements::HubCheckpointVerification::Tampered => {
1214                    all_ok = false;
1215                    security_fatal = true;
1216                    details.push(format!(
1217                        "{} hub signature failed verification (tampered or wrong key)",
1218                        cp.checkpoint_id,
1219                    ));
1220                }
1221                crate::statements::HubCheckpointVerification::NotHubKind => {
1222                    // Filter ensures this is unreachable; keep the
1223                    // arm so a future filter relaxation doesn't go
1224                    // silent.
1225                    all_ok = false;
1226                    security_fatal = true;
1227                    details.push(format!(
1228                        "{} kind toggled out of hub-org during verify",
1229                        cp.checkpoint_id,
1230                    ));
1231                }
1232                crate::statements::HubCheckpointVerification::UntrustedIssuer => {
1233                    all_ok = false;
1234                    security_fatal = true;
1235                    details.push(format!(
1236                        "{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
1237                        cp.checkpoint_id,
1238                    ));
1239                }
1240            }
1241        }
1242        if all_ok && have_valid_signature {
1243            checks.push(VerifyCheck::pass(
1244                "replay-hub-org",
1245                &details.join("; "),
1246            ));
1247        } else if security_fatal {
1248            // Untrusted issuer or tampered signature: fail-by-default
1249            // regardless of --strict. Self-signed forgeries must not
1250            // pass yellow.
1251            checks.push(VerifyCheck::fail(
1252                "replay-hub-org",
1253                &details.join("; "),
1254            ));
1255        } else {
1256            // Hub checkpoint is present but does not satisfy every
1257            // non-security gate (missing-field, coverage gap).
1258            // Default mode warns; the CLI verify wrapper's --strict
1259            // promotes to fail.
1260            checks.push(VerifyCheck::warn(
1261                "replay-hub-org",
1262                &details.join("; "),
1263            ));
1264        }
1265    }
1266    // No hub-org checkpoints embedded -> no row. The Approval
1267    // Authority panel still renders "- hub-org   not checked".
1268
1269    let _ = ReplayCheckLevel::HubOrg;
1270    let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
1271    let _ = ReplayCheck::not_performed;
1272}
1273
1274/// A single verification check result.
1275#[derive(Debug, Clone)]
1276pub struct VerifyCheck {
1277    pub name: String,
1278    pub status: VerifyStatus,
1279    pub detail: String,
1280}
1281
1282/// Status of a verification check.
1283#[derive(Debug, Clone, PartialEq, Eq)]
1284pub enum VerifyStatus {
1285    Pass,
1286    Fail,
1287    Warn,
1288}
1289
1290impl VerifyCheck {
1291    pub fn pass(name: &str, detail: &str) -> Self {
1292        Self { name: name.into(), status: VerifyStatus::Pass, detail: detail.into() }
1293    }
1294    pub fn fail(name: &str, detail: &str) -> Self {
1295        Self { name: name.into(), status: VerifyStatus::Fail, detail: detail.into() }
1296    }
1297    pub fn warn(name: &str, detail: &str) -> Self {
1298        Self { name: name.into(), status: VerifyStatus::Warn, detail: detail.into() }
1299    }
1300}
1301
1302impl VerifyCheck {
1303    pub fn passed(&self) -> bool {
1304        self.status == VerifyStatus::Pass
1305    }
1306}
1307
1308/// HTML template for the self-contained verifier preview.
1309/// Loaded at compile time so the binary carries no runtime file dependencies.
1310const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
1311
1312/// Generate a self-contained preview.html that embeds the receipt JSON
1313/// and runs Merkle verification client-side using Web Crypto API.
1314///
1315/// The HTML works fully air-gapped: no network calls, no CDN, no server.
1316/// Open it in any modern browser and it automatically verifies the receipt
1317/// and shows pass/fail for each check.
1318fn generate_preview_html(receipt: &SessionReceipt) -> String {
1319    let receipt_json = serde_json::to_string_pretty(receipt)
1320        .unwrap_or_else(|_| "{}".to_string());
1321    // Defense-in-depth: escape </script sequences so a malicious receipt
1322    // field cannot break out of the JSON data block. The primary defense
1323    // is type="application/json" which the HTML parser does not execute,
1324    // but this escaping adds a second layer.
1325    // Escape ALL '<' as '\u003c' in the JSON string to prevent any
1326    // case-variant of </script> from breaking out of the data block.
1327    // This is bulletproof: no HTML parser can see a tag open inside the JSON.
1328    let safe_json = receipt_json.replace('<', r"\u003c");
1329
1330    // Only one placeholder: __RECEIPT_JSON__ inside the data block.
1331    // The page title is set at runtime from the parsed JSON to avoid
1332    // a second replacement pass that could re-inject content.
1333    PREVIEW_TEMPLATE
1334        .replace("__RECEIPT_JSON__", &safe_json)
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::*;
1340    use crate::session::event::*;
1341    use crate::session::manifest::SessionManifest;
1342    use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
1343
1344    fn make_receipt() -> SessionReceipt {
1345        let manifest = SessionManifest::new(
1346            "ssn_pkg_test".into(),
1347            "agent://test".into(),
1348            "2026-04-05T08:00:00Z".into(),
1349            1743843600000,
1350        );
1351
1352        let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
1353            SessionEvent {
1354                session_id: "ssn_pkg_test".into(),
1355                event_id: format!("evt_{:016x}", seq),
1356                timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1357                sequence_no: seq,
1358                trace_id: "trace_1".into(),
1359                span_id: format!("span_{seq}"),
1360                parent_span_id: None,
1361                agent_id: format!("agent://{inst}"),
1362                agent_instance_id: inst.into(),
1363                agent_name: inst.into(),
1364                agent_role: None,
1365                host_id: "host_1".into(),
1366                tool_runtime_id: None,
1367                event_type: et,
1368                artifact_ref: None,
1369                meta: None,
1370            }
1371        };
1372
1373        let events = vec![
1374            mk(0, "root", EventType::SessionStarted),
1375            mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
1376            mk(2, "root", EventType::AgentCalledTool {
1377                tool_name: "read_file".into(),
1378                tool_input_digest: None,
1379                tool_output_digest: None,
1380                duration_ms: Some(10),
1381            }),
1382            mk(3, "root", EventType::AgentCompleted { termination_reason: None }),
1383            mk(4, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(60000) }),
1384        ];
1385
1386        let artifacts = vec![
1387            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
1388        ];
1389
1390        ReceiptComposer::compose(&manifest, &events, artifacts)
1391    }
1392
1393    #[test]
1394    fn build_and_read_package() {
1395        let receipt = make_receipt();
1396        let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
1397
1398        let output = build_package(&receipt, &tmp).unwrap();
1399        assert!(output.path.exists());
1400        assert!(output.path.join("receipt.json").exists());
1401        assert!(output.path.join("merkle.json").exists());
1402        assert!(output.path.join("render.json").exists());
1403        assert!(output.path.join("preview.html").exists());
1404        assert!(output.receipt_digest.starts_with("sha256:"));
1405        assert!(output.file_count >= 4);
1406
1407        // Read back
1408        let read_back = read_package(&output.path).unwrap();
1409        assert_eq!(read_back.session.id, "ssn_pkg_test");
1410        assert_eq!(read_back.type_, RECEIPT_TYPE);
1411
1412        let _ = std::fs::remove_dir_all(&tmp);
1413    }
1414
1415    #[test]
1416    fn verify_valid_package() {
1417        let receipt = make_receipt();
1418        let tmp = std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
1419
1420        let output = build_package(&receipt, &tmp).unwrap();
1421        let checks = verify_package(&output.path).unwrap();
1422
1423        let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
1424        assert!(fails.is_empty(), "unexpected failures: {fails:?}");
1425
1426        let passes: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Pass).collect();
1427        assert!(passes.len() >= 5, "expected at least 5 pass checks, got {}", passes.len());
1428
1429        let _ = std::fs::remove_dir_all(&tmp);
1430    }
1431
1432    #[test]
1433    fn verify_detects_missing_receipt() {
1434        let tmp = std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
1435        std::fs::create_dir_all(&tmp).unwrap();
1436
1437        let err = read_package(&tmp);
1438        assert!(err.is_err());
1439
1440        let _ = std::fs::remove_dir_all(&tmp);
1441    }
1442
1443    #[test]
1444    fn preview_html_contains_session_info() {
1445        let receipt = make_receipt();
1446        let html = generate_preview_html(&receipt);
1447        assert!(html.contains("ssn_pkg_test"));
1448        assert!(html.contains("treeship.dev"));
1449        assert!(html.contains("Timeline"));
1450    }
1451}