use crate::safety::Effect;
use crate::value::Value;
use std::path::{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,
Irreversible(String),
}
#[derive(Clone, Debug)]
pub struct Entry {
pub seq: usize,
pub builtin: String,
pub effect: String,
pub path: String,
pub before: Before,
}
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,
}
#[derive(Default)]
struct State {
journal: Vec<Entry>,
redo: Vec<RedoEntry>,
}
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>,
}
impl From<&Entry> for StoredEntry {
fn from(e: &Entry) -> Self {
let (contents, irreversible) = match &e.before {
Before::Contents(b) => (Some(b.clone()), None),
Before::Absent => (None, None),
Before::Irreversible(why) => (None, Some(why.clone())),
};
StoredEntry {
seq: e.seq,
builtin: e.builtin.clone(),
effect: e.effect.clone(),
path: e.path.clone(),
contents,
irreversible,
}
}
}
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) => Before::Absent,
};
Entry {
seq: s.seq,
builtin: s.builtin,
effect: s.effect,
path: s.path,
before,
}
}
}
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(),
}
}
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,
}),
})
.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)
}
fn candidate_paths(args: &[Value]) -> Vec<String> {
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);
if plausible && !out.contains(s) {
out.push(s.clone());
}
}
}
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)
}
}
pub fn record_before(builtin: &str, effect: Effect, args: &[Value]) {
if !enabled() || is_journal_builtin(builtin) {
return;
}
if !matches!(effect, Effect::WriteLocal | Effect::Destructive) {
return;
}
let paths = candidate_paths(args);
let mut s = state();
discard_redo(&mut s);
for path in paths {
let used = held_bytes(&s);
let before = match capture(&path, used) {
Ok(before) => before,
Err(why) => Before::Irreversible(why),
};
let seq = s.journal.len() + 1;
let entry = Entry {
seq,
builtin: builtin.to_string(),
effect: effect.as_str().to_string(),
path,
before,
};
persist(&entry);
s.journal.push(entry);
}
}
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 = s.journal.len() + 1;
let entry = Entry {
seq,
builtin: builtin.to_string(),
effect: effect.as_str().to_string(),
path: String::new(),
before: Before::Irreversible(why.to_string()),
};
persist(&entry);
s.journal.push(entry);
}
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,
},
}
pub fn undo(n: usize) -> Vec<Outcome> {
let mut s = state();
let mut out = Vec::new();
for _ in 0..n {
if let Some(why) = s
.journal
.last()
.and_then(|e| crate::safety::rewind_refused(&e.path))
{
let what = s.journal.last().map(|e| e.path.clone()).unwrap_or_default();
out.push(Outcome::Skipped { what, why });
break;
}
let Some(entry) = s.journal.last().cloned() else {
break;
};
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()
};
let after = capture(&entry.path, used);
if !matches!(entry.before, Before::Irreversible(_)) && Path::new(&entry.path).is_file() {
if let Err(why) = &after {
out.push(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"
),
});
break;
}
}
let outcome = match &entry.before {
Before::Contents(bytes) => match std::fs::write(&entry.path, bytes) {
Ok(()) => {
push_redo(&mut s, &entry, after);
Outcome::Restored(what)
}
Err(e) => Outcome::Failed {
what,
why: e.to_string(),
},
},
Before::Absent => {
let p = Path::new(&entry.path);
if p.is_file() {
match std::fs::remove_file(p) {
Ok(()) => {
push_redo(&mut s, &entry, after);
Outcome::Removed(what)
}
Err(e) => Outcome::Failed {
what,
why: e.to_string(),
},
}
} else {
Outcome::Skipped {
what,
why: "was absent before and is absent now".into(),
}
}
}
Before::Irreversible(why) => Outcome::Skipped {
what,
why: why.clone(),
},
};
if matches!(outcome, Outcome::Failed { .. }) {
out.push(outcome);
break;
}
s.journal.pop();
forget_persisted(entry.seq);
out.push(outcome);
}
out
}
fn push_redo(s: &mut State, undone: &Entry, after: Result<Before, String>) {
let Ok(after) = after else { return };
let r = RedoEntry {
seq: s.redo.len() + 1,
builtin: undone.builtin.clone(),
effect: undone.effect.clone(),
path: undone.path.clone(),
after,
};
persist_redo(&r);
s.redo.push(r);
}
pub fn redo(n: usize) -> Vec<Outcome> {
let mut s = state();
let mut out = Vec::new();
for _ in 0..n {
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(&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 std::fs::write(&r.path, bytes) {
Ok(()) => Outcome::Reapplied(what),
Err(e) => Outcome::Failed {
what,
why: e.to_string(),
},
},
Before::Absent => {
if Path::new(&r.path).is_file() {
match std::fs::remove_file(&r.path) {
Ok(()) => Outcome::Removed(what),
Err(e) => Outcome::Failed {
what,
why: e.to_string(),
},
}
} else {
Outcome::Skipped {
what,
why: "was absent after the step and is absent now".into(),
}
}
}
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 entry = Entry {
seq: s.journal.len() + 1,
builtin: r.builtin,
effect: r.effect,
path: r.path,
before: current,
};
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();
}
if let Some(dir) = session_dir() {
remove_if_present(std::fs::remove_dir_all(&dir)).map_err(|err| (dir, err))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[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()),
};
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()),
};
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()),
};
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"
);
}
}