Skip to main content

zeph_session/
fork.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`ForkEngine`]: eager-copy session forking (spec §7).
5//!
6//! Copy-on-write forking is explicitly deferred (spec §7.2, §15 NEVER) — eager copy is simple and
7//! self-contained for MVP, and robust to either side independently condensing the shared prefix
8//! afterward (the child log is fully self-contained; `forked_at_seq` is historical metadata only).
9
10use 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
20/// Name of the per-session directory holding content-hash-addressed blob files (spec §4.1).
21const BLOBS_DIR_NAME: &str = "blobs";
22
23/// The result of a successful fork.
24#[derive(Debug, Clone)]
25pub struct ForkResult {
26    /// The newly allocated child session id.
27    pub new_session_id: String,
28    /// Number of events copied from the parent's log (excludes the child's own `SessionStarted`
29    /// header, which is synthesized fresh).
30    pub events_copied: usize,
31}
32
33/// Forks a session at a given `seq`, producing a new, fully self-contained child session.
34pub struct ForkEngine;
35
36impl ForkEngine {
37    /// Fork `src_id` at `at_seq` into a caller-allocated `new_id` (`at_seq` is an exclusive upper
38    /// bound — matches [`ReplayEngine::replay`]'s `up_to` semantics: the child receives events
39    /// `[0, at_seq)` from the parent, plus a synthetic `SessionStarted` header recording
40    /// `forked_from`). `at_seq = None` forks at the current end of the log (copies everything) —
41    /// the default for callers with no explicit cut point (ACP's `fork_session`, which has no
42    /// `seq` parameter, and the CLI's optional `--at`).
43    ///
44    /// `new_id` is caller-supplied rather than minted internally: callers such as ACP's
45    /// `do_fork_session` need the id before the fork call completes (to construct the session's
46    /// `LoopbackChannel`/entry), and the CLI mints a fresh `SessionId::generate()` before calling
47    /// in.
48    ///
49    /// `owner` stamps the child row's `owner_key` (#5868) — see [`SessionStore::record_fork`].
50    ///
51    /// # Errors
52    ///
53    /// Returns [`SessionError::NotFound`] if `src_id` has no session-store row,
54    /// [`SessionError::InvalidForkPoint`] if `at_seq` exceeds the parent log's event count, or
55    /// [`SessionError::Io`]/[`SessionError::Db`] if the copy or store update fails.
56    #[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        // Validate the cut point is internally consistent (spec §7.2 step 2) — replay must not
82        // error. The reconstructed state itself is not needed further here.
83        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        // Non-destructive provenance record on the parent (spec §7.2 step 8).
134        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
151/// Copy the `blobs/` files referenced by `UserMessage.image_refs` in `events` from the parent's
152/// session directory into the child's (spec §7.2 step 6). Hard-links each blob (cheap, same
153/// filesystem — content-hash-addressed blobs are immutable so sharing the inode is safe); falls
154/// back to a full copy if the hard-link fails (e.g. `src_dir`/`child_dir` are on different
155/// filesystems/devices).
156///
157/// A referenced blob missing on disk is logged and skipped rather than treated as a hard
158/// error: the event-log copy (the fork's primary content) already succeeded by this point, and
159/// a missing blob only means the child loses one attachment rather than the whole conversation
160/// history — consistent with [`crate::log`]'s own torn-tail handling, which prefers a
161/// best-effort recovery over failing the whole read.
162///
163/// # Write-once contract
164///
165/// Hard-linking is only safe if blobs are content-addressed and never mutated in place after
166/// being written. No blob writer exists yet anywhere in this codebase to enforce that; when one
167/// lands, it MUST use append-by-new-hash semantics (never overwrite an existing hash's file) or
168/// this fork's hard-link would let a later parent-side mutation silently corrupt the child's
169/// copy through the shared inode.
170///
171/// # Errors
172///
173/// Returns [`SessionError::InvalidBlobHash`] if any `image_refs` entry is not a non-empty,
174/// bare hex string (rejected before use in [`Path::join`] to prevent path traversal), or
175/// [`SessionError::Io`] if directory creation, the hard-link, or the copy fallback fails.
176///
177/// A destination that already exists (e.g. a retried fork against the same `child_dir`) is not
178/// an error: blobs are content-addressed by hash, so a pre-existing entry at the hash-named path
179/// is treated as already the same content and the link is skipped as a no-op. This assumes the
180/// pre-existing file is intact; see the `TODO` on the `AlreadyExists` match arm below for the one
181/// known gap (an interrupted cross-device copy from a prior run).
182async 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    // Dedup: the same hash can legitimately appear twice (repeated attachment, or reused across
203    // messages) — without this, the second `hard_link` on an already-linked destination returns
204    // `AlreadyExists`, which the loop below already handles as a no-op. Dedup here is a
205    // micro-optimization to skip that redundant syscall+no-op, not a guard against the copy
206    // fallback (which `AlreadyExists` never reaches).
207    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                // Destination already exists — most likely a retried fork against the same
230                // child_dir. Blobs are content-addressed by hash (see `validate_blob_hash` and
231                // the module doc), so a pre-existing entry at this hash-named path is assumed to
232                // already be the right content. Treat as a no-op: NOT the generic fallback below,
233                // since `fs::copy` onto an existing hard-link truncates the shared inode to 0
234                // bytes, corrupting every link to it (including the parent's original blob).
235                //
236                // TODO(critic): the copy fallback below writes directly to `child_blob` rather
237                // than a `.tmp` path + rename, so it is not atomic. If a prior run's fallback
238                // (triggered by genuine EXDEV) was interrupted mid-write, it can leave a
239                // truncated file at this path; this no-op would then silently accept that
240                // truncated file as "already correct" on retry. No concurrent-retry call site
241                // exists yet, so this is a documented known gap rather than a fix — an atomic
242                // write (temp file + rename) would close it if/when retries become concurrent.
243                tracing::debug!(
244                    blob = hash,
245                    path = %child_blob.display(),
246                    "fork: blob already linked in child, skipping"
247                );
248            }
249            Err(_) => {
250                // Hard-link failed for a reason other than a missing source or an already-linked
251                // destination (e.g. cross-device link, EXDEV) — fall back to a full copy. Reached
252                // only when the destination does not exist (dest-exists implies AlreadyExists on
253                // all target platforms), so writing directly to `child_blob` here is safe.
254                fs::copy(&src_blob, &child_blob).await?;
255            }
256        }
257    }
258
259    Ok(())
260}
261
262/// Rejects any `image_refs` hash that is not a non-empty, bare hex string, before it is used in
263/// a [`Path::join`] (#5982 follow-up). Content hashes elsewhere in this codebase are BLAKE3 hex
264/// (64 lowercase chars, `zeph_common::hash::blake3_hex`), but no length is enforced here since
265/// no blob writer exists yet to fix the format — a bare hexdigit charset already rules out `/`,
266/// `..`, and absolute paths, which is what makes `join` safe.
267fn 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        // 1 synthesized SessionStarted header + 3 copied events.
367        assert_eq!(events.len(), 4);
368    }
369
370    /// Issue #6360, S-new-3 (critic rev3): fork must not launder a tampered parent's history into
371    /// a "fresh, trusted" child. `ForkEngine::fork` reads the parent via `SessionEventLog::read_all`
372    /// (chain-verified) and separately validates the cut point via `ReplayEngine::replay`
373    /// (also chain-verified) — either one must reject a tampered parent before any event is
374    /// copied into the child log.
375    #[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        // The child directory must not exist as a fresh, trusted session — fork failed before
410        // any laundering could occur.
411        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    /// Regression test (#5868): `ForkEngine::fork`'s `owner` argument must reach the child
435    /// row's `owner_key` column end-to-end (through `record_fork`), not just at the
436    /// `SessionStore::record_fork` unit level.
437    #[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        // seed_parent appends 4 events total.
522        assert_eq!(result.events_copied, 4);
523    }
524
525    /// Regression test for #5982 (spec §7.2 step 6): a blob referenced by a copied
526    /// `UserMessage.image_refs` must be hard-linked into the child's `blobs/` directory.
527    #[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    /// Regression test for #5982: a referenced blob missing on the parent's disk must not fail
567    /// the fork — it is logged and skipped, since the event-log copy (the fork's primary
568    /// content) already succeeded.
569    #[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    /// Regression test for #5982: when no copied event references a blob, `fork` must not
601    /// create an empty `blobs/` directory in the child (keeps the eager-copy path a no-op for
602    /// the common, image-free case).
603    #[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    /// Regression test for the critic's S3 finding: a malicious `image_refs` entry containing a
619    /// path-traversal sequence must be rejected before it reaches `PathBuf::join`, not silently
620    /// joined (which would let the parent-side `hard_link` read an arbitrary file, or the
621    /// child-side path escape `blobs/`).
622    #[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        // No child directory content should have been created by the rejected fork attempt.
650        let child_dir = crate::session_dir(data_dir.path(), "child");
651        assert!(!child_dir.join("blobs").exists());
652    }
653
654    /// Regression test for the critic's S3 finding: an absolute-path `image_refs` entry must
655    /// also be rejected — `PathBuf::join` with an absolute path silently discards the base
656    /// directory entirely, which is the most severe form of this traversal.
657    #[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    /// Regression test for the critic's M3 finding: the same hash referenced twice in the
686    /// copied range must not trigger the cross-device copy fallback on the second occurrence —
687    /// the hash list is deduped before any `hard_link` is attempted.
688    #[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    /// Regression test for #6153: re-running `copy_referenced_blobs` against the SAME
738    /// `child_dir` (e.g. a retried fork against the same `new_id`) must not corrupt the
739    /// shared blob. Before the fix, the second `hard_link` attempt returned `AlreadyExists`,
740    /// which fell into the generic `Err(_) => fs::copy` fallback arm; `fs::copy` onto a
741    /// destination that is already a hard link to the source truncates the shared inode to 0
742    /// bytes, corrupting every link to it — including the parent's original blob.
743    #[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        // First run: hard-links the blob into the child.
770        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        // Second run against the SAME child_dir — this is what previously triggered
781        // AlreadyExists -> fs::copy -> truncation.
782        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    /// Regression test for the critic's M1 finding: the child's `blobs/` directory must get the
799    /// same `0o700` permission the crate already enforces on the sibling session directory.
800    #[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}