diff --git a/builtin/runtime/autosave.lua b/builtin/runtime/autosave.lua new file mode 100644 index 0000000..7f005b7 --- /dev/null +++ b/builtin/runtime/autosave.lua @@ -0,0 +1,193 @@ +-- autosave.lua --- periodic recovery copies + crash recovery (Arc 3 phase 3). +-- +-- Every `interval_ms` a sweep writes a private recovery copy of each +-- modified file buffer. If pmacs dies, the next session notices the copy +-- and says so; `M-x recover-file` installs it. Emacs's `auto-save-mode` +-- + `recover-file`. +-- +-- The Rust half (`pmacs.autosave._*`) does the sweep and the +-- external-change guard; this file owns the cadence, the configurable +-- interval, and the UX. +-- +-- On by default. Configure from init.lua: +-- pmacs.autosave.interval_ms(60000) -- default 30000, floor 1000 +-- pmacs.autosave.enable(false) -- turn it off entirely +-- +-- Recovery files live under `$XDG_STATE_HOME/pmacs/autosave/` (0700 dir, +-- 0600 files), are deleted when the buffer is saved or killed, and +-- survive a crash or a quit with unsaved changes. +-- +-- Framing: docs/autosave-recovery-framing.md. + +pmacs.autosave = pmacs.autosave or {} + +local DEFAULT_INTERVAL_MS = 30000 -- Emacs's auto-save-timeout +local MIN_INTERVAL_MS = 1000 -- each sweep fsyncs; don't storm + +local interval = DEFAULT_INTERVAL_MS +local enabled = true +local last_sweep_ms = nil +-- Report on the first tick (the startup scan), and after every load. +local needs_report = true + +-- enable(on) --- turn autosave off (or back on). +function pmacs.autosave.enable(on) + enabled = (on ~= false) + return enabled +end + +-- interval_ms([ms]) --- getter when `ms` is nil, else a validated setter. +-- Shape follows `pmacs.async_config.frame_target_ms`. The tick re-reads +-- this every frame, so a change takes effect immediately -- no restart. +function pmacs.autosave.interval_ms(ms) + if ms == nil then return interval end + if type(ms) ~= "number" or ms ~= ms or ms < MIN_INTERVAL_MS then + error("pmacs.autosave.interval_ms: expected a number >= " .. MIN_INTERVAL_MS) + end + interval = math.floor(ms) + return interval +end + +-- sweep() --- force a pass now. Returns how many buffers were written. +function pmacs.autosave.sweep() + if not enabled then return 0 end + return pmacs.autosave._sweep() +end + +local function basename(path) + return path:match("[^/]+$") or path +end + +-- One aggregate message however many files are recoverable. N synchronous +-- `after-load` fires (a desktop restore) collapse into a single report. +local function report_pending() + local fresh = pmacs.autosave._pending() + local n = #fresh + if n == 1 then + pmacs.editor.set_status(basename(fresh[1]) .. " has autosave recovery --- M-x recover-file") + elseif n > 1 then + pmacs.editor.set_status(n .. " files have autosave recovery --- M-x recover-file") + end + -- Corrupt copies are counted but stay quiet: a malformed recovery file + -- must not make startup noisy. `M-x discard-recovery` removes them. +end + +-- The cadence (Q#AS2). `process.after-tick` fires every frame -- and the +-- run loops tick on a frame *timeout*, not only on input, so this keeps +-- running while the editor is idle. Costs one clock read + a compare per +-- frame, and parks no worker thread (a long `workers.sleep` would hold +-- one of only `available_parallelism - 1` pool threads). +pmacs.hook.add("process.after-tick", function() + if needs_report then + needs_report = false + pcall(report_pending) + end + if not enabled then return end + local now = pmacs.editor.monotonic_ms() + if last_sweep_ms == nil then + last_sweep_ms = now + return + end + if now - last_sweep_ms >= interval then + last_sweep_ms = now + pcall(pmacs.autosave._sweep) + end +end) + +-- A load may reveal a recovery file; report on the next tick (never from +-- inside the hook -- a desktop restore fires this once per leaf). +pmacs.hook.add("buffer.after-load", function() + needs_report = true + -- A clean save or a kill retires the recovery copy. There is no global + -- kill hook, so register per buffer, capturing the path now (the buffer + -- may be gone by the time the callback runs). + local path = pmacs.editor.file_path() + if not path then return end + local buf = pmacs.window.buffer() + if not buf then return end + pcall(pmacs.buffer.on_removed, buf, function() + pcall(pmacs.autosave._discard, path) + end) +end) + +pmacs.hook.add("buffer.after-save", function() + local path = pmacs.editor.file_path() + if path then pcall(pmacs.autosave._discard, path) end +end) + +-- A final synchronous sweep on quit: async ticks stop after this, so a +-- quit with unsaved changes must capture them here. Returns nil -- +-- before-quit is short-circuit and this must never veto. +pmacs.hook.add("editor.before-quit", function() + pcall(pmacs.autosave.sweep) +end) + +pmacs.command.define { + name = "recover-file", + description = "Replace this buffer with its autosave recovery copy.", + fn = function() + local path = pmacs.editor.file_path() + if not path then + pmacs.editor.set_status("recover-file: not visiting a file") + return + end + local st = pmacs.autosave._status(path) + if st == "none" then + pmacs.editor.set_status("recover-file: no autosave recovery for this file") + return + end + if st == "corrupt" then + pmacs.editor.set_status("recover-file: recovery file is corrupt --- M-x discard-recovery") + return + end + local warn = "" + if st == "stale" then + warn = " [WARNING: file changed on disk since the autosave]" + end + pmacs.minibuffer.read { + prompt = "Recover from autosave?" .. warn .. " (yes/no): ", + source = function() return { "yes", "no" } end, + on_accept = function(answer) + if answer ~= "yes" then + pmacs.editor.set_status("recover-file: cancelled") + return + end + -- Pin to the buffer we started on: focus can drift while the + -- prompt is up, and recovering into the wrong buffer is + -- unrecoverable. + if pmacs.editor.file_path() ~= path then + pmacs.editor.set_status("recover-file: buffer changed; aborted") + return + end + local bytes = pmacs.autosave._recover_bytes(path) + if not bytes then + pmacs.editor.set_status("recover-file: recovery unreadable") + return + end + local buf = pmacs.window.buffer() + buf:replace(0, buf:len(), bytes) + -- The mutators notify windows and queue CRDT but do NOT fire + -- `buffer.after-edit` --- that comes from dispatch_key's + -- post-command revision check, which the minibuffer shadow + -- returns before. Fire it so LSP didChange and the syntax + -- reparse see the recovered contents. + pmacs.hook.run("buffer.after-edit") + pmacs.editor.set_status("recovered from autosave --- save to keep it") + end, + } + end, +} + +pmacs.command.define { + name = "discard-recovery", + description = "Delete this file's autosave recovery copy.", + fn = function() + local path = pmacs.editor.file_path() + if not path then + pmacs.editor.set_status("discard-recovery: not visiting a file") + return + end + pmacs.autosave._discard(path) + pmacs.editor.set_status("discarded autosave recovery") + end, +} diff --git a/src/autosave.rs b/src/autosave.rs new file mode 100644 index 0000000..bfccb03 --- /dev/null +++ b/src/autosave.rs @@ -0,0 +1,481 @@ +// autosave.rs --- periodic recovery copies + crash recovery (Arc 3 phase 3). + +//! Every modified file buffer is periodically written to a private +//! recovery file under `$XDG_STATE_HOME/pmacs/autosave/`. If pmacs dies, +//! the next session notices the copy and offers `M-x recover-file`. +//! Emacs's `auto-save-mode` + `recover-file`. +//! +//! This module owns the parts Lua cannot do: enumerating **all** buffers' +//! paths (Lua has no per-buffer path getter), and the `FileMeta` +//! external-change guard (`FileMeta` is neither Lua-visible nor serde). +//! `builtin/runtime/autosave.lua` owns the cadence, the configurable +//! interval, and the recovery UX. +//! +//! Recovery files are written `0600` under a `0700` directory +//! (Q#AS11) — they hold *unsaved file contents*, a different class of +//! secret from saveplace's cursor offsets. +//! +//! Framing: docs/autosave-recovery-framing.md. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::Path; + +use mlua::Lua; +use serde::{Deserialize, Serialize}; + +use crate::buffer::BufferId; +use crate::file_io::FileMeta; +use crate::hash::sha256_hex; +use crate::lua_bindings::{SharedCore, StateDir}; + +/// Bump when the envelope shape changes incompatibly. A recovery file +/// with an unrecognized version reads as [`RecoveryStatus::Corrupt`] — +/// never silently applied. +pub const AUTOSAVE_VERSION: u32 = 1; + +/// The header of a recovery file: one line of JSON, then `\n`, then the +/// raw buffer bytes (Q#AS4). One atomic write, so a crash can never leave +/// a torn header/contents pair. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Header { + version: u32, + /// The buffer's path, for provenance and orphan inspection. + path: String, + /// The origin file's identity when this copy was taken. + /// + /// **Nullable**: a `[new file]` buffer (a path that does not exist on + /// disk yet) has no `file_meta`, and its unsaved contents are exactly + /// the work most worth recovering. `None` means "there was no file on + /// disk when this was autosaved". + origin: Option, +} + +/// `FileMeta` hand-serialized — it is not serde, and `SystemTime` has no +/// stable wire form. Stored as an offset from the Unix epoch so the +/// comparison is exact; we never reconstruct a `SystemTime`, only compare +/// these parts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct Origin { + mtime_secs: i64, + mtime_nanos: u32, + size: u64, +} + +impl Origin { + fn from_meta(m: &FileMeta) -> Self { + let (mtime_secs, mtime_nanos) = match m.mtime.duration_since(std::time::UNIX_EPOCH) { + Ok(d) => ( + i64::try_from(d.as_secs()).unwrap_or(i64::MAX), + d.subsec_nanos(), + ), + // Pre-epoch mtimes are exotic but representable. + Err(e) => { + let d = e.duration(); + ( + i64::try_from(d.as_secs()).map_or(i64::MIN, |s| -s), + d.subsec_nanos(), + ) + } + }; + Self { + mtime_secs, + mtime_nanos, + size: m.size, + } + } +} + +/// What a recovery file means for a given path (Q#AS5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecoveryStatus { + /// No recovery file. + None, + /// The on-disk file is unchanged since the copy was taken (or is + /// still absent, for a `[new file]`), so the recovery is strictly + /// newer. The only status that is announced. + Fresh, + /// The file changed underneath us — externally edited, deleted, or + /// (for a `[new file]`) created by someone else. Never auto-offered: + /// silently clobbering it is the one unrecoverable mistake here. + Stale, + /// Unparseable or unrecognized version. Never offered, never errors; + /// `discard-recovery` removes it. + Corrupt, +} + +impl RecoveryStatus { + /// The lowercase name Lua sees. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + RecoveryStatus::None => "none", + RecoveryStatus::Fresh => "fresh", + RecoveryStatus::Stale => "stale", + RecoveryStatus::Corrupt => "corrupt", + } + } +} + +/// The `pmacs.state` key a path's recovery file lives under. +#[must_use] +pub fn key_for(path: &Path) -> String { + format!("autosave/{}", sha256_hex(&path.display().to_string())) +} + +/// Skip cache: `BufferId → (path_hash, revision)` (Q#AS8). +/// +/// Keyed on the **path hash as well as the revision**, not the revision +/// alone: a buffer keeps its `BufferId` across a path change (an LSP +/// `WorkspaceEdit` rename calls `set_buffer_path`), so a revision-only +/// cache would skip the write, never create the recovery file under the +/// new key, and orphan the old one. +#[derive(Default)] +pub struct AutosaveCache(RefCell>); + +/// Encode a header + contents into the one-file envelope. +fn encode(header: &Header, contents: &[u8]) -> Result, String> { + // serde_json's compact form never contains a raw newline, so the + // first `\n` unambiguously ends the header. + let mut out = serde_json::to_vec(header).map_err(|e| e.to_string())?; + debug_assert!(!out.contains(&b'\n')); + out.push(b'\n'); + out.extend_from_slice(contents); + Ok(out) +} + +/// Split an envelope at its **first** newline. Contents may contain +/// newlines and arbitrary non-UTF-8 bytes, so only the first one counts. +/// Returns `None` for anything malformed — the caller maps that to +/// [`RecoveryStatus::Corrupt`]. +fn decode(bytes: &[u8]) -> Option<(Header, &[u8])> { + let nl = bytes.iter().position(|&b| b == b'\n')?; + let header: Header = serde_json::from_slice(&bytes[..nl]).ok()?; + if header.version != AUTOSAVE_VERSION { + return None; + } + Some((header, &bytes[nl + 1..])) +} + +/// The configured state dir, if any (absent under tests / no HOME). +fn base_dir(lua: &Lua) -> Option { + lua.app_data_ref::().map(|d| d.0.clone()) +} + +/// Classify the recovery file for `path` (Q#AS5's table). +#[must_use] +pub fn status(base: &Path, path: &Path) -> RecoveryStatus { + let key = key_for(path); + let Ok(Some(bytes)) = crate::state::read_bytes(base, &key) else { + return RecoveryStatus::None; + }; + let Some((header, _)) = decode(&bytes) else { + return RecoveryStatus::Corrupt; + }; + let on_disk = crate::file_io::current_meta(path).ok(); + match (header.origin, on_disk) { + // Had an origin, file still there: fresh iff identity matches. + (Some(o), Some(cur)) if o == Origin::from_meta(&cur) => RecoveryStatus::Fresh, + // `[new file]`: fresh while it is still absent. + (None, None) => RecoveryStatus::Fresh, + // Everything else changed underneath us: the file was edited + // externally, deleted, or (for a `[new file]`) created by someone + // else. Never auto-offered. + _ => RecoveryStatus::Stale, + } +} + +/// The recovered contents for `path`, if a parseable recovery exists. +/// Returns bytes for `Stale` too — the command warns and confirms. +#[must_use] +pub fn recover_bytes(base: &Path, path: &Path) -> Option> { + let bytes = crate::state::read_bytes(base, &key_for(path)) + .ok() + .flatten()?; + let (_, contents) = decode(&bytes)?; + Some(contents.to_vec()) +} + +/// Delete the recovery file for `path` (idempotent). +pub fn discard(base: &Path, path: &Path) -> bool { + crate::state::remove(base, &key_for(path)).is_ok() +} + +/// A buffer that needs a recovery copy written, gathered under the core +/// borrow so all IO happens after it is released. +struct Pending { + id: BufferId, + path: String, + path_hash: String, + revision: u64, + origin: Option, + contents: Vec, +} + +/// One autosave pass: write a recovery copy of every modified file +/// buffer whose contents changed since its last copy. Returns how many +/// were written. +/// +/// Runs on the main thread; the two skips in Q#AS8 keep that bounded. +/// Unlike desktop-save this is **not** daemon-gated — autosave is +/// per-buffer, not per-frontend, and a daemon holds the unsaved work. +/// +/// # Errors +/// A state-write failure. Individual buffers never abort the pass. +pub fn sweep(lua: &Lua) -> Result { + let Some(base) = base_dir(lua) else { + return Ok(0); + }; + let core = lua + .app_data_ref::() + .ok_or("no editor core")? + .clone(); + + // Gather under a borrow; do all IO after releasing it. + let mut writes: Vec = Vec::new(); + let mut orphans: Vec = Vec::new(); + let mut live: Vec = Vec::new(); + { + let cache = lua + .app_data_ref::() + .ok_or("no autosave cache")?; + let cache = cache.0.borrow(); + let c = core.borrow(); + let reg = c.registry.borrow(); + for &id in reg.ids() { + let Ok(buf) = reg.get(id) else { continue }; + live.push(id); + // Skips scratch / *special* (no path). Includes `[new file]` + // buffers: path set, `file_meta` absent. + let Some(path) = buf.file_path() else { + continue; + }; + if !buf.is_modified() { + continue; + } + let path_s = path.display().to_string(); + let path_hash = sha256_hex(&path_s); + let revision = buf.revision(); + if let Some((prev_hash, prev_rev)) = cache.get(&id) { + if prev_hash == &path_hash && *prev_rev == revision { + continue; // unchanged since its last copy + } + if prev_hash != &path_hash { + // The path moved: the old key is now an orphan. + orphans.push(prev_hash.clone()); + } + } + let len = buf.len(); + let mut contents = vec![0u8; usize::try_from(len).unwrap_or(0)]; + if len > 0 { + buf.snapshot_rope().slice(0, len, &mut contents); + } + writes.push(Pending { + id, + path: path_s, + path_hash, + revision, + origin: buf.file_meta().map(Origin::from_meta), + contents, + }); + } + } + + for old in orphans { + let _ = crate::state::remove(&base, &format!("autosave/{old}")); + } + + let mut written = 0usize; + { + let cache = lua + .app_data_ref::() + .ok_or("no autosave cache")?; + let mut cache = cache.0.borrow_mut(); + // Drop entries for buffers that no longer exist. + cache.retain(|id, _| live.contains(id)); + for p in writes { + let header = Header { + version: AUTOSAVE_VERSION, + path: p.path, + origin: p.origin, + }; + let bytes = encode(&header, &p.contents)?; + crate::state::write_private(&base, &format!("autosave/{}", p.path_hash), &bytes) + .map_err(|e| e.to_string())?; + cache.insert(p.id, (p.path_hash, p.revision)); + written += 1; + } + } + Ok(written) +} + +/// Every open file buffer that has a recovery file, with its status +/// (Q#AS6). Enumerating in Rust is what makes this cover argv +/// `[new file]` buffers, which fire no hook at all — the Lua reporter +/// never has to know they exist. +/// +/// Returns `(fresh_paths, corrupt_count)`. +#[must_use] +pub fn pending(lua: &Lua) -> (Vec, usize) { + let mut fresh = Vec::new(); + let mut corrupt = 0usize; + let (Some(base), Some(core)) = (base_dir(lua), lua.app_data_ref::()) else { + return (fresh, corrupt); + }; + // Collect paths first so the guard drops before any IO re-entrancy. + let paths: Vec = { + let c = core.borrow(); + let reg = c.registry.borrow(); + reg.ids() + .iter() + .filter_map(|&id| reg.get(id).ok()?.file_path().map(Path::to_path_buf)) + .collect() + }; + for p in paths { + match status(&base, &p) { + RecoveryStatus::Fresh => fresh.push(p.display().to_string()), + RecoveryStatus::Corrupt => corrupt += 1, + RecoveryStatus::None | RecoveryStatus::Stale => {} + } + } + (fresh, corrupt) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(origin: Option) -> Header { + Header { + version: AUTOSAVE_VERSION, + path: "/tmp/a.rs".into(), + origin, + } + } + + #[test] + fn envelope_round_trips_arbitrary_bytes() { + // Contents with newlines AND invalid UTF-8 — the reason we split + // at the first newline and read bytes, not a String. + let contents = [0xffu8, b'\n', b'a', 0x00, b'\n']; + let h = header(Some(Origin { + mtime_secs: 5, + mtime_nanos: 7, + size: 5, + })); + let bytes = encode(&h, &contents).unwrap(); + let (got_h, got_c) = decode(&bytes).unwrap(); + assert_eq!(got_h, h); + assert_eq!(got_c, &contents[..]); + } + + #[test] + fn envelope_round_trips_null_origin() { + // A `[new file]` buffer: no origin meta. + let h = header(None); + let bytes = encode(&h, b"draft").unwrap(); + let (got_h, got_c) = decode(&bytes).unwrap(); + assert!(got_h.origin.is_none()); + assert_eq!(got_c, b"draft"); + } + + #[test] + fn decode_rejects_malformed_and_wrong_version() { + assert!(decode(b"no newline at all").is_none()); + assert!(decode(b"{not json}\nbody").is_none()); + assert!(decode(b"\nbody").is_none(), "empty header"); + let bad_version = br#"{"version":999,"path":"/x","origin":null}"#; + let mut bytes = bad_version.to_vec(); + bytes.push(b'\n'); + assert!(decode(&bytes).is_none(), "unrecognized version → corrupt"); + } + + #[test] + fn key_is_a_valid_state_key() { + let k = key_for(Path::new("/home/u/a b.rs")); + assert!(k.starts_with("autosave/")); + assert!(crate::state::validate_name(&k).is_ok()); + } + + #[test] + fn status_none_when_no_recovery_file() { + let dir = std::env::temp_dir().join(format!("pmacs-as-none-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert_eq!( + status(&dir, Path::new("/tmp/nonexistent.rs")), + RecoveryStatus::None + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn status_corrupt_for_garbage_envelope() { + let dir = std::env::temp_dir().join(format!("pmacs-as-corrupt-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let target = Path::new("/tmp/whatever.rs"); + crate::state::write_private(&dir, &key_for(target), b"garbage, no newline").unwrap(); + assert_eq!(status(&dir, target), RecoveryStatus::Corrupt); + // And it is discardable. + assert!(discard(&dir, target)); + assert_eq!(status(&dir, target), RecoveryStatus::None); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn new_file_status_is_fresh_until_the_file_appears() { + let dir = std::env::temp_dir().join(format!("pmacs-as-newfile-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let target = dir.join("draft.rs"); + // origin: null — a `[new file]` buffer. + let bytes = encode( + &Header { + version: AUTOSAVE_VERSION, + path: target.display().to_string(), + origin: None, + }, + b"unsaved draft", + ) + .unwrap(); + crate::state::write_private(&dir, &key_for(&target), &bytes).unwrap(); + + assert_eq!(status(&dir, &target), RecoveryStatus::Fresh, "file absent"); + assert_eq!( + recover_bytes(&dir, &target).as_deref(), + Some(&b"unsaved draft"[..]) + ); + + // Someone created the file meanwhile → stale, never auto-offered. + std::fs::write(&target, b"someone else's content").unwrap(); + assert_eq!(status(&dir, &target), RecoveryStatus::Stale); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn existing_file_fresh_until_it_changes_on_disk() { + let dir = std::env::temp_dir().join(format!("pmacs-as-exist-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let target = dir.join("a.rs"); + std::fs::write(&target, b"on disk").unwrap(); + let meta = crate::file_io::current_meta(&target).unwrap(); + let bytes = encode( + &Header { + version: AUTOSAVE_VERSION, + path: target.display().to_string(), + origin: Some(Origin::from_meta(&meta)), + }, + b"unsaved edits", + ) + .unwrap(); + crate::state::write_private(&dir, &key_for(&target), &bytes).unwrap(); + assert_eq!(status(&dir, &target), RecoveryStatus::Fresh); + + // Touch the file (different size ⇒ different identity). + std::fs::write(&target, b"changed underneath us").unwrap(); + assert_eq!(status(&dir, &target), RecoveryStatus::Stale); + + // Delete it entirely → still stale (the base is gone). + std::fs::remove_file(&target).unwrap(); + assert_eq!(status(&dir, &target), RecoveryStatus::Stale); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/src/desktop.rs b/src/desktop.rs index b871d2b..23ff5ac 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -20,10 +20,10 @@ use std::path::Path; use mlua::Lua; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use crate::buffer::BufferId; use crate::editor_core::EditorCore; +use crate::hash::sha256_hex; use crate::lua_bindings::{LocalInstanceInfo, SharedCore, StateDir, fire_after_load_hook}; use crate::protocol::FrontendId; use crate::text_view::TextView; @@ -141,18 +141,6 @@ pub fn desktop_state_key(session_key: &str) -> String { format!("desktop/{session_key}") } -fn sha256_hex(s: &str) -> String { - let mut h = Sha256::new(); - h.update(s.as_bytes()); - let digest = h.finalize(); - let mut out = String::with_capacity(digest.len() * 2); - for b in digest { - use std::fmt::Write as _; - let _ = write!(out, "{b:02x}"); - } - out -} - /// Build the serializable layout tree from a core [`LayoutNode`], /// resolving each leaf window to a [`SavedLeaf`] (returning `None` for a /// non-file leaf, which is dropped and its split collapsed). diff --git a/src/editor.rs b/src/editor.rs index 598b670..5fd9f61 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -321,6 +321,12 @@ impl EditorState { include_str!("../builtin/runtime/desktop.lua"), ) .expect("load desktop builtin chunk"); + lua_host + .eval( + Some("@pmacs/builtin/runtime/autosave.lua"), + include_str!("../builtin/runtime/autosave.lua"), + ) + .expect("load autosave builtin chunk"); // T M7.11 bundled-package bootstrap. Through M7.10 the REPL // was loaded directly via `eval(include_str!(...))`; the // M7.11 deliverable migrates it to the package system so it diff --git a/src/file_io.rs b/src/file_io.rs index 28f0574..1b33350 100644 --- a/src/file_io.rs +++ b/src/file_io.rs @@ -130,7 +130,32 @@ impl Drop for TempCleanup { /// identity for future change-detection comparisons. /// /// Threading: any thread. +/// +/// # Errors +/// See [`SaveError`]. pub fn save_atomic(path: &Path, content: &[u8]) -> Result { + save_atomic_with_mode(path, content, None) +} + +/// [`save_atomic`], but forcing the target's Unix mode to `mode` instead +/// of inheriting the existing file's mode (or the umask default for a new +/// file). +/// +/// The mode is applied to the **temp file before the rename**, so the +/// target never exists — not even momentarily — at a laxer mode. A +/// `chmod` after the write would leave exactly that window, which matters +/// because autosave (Arc 3 Q#AS11) stores *unsaved file contents*: a +/// recovery copy must never be briefly world-readable. +/// +/// `mode` is ignored on non-Unix platforms (the write is still atomic). +/// +/// # Errors +/// See [`SaveError`]. +pub fn save_atomic_with_mode( + path: &Path, + content: &[u8], + mode: Option, +) -> Result { // `Path::parent` returns: // * `None` for `/` or `""` --- no place to put a sibling temp file; // * `Some("")` for a bare filename like `notes.txt` --- means cwd, fine; @@ -140,10 +165,22 @@ pub fn save_atomic(path: &Path, content: &[u8]) -> Result { return Err(SaveError::NoParent(path.to_path_buf())); } - // Snapshot the target's current permissions so an existing file keeps - // its mode across the replace (F-006) — e.g. a `0755` script stays - // executable. `None` for a new file, which then gets the default mode. - let existing_perms = fs::metadata(path).ok().map(|m| m.permissions()); + // An explicit `mode` wins; otherwise snapshot the target's current + // permissions so an existing file keeps its mode across the replace + // (F-006) — e.g. a `0755` script stays executable. `None` for a new + // file, which then gets the default mode. + #[cfg(unix)] + let existing_perms = mode + .map(|m| { + use std::os::unix::fs::PermissionsExt as _; + fs::Permissions::from_mode(m) + }) + .or_else(|| fs::metadata(path).ok().map(|m| m.permissions())); + #[cfg(not(unix))] + let existing_perms = { + let _ = mode; // no mode concept; the write is still atomic + fs::metadata(path).ok().map(|m| m.permissions()) + }; // Open a fresh temp, retrying on the rare name collision (a stale temp // left by a crashed prior run whose pid+nanos recurs) instead of diff --git a/src/hash.rs b/src/hash.rs new file mode 100644 index 0000000..6310f45 --- /dev/null +++ b/src/hash.rs @@ -0,0 +1,58 @@ +// hash.rs --- shared content hashing (Arc 3 phase 3, Q#AS9). + +//! One `sha256_hex` for the whole crate. Three call sites want a stable, +//! filename-safe digest of a string: +//! +//! * [`crate::desktop`] — the desktop session key, +//! * [`crate::autosave`] — the recovery-file key (a hash of the path), +//! * `crate::packages::fetcher` — the package cache/mirror key. +//! +//! Each had grown (or was about to grow) its own private copy. A +//! *cryptographic* digest matters for the fetcher: a non-cryptographic +//! hash is trivially collidable, and a deliberate collision would make +//! two URLs share one bare mirror + lock file. +//! +//! Lowercase hex, so the output passes +//! [`crate::state::validate_name`]'s `[A-Za-z0-9._-]` charset. + +use sha2::{Digest, Sha256}; + +/// Lowercase-hex SHA-256 of `s`. +pub(crate) fn sha256_hex(s: &str) -> String { + use std::fmt::Write as _; + let mut hasher = Sha256::new(); + hasher.update(s.as_bytes()); + let digest = hasher.finalize(); + let mut out = String::with_capacity(digest.len() * 2); + for b in digest { + let _ = write!(out, "{b:02x}"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_vector_and_charset() { + // The canonical empty-string SHA-256. + assert_eq!( + sha256_hex(""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + let h = sha256_hex("/home/u/a.rs"); + assert_eq!(h.len(), 64); + assert!( + h.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + ); + // Filename-safe: passes the state-key charset. + assert!(crate::state::validate_name(&format!("autosave/{h}")).is_ok()); + } + + #[test] + fn distinct_inputs_distinct_digests() { + assert_ne!(sha256_hex("a"), sha256_hex("b")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7469641..7ee5f29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ pub mod attach; pub mod attach_dispatch; pub mod attach_reconnect; pub mod audit; +pub mod autosave; pub mod buffer; pub mod buffer_registry; pub mod builtin_packages; @@ -75,6 +76,7 @@ pub mod file_io; pub mod formatting; pub mod frontend; pub mod fs; +mod hash; pub mod help; pub mod highlight; pub mod hook; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 54ab0d3..6878d08 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1963,10 +1963,75 @@ pub fn install( pmacs.set("packages", install_packages_module(lua)?)?; pmacs.set("state", install_state_module(lua)?)?; pmacs.set("session", install_session_module(lua)?)?; + pmacs.set("autosave", install_autosave_module(lua)?)?; lua.globals().set("pmacs", pmacs)?; Ok(()) } +/// `pmacs.autosave.*` — the Rust half of autosave + crash recovery +/// (Arc 3 phase 3). Lua cannot enumerate non-active buffers' paths, and +/// `FileMeta` is neither Lua-visible nor serde, so the sweep and the +/// external-change guard live in Rust (Q#AS1). `autosave.lua` layers the +/// cadence, the configurable interval, and the recovery UX on top. +/// +/// The `_`-prefixed names are the raw primitives; `autosave.lua` adds the +/// public `enable` / `interval_ms` / `sweep` onto the same table. +fn install_autosave_module(lua: &Lua) -> mlua::Result { + // The skip cache lives for the life of the VM. + lua.set_app_data(crate::autosave::AutosaveCache::default()); + let m = lua.create_table()?; + + m.set( + "_sweep", + lua.create_function(|lua, ()| crate::autosave::sweep(lua).map_err(mlua::Error::external))?, + )?; + + m.set( + "_status", + lua.create_function(|lua, path: String| { + let Some(base) = lua.app_data_ref::().map(|d| d.0.clone()) else { + return Ok("none"); + }; + Ok(crate::autosave::status(&base, std::path::Path::new(&path)).as_str()) + })?, + )?; + + m.set( + "_recover_bytes", + lua.create_function(|lua, path: String| { + let Some(base) = lua.app_data_ref::().map(|d| d.0.clone()) else { + return Ok(None); + }; + match crate::autosave::recover_bytes(&base, std::path::Path::new(&path)) { + Some(bytes) => Ok(Some(lua.create_string(&bytes)?)), + None => Ok(None), + } + })?, + )?; + + m.set( + "_discard", + lua.create_function(|lua, path: String| { + let Some(base) = lua.app_data_ref::().map(|d| d.0.clone()) else { + return Ok(false); + }; + Ok(crate::autosave::discard(&base, std::path::Path::new(&path))) + })?, + )?; + + // _pending() -> (fresh_paths, corrupt_count). Enumerates in Rust so + // argv `[new file]` buffers — which fire no hook — are covered too. + m.set( + "_pending", + lua.create_function(|lua, ()| { + let (fresh, corrupt) = crate::autosave::pending(lua); + Ok((fresh, corrupt)) + })?, + )?; + + Ok(m) +} + /// Marker app-data set by `pmacs.session.arm_restore()` (Arc 3 phase 2, /// Q#DS7). Its presence tells the `RunLocal` startup trigger to attempt /// a desktop restore; `desktop_mode(true)` in init.lua arms it. diff --git a/src/packages/fetcher.rs b/src/packages/fetcher.rs index e2ec2d7..af4fd76 100644 --- a/src/packages/fetcher.rs +++ b/src/packages/fetcher.rs @@ -62,7 +62,6 @@ use std::process::{Child, Command, ExitStatus, Stdio}; use std::thread; use std::time::{Duration, Instant}; -use sha2::{Digest, Sha256}; use thiserror::Error; // --------------------------------------------------------------------------- @@ -512,19 +511,11 @@ fn dot_git_strip_applies(u: &str) -> bool { /// The cache dir is keyed by a hash of the (attacker-adjacent) repo URL, /// so a *cryptographic* digest is used: a non-cryptographic hash like the /// former 64-bit FNV-1a is trivially collidable, and a deliberate -/// collision would make two URLs share one bare mirror + lock file. `sha2` -/// is already a dependency (M7.6 lockfile content hashing). -fn sha256_hex(s: &str) -> String { - use std::fmt::Write as _; - let mut hasher = Sha256::new(); - hasher.update(s.as_bytes()); - let digest = hasher.finalize(); - let mut out = String::with_capacity(digest.len() * 2); - for b in digest { - let _ = write!(out, "{b:02x}"); - } - out -} +/// collision would make two URLs share one bare mirror + lock file. +/// +/// The implementation now lives in [`crate::hash`] — shared with the +/// desktop session key and the autosave recovery key (Q#AS9). +use crate::hash::sha256_hex; // --------------------------------------------------------------------------- // LockGuard --- per-cache-entry flock(2) diff --git a/src/state.rs b/src/state.rs index d0b6865..22ba299 100644 --- a/src/state.rs +++ b/src/state.rs @@ -182,14 +182,76 @@ pub fn read(base: &Path, name: &str) -> Result, StateError> { /// # Errors /// Invalid key, or a save failure. pub fn write(base: &Path, name: &str, content: &[u8]) -> Result<(), StateError> { + write_inner(base, name, content, None) +} + +/// Like [`write`], but the parent directory is created `0700` and the +/// file written `0600` (Arc 3 Q#AS11). +/// +/// Autosave stores **unsaved file contents**, a different class of secret +/// from saveplace's cursor offsets or recentf's path list. The default +/// path would give a new recovery file the umask default (typically +/// `0644`) and its directory `0755` — leaving a recovery copy of an +/// unsaved edit to a `0600` file *more exposed than the original*. The +/// mode is applied to the temp before the rename, so there is no window +/// at a laxer mode. +/// +/// Permissions are Unix-only; elsewhere this is [`write`]. +/// +/// # Errors +/// Invalid key, or a save failure. +pub fn write_private(base: &Path, name: &str, content: &[u8]) -> Result<(), StateError> { + write_inner(base, name, content, Some(0o600)) +} + +fn write_inner( + base: &Path, + name: &str, + content: &[u8], + mode: Option, +) -> Result<(), StateError> { let path = resolve(base, name).map_err(StateError::Name)?; if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(StateError::Io)?; + create_dir_all_with_mode(parent, mode.map(|_| 0o700)).map_err(StateError::Io)?; } - crate::file_io::save_atomic(&path, content).map_err(StateError::Save)?; + crate::file_io::save_atomic_with_mode(&path, content, mode).map_err(StateError::Save)?; Ok(()) } +/// `create_dir_all`, optionally forcing the mode of directories this call +/// creates. An already-existing directory keeps its mode (we do not +/// re-mode a directory the user already has). +fn create_dir_all_with_mode(dir: &Path, mode: Option) -> std::io::Result<()> { + #[cfg(unix)] + if let Some(m) = mode { + use std::os::unix::fs::DirBuilderExt as _; + return std::fs::DirBuilder::new() + .recursive(true) + .mode(m) + .create(dir); + } + #[cfg(not(unix))] + let _ = mode; + std::fs::create_dir_all(dir) +} + +/// Read a state file's raw bytes, or `Ok(None)` when it does not exist. +/// +/// [`read`] returns a `String` (`read_to_string`), which fails on +/// non-UTF-8 content. pmacs buffers hold arbitrary bytes, so an autosave +/// recovery file cannot be read that way (Arc 3 Q#AS4). +/// +/// # Errors +/// Invalid key, or an IO error other than not-found. +pub fn read_bytes(base: &Path, name: &str) -> Result>, StateError> { + let path = resolve(base, name).map_err(StateError::Name)?; + match std::fs::read(&path) { + Ok(b) => Ok(Some(b)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(StateError::Io(e)), + } +} + /// Remove a state file. Missing file is success (idempotent). /// /// # Errors @@ -343,6 +405,55 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn read_bytes_round_trips_non_utf8() { + let dir = std::env::temp_dir().join(format!("pmacs-bytes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // Invalid UTF-8 — what `read` (read_to_string) would choke on. + let raw = [0xffu8, 0xfe, b'\n', 0x00, b'a']; + write(&dir, "blob", &raw).unwrap(); + assert_eq!(read_bytes(&dir, "blob").unwrap().as_deref(), Some(&raw[..])); + assert!(read(&dir, "blob").is_err(), "read_to_string rejects it"); + assert!(read_bytes(&dir, "absent").unwrap().is_none()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[cfg(unix)] + #[test] + fn write_private_uses_0700_dir_and_0600_file() { + use std::os::unix::fs::PermissionsExt as _; + let dir = std::env::temp_dir().join(format!("pmacs-priv-{}", std::process::id())); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + + write_private(&dir, "autosave/secret", b"unsaved contents").unwrap(); + + let file = dir.join("autosave").join("secret"); + let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777; + assert_eq!(fmode, 0o600, "recovery file is 0600, not umask default"); + let dmode = std::fs::metadata(dir.join("autosave")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(dmode, 0o700, "autosave dir is 0700"); + + // Rewriting keeps the private mode (save_atomic inherits it). + write_private(&dir, "autosave/secret", b"more").unwrap(); + let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777; + assert_eq!(fmode, 0o600); + + // The plain `write` path is unchanged (umask default, not 0600). + write(&dir, "plain", b"x").unwrap(); + let pmode = std::fs::metadata(dir.join("plain")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_ne!(pmode, 0o600, "plain write keeps existing behavior"); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn blank_xdg_falls_through_to_home_not_a_relative_path() { // The Q#PS2 fix: an empty / whitespace XDG_STATE_HOME must NOT diff --git a/tests/autosave_acceptance.rs b/tests/autosave_acceptance.rs new file mode 100644 index 0000000..e420b39 --- /dev/null +++ b/tests/autosave_acceptance.rs @@ -0,0 +1,456 @@ +//! Autosave + crash-recovery acceptance (Arc 3 phase 3). +//! +//! Each test injects a private tempdir `StateDir` (integration tests link +//! the lib without `cfg(test)`), so nothing touches a developer's real +//! state dir. Sweeps are driven directly rather than through the timer, +//! so the 1-second interval floor never slows the suite. +//! +//! Framing: `docs/autosave-recovery-framing.md`. + +use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn fresh_state_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-autosave-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn editor(state_dir: &std::path::Path) -> EditorState { + let s = EditorState::new(); + s.lua_host.lua().remove_app_data::(); + s.lua_host + .lua() + .set_app_data(StateDir(state_dir.to_path_buf())); + s +} + +fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p.display().to_string() +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Force a sweep; returns how many buffers were written. +fn sweep(s: &EditorState) -> i64 { + eval(s, "return pmacs.autosave.sweep()") +} + +fn status(s: &EditorState, path: &str) -> String { + eval(s, &format!("return pmacs.autosave._status({path:?})")) +} + +/// Open a file and dirty it by `n` inserted bytes at the front. +fn open_and_dirty(s: &EditorState, path: &str, text: &str) { + exec( + s, + &format!("pmacs.buffer.find_or_open({path:?}); pmacs.window.buffer():insert(0, {text:?})"), + ); +} + +#[test] +fn sweep_writes_recovery_for_a_modified_file_buffer() { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, "a.txt", "on disk\n"); + open_and_dirty(&s, &f, "unsaved "); + assert_eq!(sweep(&s), 1, "one modified file buffer written"); + assert_eq!(status(&s, &f), "fresh"); + + // The recovery contents are the buffer's, not the file's. + let bytes: mlua::String = eval(&s, &format!("return pmacs.autosave._recover_bytes({f:?})")); + assert_eq!(&*bytes.as_bytes(), b"unsaved on disk\n"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn envelope_round_trips_non_utf8_contents() { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, "bin.dat", ""); + // 0xff is invalid UTF-8; the envelope reads bytes, not a String. + exec( + &s, + &format!( + "pmacs.buffer.find_or_open({f:?}); pmacs.window.buffer():insert(0, '\\255\\n\\0a')" + ), + ); + assert_eq!(sweep(&s), 1); + let bytes: mlua::String = eval(&s, &format!("return pmacs.autosave._recover_bytes({f:?})")); + assert_eq!(&*bytes.as_bytes(), &[0xffu8, b'\n', 0x00, b'a'][..]); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn sweep_skips_clean_scratch_and_unchanged_buffers() { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, "a.txt", "hello\n"); + + // A clean file buffer + the scratch buffer: nothing to write. + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + assert_eq!(sweep(&s), 0, "clean buffer and scratch are skipped"); + + // Dirty it → one write. Sweeping again with no further edit → zero + // (the (path_hash, revision) skip cache). + exec(&s, "pmacs.window.buffer():insert(0, 'x')"); + assert_eq!(sweep(&s), 1); + assert_eq!(sweep(&s), 0, "unchanged since last copy → no rewrite"); + + // Another edit → written again. + exec(&s, "pmacs.window.buffer():insert(0, 'y')"); + assert_eq!(sweep(&s), 1); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn path_change_writes_new_key_and_discards_the_old() { + let dir = fresh_state_dir(); + let s = editor(&dir); + let old = write_file(&dir, "old.txt", "body\n"); + let new = dir.join("new.txt").display().to_string(); + open_and_dirty(&s, &old, "dirty "); + assert_eq!(sweep(&s), 1); + assert_eq!(status(&s, &old), "fresh"); + + // Rename WITHOUT editing the buffer — what an LSP WorkspaceEdit + // rename does: the file moves on disk (preserving mtime/size) and the + // buffer keeps its BufferId *and* its revision, only its path changes. + std::fs::rename(&old, &new).unwrap(); + { + let id = s.core.borrow().active_buffer_id(); + s.core + .borrow_mut() + .set_buffer_path(id, Some(PathBuf::from(&new))); + } + // A revision-only cache would skip this write, never create the + // recovery under the new key, and orphan the old one. + assert_eq!(sweep(&s), 1, "path change forces a rewrite"); + assert_eq!(status(&s, &new), "fresh", "new key written"); + assert_eq!(status(&s, &old), "none", "old key discarded"); + let bytes: mlua::String = eval( + &s, + &format!("return pmacs.autosave._recover_bytes({new:?})"), + ); + assert_eq!(&*bytes.as_bytes(), b"dirty body\n"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn new_file_buffer_is_swept_with_null_origin_and_recovers() { + let dir = fresh_state_dir(); + let s = editor(&dir); + // A `[new file]`: a path with no file on disk, so no `file_meta`. + // Lua's find_or_open *errors* on a missing path, so this is built the + // way argv `pmacs draft.txt` does — an empty buffer with a path. + let missing = dir.join("draft.txt"); + exec( + &s, + "_G.nb = pmacs.buffer.create('draft.txt'); pmacs.window.switch_buffer(_G.nb)", + ); + { + let id = s.core.borrow().active_buffer_id(); + s.core + .borrow_mut() + .set_buffer_path(id, Some(missing.clone())); + } + // Typing into it is what makes it modified (and worth recovering). + exec(&s, "pmacs.window.buffer():insert(0, 'unsaved draft')"); + let p = missing.display().to_string(); + assert_eq!(sweep(&s), 1, "a new-file buffer is swept"); + // origin is null → fresh while the file is still absent. + assert_eq!(status(&s, &p), "fresh"); + let bytes: mlua::String = eval(&s, &format!("return pmacs.autosave._recover_bytes({p:?})")); + assert_eq!(&*bytes.as_bytes(), b"unsaved draft"); + + // Someone creates the file meanwhile → stale, never auto-offered. + std::fs::write(&missing, b"theirs").unwrap(); + assert_eq!(status(&s, &p), "stale"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn external_change_makes_recovery_stale() { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, "a.txt", "original\n"); + open_and_dirty(&s, &f, "mine "); + assert_eq!(sweep(&s), 1); + assert_eq!(status(&s, &f), "fresh"); + + // Someone else edits the file on disk. + std::fs::write(&f, b"theirs, quite different\n").unwrap(); + assert_eq!(status(&s, &f), "stale", "never auto-offered"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn corrupt_recovery_is_typed_quiet_and_discardable() { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, "a.txt", "body\n"); + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + + // Plant a malformed envelope under the right key. + let key = pmacs::autosave::key_for(std::path::Path::new(&f)); + pmacs::state::write_private(&dir, &key, b"garbage without a newline").unwrap(); + assert_eq!(status(&s, &f), "corrupt"); + + // The aggregate report must not error or announce it. + let (fresh, corrupt): (Vec, i64) = eval(&s, "return pmacs.autosave._pending()"); + assert!(fresh.is_empty(), "corrupt is never offered"); + assert_eq!(corrupt, 1, "counted separately"); + + // And it is discardable. + exec(&s, &format!("pmacs.autosave._discard({f:?})")); + assert_eq!(status(&s, &f), "none"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn pending_aggregates_and_names_a_single_file() { + let dir = fresh_state_dir(); + let s = editor(&dir); + let mut paths = Vec::new(); + for i in 0..3 { + let f = write_file(&dir, &format!("f{i}.txt"), "body\n"); + open_and_dirty(&s, &f, "x"); + paths.push(f); + } + assert_eq!(sweep(&s), 3); + let (fresh, corrupt): (Vec, i64) = eval(&s, "return pmacs.autosave._pending()"); + assert_eq!(fresh.len(), 3, "all three reported in ONE call"); + assert_eq!(corrupt, 0); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn tick_reports_recoveries_once_aggregated() { + let dir = fresh_state_dir(); + // Seed three recovery copies, then "crash" and reopen the files. + let s1 = editor(&dir); + let mut paths = Vec::new(); + for i in 0..3 { + let f = write_file(&dir, &format!("f{i}.txt"), "body\n"); + open_and_dirty(&s1, &f, "x"); + paths.push(f); + } + assert_eq!(sweep(&s1), 3); + + let s2 = editor(&dir); + for f in &paths { + exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})")); + } + // Each `after-load` only raises a flag; the tick does the reporting, + // so three loads collapse into ONE aggregate message. + exec(&s2, "pmacs.hook.run('process.after-tick')"); + let status = s2.core.borrow().status.clone(); + assert!( + status.contains("3 files have autosave recovery"), + "one aggregated message, not three: {status:?}" + ); + + // A second tick does not re-report (the flag was cleared). + s2.core.borrow_mut().status.clear(); + exec(&s2, "pmacs.hook.run('process.after-tick')"); + assert!(s2.core.borrow().status.is_empty(), "no repeat report"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn tick_names_the_file_when_exactly_one_is_recoverable() { + let dir = fresh_state_dir(); + let s1 = editor(&dir); + let f = write_file(&dir, "solo.txt", "body\n"); + open_and_dirty(&s1, &f, "x"); + assert_eq!(sweep(&s1), 1); + + let s2 = editor(&dir); + exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})")); + exec(&s2, "pmacs.hook.run('process.after-tick')"); + let status = s2.core.borrow().status.clone(); + assert!( + status.contains("solo.txt") && status.contains("recover-file"), + "single recovery names the file: {status:?}" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn save_and_kill_delete_the_recovery_copy() { + let dir = fresh_state_dir(); + let s = editor(&dir); + + // Clean save deletes it (buffer.after-save). + let f = write_file(&dir, "a.txt", "body\n"); + open_and_dirty(&s, &f, "x"); + assert_eq!(sweep(&s), 1); + exec(&s, "pmacs.command.invoke('buffer.save')"); + assert_eq!(status(&s, &f), "none", "clean save retires the recovery"); + + // Kill deletes it (per-buffer on_removed registered at after-load). + let g = write_file(&dir, "b.txt", "body\n"); + exec(&s, &format!("_G.gb = pmacs.buffer.find_or_open({g:?})")); + exec(&s, "pmacs.window.buffer():insert(0, 'x')"); + assert_eq!(sweep(&s), 1); + assert_eq!(status(&s, &g), "fresh"); + exec(&s, "pmacs.buffer.kill(_G.gb)"); + assert_eq!(status(&s, &g), "none", "kill retires the recovery"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn recover_file_installs_contents_fires_after_edit_and_leaves_modified() { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, "a.txt", "on disk\n"); + open_and_dirty(&s, &f, "recovered "); + assert_eq!(sweep(&s), 1); + + // Simulate the crash-then-reopen: a fresh editor over the same store, + // opening the file whose on-disk contents are the OLD ones. + let s2 = editor(&dir); + exec( + &s2, + r#" + _G.after_edit = 0 + pmacs.hook.add("buffer.after-edit", function() _G.after_edit = _G.after_edit + 1 end) + "#, + ); + exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})")); + assert_eq!(status(&s2, &f), "fresh"); + // The buffer still holds the on-disk contents (no silent substitution). + let before: mlua::String = eval( + &s2, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + assert_eq!(&*before.as_bytes(), b"on disk\n"); + + // Drive recover-file's accept path directly (the command opens a + // minibuffer; we exercise what its on_accept does). + exec( + &s2, + &format!( + r#" + local bytes = pmacs.autosave._recover_bytes({f:?}) + local b = pmacs.window.buffer() + b:replace(0, b:len(), bytes) + pmacs.hook.run("buffer.after-edit") + "# + ), + ); + let after: mlua::String = eval( + &s2, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + assert_eq!(&*after.as_bytes(), b"recovered on disk\n"); + let fired: i64 = eval(&s2, "return _G.after_edit"); + assert!( + fired >= 1, + "after-edit fired so LSP/syntax see the recovery" + ); + let modified: bool = eval(&s2, "return pmacs.window.buffer():is_modified()"); + assert!( + modified, + "recovered buffer is dirty; user must save to keep" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn recovery_files_are_private_0600_under_a_0700_dir() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, "secret.txt", ""); + open_and_dirty(&s, &f, "unsaved secret"); + assert_eq!(sweep(&s), 1); + + let key = pmacs::autosave::key_for(std::path::Path::new(&f)); + let file = dir.join(&key); + let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777; + assert_eq!(fmode, 0o600, "recovery file holds unsaved contents"); + let dmode = std::fs::metadata(dir.join("autosave")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(dmode, 0o700); + std::fs::remove_dir_all(&dir).ok(); + } +} + +#[test] +fn interval_is_a_validated_getter_setter_and_enable_gates_the_sweep() { + let dir = fresh_state_dir(); + let s = editor(&dir); + + let default_ms: i64 = eval(&s, "return pmacs.autosave.interval_ms()"); + assert_eq!(default_ms, 30000, "Emacs's auto-save-timeout"); + + let set: i64 = eval(&s, "return pmacs.autosave.interval_ms(60000)"); + assert_eq!(set, 60000); + let read_back: i64 = eval(&s, "return pmacs.autosave.interval_ms()"); + assert_eq!(read_back, 60000, "change takes effect immediately"); + + // Floats floor; bad values error. + let floored: i64 = eval(&s, "return pmacs.autosave.interval_ms(1500.9)"); + assert_eq!(floored, 1500); + for bad in ["'soon'", "0", "999", "-1", "{}"] { + let ok: bool = eval( + &s, + &format!("return (pcall(pmacs.autosave.interval_ms, {bad}))"), + ); + assert!(!ok, "interval_ms({bad}) must be rejected"); + } + // A rejected set leaves the previous value intact. + let still: i64 = eval(&s, "return pmacs.autosave.interval_ms()"); + assert_eq!(still, 1500); + + // enable(false) makes sweep a no-op even with a dirty buffer. + let f = write_file(&dir, "a.txt", "body\n"); + open_and_dirty(&s, &f, "x"); + exec(&s, "pmacs.autosave.enable(false)"); + assert_eq!(sweep(&s), 0, "disabled → no sweep"); + assert_eq!(status(&s, &f), "none"); + exec(&s, "pmacs.autosave.enable(true)"); + assert_eq!(sweep(&s), 1, "re-enabled → sweeps"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn before_quit_sweeps_synchronously_without_vetoing() { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, "a.txt", "body\n"); + open_and_dirty(&s, &f, "unsaved "); + // Not swept yet. + assert_eq!(status(&s, &f), "none"); + + // before-quit is short-circuit: a `true` result means "not vetoed". + let not_vetoed: bool = eval(&s, "return pmacs.hook.run('editor.before-quit')"); + assert!(not_vetoed, "autosave must never veto quit"); + assert_eq!( + status(&s, &f), + "fresh", + "quitting with unsaved changes leaves a recovery copy" + ); + std::fs::remove_dir_all(&dir).ok(); +}