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 async fn test_fork_copies_events() {
352 let store = SessionStore::new(make_pool().await);
353 let data_dir = tempfile::tempdir().unwrap();
354 seed_parent(data_dir.path(), &store, "parent").await;
355
356 let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(3), &store, None)
357 .await
358 .unwrap();
359 assert_eq!(result.events_copied, 3);
360 assert_eq!(result.new_session_id, "child");
361
362 let child_dir = crate::session_dir(data_dir.path(), &result.new_session_id);
363 let child_log = SessionEventLog::open(&child_dir).await.unwrap();
364 let events = child_log.read_all().await.unwrap();
365 assert_eq!(events.len(), 4);
367 }
368
369 #[tokio::test]
375 async fn test_fork_rejects_a_tampered_parent_chain() {
376 let ring = Arc::new(zeph_common::hash_chain::ChainKeyRing::new(
377 0,
378 zeph_common::hash_chain::ChainKey::new([77u8; 32]),
379 ));
380 crate::log::configure_history_integrity(Some(ring));
381
382 let store = SessionStore::new(make_pool().await);
383 let data_dir = tempfile::tempdir().unwrap();
384 seed_parent(data_dir.path(), &store, "parent").await;
385
386 let events_path = crate::session_dir(data_dir.path(), "parent").join("events.jsonl");
387 let raw = std::fs::read_to_string(&events_path).unwrap();
388 let mut lines: Vec<&str> = raw.lines().collect();
389 assert!(
390 lines.len() >= 2,
391 "fixture must have a non-first line to tamper"
392 );
393 let tampered = lines[1].replace("hello", "forged-approval");
394 lines[1] = &tampered;
395 std::fs::write(&events_path, lines.join("\n") + "\n").unwrap();
396
397 let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(3), &store, None)
398 .await
399 .unwrap_err();
400 assert!(
401 matches!(err, SessionError::Integrity(_)),
402 "tampering the parent's chain must abort the fork with an Integrity error, not \
403 silently produce a child; got {err:?}"
404 );
405
406 let child_dir = crate::session_dir(data_dir.path(), "child");
409 assert!(
410 !child_dir.join("events.jsonl").exists(),
411 "a rejected fork must not leave behind a partially-written child log"
412 );
413
414 crate::log::configure_history_integrity(None);
415 }
416
417 #[tokio::test]
418 async fn test_fork_provenance_metadata() {
419 let store = SessionStore::new(make_pool().await);
420 let data_dir = tempfile::tempdir().unwrap();
421 seed_parent(data_dir.path(), &store, "parent").await;
422
423 ForkEngine::fork(data_dir.path(), "parent", "child", Some(2), &store, None)
424 .await
425 .unwrap();
426
427 let meta = store.get("child").await.unwrap().unwrap();
428 assert_eq!(meta.forked_from.as_deref(), Some("parent"));
429 assert_eq!(meta.forked_at_seq, Some(2));
430 }
431
432 #[tokio::test]
436 async fn fork_propagates_owner_to_child_row() {
437 let pool = make_pool().await;
438 let store = SessionStore::new(pool.clone());
439 let data_dir = tempfile::tempdir().unwrap();
440 seed_parent(data_dir.path(), &store, "parent").await;
441
442 ForkEngine::fork(
443 data_dir.path(),
444 "parent",
445 "child",
446 Some(2),
447 &store,
448 Some("alice"),
449 )
450 .await
451 .unwrap();
452
453 let owner_key: Option<String> = zeph_db::query_scalar(zeph_db::sql!(
454 "SELECT owner_key FROM acp_sessions WHERE id = ?"
455 ))
456 .bind("child")
457 .fetch_one(&pool)
458 .await
459 .unwrap();
460 assert_eq!(owner_key.as_deref(), Some("alice"));
461 }
462
463 #[tokio::test]
464 async fn test_fork_appends_forkpoint_to_parent() {
465 let store = SessionStore::new(make_pool().await);
466 let data_dir = tempfile::tempdir().unwrap();
467 seed_parent(data_dir.path(), &store, "parent").await;
468
469 ForkEngine::fork(data_dir.path(), "parent", "child", Some(2), &store, None)
470 .await
471 .unwrap();
472
473 let parent_dir = crate::session_dir(data_dir.path(), "parent");
474 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
475 let events = parent_log.read_all().await.unwrap();
476 assert!(matches!(
477 events.last().unwrap().kind,
478 SessionEvent::ForkPoint { .. }
479 ));
480 }
481
482 #[tokio::test]
483 async fn test_fork_rejects_seq_beyond_source() {
484 let store = SessionStore::new(make_pool().await);
485 let data_dir = tempfile::tempdir().unwrap();
486 seed_parent(data_dir.path(), &store, "parent").await;
487
488 let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(100), &store, None)
489 .await
490 .unwrap_err();
491 assert!(matches!(err, SessionError::InvalidForkPoint(_)));
492 }
493
494 #[tokio::test]
495 async fn test_fork_rejects_unknown_source() {
496 let store = SessionStore::new(make_pool().await);
497 let data_dir = tempfile::tempdir().unwrap();
498
499 let err = ForkEngine::fork(data_dir.path(), "no-such", "child", Some(0), &store, None)
500 .await
501 .unwrap_err();
502 assert!(matches!(err, SessionError::NotFound(_)));
503 }
504
505 #[tokio::test]
506 async fn test_fork_none_copies_everything() {
507 let store = SessionStore::new(make_pool().await);
508 let data_dir = tempfile::tempdir().unwrap();
509 seed_parent(data_dir.path(), &store, "parent").await;
510
511 let result = ForkEngine::fork(data_dir.path(), "parent", "child", None, &store, None)
512 .await
513 .unwrap();
514 assert_eq!(result.events_copied, 4);
516 }
517
518 #[tokio::test]
521 async fn test_fork_copies_referenced_blobs() {
522 let store = SessionStore::new(make_pool().await);
523 let data_dir = tempfile::tempdir().unwrap();
524 seed_parent(data_dir.path(), &store, "parent").await;
525
526 let parent_dir = crate::session_dir(data_dir.path(), "parent");
527 let parent_blobs = parent_dir.join("blobs");
528 tokio::fs::create_dir_all(&parent_blobs).await.unwrap();
529 tokio::fs::write(parent_blobs.join("a1b2c3"), b"image-bytes")
530 .await
531 .unwrap();
532
533 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
534 parent_log
535 .append(
536 None,
537 None,
538 SessionEvent::UserMessage {
539 text: "with image".to_owned(),
540 image_refs: vec!["a1b2c3".to_owned()],
541 },
542 )
543 .await
544 .unwrap();
545 store.update_seq("parent", 4, 5).await.unwrap();
546
547 let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
548 .await
549 .unwrap();
550 assert_eq!(result.events_copied, 5);
551
552 let child_dir = crate::session_dir(data_dir.path(), "child");
553 let child_blob = child_dir.join("blobs").join("a1b2c3");
554 let copied = tokio::fs::read(&child_blob).await.unwrap();
555 assert_eq!(copied, b"image-bytes");
556 }
557
558 #[tokio::test]
562 async fn test_fork_skips_missing_blob_without_failing() {
563 let store = SessionStore::new(make_pool().await);
564 let data_dir = tempfile::tempdir().unwrap();
565 seed_parent(data_dir.path(), &store, "parent").await;
566
567 let parent_dir = crate::session_dir(data_dir.path(), "parent");
568 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
569 parent_log
570 .append(
571 None,
572 None,
573 SessionEvent::UserMessage {
574 text: "with missing image".to_owned(),
575 image_refs: vec!["deadbeef".to_owned()],
576 },
577 )
578 .await
579 .unwrap();
580 store.update_seq("parent", 4, 5).await.unwrap();
581
582 let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
583 .await
584 .unwrap();
585 assert_eq!(result.events_copied, 5);
586
587 let child_dir = crate::session_dir(data_dir.path(), "child");
588 assert!(!child_dir.join("blobs").join("deadbeef").exists());
589 }
590
591 #[tokio::test]
595 async fn test_fork_without_image_refs_creates_no_blobs_dir() {
596 let store = SessionStore::new(make_pool().await);
597 let data_dir = tempfile::tempdir().unwrap();
598 seed_parent(data_dir.path(), &store, "parent").await;
599
600 ForkEngine::fork(data_dir.path(), "parent", "child", Some(2), &store, None)
601 .await
602 .unwrap();
603
604 let child_dir = crate::session_dir(data_dir.path(), "child");
605 assert!(!child_dir.join("blobs").exists());
606 }
607
608 #[tokio::test]
613 async fn test_fork_rejects_path_traversal_in_image_refs() {
614 let store = SessionStore::new(make_pool().await);
615 let data_dir = tempfile::tempdir().unwrap();
616 seed_parent(data_dir.path(), &store, "parent").await;
617
618 let parent_dir = crate::session_dir(data_dir.path(), "parent");
619 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
620 parent_log
621 .append(
622 None,
623 None,
624 SessionEvent::UserMessage {
625 text: "malicious ref".to_owned(),
626 image_refs: vec!["../../../etc/passwd".to_owned()],
627 },
628 )
629 .await
630 .unwrap();
631 store.update_seq("parent", 4, 5).await.unwrap();
632
633 let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
634 .await
635 .unwrap_err();
636 assert!(matches!(err, SessionError::InvalidBlobHash(_)));
637
638 let child_dir = crate::session_dir(data_dir.path(), "child");
640 assert!(!child_dir.join("blobs").exists());
641 }
642
643 #[tokio::test]
647 async fn test_fork_rejects_absolute_path_in_image_refs() {
648 let store = SessionStore::new(make_pool().await);
649 let data_dir = tempfile::tempdir().unwrap();
650 seed_parent(data_dir.path(), &store, "parent").await;
651
652 let parent_dir = crate::session_dir(data_dir.path(), "parent");
653 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
654 parent_log
655 .append(
656 None,
657 None,
658 SessionEvent::UserMessage {
659 text: "malicious absolute ref".to_owned(),
660 image_refs: vec!["/etc/passwd".to_owned()],
661 },
662 )
663 .await
664 .unwrap();
665 store.update_seq("parent", 4, 5).await.unwrap();
666
667 let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
668 .await
669 .unwrap_err();
670 assert!(matches!(err, SessionError::InvalidBlobHash(_)));
671 }
672
673 #[tokio::test]
677 async fn test_fork_dedups_duplicate_blob_hash() {
678 let store = SessionStore::new(make_pool().await);
679 let data_dir = tempfile::tempdir().unwrap();
680 seed_parent(data_dir.path(), &store, "parent").await;
681
682 let parent_dir = crate::session_dir(data_dir.path(), "parent");
683 let parent_blobs = parent_dir.join("blobs");
684 tokio::fs::create_dir_all(&parent_blobs).await.unwrap();
685 tokio::fs::write(parent_blobs.join("cafe01"), b"shared-bytes")
686 .await
687 .unwrap();
688
689 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
690 parent_log
691 .append(
692 None,
693 None,
694 SessionEvent::UserMessage {
695 text: "first ref".to_owned(),
696 image_refs: vec!["cafe01".to_owned()],
697 },
698 )
699 .await
700 .unwrap();
701 parent_log
702 .append(
703 None,
704 None,
705 SessionEvent::UserMessage {
706 text: "second ref, same hash".to_owned(),
707 image_refs: vec!["cafe01".to_owned()],
708 },
709 )
710 .await
711 .unwrap();
712 store.update_seq("parent", 4, 6).await.unwrap();
713
714 let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(6), &store, None)
715 .await
716 .unwrap();
717 assert_eq!(result.events_copied, 6);
718
719 let child_dir = crate::session_dir(data_dir.path(), "child");
720 let child_blob = child_dir.join("blobs").join("cafe01");
721 assert_eq!(tokio::fs::read(&child_blob).await.unwrap(), b"shared-bytes");
722 }
723
724 #[tokio::test]
731 async fn test_copy_referenced_blobs_retry_does_not_truncate_shared_blob() {
732 let data_dir = tempfile::tempdir().unwrap();
733 let src_dir = data_dir.path().join("parent");
734 let child_dir = data_dir.path().join("child");
735
736 let src_blobs = src_dir.join("blobs");
737 tokio::fs::create_dir_all(&src_blobs).await.unwrap();
738 let original_content = b"image-bytes-not-empty";
739 tokio::fs::write(src_blobs.join("a1b2c3"), original_content)
740 .await
741 .unwrap();
742
743 let events = vec![SessionEventEnvelope {
744 seq: 0,
745 ts_ms: 0,
746 turn_id: None,
747 parent_seq: None,
748 kind: SessionEvent::UserMessage {
749 text: "with image".to_owned(),
750 image_refs: vec!["a1b2c3".to_owned()],
751 },
752 chain: None,
753 }];
754
755 copy_referenced_blobs(&src_dir, &child_dir, &events)
757 .await
758 .unwrap();
759
760 let child_blob = child_dir.join("blobs").join("a1b2c3");
761 assert_eq!(
762 tokio::fs::read(&child_blob).await.unwrap(),
763 original_content
764 );
765
766 copy_referenced_blobs(&src_dir, &child_dir, &events)
769 .await
770 .unwrap();
771
772 assert_eq!(
773 tokio::fs::read(&child_blob).await.unwrap(),
774 original_content,
775 "child blob must not be truncated by a retried fork against the same child_dir"
776 );
777 assert_eq!(
778 tokio::fs::read(src_blobs.join("a1b2c3")).await.unwrap(),
779 original_content,
780 "parent's original blob must not be truncated by a retried fork against the same child_dir"
781 );
782 }
783
784 #[cfg(unix)]
787 #[tokio::test]
788 async fn test_fork_sets_0700_on_child_blobs_dir() {
789 use std::os::unix::fs::PermissionsExt;
790
791 let store = SessionStore::new(make_pool().await);
792 let data_dir = tempfile::tempdir().unwrap();
793 seed_parent(data_dir.path(), &store, "parent").await;
794
795 let parent_dir = crate::session_dir(data_dir.path(), "parent");
796 let parent_blobs = parent_dir.join("blobs");
797 tokio::fs::create_dir_all(&parent_blobs).await.unwrap();
798 tokio::fs::write(parent_blobs.join("a1b2c3"), b"image-bytes")
799 .await
800 .unwrap();
801
802 let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
803 parent_log
804 .append(
805 None,
806 None,
807 SessionEvent::UserMessage {
808 text: "with image".to_owned(),
809 image_refs: vec!["a1b2c3".to_owned()],
810 },
811 )
812 .await
813 .unwrap();
814 store.update_seq("parent", 4, 5).await.unwrap();
815
816 ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
817 .await
818 .unwrap();
819
820 let child_dir = crate::session_dir(data_dir.path(), "child");
821 let meta = tokio::fs::metadata(child_dir.join("blobs")).await.unwrap();
822 assert_eq!(meta.permissions().mode() & 0o777, 0o700);
823 }
824}