use crate::{
config::McPaths,
output::redact_sensitive_text,
persistence::{CrossProcessFileLock, atomic_write},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
fs,
io::{ErrorKind, Read},
path::{Component, Path, PathBuf},
process::Command,
};
pub(crate) const CHECKPOINT_SCHEMA_VERSION: u64 = 1;
pub(crate) const MAX_SNAPSHOT_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CheckpointStore {
root: PathBuf,
}
impl CheckpointStore {
pub(crate) fn new(root: PathBuf) -> Self {
Self { root }
}
pub(crate) fn from_paths(paths: &McPaths) -> Self {
Self::new(paths.checkpoints.clone())
}
fn blobs_dir(&self) -> PathBuf {
self.root.join("blobs")
}
fn ledgers_dir(&self) -> PathBuf {
self.root.join("ledgers")
}
fn ledger_path(&self, session_id: &str) -> PathBuf {
self.ledgers_dir().join(format!("{session_id}.jsonl"))
}
fn blob_path(&self, sha256: &str) -> PathBuf {
self.blobs_dir().join(sha256)
}
pub(crate) fn write_blob(&self, bytes: &[u8]) -> anyhow::Result<SnapshotBlobRef> {
let sha256 = sha256_hex(bytes);
let path = self.blob_path(&sha256);
if path.exists() {
return Ok(SnapshotBlobRef {
sha256,
bytes: bytes.len() as u64,
});
}
atomic_write(&path, bytes)?;
Ok(SnapshotBlobRef {
sha256,
bytes: bytes.len() as u64,
})
}
fn read_blob(&self, blob: &SnapshotBlobRef) -> anyhow::Result<Vec<u8>> {
let bytes = fs::read(self.blob_path(&blob.sha256))?;
if sha256_hex(&bytes) != blob.sha256 {
anyhow::bail!("checkpoint blob hash mismatch");
}
Ok(bytes)
}
pub(crate) fn append_record(&self, record: &SnapshotLedgerRecord) -> anyhow::Result<()> {
let ledger = self.ledger_path(&record.event.session_id);
if let Some(parent) = ledger.parent() {
fs::create_dir_all(parent)?;
}
let _lock = CrossProcessFileLock::acquire(&ledger)?;
let mut bytes = match fs::read(&ledger) {
Ok(bytes) => bytes,
Err(error) if error.kind() == ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
};
if !bytes.is_empty() && !bytes.ends_with(b"\n") {
bytes.push(b'\n');
}
bytes.extend_from_slice(&serde_json::to_vec(record)?);
bytes.push(b'\n');
atomic_write(&ledger, &bytes)
}
pub(crate) fn read_records(&self, session_id: &str) -> LedgerRead {
let ledger = self.ledger_path(session_id);
let mut read = LedgerRead::default();
let text = match fs::read_to_string(&ledger) {
Ok(text) => text,
Err(error) if error.kind() == ErrorKind::NotFound => return read,
Err(error) => {
read.diagnostics.push(sanitized_diagnostic(format!(
"checkpoint ledger read failed: {error}"
)));
return read;
}
};
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<SnapshotLedgerRecord>(line) {
Ok(record) if record.schema_version == CHECKPOINT_SCHEMA_VERSION => {
read.records.push(record);
}
Ok(_) => read.diagnostics.push(format!(
"ignored checkpoint ledger line {}: unsupported_schema_version",
index + 1
)),
Err(_) => read.diagnostics.push(format!(
"ignored checkpoint ledger line {}: malformed_json",
index + 1
)),
}
}
read
}
pub(crate) fn changes(&self, session_id: &str) -> ChangesReadModel {
changes_from_records(self, self.read_records(session_id))
}
pub(crate) fn plan_rewind(
&self,
session_id: &str,
cwd: &Path,
target_turn: u64,
) -> RestorePlan {
plan_rewind(self, session_id, cwd, target_turn)
}
pub(crate) fn execute_plan(&self, plan: &RestorePlan, cwd: &Path) -> RewindExecution {
execute_plan(self, plan, cwd)
}
pub(crate) fn prune_session(&self, session_id: &str) -> anyhow::Result<()> {
let ledger = self.ledger_path(session_id);
if !ledger.exists() {
return Ok(());
}
fs::remove_file(&ledger)?;
self.prune_unreferenced_blobs()
}
fn prune_unreferenced_blobs(&self) -> anyhow::Result<()> {
let mut referenced = std::collections::BTreeSet::new();
let ledgers = self.ledgers_dir();
if ledgers.exists() {
for entry in fs::read_dir(&ledgers)? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let Some(session_id) = entry
.path()
.file_stem()
.and_then(|stem| stem.to_str())
.map(str::to_string)
else {
continue;
};
for record in self.read_records(&session_id).records {
if let Some(blob) = record.event.pre {
referenced.insert(blob.sha256);
}
if let Some(blob) = record.event.post {
referenced.insert(blob.sha256);
}
}
}
}
let blobs = self.blobs_dir();
if !blobs.exists() {
return Ok(());
}
for entry in fs::read_dir(&blobs)? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if !referenced.contains(&name) {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct SnapshotBlobRef {
pub(crate) sha256: String,
pub(crate) bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct FileSnapshotEvent {
pub(crate) session_id: String,
pub(crate) user_turn: u64,
pub(crate) tool: SnapshotTool,
pub(crate) cwd: PathBuf,
pub(crate) relative_path: PathBuf,
pub(crate) pre: Option<SnapshotBlobRef>,
pub(crate) post: Option<SnapshotBlobRef>,
pub(crate) status: SnapshotCaptureStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct SnapshotLedgerRecord {
pub(crate) schema_version: u64,
pub(crate) event: FileSnapshotEvent,
}
impl SnapshotLedgerRecord {
pub(crate) fn new(event: FileSnapshotEvent) -> Self {
Self {
schema_version: CHECKPOINT_SCHEMA_VERSION,
event,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SnapshotTool {
WriteFile,
Edit,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SnapshotCaptureStatus {
Captured,
Excluded { reason: SnapshotExclusionReason },
Oversized { bytes: u64, max_bytes: u64 },
Unavailable { reason: String },
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SnapshotExclusionReason {
PathEscape,
SecretPath,
CheckpointStorage,
SessionStorage,
GitStorage,
NotFile,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SnapshotEligibility {
Eligible,
Excluded(SnapshotExclusionReason),
Oversized { bytes: u64, max_bytes: u64 },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SnapshotContext {
pub(crate) store: CheckpointStore,
pub(crate) paths: McPaths,
pub(crate) session_id: String,
pub(crate) user_turn: u64,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct LedgerRead {
pub(crate) records: Vec<SnapshotLedgerRecord>,
pub(crate) diagnostics: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChangesReadModel {
pub(crate) turns: Vec<ChangeTurn>,
pub(crate) diagnostics: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChangeTurn {
pub(crate) user_turn: u64,
pub(crate) changes: Vec<ChangeEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChangeEntry {
pub(crate) tool: SnapshotTool,
pub(crate) relative_path: PathBuf,
pub(crate) classification: ChangeClassification,
pub(crate) rewindable: bool,
pub(crate) unavailable_reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ChangeClassification {
Created,
Modified,
Deleted,
NonRewindable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RestorePlan {
pub(crate) session_id: String,
pub(crate) target_turn: u64,
pub(crate) latest_turn: Option<u64>,
pub(crate) operations: Vec<RestoreOperation>,
pub(crate) diagnostics: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RestoreOperation {
pub(crate) relative_path: PathBuf,
pub(crate) kind: RestoreOperationKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RestoreOperationKind {
Restore {
pre: SnapshotBlobRef,
expected_current: SnapshotBlobRef,
},
DeleteCreated {
expected_current: SnapshotBlobRef,
},
ResurrectDeleted {
pre: SnapshotBlobRef,
},
SkipConflict {
reason: String,
},
SkipUnavailable {
reason: String,
},
}
impl RestoreOperationKind {
pub(crate) fn label(&self) -> &'static str {
match self {
Self::Restore { .. } => "restore",
Self::DeleteCreated { .. } => "delete-created",
Self::ResurrectDeleted { .. } => "resurrect-deleted",
Self::SkipConflict { .. } => "skip-conflict",
Self::SkipUnavailable { .. } => "skip-unavailable",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RewindExecution {
pub(crate) results: Vec<RestoreOperationResult>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RestoreOperationResult {
pub(crate) relative_path: PathBuf,
pub(crate) status: RestoreStatus,
pub(crate) reason: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RestoreStatus {
Restored,
DeletedCreated,
ResurrectedDeleted,
SkipConflict,
SkipUnavailable,
}
const REDACTED_PATH: &str = "[redacted]";
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
crate::hex::lower_hex(digest)
}
pub(crate) fn classify_snapshot_eligibility(
path: &Path,
cwd_canonical: &Path,
paths: &McPaths,
) -> SnapshotEligibility {
if !path.starts_with(cwd_canonical) {
return SnapshotEligibility::Excluded(SnapshotExclusionReason::PathEscape);
}
if path.starts_with(&paths.checkpoints) {
return SnapshotEligibility::Excluded(SnapshotExclusionReason::CheckpointStorage);
}
if path.starts_with(&paths.sessions) {
return SnapshotEligibility::Excluded(SnapshotExclusionReason::SessionStorage);
}
if path == paths.auth_file
|| path == paths.settings_file
|| path == paths.project_settings_file
|| paths.local_settings_file.as_deref() == Some(path)
|| secret_like_path(path)
{
return SnapshotEligibility::Excluded(SnapshotExclusionReason::SecretPath);
}
if path
.components()
.any(|component| component.as_os_str() == ".git")
{
return SnapshotEligibility::Excluded(SnapshotExclusionReason::GitStorage);
}
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == ErrorKind::NotFound => return SnapshotEligibility::Eligible,
Err(_) => return SnapshotEligibility::Excluded(SnapshotExclusionReason::NotFile),
};
if !metadata.is_file() {
return SnapshotEligibility::Excluded(SnapshotExclusionReason::NotFile);
}
if metadata.len() > MAX_SNAPSHOT_BYTES {
return SnapshotEligibility::Oversized {
bytes: metadata.len(),
max_bytes: MAX_SNAPSHOT_BYTES,
};
}
SnapshotEligibility::Eligible
}
pub(crate) fn capture_file_snapshot(
context: &SnapshotContext,
cwd_canonical: &Path,
tool: SnapshotTool,
path: &Path,
pre_bytes: Option<&[u8]>,
post_bytes: Option<&[u8]>,
) -> anyhow::Result<()> {
let Ok(relative_path) = sanitize_relative_path(path, cwd_canonical) else {
return Ok(());
};
let event = match classify_snapshot_eligibility(path, cwd_canonical, &context.paths) {
SnapshotEligibility::Eligible => FileSnapshotEvent {
session_id: context.session_id.clone(),
user_turn: context.user_turn,
tool,
cwd: cwd_canonical.to_path_buf(),
relative_path,
pre: pre_bytes
.map(|bytes| context.store.write_blob(bytes))
.transpose()?,
post: post_bytes
.map(|bytes| context.store.write_blob(bytes))
.transpose()?,
status: SnapshotCaptureStatus::Captured,
},
SnapshotEligibility::Excluded(reason) => FileSnapshotEvent {
session_id: context.session_id.clone(),
user_turn: context.user_turn,
tool,
cwd: cwd_canonical.to_path_buf(),
relative_path,
pre: None,
post: None,
status: SnapshotCaptureStatus::Excluded { reason },
},
SnapshotEligibility::Oversized { bytes, max_bytes } => FileSnapshotEvent {
session_id: context.session_id.clone(),
user_turn: context.user_turn,
tool,
cwd: cwd_canonical.to_path_buf(),
relative_path,
pre: None,
post: None,
status: SnapshotCaptureStatus::Oversized { bytes, max_bytes },
},
};
context
.store
.append_record(&SnapshotLedgerRecord::new(event))
}
fn secret_like_path(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name == "auth.json"
|| name == "id_rsa"
|| name == "id_ed25519"
|| name.starts_with(".env")
|| name.ends_with(".pem")
|| name.ends_with(".key")
})
}
fn sanitize_relative_path(path: &Path, cwd: &Path) -> anyhow::Result<PathBuf> {
let relative = path.strip_prefix(cwd).map_err(|_| {
anyhow::anyhow!("path '{}' escapes cwd '{}'", path.display(), cwd.display())
})?;
if relative.as_os_str().is_empty()
|| relative
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
anyhow::bail!("unsafe checkpoint path");
}
Ok(relative.to_path_buf())
}
fn redacted_relative_path(reason: &SnapshotCaptureStatus, relative_path: PathBuf) -> PathBuf {
if matches!(
reason,
SnapshotCaptureStatus::Excluded {
reason: SnapshotExclusionReason::SecretPath
}
) || secret_like_path(&relative_path)
{
PathBuf::from(REDACTED_PATH)
} else {
relative_path
}
}
pub(crate) fn redacted_path_for_display(path: &Path) -> String {
safe_provider_path(&path.to_string_lossy())
}
fn safe_rewind_target_path(
store: &CheckpointStore,
cwd: &Path,
relative_path: &Path,
) -> Result<PathBuf, &'static str> {
if relative_path.is_absolute() || relative_path.as_os_str().is_empty() {
return Err("denied path");
}
if relative_path.components().any(|component| {
matches!(component, Component::ParentDir) || component.as_os_str() == ".git"
}) {
return Err("denied path");
}
if secret_like_path(relative_path) {
return Err("denied path");
}
let root = lexical_normalize(cwd);
let path = lexical_normalize(&root.join(relative_path));
if !path.starts_with(&root) || path == root {
return Err("denied path");
}
let checkpoint_root = lexical_normalize(&store.root);
if path.starts_with(&checkpoint_root) {
return Err("denied path");
}
if let Some(mc_root) = checkpoint_root.parent() {
let session_root = lexical_normalize(&mc_root.join("sessions"));
if path.starts_with(session_root) {
return Err("denied path");
}
}
Ok(path)
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
normalized.push(component.as_os_str());
}
}
}
normalized
}
fn changes_from_records(store: &CheckpointStore, read: LedgerRead) -> ChangesReadModel {
let mut by_turn: BTreeMap<u64, Vec<ChangeEntry>> = BTreeMap::new();
for record in read.records {
let event = record.event;
let visible_path = redacted_relative_path(&event.status, event.relative_path.clone());
let (classification, rewindable, unavailable_reason) = match event.status {
SnapshotCaptureStatus::Captured => {
let available = event
.pre
.as_ref()
.into_iter()
.chain(event.post.as_ref())
.all(|blob| store.blob_path(&blob.sha256).exists());
let classification = match (&event.pre, &event.post) {
(None, Some(_)) => ChangeClassification::Created,
(Some(_), Some(_)) => ChangeClassification::Modified,
(Some(_), None) => ChangeClassification::Deleted,
(None, None) => ChangeClassification::NonRewindable,
};
(
classification,
available,
(!available).then_some("missing checkpoint blob".to_string()),
)
}
SnapshotCaptureStatus::Excluded { reason } => (
ChangeClassification::NonRewindable,
false,
Some(format!("excluded: {reason:?}")),
),
SnapshotCaptureStatus::Oversized { bytes, max_bytes } => (
ChangeClassification::NonRewindable,
false,
Some(format!("oversized: {bytes} > {max_bytes}")),
),
SnapshotCaptureStatus::Unavailable { reason } => {
(ChangeClassification::NonRewindable, false, Some(reason))
}
};
by_turn
.entry(event.user_turn)
.or_default()
.push(ChangeEntry {
tool: event.tool,
relative_path: visible_path,
classification,
rewindable,
unavailable_reason,
});
}
ChangesReadModel {
turns: by_turn
.into_iter()
.map(|(user_turn, mut changes)| {
changes.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
ChangeTurn { user_turn, changes }
})
.collect(),
diagnostics: read.diagnostics,
}
}
fn plan_rewind(
store: &CheckpointStore,
session_id: &str,
cwd: &Path,
target_turn: u64,
) -> RestorePlan {
let read = store.read_records(session_id);
let latest_turn = read
.records
.iter()
.map(|record| record.event.user_turn)
.max();
let selected = read
.records
.iter()
.filter(|record| record.event.user_turn >= target_turn)
.cloned()
.collect::<Vec<_>>();
let mut by_path: BTreeMap<PathBuf, Vec<SnapshotLedgerRecord>> = BTreeMap::new();
for record in selected {
by_path
.entry(record.event.relative_path.clone())
.or_default()
.push(record);
}
let mut operations = Vec::new();
for (relative_path, mut records) in by_path {
records.sort_by_key(|record| record.event.user_turn);
let path = match safe_rewind_target_path(store, cwd, &relative_path) {
Ok(path) => path,
Err(reason) => {
operations.push(RestoreOperation {
relative_path: PathBuf::from(REDACTED_PATH),
kind: RestoreOperationKind::SkipUnavailable {
reason: reason.to_string(),
},
});
continue;
}
};
let first = &records.first().expect("group non-empty").event;
let last = &records.last().expect("group non-empty").event;
if !records
.iter()
.all(|record| record.event.status == SnapshotCaptureStatus::Captured)
{
operations.push(RestoreOperation {
relative_path,
kind: RestoreOperationKind::SkipUnavailable {
reason: "non-rewindable snapshot".to_string(),
},
});
continue;
}
let expected_current = last.post.clone();
let current_hash = match file_hash(&path) {
Ok(hash) => hash,
Err(_) => {
operations.push(RestoreOperation {
relative_path,
kind: RestoreOperationKind::SkipUnavailable {
reason: "hash check failed".to_string(),
},
});
continue;
}
};
if let Some(expected) = expected_current.as_ref() {
if current_hash.as_deref() != Some(expected.sha256.as_str()) {
operations.push(RestoreOperation {
relative_path,
kind: RestoreOperationKind::SkipConflict {
reason: "current file hash differs from checkpoint post-image".to_string(),
},
});
continue;
}
} else if path.exists() {
operations.push(RestoreOperation {
relative_path,
kind: RestoreOperationKind::SkipConflict {
reason: "expected deleted file exists".to_string(),
},
});
continue;
}
let kind = match (&first.pre, expected_current) {
(Some(pre), Some(expected_current)) => {
if !store.blob_path(&pre.sha256).exists() {
RestoreOperationKind::SkipUnavailable {
reason: "missing pre-image blob".to_string(),
}
} else {
RestoreOperationKind::Restore {
pre: pre.clone(),
expected_current,
}
}
}
(None, Some(expected_current)) => {
RestoreOperationKind::DeleteCreated { expected_current }
}
(Some(pre), None) => {
if store.blob_path(&pre.sha256).exists() {
RestoreOperationKind::ResurrectDeleted { pre: pre.clone() }
} else {
RestoreOperationKind::SkipUnavailable {
reason: "missing pre-image blob".to_string(),
}
}
}
(None, None) => RestoreOperationKind::SkipUnavailable {
reason: "missing pre and post images".to_string(),
},
};
operations.push(RestoreOperation {
relative_path,
kind,
});
}
operations.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
RestorePlan {
session_id: session_id.to_string(),
target_turn,
latest_turn,
operations,
diagnostics: read.diagnostics,
}
}
fn execute_plan(store: &CheckpointStore, plan: &RestorePlan, cwd: &Path) -> RewindExecution {
execute_plan_internal(store, plan, cwd, None)
}
#[cfg(test)]
fn execute_plan_with_failure(
store: &CheckpointStore,
plan: &RestorePlan,
cwd: &Path,
phase: &'static str,
) -> RewindExecution {
execute_plan_internal(store, plan, cwd, Some(phase))
}
fn execute_plan_internal(
store: &CheckpointStore,
plan: &RestorePlan,
cwd: &Path,
injected_failure: Option<&str>,
) -> RewindExecution {
let mut results = Vec::new();
for operation in &plan.operations {
let path = safe_rewind_target_path(store, cwd, &operation.relative_path);
let mut reason = None;
let status = match (&operation.kind, path) {
(_, Err(_)) => RestoreStatus::SkipUnavailable,
(
RestoreOperationKind::Restore {
pre,
expected_current,
},
Ok(path),
) => {
let current_hash = if injected_failure == Some("hash") {
Err(anyhow::anyhow!("injected failure"))
} else {
file_hash(&path)
};
match current_hash {
Err(_) => {
reason = Some("hash check failed".to_string());
RestoreStatus::SkipUnavailable
}
Ok(current_hash)
if current_hash.as_deref() != Some(expected_current.sha256.as_str()) =>
{
RestoreStatus::SkipConflict
}
Ok(_) => match if injected_failure == Some("blob read") {
Err(anyhow::anyhow!("injected failure"))
} else {
store.read_blob(pre)
} {
Err(_) => {
reason = Some("blob read failed".to_string());
RestoreStatus::SkipUnavailable
}
Ok(bytes) => match if injected_failure == Some("write") {
Err(anyhow::anyhow!("injected failure"))
} else {
atomic_write(&path, &bytes)
} {
Err(_) => {
reason = Some("write failed".to_string());
RestoreStatus::SkipUnavailable
}
Ok(()) => RestoreStatus::Restored,
},
},
}
}
(RestoreOperationKind::DeleteCreated { expected_current }, Ok(path)) => {
let current_hash = if injected_failure == Some("hash") {
Err(anyhow::anyhow!("injected failure"))
} else {
file_hash(&path)
};
match current_hash {
Err(_) => {
reason = Some("hash check failed".to_string());
RestoreStatus::SkipUnavailable
}
Ok(current_hash)
if current_hash.as_deref() != Some(expected_current.sha256.as_str()) =>
{
RestoreStatus::SkipConflict
}
Ok(_) => {
let result = if injected_failure == Some("trash") {
Err(anyhow::anyhow!("injected failure"))
} else {
trash_path(&path)
};
match result {
Ok(()) => RestoreStatus::DeletedCreated,
Err(_) => {
reason = Some("trash failed".to_string());
RestoreStatus::SkipUnavailable
}
}
}
}
}
(RestoreOperationKind::ResurrectDeleted { pre }, Ok(path)) => {
if path.exists() {
RestoreStatus::SkipConflict
} else {
match if injected_failure == Some("blob read") {
Err(anyhow::anyhow!("injected failure"))
} else {
store.read_blob(pre)
} {
Err(_) => {
reason = Some("blob read failed".to_string());
RestoreStatus::SkipUnavailable
}
Ok(bytes) => match if injected_failure == Some("write") {
Err(anyhow::anyhow!("injected failure"))
} else {
atomic_write(&path, &bytes)
} {
Err(_) => {
reason = Some("write failed".to_string());
RestoreStatus::SkipUnavailable
}
Ok(()) => RestoreStatus::ResurrectedDeleted,
},
}
}
}
(
RestoreOperationKind::SkipConflict {
reason: operation_reason,
},
_,
) => {
reason = Some(sanitized_diagnostic(operation_reason.clone()));
RestoreStatus::SkipConflict
}
(
RestoreOperationKind::SkipUnavailable {
reason: operation_reason,
},
_,
) => {
reason = Some(sanitized_diagnostic(operation_reason.clone()));
RestoreStatus::SkipUnavailable
}
};
results.push(RestoreOperationResult {
relative_path: operation.relative_path.clone(),
status,
reason,
});
}
RewindExecution { results }
}
fn file_hash(path: &Path) -> anyhow::Result<Option<String>> {
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(Some(sha256_hex(&bytes)))
}
fn trash_path(path: &Path) -> anyhow::Result<()> {
let status = Command::new("trash").arg(path).status()?;
if status.success() {
Ok(())
} else {
anyhow::bail!("trash command failed")
}
}
fn sanitized_diagnostic(message: String) -> String {
redact_sensitive_text(&message).chars().take(500).collect()
}
pub(crate) fn rewind_event_payload(plan: &RestorePlan, execution: &RewindExecution) -> Value {
let mut counts: BTreeMap<RestoreStatus, usize> = BTreeMap::new();
let paths = execution
.results
.iter()
.map(|result| {
*counts.entry(result.status).or_default() += 1;
let mut item = serde_json::Map::from_iter([
(
"path".to_string(),
json!(redacted_path_for_display(&result.relative_path)),
),
("status".to_string(), json!(result.status)),
]);
if let Some(reason) = &result.reason {
item.insert("reason".to_string(), json!(reason));
}
Value::Object(item)
})
.collect::<Vec<_>>();
serde_json::json!({
"target_turn": plan.target_turn,
"latest_turn": plan.latest_turn,
"paths": paths,
"counts": counts.into_iter().map(|(status, count)| (format!("{status:?}"), count)).collect::<BTreeMap<_, _>>(),
})
}
pub(crate) fn replay_rewind_context(payload: &Value) -> Option<String> {
let target = payload.get("target_turn").and_then(Value::as_u64)?;
let latest = payload
.get("latest_turn")
.and_then(Value::as_u64)
.unwrap_or(target);
let mut lines = vec![format!(
"Local filesystem rewind applied for user turns {target}..{latest}. File contents and diffs omitted."
)];
if let Some(paths) = payload.get("paths").and_then(Value::as_array) {
for item in paths.iter().take(64) {
let path = item
.get("path")
.and_then(Value::as_str)
.unwrap_or("<redacted>");
let status = item
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown");
lines.push(format!("- {}: {}", safe_provider_path(path), status));
}
}
Some(lines.join("\n"))
}
fn safe_provider_path(path: &str) -> String {
let sanitized = redact_sensitive_text(path);
if sanitized.contains("..")
|| sanitized.starts_with('/')
|| secret_like_path(Path::new(&sanitized))
{
"<redacted>".to_string()
} else {
sanitized
}
}
pub(crate) fn parse_rewind_target(arg: Option<&str>) -> Result<ParsedRewindTarget, String> {
let Some(arg) = arg.map(str::trim).filter(|arg| !arg.is_empty()) else {
return Ok(ParsedRewindTarget::NeedsSelection);
};
let mut dry_run = false;
let mut target = None;
let mut tokens = arg.split_whitespace();
while let Some(token) = tokens.next() {
match token {
"--dry-run" => dry_run = true,
"--to" => {
let Some(value) = tokens.next() else {
return Err("missing turn after --to".to_string());
};
target = Some(parse_positive_turn(value)?);
}
value if target.is_none() => target = Some(parse_positive_turn(value)?),
_ => return Err("unexpected rewind argument".to_string()),
}
}
let Some(target_turn) = target else {
return Err("missing rewind target turn".to_string());
};
Ok(ParsedRewindTarget::Target {
target_turn,
dry_run,
})
}
fn parse_positive_turn(value: &str) -> Result<u64, String> {
if value.contains("..") || value.starts_with('-') {
return Err("rewind target must be a positive turn number".to_string());
}
let turn = value
.parse::<u64>()
.map_err(|_| "rewind target must be a positive turn number".to_string())?;
if turn == 0 {
return Err("rewind target must be positive".to_string());
}
Ok(turn)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ParsedRewindTarget {
NeedsSelection,
Target { target_turn: u64, dry_run: bool },
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn paths(root: &Path) -> McPaths {
McPaths::from_root(root.join("mc"))
}
fn store(temp: &TempDir) -> CheckpointStore {
CheckpointStore::new(temp.path().join("mc/checkpoints"))
}
fn ctx(temp: &TempDir) -> SnapshotContext {
let paths = paths(temp.path());
SnapshotContext {
store: CheckpointStore::from_paths(&paths),
paths,
session_id: "session".to_string(),
user_turn: 1,
}
}
fn captured_record(
store: &CheckpointStore,
relative_path: impl Into<PathBuf>,
) -> SnapshotLedgerRecord {
SnapshotLedgerRecord::new(FileSnapshotEvent {
session_id: "session".to_string(),
user_turn: 1,
tool: SnapshotTool::Edit,
cwd: PathBuf::new(),
relative_path: relative_path.into(),
pre: Some(store.write_blob(b"before").unwrap()),
post: Some(store.write_blob(b"after").unwrap()),
status: SnapshotCaptureStatus::Captured,
})
}
fn assert_no_secret_path(text: &str) {
assert!(!text.contains("auth.json"), "leaked auth.json: {text}");
assert!(!text.contains(".env"), "leaked .env: {text}");
assert!(!text.contains("id_rsa"), "leaked id_rsa: {text}");
}
#[test]
fn blob_store_round_trips_by_hash() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
let blob = store.write_blob(b"hello").unwrap();
assert_eq!(store.read_blob(&blob).unwrap(), b"hello");
assert_eq!(blob.sha256, sha256_hex(b"hello"));
}
#[test]
fn blob_write_is_idempotent() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
let first = store.write_blob(b"same").unwrap();
let second = store.write_blob(b"same").unwrap();
assert_eq!(first, second);
}
#[test]
fn ledger_skips_malformed_lines() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
let ledger = store.ledger_path("session");
fs::create_dir_all(ledger.parent().unwrap()).unwrap();
fs::write(&ledger, "not json\n").unwrap();
let read = store.read_records("session");
assert!(read.records.is_empty());
assert_eq!(read.diagnostics.len(), 1);
}
#[test]
fn ledger_append_recovers_after_truncated_line() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
let ledger = store.ledger_path("session");
fs::create_dir_all(ledger.parent().unwrap()).unwrap();
fs::write(&ledger, "truncated").unwrap();
let event = FileSnapshotEvent {
session_id: "session".to_string(),
user_turn: 1,
tool: SnapshotTool::WriteFile,
cwd: temp.path().to_path_buf(),
relative_path: PathBuf::from("file.txt"),
pre: None,
post: None,
status: SnapshotCaptureStatus::Excluded {
reason: SnapshotExclusionReason::SecretPath,
},
};
store
.append_record(&SnapshotLedgerRecord::new(event))
.unwrap();
let read = store.read_records("session");
assert_eq!(read.records.len(), 1);
assert_eq!(read.diagnostics.len(), 1);
}
#[test]
fn snapshot_excludes_auth_and_secret_paths() {
let temp = TempDir::new().unwrap();
let paths = paths(temp.path());
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.auth_file, "secret").unwrap();
assert_eq!(
classify_snapshot_eligibility(&paths.auth_file, temp.path(), &paths),
SnapshotEligibility::Excluded(SnapshotExclusionReason::SecretPath)
);
let env_file = temp.path().join(".env");
fs::write(&env_file, "secret").unwrap();
assert_eq!(
classify_snapshot_eligibility(&env_file, temp.path(), &paths),
SnapshotEligibility::Excluded(SnapshotExclusionReason::SecretPath)
);
}
#[test]
fn snapshot_silently_skips_path_escape() {
let temp = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let context = ctx(&temp);
let file = outside.path().join("file.txt");
fs::write(&file, "x").unwrap();
assert_eq!(
classify_snapshot_eligibility(&file, temp.path(), &context.paths),
SnapshotEligibility::Excluded(SnapshotExclusionReason::PathEscape)
);
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::WriteFile,
&file,
Some(b"before"),
Some(b"after"),
)
.unwrap();
let read = context.store.read_records("session");
assert!(read.records.is_empty());
assert!(read.diagnostics.is_empty());
assert!(!context.store.blobs_dir().exists());
}
#[test]
fn snapshot_records_oversized_as_non_rewindable() {
let temp = TempDir::new().unwrap();
let context = ctx(&temp);
let file = temp.path().join("big.txt");
fs::write(&file, vec![b'x'; MAX_SNAPSHOT_BYTES as usize + 1]).unwrap();
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::WriteFile,
&file,
None,
None,
)
.unwrap();
let read = context.store.read_records("session");
assert!(matches!(
read.records[0].event.status,
SnapshotCaptureStatus::Oversized { .. }
));
}
#[test]
fn snapshots_group_by_user_turn_ordinal() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
for turn in [2, 1] {
store
.append_record(&SnapshotLedgerRecord::new(FileSnapshotEvent {
session_id: "session".to_string(),
user_turn: turn,
tool: SnapshotTool::Edit,
cwd: temp.path().to_path_buf(),
relative_path: PathBuf::from(format!("{turn}.txt")),
pre: None,
post: None,
status: SnapshotCaptureStatus::Excluded {
reason: SnapshotExclusionReason::SecretPath,
},
}))
.unwrap();
}
let changes = store.changes("session");
assert_eq!(
changes
.turns
.iter()
.map(|turn| turn.user_turn)
.collect::<Vec<_>>(),
vec![1, 2]
);
}
#[test]
fn changes_lists_rewindable_turns_without_bytes() {
let temp = TempDir::new().unwrap();
let context = ctx(&temp);
let file = temp.path().join("file.txt");
fs::write(&file, "after").unwrap();
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::WriteFile,
&file,
Some(b"before"),
Some(b"after"),
)
.unwrap();
let changes = context.store.changes("session");
assert!(changes.turns[0].changes[0].rewindable);
assert!(!format!("{changes:?}").contains("before"));
}
#[test]
fn changes_marks_missing_blob_unavailable() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
store
.append_record(&SnapshotLedgerRecord::new(FileSnapshotEvent {
session_id: "session".to_string(),
user_turn: 1,
tool: SnapshotTool::Edit,
cwd: temp.path().to_path_buf(),
relative_path: PathBuf::from("file.txt"),
pre: Some(SnapshotBlobRef {
sha256: "a".repeat(64),
bytes: 1,
}),
post: None,
status: SnapshotCaptureStatus::Captured,
}))
.unwrap();
let changes = store.changes("session");
assert!(!changes.turns[0].changes[0].rewindable);
}
#[test]
fn parse_rewind_target_matrix() {
assert_eq!(
parse_rewind_target(None).unwrap(),
ParsedRewindTarget::NeedsSelection
);
assert_eq!(
parse_rewind_target(Some("3")).unwrap(),
ParsedRewindTarget::Target {
target_turn: 3,
dry_run: false
}
);
assert_eq!(
parse_rewind_target(Some("--dry-run --to 3")).unwrap(),
ParsedRewindTarget::Target {
target_turn: 3,
dry_run: true
}
);
assert!(parse_rewind_target(Some("0")).is_err());
assert!(parse_rewind_target(Some("1..2")).is_err());
assert!(parse_rewind_target(Some("--to")).is_err());
}
#[test]
fn planner_merges_same_file_edits() {
let temp = TempDir::new().unwrap();
let context = ctx(&temp);
let file = temp.path().join("file.txt");
fs::write(&file, "c").unwrap();
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::Edit,
&file,
Some(b"a"),
Some(b"b"),
)
.unwrap();
let mut context2 = context.clone();
context2.user_turn = 2;
capture_file_snapshot(
&context2,
temp.path(),
SnapshotTool::Edit,
&file,
Some(b"b"),
Some(b"c"),
)
.unwrap();
let plan = context.store.plan_rewind("session", temp.path(), 1);
assert_eq!(plan.operations.len(), 1);
assert!(matches!(
plan.operations[0].kind,
RestoreOperationKind::Restore { .. }
));
}
#[test]
fn planner_emits_skip_conflict_on_hash_mismatch() {
let temp = TempDir::new().unwrap();
let context = ctx(&temp);
let file = temp.path().join("file.txt");
fs::write(&file, "after").unwrap();
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::Edit,
&file,
Some(b"before"),
Some(b"after"),
)
.unwrap();
fs::write(&file, "changed").unwrap();
let plan = context.store.plan_rewind("session", temp.path(), 1);
assert!(matches!(
plan.operations[0].kind,
RestoreOperationKind::SkipConflict { .. }
));
}
#[test]
fn planner_emits_delete_created_and_resurrect_deleted() {
let temp = TempDir::new().unwrap();
let context = ctx(&temp);
let created = temp.path().join("created.txt");
fs::write(&created, "created").unwrap();
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::WriteFile,
&created,
None,
Some(b"created"),
)
.unwrap();
context
.store
.append_record(&SnapshotLedgerRecord::new(FileSnapshotEvent {
session_id: "session".to_string(),
user_turn: 1,
tool: SnapshotTool::Edit,
cwd: temp.path().to_path_buf(),
relative_path: PathBuf::from("deleted.txt"),
pre: Some(context.store.write_blob(b"old").unwrap()),
post: None,
status: SnapshotCaptureStatus::Captured,
}))
.unwrap();
let plan = context.store.plan_rewind("session", temp.path(), 1);
assert!(
plan.operations
.iter()
.any(|op| matches!(op.kind, RestoreOperationKind::DeleteCreated { .. }))
);
assert!(
plan.operations
.iter()
.any(|op| matches!(op.kind, RestoreOperationKind::ResurrectDeleted { .. }))
);
}
#[test]
fn planner_tolerates_missing_ledger_and_blobs() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
assert!(
store
.plan_rewind("missing", temp.path(), 1)
.operations
.is_empty()
);
}
#[test]
fn planner_skips_unsafe_ledger_paths() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
for path in [
PathBuf::from("../outside"),
PathBuf::from("/etc/passwd"),
PathBuf::from(".git/config"),
PathBuf::from("auth.json"),
PathBuf::from(".env"),
PathBuf::from("id_rsa"),
PathBuf::from("mc/checkpoints/blob"),
PathBuf::from("mc/sessions/session.jsonl"),
] {
store.append_record(&captured_record(&store, path)).unwrap();
}
let plan = store.plan_rewind("session", temp.path(), 1);
assert_eq!(plan.operations.len(), 8);
assert!(plan.operations.iter().all(|operation| matches!(
operation.kind,
RestoreOperationKind::SkipUnavailable { .. }
)));
let text = format!("{plan:?}");
assert!(!text.contains("../outside"), "leaked path escape: {text}");
assert!(
!text.contains("/etc/passwd"),
"leaked absolute path: {text}"
);
assert!(!text.contains(".git/config"), "leaked git path: {text}");
assert_no_secret_path(&text);
}
#[test]
fn executor_skips_unsafe_operation_paths_before_io() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
let outside = temp
.path()
.parent()
.unwrap()
.join("outside-rewind-victim.txt");
fs::write(&outside, "unchanged").unwrap();
let pre = store.write_blob(b"before").unwrap();
let expected_current = SnapshotBlobRef {
sha256: sha256_hex(b"unchanged"),
bytes: 9,
};
let plan = RestorePlan {
session_id: "session".to_string(),
target_turn: 1,
latest_turn: Some(1),
diagnostics: Vec::new(),
operations: vec![
RestoreOperation {
relative_path: PathBuf::from("../outside-rewind-victim.txt"),
kind: RestoreOperationKind::Restore {
pre: pre.clone(),
expected_current: expected_current.clone(),
},
},
RestoreOperation {
relative_path: PathBuf::from("/etc/passwd"),
kind: RestoreOperationKind::DeleteCreated {
expected_current: expected_current.clone(),
},
},
RestoreOperation {
relative_path: PathBuf::from("auth.json"),
kind: RestoreOperationKind::ResurrectDeleted { pre },
},
],
};
let execution = store.execute_plan(&plan, temp.path());
assert!(
execution
.results
.iter()
.all(|result| result.status == RestoreStatus::SkipUnavailable)
);
assert_eq!(fs::read_to_string(outside).unwrap(), "unchanged");
}
#[test]
fn executor_reports_sanitized_filesystem_failure() {
let temp = TempDir::new().unwrap();
let context = ctx(&temp);
let file = temp.path().join("safe.txt");
fs::write(&file, "after").unwrap();
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::Edit,
&file,
Some(b"before"),
Some(b"after"),
)
.unwrap();
let plan = context.store.plan_rewind("session", temp.path(), 1);
let execution = execute_plan_with_failure(&context.store, &plan, temp.path(), "write");
let result = &execution.results[0];
assert_eq!(result.status, RestoreStatus::SkipUnavailable);
assert_eq!(result.reason.as_deref(), Some("write failed"));
let payload = rewind_event_payload(&plan, &execution).to_string();
assert!(payload.contains("write failed"));
assert!(!payload.contains(temp.path().to_string_lossy().as_ref()));
assert!(!payload.contains("before"));
}
#[test]
fn executor_reports_each_injected_filesystem_failure_phase() {
let temp = TempDir::new().unwrap();
let context = ctx(&temp);
let file = temp.path().join("safe.txt");
fs::write(&file, "after").unwrap();
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::Edit,
&file,
Some(b"before"),
Some(b"after"),
)
.unwrap();
let plan = context.store.plan_rewind("session", temp.path(), 1);
for (phase, reason) in [
("hash", "hash check failed"),
("blob read", "blob read failed"),
("write", "write failed"),
] {
let result =
&execute_plan_with_failure(&context.store, &plan, temp.path(), phase).results[0];
assert_eq!(result.status, RestoreStatus::SkipUnavailable);
assert_eq!(result.reason.as_deref(), Some(reason));
}
}
#[test]
fn executor_reports_trash_failure() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
let file = temp.path().join("created.txt");
fs::write(&file, "created").unwrap();
let plan = RestorePlan {
session_id: "session".to_string(),
target_turn: 1,
latest_turn: Some(1),
diagnostics: Vec::new(),
operations: vec![RestoreOperation {
relative_path: PathBuf::from("created.txt"),
kind: RestoreOperationKind::DeleteCreated {
expected_current: SnapshotBlobRef {
sha256: sha256_hex(b"created"),
bytes: 7,
},
},
}],
};
let result = &execute_plan_with_failure(&store, &plan, temp.path(), "trash").results[0];
assert_eq!(result.status, RestoreStatus::SkipUnavailable);
assert_eq!(result.reason.as_deref(), Some("trash failed"));
}
#[test]
fn rewind_payload_omits_reason_for_success_and_bounds_diagnostics() {
let plan = RestorePlan {
session_id: "session".to_string(),
target_turn: 1,
latest_turn: Some(1),
diagnostics: Vec::new(),
operations: Vec::new(),
};
let execution = RewindExecution {
results: vec![RestoreOperationResult {
relative_path: PathBuf::from("safe.txt"),
status: RestoreStatus::Restored,
reason: None,
}],
};
let item = &rewind_event_payload(&plan, &execution)["paths"][0];
assert_eq!(item, &json!({"path":"safe.txt", "status":"restored"}));
assert_eq!(sanitized_diagnostic("x".repeat(600)).chars().count(), 500);
}
#[test]
fn secret_paths_are_redacted_from_changes_payload_and_replay() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
for path in ["auth.json", ".env", "id_rsa"] {
store
.append_record(&SnapshotLedgerRecord::new(FileSnapshotEvent {
session_id: "session".to_string(),
user_turn: 1,
tool: SnapshotTool::WriteFile,
cwd: temp.path().to_path_buf(),
relative_path: PathBuf::from(path),
pre: None,
post: None,
status: SnapshotCaptureStatus::Excluded {
reason: SnapshotExclusionReason::SecretPath,
},
}))
.unwrap();
}
let changes = store.changes("session");
assert_no_secret_path(&format!("{changes:?}"));
assert!(
changes.turns[0]
.changes
.iter()
.all(|change| change.relative_path == Path::new(REDACTED_PATH))
);
let plan = RestorePlan {
session_id: "session".to_string(),
target_turn: 1,
latest_turn: Some(1),
diagnostics: Vec::new(),
operations: Vec::new(),
};
let execution = RewindExecution {
results: vec![
RestoreOperationResult {
relative_path: PathBuf::from("auth.json"),
status: RestoreStatus::SkipUnavailable,
reason: None,
},
RestoreOperationResult {
relative_path: PathBuf::from(".env"),
status: RestoreStatus::SkipUnavailable,
reason: None,
},
RestoreOperationResult {
relative_path: PathBuf::from("id_rsa"),
status: RestoreStatus::SkipUnavailable,
reason: None,
},
],
};
let payload = rewind_event_payload(&plan, &execution);
let payload_text = payload.to_string();
assert_no_secret_path(&payload_text);
let replay = replay_rewind_context(&payload).unwrap();
assert_no_secret_path(&replay);
}
#[test]
fn executor_rechecks_hash_before_restore() {
let temp = TempDir::new().unwrap();
let context = ctx(&temp);
let file = temp.path().join("file.txt");
fs::write(&file, "after").unwrap();
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::Edit,
&file,
Some(b"before"),
Some(b"after"),
)
.unwrap();
let plan = context.store.plan_rewind("session", temp.path(), 1);
fs::write(&file, "changed").unwrap();
let execution = context.store.execute_plan(&plan, temp.path());
assert_eq!(execution.results[0].status, RestoreStatus::SkipConflict);
}
#[test]
fn executor_uses_trash_for_delete_created() {
let temp = TempDir::new().unwrap();
let context = ctx(&temp);
let file = temp.path().join("created.txt");
fs::write(&file, "created").unwrap();
capture_file_snapshot(
&context,
temp.path(),
SnapshotTool::WriteFile,
&file,
None,
Some(b"created"),
)
.unwrap();
let plan = context.store.plan_rewind("session", temp.path(), 1);
let execution = context.store.execute_plan(&plan, temp.path());
assert!(matches!(
execution.results[0].status,
RestoreStatus::DeletedCreated | RestoreStatus::SkipUnavailable
));
}
#[test]
fn executor_skips_denied_path() {
let temp = TempDir::new().unwrap();
let store = store(&temp);
let plan = RestorePlan {
session_id: "session".to_string(),
target_turn: 1,
latest_turn: Some(1),
diagnostics: Vec::new(),
operations: vec![RestoreOperation {
relative_path: PathBuf::from(".git/config"),
kind: RestoreOperationKind::SkipUnavailable {
reason: "denied".to_string(),
},
}],
};
let execution = store.execute_plan(&plan, temp.path());
assert_eq!(execution.results[0].status, RestoreStatus::SkipUnavailable);
}
}