use crate::safety::Effect;
use crate::value::Value;
use std::path::Path;
use std::sync::Mutex;
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(_))
}
}
lazy_static::lazy_static! {
static ref JOURNAL: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
}
fn session_dir() -> Option<std::path::PathBuf> {
if let Ok(d) = std::env::var("AETHER_SESSION_DIR") {
return Some(std::path::PathBuf::from(d).join("journal"));
}
let key = std::env::var("AETHER_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("aethershell").join("journal").join(key))
}
#[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_persisted() -> Vec<Entry> {
let Some(dir) = session_dir() else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut files: Vec<(usize, std::path::PathBuf)> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let p = e.path();
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 persist(e: &Entry) {
let Some(dir) = session_dir() else { return };
if std::fs::create_dir_all(&dir).is_err() {
return;
}
if let Ok(json) = serde_json::to_vec(&StoredEntry::from(e)) {
let _ = std::fs::write(dir.join(format!("{:08}.json", e.seq)), json);
}
}
fn forget_persisted(seq: usize) {
if let Some(dir) = session_dir() {
let _ = std::fs::remove_file(dir.join(format!("{seq:08}.json")));
}
}
fn hydrate(j: &mut Vec<Entry>) {
if !j.is_empty() {
return;
}
*j = load_persisted();
}
pub fn enabled() -> bool {
match std::env::var("AETHER_JOURNAL").ok().as_deref() {
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")
}
fn total_bytes(entries: &[Entry]) -> usize {
entries
.iter()
.map(|e| match &e.before {
Before::Contents(b) => b.len(),
_ => 0,
})
.sum()
}
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;
}
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
}
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);
if paths.is_empty() {
return;
}
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
hydrate(&mut j);
let used = total_bytes(&j);
for path in paths {
let p = Path::new(&path);
let before = if p.is_file() {
match std::fs::metadata(p).map(|m| m.len() as usize) {
Ok(sz) if sz > MAX_FILE_BYTES => Before::Irreversible(format!(
"{sz} bytes exceeds the {MAX_FILE_BYTES}-byte capture limit"
)),
Ok(sz) if used + sz > MAX_TOTAL_BYTES => {
Before::Irreversible("session capture budget exhausted".into())
}
Ok(_) => match std::fs::read(p) {
Ok(bytes) => Before::Contents(bytes),
Err(e) => Before::Irreversible(format!("unreadable: {e}")),
},
Err(e) => Before::Irreversible(format!("unstattable: {e}")),
}
} else if p.exists() {
Before::Irreversible("path is a directory; trees are not captured".into())
} else {
Before::Absent
};
let seq = j.len() + 1;
let entry = Entry {
seq,
builtin: builtin.to_string(),
effect: effect.as_str().to_string(),
path,
before,
};
persist(&entry);
j.push(entry);
}
}
pub fn record_irreversible(builtin: &str, effect: Effect, why: &str) {
if !enabled() || is_journal_builtin(builtin) {
return;
}
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
hydrate(&mut j);
let seq = j.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);
j.push(entry);
}
#[derive(Clone, Debug, PartialEq)]
pub enum Outcome {
Restored(String),
Removed(String),
Skipped { what: String, why: String },
Failed { what: String, why: String },
}
pub fn undo(n: usize) -> Vec<Outcome> {
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
hydrate(&mut j);
let mut out = Vec::new();
for _ in 0..n {
let Some(entry) = j.pop() else { break };
forget_persisted(entry.seq);
let what = if entry.path.is_empty() {
entry.builtin.clone()
} else {
entry.path.clone()
};
match entry.before {
Before::Contents(bytes) => match std::fs::write(&entry.path, &bytes) {
Ok(()) => out.push(Outcome::Restored(what)),
Err(e) => out.push(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(()) => out.push(Outcome::Removed(what)),
Err(e) => out.push(Outcome::Failed {
what,
why: e.to_string(),
}),
}
} else {
out.push(Outcome::Skipped {
what,
why: "was absent before and is absent now".into(),
});
}
}
Before::Irreversible(why) => out.push(Outcome::Skipped { what, why }),
}
}
out
}
pub fn entries() -> Vec<Entry> {
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
hydrate(&mut j);
j.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 reversible_count() -> usize {
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
hydrate(&mut j);
j.iter().filter(|e| e.reversible()).count()
}
pub fn mark() -> usize {
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
hydrate(&mut j);
j.len()
}
pub fn rollback_to(mark: usize) {
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
while j.len() > mark {
if let Some(e) = j.pop() {
forget_persisted(e.seq);
}
}
}
pub fn clear() {
JOURNAL.lock().unwrap_or_else(|e| e.into_inner()).clear();
if let Some(dir) = session_dir() {
let _ = std::fs::remove_dir_all(dir);
}
}
#[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("file_write"));
}
}