1use 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
30pub const FORMAT_VERSION: u32 = 1;
34
35const ZSTD_LEVEL: i32 = 3;
39
40const MAX_BUNDLE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
45
46#[derive(Debug, Clone)]
49pub struct Bundle {
50 pub hh_version: String,
52 pub session: serde_json::Value,
55 pub events: Vec<serde_json::Value>,
57 pub blobs: BTreeMap<String, Vec<u8>>,
60}
61
62pub 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 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 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
193pub 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#[allow(clippy::type_complexity)] fn 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
282fn 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
317fn 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
352fn 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
373enum 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
407fn 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
426fn 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
442fn 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
467fn 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
478fn 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#[cfg(feature = "fuzzing")]
509pub mod fuzzing {
510 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 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 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 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 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 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 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 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 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 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 let original_still_ok = store.get_session(&sid).unwrap();
881 assert_eq!(original_still_ok.imported_from, None);
882 }
883}