#![allow(dead_code)]
use std::fs;
use std::io;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::doctor_runs::{
ActionRecord, RUN_ARTIFACT_SCHEMA_VERSION, RunId, append_action, create_run_dir, read_actions,
run_dir_for,
};
#[derive(Debug, thiserror::Error)]
pub(crate) enum UndoError {
#[error("run id {0:?} not found in {1:?}")]
RunNotFound(String, PathBuf),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error(
"after-hash mismatch on {path}: actions.jsonl recorded {expected}, current file is {actual}"
)]
AfterHashMismatch {
path: PathBuf,
expected: String,
actual: String,
},
#[error("undo target file missing for path {0:?} (was deleted out-of-band)")]
TargetMissing(PathBuf),
#[error("backup file missing: {0:?}")]
BackupMissing(PathBuf),
#[error("backup hash mismatch on {path:?}: expected {expected}, found {actual}")]
BackupHashMismatch {
path: PathBuf,
expected: String,
actual: String,
},
#[error("actions.jsonl unparseable at {0}")]
ActionsParseError(String),
#[error(
"op::rename is not reversible in pass-1+ schema: {path:?} (the rename destination is not recorded in actions.jsonl)"
)]
OpRenameNotReversible { path: PathBuf },
#[error("undo quarantine destination {0:?} already exists; refusing to overwrite it")]
QuarantineDestinationExists(PathBuf),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct UndoStepReceipt {
pub original_mutation_path: String,
pub original_mutation_op: String,
pub action_taken: String,
pub before_hash_after_undo: Option<String>,
pub elapsed_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct UndoReceipt {
pub schema_version: u32,
pub original_run_id: String,
pub undo_run_id: String,
pub steps_total: usize,
pub steps_succeeded: usize,
pub steps_skipped: usize,
pub steps_failed: usize,
pub steps: Vec<UndoStepReceipt>,
}
pub(crate) fn undo_run(
data_dir: &Path,
original_run_id: &RunId,
target_sha: &str,
) -> Result<UndoReceipt, UndoError> {
let original_run_dir = run_dir_for(data_dir, original_run_id);
if !original_run_dir.exists() {
return Err(UndoError::RunNotFound(
original_run_id.as_str().to_string(),
original_run_dir,
));
}
let (records, parse_errors) = read_actions(&original_run_dir)?;
if !parse_errors.is_empty() {
return Err(UndoError::ActionsParseError(format!(
"{} bad lines in actions.jsonl",
parse_errors.len()
)));
}
let undo_run_id = RunId::new(target_sha);
let undo_dir = create_run_dir(data_dir, &undo_run_id)?;
let started_at_ms = current_unix_ms();
append_action(
&undo_dir,
&ActionRecord::RunStarted {
schema_version: RUN_ARTIFACT_SCHEMA_VERSION,
run_id: undo_run_id.as_str().to_string(),
target_sha: target_sha.to_string(),
mode: format!("undo:{}", original_run_id.as_str()),
started_at_ms,
},
)?;
let mut mutations_rev: Vec<&ActionRecord> = records
.iter()
.filter(|r| matches!(r, ActionRecord::Mutation { .. }))
.collect();
mutations_rev.reverse();
let mut steps = Vec::with_capacity(mutations_rev.len());
let mut succeeded = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
for rec in &mutations_rev {
let ActionRecord::Mutation {
path,
op,
before_blake3,
after_blake3,
..
} = rec
else {
continue;
};
let target = PathBuf::from(path);
let step_start = current_unix_ms();
let step_result = undo_one(
data_dir,
&original_run_dir,
&target,
op,
before_blake3.as_deref(),
after_blake3.as_deref(),
);
let elapsed_ms = current_unix_ms() - step_start;
let receipt = match step_result {
Ok(action) => {
succeeded += 1;
UndoStepReceipt {
original_mutation_path: path.clone(),
original_mutation_op: op.clone(),
action_taken: action.to_string(),
before_hash_after_undo: before_blake3.clone(),
elapsed_ms,
}
}
Err(e) => {
let action: &'static str = match &e {
UndoError::TargetMissing(_) => {
skipped += 1;
"skipped-target-missing"
}
_ => {
failed += 1;
"failed"
}
};
let receipt = UndoStepReceipt {
original_mutation_path: path.clone(),
original_mutation_op: op.clone(),
action_taken: action.to_string(),
before_hash_after_undo: None,
elapsed_ms,
};
steps.push(receipt);
if !matches!(e, UndoError::TargetMissing(_)) {
let ended_at_ms = current_unix_ms();
append_action(
&undo_dir,
&ActionRecord::RunEnded {
run_id: undo_run_id.as_str().to_string(),
exit_code: 3, exit_code_kind: "repair-failure".to_string(),
ended_at_ms,
},
)?;
return Err(e);
}
continue;
}
};
steps.push(receipt);
}
let ended_at_ms = current_unix_ms();
append_action(
&undo_dir,
&ActionRecord::RunEnded {
run_id: undo_run_id.as_str().to_string(),
exit_code: 0,
exit_code_kind: "success".to_string(),
ended_at_ms,
},
)?;
Ok(UndoReceipt {
schema_version: 1,
original_run_id: original_run_id.as_str().to_string(),
undo_run_id: undo_run_id.as_str().to_string(),
steps_total: mutations_rev.len(),
steps_succeeded: succeeded,
steps_skipped: skipped,
steps_failed: failed,
steps,
})
}
fn undo_one(
data_dir: &Path,
original_run_dir: &Path,
target: &Path,
op_kind: &str,
before_blake3: Option<&str>,
after_blake3: Option<&str>,
) -> Result<&'static str, UndoError> {
if op_kind == "rename" {
return Err(UndoError::OpRenameNotReversible {
path: target.to_path_buf(),
});
}
let current = blake3_of_file_if_exists(target)?;
match (current.as_deref(), after_blake3) {
(Some(actual), Some(expected)) if actual == expected => {
}
(None, Some(_)) => {
return Err(UndoError::TargetMissing(target.to_path_buf()));
}
(Some(actual), Some(expected)) => {
return Err(UndoError::AfterHashMismatch {
path: target.to_path_buf(),
expected: expected.to_string(),
actual: actual.to_string(),
});
}
(None, None) => {
}
(Some(actual), None) => {
if let Some(before) = before_blake3
&& actual == before
{
return Ok("noop-already-idempotent-restored");
}
return Err(UndoError::AfterHashMismatch {
path: target.to_path_buf(),
expected: "<absent>".to_string(),
actual: actual.to_string(),
});
}
}
if before_blake3.is_none() {
if !target.exists() {
return Ok("noop-create-already-removed");
}
let undo_quarantine_dir = data_dir
.join("doctor")
.join("undo-quarantine")
.join(format!("{}", current_unix_ms()));
create_private_undo_quarantine_dir(&undo_quarantine_dir)?;
let dest = undo_quarantine_dir.join(
target
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("orphan")),
);
match fs::symlink_metadata(&dest) {
Ok(_) => return Err(UndoError::QuarantineDestinationExists(dest)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(UndoError::Io(err)),
}
fs::rename(target, &dest)?;
return Ok("quarantined-because-original-was-create");
}
let rel = target
.strip_prefix(data_dir)
.map(|p| p.to_path_buf())
.unwrap_or_else(|_| {
target
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("orphan"))
});
let backup_path = original_run_dir.join("backups").join(&rel);
if !backup_path.exists() {
return Err(UndoError::BackupMissing(backup_path));
}
let backup_bytes = fs::read(&backup_path)?;
let backup_hash = {
let mut hasher = blake3::Hasher::new();
hasher.update(&backup_bytes);
hasher.finalize().to_hex().to_string()
};
let expected_before = before_blake3.expect("checked above");
if backup_hash != expected_before {
return Err(UndoError::BackupHashMismatch {
path: backup_path,
expected: expected_before.to_string(),
actual: backup_hash,
});
}
let parent = target.parent().ok_or_else(|| {
UndoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"target has no parent",
))
})?;
fs::create_dir_all(parent)?;
let tmp = parent.join(format!(
".{}.undo.tmp.{}.{}.{}",
target
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("doctor"),
std::process::id(),
thread_id_hash_undo(),
tmp_nonce_undo(),
));
{
let mut f = create_new_undo_temp_file(&tmp)?;
f.write_all(&backup_bytes)?;
f.sync_all()?;
}
fs::rename(&tmp, target)?;
let post = blake3_of_file(target)?;
if post != expected_before {
return Err(UndoError::BackupHashMismatch {
path: target.to_path_buf(),
expected: expected_before.to_string(),
actual: post,
});
}
Ok("restored-from-backup")
}
fn blake3_of_file_if_exists(path: &Path) -> std::io::Result<Option<String>> {
match fs::metadata(path) {
Ok(_) => Ok(Some(blake3_of_file(path)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn blake3_of_file(path: &Path) -> std::io::Result<String> {
let bytes = fs::read(path)?;
let mut hasher = blake3::Hasher::new();
hasher.update(&bytes);
Ok(hasher.finalize().to_hex().to_string())
}
fn create_new_undo_temp_file(path: &Path) -> std::io::Result<fs::File> {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
}
fn create_private_undo_quarantine_dir(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::fs::DirBuilder;
use std::os::unix::fs::DirBuilderExt;
let mut builder = DirBuilder::new();
builder.recursive(true);
builder.mode(0o700);
builder.create(path)?;
}
#[cfg(not(unix))]
{
fs::create_dir_all(path)?;
}
let meta = fs::symlink_metadata(path)?;
let file_type = meta.file_type();
if file_type.is_symlink() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("undo quarantine directory {path:?} must not be a symlink"),
));
}
if !file_type.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("undo quarantine directory {path:?} is not a directory"),
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o777 != 0o700 {
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
}
}
Ok(())
}
fn current_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default()
}
fn thread_id_hash_undo() -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
std::thread::current().id().hash(&mut h);
h.finish()
}
fn tmp_nonce_undo() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
COUNTER.fetch_add(1, Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doctor_chokepoint::{MutationRequest, Op, mutate};
#[test]
fn round_trip_write_then_undo_restores_byte_identical() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().to_path_buf();
let run_id = RunId::from_parts("sha", 1_700_000_000_000);
let _ = create_run_dir(&data_dir, &run_id).unwrap();
let target = data_dir.join("config.toml");
fs::write(&target, b"original=1\n").unwrap();
let before_bytes = fs::read(&target).unwrap();
let _r = mutate(MutationRequest {
run_id: run_id.clone(),
data_dir: data_dir.clone(),
fm_id: "fm-test".into(),
path: target.clone(),
op: Op::Write {
content: b"replaced=2\n".to_vec(),
},
})
.expect("mutate ok");
assert_eq!(fs::read(&target).unwrap(), b"replaced=2\n");
let receipt = undo_run(&data_dir, &run_id, "sha2").expect("undo ok");
assert_eq!(receipt.steps_succeeded, 1);
assert_eq!(receipt.steps_failed, 0);
assert_eq!(fs::read(&target).unwrap(), before_bytes);
}
#[cfg(unix)]
#[test]
fn undo_temp_file_creation_refuses_preexisting_symlink() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let protected = tmp.path().join("protected.txt");
let temp_path = tmp.path().join(".doctor.undo.tmp");
fs::write(&protected, b"protected").unwrap();
symlink(&protected, &temp_path).unwrap();
let err = create_new_undo_temp_file(&temp_path).expect_err("symlink collision should fail");
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&protected).unwrap(), b"protected");
}
#[cfg(unix)]
#[test]
fn undo_quarantine_dir_refuses_preexisting_symlink() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let quarantine_dir = tmp.path().join("doctor/undo-quarantine/123");
fs::create_dir_all(quarantine_dir.parent().unwrap()).unwrap();
symlink(outside.path(), &quarantine_dir).unwrap();
let err =
create_private_undo_quarantine_dir(&quarantine_dir).expect_err("symlink must fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
fs::symlink_metadata(&quarantine_dir)
.unwrap()
.file_type()
.is_symlink()
);
}
#[test]
fn undo_create_quarantines_instead_of_deleting() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().to_path_buf();
let run_id = RunId::from_parts("sha", 1_700_000_000_000);
let _ = create_run_dir(&data_dir, &run_id).unwrap();
let target = data_dir.join("new.txt");
let _r = mutate(MutationRequest {
run_id: run_id.clone(),
data_dir: data_dir.clone(),
fm_id: "fm-test".into(),
path: target.clone(),
op: Op::Write {
content: b"hello".to_vec(),
},
})
.expect("create ok");
assert!(target.exists());
let receipt = undo_run(&data_dir, &run_id, "sha2").expect("undo ok");
assert_eq!(receipt.steps_succeeded, 1);
assert!(!target.exists(), "original target should be moved");
let undo_quar_root = data_dir.join("doctor").join("undo-quarantine");
assert!(undo_quar_root.exists());
}
#[test]
fn undo_fails_closed_on_after_hash_mismatch() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().to_path_buf();
let run_id = RunId::from_parts("sha", 1_700_000_000_000);
let _ = create_run_dir(&data_dir, &run_id).unwrap();
let target = data_dir.join("file.txt");
fs::write(&target, b"original").unwrap();
let _r = mutate(MutationRequest {
run_id: run_id.clone(),
data_dir: data_dir.clone(),
fm_id: "fm-test".into(),
path: target.clone(),
op: Op::Write {
content: b"replaced".to_vec(),
},
})
.expect("mutate ok");
fs::write(&target, b"third-party-edit").unwrap();
let res = undo_run(&data_dir, &run_id, "sha2");
assert!(matches!(res, Err(UndoError::AfterHashMismatch { .. })));
}
#[test]
fn pass11_undo_of_op_quarantine_restores_from_backup() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().to_path_buf();
let run_id = RunId::from_parts("sha-quar-undo", 1_700_000_000_000);
let _ = create_run_dir(&data_dir, &run_id).unwrap();
let target = data_dir.join("config.toml");
let pre = b"original=true\n";
fs::write(&target, pre).unwrap();
let _r = crate::doctor_chokepoint::mutate(crate::doctor_chokepoint::MutationRequest {
run_id: run_id.clone(),
data_dir: data_dir.clone(),
fm_id: "fm-test-quarantine-undo".into(),
path: target.clone(),
op: crate::doctor_chokepoint::Op::Quarantine {
reason: "regression test for pass-11 bug".into(),
},
})
.expect("quarantine ok");
assert!(!target.exists(), "post-fix: file moved out");
let receipt = undo_run(&data_dir, &run_id, "sha-quar-undo").expect("undo ok");
assert_eq!(receipt.steps_succeeded, 1);
assert!(
target.exists(),
"post-undo: file restored at original location"
);
assert_eq!(fs::read(&target).unwrap(), pre.to_vec());
}
#[test]
fn pass12_undo_of_op_rename_refuses_with_precise_error() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().to_path_buf();
let run_id = RunId::from_parts("sha-rename", 1_700_000_000_000);
let _ = create_run_dir(&data_dir, &run_id).unwrap();
let src = data_dir.join("source.toml");
let dst = data_dir.join("renamed.toml");
fs::write(&src, b"content\n").unwrap();
let _r = crate::doctor_chokepoint::mutate(crate::doctor_chokepoint::MutationRequest {
run_id: run_id.clone(),
data_dir: data_dir.clone(),
fm_id: "fm-test-rename".into(),
path: src.clone(),
op: crate::doctor_chokepoint::Op::Rename { to: dst.clone() },
})
.expect("rename ok");
assert!(!src.exists(), "source moved");
assert!(dst.exists(), "destination written");
let res = undo_run(&data_dir, &run_id, "sha-rename");
assert!(matches!(res, Err(UndoError::OpRenameNotReversible { .. })));
assert!(!src.exists());
assert!(dst.exists());
}
#[test]
fn pass11_undo_of_quarantine_is_idempotent_on_re_undo() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().to_path_buf();
let run_id = RunId::from_parts("sha-idem", 1_700_000_000_000);
let _ = create_run_dir(&data_dir, &run_id).unwrap();
let target = data_dir.join("foo.bin");
let pre = b"v1";
fs::write(&target, pre).unwrap();
let _r = crate::doctor_chokepoint::mutate(crate::doctor_chokepoint::MutationRequest {
run_id: run_id.clone(),
data_dir: data_dir.clone(),
fm_id: "fm-test-idem".into(),
path: target.clone(),
op: crate::doctor_chokepoint::Op::Quarantine {
reason: "test".into(),
},
})
.expect("quar ok");
let r1 = undo_run(&data_dir, &run_id, "sha-idem").expect("undo1 ok");
assert_eq!(r1.steps_succeeded, 1);
assert_eq!(fs::read(&target).unwrap(), pre.to_vec());
let r2 = undo_run(&data_dir, &run_id, "sha-idem").expect("undo2 ok (idempotent)");
assert_eq!(r2.steps_failed, 0);
}
#[test]
fn undo_skips_target_missing_with_warning() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().to_path_buf();
let run_id = RunId::from_parts("sha", 1_700_000_000_000);
let _ = create_run_dir(&data_dir, &run_id).unwrap();
let target = data_dir.join("ephemeral.txt");
fs::write(&target, b"x").unwrap();
let _r = mutate(MutationRequest {
run_id: run_id.clone(),
data_dir: data_dir.clone(),
fm_id: "fm-test".into(),
path: target.clone(),
op: Op::Write {
content: b"y".to_vec(),
},
})
.expect("ok");
fs::remove_file(&target).unwrap();
let receipt = undo_run(&data_dir, &run_id, "sha2").expect("undo continues");
assert_eq!(receipt.steps_skipped, 1);
assert_eq!(receipt.steps_failed, 0);
}
}