1use std::path::Path;
11
12use tokio::fs;
13
14use crate::error::SessionError;
15use crate::event::{SessionEvent, SessionEventEnvelope};
16use crate::log::SessionEventLog;
17use crate::replay::ReplayEngine;
18use crate::store::SessionStore;
19
20const BLOBS_DIR_NAME: &str = "blobs";
22
23#[derive(Debug, Clone)]
25pub struct ForkResult {
26 pub new_session_id: String,
28 pub events_copied: usize,
31}
32
33pub struct ForkEngine;
35
36impl ForkEngine {
37 #[tracing::instrument(name = "session.fork.run", skip_all, level = "info", fields(at_seq))]
57 pub async fn fork(
58 data_dir: &Path,
59 src_id: &str,
60 new_id: &str,
61 at_seq: Option<u64>,
62 store: &SessionStore,
63 owner: Option<&str>,
64 ) -> Result<ForkResult, SessionError> {
65 if store.get(src_id).await?.is_none() {
66 return Err(SessionError::NotFound(src_id.to_owned()));
67 }
68
69 let src_dir = crate::session_dir(data_dir, src_id);
70 let src_log = SessionEventLog::open(&src_dir).await?;
71 let all_events = src_log.read_all().await?;
72
73 let total = u64::try_from(all_events.len()).unwrap_or(u64::MAX);
74 let at_seq = at_seq.unwrap_or(total);
75 if at_seq > total {
76 return Err(SessionError::InvalidForkPoint(format!(
77 "at_seq={at_seq} exceeds source session's event count={total}"
78 )));
79 }
80
81 ReplayEngine::replay(&src_dir, Some(at_seq)).await?;
84
85 let take_n = usize::try_from(at_seq).unwrap_or(usize::MAX);
86 let to_copy: Vec<_> = all_events.iter().take(take_n).cloned().collect();
87 let (cwd, provider_name, model) = to_copy
88 .iter()
89 .find_map(|e| match &e.kind {
90 SessionEvent::SessionStarted {
91 cwd,
92 provider_name,
93 model,
94 ..
95 } => Some((cwd.clone(), provider_name.clone(), model.clone())),
96 _ => None,
97 })
98 .unwrap_or_default();
99
100 let child_dir = crate::session_dir(data_dir, new_id);
101 let child_log = SessionEventLog::open(&child_dir).await?;
102
103 child_log
104 .append(
105 None,
106 None,
107 SessionEvent::SessionStarted {
108 session_id: new_id.to_owned(),
109 cwd,
110 provider_name,
111 model,
112 forked_from: Some((src_id.to_owned(), at_seq)),
113 },
114 )
115 .await?;
116 for envelope in &to_copy {
117 child_log
118 .append(envelope.turn_id, envelope.parent_seq, envelope.kind.clone())
119 .await?;
120 }
121
122 copy_referenced_blobs(&src_dir, &child_dir, &to_copy).await?;
123
124 store.record_fork(new_id, src_id, at_seq, owner).await?;
125 store
126 .update_seq(
127 new_id,
128 child_log.last_seq().unwrap_or(0),
129 to_copy.len() as u64 + 1,
130 )
131 .await?;
132
133 src_log
135 .append(
136 None,
137 None,
138 SessionEvent::ForkPoint {
139 new_session_id: new_id.to_owned(),
140 },
141 )
142 .await?;
143
144 Ok(ForkResult {
145 new_session_id: new_id.to_owned(),
146 events_copied: to_copy.len(),
147 })
148 }
149}
150
151async fn copy_referenced_blobs(
183 src_dir: &Path,
184 child_dir: &Path,
185 events: &[SessionEventEnvelope],
186) -> Result<(), SessionError> {
187 let mut hashes: Vec<&str> = Vec::new();
188 for envelope in events {
189 let SessionEvent::UserMessage { image_refs, .. } = &envelope.kind else {
190 continue;
191 };
192 for hash in image_refs {
193 validate_blob_hash(hash)?;
194 hashes.push(hash.as_str());
195 }
196 }
197
198 if hashes.is_empty() {
199 return Ok(());
200 }
201
202 hashes.sort_unstable();
208 hashes.dedup();
209
210 let src_blobs = src_dir.join(BLOBS_DIR_NAME);
211 let child_blobs = child_dir.join(BLOBS_DIR_NAME);
212 fs::create_dir_all(&child_blobs).await?;
213 crate::log::set_permissions(&child_blobs, 0o700).await?;
214
215 for hash in hashes {
216 let src_blob = src_blobs.join(hash);
217 let child_blob = child_blobs.join(hash);
218
219 match fs::hard_link(&src_blob, &child_blob).await {
220 Ok(()) => {}
221 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
222 tracing::warn!(
223 blob = hash,
224 path = %src_blob.display(),
225 "fork: referenced blob missing on parent's disk, skipping"
226 );
227 }
228 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
229 tracing::debug!(
244 blob = hash,
245 path = %child_blob.display(),
246 "fork: blob already linked in child, skipping"
247 );
248 }
249 Err(_) => {
250 fs::copy(&src_blob, &child_blob).await?;
255 }
256 }
257 }
258
259 Ok(())
260}
261
262fn validate_blob_hash(hash: &str) -> Result<(), SessionError> {
268 if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
269 return Err(SessionError::InvalidBlobHash(hash.to_owned()));
270 }
271 Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276 use std::sync::Arc;
277
278 use super::*;
279 use crate::store::SessionStore;
280
281 async fn make_pool() -> zeph_db::DbPool {
282 let config = zeph_db::DbConfig {
283 url: ":memory:".to_owned(),
284 ..Default::default()
285 };
286 let pool = config
287 .connect()
288 .await
289 .expect("connect in-memory sqlite pool");
290 zeph_db::run_migrations(&pool)
291 .await
292 .expect("run migrations");
293 pool
294 }
295
296 async fn seed_parent(data_dir: &Path, store: &SessionStore, id: &str) {
297 store.create(id).await.unwrap();
298 let dir = crate::session_dir(data_dir, id);
299 let log = SessionEventLog::open(&dir).await.unwrap();
300 log.append(
301 None,
302 None,
303 SessionEvent::SessionStarted {
304 session_id: id.to_owned(),
305 cwd: "/repo".to_owned(),
306 provider_name: "claude".to_owned(),
307 model: "opus".to_owned(),
308 forked_from: None,
309 },
310 )
311 .await
312 .unwrap();
313 log.append(
314 None,
315 None,
316 SessionEvent::UserMessage {
317 text: "hello".to_owned(),
318 image_refs: vec![],
319 },
320 )
321 .await
322 .unwrap();
323 log.append(
324 None,
325 None,
326 SessionEvent::AssistantMessage {
327 parts: vec![zeph_llm::provider::MessagePart::Text {
328 text: "hi".to_owned(),
329 }],
330 },
331 )
332 .await
333 .unwrap();
334 log.append(
335 None,
336 None,
337 SessionEvent::UserMessage {
338 text: "second turn".to_owned(),
339 image_refs: vec![],
340 },
341 )
342 .await
343 .unwrap();
344 store
345 .update_seq(id, log.last_seq().unwrap(), 4)
346 .await
347 .unwrap();
348 }
349
350 #[tokio::test]
351 #[serial_test::serial(session_history_integrity)]
352 async fn test_fork_copies_events() {
353 let store = SessionStore::new(make_pool().await);
354 let data_dir = tempfile::tempdir().unwrap();
355 seed_parent(data_dir.path(), &store, "parent").await;
356
357 let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(3), &store, None)
358 .await
359 .unwrap();
360 assert_eq!(result.events_copied, 3);
361 assert_eq!(result.new_session_id, "child");
362
363 let child_dir = crate::session_dir(data_dir.path(), &result.new_session_id);
364 let child_log = SessionEventLog::open(&child_dir).await.unwrap();
365 let events = child_log.read_all().await.unwrap();
366 assert_eq!(events.len(), 4);
368 }
369
370 #[tokio::test]
376 #[serial_test::serial(session_history_integrity)]
377 async fn test_fork_rejects_a_tampered_parent_chain() {
378 let _guard = crate::log::IntegrityConfigGuard::new();
379 let ring = Arc::new(zeph_common::hash_chain::ChainKeyRing::new(
380 0,
381 zeph_common::hash_chain::ChainKey::new([77u8; 32]),
382 ));
383 crate::log::configure_history_integrity(Some(ring));
384
385 let store = SessionStore::new(make_pool().await);
386 let data_dir = tempfile::tempdir().unwrap();
387 seed_parent(data_dir.path(), &store, "parent").await;
388
389 let events_path = crate::session_dir(data_dir.path(), "parent").join("events.jsonl");
390 let raw = std::fs::read_to_string(&events_path).unwrap();
391 let mut lines: Vec<&str> = raw.lines().collect();
392 assert!(
393 lines.len() >= 2,
394 "fixture must have a non-first line to tamper"
395 );
396 let tampered = lines[1].replace("hello", "forged-approval");
397 lines[1] = &tampered;
398 std::fs::write(&events_path, lines.join("\n") + "\n").unwrap();
399
400 let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(3), &store, None)
401 .await
402 .unwrap_err();
403 assert!(
404 matches!(err, SessionError::Integrity(_)),
405 "tampering the parent's chain must abort the fork with an Integrity error, not \
406 silently produce a child; got {err:?}"
407 );
408
409 let child_dir = crate::session_dir(data_dir.path(), "child");
412 assert!(
413 !child_dir.join("events.jsonl").exists(),
414 "a rejected fork must not leave behind a partially-written child log"
415 );
416 }
417
418 #[tokio::test]
419 #[serial_test::serial(session_history_integrity)]
420 async fn test_fork_provenance_metadata() {
421 let store = SessionStore::new(make_pool().await);
422 let data_dir = tempfile::tempdir().unwrap();
423 seed_parent(data_dir.path(), &store, "parent").await;
424
425 ForkEngine::fork(data_dir.path(), "parent", "child", Some(2), &store, None)
426 .await
427 .unwrap();
428
429 let meta = store.get("child").await.unwrap().unwrap();
430 assert_eq!(meta.forked_from.as_deref(), Some("parent"));
431 assert_eq!(meta.forked_at_seq, Some(2));
432 }
433
434 #[tokio::test]
438 #[serial_test::serial(session_history_integrity)]
439 async fn fork_propagates_owner_to_child_row() {
440 let pool = make_pool().await;
441 let store = SessionStore::new(pool.clone());
442 let data_dir = tempfile::tempdir().unwrap();
443 seed_parent(data_dir.path(), &store, "parent").await;
444
445 ForkEngine::fork(
446 data_dir.path(),
447 "parent",
448 "child",
449 Some(2),
450 &store,
451 Some("alice"),
452 )
453 .await
454 .unwrap();
455
456 let owner_key: Option<String> = zeph_db::query_scalar(zeph_db::sql!(
457 "SELECT owner_key FROM acp_sessions WHERE id = ?"
458 ))
459 .bind("child")
460 .fetch_one(&pool)
461 .await
462 .unwrap();
463 assert_eq!(owner_key.as_deref(), Some("alice"));
464 }
465
466 #[tokio::test]
467 #[serial_test::serial(session_history_integrity)]
468 async fn test_fork_appends_forkpoint_to_parent() {
469 let store = SessionStore::new(make_pool().await);
470 let data_dir = tempfile::tempdir().unwrap();
471 seed_parent(data_dir.path(), &store, "parent").await;
472
473 ForkEngine::fork(data_dir.path(), "parent", "child", Some(2), &store, None)
474 .await
475 .unwrap();
476
477 let parent_dir = crate::session_dir(data_dir.path(), "parent");
478 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
479 let events = parent_log.read_all().await.unwrap();
480 assert!(matches!(
481 events.last().unwrap().kind,
482 SessionEvent::ForkPoint { .. }
483 ));
484 }
485
486 #[tokio::test]
487 #[serial_test::serial(session_history_integrity)]
488 async fn test_fork_rejects_seq_beyond_source() {
489 let store = SessionStore::new(make_pool().await);
490 let data_dir = tempfile::tempdir().unwrap();
491 seed_parent(data_dir.path(), &store, "parent").await;
492
493 let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(100), &store, None)
494 .await
495 .unwrap_err();
496 assert!(matches!(err, SessionError::InvalidForkPoint(_)));
497 }
498
499 #[tokio::test]
500 #[serial_test::serial(session_history_integrity)]
501 async fn test_fork_rejects_unknown_source() {
502 let store = SessionStore::new(make_pool().await);
503 let data_dir = tempfile::tempdir().unwrap();
504
505 let err = ForkEngine::fork(data_dir.path(), "no-such", "child", Some(0), &store, None)
506 .await
507 .unwrap_err();
508 assert!(matches!(err, SessionError::NotFound(_)));
509 }
510
511 #[tokio::test]
512 #[serial_test::serial(session_history_integrity)]
513 async fn test_fork_none_copies_everything() {
514 let store = SessionStore::new(make_pool().await);
515 let data_dir = tempfile::tempdir().unwrap();
516 seed_parent(data_dir.path(), &store, "parent").await;
517
518 let result = ForkEngine::fork(data_dir.path(), "parent", "child", None, &store, None)
519 .await
520 .unwrap();
521 assert_eq!(result.events_copied, 4);
523 }
524
525 #[tokio::test]
528 #[serial_test::serial(session_history_integrity)]
529 async fn test_fork_copies_referenced_blobs() {
530 let store = SessionStore::new(make_pool().await);
531 let data_dir = tempfile::tempdir().unwrap();
532 seed_parent(data_dir.path(), &store, "parent").await;
533
534 let parent_dir = crate::session_dir(data_dir.path(), "parent");
535 let parent_blobs = parent_dir.join("blobs");
536 tokio::fs::create_dir_all(&parent_blobs).await.unwrap();
537 tokio::fs::write(parent_blobs.join("a1b2c3"), b"image-bytes")
538 .await
539 .unwrap();
540
541 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
542 parent_log
543 .append(
544 None,
545 None,
546 SessionEvent::UserMessage {
547 text: "with image".to_owned(),
548 image_refs: vec!["a1b2c3".to_owned()],
549 },
550 )
551 .await
552 .unwrap();
553 store.update_seq("parent", 4, 5).await.unwrap();
554
555 let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
556 .await
557 .unwrap();
558 assert_eq!(result.events_copied, 5);
559
560 let child_dir = crate::session_dir(data_dir.path(), "child");
561 let child_blob = child_dir.join("blobs").join("a1b2c3");
562 let copied = tokio::fs::read(&child_blob).await.unwrap();
563 assert_eq!(copied, b"image-bytes");
564 }
565
566 #[tokio::test]
570 #[serial_test::serial(session_history_integrity)]
571 async fn test_fork_skips_missing_blob_without_failing() {
572 let store = SessionStore::new(make_pool().await);
573 let data_dir = tempfile::tempdir().unwrap();
574 seed_parent(data_dir.path(), &store, "parent").await;
575
576 let parent_dir = crate::session_dir(data_dir.path(), "parent");
577 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
578 parent_log
579 .append(
580 None,
581 None,
582 SessionEvent::UserMessage {
583 text: "with missing image".to_owned(),
584 image_refs: vec!["deadbeef".to_owned()],
585 },
586 )
587 .await
588 .unwrap();
589 store.update_seq("parent", 4, 5).await.unwrap();
590
591 let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
592 .await
593 .unwrap();
594 assert_eq!(result.events_copied, 5);
595
596 let child_dir = crate::session_dir(data_dir.path(), "child");
597 assert!(!child_dir.join("blobs").join("deadbeef").exists());
598 }
599
600 #[tokio::test]
604 #[serial_test::serial(session_history_integrity)]
605 async fn test_fork_without_image_refs_creates_no_blobs_dir() {
606 let store = SessionStore::new(make_pool().await);
607 let data_dir = tempfile::tempdir().unwrap();
608 seed_parent(data_dir.path(), &store, "parent").await;
609
610 ForkEngine::fork(data_dir.path(), "parent", "child", Some(2), &store, None)
611 .await
612 .unwrap();
613
614 let child_dir = crate::session_dir(data_dir.path(), "child");
615 assert!(!child_dir.join("blobs").exists());
616 }
617
618 #[tokio::test]
623 #[serial_test::serial(session_history_integrity)]
624 async fn test_fork_rejects_path_traversal_in_image_refs() {
625 let store = SessionStore::new(make_pool().await);
626 let data_dir = tempfile::tempdir().unwrap();
627 seed_parent(data_dir.path(), &store, "parent").await;
628
629 let parent_dir = crate::session_dir(data_dir.path(), "parent");
630 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
631 parent_log
632 .append(
633 None,
634 None,
635 SessionEvent::UserMessage {
636 text: "malicious ref".to_owned(),
637 image_refs: vec!["../../../etc/passwd".to_owned()],
638 },
639 )
640 .await
641 .unwrap();
642 store.update_seq("parent", 4, 5).await.unwrap();
643
644 let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
645 .await
646 .unwrap_err();
647 assert!(matches!(err, SessionError::InvalidBlobHash(_)));
648
649 let child_dir = crate::session_dir(data_dir.path(), "child");
651 assert!(!child_dir.join("blobs").exists());
652 }
653
654 #[tokio::test]
658 #[serial_test::serial(session_history_integrity)]
659 async fn test_fork_rejects_absolute_path_in_image_refs() {
660 let store = SessionStore::new(make_pool().await);
661 let data_dir = tempfile::tempdir().unwrap();
662 seed_parent(data_dir.path(), &store, "parent").await;
663
664 let parent_dir = crate::session_dir(data_dir.path(), "parent");
665 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
666 parent_log
667 .append(
668 None,
669 None,
670 SessionEvent::UserMessage {
671 text: "malicious absolute ref".to_owned(),
672 image_refs: vec!["/etc/passwd".to_owned()],
673 },
674 )
675 .await
676 .unwrap();
677 store.update_seq("parent", 4, 5).await.unwrap();
678
679 let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
680 .await
681 .unwrap_err();
682 assert!(matches!(err, SessionError::InvalidBlobHash(_)));
683 }
684
685 #[tokio::test]
689 #[serial_test::serial(session_history_integrity)]
690 async fn test_fork_dedups_duplicate_blob_hash() {
691 let store = SessionStore::new(make_pool().await);
692 let data_dir = tempfile::tempdir().unwrap();
693 seed_parent(data_dir.path(), &store, "parent").await;
694
695 let parent_dir = crate::session_dir(data_dir.path(), "parent");
696 let parent_blobs = parent_dir.join("blobs");
697 tokio::fs::create_dir_all(&parent_blobs).await.unwrap();
698 tokio::fs::write(parent_blobs.join("cafe01"), b"shared-bytes")
699 .await
700 .unwrap();
701
702 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
703 parent_log
704 .append(
705 None,
706 None,
707 SessionEvent::UserMessage {
708 text: "first ref".to_owned(),
709 image_refs: vec!["cafe01".to_owned()],
710 },
711 )
712 .await
713 .unwrap();
714 parent_log
715 .append(
716 None,
717 None,
718 SessionEvent::UserMessage {
719 text: "second ref, same hash".to_owned(),
720 image_refs: vec!["cafe01".to_owned()],
721 },
722 )
723 .await
724 .unwrap();
725 store.update_seq("parent", 4, 6).await.unwrap();
726
727 let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(6), &store, None)
728 .await
729 .unwrap();
730 assert_eq!(result.events_copied, 6);
731
732 let child_dir = crate::session_dir(data_dir.path(), "child");
733 let child_blob = child_dir.join("blobs").join("cafe01");
734 assert_eq!(tokio::fs::read(&child_blob).await.unwrap(), b"shared-bytes");
735 }
736
737 #[tokio::test]
744 #[serial_test::serial(session_history_integrity)]
745 async fn test_copy_referenced_blobs_retry_does_not_truncate_shared_blob() {
746 let data_dir = tempfile::tempdir().unwrap();
747 let src_dir = data_dir.path().join("parent");
748 let child_dir = data_dir.path().join("child");
749
750 let src_blobs = src_dir.join("blobs");
751 tokio::fs::create_dir_all(&src_blobs).await.unwrap();
752 let original_content = b"image-bytes-not-empty";
753 tokio::fs::write(src_blobs.join("a1b2c3"), original_content)
754 .await
755 .unwrap();
756
757 let events = vec![SessionEventEnvelope {
758 seq: 0,
759 ts_ms: 0,
760 turn_id: None,
761 parent_seq: None,
762 kind: SessionEvent::UserMessage {
763 text: "with image".to_owned(),
764 image_refs: vec!["a1b2c3".to_owned()],
765 },
766 chain: None,
767 }];
768
769 copy_referenced_blobs(&src_dir, &child_dir, &events)
771 .await
772 .unwrap();
773
774 let child_blob = child_dir.join("blobs").join("a1b2c3");
775 assert_eq!(
776 tokio::fs::read(&child_blob).await.unwrap(),
777 original_content
778 );
779
780 copy_referenced_blobs(&src_dir, &child_dir, &events)
783 .await
784 .unwrap();
785
786 assert_eq!(
787 tokio::fs::read(&child_blob).await.unwrap(),
788 original_content,
789 "child blob must not be truncated by a retried fork against the same child_dir"
790 );
791 assert_eq!(
792 tokio::fs::read(src_blobs.join("a1b2c3")).await.unwrap(),
793 original_content,
794 "parent's original blob must not be truncated by a retried fork against the same child_dir"
795 );
796 }
797
798 #[cfg(unix)]
801 #[tokio::test]
802 #[serial_test::serial(session_history_integrity)]
803 async fn test_fork_sets_0700_on_child_blobs_dir() {
804 use std::os::unix::fs::PermissionsExt;
805
806 let store = SessionStore::new(make_pool().await);
807 let data_dir = tempfile::tempdir().unwrap();
808 seed_parent(data_dir.path(), &store, "parent").await;
809
810 let parent_dir = crate::session_dir(data_dir.path(), "parent");
811 let parent_blobs = parent_dir.join("blobs");
812 tokio::fs::create_dir_all(&parent_blobs).await.unwrap();
813 tokio::fs::write(parent_blobs.join("a1b2c3"), b"image-bytes")
814 .await
815 .unwrap();
816
817 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
818 parent_log
819 .append(
820 None,
821 None,
822 SessionEvent::UserMessage {
823 text: "with image".to_owned(),
824 image_refs: vec!["a1b2c3".to_owned()],
825 },
826 )
827 .await
828 .unwrap();
829 store.update_seq("parent", 4, 5).await.unwrap();
830
831 ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
832 .await
833 .unwrap();
834
835 let child_dir = crate::session_dir(data_dir.path(), "child");
836 let meta = tokio::fs::metadata(child_dir.join("blobs")).await.unwrap();
837 assert_eq!(meta.permissions().mode() & 0o777, 0o700);
838 }
839}