#[cfg(unix)]
use std::fs::File;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use sha2::{Digest, Sha256};
use super::types::{RepairReceipt, RollbackReceipt};
static ROLLBACK_NONCE: AtomicU64 = AtomicU64::new(0);
fn sha256_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
pub fn atomic_commit_bytes(target_path: &Path, bytes: &[u8]) -> Result<()> {
let parent = target_path
.parent()
.context("target path has no parent directory")?;
fs::create_dir_all(parent)
.with_context(|| format!("create parent dir {}", parent.display()))?;
let file_name = target_path
.file_name()
.context("target path has no filename")?
.to_string_lossy();
let nonce = ROLLBACK_NONCE.fetch_add(1, Ordering::Relaxed);
let tmp_name = format!(".{}.vetto_tmp.{}.{}", file_name, std::process::id(), nonce);
let tmp_path = parent.join(tmp_name);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
.with_context(|| format!("create atomic tmp file {}", tmp_path.display()))?;
file.write_all(bytes)
.with_context(|| format!("write to atomic tmp file {}", tmp_path.display()))?;
file.sync_all()
.with_context(|| format!("sync atomic tmp file {}", tmp_path.display()))?;
drop(file);
fs::rename(&tmp_path, target_path).with_context(|| {
format!(
"atomic swap {} -> {}",
tmp_path.display(),
target_path.display()
)
})?;
#[cfg(unix)]
if let Ok(dir_file) = File::open(parent) {
let _ = dir_file.sync_all();
}
Ok(())
}
pub fn rollback_repair(
receipt_path: &Path,
target_override: Option<&Path>,
) -> Result<RollbackReceipt> {
let receipt_bytes = fs::read(receipt_path)
.with_context(|| format!("read repair receipt {}", receipt_path.display()))?;
let receipt: RepairReceipt = serde_json::from_slice(&receipt_bytes)
.with_context(|| format!("parse repair receipt {}", receipt_path.display()))?;
let backup_path = &receipt.backup_archive_path;
if !backup_path.exists() {
bail!(
"backup archive {} specified in receipt does not exist",
backup_path.display()
);
}
let backup_bytes = fs::read(backup_path)
.with_context(|| format!("read backup archive {}", backup_path.display()))?;
let actual_backup_sha256 = sha256_bytes(&backup_bytes);
if actual_backup_sha256 != receipt.original_sha256 {
bail!(
"backup archive cryptographic hash mismatch: expected {}, found {}",
receipt.original_sha256,
actual_backup_sha256
);
}
let target_path: PathBuf = match target_override {
Some(t) => t.to_path_buf(),
None => {
let candidate = PathBuf::from(&receipt.session_key);
if candidate.exists() {
candidate
} else {
bail!(
"target file for session {} could not be automatically determined; pass --target explicitly",
receipt.session_key
);
}
}
};
if let Some(_backup_parent) = backup_path.parent() {
let backup_base = backup_path.to_string_lossy();
for ext in ["-wal", "-shm", "-journal"] {
let src_sc = PathBuf::from(format!("{}{}", backup_base, ext));
if src_sc.exists() {
let target_sc = PathBuf::from(format!("{}{}", target_path.display(), ext));
if let Ok(sc_bytes) = fs::read(&src_sc) {
let _ = atomic_commit_bytes(&target_sc, &sc_bytes);
}
}
}
}
atomic_commit_bytes(&target_path, &backup_bytes)?;
let restored_bytes = fs::read(&target_path)
.with_context(|| format!("read restored target {}", target_path.display()))?;
let restored_sha256 = sha256_bytes(&restored_bytes);
if restored_sha256 != receipt.original_sha256 {
bail!("rollback verification hash mismatch after restore");
}
let timestamp_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Ok(RollbackReceipt {
adapter: receipt.adapter,
session_key: receipt.session_key,
target_path: target_path.to_string_lossy().to_string(),
restored_sha256,
timestamp_unix_secs,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static NEXT: AtomicU64 = AtomicU64::new(0);
fn test_dir(tag: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let n = NEXT.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"vetto-rollback-{tag}-{}-{nonce}-{n}",
std::process::id()
));
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn atomic_commit_writes_file_cleanly() {
let dir = test_dir("commit");
let target = dir.join("session.jsonl");
atomic_commit_bytes(&target, b"line 1\nline 2\n").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"line 1\nline 2\n");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn rollback_restores_exact_pre_repair_bytes_from_receipt() {
let dir = test_dir("rb-test");
let target = dir.join("session.jsonl");
let original_data = b"original pre-repair content\n";
let repaired_data = b"repaired content\n";
fs::write(&target, repaired_data).unwrap();
let backup_file = dir.join("backup_session.jsonl");
fs::write(&backup_file, original_data).unwrap();
let receipt = RepairReceipt {
adapter: "test".to_string(),
session_key: "session.jsonl".to_string(),
original_sha256: sha256_bytes(original_data),
repaired_sha256: sha256_bytes(repaired_data),
backup_archive_path: backup_file.clone(),
actions_applied: vec!["test_repair".to_string()],
timestamp_unix_secs: 1234567,
};
let receipt_path = dir.join("receipt.json");
fs::write(
&receipt_path,
serde_json::to_string_pretty(&receipt).unwrap(),
)
.unwrap();
let rb_receipt = rollback_repair(&receipt_path, Some(&target)).expect("rollback");
assert_eq!(rb_receipt.restored_sha256, receipt.original_sha256);
assert_eq!(fs::read(&target).unwrap(), original_data);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn rollback_rejects_tampered_backup() {
let dir = test_dir("tamper-test");
let target = dir.join("session.jsonl");
let original_data = b"original content";
let backup_file = dir.join("backup.jsonl");
fs::write(&backup_file, b"tampered content").unwrap();
let receipt = RepairReceipt {
adapter: "test".to_string(),
session_key: "session.jsonl".to_string(),
original_sha256: sha256_bytes(original_data),
repaired_sha256: sha256_bytes(b"repaired"),
backup_archive_path: backup_file,
actions_applied: vec![],
timestamp_unix_secs: 100,
};
let receipt_path = dir.join("receipt.json");
fs::write(&receipt_path, serde_json::to_string(&receipt).unwrap()).unwrap();
let res = rollback_repair(&receipt_path, Some(&target));
assert!(res.is_err());
assert!(res.unwrap_err().to_string().contains("hash mismatch"));
let _ = fs::remove_dir_all(dir);
}
}