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    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        // 1 synthesized SessionStarted header + 3 copied events.
366        assert_eq!(events.len(), 4);
367    }
368
369    /// Issue #6360, S-new-3 (critic rev3): fork must not launder a tampered parent's history into
370    /// a "fresh, trusted" child. `ForkEngine::fork` reads the parent via `SessionEventLog::read_all`
371    /// (chain-verified) and separately validates the cut point via `ReplayEngine::replay`
372    /// (also chain-verified) — either one must reject a tampered parent before any event is
373    /// copied into the child log.
374    #[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        // The child directory must not exist as a fresh, trusted session — fork failed before
407        // any laundering could occur.
408        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    /// Regression test (#5868): `ForkEngine::fork`'s `owner` argument must reach the child
433    /// row's `owner_key` column end-to-end (through `record_fork`), not just at the
434    /// `SessionStore::record_fork` unit level.
435    #[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        // seed_parent appends 4 events total.
515        assert_eq!(result.events_copied, 4);
516    }
517
518    /// Regression test for #5982 (spec §7.2 step 6): a blob referenced by a copied
519    /// `UserMessage.image_refs` must be hard-linked into the child's `blobs/` directory.
520    #[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    /// Regression test for #5982: a referenced blob missing on the parent's disk must not fail
559    /// the fork — it is logged and skipped, since the event-log copy (the fork's primary
560    /// content) already succeeded.
561    #[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    /// Regression test for #5982: when no copied event references a blob, `fork` must not
592    /// create an empty `blobs/` directory in the child (keeps the eager-copy path a no-op for
593    /// the common, image-free case).
594    #[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    /// Regression test for the critic's S3 finding: a malicious `image_refs` entry containing a
609    /// path-traversal sequence must be rejected before it reaches `PathBuf::join`, not silently
610    /// joined (which would let the parent-side `hard_link` read an arbitrary file, or the
611    /// child-side path escape `blobs/`).
612    #[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        // No child directory content should have been created by the rejected fork attempt.
639        let child_dir = crate::session_dir(data_dir.path(), "child");
640        assert!(!child_dir.join("blobs").exists());
641    }
642
643    /// Regression test for the critic's S3 finding: an absolute-path `image_refs` entry must
644    /// also be rejected — `PathBuf::join` with an absolute path silently discards the base
645    /// directory entirely, which is the most severe form of this traversal.
646    #[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    /// Regression test for the critic's M3 finding: the same hash referenced twice in the
674    /// copied range must not trigger the cross-device copy fallback on the second occurrence —
675    /// the hash list is deduped before any `hard_link` is attempted.
676    #[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    /// Regression test for #6153: re-running `copy_referenced_blobs` against the SAME
725    /// `child_dir` (e.g. a retried fork against the same `new_id`) must not corrupt the
726    /// shared blob. Before the fix, the second `hard_link` attempt returned `AlreadyExists`,
727    /// which fell into the generic `Err(_) => fs::copy` fallback arm; `fs::copy` onto a
728    /// destination that is already a hard link to the source truncates the shared inode to 0
729    /// bytes, corrupting every link to it — including the parent's original blob.
730    #[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        // First run: hard-links the blob into the child.
756        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        // Second run against the SAME child_dir — this is what previously triggered
767        // AlreadyExists -> fs::copy -> truncation.
768        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    /// Regression test for the critic's M1 finding: the child's `blobs/` directory must get the
785    /// same `0o700` permission the crate already enforces on the sibling session directory.
786    #[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}