feat(persistence): autosave + crash recovery (Arc 3 phase 3)
Framing: docs/autosave-recovery-framing.md (Q#AS1-11). Closes the
persistence arc. Every modified file buffer is periodically written to a
private recovery copy; if pmacs dies, the next session says so and
`M-x recover-file` installs it. Emacs's auto-save-mode + recover-file.
Hybrid, forced by the same two gaps as phase 2: Lua has no per-buffer
path getter and FileMeta is neither Lua-visible nor serde. Rust owns the
sweep and the external-change guard; Lua owns cadence, config, and UX.
src/autosave.rs (new):
- One atomic envelope per recovery: a JSON header line + `\n` + raw
buffer bytes. Split at the FIRST newline, so contents may hold newlines
and non-UTF-8. A crash can never leave a torn header/contents pair.
- `origin` is NULLABLE: a `[new file]` buffer (a path with nothing on
disk) has no FileMeta, and its unsaved contents are exactly the work
most worth recovering.
- status(): Fresh / Stale / Corrupt / None. Only Fresh is announced;
Stale (file changed, deleted, or created underneath us) is never
auto-offered; Corrupt is typed, quiet, and discardable.
- sweep(): all modified file buffers, skipping clean/scratch and those
unchanged since their last copy. The skip cache is keyed
BufferId -> (path_hash, revision), not revision alone: a buffer keeps
its BufferId across a path change (LSP WorkspaceEdit rename), so a
revision-only cache would skip the write and orphan the old key.
- pending(): enumerates ALL open file buffers in Rust, which is what
covers argv `[new file]` buffers -- they fire no hook at all.
Private storage (Q#AS11, a precondition for default-on): autosave stores
unsaved FILE CONTENTS, not metadata. New `file_io::save_atomic_with_mode`
sets the temp's mode BEFORE the rename (a chmod-after-write leaves a
window where the file is 0644), and `state::write_private` creates the
dir 0700 and the file 0600. Plus `state::read_bytes` (state::read is
read_to_string, which non-UTF-8 buffer contents would fail).
builtin/runtime/autosave.lua:
- Cadence is `process.after-tick` + monotonic_ms, NOT workers.sleep: a
long sleep parks one of only `available_parallelism - 1` pool threads,
and re-reading the interval each tick makes it live-reconfigurable.
- pmacs.autosave.interval_ms([ms]) -- validated getter/setter following
the async_config.frame_target_ms shape. Default 30000, floor 1000.
pmacs.autosave.enable(on). On by default.
- Notify, never prompt: `after-load` only raises a flag; the tick emits
ONE aggregate message ("3 files have autosave recovery"). A modal
prompt from after-load would stack N modals during a desktop restore.
- recover-file confirms, pins to the origin buffer, replaces contents,
then explicitly fires `buffer.after-edit` -- the mutators only notify
windows and queue CRDT, and after-edit comes from dispatch_key's
post-command check, which the minibuffer shadow returns before. Without
the explicit fire, LSP didChange and the syntax reparse never see the
recovery. discard-recovery deletes a copy (including a Corrupt one).
- Cleanup: after-save discards; per-buffer on_removed discards on kill
(there is no global kill hook); before-quit does one final synchronous
sweep and never vetoes.
src/hash.rs (new): one pub(crate) sha256_hex, shared by desktop, autosave,
and packages::fetcher -- which had two private duplicates (Q#AS9).
Not daemon-gated (unlike desktop-save): autosave is per-buffer, not
per-frontend, and a daemon holds the unsaved work.
Tests: 8 autosave units + 13 state/hash units + tests/autosave_acceptance
(15): sweep round-trip, non-UTF-8 envelope, [new file] null-origin
Fresh->Stale, 0600/0700 perms, skip clean/scratch/unchanged, path-change
rewrites new key + discards old, save/kill cleanup, Stale not offered,
Corrupt typed+quiet+discardable, recover-file installs + fires after-edit
+ leaves modified, tick aggregation (3 loads -> 1 message, no repeat),
single-file naming, interval validation + live change, enable gate,
before-quit sweeps without vetoing.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 15;
desktop 11; persistence 5; m4 90; m7_8 5; m8 10; GPU 58; git diff --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a6d8c60faf
commit
ec42526652
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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<Origin>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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<HashMap<BufferId, (String, u64)>>);
|
||||||
|
|
||||||
|
/// Encode a header + contents into the one-file envelope.
|
||||||
|
fn encode(header: &Header, contents: &[u8]) -> Result<Vec<u8>, 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<std::path::PathBuf> {
|
||||||
|
lua.app_data_ref::<StateDir>().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<Vec<u8>> {
|
||||||
|
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<Origin>,
|
||||||
|
contents: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<usize, String> {
|
||||||
|
let Some(base) = base_dir(lua) else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
let core = lua
|
||||||
|
.app_data_ref::<SharedCore>()
|
||||||
|
.ok_or("no editor core")?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Gather under a borrow; do all IO after releasing it.
|
||||||
|
let mut writes: Vec<Pending> = Vec::new();
|
||||||
|
let mut orphans: Vec<String> = Vec::new();
|
||||||
|
let mut live: Vec<BufferId> = Vec::new();
|
||||||
|
{
|
||||||
|
let cache = lua
|
||||||
|
.app_data_ref::<AutosaveCache>()
|
||||||
|
.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::<AutosaveCache>()
|
||||||
|
.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<String>, usize) {
|
||||||
|
let mut fresh = Vec::new();
|
||||||
|
let mut corrupt = 0usize;
|
||||||
|
let (Some(base), Some(core)) = (base_dir(lua), lua.app_data_ref::<SharedCore>()) else {
|
||||||
|
return (fresh, corrupt);
|
||||||
|
};
|
||||||
|
// Collect paths first so the guard drops before any IO re-entrancy.
|
||||||
|
let paths: Vec<std::path::PathBuf> = {
|
||||||
|
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<Origin>) -> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,10 +20,10 @@ use std::path::Path;
|
||||||
|
|
||||||
use mlua::Lua;
|
use mlua::Lua;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::buffer::BufferId;
|
use crate::buffer::BufferId;
|
||||||
use crate::editor_core::EditorCore;
|
use crate::editor_core::EditorCore;
|
||||||
|
use crate::hash::sha256_hex;
|
||||||
use crate::lua_bindings::{LocalInstanceInfo, SharedCore, StateDir, fire_after_load_hook};
|
use crate::lua_bindings::{LocalInstanceInfo, SharedCore, StateDir, fire_after_load_hook};
|
||||||
use crate::protocol::FrontendId;
|
use crate::protocol::FrontendId;
|
||||||
use crate::text_view::TextView;
|
use crate::text_view::TextView;
|
||||||
|
|
@ -141,18 +141,6 @@ pub fn desktop_state_key(session_key: &str) -> String {
|
||||||
format!("desktop/{session_key}")
|
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`],
|
/// Build the serializable layout tree from a core [`LayoutNode`],
|
||||||
/// resolving each leaf window to a [`SavedLeaf`] (returning `None` for a
|
/// resolving each leaf window to a [`SavedLeaf`] (returning `None` for a
|
||||||
/// non-file leaf, which is dropped and its split collapsed).
|
/// non-file leaf, which is dropped and its split collapsed).
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,12 @@ impl EditorState {
|
||||||
include_str!("../builtin/runtime/desktop.lua"),
|
include_str!("../builtin/runtime/desktop.lua"),
|
||||||
)
|
)
|
||||||
.expect("load desktop builtin chunk");
|
.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
|
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
|
||||||
// was loaded directly via `eval(include_str!(...))`; the
|
// was loaded directly via `eval(include_str!(...))`; the
|
||||||
// M7.11 deliverable migrates it to the package system so it
|
// M7.11 deliverable migrates it to the package system so it
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,32 @@ impl Drop for TempCleanup {
|
||||||
/// identity for future change-detection comparisons.
|
/// identity for future change-detection comparisons.
|
||||||
///
|
///
|
||||||
/// Threading: any thread.
|
/// Threading: any thread.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// See [`SaveError`].
|
||||||
pub fn save_atomic(path: &Path, content: &[u8]) -> Result<FileMeta, SaveError> {
|
pub fn save_atomic(path: &Path, content: &[u8]) -> Result<FileMeta, SaveError> {
|
||||||
|
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<u32>,
|
||||||
|
) -> Result<FileMeta, SaveError> {
|
||||||
// `Path::parent` returns:
|
// `Path::parent` returns:
|
||||||
// * `None` for `/` or `""` --- no place to put a sibling temp file;
|
// * `None` for `/` or `""` --- no place to put a sibling temp file;
|
||||||
// * `Some("")` for a bare filename like `notes.txt` --- means cwd, fine;
|
// * `Some("")` for a bare filename like `notes.txt` --- means cwd, fine;
|
||||||
|
|
@ -140,10 +165,22 @@ pub fn save_atomic(path: &Path, content: &[u8]) -> Result<FileMeta, SaveError> {
|
||||||
return Err(SaveError::NoParent(path.to_path_buf()));
|
return Err(SaveError::NoParent(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the target's current permissions so an existing file keeps
|
// An explicit `mode` wins; otherwise snapshot the target's current
|
||||||
// its mode across the replace (F-006) — e.g. a `0755` script stays
|
// permissions so an existing file keeps its mode across the replace
|
||||||
// executable. `None` for a new file, which then gets the default mode.
|
// (F-006) — e.g. a `0755` script stays executable. `None` for a new
|
||||||
let existing_perms = fs::metadata(path).ok().map(|m| m.permissions());
|
// 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
|
// 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
|
// left by a crashed prior run whose pid+nanos recurs) instead of
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,7 @@ pub mod attach;
|
||||||
pub mod attach_dispatch;
|
pub mod attach_dispatch;
|
||||||
pub mod attach_reconnect;
|
pub mod attach_reconnect;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod autosave;
|
||||||
pub mod buffer;
|
pub mod buffer;
|
||||||
pub mod buffer_registry;
|
pub mod buffer_registry;
|
||||||
pub mod builtin_packages;
|
pub mod builtin_packages;
|
||||||
|
|
@ -75,6 +76,7 @@ pub mod file_io;
|
||||||
pub mod formatting;
|
pub mod formatting;
|
||||||
pub mod frontend;
|
pub mod frontend;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
|
mod hash;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
pub mod hook;
|
pub mod hook;
|
||||||
|
|
|
||||||
|
|
@ -1963,10 +1963,75 @@ pub fn install(
|
||||||
pmacs.set("packages", install_packages_module(lua)?)?;
|
pmacs.set("packages", install_packages_module(lua)?)?;
|
||||||
pmacs.set("state", install_state_module(lua)?)?;
|
pmacs.set("state", install_state_module(lua)?)?;
|
||||||
pmacs.set("session", install_session_module(lua)?)?;
|
pmacs.set("session", install_session_module(lua)?)?;
|
||||||
|
pmacs.set("autosave", install_autosave_module(lua)?)?;
|
||||||
lua.globals().set("pmacs", pmacs)?;
|
lua.globals().set("pmacs", pmacs)?;
|
||||||
Ok(())
|
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<Table> {
|
||||||
|
// 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::<StateDir>().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::<StateDir>().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::<StateDir>().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,
|
/// Marker app-data set by `pmacs.session.arm_restore()` (Arc 3 phase 2,
|
||||||
/// Q#DS7). Its presence tells the `RunLocal` startup trigger to attempt
|
/// Q#DS7). Its presence tells the `RunLocal` startup trigger to attempt
|
||||||
/// a desktop restore; `desktop_mode(true)` in init.lua arms it.
|
/// a desktop restore; `desktop_mode(true)` in init.lua arms it.
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ use std::process::{Child, Command, ExitStatus, Stdio};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use thiserror::Error;
|
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,
|
/// 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
|
/// so a *cryptographic* digest is used: a non-cryptographic hash like the
|
||||||
/// former 64-bit FNV-1a is trivially collidable, and a deliberate
|
/// former 64-bit FNV-1a is trivially collidable, and a deliberate
|
||||||
/// collision would make two URLs share one bare mirror + lock file. `sha2`
|
/// collision would make two URLs share one bare mirror + lock file.
|
||||||
/// is already a dependency (M7.6 lockfile content hashing).
|
///
|
||||||
fn sha256_hex(s: &str) -> String {
|
/// The implementation now lives in [`crate::hash`] — shared with the
|
||||||
use std::fmt::Write as _;
|
/// desktop session key and the autosave recovery key (Q#AS9).
|
||||||
let mut hasher = Sha256::new();
|
use crate::hash::sha256_hex;
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LockGuard --- per-cache-entry flock(2)
|
// LockGuard --- per-cache-entry flock(2)
|
||||||
|
|
|
||||||
115
src/state.rs
115
src/state.rs
|
|
@ -182,14 +182,76 @@ pub fn read(base: &Path, name: &str) -> Result<Option<String>, StateError> {
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Invalid key, or a save failure.
|
/// Invalid key, or a save failure.
|
||||||
pub fn write(base: &Path, name: &str, content: &[u8]) -> Result<(), StateError> {
|
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<u32>,
|
||||||
|
) -> Result<(), StateError> {
|
||||||
let path = resolve(base, name).map_err(StateError::Name)?;
|
let path = resolve(base, name).map_err(StateError::Name)?;
|
||||||
if let Some(parent) = path.parent() {
|
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(())
|
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<u32>) -> 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<Option<Vec<u8>>, 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).
|
/// Remove a state file. Missing file is success (idempotent).
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|
@ -343,6 +405,55 @@ mod tests {
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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]
|
#[test]
|
||||||
fn blank_xdg_falls_through_to_home_not_a_relative_path() {
|
fn blank_xdg_falls_through_to_home_not_a_relative_path() {
|
||||||
// The Q#PS2 fix: an empty / whitespace XDG_STATE_HOME must NOT
|
// The Q#PS2 fix: an empty / whitespace XDG_STATE_HOME must NOT
|
||||||
|
|
|
||||||
|
|
@ -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::<StateDir>();
|
||||||
|
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<T: mlua::FromLuaMulti>(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<String>, 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<String>, 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();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue