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:
817 files changed
+143101
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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, ©, b"local").expect("a new file keeps the local version");
|
||||
assert_eq!(read(©), "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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user