Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
use std::{
io,
path::{Path, PathBuf},
};
/// What the last session's end looked like (§6.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Marker {
Clean,
Crashed,
Discarded,
}
/// The lock that tells crash from discard.
pub struct Crash {
file: PathBuf,
}
impl Crash {
/// Marks the session open under `pid`.
pub fn open(dir: &Path, pid: u32) -> io::Result<Self> {
std::fs::create_dir_all(dir)?;
let file = dir.join("session.lock");
std::fs::write(&file, format!("open {pid}"))?;
Ok(Self { file })
}
/// Reads the lock; a dead pid means a crash (§6.2).
pub fn check(dir: &Path, alive: impl Fn(u32) -> bool) -> io::Result<Marker> {
let file = dir.join("session.lock");
let text = match std::fs::read_to_string(&file) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Marker::Clean),
Err(error) => return Err(error),
};
let mut parts = text.split_whitespace();
match (parts.next(), parts.next()) {
(Some("discarded"), _) => Ok(Marker::Discarded),
(Some("open"), Some(pid)) => {
let pid: u32 = pid.parse().map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "bad pid in the session lock")
})?;
Ok(if alive(pid) {
Marker::Clean
} else {
Marker::Crashed
})
}
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
"bad session lock",
)),
}
}
/// An explicit discard: never a crash next launch (§6.2).
pub fn discard(self) -> io::Result<()> {
std::fs::write(&self.file, "discarded 0")
}
/// A clean close removes the lock.
pub fn close(self) -> io::Result<()> {
match std::fs::remove_file(&self.file) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
}
/// Whether process `pid` still runs; a crash leaves none.
#[cfg(unix)]
pub fn alive(pid: u32) -> bool {
let Ok(pid) = i32::try_from(pid) else {
return false;
};
// Another user's live process answers EPERM.
let answered = unsafe { libc::kill(pid, 0) } == 0;
answered || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
/// Whether process `pid` still runs; a crash leaves none.
#[cfg(windows)]
pub fn alive(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
use windows_sys::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return false;
}
let mut code = 0;
let read = unsafe { GetExitCodeProcess(handle, &mut code) } != 0;
unsafe { CloseHandle(handle) };
read && code == STILL_ACTIVE as u32
}
#[cfg(all(test, unix))]
mod alive_tests {
#[test]
fn this_process_lives_a_reaped_one_does_not() {
assert!(super::alive(std::process::id()));
let mut child = std::process::Command::new("true").spawn().expect("a child");
let pid = child.id();
child.wait().expect("the child ends");
assert!(!super::alive(pid));
}
}
+207
View File
@@ -0,0 +1,207 @@
use std::{
io,
path::Path,
time::{Duration, Instant},
};
#[cfg(test)]
mod tests;
/// A key store backend, or a test double.
pub trait Backend: Send + Sync {
/// The stored key bytes, or None when absent.
fn get(&self) -> io::Result<Option<Vec<u8>>>;
/// Stores the key bytes.
fn set(&self, key: &[u8]) -> io::Result<()>;
}
/// The system key store through `keyring` (§18.2).
pub struct KeyringBackend;
impl Backend for KeyringBackend {
fn get(&self) -> io::Result<Option<Vec<u8>>> {
let entry = entry()?;
match entry.get_secret() {
Ok(bytes) => Ok(Some(bytes)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("the key store keeps the recovery key: {error}"),
)),
}
}
fn set(&self, key: &[u8]) -> io::Result<()> {
entry()?
.set_secret(key)
.map_err(|error| io::Error::other(format!("the key store refused the key: {error}")))
}
}
/// The credential entry for the recovery key.
fn entry() -> io::Result<keyring::Entry> {
keyring::Entry::new("ElyOffice Recovery", "recovery-key")
.map_err(|error| io::Error::other(format!("no credential entry: {error}")))
}
/// The recovery key, and whether it outlives this session (§18.2).
#[derive(Clone, Copy, Debug)]
pub struct RecoveryKey {
pub key: [u8; 32],
/// False when the system store refused: this run only.
pub lasting: bool,
}
impl RecoveryKey {
/// The stored key, else this session's; never a file.
pub fn load() -> Self {
let dir = account_home().map(|home| shared_dir(&home));
Self::from(dir.and_then(|dir| load_with(&KeyringBackend, &dir)))
}
/// The loaded key, or this session's when loading failed.
pub fn from(loaded: io::Result<[u8; 32]>) -> Self {
match loaded {
Ok(key) => Self { key, lasting: true },
Err(error) => {
log::warn!("recovery: no system key store, this session's key only: {error}");
Self { key: *session_key(), lasting: false }
}
}
}
}
/// One random key per run, kept only in memory.
fn session_key() -> &'static [u8; 32] {
static KEY: std::sync::OnceLock<[u8; 32]> = std::sync::OnceLock::new();
KEY.get_or_init(|| {
let mut key = [0u8; 32];
getrandom::fill(&mut key).expect("system randomness");
key
})
}
/// The OS account's home, deaf to environment overrides.
#[cfg(unix)]
fn account_home() -> io::Result<std::path::PathBuf> {
use std::os::unix::ffi::OsStringExt;
let mut passwd: libc::passwd = unsafe { std::mem::zeroed() };
let mut buffer = vec![0u8; 1024];
let mut result = std::ptr::null_mut();
loop {
let status = unsafe {
libc::getpwuid_r(
libc::getuid(),
&mut passwd,
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
match status {
0 => break,
libc::ERANGE => buffer.resize(buffer.len() * 2, 0),
error => return Err(io::Error::from_raw_os_error(error)),
}
}
if result.is_null() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"no account home for this uid",
));
}
let dir = unsafe { std::ffi::CStr::from_ptr(passwd.pw_dir) };
Ok(std::path::PathBuf::from(std::ffi::OsString::from_vec(
dir.to_bytes().to_vec(),
)))
}
/// The OS account's profile, deaf to environment overrides.
#[cfg(windows)]
fn account_home() -> io::Result<std::path::PathBuf> {
use windows_sys::Win32::System::Com::CoTaskMemFree;
use windows_sys::Win32::UI::Shell::{FOLDERID_Profile, SHGetKnownFolderPath};
let mut path = std::ptr::null_mut();
let result =
unsafe { SHGetKnownFolderPath(&FOLDERID_Profile, 0, std::ptr::null_mut(), &mut path) };
if result != 0 || path.is_null() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"no account profile directory",
));
}
let text = unsafe {
let len = (0..).take_while(|i| *path.add(*i) != 0).count();
let text = String::from_utf16_lossy(std::slice::from_raw_parts(path, len));
CoTaskMemFree(path.cast());
text
};
Ok(std::path::PathBuf::from(text))
}
/// The lock's home: per user, deaf to data-dir env overrides.
fn shared_dir(home: &Path) -> std::path::PathBuf {
#[cfg(target_os = "macos")]
{
home.join("Library/Application Support/ElyOffice")
}
#[cfg(target_os = "windows")]
{
home.join("AppData").join("Local").join("ElyOffice")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
home.join(".local").join("share").join("ElyOffice")
}
#[cfg(not(any(unix, windows)))]
{
home.join(".elyoffice")
}
}
/// Reads or initializes the key; one winner across processes.
pub fn load_with(backend: &dyn Backend, dir: &Path) -> io::Result<[u8; 32]> {
std::fs::create_dir_all(dir)?;
let lock = dir.join(".recovery-key.lock");
let deadline = Instant::now() + Duration::from_secs(1);
let guard = loop {
if let Some(guard) = ely_platform::lock::FileLock::try_take(&lock)? {
break guard;
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"another process still initializes the recovery key",
));
}
std::thread::sleep(Duration::from_millis(20));
};
let outcome = match backend.get()? {
Some(bytes) => key_of(bytes),
None => init(backend),
};
drop(guard);
outcome
}
/// Generates and stores the key, then re-reads the persisted one.
fn init(backend: &dyn Backend) -> io::Result<[u8; 32]> {
let mut key = [0u8; 32];
getrandom::fill(&mut key).map_err(|_| io::Error::other("system randomness failed"))?;
backend.set(&key)?;
log::info!("recovery: a new recovery key in the system store");
let Some(bytes) = backend.get()? else {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"the key store did not keep the new key",
));
};
key_of(bytes)
}
/// The key's exact length, or a loud error.
fn key_of(bytes: Vec<u8>) -> io::Result<[u8; 32]> {
bytes
.try_into()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "recovery key length"))
}
+289
View File
@@ -0,0 +1,289 @@
use super::*;
use std::sync::{Arc, Barrier, Mutex};
struct FakeBackend {
stored: Mutex<Option<Vec<u8>>>,
get_delay: Option<Duration>,
fail_set: Mutex<bool>,
}
impl FakeBackend {
/// Makes the next `set` fail once.
fn fail_next_set(&self) {
*self.fail_set.lock().expect("the flag") = true;
}
}
impl Backend for FakeBackend {
fn get(&self) -> io::Result<Option<Vec<u8>>> {
if let Some(delay) = self.get_delay {
std::thread::sleep(delay);
}
Ok(self.stored.lock().expect("the store").clone())
}
fn set(&self, key: &[u8]) -> io::Result<()> {
let mut fail = self.fail_set.lock().expect("the flag");
if *fail {
*fail = false;
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"the store is locked",
));
}
*self.stored.lock().expect("the store") = Some(key.to_vec());
Ok(())
}
}
struct RefusingBackend;
impl Backend for RefusingBackend {
fn get(&self) -> io::Result<Option<Vec<u8>>> {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "locked"))
}
fn set(&self, _: &[u8]) -> io::Result<()> {
unreachable!("a locked store never takes a key")
}
}
#[test]
fn two_loaders_share_one_key() {
let dir = tempfile::tempdir().expect("a temp dir");
let backend = Arc::new(FakeBackend {
stored: Mutex::new(None),
get_delay: Some(Duration::from_millis(30)),
fail_set: Mutex::new(false),
});
let barrier = Arc::new(Barrier::new(2));
let mut handles = Vec::new();
for _ in 0..2 {
let (backend, barrier, dir) = (
Arc::clone(&backend),
Arc::clone(&barrier),
dir.path().to_path_buf(),
);
handles.push(std::thread::spawn(move || {
barrier.wait();
load_with(backend.as_ref() as &dyn Backend, &dir).expect("the load")
}));
}
let a = handles.pop().expect("one handle").join().expect("a joins");
let b = handles
.pop()
.expect("another handle")
.join()
.expect("b joins");
assert_eq!(a, b, "both processes hold the one persisted key");
assert_eq!(a.len(), 32);
}
#[test]
fn a_locked_store_fails_loud() {
let dir = tempfile::tempdir().expect("a temp dir");
let refused = load_with(&RefusingBackend as &dyn Backend, dir.path());
assert!(refused.is_err());
}
#[test]
fn the_lock_holder_rechecks_before_generating() {
let dir = tempfile::tempdir().expect("a temp dir");
let backend = Arc::new(FakeBackend {
stored: Mutex::new(None),
get_delay: Some(Duration::from_millis(50)),
fail_set: Mutex::new(false),
});
let (a_backend, b_backend) = (Arc::clone(&backend), backend);
let dir_a = dir.path().to_path_buf();
let dir_b = dir.path().to_path_buf();
let a = std::thread::spawn(move || {
load_with(a_backend.as_ref() as &dyn Backend, &dir_a).expect("the load")
});
std::thread::sleep(Duration::from_millis(10));
let b = load_with(b_backend.as_ref() as &dyn Backend, &dir_b).expect("the load");
let a = a.join().expect("a joins");
assert_eq!(a, b, "the second loader returns the first key");
}
#[test]
fn a_slow_init_times_out_the_waiter_but_keeps_its_key() {
let dir = tempfile::tempdir().expect("a temp dir");
struct SlowBackend(Mutex<Option<Vec<u8>>>);
impl Backend for SlowBackend {
fn get(&self) -> io::Result<Option<Vec<u8>>> {
Ok(self.0.lock().expect("the store").clone())
}
fn set(&self, key: &[u8]) -> io::Result<()> {
std::thread::sleep(Duration::from_millis(1500));
*self.0.lock().expect("the store") = Some(key.to_vec());
Ok(())
}
}
let backend = Arc::new(SlowBackend(Mutex::new(None)));
let (a_backend, dir_a) = (Arc::clone(&backend), dir.path().to_path_buf());
let a = std::thread::spawn(move || {
load_with(a_backend.as_ref() as &dyn Backend, &dir_a).expect("the load")
});
std::thread::sleep(Duration::from_millis(100));
let timed_out = load_with(backend.as_ref() as &dyn Backend, dir.path());
let a_key = a.join().expect("a joins");
assert!(timed_out.is_err(), "a live holder is never overthrown");
assert_eq!(
backend
.0
.lock()
.expect("the store")
.as_ref()
.expect("a key")
.len(),
32
);
let again = load_with(backend.as_ref() as &dyn Backend, dir.path()).expect("the reload");
assert_eq!(again, a_key, "the slow initializer's key survives");
}
#[test]
fn a_failed_init_releases_the_lock_for_a_retry() {
let dir = tempfile::tempdir().expect("a temp dir");
let backend = Arc::new(FakeBackend {
stored: Mutex::new(None),
get_delay: None,
fail_set: Mutex::new(false),
});
backend.fail_next_set();
let first = load_with(backend.as_ref() as &dyn Backend, dir.path());
assert!(first.is_err(), "the failing set propagates");
let second = load_with(backend.as_ref() as &dyn Backend, dir.path()).expect("the retry");
assert_eq!(second.len(), 32);
}
#[test]
fn the_lock_lives_at_a_stable_home_path() {
let home = Path::new("/home/u");
let dir = shared_dir(home);
#[cfg(target_os = "macos")]
assert_eq!(dir, home.join("Library/Application Support/ElyOffice"));
#[cfg(all(unix, not(target_os = "macos")))]
assert_eq!(dir, home.join(".local/share/ElyOffice"));
#[cfg(windows)]
assert_eq!(dir, home.join("AppData/Local/ElyOffice"));
}
#[test]
fn a_mid_init_key_is_never_visible_to_another_loader() {
struct StagedBackend {
visible: Mutex<Option<Vec<u8>>>,
persist_delay: Duration,
armed: Mutex<bool>,
}
impl Backend for StagedBackend {
fn get(&self) -> io::Result<Option<Vec<u8>>> {
Ok(self.visible.lock().expect("the store").clone())
}
fn set(&self, key: &[u8]) -> io::Result<()> {
*self.visible.lock().expect("the store") = Some(key.to_vec());
std::thread::sleep(self.persist_delay);
let mut armed = self.armed.lock().expect("the flag");
if *armed {
*armed = false;
*self.visible.lock().expect("the store") = None;
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"rolled back",
));
}
Ok(())
}
}
let dir = tempfile::tempdir().expect("a temp dir");
let backend = Arc::new(StagedBackend {
visible: Mutex::new(None),
persist_delay: Duration::from_millis(120),
armed: Mutex::new(true),
});
let (a_backend, dir_a) = (Arc::clone(&backend), dir.path().to_path_buf());
let a = std::thread::spawn(move || load_with(a_backend.as_ref() as &dyn Backend, &dir_a));
std::thread::sleep(Duration::from_millis(30));
let b = load_with(backend.as_ref() as &dyn Backend, dir.path()).expect("b loads");
assert!(a.join().expect("a joins").is_err(), "a's init rolls back");
assert_eq!(b.len(), 32, "b initialized its own committed key");
let persisted = backend
.visible
.lock()
.expect("the store")
.clone()
.expect("a key");
assert_eq!(
b.as_slice(),
persisted.as_slice(),
"b's key is the persisted one"
);
}
#[cfg(unix)]
#[test]
fn the_account_home_holds_under_concurrent_queries() {
use std::sync::atomic::{AtomicBool, Ordering};
let want = super::account_home().expect("the account home");
let stop = Arc::new(AtomicBool::new(false));
let other = {
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
let _ = unsafe { libc::getpwuid(0) };
}
})
};
for _ in 0..2000 {
assert_eq!(super::account_home().expect("the account home"), want);
}
stop.store(true, Ordering::Relaxed);
other.join().expect("the query thread joins");
}
#[cfg(unix)]
#[test]
fn the_account_home_ignores_the_env() {
let here = super::account_home().expect("the account home");
let out = std::process::Command::new(std::env::current_exe().expect("the test binary"))
.args([
"--exact",
"key::tests::print_account_home",
"--nocapture",
"--include-ignored",
])
.env("HOME", "/nonexistent-home")
.output()
.expect("the child runs");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let child = stdout
.lines()
.find(|line| line.starts_with('/'))
.expect("the child prints its account home")
.to_string();
assert_eq!(child, here.display().to_string());
}
#[cfg(unix)]
#[test]
#[ignore]
fn print_account_home() {
println!(
"{}",
super::account_home().expect("the account home").display()
);
}
#[test]
fn without_a_store_the_key_lasts_one_session_only() {
let dir = tempfile::tempdir().expect("a temp dir");
let first = RecoveryKey::from(load_with(&RefusingBackend, dir.path()));
let again = RecoveryKey::from(load_with(&RefusingBackend, dir.path()));
assert!(!first.lasting, "the store refused, so nothing lasts");
assert_eq!(first.key, again.key, "one key for the whole run");
let names: Vec<_> = std::fs::read_dir(dir.path()).expect("the dir").flatten().map(|e| e.file_name()).collect();
assert!(names.iter().all(|name| name.to_string_lossy().ends_with(".lock")), "no key on disk: {names:?}");
let stored = RecoveryKey::from(Ok([7; 32]));
assert!(stored.lasting);
}
+24
View File
@@ -0,0 +1,24 @@
//! File sessions, saving and recovery (PRD §6).
mod crash;
mod key;
mod policy;
pub mod recent;
mod recovery;
mod rename;
mod save;
mod session;
mod task;
pub use crash::{Crash, Marker, alive};
pub use key::{Backend, RecoveryKey};
pub use policy::{Action, DEBOUNCE, Policy, PolicyEngine};
pub use recovery::{Recovery, RecoveryPoint, restore_copy};
pub use rename::rename_new;
pub use task::RecoveryTask;
pub use save::{
SaveError, Saved, atomic_capable, claim_all, export, import_attachment, same_file, save,
save_as, save_atomic, save_overwrite, save_with_backup, save_with_probe,
};
pub use session::{Fingerprint, Open, SaveState, SaveTicket, Session, Sessions, StaleTicket};
+117
View File
@@ -0,0 +1,117 @@
use std::time::{Duration, Instant};
#[cfg(test)]
mod tests;
/// The debounce after typing stops: Mark's 1 second (§6.2).
pub const DEBOUNCE: Duration = Duration::from_secs(1);
/// How an app saves (§6.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Policy {
/// Explicit save only; recovery copies run behind (§6.2).
Explicit,
/// Opt-in autosave for normal unsigned files.
AutoSave,
/// ElyMark: autosave once typing stops for a second.
MarkAutoSave,
}
/// What a tick decided.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Nothing,
/// A save of that revision should run now.
SaveDue(u64),
/// Autosave stopped by an external change or compat risk (§6.2).
AutoSaveStopped,
}
/// The save policy engine: debounce, stop rules and revision tracking.
pub struct PolicyEngine {
policy: Policy,
has_path: bool,
stopped: bool,
revision: u64,
persisted: u64,
last_edit: Option<(u64, Instant)>,
}
impl PolicyEngine {
pub fn new(policy: Policy, has_path: bool) -> Self {
Self {
policy,
has_path,
stopped: false,
revision: 0,
persisted: 0,
last_edit: None,
}
}
pub fn policy(&self) -> Policy {
self.policy
}
/// Whether autosave still runs.
pub fn autosaving(&self) -> bool {
!self.stopped && self.policy != Policy::Explicit
}
/// The first save assigned a path; Mark autosave can start.
pub fn set_has_path(&mut self) {
self.has_path = true;
}
/// An edit happened; the debounce restarts (§6.2).
pub fn edit(&mut self, now: Instant) {
self.revision += 1;
self.last_edit = Some((self.revision, now));
}
/// Time passed; maybe a save is due.
pub fn tick(&mut self, now: Instant) -> Action {
if !self.autosaving() || !self.has_path {
return Action::Nothing;
}
let Some((revision, at)) = self.last_edit else {
return Action::Nothing;
};
if revision > self.persisted && now.saturating_duration_since(at) >= DEBOUNCE {
return Action::SaveDue(revision);
}
Action::Nothing
}
/// An external change stops auto-overwrite for good (§6.2).
pub fn external_modified(&mut self) -> Action {
self.stop()
}
/// A compat risk stops auto-overwrite for good (§6.2).
pub fn compat_risk(&mut self) -> Action {
self.stop()
}
/// The shared stop: once, loud, permanent.
fn stop(&mut self) -> Action {
if !self.autosaving() {
return Action::Nothing;
}
self.stopped = true;
self.last_edit = None;
log::warn!("policy: autosave stopped; the user decides from here");
Action::AutoSaveStopped
}
/// A save finished; newer edits keep their due.
pub fn save_finished(&mut self, revision: u64, ok: bool) {
if !ok {
return;
}
self.persisted = self.persisted.max(revision);
if self.last_edit.map(|(r, _)| r) == Some(self.persisted) {
self.last_edit = None;
}
}
}
+135
View File
@@ -0,0 +1,135 @@
use super::*;
fn at(base: Instant, secs: u64) -> Instant {
base + Duration::from_secs(secs)
}
#[test]
fn explicit_never_saves_by_itself() {
let t0 = Instant::now();
let mut engine = PolicyEngine::new(Policy::Explicit, true);
engine.edit(t0);
for n in 0..10 {
assert_eq!(engine.tick(at(t0, n)), Action::Nothing);
}
assert!(!engine.autosaving());
}
#[test]
fn autosave_fires_a_second_after_typing_stops() {
let t0 = Instant::now();
let mut engine = PolicyEngine::new(Policy::AutoSave, true);
engine.edit(t0);
assert_eq!(engine.tick(t0), Action::Nothing);
assert_eq!(engine.tick(at(t0, 1)), Action::SaveDue(1));
engine.save_finished(1, true);
assert_eq!(engine.tick(at(t0, 2)), Action::Nothing);
engine.edit(at(t0, 3));
assert_eq!(engine.tick(at(t0, 4)), Action::SaveDue(2));
}
#[test]
fn the_debounce_restarts_per_edit() {
let t0 = Instant::now();
let mut engine = PolicyEngine::new(Policy::MarkAutoSave, true);
engine.edit(t0);
engine.edit(t0 + Duration::from_millis(700));
assert_eq!(engine.tick(at(t0, 1)), Action::Nothing);
assert_eq!(
engine.tick(t0 + Duration::from_millis(1800)),
Action::SaveDue(2)
);
}
#[test]
fn a_save_leaves_newer_edits_their_due() {
let t0 = Instant::now();
let mut engine = PolicyEngine::new(Policy::MarkAutoSave, true);
engine.edit(t0);
assert_eq!(engine.tick(at(t0, 1)), Action::SaveDue(1));
engine.edit(t0 + Duration::from_millis(1200));
engine.save_finished(1, true);
assert_eq!(
engine.tick(t0 + Duration::from_millis(2100)),
Action::Nothing
);
assert_eq!(
engine.tick(t0 + Duration::from_millis(2300)),
Action::SaveDue(2),
"the newer edit gets its own second"
);
engine.save_finished(2, true);
assert_eq!(engine.tick(at(t0, 4)), Action::Nothing);
}
#[test]
fn a_failed_autosave_stays_dirty() {
let t0 = Instant::now();
let mut engine = PolicyEngine::new(Policy::AutoSave, true);
engine.edit(t0);
assert_eq!(engine.tick(at(t0, 1)), Action::SaveDue(1));
engine.save_finished(1, false);
assert_eq!(engine.tick(at(t0, 2)), Action::SaveDue(1));
}
#[test]
fn an_external_change_stops_autosave_for_good() {
let t0 = Instant::now();
let mut engine = PolicyEngine::new(Policy::AutoSave, true);
assert_eq!(engine.external_modified(), Action::AutoSaveStopped);
assert!(!engine.autosaving());
engine.edit(t0);
assert_eq!(engine.tick(at(t0, 10)), Action::Nothing);
assert_eq!(engine.external_modified(), Action::Nothing, "reported once");
}
#[test]
fn a_compat_risk_stops_autosave_too() {
let t0 = Instant::now();
let mut engine = PolicyEngine::new(Policy::MarkAutoSave, true);
assert_eq!(engine.compat_risk(), Action::AutoSaveStopped);
engine.edit(t0);
assert_eq!(engine.tick(at(t0, 10)), Action::Nothing);
}
#[test]
fn mark_without_a_path_recovers_until_first_save() {
let t0 = Instant::now();
let mut engine = PolicyEngine::new(Policy::MarkAutoSave, false);
engine.edit(t0);
for n in 0..10 {
assert_eq!(engine.tick(at(t0, n)), Action::Nothing);
}
engine.edit(at(t0, 5));
engine.set_has_path();
assert_eq!(
engine.tick(at(t0, 6)),
Action::SaveDue(2),
"the pathless edits wait for a path"
);
}
#[test]
fn pathless_edits_during_the_first_save_get_scheduled() {
let t0 = Instant::now();
let mut engine = PolicyEngine::new(Policy::MarkAutoSave, false);
engine.edit(t0);
engine.edit(t0 + Duration::from_millis(500));
engine.save_finished(1, true);
engine.set_has_path();
assert_eq!(
engine.tick(t0 + Duration::from_millis(1400)),
Action::Nothing
);
assert_eq!(
engine.tick(t0 + Duration::from_millis(1600)),
Action::SaveDue(2)
);
}
#[test]
fn explicit_ignores_the_stop_triggers() {
let mut engine = PolicyEngine::new(Policy::Explicit, true);
assert_eq!(engine.external_modified(), Action::Nothing);
assert_eq!(engine.compat_risk(), Action::Nothing);
}
+75
View File
@@ -0,0 +1,75 @@
//! Recently opened paths, kept in a JSON file.
use std::{
io,
path::{Path, PathBuf},
};
/// How many paths the list keeps.
const KEEP: usize = 20;
/// Recently opened paths, newest first.
pub fn recent(file: &Path) -> io::Result<Vec<PathBuf>> {
match std::fs::read(file) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|error| io::Error::other(format!("{}: {error}", file.display()))),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error),
}
}
/// Takes `root` out of the list.
pub fn forget(file: &Path, root: &Path) -> io::Result<()> {
let mut list = recent(file)?;
list.retain(|known| known != root);
crate::save_atomic(
file,
&serde_json::to_vec(&list).map_err(io::Error::other)?,
0,
)
.map(drop)
}
/// Puts `root` first in the list and writes it.
pub fn remember(file: &Path, root: &Path) -> io::Result<Vec<PathBuf>> {
let mut list = recent(file)?;
list.retain(|known| known != root);
list.insert(0, root.to_path_buf());
list.truncate(KEEP);
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir)?;
}
crate::save_atomic(
file,
&serde_json::to_vec(&list).map_err(io::Error::other)?,
0,
)?;
Ok(list)
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::{forget, recent, remember};
#[test]
fn the_list_puts_the_newest_first_and_keeps_twenty() {
let dir = tempfile::tempdir().expect("a temp dir");
let file = dir.path().join("data/recent.json");
assert!(recent(&file).expect("none yet").is_empty());
for n in 0..25 {
remember(&file, Path::new(&format!("/r/{n}"))).expect("remembers");
}
let list = remember(&file, Path::new("/r/10")).expect("remembers");
assert_eq!(list.len(), 20);
assert_eq!(list[..2], [PathBuf::from("/r/10"), PathBuf::from("/r/24")]);
forget(&file, Path::new("/r/10")).expect("forgets");
assert_eq!(recent(&file).expect("reads")[0], PathBuf::from("/r/24"));
std::fs::write(&file, "not json").expect("writes");
assert!(
remember(&file, Path::new("/r/1")).is_err(),
"a broken list is not overwritten"
);
}
}
+420
View File
@@ -0,0 +1,420 @@
use std::{
io::{self, Write},
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
#[cfg(test)]
mod tests;
/// One recovery point and what it restores (§6.3).
#[derive(Clone, Debug)]
pub struct RecoveryPoint {
pub at: SystemTime,
pub content: PathBuf,
pub cursor: Option<String>,
pub history: bool,
/// Sealed at rest; only its key restores it (§18.2).
pub sealed: bool,
}
/// Per-document recovery copies: 10s apart, 20 points, 7 days (§6.2).
pub struct Recovery {
dir: PathBuf,
keep: usize,
max_age: Duration,
cap: u64,
running: bool,
key: Option<[u8; 32]>,
next_due: Option<SystemTime>,
last_ok: Option<SystemTime>,
}
impl Recovery {
pub const INTERVAL: Duration = Duration::from_secs(10);
pub const KEEP: usize = 20;
pub const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
pub const CAP: u64 = 64 * 1024 * 1024;
pub fn new(dir: PathBuf) -> Self {
Self {
dir,
keep: Self::KEEP,
max_age: Self::MAX_AGE,
cap: Self::CAP,
running: false,
key: None,
next_due: None,
last_ok: None,
}
}
/// Prunes expired points; call after every (re)open (§6.2).
pub fn maintain(&self, now: SystemTime) -> io::Result<()> {
self.prune(now)
}
/// An encrypted document's recovery, sealed with its key (§18.2).
pub fn sensitive(dir: PathBuf, key: [u8; 32]) -> Self {
Self {
key: Some(key),
..Self::new(dir)
}
}
/// The user's own limits (§6.2).
pub fn with_limits(mut self, keep: usize, cap: u64) -> Self {
self.keep = keep;
self.cap = cap;
self
}
/// The last successful recovery write; nothing else means persisted.
pub fn last_ok(&self) -> Option<SystemTime> {
self.last_ok
}
/// Whether a recovery write is due now (§6.2).
pub fn tick(&mut self, now: SystemTime) -> bool {
if self.next_due.is_some_and(|due| now < due) {
return false;
}
self.next_due = Some(now + Self::INTERVAL);
true
}
/// Whether the user explicitly discarded the last session (§6.2).
pub fn discarded(&self) -> io::Result<bool> {
self.dir.join(".discarded").try_exists()
}
/// One point on disk, one task at a time.
pub fn write_once(
&mut self,
content: &[u8],
cursor: Option<&str>,
now: SystemTime,
) -> io::Result<Option<PathBuf>> {
if self.running {
log::info!(
"recovery: a task is already running for {}",
self.dir.display()
);
return Ok(None);
}
let extra = cursor.map(|text| text.len() as u64).unwrap_or(0);
let size = if self.key.is_some() {
1 + 4 + extra + content.len() as u64 + 16 + 12
} else {
content.len() as u64 + extra
};
if size > self.cap {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("the recovery point exceeds its {} byte cap", self.cap),
));
}
self.running = true;
let result = self.write_inner(content, cursor, now);
self.running = false;
let path = result?;
self.prune(now)?;
self.last_ok = Some(now);
match std::fs::remove_file(self.dir.join(".discarded")) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
Ok(Some(path))
}
/// Writes the point's files, staged and published.
fn write_inner(
&self,
content: &[u8],
cursor: Option<&str>,
now: SystemTime,
) -> io::Result<PathBuf> {
let dir = self.dir.join("points");
std::fs::create_dir_all(&dir)?;
let stamp = stamp_of(now)?;
if let Some(key) = self.key {
let cursor_bytes = cursor.map(|text| text.as_bytes()).unwrap_or(&[]);
let mut bundle = vec![u8::from(cursor.is_some())];
bundle.extend_from_slice(&(cursor_bytes.len() as u32).to_le_bytes());
bundle.extend_from_slice(cursor_bytes);
bundle.extend_from_slice(content);
let (sealed, nonce) = seal(&key, &bundle)?;
let mut point = nonce.to_vec();
point.extend_from_slice(&sealed);
let enc = dir.join(format!("{stamp}.enc"));
crate::save_atomic(&enc, &point, 0)?;
log::info!("recovery: a sealed point at {}", enc.display());
return Ok(enc);
}
let content_path = dir.join(format!("{stamp}.bin"));
crate::save_atomic(&content_path, content, 0)?;
if let Some(cursor) = cursor {
crate::save_atomic(&dir.join(format!("{stamp}.cursor")), cursor.as_bytes(), 0)?;
}
log::info!("recovery: a point at {}", content_path.display());
Ok(content_path)
}
/// Every point on disk, newest first.
pub fn points(&self) -> io::Result<Vec<RecoveryPoint>> {
let dir = self.dir.join("points");
let mut stamps = Vec::new();
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error),
};
for entry in entries {
let name = entry?.file_name().to_string_lossy().into_owned();
let Some((stamp, ext)) = name.split_once('.') else {
continue;
};
if matches!(ext, "bin" | "enc")
&& let Ok(stamp) = stamp.parse::<u128>()
{
stamps.push(stamp);
}
}
stamps.sort_unstable_by(|a, b| b.cmp(a));
stamps.dedup();
stamps
.into_iter()
.map(|stamp| self.point(stamp))
.collect::<io::Result<_>>()
}
/// The point one stamp names.
fn point(&self, stamp: u128) -> io::Result<RecoveryPoint> {
let dir = self.dir.join("points");
let at = SystemTime::UNIX_EPOCH + Duration::from_nanos(stamp as u64);
let enc = dir.join(format!("{stamp}.enc"));
if enc.try_exists()? {
let cursor = match self.key {
Some(key) => open_cursor(&key, &enc)?,
None => None,
};
return Ok(RecoveryPoint {
at,
content: enc,
cursor,
history: false,
sealed: true,
});
}
let cursor = match std::fs::read_to_string(dir.join(format!("{stamp}.cursor"))) {
Ok(cursor) => Some(cursor),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
Ok(RecoveryPoint {
at,
content: dir.join(format!("{stamp}.bin")),
cursor,
history: dir.join(format!("{stamp}.history")).try_exists()?,
sealed: false,
})
}
/// Drops the oldest points past age, count and byte limits.
fn prune(&self, now: SystemTime) -> io::Result<()> {
let mut points = self.points()?;
while points.len() > self.keep
|| points.last().is_some_and(|point| {
now.duration_since(point.at)
.map(|age| age > self.max_age)
.unwrap_or(false)
})
|| self.bytes(&points)? > self.cap
{
let Some(oldest) = points.pop() else { break };
self.drop(oldest.at)?;
}
Ok(())
}
/// The total size of every file of every live point.
fn bytes(&self, points: &[RecoveryPoint]) -> io::Result<u64> {
let mut total = 0;
for point in points {
let stamp = stamp_of(point.at)?;
let dir = self.dir.join("points");
for ext in ["bin", "cursor", "history", "enc"] {
let path = dir.join(format!("{stamp}.{ext}"));
match std::fs::metadata(&path) {
Ok(meta) => total += meta.len(),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
}
Ok(total)
}
/// Removes a point's files, its metadata first.
fn drop(&self, at: SystemTime) -> io::Result<()> {
let stamp = stamp_of(at)?;
let dir = self.dir.join("points");
for ext in ["cursor", "history", "bin", "enc"] {
match std::fs::remove_file(dir.join(format!("{stamp}.{ext}"))) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Ok(())
}
/// An explicit discard: points go, the result stays recorded (§6.2).
pub fn discard(&mut self) -> io::Result<()> {
for point in self.points()? {
self.drop(point.at)?;
}
std::fs::write(self.dir.join(".discarded"), "discarded")?;
self.last_ok = None;
Ok(())
}
/// Restores the point beside, never over; sealed needs its key.
pub fn restore(&self, point: &RecoveryPoint, dest: &Path) -> io::Result<()> {
if point.sealed && self.key.is_none() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"a sealed point needs its recovery key",
));
}
let mut file = fresh_dest(dest)?;
if point.sealed {
let bundle = open_bundle(&self.key.expect("a sealed recovery holds its key"), point)?;
let (prefix, rest) = bundle.split_at(5);
let cursor_len = u32::from_le_bytes(prefix[1..].try_into().expect("a u32 prefix"));
file.write_all(&rest[cursor_len as usize..])?;
} else {
let mut source = std::fs::File::open(&point.content)?;
io::copy(&mut source, &mut file)?;
}
file.sync_all()?;
log::info!("recovery: restored to {}", dest.display());
Ok(())
}
}
/// The point's timestamp as its file stem.
fn stamp_of(at: SystemTime) -> io::Result<u128> {
Ok(at
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "time before 1970"))?
.as_nanos())
}
/// An exclusive fresh restore target; links never followed.
fn fresh_dest(dest: &Path) -> io::Result<std::fs::File> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dest)
.map_err(|error| io::Error::new(error.kind(), format!("{}: {error}", dest.display())))
}
/// AES-256-GCM with a fresh nonce (§18.2).
fn seal(key: &[u8; 32], plaintext: &[u8]) -> io::Result<(Vec<u8>, [u8; 12])> {
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
let mut nonce_bytes = [0u8; 12];
getrandom::fill(&mut nonce_bytes).map_err(|_| io::Error::other("system randomness failed"))?;
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "recovery key rejected"))?;
let out = cipher
.encrypt(nonce(&nonce_bytes)?, plaintext)
.map_err(|_| io::Error::other("sealing failed"))?;
Ok((out, nonce_bytes))
}
/// A 96-bit nonce reference from its bytes.
fn nonce<'a>(bytes: &'a [u8]) -> io::Result<&'a aes_gcm::aead::Nonce<aes_gcm::Aes256Gcm>> {
<&'a aes_gcm::aead::Nonce<aes_gcm::Aes256Gcm>>::try_from(bytes)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "bad nonce"))
}
/// Reverses `seal`; a wrong key fails loud.
fn open_sealed(key: &[u8; 32], ciphertext: &[u8], nonce_bytes: &[u8]) -> io::Result<Vec<u8>> {
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "recovery key rejected"))?;
cipher
.decrypt(nonce(nonce_bytes)?, ciphertext)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "the sealed point is unreadable"))
}
/// Reads a sealed point's bundle from disk.
fn open_bundle(key: &[u8; 32], point: &RecoveryPoint) -> io::Result<Vec<u8>> {
let file = std::fs::read(&point.content)?;
let Some((nonce, ciphertext)) = file.split_at_checked(12) else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"a short sealed point",
));
};
open_sealed(key, ciphertext, nonce)
}
/// The cursor a sealed bundle leads with, when present.
fn open_cursor(key: &[u8; 32], enc: &Path) -> io::Result<Option<String>> {
let point = RecoveryPoint {
at: stamp_of_file(enc)?,
content: enc.to_path_buf(),
cursor: None,
history: false,
sealed: true,
};
let bundle = open_bundle(key, &point)?;
let (flag, rest) = bundle
.split_at_checked(1)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "a short sealed bundle"))?;
if flag[0] == 0 {
return Ok(None);
}
let (prefix, rest) = rest
.split_at_checked(4)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "a short sealed bundle"))?;
let cursor_len = u32::from_le_bytes(prefix.try_into().expect("a u32 prefix")) as usize;
let cursor = rest
.get(..cursor_len)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "a short sealed bundle"))?;
Ok(Some(String::from_utf8(cursor.to_vec()).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "cursor is no text")
})?))
}
/// The stamp an `.enc` file name carries.
fn stamp_of_file(path: &Path) -> io::Result<SystemTime> {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
let stamp: u128 = name
.parse()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "bad point name"))?;
Ok(SystemTime::UNIX_EPOCH + Duration::from_nanos(stamp as u64))
}
/// A recovery copy lands beside, never over.
pub fn restore_copy(point: &RecoveryPoint, dest: &Path) -> io::Result<()> {
if point.sealed {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a sealed point restores through its Recovery",
));
}
let mut file = fresh_dest(dest)?;
let mut source = std::fs::File::open(&point.content)?;
io::copy(&mut source, &mut file)?;
file.sync_all()?;
log::info!(
"recovery: restored {} to {}",
point.content.display(),
dest.display()
);
Ok(())
}
+395
View File
@@ -0,0 +1,395 @@
use super::*;
use crate::crash::{Crash, Marker};
fn at(secs: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
}
fn key() -> [u8; 32] {
[7u8; 32]
}
#[test]
fn a_point_lands_with_cursor_and_last_ok() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
assert_eq!(recovery.last_ok(), None);
let path = recovery
.write_once(b"body", Some("12:4"), at(100))
.expect("the write lands")
.expect("the task runs");
assert_eq!(
std::fs::read_to_string(&path).expect("the point reads"),
"body"
);
assert_eq!(recovery.last_ok(), Some(at(100)));
let points = recovery.points().expect("the points list");
assert_eq!(points.len(), 1);
assert_eq!(points[0].cursor.as_deref(), Some("12:4"));
assert!(!points[0].history, "undo history is not captured");
assert!(!points[0].sealed);
assert_eq!(points[0].at, at(100));
}
#[test]
fn a_running_task_skips_the_tick() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
recovery.running = true;
let skipped = recovery
.write_once(b"body", None, at(100))
.expect("the skip");
assert_eq!(skipped, None);
assert_eq!(recovery.last_ok(), None);
}
#[test]
fn the_cadence_drives_ten_second_writes() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
assert!(recovery.tick(at(100)), "the first tick fires");
assert!(!recovery.tick(at(109)), "nine seconds is too soon");
assert!(recovery.tick(at(110)), "ten seconds is due");
recovery
.write_once(b"0", None, at(110))
.expect("the write lands");
assert!(recovery.tick(at(120)), "the cadence continues");
}
#[test]
fn only_twenty_points_stay() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
for n in 0..25u64 {
recovery
.write_once(b"x", None, at(100 + n))
.expect("the write lands");
}
let points = recovery.points().expect("the points list");
assert_eq!(points.len(), 20);
assert_eq!(points.last().expect("an oldest").at, at(105));
}
#[test]
fn points_older_than_seven_days_go() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
recovery
.write_once(b"old", None, at(100))
.expect("the write lands");
let week = 8 * 24 * 3600;
recovery
.write_once(b"new", None, at(100 + week))
.expect("the write lands");
let points = recovery.points().expect("the points list");
assert_eq!(points.len(), 1);
assert_eq!(points[0].at, at(100 + week));
}
#[test]
fn reopening_prunes_without_a_new_write() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
recovery
.write_once(b"old", None, at(100))
.expect("the write lands");
drop(recovery);
let week = 8 * 24 * 3600;
let recovery = Recovery::new(dir.path().to_path_buf());
recovery
.maintain(at(100 + week))
.expect("the reopen maintain");
assert!(recovery.points().expect("the points list").is_empty());
}
#[test]
fn the_byte_cap_drops_oldest() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf()).with_limits(20, 12);
for n in 0..5u64 {
recovery
.write_once(b"12345", None, at(100 + n))
.expect("the write lands");
}
let points = recovery.points().expect("the points list");
assert_eq!(points.len(), 2);
assert_eq!(points.last().expect("an oldest").at, at(103));
}
#[test]
fn an_oversize_point_fails_and_keeps_the_previous() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf()).with_limits(20, 4);
recovery
.write_once(b"old!", None, at(100))
.expect("the write lands");
let refused = recovery.write_once(b"large", None, at(200));
assert!(refused.is_err());
assert_eq!(recovery.last_ok(), Some(at(100)));
let points = recovery.points().expect("the points list");
assert_eq!(points.len(), 1);
assert_eq!(points[0].at, at(100));
}
#[test]
fn the_cap_counts_the_cursor_too() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf()).with_limits(20, 8);
let refused = recovery.write_once(b"a", Some("12345678"), at(100));
assert!(refused.is_err());
assert_eq!(recovery.last_ok(), None);
}
#[cfg(unix)]
#[test]
fn a_failed_write_keeps_last_ok_honest() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
recovery
.write_once(b"0", None, at(100))
.expect("the write lands");
let points = dir.path().join("points");
std::fs::set_permissions(&points, std::fs::Permissions::from_mode(0o555))
.expect("the lockdown");
let failed = recovery.write_once(b"1", None, at(200));
std::fs::set_permissions(&points, std::fs::Permissions::from_mode(0o755)).expect("the restore");
assert!(failed.is_err());
assert_eq!(recovery.last_ok(), Some(at(100)));
let listed = recovery.points().expect("the points list");
assert_eq!(listed.len(), 1, "a partial write is never listed");
}
#[test]
fn dangling_metadata_is_no_point() {
let dir = tempfile::tempdir().expect("a temp dir");
let points = dir.path().join("points");
std::fs::create_dir_all(&points).expect("the points dir");
std::fs::write(points.join("100.cursor"), "12:4").expect("the dangling cursor");
std::fs::write(points.join("200.nonce"), [0u8; 12]).expect("the dangling nonce");
let recovery = Recovery::new(dir.path().to_path_buf());
assert!(recovery.points().expect("the points list").is_empty());
}
#[test]
fn a_recovery_copy_lands_beside_never_over() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
recovery
.write_once(b"body", None, at(100))
.expect("the write lands");
let points = recovery.points().expect("the points list");
let dest = dir.path().join("restored.md");
restore_copy(&points[0], &dest).expect("the copy lands");
assert_eq!(
std::fs::read_to_string(&dest).expect("the copy reads"),
"body"
);
assert!(points[0].content.try_exists().expect("the point stays"));
let refused = restore_copy(&points[0], &dest);
assert!(refused.is_err(), "an existing target is never overwritten");
}
#[cfg(unix)]
#[test]
fn a_restore_never_follows_links() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
recovery
.write_once(b"body", None, at(100))
.expect("the write lands");
let points = recovery.points().expect("the points list");
let other = dir.path().join("other.md");
write(&other, "other");
let linked = dir.path().join("linked.md");
std::os::unix::fs::symlink(&other, &linked).expect("the symlink");
assert!(restore_copy(&points[0], &linked).is_err());
assert_eq!(
std::fs::read_to_string(&other).expect("the original reads"),
"other"
);
}
#[cfg(unix)]
fn write(path: &Path, body: &str) {
std::fs::write(path, body).expect("the fixture file writes");
}
#[test]
fn a_sealed_point_hides_everything_but_restores_with_the_key() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::sensitive(dir.path().to_path_buf(), key());
recovery
.write_once(b"secret content", Some("12:4"), at(100))
.expect("the write lands");
let points = recovery.points().expect("the points list");
assert_eq!(points.len(), 1);
assert!(points[0].sealed);
assert_eq!(points[0].cursor.as_deref(), Some("12:4"));
let on_disk = std::fs::read(&points[0].content).expect("the sealed bytes");
let text = String::from_utf8_lossy(&on_disk);
assert!(!text.contains("secret"), "no plaintext on disk");
assert!(!text.contains("12:4"), "no plaintext cursor on disk");
let dest = dir.path().join("restored.md");
recovery
.restore(&points[0], &dest)
.expect("the restore lands");
assert_eq!(
std::fs::read_to_string(&dest).expect("the restore reads"),
"secret content"
);
assert!(restore_copy(&points[0], &dir.path().join("wrong.md")).is_err());
}
#[test]
fn a_sealed_point_needs_its_key_to_restore() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::sensitive(dir.path().to_path_buf(), key());
recovery
.write_once(b"secret", None, at(100))
.expect("the write lands");
let points = recovery.points().expect("the points list");
let keyless = Recovery::new(dir.path().to_path_buf());
let dest = dir.path().join("restored.md");
assert!(keyless.restore(&points[0], &dest).is_err());
assert!(!dest.try_exists().expect("no unprotected copy"));
}
#[test]
fn a_discard_drops_points_and_records_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
recovery
.write_once(b"0", None, at(100))
.expect("the write lands");
recovery.discard().expect("the discard lands");
assert!(recovery.discarded().expect("the marker"));
assert!(recovery.points().expect("the points list").is_empty());
assert_eq!(recovery.last_ok(), None);
recovery
.write_once(b"1", None, at(200))
.expect("the write lands");
assert!(!recovery.discarded().expect("the marker clears"));
}
#[test]
fn the_lock_tells_crash_from_discard() {
let dir = tempfile::tempdir().expect("a temp dir");
let lock = Crash::open(dir.path(), 4242).expect("the lock opens");
assert_eq!(
Crash::check(dir.path(), |pid| pid == 4242).expect("the check"),
Marker::Clean
);
assert_eq!(
Crash::check(dir.path(), |pid| pid != 4242).expect("the check"),
Marker::Crashed
);
lock.discard().expect("the discard lands");
assert_eq!(
Crash::check(dir.path(), |_| false).expect("the check"),
Marker::Discarded
);
let lock = Crash::open(dir.path(), 4242).expect("the lock reopens");
lock.close().expect("the clean close");
assert_eq!(
Crash::check(dir.path(), |_| false).expect("the check"),
Marker::Clean
);
}
#[test]
fn the_scheduler_writes_every_interval_and_stops() {
use std::sync::atomic::{AtomicU64, Ordering};
let dir = tempfile::tempdir().expect("a temp dir");
let ticks = std::sync::Arc::new(AtomicU64::new(0));
let snapshot = {
let ticks = std::sync::Arc::clone(&ticks);
move || {
let n = ticks.fetch_add(1, Ordering::Relaxed);
(format!("tick {n}").into_bytes(), None)
}
};
let task = crate::RecoveryTask::start(
Recovery::new(dir.path().to_path_buf()),
Duration::from_millis(10),
snapshot,
);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while ticks.load(Ordering::Relaxed) < 3 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(task.last_ok().is_some(), "the scheduler wrote a point");
task.stop();
let points = Recovery::new(dir.path().to_path_buf())
.points()
.expect("the points list");
assert!(points.len() >= 2, "several ticks landed: {}", points.len());
let content = std::fs::read_to_string(&points[0].content).expect("the newest reads");
assert!(content.starts_with("tick "));
}
#[test]
fn a_dropped_task_never_writes_after_a_discard() {
let dir = tempfile::tempdir().expect("a temp dir");
let slow = || {
std::thread::sleep(Duration::from_millis(100));
(b"tick".to_vec(), None)
};
let task = crate::RecoveryTask::start(
Recovery::new(dir.path().to_path_buf()),
Duration::from_millis(10),
slow,
);
std::thread::sleep(Duration::from_millis(150));
drop(task);
let mut recovery = Recovery::new(dir.path().to_path_buf());
recovery.discard().expect("the discard lands");
assert!(recovery.discarded().expect("the marker stays"));
assert_eq!(recovery.points().expect("the points list").len(), 0);
std::thread::sleep(Duration::from_millis(150));
assert!(recovery.discarded().expect("the marker survives"));
assert_eq!(recovery.points().expect("the points list").len(), 0);
}
#[test]
fn last_ok_reads_free_during_a_slow_write() {
let dir = tempfile::tempdir().expect("a temp dir");
let slow = || {
std::thread::sleep(Duration::from_millis(200));
(b"tick".to_vec(), None)
};
let task = crate::RecoveryTask::start(
Recovery::new(dir.path().to_path_buf()),
Duration::from_millis(10),
slow,
);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while task.last_ok().is_none() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(task.last_ok().is_some(), "the first point landed");
std::thread::sleep(Duration::from_millis(30));
let began = std::time::Instant::now();
let read = task.last_ok();
let took = began.elapsed();
assert!(read.is_some());
assert!(
took < Duration::from_millis(150),
"the read never waits for disk: {took:?}"
);
task.stop();
}
#[test]
fn the_task_status_starts_from_the_recovery() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut recovery = Recovery::new(dir.path().to_path_buf());
recovery
.write_once(b"0", None, at(100))
.expect("the write lands");
let task = crate::RecoveryTask::start(recovery, Duration::from_secs(60), || {
(b"tick".to_vec(), None)
});
assert_eq!(task.last_ok(), Some(at(100)));
task.stop();
}
+95
View File
@@ -0,0 +1,95 @@
//! Renames that never replace a file (MARK-01).
use std::{io, path::Path};
/// Renames `from` to `to`; an existing `to` fails, atomically.
#[cfg(target_os = "macos")]
pub fn rename_new(from: &Path, to: &Path) -> io::Result<()> {
let (from, to) = (c_path(from)?, c_path(to)?);
// SAFETY: both are NUL-terminated paths that outlive the call.
let done = unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_EXCL) };
if done == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(target_os = "linux")]
pub fn rename_new(from: &Path, to: &Path) -> io::Result<()> {
let (from, to) = (c_path(from)?, c_path(to)?);
// SAFETY: both are NUL-terminated paths that outlive the call.
let done = unsafe {
libc::renameat2(
libc::AT_FDCWD,
from.as_ptr(),
libc::AT_FDCWD,
to.as_ptr(),
libc::RENAME_NOREPLACE,
)
};
if done == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(windows)]
pub fn rename_new(from: &Path, to: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt as _;
let wide = |path: &Path| {
path.as_os_str()
.encode_wide()
.chain([0])
.collect::<Vec<u16>>()
};
let (from, to) = (wide(from), wide(to));
// SAFETY: both are NUL-terminated wide paths; no flag replaces.
let done = unsafe {
windows_sys::Win32::Storage::FileSystem::MoveFileExW(from.as_ptr(), to.as_ptr(), 0)
};
if done != 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
pub fn rename_new(_: &Path, _: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"renaming is not built for this platform",
))
}
#[cfg(unix)]
fn c_path(path: &Path) -> io::Result<std::ffi::CString> {
use std::os::unix::ffi::OsStrExt as _;
std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "a path holds NUL"))
}
#[cfg(test)]
mod tests {
use super::rename_new;
#[test]
fn a_rename_moves_and_never_replaces() {
let dir = tempfile::tempdir().expect("a temp dir");
let (a, b, c) = (
dir.path().join("a.md"),
dir.path().join("b.md"),
dir.path().join("c.md"),
);
std::fs::write(&a, "a").expect("writes");
std::fs::write(&b, "b").expect("writes");
let error = rename_new(&a, &b).expect_err("b exists");
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read_to_string(&b).expect("reads"), "b");
rename_new(&a, &c).expect("renames");
assert!(!a.exists());
assert_eq!(std::fs::read_to_string(&c).expect("reads"), "a");
}
}
+466
View File
@@ -0,0 +1,466 @@
use std::{
io::{self, Write},
path::{Path, PathBuf},
};
use crate::{Fingerprint, SaveState, Session};
#[cfg(test)]
mod tests;
#[cfg(all(test, target_os = "macos"))]
mod full;
/// The largest name the filesystem takes.
const NAME_MAX: usize = 255;
/// `error` with the path in its message.
fn with_path(path: &Path, error: io::Error) -> io::Error {
io::Error::new(error.kind(), format!("{}: {error}", path.display()))
}
/// The directory a path lives in (bare names: `.`).
fn parent_dir(path: &Path) -> &Path {
match path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => Path::new("."),
}
}
/// `stem` capped so `suffix` survives the name limit (FILE-09).
fn with_suffix(stem: &str, suffix: &str) -> String {
take_bytes(stem, NAME_MAX.saturating_sub(suffix.len())) + suffix
}
/// `text` taken head-first within `max` bytes, on char edges.
fn take_bytes(text: &str, max: usize) -> String {
let mut out = String::new();
let mut used = 0;
for ch in text.chars() {
let len = ch.len_utf8();
if used + len > max {
break;
}
out.push(ch);
used += len;
}
out
}
/// A collision name: the counter always survives.
fn numbered_name(stem: &str, counter: u32, ext: &str) -> String {
let middle = format!("-{counter}");
let ext = take_bytes(ext, 32);
let dot = if ext.is_empty() { "" } else { "." };
let room = NAME_MAX.saturating_sub(middle.len() + dot.len() + ext.len());
format!("{}{middle}{dot}{ext}", take_bytes(stem, room))
}
/// Writes `bytes` beside `path`, checks the write, then replaces (FILE-04).
pub fn save_atomic(path: &Path, bytes: &[u8], revision: u64) -> io::Result<Fingerprint> {
let target = resolved(path)?;
let mut staging =
tempfile::NamedTempFile::new_in(parent_dir(&target)).map_err(|e| with_path(path, e))?;
staging.write_all(bytes).map_err(|e| with_path(path, e))?;
staging
.as_file()
.sync_all()
.map_err(|e| with_path(path, e))?;
if let Ok(meta) = std::fs::metadata(&target) {
staging
.as_file()
.set_permissions(meta.permissions())
.map_err(|e| with_path(path, e))?;
copy_acl(&target, staging.path()).map_err(|e| with_path(path, e))?;
}
staging
.persist(&target)
.map_err(|error| with_path(path, error.error))?;
log::info!("files: saved revision {revision} to {}", path.display());
Fingerprint::of(&target)
}
/// The access-control entries of an existing target (macOS).
#[cfg(target_os = "macos")]
fn copy_acl(from: &Path, to: &Path) -> io::Result<()> {
use std::os::unix::ffi::OsStrExt;
const COPYFILE_ACL: u32 = 1 << 0;
let cstring = |path: &Path| {
std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "the path holds a NUL"))
};
let (from, to) = (cstring(from)?, cstring(to)?);
if unsafe {
libc::copyfile(
from.as_ptr(),
to.as_ptr(),
std::ptr::null_mut(),
COPYFILE_ACL,
)
} != 0
{
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn copy_acl(_: &Path, _: &Path) -> io::Result<()> {
Ok(())
}
/// Non-atomic targets keep the previous file aside (§6.1).
pub fn save_with_backup(
path: &Path,
bytes: &[u8],
revision: u64,
) -> io::Result<(Fingerprint, Option<PathBuf>)> {
let backup = if path.try_exists().map_err(|e| with_path(path, e))? {
let (name, mut file) = fresh_backup(path)?;
let mut source = std::fs::File::open(path).map_err(|e| with_path(path, e))?;
io::copy(&mut source, &mut file).map_err(|e| with_path(&name, e))?;
file.sync_all().map_err(|e| with_path(&name, e))?;
log::info!("files: the previous file stays at {}", name.display());
Some(name)
} else {
None
};
let written = save_atomic(path, bytes, revision)?;
Ok((written, backup))
}
/// An exclusive fresh backup file with the source's access limits.
fn fresh_backup(path: &Path) -> io::Result<(PathBuf, std::fs::File)> {
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no file name", path.display()),
)
})?;
for n in 0u32.. {
let suffix = if n == 0 {
".bak".to_string()
} else {
format!("-{n}.bak")
};
let candidate = parent_dir(path).join(with_suffix(&name.to_string_lossy(), &suffix));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(file) => {
let meta = std::fs::metadata(path).map_err(|e| with_path(path, e))?;
file.set_permissions(meta.permissions())
.map_err(|e| with_path(&candidate, e))?;
copy_acl(path, &candidate).map_err(|e| with_path(&candidate, e))?;
return Ok((candidate, file));
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(with_path(&candidate, error)),
}
}
unreachable!("u32 backup names never run out")
}
/// An export never overwrites the session's file (FILE-06).
pub fn export(
path: &Path,
session: Option<&Path>,
bytes: &[u8],
revision: u64,
) -> io::Result<Fingerprint> {
if let Some(open) = session
&& same_file(open, path)?
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("export would overwrite the open file {}", path.display()),
));
}
log::info!("files: exported revision {revision} to {}", path.display());
save_atomic(path, bytes, revision)
}
/// The canonical form of a possibly missing path.
fn resolved(path: &Path) -> io::Result<PathBuf> {
match std::fs::canonicalize(path) {
Ok(real) => Ok(real),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no file name", path.display()),
)
})?;
Ok(std::fs::canonicalize(parent_dir(path))?.join(name))
}
Err(error) => Err(error),
}
}
/// Whether two paths name one file, links resolved.
pub fn same_file(a: &Path, b: &Path) -> io::Result<bool> {
if resolved(a)? == resolved(b)? {
return Ok(true);
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) {
return Ok((ma.dev(), ma.ino()) == (mb.dev(), mb.ino()));
}
}
Ok(false)
}
/// Copies an attachment in; the source is never touched (FILE-07).
pub fn import_attachment(source: &Path, dir: &Path) -> io::Result<PathBuf> {
std::fs::create_dir_all(dir).map_err(|e| with_path(dir, e))?;
let name = source.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no file name", source.display()),
)
})?;
let mut target = dir.join(name);
let stem = Path::new(name)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let ext = Path::new(name)
.extension()
.map(|ext| ext.to_string_lossy().into_owned())
.unwrap_or_default();
let mut from = std::fs::File::open(source).map_err(|e| with_path(source, e))?;
let mut counter = 1;
// Claims the name; another writer's file is never replaced.
let mut to = loop {
match std::fs::File::create_new(&target) {
Ok(file) => break file,
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
target = dir.join(numbered_name(&stem, counter, &ext));
counter += 1;
}
Err(e) => return Err(with_path(&target, e)),
}
};
let copied = io::copy(&mut from, &mut to).and_then(|_| to.flush());
if let Err(e) = copied {
// A half copy is not left behind.
std::fs::remove_file(&target).ok();
return Err(with_path(&target, e));
}
log::info!(
"files: imported {} to {}",
source.display(),
target.display()
);
Ok(target)
}
/// Why a save did not land (FILE-05).
#[derive(Debug)]
pub enum SaveError {
Conflict,
Refused(SaveState),
Io(io::Error),
}
/// What a completed save left behind.
pub struct Saved {
pub path: PathBuf,
pub written: Fingerprint,
/// Some on non-atomic volumes; the previous file lives there.
pub backup: Option<PathBuf>,
}
/// The whole save flow: conflict check, ticket, write, confirm (FILE-04/05).
pub fn save(session: &mut Session, bytes: &[u8]) -> Result<Saved, SaveError> {
save_with_probe(session, bytes, atomic_capable)
}
/// The save flow with the volume probe handed in (§6.1).
pub fn save_with_probe(
session: &mut Session,
bytes: &[u8],
probe: impl FnOnce(&Path) -> io::Result<bool>,
) -> Result<Saved, SaveError> {
let Some(path) = session.path().map(Path::to_path_buf) else {
return Err(SaveError::Io(io::Error::new(
io::ErrorKind::NotFound,
"no path to save",
)));
};
let target = resolved(&path).map_err(SaveError::Io)?;
let backup = !probe(&target).map_err(SaveError::Io)?;
if backup {
log::warn!("files: {} sits on a non-atomic volume", target.display());
}
save_mode(session, &target, bytes, backup)
}
/// The save flow with the volume's capability decided.
fn save_mode(
session: &mut Session,
path: &Path,
bytes: &[u8],
backup: bool,
) -> Result<Saved, SaveError> {
if session.external_changed().map_err(SaveError::Io)? {
session.external_modified();
log::warn!("files: save to {} meets an external change", path.display());
return Err(SaveError::Conflict);
}
write_ticketed(session, path, bytes, backup, false)
}
/// Save As to a chosen path (FILE-04, FILE-05).
pub fn save_as(session: &mut Session, path: &Path, bytes: &[u8]) -> Result<Saved, SaveError> {
let target = resolved(path).map_err(SaveError::Io)?;
let backup = !atomic_capable(&target).map_err(SaveError::Io)?;
let own = match session.path() {
Some(current) => same_file(current, &target).map_err(SaveError::Io)?,
None => false,
};
if own {
// The session's own file keeps its conflict check.
return save_mode(session, &target, bytes, backup);
}
log::info!("files: save as {}", target.display());
// A new file keeps the local version in a conflict.
let keep_local = session.state() == SaveState::ExternalModified;
write_ticketed(session, &target, bytes, backup, keep_local)
}
/// Keep-local in a conflict: overwrite the changed file (FILE-05).
pub fn save_overwrite(session: &mut Session, bytes: &[u8]) -> Result<Saved, SaveError> {
let Some(path) = session.path().map(Path::to_path_buf) else {
return Err(SaveError::Io(io::Error::new(
io::ErrorKind::NotFound,
"no path to save",
)));
};
let target = resolved(&path).map_err(SaveError::Io)?;
let backup = !atomic_capable(&target).map_err(SaveError::Io)?;
write_ticketed(session, &target, bytes, backup, true)
}
/// Ticket, write, confirm: the shared tail of every save.
fn write_ticketed(
session: &mut Session,
path: &Path,
bytes: &[u8],
backup: bool,
keep_local: bool,
) -> Result<Saved, SaveError> {
let ticket = if keep_local {
session.begin_save_overwrite()
} else {
session.begin_save()
}
.map_err(SaveError::Refused)?;
let revision = session.revision();
let outcome = if backup {
save_with_backup(path, bytes, revision)
} else {
save_atomic(path, bytes, revision).map(|written| (written, None))
};
let (written, backup) = match outcome {
Ok(done) => done,
Err(error) => {
if session.fail_save(ticket).is_err() {
log::error!("files: the failed save left an unknown ticket");
}
return Err(SaveError::Io(error));
}
};
session
.finish_save(ticket, path.to_path_buf(), written)
.map_err(|_| {
SaveError::Io(io::Error::new(
io::ErrorKind::InvalidData,
"stale save ticket",
))
})?;
Ok(Saved {
path: path.to_path_buf(),
written,
backup,
})
}
/// Whether the target's volume replaces files atomically (§6.1).
pub fn atomic_capable(path: &Path) -> io::Result<bool> {
capable(parent_dir(path))
}
#[cfg(unix)]
fn statfs(dir: &Path) -> io::Result<libc::statfs> {
use std::os::unix::ffi::OsStrExt;
let path = std::ffi::CString::new(dir.as_os_str().as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "the path holds a NUL"))?;
let mut stats: libc::statfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statfs(path.as_ptr(), &mut stats) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(stats)
}
#[cfg(target_os = "macos")]
fn capable(dir: &Path) -> io::Result<bool> {
const MNT_LOCAL: i32 = 0x0000_1000;
Ok(statfs(dir)?.f_flags as i32 & MNT_LOCAL != 0)
}
#[cfg(target_os = "linux")]
fn capable(dir: &Path) -> io::Result<bool> {
const NFS: i64 = 0x6969;
const SMB: i64 = 0x517B;
const CIFS: i64 = 0xFF534D42;
Ok(!matches!(statfs(dir)?.f_type, NFS | SMB | CIFS))
}
#[cfg(windows)]
fn capable(dir: &Path) -> io::Result<bool> {
use std::path::Component;
use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW;
const DRIVE_REMOTE: u32 = 4;
let root = match dir.components().next() {
Some(Component::Prefix(prefix)) => prefix.as_os_str().to_string_lossy().into_owned() + "\\",
_ => "\\".to_string(),
};
let wide: Vec<u16> = root.encode_utf16().chain(Some(0)).collect();
Ok(unsafe { GetDriveTypeW(wide.as_ptr()) } != DRIVE_REMOTE)
}
#[cfg(not(any(unix, windows)))]
fn capable(_: &Path) -> io::Result<bool> {
Ok(true)
}
/// Takes every target or none; a file there already refuses.
pub fn claim_all(paths: &[PathBuf]) -> io::Result<Vec<std::fs::File>> {
let mut taken = Vec::new();
for path in paths {
// A new file only: never a link someone left.
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(file) => taken.push(file),
Err(error) => {
paths[..taken.len()]
.iter()
.try_for_each(std::fs::remove_file)?;
let name = path.file_name().map_or_else(
|| path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
);
return Err(io::Error::new(error.kind(), name));
}
}
}
Ok(taken)
}
+67
View File
@@ -0,0 +1,67 @@
//! A full disk refuses the save; the original stays (§20.2).
use std::{path::Path, process::Command};
use super::save_atomic;
/// A 4 MB disk image, attached; detached when dropped.
struct Volume(tempfile::TempDir);
impl Volume {
fn new() -> Option<Self> {
let dir = tempfile::tempdir().expect("a temp dir");
let image = dir.path().join("full.dmg");
let made = Command::new("hdiutil")
.args(["create", "-quiet", "-size", "4m", "-fs", "HFS+", "-volname", "elyfull"])
.arg(&image)
.status();
if !made.is_ok_and(|status| status.success()) {
return None;
}
let mount = dir.path().join("mount");
std::fs::create_dir(&mount).expect("the mount point");
let attached = Command::new("hdiutil")
.args(["attach", "-quiet", "-nobrowse", "-mountpoint"])
.arg(&mount)
.arg(&image)
.status()
.expect("hdiutil runs");
assert!(attached.success(), "the image attaches");
Some(Volume(dir))
}
fn root(&self) -> std::path::PathBuf {
self.0.path().join("mount")
}
}
impl Drop for Volume {
fn drop(&mut self) {
let detached = Command::new("hdiutil").args(["detach", "-quiet", "-force"]).arg(self.root()).status();
if !detached.is_ok_and(|status| status.success()) {
eprintln!("the test image stays attached at {}", self.root().display());
}
}
}
/// Writes until the volume refuses, then frees nothing.
fn fill(root: &Path) {
let block = vec![7u8; 64 * 1024];
let mut filler = std::fs::File::create(root.join("filler")).expect("the filler");
while std::io::Write::write_all(&mut filler, &block).is_ok() {}
}
#[test]
fn a_full_disk_refuses_the_save_and_keeps_the_original() {
let Some(volume) = Volume::new() else {
return eprintln!("SKIP: no hdiutil");
};
let target = volume.root().join("keep.md");
std::fs::write(&target, "original").expect("the original");
fill(&volume.root());
let failed = save_atomic(&target, &vec![b'x'; 256 * 1024], 1).expect_err("no room");
assert_eq!(failed.kind(), std::io::ErrorKind::StorageFull, "{failed}");
assert_eq!(std::fs::read_to_string(&target).expect("reads"), "original");
let left: Vec<_> = std::fs::read_dir(volume.root()).expect("lists").flatten().map(|entry| entry.file_name()).collect();
assert_eq!(left.len(), 2, "no half-written file stays: {left:?}");
}
+500
View File
@@ -0,0 +1,500 @@
use super::*;
use crate::Session;
fn write(path: &Path, body: &str) {
std::fs::write(path, body).expect("the fixture file writes");
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("the file reads")
}
#[test]
fn an_atomic_save_lands_and_fingerprints() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("doc.md");
let written = save_atomic(&target, b"body", 3).expect("the save lands");
assert_eq!(read(&target), "body");
assert_eq!(written, Fingerprint::of(&target).expect("the fingerprint"));
assert_eq!(written.len, 4);
}
#[test]
fn a_bare_relative_name_saves_and_probes() {
let dir = tempfile::tempdir().expect("a temp dir");
let here = std::env::current_dir().expect("the cwd");
std::env::set_current_dir(dir.path()).expect("into the temp dir");
let result = save_atomic(Path::new("report.md"), b"0", 0)
.map(|_| atomic_capable(Path::new("report.md")));
std::env::set_current_dir(here).expect("back to the cwd");
assert!(result.expect("the save lands").expect("the probe"));
assert_eq!(read(&dir.path().join("report.md")), "0");
}
#[cfg(unix)]
#[test]
fn a_failed_write_keeps_the_original() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("keep.md");
write(&target, "original");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555))
.expect("the lockdown");
let failed = save_atomic(&target, b"new", 1);
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755))
.expect("the restore");
assert!(failed.is_err());
assert_eq!(read(&target), "original");
}
#[cfg(unix)]
#[test]
fn a_save_keeps_the_original_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("shared.md");
write(&target, "0");
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640))
.expect("the group read");
save_atomic(&target, b"1", 1).expect("the save lands");
let mode = std::fs::metadata(&target)
.expect("the metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o640);
}
#[test]
fn paths_keep_spaces_cjk_and_case() {
let dir = tempfile::tempdir().expect("a temp dir");
let name = "报告 Final 版本 — Copy.TXT";
let target = dir.path().join(name);
save_atomic(&target, b"0", 0).expect("the save lands");
let listed: Vec<String> = std::fs::read_dir(dir.path())
.expect("the dir lists")
.map(|entry| {
entry
.expect("an entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(listed, vec![name.to_string()]);
}
#[test]
fn a_backup_save_keeps_the_previous_file() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("net.md");
write(&target, "previous");
let (_, backup) = save_with_backup(&target, b"new", 1).expect("the save lands");
let backup = backup.expect("a backup exists");
assert_eq!(read(&target), "new");
assert_eq!(read(&backup), "previous");
assert_eq!(backup, dir.path().join("net.md.bak"));
}
#[cfg(unix)]
#[test]
fn a_backup_never_truncates_or_follows_links() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("report.md");
write(&target, "previous");
let taken = dir.path().join("report.md.bak");
std::fs::hard_link(&target, &taken).expect("the hard link");
let other = dir.path().join("other.md");
write(&other, "other");
let linked = dir.path().join("report.md-1.bak");
std::os::unix::fs::symlink(&other, &linked).expect("the symlink");
let (_, backup) = save_with_backup(&target, b"new", 1).expect("the save lands");
let backup = backup.expect("a backup exists");
assert!(
!matches!(backup.file_name(), Some(n) if n == "report.md.bak" || n == "report.md-1.bak")
);
assert_eq!(read(&target), "new");
assert_eq!(read(&other), "other");
assert_eq!(read(&backup), "previous");
}
#[test]
fn a_backup_name_fits_long_file_names() {
let dir = tempfile::tempdir().expect("a temp dir");
let name = format!("{}.md", "x".repeat(250));
let target = dir.path().join(name);
write(&target, "previous");
let (_, backup) = save_with_backup(&target, b"new", 1).expect("the save lands");
let backup = backup.expect("a backup exists");
assert_eq!(read(&backup), "previous");
let len = backup.file_name().expect("a name").len();
assert!(len <= 255, "the backup name fits: {len}");
}
#[test]
fn an_export_is_independent_and_never_overwrites() {
let dir = tempfile::tempdir().expect("a temp dir");
let editing = dir.path().join("report.md");
write(&editing, "session");
let out = dir.path().join("out.pdf");
save_atomic(&out, b"%PDF", 2).expect("the export lands");
assert_eq!(read(&editing), "session");
let refused = export(&editing, Some(editing.as_path()), b"%PDF", 2);
assert!(refused.is_err());
assert_eq!(read(&editing), "session");
assert!(export(&out, Some(editing.as_path()), b"%PDF2", 2).is_ok());
}
#[cfg(unix)]
#[test]
fn an_export_refuses_path_aliases_of_the_session() {
let dir = tempfile::tempdir().expect("a temp dir");
let editing = dir.path().join("report.md");
write(&editing, "session");
let child = dir.path().join("child");
std::fs::create_dir(&child).expect("the child dir");
let dotted = child.join("..").join("report.md");
assert!(export(&dotted, Some(editing.as_path()), b"%PDF", 2).is_err());
let linked = dir.path().join("linked");
std::os::unix::fs::symlink(&editing, &linked).expect("the symlink");
assert!(export(&linked, Some(editing.as_path()), b"%PDF", 2).is_err());
let alias = dir.path().join("alias.md");
std::fs::hard_link(&editing, &alias).expect("the hard link");
assert!(export(&alias, Some(editing.as_path()), b"%PDF", 2).is_err());
assert_eq!(read(&editing), "session");
}
#[test]
fn an_import_copies_and_never_deletes() {
let dir = tempfile::tempdir().expect("a temp dir");
let source = dir.path().join("photo.png");
write(&source, "png-bytes");
let attachments = dir.path().join("attachments");
let first = import_attachment(&source, &attachments).expect("the import lands");
assert!(source.try_exists().expect("the source check"));
assert_eq!(read(&source), "png-bytes");
assert_eq!(read(&first), "png-bytes");
let second = import_attachment(&source, &attachments).expect("the second import");
assert_ne!(first, second);
assert_eq!(read(&first), "png-bytes");
assert_eq!(read(&second), "png-bytes");
}
#[test]
fn a_numbered_import_name_fits_long_file_names() {
let dir = tempfile::tempdir().expect("a temp dir");
let source = dir.path().join(format!("{}.png", "x".repeat(251)));
write(&source, "png");
let attachments = dir.path().join("attachments");
import_attachment(&source, &attachments).expect("the first import");
let second = import_attachment(&source, &attachments).expect("the second import");
let len = second.file_name().expect("a name").len();
assert!(len <= 255, "the numbered name fits: {len}");
assert_eq!(read(&second), "png");
}
#[test]
fn the_save_flow_checks_external_changes_first() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("flow.md");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
write(&target, "external");
let outcome = save(&mut session, b"local");
assert!(matches!(outcome, Err(SaveError::Conflict)));
assert_eq!(read(&target), "external");
assert_eq!(session.revision(), 1);
assert_eq!(session.state(), crate::SaveState::ExternalModified);
let ticket = session.begin_save_overwrite().expect("keep-local starts");
write(&target, "local");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(ticket, target, written)
.expect("the overwrite lands");
assert_eq!(session.state(), crate::SaveState::Saved);
}
#[test]
fn the_save_flow_writes_and_confirms() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("ok.md");
write(&target, "0");
let mut session = Session::load(target).expect("the file loads");
session.edit();
let saved = save(&mut session, b"edited").expect("the save lands");
assert_eq!(read(&saved.path), "edited");
assert_eq!(saved.backup, None);
assert_eq!(session.state(), crate::SaveState::Saved);
}
#[test]
fn external_change_detection_follows_the_fingerprint() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("x.md");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
assert!(!session.external_changed().expect("the check"));
write(&target, "longer content");
assert!(session.external_changed().expect("the check"));
session.reload().expect("the file reloads");
assert!(!session.external_changed().expect("the check"));
let fresh = Session::new_file();
assert!(!fresh.external_changed().expect("the check"));
}
#[test]
fn local_volumes_are_atomic_capable() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("local.md");
assert!(atomic_capable(&target).expect("the probe"));
}
#[test]
fn the_save_flow_keeps_a_backup_on_a_limited_volume() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("net.md");
write(&target, "previous");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let saved = save_mode(&mut session, &target, b"new", true).expect("the save lands");
let backup = saved.backup.expect("a backup on a limited volume");
assert_eq!(read(&target), "new");
assert_eq!(read(&backup), "previous");
assert_eq!(session.state(), crate::SaveState::Saved);
}
#[cfg(target_os = "macos")]
#[test]
fn a_save_keeps_the_original_acl() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("acl.md");
write(&target, "0");
let chmod = std::process::Command::new("chmod")
.args(["+a", "nobody deny read", target.to_str().expect("utf8")])
.status()
.expect("chmod runs");
assert!(chmod.success());
save_atomic(&target, b"1", 1).expect("the save lands");
let listed = std::process::Command::new("/bin/ls")
.args(["-le", target.to_str().expect("utf8")])
.output()
.expect("ls runs");
let text = String::from_utf8_lossy(&listed.stdout).into_owned();
assert!(text.contains("deny read"), "the ACL survived: {text}");
}
#[cfg(unix)]
#[test]
fn a_backup_keeps_the_source_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("private.md");
write(&target, "secret");
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600))
.expect("the private mode");
let (_, backup) = save_with_backup(&target, b"new", 1).expect("the save lands");
let mode = std::fs::metadata(backup.expect("a backup"))
.expect("the metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[cfg(unix)]
#[test]
fn a_save_through_a_symlink_keeps_the_link() {
let dir = tempfile::tempdir().expect("a temp dir");
let real = dir.path().join("report.md");
let alias = dir.path().join("alias.md");
write(&real, "0");
std::os::unix::fs::symlink(&real, &alias).expect("the symlink");
let mut session = Session::load(alias.clone()).expect("the file loads");
session.edit();
save(&mut session, b"edited").expect("the save lands");
assert_eq!(read(&real), "edited");
let kind = std::fs::symlink_metadata(&alias)
.expect("the metadata")
.file_type();
assert!(kind.is_symlink(), "the link survives the save");
assert_eq!(session.state(), crate::SaveState::Saved);
}
#[test]
fn backups_never_run_out_of_names() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("report.md");
write(&target, "0");
for n in 0..105u32 {
save_with_backup(&target, format!("v{n}").as_bytes(), n as u64).expect("the save lands");
}
let names = std::fs::read_dir(dir.path())
.expect("the dir lists")
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".bak"))
.count();
assert_eq!(names, 105);
}
#[test]
fn a_long_extension_import_fits_twice() {
let dir = tempfile::tempdir().expect("a temp dir");
let source = dir.path().join(format!("a.{}", "x".repeat(253)));
write(&source, "png");
let attachments = dir.path().join("attachments");
import_attachment(&source, &attachments).expect("the first import");
let second = import_attachment(&source, &attachments).expect("the second import");
let len = second.file_name().expect("a name").len();
assert!(len <= 255, "the numbered name fits: {len}");
assert_eq!(read(&second), "png");
}
#[cfg(unix)]
#[test]
fn a_save_through_a_cross_dir_link_probes_the_real_volume() {
let dir = tempfile::tempdir().expect("a temp dir");
let home = dir.path().join("home");
let cloud = dir.path().join("cloud");
std::fs::create_dir(&home).expect("the home dir");
std::fs::create_dir(&cloud).expect("the cloud dir");
let real = cloud.join("report.md");
let alias = home.join("alias.md");
write(&real, "previous");
std::os::unix::fs::symlink(&real, &alias).expect("the symlink");
let mut session = Session::load(alias).expect("the file loads");
session.edit();
let limited = resolved(&real).expect("the resolved target");
let probe = move |path: &Path| Ok(path != limited);
let saved = save_with_probe(&mut session, b"new", probe).expect("the save lands");
let backup = saved.backup.expect("a backup on the limited volume");
let real_dir = resolved(&cloud).expect("the resolved cloud dir");
assert_eq!(backup.parent(), Some(real_dir.as_path()));
assert_eq!(read(&backup), "previous");
assert_eq!(read(&real), "new");
assert_eq!(session.state(), crate::SaveState::Saved);
}
#[test]
fn a_full_length_name_gets_a_backup() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("x".repeat(255));
write(&target, "previous");
let (_, backup) = save_with_backup(&target, b"new", 1).expect("the save lands");
let backup = backup.expect("a backup exists");
let len = backup.file_name().expect("a name").len();
assert!(len <= 255, "the backup name fits: {len}");
assert_eq!(read(&backup), "previous");
}
#[test]
fn a_long_name_gets_distinct_backups_every_time() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join(format!("{}.md", "x".repeat(250)));
write(&target, "0");
let mut names = std::collections::HashSet::new();
for n in 0..12u32 {
let (_, backup) =
save_with_backup(&target, format!("v{n}").as_bytes(), n as u64).expect("the save");
let name = backup
.expect("a backup")
.file_name()
.expect("a name")
.to_owned();
assert!(name.len() <= 255, "the backup name fits");
assert!(names.insert(name), "every backup name differs");
}
}
#[test]
fn a_full_length_attachment_imports_twice() {
let dir = tempfile::tempdir().expect("a temp dir");
let source = dir.path().join("x".repeat(255));
write(&source, "png");
let attachments = dir.path().join("attachments");
let first = import_attachment(&source, &attachments).expect("the first import");
let second = import_attachment(&source, &attachments).expect("the second import");
assert_ne!(first, second);
let len = second.file_name().expect("a name").len();
assert!(len <= 255, "the numbered name fits: {len}");
assert_eq!(read(&second), "png");
}
#[test]
fn save_as_gives_a_new_file_its_path_and_leaves_the_source() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut session = Session::new_file();
session.edit();
let first = dir.path().join("Untitled.docx");
let saved = save_as(&mut session, &first, b"one").expect("the first save");
assert_eq!(read(&first), "one");
assert_eq!(
session.path(),
Some(saved.path.as_path()),
"the session takes the path"
);
assert_eq!(session.state(), SaveState::Saved);
session.edit();
let second = dir.path().join("copy.odt");
save_as(&mut session, &second, b"two").expect("the second save");
assert_eq!(read(&first), "one", "the source stays");
assert_eq!(read(&second), "two");
assert!(
session
.path()
.is_some_and(|path| path.ends_with("copy.odt"))
);
session.edit();
save(&mut session, b"three").expect("a plain save follows the new path");
assert_eq!(read(&second), "three");
}
#[test]
fn save_as_onto_its_own_file_checks_for_outside_changes() {
let dir = tempfile::tempdir().expect("a temp dir");
let own = dir.path().join("own.docx");
let mut session = Session::new_file();
session.edit();
save_as(&mut session, &own, b"mine").expect("the first save");
session.edit();
std::thread::sleep(std::time::Duration::from_millis(20));
write(&own, "EXTERNAL REVISION");
let alias = dir.path().join("alias.docx");
#[cfg(unix)]
std::os::unix::fs::symlink(&own, &alias).expect("the alias");
#[cfg(not(unix))]
let alias = own.clone();
assert!(matches!(
save_as(&mut session, &alias, b"local"),
Err(SaveError::Conflict)
));
assert_eq!(read(&own), "EXTERNAL REVISION", "the outside change stays");
let copy = dir.path().join("local-copy.docx");
save_as(&mut session, &copy, b"local").expect("a new file keeps the local version");
assert_eq!(read(&copy), "local");
assert_eq!(read(&own), "EXTERNAL REVISION");
assert_eq!(
session.state(),
SaveState::Saved,
"the session moved to the copy"
);
}
#[test]
fn overwrite_resolves_a_conflict_with_the_local_version() {
let dir = tempfile::tempdir().expect("a temp dir");
let own = dir.path().join("own.odt");
let mut session = Session::new_file();
session.edit();
save_as(&mut session, &own, b"mine").expect("the first save");
session.edit();
std::thread::sleep(std::time::Duration::from_millis(20));
write(&own, "theirs");
assert!(matches!(
save(&mut session, b"local"),
Err(SaveError::Conflict)
));
save_overwrite(&mut session, b"local").expect("keep-local overwrites");
assert_eq!(read(&own), "local");
assert_eq!(session.state(), SaveState::Saved);
}
+392
View File
@@ -0,0 +1,392 @@
use std::{
fmt, io,
path::{Path, PathBuf},
time::SystemTime,
};
/// Length and mtime when last loaded or saved.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fingerprint {
pub len: u64,
pub modified: SystemTime,
}
impl Fingerprint {
pub fn of(path: &Path) -> io::Result<Self> {
let meta = std::fs::metadata(path)?;
Ok(Self {
len: meta.len(),
modified: meta.modified()?,
})
}
}
/// The save states a session shows (FILE-02).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SaveState {
Loading,
LoadFailed,
Saved,
Dirty,
Saving,
ReadOnly,
ExternalModified,
SaveFailed,
}
impl fmt::Display for SaveState {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let name = match self {
SaveState::Loading => "loading",
SaveState::LoadFailed => "load-failed",
SaveState::Saved => "saved",
SaveState::Dirty => "dirty",
SaveState::Saving => "saving",
SaveState::ReadOnly => "read-only",
SaveState::ExternalModified => "external-modified",
SaveState::SaveFailed => "save-failed",
};
f.write_str(name)
}
}
/// A save in flight: its task, generation and revision (FILE-03).
#[derive(Clone, Copy, Debug)]
pub struct SaveTicket {
task: u64,
generation: u64,
revision: u64,
}
/// A ticket from before a reload; its content is obsolete.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct StaleTicket;
/// One open document and its session data (§6.1).
pub struct Session {
path: Option<PathBuf>,
fingerprint: Option<Fingerprint>,
generation: u64,
revision: u64,
saved_revision: u64,
next_task: u64,
state: SaveState,
external_unresolved: bool,
loading: bool,
loaded: bool,
saving: Option<SaveTicket>,
pub cursor: Option<String>,
pub zoom: f32,
recovery: Option<PathBuf>,
}
impl Session {
/// A brand-new document: no target path until first saved (FILE-01).
pub fn new_file() -> Self {
Self {
path: None,
fingerprint: None,
generation: 0,
revision: 0,
saved_revision: 0,
next_task: 0,
state: SaveState::Saved,
external_unresolved: false,
loading: false,
loaded: true,
saving: None,
cursor: None,
zoom: 1.0,
recovery: None,
}
}
/// A document the kernel still loads asynchronously.
pub fn loading(path: PathBuf) -> Self {
Self {
state: SaveState::Loading,
loading: true,
loaded: false,
..Self::new_file()
}
.with_path(path)
}
fn with_path(mut self, path: PathBuf) -> Self {
self.path = Some(path);
self
}
/// Loaded from disk; the fingerprint is what was read.
pub fn load(path: PathBuf) -> io::Result<Self> {
let fingerprint = Fingerprint::of(&path)?;
Ok(Self {
fingerprint: Some(fingerprint),
..Self::new_file()
}
.with_path(path))
}
/// A read-only view: it never saves.
pub fn read_only(path: PathBuf) -> io::Result<Self> {
let mut session = Self::load(path)?;
session.state = SaveState::ReadOnly;
Ok(session)
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
/// The file moved to `path`; its contents did not change.
pub fn renamed(&mut self, path: PathBuf) {
self.path = Some(path);
}
pub fn fingerprint(&self) -> Option<Fingerprint> {
self.fingerprint
}
/// Whether the file on disk differs from our fingerprint (FILE-05).
pub fn external_changed(&self) -> io::Result<bool> {
let (Some(path), Some(fingerprint)) = (self.path(), self.fingerprint()) else {
return Ok(false);
};
Ok(Fingerprint::of(path)? != fingerprint)
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn saved_revision(&self) -> u64 {
self.saved_revision
}
pub fn state(&self) -> SaveState {
self.state
}
pub fn recovery(&self) -> Option<&Path> {
self.recovery.as_deref()
}
pub fn set_recovery(&mut self, location: PathBuf) {
self.recovery = Some(location);
}
/// Confirms the actually loaded content; a conflict outlives it.
pub fn finish_load(&mut self, loaded: Fingerprint) -> Result<(), StaleTicket> {
if !self.loading {
log::warn!("session: a stale load callback is dropped");
return Err(StaleTicket);
}
self.fingerprint = Some(loaded);
self.loading = false;
self.loaded = true;
self.state = if self.external_unresolved {
SaveState::ExternalModified
} else {
SaveState::Saved
};
log::info!("session: loaded at {} bytes", loaded.len);
Ok(())
}
/// The load failed; nothing is editable yet.
pub fn fail_load(&mut self) -> Result<(), StaleTicket> {
if !self.loading {
log::warn!("session: a stale load failure is dropped");
return Err(StaleTicket);
}
let failed_path = self.path().unwrap_or(Path::new("")).display().to_string();
log::warn!("session: load failed for {failed_path}");
self.loading = false;
self.loaded = false;
self.state = SaveState::LoadFailed;
Ok(())
}
/// An edit bumps the revision; a conflict stays shown (FILE-02).
pub fn edit(&mut self) {
self.revision += 1;
if self.state == SaveState::Saved {
self.state = SaveState::Dirty;
}
}
/// Undo reached the saved content; other states stay (FILE-02).
pub fn reverted(&mut self) {
if self.state == SaveState::Dirty {
log::info!("session: back to the saved content");
self.state = SaveState::Saved;
}
}
/// Starts a save; read-only, loading and conflicted states refuse.
pub fn begin_save(&mut self) -> Result<SaveTicket, SaveState> {
self.ready_to_save(false)
}
/// The user chose keep-local in a conflict; overwrite anyway.
pub fn begin_save_overwrite(&mut self) -> Result<SaveTicket, SaveState> {
let ticket = self.ready_to_save(true)?;
log::info!("session: keep-local overwrites the external change");
self.external_unresolved = false;
Ok(ticket)
}
/// Shared guards: loading, saving, unloaded, read-only, conflict.
fn ready_to_save(&mut self, overwrite: bool) -> Result<SaveTicket, SaveState> {
if self.loading {
log::warn!("session: a load is still running");
return Err(SaveState::Loading);
}
if self.saving.is_some() {
log::warn!("session: a save is already running");
return Err(SaveState::Saving);
}
if !self.loaded {
log::warn!("session: nothing loaded yet to save");
return Err(SaveState::LoadFailed);
}
if self.state == SaveState::ReadOnly {
log::warn!("session: a read-only view cannot save");
return Err(self.state);
}
if !overwrite && self.external_unresolved {
log::warn!("session: an unresolved conflict refuses a plain save");
return Err(SaveState::ExternalModified);
}
Ok(self.ticket())
}
fn ticket(&mut self) -> SaveTicket {
self.state = SaveState::Saving;
let ticket = SaveTicket {
task: self.next_task,
generation: self.generation,
revision: self.revision,
};
self.next_task += 1;
self.saving = Some(ticket);
ticket
}
/// Confirms the written revision; a conflict outlives it (FILE-03).
pub fn finish_save(
&mut self,
ticket: SaveTicket,
path: PathBuf,
written: Fingerprint,
) -> Result<(), StaleTicket> {
let Some(running) = self.saving else {
log::warn!("session: no save is running for {}", path.display());
return Err(StaleTicket);
};
if running.task != ticket.task {
log::warn!(
"session: an unknown ticket for {} is dropped",
path.display()
);
return Err(StaleTicket);
}
self.saving = None;
if ticket.generation != self.generation {
log::warn!("session: a stale ticket for {} is dropped", path.display());
return Err(StaleTicket);
}
log::info!(
"session: saved revision {} to {}",
ticket.revision,
path.display()
);
self.path = Some(path);
self.fingerprint = Some(written);
self.saved_revision = ticket.revision;
self.state = if self.external_unresolved {
SaveState::ExternalModified
} else if self.saved_revision == self.revision {
SaveState::Saved
} else {
SaveState::Dirty
};
Ok(())
}
/// A failed save keeps the file and the unsaved content.
pub fn fail_save(&mut self, ticket: SaveTicket) -> Result<(), StaleTicket> {
if self.saving.map(|running| running.task) != Some(ticket.task) {
log::warn!("session: a failure for an unknown save is dropped");
return Err(StaleTicket);
}
self.saving = None;
if ticket.generation != self.generation {
log::warn!("session: a stale save failure is dropped");
return Err(StaleTicket);
}
log::warn!("session: save failed at revision {}", self.revision);
self.state = SaveState::SaveFailed;
Ok(())
}
/// A save that failed before any write (FILE-02).
pub fn save_failed(&mut self) {
if self.saving.is_none() && matches!(self.state, SaveState::Saved | SaveState::Dirty) {
log::warn!("session: a save failed before writing");
self.state = SaveState::SaveFailed;
}
}
/// The file changed on disk since our fingerprint.
pub fn external_modified(&mut self) {
if self.state == SaveState::ReadOnly {
return;
}
self.external_unresolved = true;
self.state = SaveState::ExternalModified;
}
/// Reloaded from disk; the access mode survives, tickets die.
pub fn reload(&mut self) -> io::Result<()> {
let Some(path) = self.path.clone() else {
return Err(io::Error::new(io::ErrorKind::NotFound, "no path to reload"));
};
self.reload_at(Fingerprint::of(&path)?)
}
/// The disk at `fingerprint` is seen; edits stay unsaved.
pub fn rebase(&mut self, fingerprint: Fingerprint) {
self.fingerprint = Some(fingerprint);
self.external_unresolved = false;
if self.state != SaveState::ReadOnly {
self.state = SaveState::Dirty;
}
log::info!("session: the disk version is seen; local edits stay");
}
/// Reloaded from bytes read at `fingerprint`, not disk now.
pub fn reload_at(&mut self, fingerprint: Fingerprint) -> io::Result<()> {
let Some(path) = self.path.clone() else {
return Err(io::Error::new(io::ErrorKind::NotFound, "no path to reload"));
};
self.fingerprint = Some(fingerprint);
self.generation += 1;
self.revision = 0;
self.saved_revision = 0;
self.external_unresolved = false;
self.loading = false;
self.loaded = true;
if self.state != SaveState::ReadOnly {
self.state = SaveState::Saved;
}
log::info!("session: reloaded {}", path.display());
Ok(())
}
}
mod registry;
pub use registry::{Open, Sessions};
#[cfg(test)]
mod tests;
+234
View File
@@ -0,0 +1,234 @@
use std::{
io,
path::{Path, PathBuf},
};
/// A file's device and index.
type FileId = (u64, u64);
/// The app's open sessions (§6.1).
#[derive(Default)]
pub struct Sessions {
/// Path, file identity when opened, session id.
open: Vec<(PathBuf, Option<FileId>, usize)>,
next: usize,
}
/// What opening a path did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Open {
/// A new session id.
Fresh(usize),
/// The path is already open; focus that session.
Focus(usize),
/// A new read-only view id beside the existing session.
View(usize),
}
/// The dedup key: canonical path plus file identity.
#[derive(Clone, Debug)]
struct Key {
canonical: PathBuf,
id: Option<(u64, u64)>,
}
#[cfg(unix)]
fn file_id(path: &Path) -> io::Result<Option<(u64, u64)>> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path)?;
Ok(Some((meta.dev(), meta.ino())))
}
#[cfg(windows)]
fn file_id(path: &Path) -> io::Result<Option<(u64, u64)>> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE,
FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileInformationByHandle, OPEN_EXISTING,
};
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
0,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null(),
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let ok = unsafe { GetFileInformationByHandle(handle, &mut info) };
unsafe { CloseHandle(handle) };
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(Some((
info.dwVolumeSerialNumber as u64,
((info.nFileIndexHigh as u64) << 32) | info.nFileIndexLow as u64,
)))
}
#[cfg(not(any(unix, windows)))]
fn file_id(_: &Path) -> io::Result<Option<(u64, u64)>> {
Ok(None)
}
impl Sessions {
/// The key of an existing file; missing fails loud.
fn key(path: &Path) -> io::Result<Key> {
Ok(Key {
canonical: std::fs::canonicalize(path)?,
id: file_id(path)?,
})
}
/// A match by path, identity, or a moved file's.
fn matches(have: &Path, had: Option<FileId>, key: &Key) -> io::Result<bool> {
if *have == key.canonical {
return Ok(true);
}
match file_id(have) {
Ok(Some(a)) => Ok(key.id == Some(a)),
Ok(None) => Ok(false),
// Gone from its path: the same file renamed.
Err(error) if error.kind() == io::ErrorKind::NotFound => {
Ok(had.is_some() && key.id == had)
}
Err(error) => Err(error),
}
}
/// The first live match, else the first identity error.
fn focus(&self, key: &Key) -> io::Result<Option<usize>> {
let mut error = None;
for (have, had, id) in &self.open {
match Self::matches(have, *had, key) {
Ok(true) => return Ok(Some(*id)),
Ok(false) => {}
Err(current) => {
if error.is_none() {
error = Some(current);
}
}
}
}
match error {
Some(current) => Err(current),
None => Ok(None),
}
}
/// A second open focuses, or views read-only.
pub fn open(&mut self, path: &Path, read_only: bool) -> io::Result<Open> {
let key = Self::key(path)?;
let found = self.focus(&key)?;
if let Some(id) = found
&& !read_only
{
log::info!("sessions: {path:?} is open as {id}; focusing it");
return Ok(Open::Focus(id));
}
let id = self.next;
self.next += 1;
self.open.push((key.canonical.clone(), key.id, id));
if found.is_some() {
log::info!("sessions: {path:?} also opens as read-only view {id}");
return Ok(Open::View(id));
}
Ok(Open::Fresh(id))
}
/// Drops a closed session's record.
pub fn close(&mut self, id: usize) {
self.open.retain(|(_, _, have)| *have != id);
}
/// Session `id` now names the file at `path`.
pub fn track(&mut self, id: usize, path: &Path) -> io::Result<()> {
let key = Self::key(path)?;
for entry in self.open.iter_mut().filter(|(_, _, have)| *have == id) {
(entry.0, entry.1) = (key.canonical.clone(), key.id);
}
Ok(())
}
/// Where session `id`'s file went in `dir`, if renamed there.
pub fn renamed_in(&self, id: usize, dir: &Path) -> io::Result<Option<PathBuf>> {
let had = self.open.iter().find(|(_, _, have)| *have == id);
let Some(had) = had.and_then(|(_, had, _)| *had) else {
return Ok(None);
};
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
// A link to the file is not the file.
if entry.file_type()?.is_symlink() {
continue;
}
match file_id(&entry.path()) {
Ok(found) if found == Some(had) => return Ok(Some(entry.path())),
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Ok(None)
}
pub fn is_open(&self, path: &Path) -> io::Result<bool> {
let key = Self::key(path)?;
Ok(self.focus(&key)?.is_some())
}
}
#[cfg(test)]
mod tests {
use super::{Open, Sessions};
#[test]
fn a_renamed_file_stays_one_session() {
let dir = tempfile::tempdir().expect("a temp dir");
let (a, b) = (dir.path().join("a.md"), dir.path().join("b.md"));
std::fs::write(&a, "a").expect("writes");
let mut sessions = Sessions::default();
let Open::Fresh(id) = sessions.open(&a, false).expect("opens") else {
panic!("a first open is fresh");
};
std::fs::rename(&a, &b).expect("renames");
// Opened in the gap: the same file, so focus.
assert_eq!(sessions.open(&b, false).expect("opens"), Open::Focus(id));
sessions.track(id, &b).expect("moves");
assert_eq!(sessions.open(&b, false).expect("opens"), Open::Focus(id));
sessions.close(id);
assert!(matches!(
sessions.open(&b, false).expect("opens"),
Open::Fresh(_)
));
}
#[test]
fn a_tracked_save_keeps_a_later_rename_one_session() {
let dir = tempfile::tempdir().expect("a temp dir");
let (a, b, temp) = (
dir.path().join("a.md"),
dir.path().join("b.md"),
dir.path().join("t"),
);
std::fs::write(&a, "a").expect("writes");
let mut sessions = Sessions::default();
let Open::Fresh(id) = sessions.open(&a, false).expect("opens") else {
panic!("a first open is fresh");
};
// An atomic save: a new file takes the name.
std::fs::write(&temp, "saved").expect("writes");
std::fs::rename(&temp, &a).expect("replaces");
sessions.track(id, &a).expect("tracks");
std::fs::rename(&a, &b).expect("renames");
assert_eq!(sessions.open(&b, false).expect("opens"), Open::Focus(id));
}
}
@@ -0,0 +1,24 @@
use super::*;
#[test]
fn a_failure_before_writing_shows_until_a_save_lands() {
let mut session = Session::new_file();
session.edit();
session.save_failed();
assert_eq!(session.state(), SaveState::SaveFailed);
let ticket = session.begin_save().expect("a retry may start");
assert_eq!(session.state(), SaveState::Saving);
let _ = ticket;
let dir = tempfile::tempdir().expect("a temp dir");
let file = dir.path().join("ro.txt");
std::fs::write(&file, "ORIGINAL").expect("writes");
let mut read_only = Session::read_only(file.clone()).expect("a read-only view");
read_only.save_failed();
assert_eq!(
read_only.state(),
SaveState::ReadOnly,
"a failure keeps read-only"
);
assert!(crate::save(&mut read_only, b"OVERWRITTEN").is_err());
assert_eq!(std::fs::read_to_string(&file).expect("reads"), "ORIGINAL");
}
+484
View File
@@ -0,0 +1,484 @@
use super::*;
mod registry;
mod revert;
fn write(path: &Path, body: &str) {
std::fs::write(path, body).expect("the fixture file writes");
}
fn saved(session: &mut Session, target: &Path) {
let ticket = session.begin_save().expect("the save starts");
write(target, "saved");
let written = Fingerprint::of(target).expect("the written fingerprint");
session
.finish_save(ticket, target.to_path_buf(), written)
.expect("the save lands");
}
#[test]
fn a_new_file_has_no_path_until_saved() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut session = Session::new_file();
assert_eq!(session.path(), None);
session.edit();
assert_eq!(session.state(), SaveState::Dirty);
let target = dir.path().join("草稿 01.md");
saved(&mut session, &target);
assert_eq!(session.path(), Some(target.as_path()));
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn an_old_ticket_never_marks_newer_edits_saved() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("r.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let stale = session.begin_save().expect("the first save starts");
session.edit();
write(&target, "1");
let written = Fingerprint::of(&target).expect("the written fingerprint");
session
.finish_save(stale, target.clone(), written)
.expect("the stale save lands");
assert_eq!(session.saved_revision(), 1);
assert_eq!(session.revision(), 2);
assert_eq!(session.state(), SaveState::Dirty);
saved(&mut session, &target);
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn a_reload_kills_old_tickets() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("k.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let stale = session.begin_save().expect("the save starts");
session.reload().expect("the file reloads");
session.edit();
let written = Fingerprint::of(&target).expect("the fingerprint");
assert_eq!(
session.finish_save(stale, target, written),
Err(StaleTicket)
);
assert_eq!(session.saved_revision(), 0);
assert_eq!(session.state(), SaveState::Dirty);
}
#[test]
fn a_read_only_view_stays_read_only_through_reload() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("v.txt");
write(&target, "0");
let mut session = Session::read_only(target.clone()).expect("the view loads");
write(&target, "1");
session.external_modified();
assert_eq!(session.state(), SaveState::ReadOnly);
session.reload().expect("the view reloads");
assert_eq!(session.state(), SaveState::ReadOnly);
assert!(matches!(session.begin_save(), Err(SaveState::ReadOnly)));
}
#[test]
fn a_save_completion_keeps_a_newer_conflict() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("c.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let ticket = session.begin_save().expect("the save starts");
write(&target, "ours");
let written = Fingerprint::of(&target).expect("our written fingerprint");
write(&target, "external content, longer");
session.external_modified();
session
.finish_save(ticket, target.clone(), written)
.expect("our save lands");
assert_eq!(session.state(), SaveState::ExternalModified);
assert_eq!(session.saved_revision(), 1);
assert_eq!(session.fingerprint(), Some(written));
assert_ne!(
session.fingerprint(),
Fingerprint::of(&target).ok(),
"the foreign fingerprint is never adopted"
);
session.reload().expect("the file reloads");
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn a_conflict_refuses_a_plain_save() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("m.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
write(&target, "1");
session.external_modified();
assert!(matches!(
session.begin_save(),
Err(SaveState::ExternalModified)
));
let ticket = session
.begin_save_overwrite()
.expect("the overwrite starts");
write(&target, "kept local");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(ticket, target, written)
.expect("the overwrite lands");
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn an_overwrite_keeps_the_access_mode_guards() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("g.txt");
write(&target, "0");
let mut view = Session::read_only(target.clone()).expect("the view loads");
assert!(matches!(
view.begin_save_overwrite(),
Err(SaveState::ReadOnly)
));
let mut loading = Session::loading(target);
assert!(matches!(
loading.begin_save_overwrite(),
Err(SaveState::Loading)
));
}
#[test]
fn a_conflict_survives_edits_until_resolved() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("e.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
write(&target, "1");
session.external_modified();
session.edit();
assert_eq!(session.state(), SaveState::ExternalModified);
assert!(matches!(
session.begin_save(),
Err(SaveState::ExternalModified)
));
let ticket = session.begin_save_overwrite().expect("keep-local starts");
write(&target, "kept");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(ticket, target, written)
.expect("the overwrite lands");
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn a_conflict_survives_a_failed_save() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("s.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let ticket = session.begin_save().expect("the save starts");
write(&target, "external");
session.external_modified();
session.fail_save(ticket).expect("the failure lands");
assert_eq!(session.state(), SaveState::SaveFailed);
assert!(matches!(
session.begin_save(),
Err(SaveState::ExternalModified)
));
session.reload().expect("the file reloads");
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn a_delayed_load_keeps_a_newer_conflict() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("d.txt");
write(&target, "fourteen bytes");
let loaded = Fingerprint::of(&target).expect("the loaded fingerprint");
let mut session = Session::loading(target.clone());
write(&target, "external content, much longer than before");
session.external_modified();
session.finish_load(loaded).expect("the load lands");
assert_eq!(session.state(), SaveState::ExternalModified);
assert_eq!(session.fingerprint(), Some(loaded));
}
#[test]
fn a_failed_save_keeps_the_unsaved_content() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("f.txt");
write(&target, "0");
let mut session = Session::load(target).expect("the file loads");
session.edit();
session.edit();
let ticket = session.begin_save().expect("the save starts");
session.fail_save(ticket).expect("the failure lands");
assert_eq!(session.state(), SaveState::SaveFailed);
assert_eq!(session.revision(), 2);
let retry = session.begin_save();
assert!(retry.is_ok());
}
#[test]
fn loading_transitions_cover_success_and_failure() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("big.docx");
write(&target, "0");
let mut session = Session::loading(target.clone());
assert_eq!(session.state(), SaveState::Loading);
assert!(matches!(session.begin_save(), Err(SaveState::Loading)));
let loaded = Fingerprint::of(&target).expect("the loaded fingerprint");
session.finish_load(loaded).expect("the load lands");
assert_eq!(session.state(), SaveState::Saved);
assert!(session.fingerprint().is_some());
let mut failed = Session::loading(target);
failed.fail_load().expect("the failure lands");
assert_eq!(failed.state(), SaveState::LoadFailed);
assert!(matches!(failed.begin_save(), Err(SaveState::LoadFailed)));
}
#[test]
fn a_running_task_refuses_another() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("t.txt");
write(&target, "0");
let mut loading = Session::loading(target.clone());
write(&target, "external");
loading.external_modified();
assert!(matches!(
loading.begin_save_overwrite(),
Err(SaveState::Loading)
));
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let first = session.begin_save().expect("the first save starts");
write(&target, "external again");
session.external_modified();
session.edit();
assert!(matches!(
session.begin_save_overwrite(),
Err(SaveState::Saving)
));
write(&target, "first");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(first, target.clone(), written)
.expect("the first save lands");
assert_eq!(session.state(), SaveState::ExternalModified);
let second = session.begin_save_overwrite().expect("keep-local starts");
write(&target, "second");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(second, target, written)
.expect("the second save lands");
assert_eq!(session.state(), SaveState::Saved);
assert_eq!(session.saved_revision(), session.revision());
}
#[test]
fn a_stale_completion_releases_the_save_slot() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("slot.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let stale = session.begin_save().expect("the save starts");
write(&target, "external");
session.external_modified();
session.reload().expect("the file reloads");
let written = Fingerprint::of(&target).expect("the fingerprint");
assert_eq!(
session.finish_save(stale, target.clone(), written),
Err(StaleTicket)
);
session.edit();
let fresh = session.begin_save().expect("the slot released");
write(&target, "fresh");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(fresh, target.clone(), written)
.expect("the fresh save lands");
assert_eq!(session.state(), SaveState::Saved);
let written = Fingerprint::of(&target).expect("the fingerprint");
assert_eq!(
session.finish_save(fresh, target, written),
Err(StaleTicket),
"a second completion of one ticket is stale"
);
}
#[test]
fn a_failed_load_never_saves_after_external_events() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("fl.txt");
write(&target, "0");
let mut session = Session::loading(target.clone());
session.fail_load().expect("the failure lands");
write(&target, "external");
session.external_modified();
assert!(matches!(
session.begin_save_overwrite(),
Err(SaveState::LoadFailed)
));
assert!(matches!(session.begin_save(), Err(SaveState::LoadFailed)));
}
#[test]
fn a_duplicate_stale_completion_keeps_the_running_save() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("dup.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let stale = session.begin_save().expect("the old save starts");
session.reload().expect("the file reloads");
let written = Fingerprint::of(&target).expect("the fingerprint");
assert_eq!(
session.finish_save(stale, target.clone(), written),
Err(StaleTicket)
);
session.edit();
let fresh = session.begin_save().expect("the new save starts");
let written = Fingerprint::of(&target).expect("the fingerprint");
assert_eq!(
session.finish_save(stale, target.clone(), written),
Err(StaleTicket),
"a duplicate stale completion never frees the running save"
);
write(&target, "fresh");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(fresh, target, written)
.expect("the running save lands");
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn a_failed_load_then_reload_recovers_saving() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("rec.txt");
write(&target, "0");
let mut session = Session::loading(target.clone());
session.fail_load().expect("the failure lands");
assert!(matches!(session.begin_save(), Err(SaveState::LoadFailed)));
session.reload().expect("the file reloads");
assert_eq!(session.state(), SaveState::Saved);
session.edit();
let ticket = session.begin_save().expect("the save starts after reload");
write(&target, "edited");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(ticket, target, written)
.expect("the save lands");
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn save_as_tasks_have_unique_identities() {
let dir = tempfile::tempdir().expect("a temp dir");
let first = dir.path().join("first.md");
let second = dir.path().join("second.md");
write(&first, "0");
let mut session = Session::load(first.clone()).expect("the file loads");
session.edit();
let one = session.begin_save().expect("the first save starts");
write(&first, "1");
let written = Fingerprint::of(&first).expect("the fingerprint");
session
.finish_save(one, first.clone(), written)
.expect("the first save lands");
let two = session.begin_save().expect("the save-as starts");
let written = Fingerprint::of(&first).expect("the fingerprint");
assert_eq!(
session.finish_save(one, first, written),
Err(StaleTicket),
"a repeated old callback never frees the save-as task"
);
write(&second, "1");
let written = Fingerprint::of(&second).expect("the fingerprint");
session
.finish_save(two, second.clone(), written)
.expect("the save-as lands");
assert_eq!(session.path(), Some(second.as_path()));
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn a_duplicate_failure_keeps_the_running_save() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("df.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let old = session.begin_save().expect("task A starts");
session.fail_save(old).expect("task A fails");
session.edit();
let running = session.begin_save().expect("task B starts");
assert_eq!(
session.fail_save(old),
Err(StaleTicket),
"a repeated old failure never frees task B"
);
assert_eq!(session.state(), SaveState::Saving);
write(&target, "done");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(running, target, written)
.expect("task B lands");
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn a_stale_failure_keeps_the_reloaded_state() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("sf.txt");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
let stale = session.begin_save().expect("the save starts");
write(&target, "external");
session.external_modified();
session.reload().expect("the file reloads");
assert_eq!(session.fail_save(stale), Err(StaleTicket));
assert_eq!(session.state(), SaveState::Saved);
session.edit();
let fresh = session.begin_save().expect("the slot released");
write(&target, "fresh");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(fresh, target, written)
.expect("the fresh save lands");
assert_eq!(session.state(), SaveState::Saved);
}
#[test]
fn a_stale_load_callback_keeps_the_reloaded_state() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("sl.txt");
write(&target, "0");
let old = Fingerprint::of(&target).expect("the old fingerprint");
let mut session = Session::loading(target.clone());
write(&target, "external, longer");
session.external_modified();
session.reload().expect("the file reloads");
session.edit();
assert_eq!(session.finish_load(old), Err(StaleTicket));
assert_eq!(session.state(), SaveState::Dirty);
assert_ne!(session.fingerprint(), Some(old));
assert_eq!(session.fail_load(), Err(StaleTicket));
assert_eq!(session.state(), SaveState::Dirty);
let ticket = session.begin_save().expect("the save starts");
write(&target, "current");
let written = Fingerprint::of(&target).expect("the fingerprint");
session
.finish_save(ticket, target, written)
.expect("the save lands");
assert_eq!(session.state(), SaveState::Saved);
}
mod failed;
@@ -0,0 +1,287 @@
use super::*;
#[cfg(unix)]
#[test]
fn a_replace_rekeys_links_and_forks_old_ones() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("doc.md");
let old = dir.path().join("old.md");
write(&target, "0");
std::fs::hard_link(&target, &old).expect("the old link");
let mut sessions = Sessions::default();
assert!(matches!(
sessions.open(&target, false).expect("the first open"),
Open::Fresh(_)
));
let staging = dir.path().join("doc.md.tmp");
write(&staging, "1");
std::fs::rename(&staging, &target).expect("the atomic replace");
let alias = dir.path().join("alias.md");
std::fs::hard_link(&target, &alias).expect("the new link");
assert!(sessions.is_open(&target).expect("the check"));
assert_eq!(
sessions.open(&alias, false).expect("the alias open"),
Open::Focus(0)
);
assert!(matches!(
sessions
.open(&old, false)
.expect("the old link opens alone"),
Open::Fresh(_)
));
}
#[test]
fn opening_twice_focuses_or_marks_a_view() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("doc.txt");
write(&target, "0");
let mut sessions = Sessions::default();
assert!(matches!(
sessions.open(&target, false).expect("the first open"),
Open::Fresh(_)
));
assert_eq!(
sessions.open(&target, false).expect("the second open"),
Open::Focus(0)
);
assert_eq!(
sessions.open(&target, true).expect("the view open"),
Open::View(1)
);
assert!(sessions.is_open(&target).expect("the check"));
sessions.close(0);
sessions.close(1);
assert!(!sessions.is_open(&target).expect("the check"));
}
#[test]
fn hard_links_share_one_session() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("doc.txt");
let alias = dir.path().join("alias.txt");
write(&target, "0");
std::fs::hard_link(&target, &alias).expect("the hard link");
let mut sessions = Sessions::default();
assert!(matches!(
sessions.open(&target, false).expect("the first open"),
Open::Fresh(_)
));
assert_eq!(
sessions.open(&alias, false).expect("the alias open"),
Open::Focus(0)
);
}
#[cfg(unix)]
#[test]
fn a_dangling_symlink_fails_loud_and_registers_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("gone.txt");
let link = dir.path().join("link.txt");
write(&target, "0");
std::os::unix::fs::symlink(&target, &link).expect("the symlink");
std::fs::remove_file(&target).expect("the target goes");
let mut sessions = Sessions::default();
assert!(sessions.open(&link, false).is_err());
assert!(sessions.is_open(&link).is_err());
let real = dir.path().join("real.txt");
write(&real, "0");
assert_eq!(
sessions.open(&real, false).expect("the open"),
Open::Fresh(0)
);
}
#[test]
fn an_atomic_replace_keeps_the_session() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("doc.md");
write(&target, "0");
let mut sessions = Sessions::default();
assert!(matches!(
sessions.open(&target, false).expect("the first open"),
Open::Fresh(_)
));
let staging = dir.path().join("doc.md.tmp");
write(&staging, "1");
std::fs::rename(&staging, &target).expect("the atomic replace");
assert!(sessions.is_open(&target).expect("the check"));
assert_eq!(
sessions.open(&target, false).expect("the second open"),
Open::Focus(0)
);
}
#[test]
fn a_missing_file_fails_open_loud() {
let dir = tempfile::tempdir().expect("a temp dir");
let missing = dir.path().join("nope.txt");
let mut sessions = Sessions::default();
assert!(sessions.open(&missing, false).is_err());
assert!(sessions.is_open(&missing).is_err());
let real = dir.path().join("real.txt");
write(&real, "0");
assert_eq!(
sessions.open(&real, false).expect("the open"),
Open::Fresh(0)
);
std::fs::remove_file(&real).expect("the file goes");
assert!(sessions.is_open(&real).is_err());
assert!(sessions.open(&real, false).is_err());
}
#[test]
fn a_missing_file_fails_loud() {
let dir = tempfile::tempdir().expect("a temp dir");
let missing = dir.path().join("nope.txt");
assert!(Session::load(missing.clone()).is_err());
assert!(Fingerprint::of(&missing).is_err());
}
#[cfg(unix)]
#[test]
fn a_revoked_entry_path_fails_loud() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let restricted = dir.path().join("restricted");
std::fs::create_dir(&restricted).expect("the dir");
let target = restricted.join("doc.md");
let alias = dir.path().join("alias.md");
write(&target, "0");
std::fs::hard_link(&target, &alias).expect("the hard link");
let mut sessions = Sessions::default();
assert!(matches!(
sessions.open(&target, false).expect("the first open"),
Open::Fresh(_)
));
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000))
.expect("the lockdown");
let opened = sessions.open(&alias, false);
let checked = sessions.is_open(&alias);
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o755))
.expect("the restore");
assert!(opened.is_err());
assert!(checked.is_err());
assert_eq!(
sessions.open(&alias, false).expect("the alias open"),
Open::Focus(0)
);
}
#[cfg(unix)]
#[test]
fn a_failed_identity_query_registers_nothing() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let first = dir.path().join("first.md");
let restricted = dir.path().join("restricted");
std::fs::create_dir(&restricted).expect("the dir");
let second = restricted.join("second.md");
write(&first, "0");
write(&second, "0");
let mut sessions = Sessions::default();
assert!(matches!(
sessions.open(&first, false).expect("the first open"),
Open::Fresh(_)
));
assert!(matches!(
sessions.open(&second, false).expect("the second open"),
Open::Fresh(_)
));
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000))
.expect("the lockdown");
let viewed = sessions.open(&first, true);
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o755))
.expect("the restore");
assert_eq!(
viewed.expect("a definitive match outranks the stuck entry"),
Open::View(2)
);
sessions.close(0);
sessions.close(2);
assert!(!sessions.is_open(&first).expect("no phantom entry"));
assert!(matches!(
sessions.open(&first, false).expect("the re-open"),
Open::Fresh(3)
));
}
#[cfg(unix)]
#[test]
fn a_focus_returns_before_unreachable_entries() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let first = dir.path().join("first.md");
let restricted = dir.path().join("restricted");
std::fs::create_dir(&restricted).expect("the dir");
let second = restricted.join("second.md");
write(&first, "0");
write(&second, "0");
let mut sessions = Sessions::default();
assert!(matches!(
sessions.open(&first, false).expect("the first open"),
Open::Fresh(_)
));
assert!(matches!(
sessions.open(&second, false).expect("the second open"),
Open::Fresh(_)
));
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000))
.expect("the lockdown");
let focused = sessions.open(&first, false);
let checked = sessions.is_open(&first);
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o755))
.expect("the restore");
assert_eq!(focused.expect("the focus"), Open::Focus(0));
assert!(checked.expect("the check"));
}
#[cfg(unix)]
#[test]
fn a_focus_ignores_unreachable_entries_after_a_match() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let restricted = dir.path().join("restricted");
std::fs::create_dir(&restricted).expect("the dir");
let first = restricted.join("first.md");
let second = dir.path().join("second.md");
write(&first, "0");
write(&second, "0");
let mut sessions = Sessions::default();
assert!(matches!(
sessions.open(&first, false).expect("the first open"),
Open::Fresh(_)
));
assert!(matches!(
sessions.open(&second, false).expect("the second open"),
Open::Fresh(_)
));
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000))
.expect("the lockdown");
let focused = sessions.open(&second, false);
let checked = sessions.is_open(&second);
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o755))
.expect("the restore");
assert_eq!(focused.expect("the focus"), Open::Focus(1));
assert!(checked.expect("the check"));
}
#[cfg(unix)]
#[test]
fn a_rename_is_found_by_identity_not_by_link() {
let dir = tempfile::tempdir().expect("a temp dir");
let first = dir.path().join("a.md");
write(&first, "0");
let mut sessions = Sessions::default();
let Open::Fresh(id) = sessions.open(&first, false).expect("the open") else {
panic!("a fresh session")
};
let moved = dir.path().join("b.md");
std::fs::rename(&first, &moved).expect("the rename");
std::os::unix::fs::symlink(&moved, dir.path().join("link.md")).expect("the link");
let found = sessions.renamed_in(id, dir.path()).expect("the scan");
assert_eq!(found, Some(moved.clone()));
std::fs::remove_file(&moved).expect("the delete");
assert_eq!(sessions.renamed_in(id, dir.path()).expect("the scan"), None);
}
@@ -0,0 +1,50 @@
//! Undo back to the saved content (FILE-02).
use super::*;
#[test]
fn undo_to_the_saved_content_is_clean_but_a_conflict_stays() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("r.pdf");
write(&target, "0");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
session.reverted();
assert_eq!(session.state(), SaveState::Saved);
session.edit();
let _ticket = session.begin_save().expect("the save starts");
session.reverted();
assert_eq!(
session.state(),
SaveState::Saving,
"a save in flight decides"
);
let mut changed = Session::load(target.clone()).expect("the file loads");
changed.external_modified();
changed.reverted();
assert_eq!(changed.state(), SaveState::ExternalModified);
}
#[test]
fn a_rebase_saves_against_the_seen_disk_only() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("m.md");
write(&target, "base");
let mut session = Session::load(target.clone()).expect("the file loads");
session.edit();
std::thread::sleep(std::time::Duration::from_millis(20));
write(&target, "seen change");
session.external_modified();
let seen = Fingerprint::of(&target).expect("the fingerprint");
session.rebase(seen);
assert_eq!(session.state(), SaveState::Dirty);
assert!(!session.external_changed().expect("the check"));
std::thread::sleep(std::time::Duration::from_millis(20));
write(&target, "a later change");
assert!(session.external_changed().expect("the check"), "a later write still conflicts");
assert!(matches!(
crate::save(&mut session, b"local"),
Err(crate::SaveError::Conflict)
));
assert_eq!(std::fs::read(&target).expect("the file"), b"a later change");
}
+103
View File
@@ -0,0 +1,103 @@
use std::{
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, SystemTime},
};
use crate::Recovery;
/// The background recovery task: one per document (§6.2).
pub struct RecoveryTask {
status: Arc<Mutex<Option<SystemTime>>>,
stop: Arc<AtomicBool>,
bell: Arc<Condvar>,
join: Option<std::thread::JoinHandle<()>>,
}
impl RecoveryTask {
/// Runs `snapshot` every interval; failures keep looping.
pub fn start(
recovery: Recovery,
interval: Duration,
snapshot: impl Fn() -> (Vec<u8>, Option<String>) + Send + 'static,
) -> Self {
let status = Arc::new(Mutex::new(recovery.last_ok()));
let shared = Arc::new(Mutex::new(recovery));
let stop = Arc::new(AtomicBool::new(false));
let bell = Arc::new(Condvar::new());
let (worker, worker_status, flag, worker_bell) = (
Arc::clone(&shared),
Arc::clone(&status),
Arc::clone(&stop),
Arc::clone(&bell),
);
let join = std::thread::spawn(move || {
{
let recovery = worker.lock().expect("the recovery lock");
if let Err(error) = recovery.maintain(SystemTime::now()) {
log::warn!("recovery: the opening maintain failed: {error}");
}
}
let gate = Mutex::new(());
while !flag.load(Ordering::Relaxed) {
let hold = gate.lock().expect("the gate lock");
let (hold, waited) = worker_bell
.wait_timeout(hold, interval)
.expect("the gate wait");
drop(hold);
if flag.load(Ordering::Relaxed) {
break;
}
if !waited.timed_out() {
continue;
}
let (content, cursor) = snapshot();
if flag.load(Ordering::Relaxed) {
log::info!("recovery: a snapshot dropped after cancel");
break;
}
let mut recovery = worker.lock().expect("the recovery lock");
let written = recovery.write_once(&content, cursor.as_deref(), SystemTime::now());
let last_ok = recovery.last_ok();
drop(recovery);
*worker_status.lock().expect("the status lock") = last_ok;
if let Err(error) = written {
log::warn!("recovery: the scheduled write failed: {error}");
}
}
});
Self {
status,
stop,
bell,
join: Some(join),
}
}
/// The last successful scheduled write, never behind disk I/O.
pub fn last_ok(&self) -> Option<SystemTime> {
*self.status.lock().expect("the status lock")
}
/// Stops the loop and joins its thread.
pub fn stop(mut self) {
self.cancel();
}
/// Sets the flag, wakes the sleeper and joins.
fn cancel(&mut self) {
self.stop.store(true, Ordering::Relaxed);
self.bell.notify_all();
if let Some(join) = self.join.take() {
join.join().expect("the recovery thread joins");
}
}
}
impl Drop for RecoveryTask {
fn drop(&mut self) {
self.cancel();
}
}