zeph-session 0.22.1

Conversation-session persistence: append-only JSONL event log, replay, and fork engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

//! [`ForkEngine`]: eager-copy session forking (spec §7).
//!
//! Copy-on-write forking is explicitly deferred (spec §7.2, §15 NEVER) — eager copy is simple and
//! self-contained for MVP, and robust to either side independently condensing the shared prefix
//! afterward (the child log is fully self-contained; `forked_at_seq` is historical metadata only).

use std::path::Path;

use tokio::fs;

use crate::error::SessionError;
use crate::event::{SessionEvent, SessionEventEnvelope};
use crate::log::SessionEventLog;
use crate::replay::ReplayEngine;
use crate::store::SessionStore;

/// Name of the per-session directory holding content-hash-addressed blob files (spec §4.1).
const BLOBS_DIR_NAME: &str = "blobs";

/// The result of a successful fork.
#[derive(Debug, Clone)]
pub struct ForkResult {
    /// The newly allocated child session id.
    pub new_session_id: String,
    /// Number of events copied from the parent's log (excludes the child's own `SessionStarted`
    /// header, which is synthesized fresh).
    pub events_copied: usize,
}

/// Forks a session at a given `seq`, producing a new, fully self-contained child session.
pub struct ForkEngine;

impl ForkEngine {
    /// Fork `src_id` at `at_seq` into a caller-allocated `new_id` (`at_seq` is an exclusive upper
    /// bound — matches [`ReplayEngine::replay`]'s `up_to` semantics: the child receives events
    /// `[0, at_seq)` from the parent, plus a synthetic `SessionStarted` header recording
    /// `forked_from`). `at_seq = None` forks at the current end of the log (copies everything) —
    /// the default for callers with no explicit cut point (ACP's `fork_session`, which has no
    /// `seq` parameter, and the CLI's optional `--at`).
    ///
    /// `new_id` is caller-supplied rather than minted internally: callers such as ACP's
    /// `do_fork_session` need the id before the fork call completes (to construct the session's
    /// `LoopbackChannel`/entry), and the CLI mints a fresh `SessionId::generate()` before calling
    /// in.
    ///
    /// `owner` stamps the child row's `owner_key` (#5868) — see [`SessionStore::record_fork`].
    ///
    /// # Errors
    ///
    /// Returns [`SessionError::NotFound`] if `src_id` has no session-store row,
    /// [`SessionError::InvalidForkPoint`] if `at_seq` exceeds the parent log's event count, or
    /// [`SessionError::Io`]/[`SessionError::Db`] if the copy or store update fails.
    #[tracing::instrument(name = "session.fork.run", skip_all, level = "info", fields(at_seq))]
    pub async fn fork(
        data_dir: &Path,
        src_id: &str,
        new_id: &str,
        at_seq: Option<u64>,
        store: &SessionStore,
        owner: Option<&str>,
    ) -> Result<ForkResult, SessionError> {
        if store.get(src_id).await?.is_none() {
            return Err(SessionError::NotFound(src_id.to_owned()));
        }

        let src_dir = crate::session_dir(data_dir, src_id);
        let src_log = SessionEventLog::open(&src_dir).await?;
        let all_events = src_log.read_all().await?;

        let total = u64::try_from(all_events.len()).unwrap_or(u64::MAX);
        let at_seq = at_seq.unwrap_or(total);
        if at_seq > total {
            return Err(SessionError::InvalidForkPoint(format!(
                "at_seq={at_seq} exceeds source session's event count={total}"
            )));
        }

        // Validate the cut point is internally consistent (spec §7.2 step 2) — replay must not
        // error. The reconstructed state itself is not needed further here.
        ReplayEngine::replay(&src_dir, Some(at_seq)).await?;

        let take_n = usize::try_from(at_seq).unwrap_or(usize::MAX);
        let to_copy: Vec<_> = all_events.iter().take(take_n).cloned().collect();
        let (cwd, provider_name, model) = to_copy
            .iter()
            .find_map(|e| match &e.kind {
                SessionEvent::SessionStarted {
                    cwd,
                    provider_name,
                    model,
                    ..
                } => Some((cwd.clone(), provider_name.clone(), model.clone())),
                _ => None,
            })
            .unwrap_or_default();

        let child_dir = crate::session_dir(data_dir, new_id);
        let child_log = SessionEventLog::open(&child_dir).await?;

        child_log
            .append(
                None,
                None,
                SessionEvent::SessionStarted {
                    session_id: new_id.to_owned(),
                    cwd,
                    provider_name,
                    model,
                    forked_from: Some((src_id.to_owned(), at_seq)),
                },
            )
            .await?;
        for envelope in &to_copy {
            child_log
                .append(envelope.turn_id, envelope.parent_seq, envelope.kind.clone())
                .await?;
        }

        copy_referenced_blobs(&src_dir, &child_dir, &to_copy).await?;

        store.record_fork(new_id, src_id, at_seq, owner).await?;
        store
            .update_seq(
                new_id,
                child_log.last_seq().unwrap_or(0),
                to_copy.len() as u64 + 1,
            )
            .await?;

        // Non-destructive provenance record on the parent (spec §7.2 step 8).
        src_log
            .append(
                None,
                None,
                SessionEvent::ForkPoint {
                    new_session_id: new_id.to_owned(),
                },
            )
            .await?;

        Ok(ForkResult {
            new_session_id: new_id.to_owned(),
            events_copied: to_copy.len(),
        })
    }
}

/// Copy the `blobs/` files referenced by `UserMessage.image_refs` in `events` from the parent's
/// session directory into the child's (spec §7.2 step 6). Hard-links each blob (cheap, same
/// filesystem — content-hash-addressed blobs are immutable so sharing the inode is safe); falls
/// back to a full copy if the hard-link fails (e.g. `src_dir`/`child_dir` are on different
/// filesystems/devices).
///
/// A referenced blob missing on disk is logged and skipped rather than treated as a hard
/// error: the event-log copy (the fork's primary content) already succeeded by this point, and
/// a missing blob only means the child loses one attachment rather than the whole conversation
/// history — consistent with [`crate::log`]'s own torn-tail handling, which prefers a
/// best-effort recovery over failing the whole read.
///
/// # Write-once contract
///
/// Hard-linking is only safe if blobs are content-addressed and never mutated in place after
/// being written. No blob writer exists yet anywhere in this codebase to enforce that; when one
/// lands, it MUST use append-by-new-hash semantics (never overwrite an existing hash's file) or
/// this fork's hard-link would let a later parent-side mutation silently corrupt the child's
/// copy through the shared inode.
///
/// # Errors
///
/// Returns [`SessionError::InvalidBlobHash`] if any `image_refs` entry is not a non-empty,
/// bare hex string (rejected before use in [`Path::join`] to prevent path traversal), or
/// [`SessionError::Io`] if directory creation, the hard-link, or the copy fallback fails.
///
/// A destination that already exists (e.g. a retried fork against the same `child_dir`) is not
/// an error: blobs are content-addressed by hash, so a pre-existing entry at the hash-named path
/// is treated as already the same content and the link is skipped as a no-op. This assumes the
/// pre-existing file is intact; see the `TODO` on the `AlreadyExists` match arm below for the one
/// known gap (an interrupted cross-device copy from a prior run).
async fn copy_referenced_blobs(
    src_dir: &Path,
    child_dir: &Path,
    events: &[SessionEventEnvelope],
) -> Result<(), SessionError> {
    let mut hashes: Vec<&str> = Vec::new();
    for envelope in events {
        let SessionEvent::UserMessage { image_refs, .. } = &envelope.kind else {
            continue;
        };
        for hash in image_refs {
            validate_blob_hash(hash)?;
            hashes.push(hash.as_str());
        }
    }

    if hashes.is_empty() {
        return Ok(());
    }

    // Dedup: the same hash can legitimately appear twice (repeated attachment, or reused across
    // messages) — without this, the second `hard_link` on an already-linked destination returns
    // `AlreadyExists`, which the loop below already handles as a no-op. Dedup here is a
    // micro-optimization to skip that redundant syscall+no-op, not a guard against the copy
    // fallback (which `AlreadyExists` never reaches).
    hashes.sort_unstable();
    hashes.dedup();

    let src_blobs = src_dir.join(BLOBS_DIR_NAME);
    let child_blobs = child_dir.join(BLOBS_DIR_NAME);
    fs::create_dir_all(&child_blobs).await?;
    crate::log::set_permissions(&child_blobs, 0o700).await?;

    for hash in hashes {
        let src_blob = src_blobs.join(hash);
        let child_blob = child_blobs.join(hash);

        match fs::hard_link(&src_blob, &child_blob).await {
            Ok(()) => {}
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                tracing::warn!(
                    blob = hash,
                    path = %src_blob.display(),
                    "fork: referenced blob missing on parent's disk, skipping"
                );
            }
            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
                // Destination already exists — most likely a retried fork against the same
                // child_dir. Blobs are content-addressed by hash (see `validate_blob_hash` and
                // the module doc), so a pre-existing entry at this hash-named path is assumed to
                // already be the right content. Treat as a no-op: NOT the generic fallback below,
                // since `fs::copy` onto an existing hard-link truncates the shared inode to 0
                // bytes, corrupting every link to it (including the parent's original blob).
                //
                // TODO(critic): the copy fallback below writes directly to `child_blob` rather
                // than a `.tmp` path + rename, so it is not atomic. If a prior run's fallback
                // (triggered by genuine EXDEV) was interrupted mid-write, it can leave a
                // truncated file at this path; this no-op would then silently accept that
                // truncated file as "already correct" on retry. No concurrent-retry call site
                // exists yet, so this is a documented known gap rather than a fix — an atomic
                // write (temp file + rename) would close it if/when retries become concurrent.
                tracing::debug!(
                    blob = hash,
                    path = %child_blob.display(),
                    "fork: blob already linked in child, skipping"
                );
            }
            Err(_) => {
                // Hard-link failed for a reason other than a missing source or an already-linked
                // destination (e.g. cross-device link, EXDEV) — fall back to a full copy. Reached
                // only when the destination does not exist (dest-exists implies AlreadyExists on
                // all target platforms), so writing directly to `child_blob` here is safe.
                fs::copy(&src_blob, &child_blob).await?;
            }
        }
    }

    Ok(())
}

/// Rejects any `image_refs` hash that is not a non-empty, bare hex string, before it is used in
/// a [`Path::join`] (#5982 follow-up). Content hashes elsewhere in this codebase are BLAKE3 hex
/// (64 lowercase chars, `zeph_common::hash::blake3_hex`), but no length is enforced here since
/// no blob writer exists yet to fix the format — a bare hexdigit charset already rules out `/`,
/// `..`, and absolute paths, which is what makes `join` safe.
fn validate_blob_hash(hash: &str) -> Result<(), SessionError> {
    if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
        return Err(SessionError::InvalidBlobHash(hash.to_owned()));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::store::SessionStore;

    async fn make_pool() -> zeph_db::DbPool {
        let config = zeph_db::DbConfig {
            url: ":memory:".to_owned(),
            ..Default::default()
        };
        let pool = config
            .connect()
            .await
            .expect("connect in-memory sqlite pool");
        zeph_db::run_migrations(&pool)
            .await
            .expect("run migrations");
        pool
    }

    async fn seed_parent(data_dir: &Path, store: &SessionStore, id: &str) {
        store.create(id).await.unwrap();
        let dir = crate::session_dir(data_dir, id);
        let log = SessionEventLog::open(&dir).await.unwrap();
        log.append(
            None,
            None,
            SessionEvent::SessionStarted {
                session_id: id.to_owned(),
                cwd: "/repo".to_owned(),
                provider_name: "claude".to_owned(),
                model: "opus".to_owned(),
                forked_from: None,
            },
        )
        .await
        .unwrap();
        log.append(
            None,
            None,
            SessionEvent::UserMessage {
                text: "hello".to_owned(),
                image_refs: vec![],
            },
        )
        .await
        .unwrap();
        log.append(
            None,
            None,
            SessionEvent::AssistantMessage {
                parts: vec![zeph_llm::provider::MessagePart::Text {
                    text: "hi".to_owned(),
                }],
            },
        )
        .await
        .unwrap();
        log.append(
            None,
            None,
            SessionEvent::UserMessage {
                text: "second turn".to_owned(),
                image_refs: vec![],
            },
        )
        .await
        .unwrap();
        store
            .update_seq(id, log.last_seq().unwrap(), 4)
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn test_fork_copies_events() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(3), &store, None)
            .await
            .unwrap();
        assert_eq!(result.events_copied, 3);
        assert_eq!(result.new_session_id, "child");

        let child_dir = crate::session_dir(data_dir.path(), &result.new_session_id);
        let child_log = SessionEventLog::open(&child_dir).await.unwrap();
        let events = child_log.read_all().await.unwrap();
        // 1 synthesized SessionStarted header + 3 copied events.
        assert_eq!(events.len(), 4);
    }

    #[tokio::test]
    async fn test_fork_provenance_metadata() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        ForkEngine::fork(data_dir.path(), "parent", "child", Some(2), &store, None)
            .await
            .unwrap();

        let meta = store.get("child").await.unwrap().unwrap();
        assert_eq!(meta.forked_from.as_deref(), Some("parent"));
        assert_eq!(meta.forked_at_seq, Some(2));
    }

    /// Regression test (#5868): `ForkEngine::fork`'s `owner` argument must reach the child
    /// row's `owner_key` column end-to-end (through `record_fork`), not just at the
    /// `SessionStore::record_fork` unit level.
    #[tokio::test]
    async fn fork_propagates_owner_to_child_row() {
        let pool = make_pool().await;
        let store = SessionStore::new(pool.clone());
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        ForkEngine::fork(
            data_dir.path(),
            "parent",
            "child",
            Some(2),
            &store,
            Some("alice"),
        )
        .await
        .unwrap();

        let owner_key: Option<String> = zeph_db::query_scalar(zeph_db::sql!(
            "SELECT owner_key FROM acp_sessions WHERE id = ?"
        ))
        .bind("child")
        .fetch_one(&pool)
        .await
        .unwrap();
        assert_eq!(owner_key.as_deref(), Some("alice"));
    }

    #[tokio::test]
    async fn test_fork_appends_forkpoint_to_parent() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        ForkEngine::fork(data_dir.path(), "parent", "child", Some(2), &store, None)
            .await
            .unwrap();

        let parent_dir = crate::session_dir(data_dir.path(), "parent");
        let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
        let events = parent_log.read_all().await.unwrap();
        assert!(matches!(
            events.last().unwrap().kind,
            SessionEvent::ForkPoint { .. }
        ));
    }

    #[tokio::test]
    async fn test_fork_rejects_seq_beyond_source() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(100), &store, None)
            .await
            .unwrap_err();
        assert!(matches!(err, SessionError::InvalidForkPoint(_)));
    }

    #[tokio::test]
    async fn test_fork_rejects_unknown_source() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();

        let err = ForkEngine::fork(data_dir.path(), "no-such", "child", Some(0), &store, None)
            .await
            .unwrap_err();
        assert!(matches!(err, SessionError::NotFound(_)));
    }

    #[tokio::test]
    async fn test_fork_none_copies_everything() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        let result = ForkEngine::fork(data_dir.path(), "parent", "child", None, &store, None)
            .await
            .unwrap();
        // seed_parent appends 4 events total.
        assert_eq!(result.events_copied, 4);
    }

    /// Regression test for #5982 (spec §7.2 step 6): a blob referenced by a copied
    /// `UserMessage.image_refs` must be hard-linked into the child's `blobs/` directory.
    #[tokio::test]
    async fn test_fork_copies_referenced_blobs() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        let parent_dir = crate::session_dir(data_dir.path(), "parent");
        let parent_blobs = parent_dir.join("blobs");
        tokio::fs::create_dir_all(&parent_blobs).await.unwrap();
        tokio::fs::write(parent_blobs.join("a1b2c3"), b"image-bytes")
            .await
            .unwrap();

        let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
        parent_log
            .append(
                None,
                None,
                SessionEvent::UserMessage {
                    text: "with image".to_owned(),
                    image_refs: vec!["a1b2c3".to_owned()],
                },
            )
            .await
            .unwrap();
        store.update_seq("parent", 4, 5).await.unwrap();

        let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
            .await
            .unwrap();
        assert_eq!(result.events_copied, 5);

        let child_dir = crate::session_dir(data_dir.path(), "child");
        let child_blob = child_dir.join("blobs").join("a1b2c3");
        let copied = tokio::fs::read(&child_blob).await.unwrap();
        assert_eq!(copied, b"image-bytes");
    }

    /// Regression test for #5982: a referenced blob missing on the parent's disk must not fail
    /// the fork — it is logged and skipped, since the event-log copy (the fork's primary
    /// content) already succeeded.
    #[tokio::test]
    async fn test_fork_skips_missing_blob_without_failing() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        let parent_dir = crate::session_dir(data_dir.path(), "parent");
        let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
        parent_log
            .append(
                None,
                None,
                SessionEvent::UserMessage {
                    text: "with missing image".to_owned(),
                    image_refs: vec!["deadbeef".to_owned()],
                },
            )
            .await
            .unwrap();
        store.update_seq("parent", 4, 5).await.unwrap();

        let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
            .await
            .unwrap();
        assert_eq!(result.events_copied, 5);

        let child_dir = crate::session_dir(data_dir.path(), "child");
        assert!(!child_dir.join("blobs").join("deadbeef").exists());
    }

    /// Regression test for #5982: when no copied event references a blob, `fork` must not
    /// create an empty `blobs/` directory in the child (keeps the eager-copy path a no-op for
    /// the common, image-free case).
    #[tokio::test]
    async fn test_fork_without_image_refs_creates_no_blobs_dir() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        ForkEngine::fork(data_dir.path(), "parent", "child", Some(2), &store, None)
            .await
            .unwrap();

        let child_dir = crate::session_dir(data_dir.path(), "child");
        assert!(!child_dir.join("blobs").exists());
    }

    /// Regression test for the critic's S3 finding: a malicious `image_refs` entry containing a
    /// path-traversal sequence must be rejected before it reaches `PathBuf::join`, not silently
    /// joined (which would let the parent-side `hard_link` read an arbitrary file, or the
    /// child-side path escape `blobs/`).
    #[tokio::test]
    async fn test_fork_rejects_path_traversal_in_image_refs() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        let parent_dir = crate::session_dir(data_dir.path(), "parent");
        let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
        parent_log
            .append(
                None,
                None,
                SessionEvent::UserMessage {
                    text: "malicious ref".to_owned(),
                    image_refs: vec!["../../../etc/passwd".to_owned()],
                },
            )
            .await
            .unwrap();
        store.update_seq("parent", 4, 5).await.unwrap();

        let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
            .await
            .unwrap_err();
        assert!(matches!(err, SessionError::InvalidBlobHash(_)));

        // No child directory content should have been created by the rejected fork attempt.
        let child_dir = crate::session_dir(data_dir.path(), "child");
        assert!(!child_dir.join("blobs").exists());
    }

    /// Regression test for the critic's S3 finding: an absolute-path `image_refs` entry must
    /// also be rejected — `PathBuf::join` with an absolute path silently discards the base
    /// directory entirely, which is the most severe form of this traversal.
    #[tokio::test]
    async fn test_fork_rejects_absolute_path_in_image_refs() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        let parent_dir = crate::session_dir(data_dir.path(), "parent");
        let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
        parent_log
            .append(
                None,
                None,
                SessionEvent::UserMessage {
                    text: "malicious absolute ref".to_owned(),
                    image_refs: vec!["/etc/passwd".to_owned()],
                },
            )
            .await
            .unwrap();
        store.update_seq("parent", 4, 5).await.unwrap();

        let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
            .await
            .unwrap_err();
        assert!(matches!(err, SessionError::InvalidBlobHash(_)));
    }

    /// Regression test for the critic's M3 finding: the same hash referenced twice in the
    /// copied range must not trigger the cross-device copy fallback on the second occurrence —
    /// the hash list is deduped before any `hard_link` is attempted.
    #[tokio::test]
    async fn test_fork_dedups_duplicate_blob_hash() {
        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        let parent_dir = crate::session_dir(data_dir.path(), "parent");
        let parent_blobs = parent_dir.join("blobs");
        tokio::fs::create_dir_all(&parent_blobs).await.unwrap();
        tokio::fs::write(parent_blobs.join("cafe01"), b"shared-bytes")
            .await
            .unwrap();

        let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
        parent_log
            .append(
                None,
                None,
                SessionEvent::UserMessage {
                    text: "first ref".to_owned(),
                    image_refs: vec!["cafe01".to_owned()],
                },
            )
            .await
            .unwrap();
        parent_log
            .append(
                None,
                None,
                SessionEvent::UserMessage {
                    text: "second ref, same hash".to_owned(),
                    image_refs: vec!["cafe01".to_owned()],
                },
            )
            .await
            .unwrap();
        store.update_seq("parent", 4, 6).await.unwrap();

        let result = ForkEngine::fork(data_dir.path(), "parent", "child", Some(6), &store, None)
            .await
            .unwrap();
        assert_eq!(result.events_copied, 6);

        let child_dir = crate::session_dir(data_dir.path(), "child");
        let child_blob = child_dir.join("blobs").join("cafe01");
        assert_eq!(tokio::fs::read(&child_blob).await.unwrap(), b"shared-bytes");
    }

    /// Regression test for #6153: re-running `copy_referenced_blobs` against the SAME
    /// `child_dir` (e.g. a retried fork against the same `new_id`) must not corrupt the
    /// shared blob. Before the fix, the second `hard_link` attempt returned `AlreadyExists`,
    /// which fell into the generic `Err(_) => fs::copy` fallback arm; `fs::copy` onto a
    /// destination that is already a hard link to the source truncates the shared inode to 0
    /// bytes, corrupting every link to it — including the parent's original blob.
    #[tokio::test]
    async fn test_copy_referenced_blobs_retry_does_not_truncate_shared_blob() {
        let data_dir = tempfile::tempdir().unwrap();
        let src_dir = data_dir.path().join("parent");
        let child_dir = data_dir.path().join("child");

        let src_blobs = src_dir.join("blobs");
        tokio::fs::create_dir_all(&src_blobs).await.unwrap();
        let original_content = b"image-bytes-not-empty";
        tokio::fs::write(src_blobs.join("a1b2c3"), original_content)
            .await
            .unwrap();

        let events = vec![SessionEventEnvelope {
            seq: 0,
            ts_ms: 0,
            turn_id: None,
            parent_seq: None,
            kind: SessionEvent::UserMessage {
                text: "with image".to_owned(),
                image_refs: vec!["a1b2c3".to_owned()],
            },
        }];

        // First run: hard-links the blob into the child.
        copy_referenced_blobs(&src_dir, &child_dir, &events)
            .await
            .unwrap();

        let child_blob = child_dir.join("blobs").join("a1b2c3");
        assert_eq!(
            tokio::fs::read(&child_blob).await.unwrap(),
            original_content
        );

        // Second run against the SAME child_dir — this is what previously triggered
        // AlreadyExists -> fs::copy -> truncation.
        copy_referenced_blobs(&src_dir, &child_dir, &events)
            .await
            .unwrap();

        assert_eq!(
            tokio::fs::read(&child_blob).await.unwrap(),
            original_content,
            "child blob must not be truncated by a retried fork against the same child_dir"
        );
        assert_eq!(
            tokio::fs::read(src_blobs.join("a1b2c3")).await.unwrap(),
            original_content,
            "parent's original blob must not be truncated by a retried fork against the same child_dir"
        );
    }

    /// Regression test for the critic's M1 finding: the child's `blobs/` directory must get the
    /// same `0o700` permission the crate already enforces on the sibling session directory.
    #[cfg(unix)]
    #[tokio::test]
    async fn test_fork_sets_0700_on_child_blobs_dir() {
        use std::os::unix::fs::PermissionsExt;

        let store = SessionStore::new(make_pool().await);
        let data_dir = tempfile::tempdir().unwrap();
        seed_parent(data_dir.path(), &store, "parent").await;

        let parent_dir = crate::session_dir(data_dir.path(), "parent");
        let parent_blobs = parent_dir.join("blobs");
        tokio::fs::create_dir_all(&parent_blobs).await.unwrap();
        tokio::fs::write(parent_blobs.join("a1b2c3"), b"image-bytes")
            .await
            .unwrap();

        let parent_log = SessionEventLog::open(&parent_dir).await.unwrap();
        parent_log
            .append(
                None,
                None,
                SessionEvent::UserMessage {
                    text: "with image".to_owned(),
                    image_refs: vec!["a1b2c3".to_owned()],
                },
            )
            .await
            .unwrap();
        store.update_seq("parent", 4, 5).await.unwrap();

        ForkEngine::fork(data_dir.path(), "parent", "child", Some(5), &store, None)
            .await
            .unwrap();

        let child_dir = crate::session_dir(data_dir.path(), "child");
        let meta = tokio::fs::metadata(child_dir.join("blobs")).await.unwrap();
        assert_eq!(meta.permissions().mode() & 0o777, 0o700);
    }
}