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;
const BLOBS_DIR_NAME: &str = "blobs";
#[derive(Debug, Clone)]
pub struct ForkResult {
pub new_session_id: String,
pub events_copied: usize,
}
pub struct ForkEngine;
impl ForkEngine {
#[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}"
)));
}
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?;
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(),
})
}
}
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(());
}
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 => {
tracing::debug!(
blob = hash,
path = %child_blob.display(),
"fork: blob already linked in child, skipping"
);
}
Err(_) => {
fs::copy(&src_blob, &child_blob).await?;
}
}
}
Ok(())
}
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 std::sync::Arc;
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]
#[serial_test::serial(session_history_integrity)]
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();
assert_eq!(events.len(), 4);
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
async fn test_fork_rejects_a_tampered_parent_chain() {
let _guard = crate::log::IntegrityConfigGuard::new();
let ring = Arc::new(zeph_common::hash_chain::ChainKeyRing::new(
0,
zeph_common::hash_chain::ChainKey::new([77u8; 32]),
));
crate::log::configure_history_integrity(Some(ring));
let store = SessionStore::new(make_pool().await);
let data_dir = tempfile::tempdir().unwrap();
seed_parent(data_dir.path(), &store, "parent").await;
let events_path = crate::session_dir(data_dir.path(), "parent").join("events.jsonl");
let raw = std::fs::read_to_string(&events_path).unwrap();
let mut lines: Vec<&str> = raw.lines().collect();
assert!(
lines.len() >= 2,
"fixture must have a non-first line to tamper"
);
let tampered = lines[1].replace("hello", "forged-approval");
lines[1] = &tampered;
std::fs::write(&events_path, lines.join("\n") + "\n").unwrap();
let err = ForkEngine::fork(data_dir.path(), "parent", "child", Some(3), &store, None)
.await
.unwrap_err();
assert!(
matches!(err, SessionError::Integrity(_)),
"tampering the parent's chain must abort the fork with an Integrity error, not \
silently produce a child; got {err:?}"
);
let child_dir = crate::session_dir(data_dir.path(), "child");
assert!(
!child_dir.join("events.jsonl").exists(),
"a rejected fork must not leave behind a partially-written child log"
);
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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));
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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]
#[serial_test::serial(session_history_integrity)]
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]
#[serial_test::serial(session_history_integrity)]
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]
#[serial_test::serial(session_history_integrity)]
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]
#[serial_test::serial(session_history_integrity)]
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();
assert_eq!(result.events_copied, 4);
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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");
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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());
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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());
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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(_)));
let child_dir = crate::session_dir(data_dir.path(), "child");
assert!(!child_dir.join("blobs").exists());
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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(_)));
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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");
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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()],
},
chain: None,
}];
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
);
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"
);
}
#[cfg(unix)]
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
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);
}
}