Skip to main content

hh_core/
bundle.rs

1//! Portable session bundle format (`hh export --bundle` / `hh import`).
2//!
3//! A bundle is a single zstd-compressed tar containing:
4//! - `manifest.json` — `format_version`, the `hh` version that wrote it, a
5//!   redacted session block, and integrity hashes.
6//! - `events.ndjson` — one compact JSON object per event, in original
7//!   `(ts_ms, id)` order, position-indexed by `seq` (DB row ids do not
8//!   survive import, so `correlates` is expressed as `correlates_seq`
9//!   instead).
10//! - `blobs/<hash[0..2]>/<hash>` — raw content for every blob any event or
11//!   file_change references. Only the outer tar is zstd-compressed; blobs are
12//!   stored raw inside it (no double compression).
13//!
14//! Tar entries are added in a fixed order (manifest, events, then blobs
15//! sorted ascending by hash) with fixed mtime/uid/gid/mode, so two exports of
16//! an unchanged session produce byte-identical bundles.
17//!
18//! [`export`] is the trusted-data path (built from a live [`crate::store::Store`]).
19//! [`parse`] is the untrusted-input path (`hh import file.hh`): it must never
20//! panic on malformed input, and every failure mode reports precisely what
21//! is wrong (v1.0.0 addendum).
22
23use crate::error::{BundleError, Result};
24use crate::event::{FileChange, RawEventRow, SessionRow};
25use crate::redact::Detectors;
26use crate::store::Store;
27use std::collections::{BTreeMap, BTreeSet, HashMap};
28use std::io::Read;
29
30/// The bundle format version this build of `hh` writes, and the highest
31/// version it will read. Bumped only for a breaking change to the manifest/
32/// events/blob layout; new *optional* fields do not require a bump.
33pub const FORMAT_VERSION: u32 = 1;
34
35/// zstd compression level for the outer bundle stream. 3 matches the level
36/// [`crate::blob::BlobStore`] already uses — fast and compact for the mixed
37/// text/binary content a session bundle carries.
38const ZSTD_LEVEL: i32 = 3;
39
40/// Safety cap on decompressed bundle size (defense in depth against a
41/// hostile/corrupt zstd frame claiming an enormous content size). 2 GiB is
42/// far above any real session; `hh import` on a legitimate bundle never
43/// approaches it.
44const MAX_BUNDLE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
45
46/// A parsed, validated bundle — the output of [`parse`] and the input to
47/// [`crate::store::Store::import`].
48#[derive(Debug, Clone)]
49pub struct Bundle {
50    /// The `hh` version string that produced this bundle.
51    pub hh_version: String,
52    /// The session block from `manifest.json` (already redacted at export
53    /// time, if redaction was enabled).
54    pub session: serde_json::Value,
55    /// Events in original `(ts_ms, id)` order, `seq`-indexed (see module docs).
56    pub events: Vec<serde_json::Value>,
57    /// Every blob referenced by `events`, keyed by its (possibly
58    /// redaction-remapped) BLAKE3 hash.
59    pub blobs: BTreeMap<String, Vec<u8>>,
60}
61
62/// Build a portable bundle for `session_id` (the trusted-data path).
63///
64/// `hh_version` is the caller's version string (`hh-core` has no build-time
65/// access to the binary's version). `detectors` mirrors the existing
66/// `hh export`/`hh export --html` redaction chokepoint: `Some` redacts the
67/// session block, every event, and every referenced blob's content before
68/// anything is packed; `None` (only reachable via the interactive
69/// `--no-redact` confirmation) packs the session as-is.
70///
71/// # Errors
72///
73/// Propagates any storage/blob-store failure reading the session.
74pub fn export(
75    store: &Store,
76    session_id: &str,
77    hh_version: &str,
78    detectors: Option<&Detectors>,
79) -> Result<Vec<u8>> {
80    let session_row = store.get_session(session_id)?;
81
82    let mut raw_events: Vec<RawEventRow> = Vec::new();
83    store.for_each_event_raw(session_id, |row| {
84        raw_events.push(row);
85        Ok(())
86    })?;
87    let id_to_seq: HashMap<i64, u64> = raw_events
88        .iter()
89        .enumerate()
90        .map(|(i, r)| (r.id, u64::try_from(i).unwrap_or(u64::MAX)))
91        .collect();
92
93    // Every blob any event or file_change references — a portable bundle
94    // must carry these byte-for-byte (see `Store::for_each_event_raw`'s docs
95    // for why `for_each_event_detail`'s resolved body is not enough).
96    let mut referenced: BTreeSet<String> = BTreeSet::new();
97    for r in &raw_events {
98        if let Some(h) = &r.blob_hash {
99            referenced.insert(h.clone());
100        }
101        if let Some(fc) = &r.file_change {
102            if let Some(h) = &fc.before_hash {
103                referenced.insert(h.clone());
104            }
105            if let Some(h) = &fc.after_hash {
106                referenced.insert(h.clone());
107            }
108        }
109    }
110
111    // Fetch (+ redact) every referenced blob, tracking hashes that changed
112    // under redaction so event/file_change references can be remapped below.
113    let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();
114    let mut remap: HashMap<String, String> = HashMap::new();
115    for hash in &referenced {
116        let content = store.blobs().get(hash)?;
117        match detectors.and_then(|d| d.redact_bytes(&content)) {
118            Some(redacted) => {
119                let new_hash = crate::blob::BlobStore::hash(&redacted.bytes);
120                if &new_hash != hash {
121                    remap.insert(hash.clone(), new_hash.clone());
122                }
123                blobs.insert(new_hash, redacted.bytes);
124            }
125            None => {
126                blobs.insert(hash.clone(), content);
127            }
128        }
129    }
130
131    let mut events_json: Vec<serde_json::Value> = Vec::with_capacity(raw_events.len());
132    for (seq, r) in raw_events.iter().enumerate() {
133        let correlates_seq = r.correlates.and_then(|cid| id_to_seq.get(&cid).copied());
134        let file_change_json = r.file_change.as_ref().map(file_change_to_json);
135        let mut ev = serde_json::json!({
136            "seq": u64::try_from(seq).unwrap_or(u64::MAX),
137            "ts_ms": r.ts_ms,
138            "kind": r.kind.to_string(),
139            "step": r.step,
140            "correlates_seq": correlates_seq,
141            "summary": r.summary,
142            "body": r.body_json,
143            "blob_hash": r.blob_hash,
144            "blob_size": r.blob_size,
145            "file_change": file_change_json,
146        });
147        if let Some(d) = detectors {
148            let _ = d.redact_json(&mut ev);
149        }
150        if !remap.is_empty() {
151            remap_hashes(&mut ev, &remap);
152        }
153        events_json.push(ev);
154    }
155
156    let mut session_json = session_to_bundle_json(&session_row);
157    if let Some(d) = detectors {
158        let _ = d.redact_json(&mut session_json);
159    }
160
161    let mut ndjson = String::new();
162    for ev in &events_json {
163        let line = serde_json::to_string(ev).map_err(|e| BundleError::Events(e.to_string()))?;
164        ndjson.push_str(&line);
165        ndjson.push('\n');
166    }
167    let events_bytes = ndjson.into_bytes();
168    let events_blake3 = blake3::hash(&events_bytes).to_hex().to_string();
169
170    let blob_list: Vec<serde_json::Value> = blobs
171        .iter()
172        .map(|(hash, content)| serde_json::json!({ "hash": hash, "size": content.len() }))
173        .collect();
174    let manifest = serde_json::json!({
175        "format_version": FORMAT_VERSION,
176        "hh_version": hh_version,
177        "session": session_json,
178        "integrity": {
179            "events_blake3": events_blake3,
180            "event_count": events_json.len(),
181            "blobs": blob_list,
182        },
183    });
184    let manifest_bytes =
185        serde_json::to_vec(&manifest).map_err(|e| BundleError::Manifest(e.to_string()))?;
186
187    let tar_bytes = build_tar(&manifest_bytes, &events_bytes, &blobs)?;
188    let compressed = zstd::stream::encode_all(&tar_bytes[..], ZSTD_LEVEL)
189        .map_err(|e| BundleError::Zstd(e.to_string()))?;
190    Ok(compressed)
191}
192
193/// Parse and validate an untrusted bundle (`hh import file.hh`). Never
194/// panics: every malformed-input path returns a precise [`BundleError`].
195///
196/// # Errors
197///
198/// See [`BundleError`]'s variants for every rejection reason (bad zstd, bad
199/// tar entry, missing/malformed manifest, unsupported `format_version`,
200/// blob hash mismatch, missing referenced blob, events digest mismatch).
201pub fn parse(bytes: &[u8]) -> Result<Bundle> {
202    let tar_bytes = bounded_decompress(bytes)?;
203    let (manifest_bytes, events_bytes, blobs) = extract_tar_entries(&tar_bytes)?;
204
205    let manifest_bytes =
206        manifest_bytes.ok_or_else(|| BundleError::Manifest("missing manifest.json".into()))?;
207    let manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes)
208        .map_err(|e| BundleError::Manifest(e.to_string()))?;
209    let (hh_version, session, expected_digest) = parse_manifest(&manifest)?;
210
211    let events_bytes =
212        events_bytes.ok_or_else(|| BundleError::Events("missing events.ndjson".into()))?;
213    let actual_digest = blake3::hash(&events_bytes).to_hex().to_string();
214    if expected_digest != actual_digest {
215        return Err(BundleError::IntegrityMismatch.into());
216    }
217    let events = parse_events(&events_bytes, &blobs)?;
218
219    Ok(Bundle {
220        hh_version,
221        session,
222        events,
223        blobs,
224    })
225}
226
227/// Walk every tar entry, verify each blob's content against its filename
228/// hash, and sort them into `(manifest.json, events.ndjson, blobs)`. Rejects
229/// any entry outside the allow-list (see [`classify_entry`]) and any
230/// non-regular-file entry (symlink/hardlink/directory).
231#[allow(clippy::type_complexity)] // the three extracted pieces have no natural shared struct
232fn extract_tar_entries(
233    tar_bytes: &[u8],
234) -> Result<(Option<Vec<u8>>, Option<Vec<u8>>, BTreeMap<String, Vec<u8>>)> {
235    let mut archive = tar::Archive::new(tar_bytes);
236    let mut manifest_bytes: Option<Vec<u8>> = None;
237    let mut events_bytes: Option<Vec<u8>> = None;
238    let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();
239
240    let entries = archive
241        .entries()
242        .map_err(|e| BundleError::Tar(e.to_string()))?;
243    for entry in entries {
244        let mut entry = entry.map_err(|e| BundleError::Tar(e.to_string()))?;
245        if entry.header().entry_type().ne(&tar::EntryType::Regular) {
246            return Err(BundleError::Tar(
247                "bundle contains a non-regular-file entry (symlink/hardlink/directory) — refusing"
248                    .into(),
249            )
250            .into());
251        }
252        let path = entry.path().map_err(|e| BundleError::Tar(e.to_string()))?;
253        let path_str = path.to_string_lossy().into_owned();
254        let mut data = Vec::new();
255        entry
256            .read_to_end(&mut data)
257            .map_err(|e| BundleError::Tar(e.to_string()))?;
258        match classify_entry(&path_str) {
259            EntryKind::Manifest => manifest_bytes = Some(data),
260            EntryKind::Events => events_bytes = Some(data),
261            EntryKind::Blob(hash) => {
262                let actual = blake3::hash(&data).to_hex().to_string();
263                if actual != hash {
264                    return Err(BundleError::HashMismatch {
265                        expected: hash,
266                        actual,
267                    }
268                    .into());
269                }
270                blobs.insert(hash, data);
271            }
272            EntryKind::Unknown => {
273                return Err(
274                    BundleError::Tar(format!("unexpected entry in bundle: {path_str}")).into(),
275                );
276            }
277        }
278    }
279    Ok((manifest_bytes, events_bytes, blobs))
280}
281
282/// Validate `manifest`'s `format_version` and pull out `(hh_version, session,
283/// expected events.ndjson digest)`.
284fn parse_manifest(manifest: &serde_json::Value) -> Result<(String, serde_json::Value, String)> {
285    let format_version = manifest
286        .get("format_version")
287        .and_then(serde_json::Value::as_u64)
288        .ok_or_else(|| BundleError::Manifest("missing or non-numeric format_version".into()))?;
289    let format_version = u32::try_from(format_version)
290        .map_err(|_| BundleError::Manifest("format_version out of range".into()))?;
291    if format_version > FORMAT_VERSION {
292        return Err(BundleError::UnsupportedVersion {
293            found: format_version,
294            max: FORMAT_VERSION,
295        }
296        .into());
297    }
298
299    let hh_version = manifest
300        .get("hh_version")
301        .and_then(serde_json::Value::as_str)
302        .unwrap_or("unknown")
303        .to_string();
304    let session = manifest
305        .get("session")
306        .cloned()
307        .ok_or_else(|| BundleError::Manifest("missing session block".into()))?;
308    let expected_digest = manifest
309        .get("integrity")
310        .and_then(|i| i.get("events_blake3"))
311        .and_then(serde_json::Value::as_str)
312        .unwrap_or_default()
313        .to_string();
314    Ok((hh_version, session, expected_digest))
315}
316
317/// Parse `events.ndjson` line by line and confirm every blob hash any event
318/// or `file_change` references is among `blobs`.
319fn parse_events(
320    events_bytes: &[u8],
321    blobs: &BTreeMap<String, Vec<u8>>,
322) -> Result<Vec<serde_json::Value>> {
323    let mut events = Vec::new();
324    for line in events_bytes.split(|&b| b == b'\n') {
325        if line.is_empty() {
326            continue;
327        }
328        let v: serde_json::Value =
329            serde_json::from_slice(line).map_err(|e| BundleError::Events(e.to_string()))?;
330        events.push(v);
331    }
332
333    for ev in &events {
334        if let Some(h) = ev.get("blob_hash").and_then(serde_json::Value::as_str) {
335            if !blobs.contains_key(h) {
336                return Err(BundleError::MissingBlob(h.to_string()).into());
337            }
338        }
339        if let Some(fc) = ev.get("file_change").filter(|v| !v.is_null()) {
340            for key in ["before_hash", "after_hash"] {
341                if let Some(h) = fc.get(key).and_then(serde_json::Value::as_str) {
342                    if !blobs.contains_key(h) {
343                        return Err(BundleError::MissingBlob(h.to_string()).into());
344                    }
345                }
346            }
347        }
348    }
349    Ok(events)
350}
351
352/// Decompress `bytes` as zstd, bounded to [`MAX_BUNDLE_BYTES`] — defense in
353/// depth against a hostile/corrupt frame that claims an enormous content
354/// size (`Read::take` stops pulling bytes from the decoder once the cap is
355/// hit, so this never allocates unbounded memory).
356fn bounded_decompress(bytes: &[u8]) -> Result<Vec<u8>> {
357    let decoder =
358        zstd::stream::Decoder::new(bytes).map_err(|e| BundleError::Zstd(e.to_string()))?;
359    let mut limited = decoder.take(MAX_BUNDLE_BYTES + 1);
360    let mut out = Vec::new();
361    limited
362        .read_to_end(&mut out)
363        .map_err(|e| BundleError::Zstd(e.to_string()))?;
364    if out.len() as u64 > MAX_BUNDLE_BYTES {
365        return Err(BundleError::Tar(format!(
366            "decompressed bundle exceeds the {MAX_BUNDLE_BYTES}-byte safety limit"
367        ))
368        .into());
369    }
370    Ok(out)
371}
372
373/// What a tar entry's path means, or [`EntryKind::Unknown`] if it matches
374/// nothing on the allow-list (`manifest.json` / `events.ndjson` /
375/// `blobs/<2hex>/<64hex>`). This allow-list is also the path-traversal
376/// defense: parsing never writes to the filesystem (entries are read
377/// straight into memory), so an unexpected path cannot escape anywhere — it
378/// is simply rejected as an unrecognized entry.
379enum EntryKind {
380    Manifest,
381    Events,
382    Blob(String),
383    Unknown,
384}
385
386fn classify_entry(path: &str) -> EntryKind {
387    if path == "manifest.json" {
388        return EntryKind::Manifest;
389    }
390    if path == "events.ndjson" {
391        return EntryKind::Events;
392    }
393    if let Some(rest) = path.strip_prefix("blobs/") {
394        if let Some((prefix, hash)) = rest.split_once('/') {
395            if prefix.len() == 2
396                && hash.len() == 64
397                && hash.starts_with(prefix)
398                && hash.bytes().all(|b| b.is_ascii_hexdigit())
399            {
400                return EntryKind::Blob(hash.to_string());
401            }
402        }
403    }
404    EntryKind::Unknown
405}
406
407/// Append one entry to `builder` with fixed mtime/uid/gid/mode (determinism:
408/// identical content at the same path always produces the same header).
409fn append_entry(builder: &mut tar::Builder<Vec<u8>>, path: &str, data: &[u8]) -> Result<()> {
410    let mut header = tar::Header::new_gnu();
411    header
412        .set_path(path)
413        .map_err(|e| BundleError::Tar(e.to_string()))?;
414    header.set_size(data.len() as u64);
415    header.set_mtime(0);
416    header.set_uid(0);
417    header.set_gid(0);
418    header.set_mode(0o644);
419    header.set_cksum();
420    builder
421        .append_data(&mut header, path, data)
422        .map_err(|e| BundleError::Tar(e.to_string()))?;
423    Ok(())
424}
425
426/// Build the deterministic tar: manifest, then events, then blobs sorted
427/// ascending by hash (`blobs` is a `BTreeMap`, so iteration is already
428/// sorted).
429fn build_tar(manifest: &[u8], events: &[u8], blobs: &BTreeMap<String, Vec<u8>>) -> Result<Vec<u8>> {
430    let mut builder = tar::Builder::new(Vec::new());
431    append_entry(&mut builder, "manifest.json", manifest)?;
432    append_entry(&mut builder, "events.ndjson", events)?;
433    for (hash, content) in blobs {
434        let path = format!("blobs/{}/{}", &hash[..2], hash);
435        append_entry(&mut builder, &path, content)?;
436    }
437    builder
438        .into_inner()
439        .map_err(|e| BundleError::Tar(e.to_string()).into())
440}
441
442/// The `session` block for `manifest.json`. Mirrors the shape of `hh list
443/// --json`'s per-session object (`session_to_json` in the `hh` binary) so
444/// the two stay recognizable as the same "session" concept, without hh-core
445/// depending on the binary crate.
446fn session_to_bundle_json(row: &SessionRow) -> serde_json::Value {
447    let duration_ms = row.ended_at.map(|end| (end - row.started_at).max(0));
448    serde_json::json!({
449        "schema": crate::JSON_SCHEMA_VERSION,
450        "id": row.id,
451        "short_id": row.short_id,
452        "status": row.status.to_string(),
453        "agent_kind": row.agent_kind.to_string(),
454        "adapter_status": row.adapter_status.to_string(),
455        "started_at": row.started_at,
456        "ended_at": row.ended_at,
457        "exit_code": row.exit_code,
458        "duration_ms": duration_ms,
459        "steps": row.step_count,
460        "files_changed": row.files_changed,
461        "command": row.command,
462        "cwd": row.cwd.to_string_lossy(),
463        "imported_from": row.imported_from,
464    })
465}
466
467/// The JSON object for a `file_changes` row, as carried in a bundle event.
468fn file_change_to_json(fc: &FileChange) -> serde_json::Value {
469    serde_json::json!({
470        "path": fc.path,
471        "change_kind": fc.change_kind.to_string(),
472        "before_hash": fc.before_hash,
473        "after_hash": fc.after_hash,
474        "is_binary": fc.is_binary,
475    })
476}
477
478/// Replace every string leaf in `value` that exactly matches a key in
479/// `remap` with its mapped value. Used after blob redaction changes a blob's
480/// hash: rewrites every reference to the old hash (the event's top-level
481/// `blob_hash`, a `file_change`'s `before_hash`/`after_hash`, and any
482/// embedded `blob_hash` inside an unresolved overflow envelope) to the new
483/// one, wherever it appears in the event's JSON tree — one generic walk
484/// instead of special-casing each field.
485fn remap_hashes(value: &mut serde_json::Value, remap: &HashMap<String, String>) {
486    match value {
487        serde_json::Value::String(s) => {
488            if let Some(new_hash) = remap.get(s.as_str()) {
489                *s = new_hash.clone();
490            }
491        }
492        serde_json::Value::Array(items) => {
493            for v in items {
494                remap_hashes(v, remap);
495            }
496        }
497        serde_json::Value::Object(map) => {
498            for v in map.values_mut() {
499                remap_hashes(v, remap);
500            }
501        }
502        _ => {}
503    }
504}
505
506/// Fuzz-only entry point (`cargo fuzz` target `import_bundle`). Gated behind
507/// the `fuzzing` feature so it never widens the crate's normal public API.
508#[cfg(feature = "fuzzing")]
509pub mod fuzzing {
510    /// Fuzz [`super::parse`] on arbitrary bytes — must never panic regardless
511    /// of malformed zstd/tar/JSON/hash content.
512    pub fn fuzz_parse(bytes: &[u8]) {
513        let _ = super::parse(bytes);
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::config::RedactionConfig;
521    use crate::event::{
522        AdapterStatus, AgentKind, ChangeKind, Event, EventKind, NewSession, SessionStatus,
523    };
524    use std::path::PathBuf;
525    use tempfile::TempDir;
526
527    fn new_session() -> NewSession {
528        NewSession {
529            id: crate::event::now_v7(),
530            started_at: 1_700_000_000_000,
531            agent_kind: AgentKind::Generic,
532            adapter_status: AdapterStatus::None,
533            command: vec!["agent".into()],
534            cwd: PathBuf::from("/tmp/proj"),
535            hostname: Some("devbox".into()),
536            hh_version: "test".into(),
537            model: None,
538            git_branch: None,
539            git_sha: None,
540            git_dirty: None,
541        }
542    }
543
544    /// A store with one session: a plain user_message step, a correlated
545    /// tool_call/tool_result pair, and a file change with real blob content —
546    /// enough surface to exercise seq/correlates_seq and blob carrying.
547    fn fixture() -> (TempDir, Store, String) {
548        let tmp = TempDir::new().unwrap();
549        let store = Store::open(&tmp.path().join("hh.db"), &tmp.path().join("blobs")).unwrap();
550        let created = store.create_session(&new_session()).unwrap();
551        let sid = created.id.clone();
552        let writer = store.event_writer().unwrap();
553        writer
554            .append_event(Event {
555                session_id: sid.clone(),
556                ts_ms: 0,
557                kind: EventKind::UserMessage,
558                step: None,
559                summary: "hello".into(),
560                body_json: Some(serde_json::json!({"text": "hello there"})),
561                blob_hash: None,
562                blob_size: None,
563                correlates: None,
564            })
565            .unwrap();
566        let call = writer
567            .append_event(Event {
568                session_id: sid.clone(),
569                ts_ms: 1_000,
570                kind: EventKind::ToolCall,
571                step: None,
572                summary: "tool_call: Bash".into(),
573                body_json: Some(serde_json::json!({"name": "Bash", "input": {"command": "ls"}})),
574                blob_hash: None,
575                blob_size: None,
576                correlates: None,
577            })
578            .unwrap();
579        writer
580            .append_event(Event {
581                session_id: sid.clone(),
582                ts_ms: 1_500,
583                kind: EventKind::ToolResult,
584                step: None,
585                summary: "tool_result: ok".into(),
586                body_json: Some(serde_json::json!({"content": "file.txt", "is_error": false})),
587                blob_hash: None,
588                blob_size: None,
589                correlates: Some(call),
590            })
591            .unwrap();
592        let after = store.blobs().put(b"created content\n").unwrap();
593        writer
594            .append_file_change(
595                Event {
596                    session_id: sid.clone(),
597                    ts_ms: 2_000,
598                    kind: EventKind::FileChange,
599                    step: None,
600                    summary: "created.txt created".into(),
601                    body_json: Some(serde_json::json!({"path": "created.txt"})),
602                    blob_hash: Some(after.hash.clone()),
603                    blob_size: Some(after.size),
604                    correlates: None,
605                },
606                FileChange {
607                    event_id: 0,
608                    path: "created.txt".into(),
609                    change_kind: ChangeKind::Created,
610                    before_hash: None,
611                    after_hash: Some(after.hash.clone()),
612                    is_binary: false,
613                },
614            )
615            .unwrap();
616        writer.finish().unwrap();
617        store.assign_steps(&sid).unwrap();
618        store
619            .finalize_session(&sid, 1_700_000_003_000, Some(0), SessionStatus::Ok)
620            .unwrap();
621        (tmp, store, sid)
622    }
623
624    #[test]
625    fn export_then_parse_round_trips_the_shape() {
626        let (_tmp, store, sid) = fixture();
627        let bytes = export(&store, &sid, "test-version", None).unwrap();
628        let bundle = parse(&bytes).unwrap();
629        assert_eq!(bundle.hh_version, "test-version");
630        assert_eq!(
631            bundle.session["short_id"],
632            store.get_session(&sid).unwrap().short_id
633        );
634        assert_eq!(bundle.events.len(), 4);
635        // The tool_result (seq 2) correlates back to the tool_call (seq 1).
636        assert_eq!(bundle.events[2]["kind"], "tool_result");
637        assert_eq!(bundle.events[2]["correlates_seq"], 1);
638        assert_eq!(bundle.events[1]["correlates_seq"], serde_json::Value::Null);
639        // The file_change's blob content is carried.
640        let after_hash = bundle.events[3]["file_change"]["after_hash"]
641            .as_str()
642            .unwrap();
643        assert_eq!(bundle.blobs[after_hash], b"created content\n");
644    }
645
646    #[test]
647    fn export_is_deterministic() {
648        let (_tmp, store, sid) = fixture();
649        let a = export(&store, &sid, "v", None).unwrap();
650        let b = export(&store, &sid, "v", None).unwrap();
651        assert_eq!(
652            a, b,
653            "identical session must produce byte-identical bundles"
654        );
655    }
656
657    #[test]
658    fn redaction_scrubs_session_events_and_blob_and_remaps_the_hash() {
659        let (_tmp, store, sid) = fixture();
660        // Seed a secret into an event body and into the file-change blob.
661        let writer = store.event_writer().unwrap();
662        writer
663            .append_event(Event {
664                session_id: sid.clone(),
665                ts_ms: 3_000,
666                kind: EventKind::AgentMessage,
667                step: None,
668                summary: "leaking a key".into(),
669                body_json: Some(serde_json::json!({"text": "key AKIAIOSFODNN7EXAMPLE end"})),
670                blob_hash: None,
671                blob_size: None,
672                correlates: None,
673            })
674            .unwrap();
675        writer.finish().unwrap();
676        store.assign_steps(&sid).unwrap();
677
678        let secret_blob = store.blobs().put(b"secret AKIAIOSFODNN7EXAMPLE\n").unwrap();
679        writer_append_file_change_with_secret(&store, &sid, &secret_blob.hash);
680
681        let detectors = Detectors::new(&RedactionConfig::default()).unwrap();
682        let bytes = export(&store, &sid, "v", Some(&detectors)).unwrap();
683        let bundle = parse(&bytes).unwrap();
684
685        let joined = serde_json::to_string(&bundle.events).unwrap();
686        assert!(
687            !joined.contains("AKIAIOSFODNN7EXAMPLE"),
688            "secret must not appear in events: {joined}"
689        );
690        for content in bundle.blobs.values() {
691            let text = String::from_utf8_lossy(content);
692            assert!(
693                !text.contains("AKIAIOSFODNN7EXAMPLE"),
694                "secret must not appear in any blob: {text}"
695            );
696        }
697        // The redacted blob is stored under its new (redacted-content) hash,
698        // and every reference to it in events.ndjson was remapped to match —
699        // no event/file_change points at the pre-redaction hash.
700        assert!(
701            !bundle.blobs.contains_key(&secret_blob.hash),
702            "the original (unredacted) blob hash must not survive export"
703        );
704    }
705
706    fn writer_append_file_change_with_secret(store: &Store, sid: &str, hash: &str) {
707        let writer = store.event_writer().unwrap();
708        writer
709            .append_file_change(
710                Event {
711                    session_id: sid.to_string(),
712                    ts_ms: 4_000,
713                    kind: EventKind::FileChange,
714                    step: None,
715                    summary: "secret.txt created".into(),
716                    body_json: Some(serde_json::json!({"path": "secret.txt"})),
717                    blob_hash: Some(hash.to_string()),
718                    blob_size: Some(27),
719                    correlates: None,
720                },
721                FileChange {
722                    event_id: 0,
723                    path: "secret.txt".into(),
724                    change_kind: ChangeKind::Created,
725                    before_hash: None,
726                    after_hash: Some(hash.to_string()),
727                    is_binary: false,
728                },
729            )
730            .unwrap();
731        writer.finish().unwrap();
732        store.assign_steps(sid).unwrap();
733    }
734
735    #[test]
736    fn parse_rejects_newer_format_version() {
737        let manifest = serde_json::json!({
738            "format_version": FORMAT_VERSION + 1,
739            "hh_version": "future",
740            "session": {},
741            "integrity": {"events_blake3": blake3::hash(b"").to_hex().to_string(), "event_count": 0, "blobs": []},
742        });
743        let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
744        let tar_bytes = build_tar(&manifest_bytes, b"", &BTreeMap::new()).unwrap();
745        let compressed = zstd::stream::encode_all(&tar_bytes[..], ZSTD_LEVEL).unwrap();
746        let err = parse(&compressed).unwrap_err();
747        assert!(matches!(
748            err,
749            crate::Error::Bundle(BundleError::UnsupportedVersion { .. })
750        ));
751    }
752
753    #[test]
754    fn parse_rejects_a_tampered_blob() {
755        let (_tmp, store, sid) = fixture();
756        let bytes = export(&store, &sid, "v", None).unwrap();
757        let tar_bytes = bounded_decompress(&bytes).unwrap();
758        let mut archive = tar::Archive::new(&tar_bytes[..]);
759        // Rebuild the tar with one blob's content corrupted so its hash no
760        // longer matches its path.
761        let mut builder = tar::Builder::new(Vec::new());
762        for entry in archive.entries().unwrap() {
763            let mut entry = entry.unwrap();
764            let path = entry.path().unwrap().to_string_lossy().into_owned();
765            let mut data = Vec::new();
766            entry.read_to_end(&mut data).unwrap();
767            if path.starts_with("blobs/") {
768                data.push(0xFF);
769            }
770            append_entry(&mut builder, &path, &data).unwrap();
771        }
772        let tampered_tar = builder.into_inner().unwrap();
773        let compressed = zstd::stream::encode_all(&tampered_tar[..], ZSTD_LEVEL).unwrap();
774        let err = parse(&compressed).unwrap_err();
775        assert!(matches!(
776            err,
777            crate::Error::Bundle(BundleError::HashMismatch { .. })
778        ));
779    }
780
781    #[test]
782    fn parse_rejects_garbage_bytes_without_panicking() {
783        for input in [&b""[..], &b"not a bundle"[..], &[0u8; 4096][..]] {
784            let err = parse(input);
785            assert!(err.is_err(), "garbage input must be rejected, not accepted");
786        }
787    }
788
789    #[test]
790    fn parse_rejects_an_unexpected_tar_entry() {
791        let mut builder = tar::Builder::new(Vec::new());
792        let manifest = serde_json::json!({
793            "format_version": 1,
794            "hh_version": "v",
795            "session": {},
796            "integrity": {"events_blake3": blake3::hash(b"").to_hex().to_string(), "event_count": 0, "blobs": []},
797        });
798        append_entry(
799            &mut builder,
800            "manifest.json",
801            &serde_json::to_vec(&manifest).unwrap(),
802        )
803        .unwrap();
804        append_entry(&mut builder, "events.ndjson", b"").unwrap();
805        append_entry(&mut builder, "readme.txt", b"not on the allow-list").unwrap();
806        let tar_bytes = builder.into_inner().unwrap();
807        let compressed = zstd::stream::encode_all(&tar_bytes[..], ZSTD_LEVEL).unwrap();
808        let err = parse(&compressed).unwrap_err();
809        assert!(matches!(err, crate::Error::Bundle(BundleError::Tar(_))));
810    }
811
812    #[test]
813    fn store_import_mints_a_new_id_and_records_provenance() {
814        let (_tmp, store, sid) = fixture();
815        let original = store.get_session(&sid).unwrap();
816        let bytes = export(&store, &sid, "v", None).unwrap();
817        let bundle = parse(&bytes).unwrap();
818
819        let target_tmp = TempDir::new().unwrap();
820        let target = Store::open(
821            &target_tmp.path().join("hh.db"),
822            &target_tmp.path().join("blobs"),
823        )
824        .unwrap();
825        let created = target.import(&bundle).unwrap();
826        assert_ne!(created.id, original.id, "import must mint a fresh id");
827
828        let imported = target.get_session(&created.id).unwrap();
829        assert_eq!(
830            imported.imported_from.as_deref(),
831            Some(original.id.as_str())
832        );
833        assert_eq!(imported.command, original.command);
834        assert_eq!(imported.status, original.status);
835
836        let mut events = Vec::new();
837        target
838            .for_each_event_raw(&created.id, |r| {
839                events.push(r);
840                Ok(())
841            })
842            .unwrap();
843        assert_eq!(events.len(), 4, "every bundled event must be re-inserted");
844        // The tool_result correlates to the re-inserted tool_call, not any
845        // stale id from the source store.
846        let call = events
847            .iter()
848            .find(|e| e.kind == EventKind::ToolCall)
849            .unwrap();
850        let result = events
851            .iter()
852            .find(|e| e.kind == EventKind::ToolResult)
853            .unwrap();
854        assert_eq!(result.correlates, Some(call.id));
855        // The file_change's blob content is retrievable in the new store.
856        let fc_event = events
857            .iter()
858            .find(|e| e.kind == EventKind::FileChange)
859            .unwrap();
860        let fc = fc_event.file_change.as_ref().unwrap();
861        let after_hash = fc.after_hash.as_ref().unwrap();
862        assert_eq!(
863            target.blobs().get(after_hash).unwrap(),
864            b"created content\n"
865        );
866    }
867
868    #[test]
869    fn store_import_into_the_same_store_as_the_source_dedupes_blobs() {
870        // Importing back into the store the bundle was exported from is a
871        // legitimate use (e.g. "restore from an archived bundle") and must
872        // not corrupt the source session's blobs (content-addressing makes
873        // re-`put`ting identical content a no-op).
874        let (_tmp, store, sid) = fixture();
875        let bytes = export(&store, &sid, "v", None).unwrap();
876        let bundle = parse(&bytes).unwrap();
877        let created = store.import(&bundle).unwrap();
878        assert_ne!(created.id, sid);
879        // The original session is untouched.
880        let original_still_ok = store.get_session(&sid).unwrap();
881        assert_eq!(original_still_ok.imported_from, None);
882    }
883}