use crate::safety::Effect;
use crate::value::Value;
use std::path::{Component, Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq)]
pub enum Before {
Contents(Vec<u8>),
Absent,
Directory,
Irreversible(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Fingerprint {
Absent,
Digest(String),
}
impl Fingerprint {
fn encode(&self) -> String {
match self {
Fingerprint::Absent => "absent".to_string(),
Fingerprint::Digest(hex) => hex.clone(),
}
}
fn decode(text: &str) -> Fingerprint {
if text == "absent" {
Fingerprint::Absent
} else {
Fingerprint::Digest(text.to_string())
}
}
fn of_before(before: &Before) -> Option<Fingerprint> {
match before {
Before::Contents(bytes) => {
use ironcrypto::core_types::traits::Digest;
let digest = ironcrypto::hash::Sha256::digest(bytes.as_slice()).ok()?;
Some(Fingerprint::Digest(hex::encode(digest)))
}
Before::Absent => Some(Fingerprint::Absent),
Before::Directory | Before::Irreversible(_) => None,
}
}
fn of_path(path: &str) -> Option<Fingerprint> {
use ironcrypto::core_types::traits::Digest;
use std::io::Read;
let p = Path::new(path);
if !p.exists() {
return Some(Fingerprint::Absent);
}
if !p.is_file() {
return None;
}
let mut file = std::fs::File::open(p).ok()?;
let mut hasher = ironcrypto::hash::Sha256::new();
let mut buf = vec![0u8; 1 << 20];
loop {
let n = file.read(&mut buf).ok()?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Some(Fingerprint::Digest(hex::encode(hasher.finalize().ok()?)))
}
}
#[derive(Clone, Debug)]
pub struct Entry {
pub seq: usize,
pub builtin: String,
pub effect: String,
pub path: String,
pub before: Before,
pub after: Option<Fingerprint>,
pub group: Option<usize>,
}
impl Entry {
pub fn reversible(&self) -> bool {
!matches!(self.before, Before::Irreversible(_))
}
}
#[derive(Clone, Debug)]
pub struct RedoEntry {
pub seq: usize,
pub builtin: String,
pub effect: String,
pub path: String,
pub after: Before,
pub group: Option<usize>,
}
#[derive(Default)]
struct State {
journal: Vec<Entry>,
redo: Vec<RedoEntry>,
next_id: usize,
}
lazy_static::lazy_static! {
static ref STATE: Mutex<State> = Mutex::new(State::default());
static ref HYDRATED_FROM: Mutex<Option<Option<PathBuf>>> = Mutex::new(None);
}
fn session_dir() -> Option<PathBuf> {
if let Ok(d) = std::env::var("IRONSHELL_SESSION_DIR") {
return Some(PathBuf::from(d).join("journal"));
}
let key = std::env::var("IRONSHELL_SESSION").ok().unwrap_or_else(|| {
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
format!("{:x}", md5::compute(cwd.as_bytes()))
});
dirs::cache_dir().map(|c| c.join("ironshell").join("journal").join(key))
}
fn redo_dir() -> Option<PathBuf> {
session_dir().map(|d| d.join("redo"))
}
#[derive(serde::Serialize, serde::Deserialize)]
struct StoredEntry {
seq: usize,
builtin: String,
effect: String,
path: String,
contents: Option<Vec<u8>>,
irreversible: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
after: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
group: Option<usize>,
#[serde(default, skip_serializing_if = "is_false")]
directory: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl From<&Entry> for StoredEntry {
fn from(e: &Entry) -> Self {
let (contents, irreversible, directory) = match &e.before {
Before::Contents(b) => (Some(b.clone()), None, false),
Before::Absent => (None, None, false),
Before::Directory => (None, None, true),
Before::Irreversible(why) => (None, Some(why.clone()), false),
};
StoredEntry {
seq: e.seq,
builtin: e.builtin.clone(),
effect: e.effect.clone(),
path: e.path.clone(),
contents,
irreversible,
after: e.after.as_ref().map(Fingerprint::encode),
group: e.group,
directory,
}
}
}
impl From<StoredEntry> for Entry {
fn from(s: StoredEntry) -> Self {
let before = match (s.contents, s.irreversible) {
(_, Some(why)) => Before::Irreversible(why),
(Some(b), None) => Before::Contents(b),
(None, None) if s.directory => Before::Directory,
(None, None) => Before::Absent,
};
Entry {
seq: s.seq,
builtin: s.builtin,
effect: s.effect,
path: s.path,
before,
after: s.after.as_deref().map(Fingerprint::decode),
group: s.group,
}
}
}
fn load_dir(dir: &Path) -> Vec<Entry> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<(usize, PathBuf)> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let p = e.path();
if !p.is_file() {
return None;
}
let stem = p.file_stem()?.to_string_lossy().into_owned();
stem.parse::<usize>().ok().map(|n| (n, p))
})
.collect();
files.sort_by_key(|(n, _)| *n);
files
.into_iter()
.filter_map(|(_, p)| {
let raw = std::fs::read(&p).ok()?;
serde_json::from_slice::<StoredEntry>(&raw)
.ok()
.map(Entry::from)
})
.collect()
}
fn make_private_dir(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
if std::fs::metadata(dir)?.permissions().mode() & 0o077 != 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"the directory's mode did not become private",
));
}
}
Ok(())
}
pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
#[cfg(not(unix))]
return std::fs::write(path, bytes);
#[cfg(unix)]
{
use std::io::Write;
let existed = path.exists();
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
if existed {
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
file.write_all(bytes)
}
}
fn persist_in(dir: Option<PathBuf>, e: &Entry) {
let Some(dir) = dir else { return };
let path = dir.join(format!("{:08}.json", e.seq));
let written = make_private_dir(&dir).and_then(|()| {
let json = serde_json::to_vec(&StoredEntry::from(e))
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
write_private(&path, &json)
});
if let Err(err) = written {
tracing::warn!(
target: "journal",
"undo entry {} was not persisted, so it will not survive this process: {}: {err}",
e.seq,
path.display()
);
}
}
fn forget_in(dir: Option<PathBuf>, seq: usize) {
if let Some(dir) = dir {
let path = dir.join(format!("{seq:08}.json"));
if let Err(err) = remove_if_present(std::fs::remove_file(&path)) {
warn_left_behind(&path, &err);
}
}
}
fn remove_if_present(removed: std::io::Result<()>) -> std::io::Result<()> {
match removed {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
}
fn warn_left_behind(path: &Path, err: &std::io::Error) {
tracing::warn!(
target: "journal",
"{} could not be removed, so a later process may load it: {err}",
path.display()
);
}
fn persist(e: &Entry) {
persist_in(session_dir(), e);
}
fn forget_persisted(seq: usize) {
forget_in(session_dir(), seq);
}
fn persist_redo(r: &RedoEntry) {
persist_in(redo_dir(), &redo_as_entry(r));
}
fn forget_redo_persisted(seq: usize) {
forget_in(redo_dir(), seq);
}
fn redo_as_entry(r: &RedoEntry) -> Entry {
Entry {
seq: r.seq,
builtin: r.builtin.clone(),
effect: r.effect.clone(),
path: r.path.clone(),
before: r.after.clone(),
after: None,
group: r.group,
}
}
fn hydrate(s: &mut State) {
let dir = session_dir();
{
let mut h = HYDRATED_FROM.lock().unwrap_or_else(|e| e.into_inner());
if h.as_ref() == Some(&dir) {
return;
}
*h = Some(dir);
}
if s.journal.is_empty() {
s.journal = load_dir(&session_dir().unwrap_or_default());
}
if s.redo.is_empty() {
s.redo = load_dir(&redo_dir().unwrap_or_default())
.into_iter()
.filter_map(|e| match e.before {
Before::Irreversible(_) => None,
after => Some(RedoEntry {
seq: e.seq,
builtin: e.builtin,
effect: e.effect,
path: e.path,
after,
group: e.group,
}),
})
.collect();
}
}
fn state() -> MutexGuard<'static, State> {
let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner());
hydrate(&mut s);
s
}
pub fn enabled() -> bool {
match std::env::var_os("IRONSHELL_JOURNAL")
.as_deref()
.and_then(|v| v.to_str())
{
Some("on") | Some("1") | Some("true") => true,
Some("off") | Some("0") | Some("false") => false,
_ => crate::safety::current_mode() == crate::safety::Mode::Agent,
}
}
fn is_journal_builtin(name: &str) -> bool {
matches!(
name,
"undo"
| "journal"
| "journal_clear"
| "rewind"
| "session_undo"
| "session_redo"
| "session_end"
| "session_history"
| "tx_begin"
| "tx_savepoint"
| "tx_commit"
| "tx_rollback"
| "tx_rollback_to"
)
}
fn total_bytes(entries: &[Entry]) -> usize {
entries
.iter()
.map(|e| match &e.before {
Before::Contents(b) => b.len(),
_ => 0,
})
.sum()
}
fn redo_bytes(entries: &[RedoEntry]) -> usize {
entries
.iter()
.map(|e| match &e.after {
Before::Contents(b) => b.len(),
_ => 0,
})
.sum()
}
fn held_bytes(s: &State) -> usize {
total_bytes(&s.journal) + redo_bytes(&s.redo)
}
const NEXT_ID_FILE: &str = "next_id";
fn read_next_id(dir: Option<&Path>) -> usize {
dir.and_then(|d| std::fs::read_to_string(d.join(NEXT_ID_FILE)).ok())
.and_then(|text| text.trim().parse::<usize>().ok())
.unwrap_or(0)
}
fn persist_next_id(dir: Option<PathBuf>, next: usize) {
let Some(dir) = dir else { return };
let path = dir.join(NEXT_ID_FILE);
let written =
make_private_dir(&dir).and_then(|()| write_private(&path, next.to_string().as_bytes()));
if let Err(err) = written {
tracing::warn!(
target: "journal",
"the next undo id was not persisted, so a later process may reuse one: {}: {err}",
path.display()
);
}
}
fn take_seq(s: &mut State) -> usize {
let dir = session_dir();
let held = s
.journal
.iter()
.map(|e| e.seq)
.chain(s.redo.iter().map(|r| r.seq))
.max()
.unwrap_or(0);
let id = s.next_id.max(read_next_id(dir.as_deref())).max(held + 1);
s.next_id = id + 1;
persist_next_id(dir, s.next_id);
id
}
fn written_path_argument(builtin: &str) -> Option<usize> {
let canonical = crate::signature::signature_of(builtin).map_or(builtin, |s| s.name);
match canonical {
"file_write" | "file_append" | "json_set" | "save_json" | "file_patch" | "file_replace"
| "file_insert" | "file_edit" | "touch" | "rm" | "tee" => Some(0),
"file_copy" => Some(1),
_ => None,
}
}
fn written_path_candidates(builtin: &str, args: &[Value]) -> Option<Vec<String>> {
let index = written_path_argument(builtin)?;
let usable = |v: Option<&Value>| match v {
Some(Value::Str(s))
if !s.is_empty()
&& s.len() <= 4096
&& !s.contains('\n')
&& !s.contains('\0')
&& !crate::safety::names_a_remote_share(s) =>
{
Some(crate::safety::resolve_path_str(s))
}
_ => None,
};
let out = match usable(args.get(index)) {
Some(mut target) => {
if index == 1 {
if let Some(source) = usable(args.first()) {
target = crate::builtins::file_copy_target(&source, &target);
}
}
vec![normalized_path(&target)]
}
None => Vec::new(),
};
Some(out)
}
pub fn normalized_path(path: &str) -> String {
let p = Path::new(path);
let absolute: PathBuf = if p.is_absolute() {
p.to_path_buf()
} else {
match std::env::current_dir() {
Ok(cwd) => cwd.join(p),
Err(_) => p.to_path_buf(),
}
};
let mut out = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
let last_is_name =
matches!(out.components().next_back(), Some(Component::Normal(_)));
let at_root = matches!(
out.components().next_back(),
Some(Component::RootDir) | Some(Component::Prefix(_))
);
if last_is_name {
out.pop();
} else if !at_root {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out.display().to_string()
}
fn candidate_paths(builtin: &str, args: &[Value]) -> Vec<String> {
if let Some(paths) = written_path_candidates(builtin, args) {
return paths;
}
let mut out = Vec::new();
for a in args {
if let Value::Str(s) = a {
if s.is_empty() || s.len() > 4096 || s.contains('\n') || s.contains('\0') {
continue;
}
if crate::safety::names_a_remote_share(s) {
continue;
}
let p = Path::new(s);
let plausible = p.is_file()
|| p.parent()
.map(|d| !d.as_os_str().is_empty() && d.is_dir())
.unwrap_or(false);
let absolute = normalized_path(s);
if plausible && !out.contains(&absolute) {
out.push(absolute);
}
}
}
out
}
fn capture(path: &str, used: usize) -> Result<Before, String> {
let p = Path::new(path);
if p.is_file() {
let size = std::fs::metadata(p)
.map(|m| m.len() as usize)
.map_err(|e| format!("unstattable: {e}"))?;
if size > MAX_FILE_BYTES {
return Err(format!(
"{size} bytes exceeds the {MAX_FILE_BYTES}-byte capture limit"
));
}
if used + size > MAX_TOTAL_BYTES {
return Err("session capture budget exhausted".into());
}
std::fs::read(p)
.map(Before::Contents)
.map_err(|e| format!("unreadable: {e}"))
} else if p.exists() {
Err("path is a directory; trees are not captured".into())
} else {
Ok(Before::Absent)
}
}
fn capture_state(path: &str, used: usize) -> Result<Before, String> {
let p = Path::new(path);
let is_real_dir = std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir());
if is_real_dir && std::fs::read_dir(p).is_ok_and(|mut d| d.next().is_none()) {
return Ok(Before::Directory);
}
capture(path, used)
}
pub fn record_before(builtin: &str, effect: Effect, args: &[Value]) {
NO_CHANGE.with(|c| c.set(false));
if !enabled() || is_journal_builtin(builtin) || records_its_own_steps(builtin) {
return;
}
if !matches!(effect, Effect::WriteLocal | Effect::Destructive) {
return;
}
let paths = candidate_paths(builtin, args);
let mut s = state();
discard_redo(&mut s);
for path in paths {
push_capture(&mut s, builtin, effect, path, None);
}
}
fn records_its_own_steps(builtin: &str) -> bool {
let canonical = crate::signature::signature_of(builtin).map_or(builtin, |s| s.name);
matches!(
canonical,
"file_move"
| "file_mkdir"
| "rmdir"
| "zip_extract"
| "tar_extract"
| "zip_create"
| "zip_add"
| "tar_create"
| "gzip_compress"
| "gzip_decompress"
| "bzip2_compress"
| "bzip2_decompress"
| "xz_compress"
| "xz_decompress"
| "zstd_compress"
| "zstd_decompress"
| "file_backup"
| "ssh_keygen"
| "db_sqlite_backup"
| "openssl_genrsa"
| "gpg_encrypt"
| "gpg_decrypt"
| "age_encrypt"
| "age_decrypt"
| "age_keygen"
| "dd_copy"
| "db_sqlite_create"
| "db_sqlite_open"
| "db_sqlite_insert"
| "db_sqlite_update"
| "db_sqlite_delete"
| "db_sqlite_create_table"
| "db_sqlite_drop_table"
| "db_sqlite_vacuum"
| "db_sqlite_import"
| "db_sqlite_to_csv"
| "db_json_to_sqlite"
| "db_kv_set"
| "db_kv_delete"
| "db_kv_store"
)
}
pub fn record_file_once(builtin: &str, path: &str) {
if !enabled() || is_journal_builtin(builtin) || path.is_empty() || path == ":memory:" {
return;
}
let normalized = normalized_path(path);
let mut s = state();
if let Some(last) = s.journal.last() {
if last.path == normalized && last.after.is_none() && last.reversible() {
return;
}
}
discard_redo(&mut s);
let group = Some(take_seq(&mut s));
push_capture(&mut s, builtin, Effect::WriteLocal, normalized, group);
}
#[allow(clippy::permissions_set_readonly_false)]
fn write_over(path: &str, bytes: &[u8]) -> std::io::Result<()> {
match std::fs::write(path, bytes) {
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let mut perms = std::fs::metadata(path)?.permissions();
if !perms.readonly() {
return Err(e);
}
#[cfg(unix)]
perms.set_mode(perms.mode() | 0o200);
#[cfg(not(unix))]
perms.set_readonly(false);
std::fs::set_permissions(path, perms)?;
std::fs::write(path, bytes)
}
other => other,
}
}
pub fn record_paths(builtin: &str, effect: Effect, paths: Vec<String>) {
if !enabled() || is_journal_builtin(builtin) {
return;
}
if !matches!(effect, Effect::WriteLocal | Effect::Destructive) {
return;
}
let mut s = state();
discard_redo(&mut s);
for path in paths {
push_capture(&mut s, builtin, effect, path, None);
}
}
pub fn record_group(builtin: &str, effect: Effect, paths: Vec<String>) {
if !enabled() || is_journal_builtin(builtin) {
return;
}
if !matches!(effect, Effect::WriteLocal | Effect::Destructive) {
return;
}
let mut s = state();
discard_redo(&mut s);
let group = Some(take_seq(&mut s));
for path in paths {
push_capture(&mut s, builtin, effect, path, group);
}
}
pub fn would_capture(path: &str) -> bool {
if !enabled() {
return false;
}
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
let size = meta.len() as usize;
size <= MAX_FILE_BYTES && held_bytes(&state()) + size <= MAX_TOTAL_BYTES
}
fn push_capture(s: &mut State, builtin: &str, effect: Effect, path: String, group: Option<usize>) {
let path = normalized_path(&path);
let used = held_bytes(s);
let before = match capture(&path, used) {
Ok(before) => before,
Err(why) => Before::Irreversible(why),
};
let seq = take_seq(s);
let entry = Entry {
seq,
builtin: builtin.to_string(),
effect: effect.as_str().to_string(),
path,
before,
after: None,
group,
};
persist(&entry);
s.journal.push(entry);
}
thread_local! {
static NO_CHANGE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub fn report_no_change() {
NO_CHANGE.with(|c| c.set(true));
}
pub fn take_no_change() -> bool {
NO_CHANGE.with(|c| c.replace(false))
}
pub fn record_irreversible(builtin: &str, effect: Effect, why: &str) {
if !enabled() || is_journal_builtin(builtin) {
return;
}
let mut s = state();
discard_redo(&mut s);
let seq = take_seq(&mut s);
let entry = Entry {
seq,
builtin: builtin.to_string(),
effect: effect.as_str().to_string(),
path: String::new(),
before: Before::Irreversible(why.to_string()),
after: None,
group: None,
};
persist(&entry);
s.journal.push(entry);
}
pub fn record_removed_directory(builtin: &str, effect: Effect, path: &str, tree_why: &str) {
if !enabled() || is_journal_builtin(builtin) {
return;
}
if !matches!(effect, Effect::WriteLocal | Effect::Destructive) {
return;
}
let path = normalized_path(path);
let mut s = state();
discard_redo(&mut s);
let before = match capture_state(&path, held_bytes(&s)) {
Ok(before) => before,
Err(_) => Before::Irreversible(tree_why.to_string()),
};
let seq = take_seq(&mut s);
let entry = Entry {
seq,
builtin: builtin.to_string(),
effect: effect.as_str().to_string(),
path,
before,
after: None,
group: None,
};
persist(&entry);
s.journal.push(entry);
}
pub fn settle(mark: usize, ok: bool) {
if !ok || take_no_change() {
rollback_to(mark);
} else {
seal_since(mark);
}
}
pub fn seal_steps(mark: usize) {
seal_since(mark);
}
fn seal_since(mark: usize) {
let mut s = state();
for e in s.journal.iter_mut().skip(mark) {
if e.after.is_some() || e.path.is_empty() || !e.reversible() {
continue;
}
if let Some(after) = Fingerprint::of_path(&e.path) {
e.after = Some(after);
persist(e);
}
}
}
fn discard_redo(s: &mut State) {
s.redo.clear();
if let Some(dir) = redo_dir() {
if let Err(err) = remove_if_present(std::fs::remove_dir_all(&dir)) {
warn_left_behind(&dir, &err);
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Outcome {
Restored(String),
Removed(String),
Reapplied(String),
Skipped {
what: String,
why: String,
},
Failed {
what: String,
why: String,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct Rewound {
pub seq: Option<usize>,
pub outcome: Outcome,
}
pub fn undo(n: usize, force: bool) -> Result<Vec<Rewound>, (String, bool)> {
let mut s = state();
let mut out = Vec::new();
let mut steps = 0;
let mut group: Option<usize> = None;
while let Some(top) = s.journal.last() {
let top_group = top.group;
if group.is_none() || top_group != group {
if steps == n {
break;
}
steps += 1;
group = top_group;
}
let index = s.journal.len() - 1;
match undo_at(&mut s, index, force, &mut out) {
Step::Next => {}
Step::Stop => {
report_not_reached(&s, index, n, steps, group, &mut out);
break;
}
Step::Refused(refusal) => return Err(refusal),
}
}
Ok(out)
}
fn report_not_reached(
s: &State,
index: usize,
n: usize,
mut steps: usize,
mut group: Option<usize>,
out: &mut Vec<Rewound>,
) {
let blocker = s.journal.get(index).map(|e| e.seq);
for e in s.journal[..index.min(s.journal.len())].iter().rev() {
if group.is_none() || e.group != group {
if steps == n {
break;
}
steps += 1;
group = e.group;
}
let what = if e.path.is_empty() {
e.builtin.clone()
} else {
e.path.clone()
};
let why = match blocker {
Some(b) => format!(
"not reached: the undo stopped at step {b} and does not go past it; this step is still in the journal"
),
None => "not reached: the undo stopped at a newer step and does not go past it; this step is still in the journal".to_string(),
};
out.push(Rewound {
seq: Some(e.seq),
outcome: Outcome::Skipped { what, why },
});
}
}
enum Step {
Next,
Stop,
Refused((String, bool)),
}
fn undo_at(s: &mut State, index: usize, force: bool, out: &mut Vec<Rewound>) -> Step {
let seq = s.journal.get(index).map(|e| e.seq);
if let Some(why) = s
.journal
.get(index)
.and_then(|e| crate::safety::rewind_refused(&e.path))
{
let what = s
.journal
.get(index)
.map(|e| e.path.clone())
.unwrap_or_default();
out.push(Rewound {
seq,
outcome: Outcome::Skipped { what, why },
});
return Step::Stop;
}
let Some(entry) = s.journal.get(index).cloned() else {
return Step::Stop;
};
let used = held_bytes(s).saturating_sub(total_bytes(std::slice::from_ref(&entry)));
let what = if entry.path.is_empty() {
entry.builtin.clone()
} else {
entry.path.clone()
};
if let (false, Some(sealed)) = (force, &entry.after) {
let now = Fingerprint::of_path(&entry.path);
let restores = matches!(entry.before, Before::Contents(_) | Before::Directory);
let removed =
restores && now == Some(Fingerprint::Absent) && *sealed != Fingerprint::Absent;
let changed = if now == Some(Fingerprint::Absent) {
removed
} else {
now.as_ref() != Some(sealed)
};
if changed {
if out.is_empty() {
return Step::Refused((entry.path.clone(), removed));
}
out.push(Rewound {
seq,
outcome: Outcome::Skipped {
what,
why: if removed {
REMOVED_SINCE_STEP.to_string()
} else {
CHANGED_SINCE_STEP.to_string()
},
},
});
return Step::Stop;
}
}
let after = capture_state(&entry.path, used);
if !matches!(entry.before, Before::Irreversible(_)) && Path::new(&entry.path).is_file() {
if let Err(why) = &after {
out.push(Rewound {
seq,
outcome: Outcome::Skipped {
what,
why: format!(
"current contents could not be kept for redo, so left as they are: {why}. Move the file away to undo this step"
),
},
});
return Step::Stop;
}
}
let outcome = match &entry.before {
Before::Contents(bytes) => match write_over(&entry.path, bytes) {
Ok(()) => {
push_redo(s, &entry, after);
Outcome::Restored(what)
}
Err(e) => Outcome::Failed {
what,
why: e.to_string(),
},
},
Before::Absent => {
let outcome = remove_created(&entry.path, what, ABSENT_BEFORE_AND_NOW);
if matches!(outcome, Outcome::Removed(_)) {
push_redo(s, &entry, after);
}
outcome
}
Before::Directory => {
let outcome = restore_directory(&entry.path, what);
if matches!(outcome, Outcome::Restored(_)) {
push_redo(s, &entry, after);
}
outcome
}
Before::Irreversible(why) => Outcome::Skipped {
what,
why: why.clone(),
},
};
if matches!(outcome, Outcome::Failed { .. }) {
out.push(Rewound { seq, outcome });
return Step::Stop;
}
s.journal.remove(index);
forget_persisted(entry.seq);
out.push(Rewound { seq, outcome });
Step::Next
}
#[derive(Debug)]
pub enum UndoRefusal {
NotFound,
LaterStep { seq: usize, path: String },
Changed { path: String, removed: bool },
}
pub fn undo_by_id(seq: usize, force: bool) -> Result<Vec<Rewound>, UndoRefusal> {
let mut s = state();
let first = s
.journal
.iter()
.position(|e| e.seq == seq)
.ok_or(UndoRefusal::NotFound)?;
let group = s.journal[first].group;
let members: Vec<usize> = match group {
None => vec![first],
Some(g) => (0..s.journal.len())
.filter(|&i| s.journal[i].group == Some(g))
.collect(),
};
let last = members.last().copied().unwrap_or(first);
if !force {
for later in s.journal.iter().skip(last + 1) {
if !later.path.is_empty() && members.iter().any(|&m| s.journal[m].path == later.path) {
return Err(UndoRefusal::LaterStep {
seq: later.seq,
path: later.path.clone(),
});
}
}
}
let mut out = Vec::new();
for &index in members.iter().rev() {
match undo_at(&mut s, index, force, &mut out) {
Step::Next => {}
Step::Stop => break,
Step::Refused((path, removed)) => return Err(UndoRefusal::Changed { path, removed }),
}
}
Ok(out)
}
const CHANGED_SINCE_STEP: &str = "changed after this step, so undoing would discard those changes; left as it is. Read the file, or undo({force: true}) to restore it anyway";
const REMOVED_SINCE_STEP: &str = "was removed after this step; undoing would recreate it; left as it is. undo({force: true}) recreates it";
const ABSENT_BEFORE_AND_NOW: &str = "was absent before and is absent now";
const NOT_EMPTY_SINCE_STEP: &str = "holds files this step did not record, so it is left as it is";
fn remove_created(path: &str, what: String, absent_why: &str) -> Outcome {
let p = Path::new(path);
if p.is_file() {
return match std::fs::remove_file(p) {
Ok(()) => Outcome::Removed(what),
Err(e) => Outcome::Failed {
what,
why: e.to_string(),
},
};
}
if std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir()) {
return match std::fs::remove_dir(p) {
Ok(()) => Outcome::Removed(what),
Err(e) if crate::safety::dir_not_empty(&e) => Outcome::Skipped {
what,
why: NOT_EMPTY_SINCE_STEP.into(),
},
Err(e) => Outcome::Failed {
what,
why: e.to_string(),
},
};
}
Outcome::Skipped {
what,
why: absent_why.into(),
}
}
fn restore_directory(path: &str, what: String) -> Outcome {
let p = Path::new(path);
match std::fs::symlink_metadata(p) {
Ok(m) if m.is_dir() => {
if std::fs::read_dir(p).is_ok_and(|mut d| d.next().is_none()) {
Outcome::Restored(what)
} else {
Outcome::Skipped {
what,
why: "exists now and holds files, so it is left as it is".into(),
}
}
}
Ok(_) => Outcome::Failed {
what,
why: "a file is in its place".into(),
},
Err(_) => match std::fs::create_dir(p) {
Ok(()) => Outcome::Restored(what),
Err(e) => Outcome::Failed {
what,
why: e.to_string(),
},
},
}
}
fn push_redo(s: &mut State, undone: &Entry, after: Result<Before, String>) {
let Ok(after) = after else { return };
let r = RedoEntry {
seq: undone.seq,
builtin: undone.builtin.clone(),
effect: undone.effect.clone(),
path: undone.path.clone(),
after,
group: undone.group,
};
persist_redo(&r);
s.redo.push(r);
}
pub fn redo(n: usize) -> Vec<Outcome> {
let mut s = state();
let mut out = Vec::new();
let mut steps = 0;
let mut group: Option<usize> = None;
while let Some(top) = s.redo.last() {
if group.is_none() || top.group != group {
if steps == n {
break;
}
steps += 1;
group = top.group;
}
if let Some(why) = s
.redo
.last()
.and_then(|r| crate::safety::rewind_refused(&r.path))
{
let what = s.redo.last().map(|r| r.path.clone()).unwrap_or_default();
out.push(Outcome::Skipped { what, why });
break;
}
let Some(r) = s.redo.last().cloned() else {
break;
};
let used = held_bytes(&s).saturating_sub(redo_bytes(std::slice::from_ref(&r)));
let what = if r.path.is_empty() {
r.builtin.clone()
} else {
r.path.clone()
};
let current = match capture_state(&r.path, used) {
Ok(current) => current,
Err(why) => {
out.push(Outcome::Skipped {
what,
why: format!("current state not captured, so left as it is: {why}"),
});
break;
}
};
let outcome = match &r.after {
Before::Contents(bytes) => match write_over(&r.path, bytes) {
Ok(()) => Outcome::Reapplied(what),
Err(e) => Outcome::Failed {
what,
why: e.to_string(),
},
},
Before::Absent => {
remove_created(&r.path, what, "was absent after the step and is absent now")
}
Before::Directory => match restore_directory(&r.path, what) {
Outcome::Restored(what) => Outcome::Reapplied(what),
other => other,
},
Before::Irreversible(why) => Outcome::Skipped {
what,
why: why.clone(),
},
};
if matches!(outcome, Outcome::Failed { .. }) {
out.push(outcome);
break;
}
s.redo.pop();
forget_redo_persisted(r.seq);
if matches!(outcome, Outcome::Reapplied(_) | Outcome::Removed(_)) {
let seq = take_seq(&mut s);
let entry = Entry {
seq,
builtin: r.builtin,
effect: r.effect,
path: r.path,
before: current,
after: Fingerprint::of_before(&r.after),
group: r.group,
};
persist(&entry);
s.journal.push(entry);
}
out.push(outcome);
}
out
}
pub fn entries() -> Vec<Entry> {
let s = state();
s.journal
.iter()
.map(|e| Entry {
before: match &e.before {
Before::Contents(b) => Before::Contents(vec![0; b.len().min(1)]),
other => other.clone(),
},
..e.clone()
})
.collect()
}
pub fn undone() -> Vec<RedoEntry> {
let s = state();
s.redo
.iter()
.map(|r| RedoEntry {
after: match &r.after {
Before::Contents(b) => Before::Contents(vec![0; b.len().min(1)]),
other => other.clone(),
},
..r.clone()
})
.collect()
}
pub fn redo_count() -> usize {
state().redo.len()
}
pub fn reversible_count() -> usize {
state().journal.iter().filter(|e| e.reversible()).count()
}
pub fn mark() -> usize {
state().journal.len()
}
pub fn rollback_to(mark: usize) {
let mut s = state();
while s.journal.len() > mark {
if let Some(e) = s.journal.pop() {
forget_persisted(e.seq);
}
}
}
pub fn clear() {
if let Err((dir, err)) = try_clear() {
warn_left_behind(&dir, &err);
}
}
pub fn try_clear() -> Result<(), (PathBuf, std::io::Error)> {
let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner());
s.journal.clear();
s.redo.clear();
let next = s.next_id.max(read_next_id(session_dir().as_deref()));
if let Some(dir) = session_dir() {
let attempts = if cfg!(windows) { REMOVE_ATTEMPTS } else { 1 };
let removed = remove_dir_retrying(
&dir,
attempts,
|d| std::fs::remove_dir_all(d),
std::thread::sleep,
);
remove_if_present(removed).map_err(|err| (dir.clone(), err))?;
if next > 0 {
persist_next_id(Some(dir), next);
}
}
Ok(())
}
const REMOVE_ATTEMPTS: u32 = 5;
const REMOVE_PAUSES: [std::time::Duration; 4] = [
std::time::Duration::from_millis(50),
std::time::Duration::from_millis(100),
std::time::Duration::from_millis(150),
std::time::Duration::from_millis(200),
];
fn remove_dir_retrying(
dir: &Path,
attempts: u32,
mut remove: impl FnMut(&Path) -> std::io::Result<()>,
mut pause: impl FnMut(std::time::Duration),
) -> std::io::Result<()> {
let mut tries = 1;
loop {
match remove(dir) {
Ok(()) => return Ok(()),
Err(e)
if tries < attempts
&& (e.kind() == std::io::ErrorKind::PermissionDenied
|| e.raw_os_error() == Some(32)) =>
{
let wait = REMOVE_PAUSES[(tries as usize - 1).min(REMOVE_PAUSES.len() - 1)];
pause(wait);
tries += 1;
}
Err(e) => return Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn access_denied() -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Access is denied.")
}
#[test]
fn a_refusal_to_remove_that_goes_away_is_retried() {
let mut calls = 0;
let mut pauses = Vec::new();
let r = remove_dir_retrying(
Path::new("journal"),
REMOVE_ATTEMPTS,
|_| {
calls += 1;
if calls < 3 {
Err(access_denied())
} else {
Ok(())
}
},
|p| pauses.push(p),
);
assert!(r.is_ok());
assert_eq!(calls, 3);
assert_eq!(pauses.len(), 2);
}
#[test]
fn a_refusal_that_persists_is_reported_within_half_a_second() {
let mut calls = 0;
let mut waited = std::time::Duration::ZERO;
let r = remove_dir_retrying(
Path::new("journal"),
REMOVE_ATTEMPTS,
|_| {
calls += 1;
Err(access_denied())
},
|p| waited += p,
);
assert_eq!(r.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied);
assert_eq!(calls, REMOVE_ATTEMPTS);
assert!(
waited <= std::time::Duration::from_millis(500),
"{waited:?}"
);
}
#[test]
fn an_error_that_is_not_a_transient_refusal_is_not_retried() {
let mut calls = 0;
let r = remove_dir_retrying(
Path::new("journal"),
REMOVE_ATTEMPTS,
|_| {
calls += 1;
Err(std::io::Error::other("disk on fire"))
},
|_| panic!("paused before a non-transient error"),
);
assert!(r.is_err());
assert_eq!(calls, 1);
}
#[test]
fn an_oversized_file_is_recorded_as_irreversible_not_skipped() {
let e = Entry {
seq: 1,
builtin: "file_write".into(),
effect: "write_local".into(),
path: "big.bin".into(),
before: Before::Irreversible("too big".into()),
after: None,
group: None,
};
assert!(!e.reversible());
}
#[test]
fn a_directory_target_is_refused_rather_than_half_captured() {
let e = Entry {
seq: 1,
builtin: "rm".into(),
effect: "destructive".into(),
path: ".".into(),
before: Before::Irreversible("path is a directory; trees are not captured".into()),
after: None,
group: None,
};
assert!(!e.reversible());
}
#[test]
fn journal_builtins_are_never_recorded() {
assert!(is_journal_builtin("undo"));
assert!(is_journal_builtin("journal"));
assert!(is_journal_builtin("session_redo"));
assert!(!is_journal_builtin("file_write"));
}
#[cfg(unix)]
#[test]
fn a_persisted_entry_is_private_and_tightens_its_directory() {
use std::os::unix::fs::PermissionsExt;
let dir =
std::env::temp_dir().join(format!("ironshell-journal-private-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
let stale = dir.join("00000001.json");
std::fs::write(&stale, b"{}").unwrap();
std::fs::set_permissions(&stale, std::fs::Permissions::from_mode(0o644)).unwrap();
let e = Entry {
seq: 1,
builtin: "file_write".into(),
effect: "write_local".into(),
path: "secret.txt".into(),
before: Before::Contents(b"secret".to_vec()),
after: None,
group: None,
};
persist_in(Some(dir.clone()), &e);
let dir_mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
let file_mode = std::fs::metadata(&stale).unwrap().permissions().mode() & 0o777;
let stored = std::fs::read(&stale).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(dir_mode, 0o700);
assert_eq!(file_mode, 0o600);
assert!(
serde_json::from_slice::<StoredEntry>(&stored).is_ok(),
"the entry was written"
);
}
}