mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-12 13:24:56 +00:00
905 lines
40 KiB
Rust
905 lines
40 KiB
Rust
//! Crash recovery (InDesign's automatic recovery data): unsaved changes of every open document are
|
|
//! written to a recovery folder (`file.recovery.save`, called on a timer by the app); saving or
|
|
//! closing a document removes its file; after a crash `file.recovery.open` reopens what was left
|
|
//! there as unsaved documents that remember where they were saved.
|
|
//!
|
|
//! A recovery entry is `<uid>.designcraft` (the document) plus `<uid>.json` (`{"path", "title",
|
|
//! "saved"}`: the original file, the title, and when it was written).
|
|
//!
|
|
//! Document uids start at 1 in every process, so each running app keeps its entries in a folder
|
|
//! of its own in the recovery folder (`run_dir`) and holds the lock in it for as long as it runs.
|
|
//! An app that starts moves into its own folder what apps that have ended left in theirs, and
|
|
//! leaves the folder of one still running alone.
|
|
//!
|
|
//! A folder given to `file.recovery.save` that is not this run's own may hold other runs' entries
|
|
//! under the same uids. This run writes there only entries it finds free, replaces and removes
|
|
//! only those it wrote or recovered, and refuses the folder of a run whose lock is still held.
|
|
//!
|
|
//! Each file is replaced atomically, but the pair is not a transaction. The document is published
|
|
//! and made durable before its metadata so a crash can leave metadata missing or stale, never a
|
|
//! listed metadata record without a complete primary document. Listing and opening recovery data
|
|
//! therefore treat metadata as optional.
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::{DocState, EngineError, Result, Session};
|
|
|
|
/// The platform's per-user recovery folder.
|
|
pub fn default_dir() -> Option<PathBuf> {
|
|
let home = std::env::var_os("HOME").map(PathBuf::from);
|
|
if cfg!(target_os = "macos") {
|
|
return home.map(|h| h.join("Library/Application Support/DesignCraft/Recovery"));
|
|
}
|
|
if cfg!(target_os = "windows") {
|
|
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("DesignCraft").join("Recovery"));
|
|
}
|
|
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("designcraft/recovery"))
|
|
}
|
|
|
|
/// This process's folder in a recovery folder, with the lock that says it is still running.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
struct Run {
|
|
root: PathBuf,
|
|
dir: PathBuf,
|
|
lock: std::fs::File,
|
|
#[cfg(unix)]
|
|
directory: std::fs::File,
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
static RUNS: std::sync::Mutex<Vec<Run>> = std::sync::Mutex::new(Vec::new());
|
|
|
|
/// The lock in a run's folder.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const LOCK: &str = "lock";
|
|
|
|
/// This run's own folder in the recovery folder `root`, made on the first call. Document uids
|
|
/// start at 1 in every process, so two running apps writing to one folder wrote over each
|
|
/// other's entries. The folder's lock is held until the process ends: an app that starts leaves
|
|
/// the folder of one that is still running alone.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub fn run_dir(root: &Path) -> Result<PathBuf> {
|
|
let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if let Some(run) = runs.iter().find(|r| r.root == root) {
|
|
return Ok(run.dir.clone());
|
|
}
|
|
secure_recovery_dir(root)?;
|
|
for n in 0..64 {
|
|
let dir = root.join(format!("run-{}-{n}", std::process::id()));
|
|
// Only a folder this run makes is its own. One that is there is another app's, or was
|
|
// left by a run that ended under this process id.
|
|
#[cfg(unix)]
|
|
let made = {
|
|
use std::os::unix::fs::DirBuilderExt;
|
|
|
|
let mut builder = std::fs::DirBuilder::new();
|
|
builder.mode(0o700).create(&dir)
|
|
};
|
|
#[cfg(not(unix))]
|
|
let made = std::fs::create_dir(&dir);
|
|
match made {
|
|
Ok(()) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
|
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
|
}
|
|
let locked = validate_recovery_dir(&dir).and_then(|()| {
|
|
let lock = new_lock(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
match lock.try_lock() {
|
|
Ok(()) => {
|
|
validate_lock(&dir, &lock)?;
|
|
Ok(Some(lock))
|
|
}
|
|
Err(std::fs::TryLockError::WouldBlock) => Ok(None),
|
|
Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
|
}
|
|
});
|
|
match locked {
|
|
// An app that is starting can take a folder this new for one a run left, and remove
|
|
// it before the lock is held: then the lock is gone, or that app holds it.
|
|
Ok(Some(lock)) => {
|
|
#[cfg(unix)]
|
|
let directory = std::fs::File::open(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
runs.push(Run {
|
|
root: root.to_path_buf(),
|
|
dir: dir.clone(),
|
|
lock,
|
|
#[cfg(unix)]
|
|
directory,
|
|
});
|
|
return Ok(dir);
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
// Never unlink a lock that another starting run may now own.
|
|
let _ = std::fs::remove_dir(&dir);
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
Err(EngineError::Other(format!("{}: no free folder for this run", root.display())))
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn new_lock(dir: &Path) -> std::io::Result<std::fs::File> {
|
|
let mut new = std::fs::OpenOptions::new();
|
|
new.read(true).write(true).create_new(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
new.mode(0o600);
|
|
}
|
|
let mut file = new.open(dir.join(LOCK))?;
|
|
// A marker supplements Unix inode validation and identifies locks on other platforms.
|
|
// Windows uses live handle identity because its exclusive lock prevents separate-handle reads.
|
|
// Empty older-version locks remain valid.
|
|
static NEXT_LOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(std::io::Error::other)?.as_nanos();
|
|
let marker = format!("{}:{}:{now}", std::process::id(), NEXT_LOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
|
file.write_all(marker.as_bytes())?;
|
|
file.sync_all()?;
|
|
Ok(file)
|
|
}
|
|
|
|
/// Check the current lock name without accepting a symlink or a lock held through an unlinked
|
|
/// handle. An advisory lock on an old handle does not protect a replacement at the same name.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn validate_lock(dir: &Path, lock: &std::fs::File) -> Result<()> {
|
|
let path = dir.join(LOCK);
|
|
let fail = || EngineError::Other(format!("{}: this run no longer owns its recovery lock", path.display()));
|
|
let before = std::fs::symlink_metadata(&path).map_err(|_| fail())?;
|
|
if is_link_like(&before) || !before.file_type().is_file() {
|
|
return Err(fail());
|
|
}
|
|
let named = std::fs::File::open(&path).map_err(|_| fail())?;
|
|
let held = lock.metadata().map_err(|_| fail())?;
|
|
let current = named.metadata().map_err(|_| fail())?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
if held.dev() != before.dev() || held.ino() != before.ino() || held.dev() != current.dev() || held.ino() != current.ino() {
|
|
return Err(fail());
|
|
}
|
|
}
|
|
#[cfg(not(unix))]
|
|
let _ = (held, current);
|
|
// Windows locks prevent reads through separately opened handles, even in this process.
|
|
// Compare the held and named file identities without releasing the recovery lock.
|
|
#[cfg(windows)]
|
|
let held_identity = {
|
|
let held = same_file::Handle::from_file(lock.try_clone().map_err(|_| fail())?).map_err(|_| fail())?;
|
|
let current = same_file::Handle::from_file(named.try_clone().map_err(|_| fail())?).map_err(|_| fail())?;
|
|
if held != current {
|
|
return Err(fail());
|
|
}
|
|
held
|
|
};
|
|
#[cfg(not(windows))]
|
|
if lock_marker(lock).map_err(|_| fail())? != lock_marker(&named).map_err(|_| fail())? {
|
|
return Err(fail());
|
|
}
|
|
let after = std::fs::symlink_metadata(&path).map_err(|_| fail())?;
|
|
if is_link_like(&after) || !after.file_type().is_file() {
|
|
return Err(fail());
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
if held.dev() != after.dev() || held.ino() != after.ino() {
|
|
return Err(fail());
|
|
}
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let after_named = std::fs::File::open(&path).map_err(|_| fail())?;
|
|
let after_identity = same_file::Handle::from_file(after_named).map_err(|_| fail())?;
|
|
if held_identity != after_identity {
|
|
return Err(fail());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(not(target_arch = "wasm32"), not(windows)))]
|
|
fn lock_marker(file: &std::fs::File) -> std::io::Result<Vec<u8>> {
|
|
use std::io::{Read, Seek};
|
|
let mut file = file.try_clone()?;
|
|
file.seek(std::io::SeekFrom::Start(0))?;
|
|
let mut bytes = Vec::new();
|
|
file.take(129).read_to_end(&mut bytes)?;
|
|
if bytes.len() > 128 {
|
|
return Err(std::io::Error::other("invalid recovery lock marker"));
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
/// A run's folder that was removed while it runs is made again by the next save: so is its lock.
|
|
/// Returns whether `dir` is this run's folder.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn relock(dir: &Path, created: bool) -> Result<bool> {
|
|
let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(false) };
|
|
validate_recovery_dir(&run.root)?;
|
|
#[cfg(unix)]
|
|
let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
// Reclaim a newly recreated folder only when this operation actually created it. Existing
|
|
// replacements, including ones without a lock, may contain somebody else's documents.
|
|
let same_directory = {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
let held = run.directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
held.dev() == directory.dev() && held.ino() == directory.ino()
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
// Without stable directory identity, a valid held lock is the proof of ownership.
|
|
// A missing lock cannot authorize an existing folder, even on coarse-timestamp disks.
|
|
validate_lock(dir, &run.lock).is_ok()
|
|
}
|
|
};
|
|
if !created && !same_directory {
|
|
return Err(EngineError::Other(format!("{}: this run no longer owns its recovery folder", dir.display())));
|
|
}
|
|
match std::fs::symlink_metadata(dir.join(LOCK)) {
|
|
Ok(_) => return validate_lock(dir, &run.lock).map(|()| true),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
|
}
|
|
let lock = new_lock(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
lock.try_lock().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
validate_lock(dir, &lock)?;
|
|
#[cfg(unix)]
|
|
let replacement_directory = std::fs::File::open(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
let held = replacement_directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
if held.dev() != directory.dev() || held.ino() != directory.ino() {
|
|
return Err(EngineError::Other(format!("{}: recovery folder changed while restoring its lock", dir.display())));
|
|
}
|
|
}
|
|
validate_lock(dir, &lock)?;
|
|
run.lock = lock;
|
|
#[cfg(unix)]
|
|
{
|
|
run.directory = replacement_directory;
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
/// Entries this process wrote or recovered in folders that are not its run's own: (canonical
|
|
/// folder, uid). Document uids start at 1 in every process, so a shared folder's entry under
|
|
/// one of this run's uids may be another run's.
|
|
static WRITTEN: std::sync::Mutex<std::collections::BTreeSet<(PathBuf, u64)>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
|
|
|
|
/// What this run may do in a recovery folder.
|
|
struct Access {
|
|
/// This run's own folder (`run_dir`): every entry in it is this run's.
|
|
own: bool,
|
|
key: PathBuf,
|
|
}
|
|
|
|
impl Access {
|
|
fn owns(&self, uid: u64) -> bool {
|
|
self.own || WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&(self.key.clone(), uid))
|
|
}
|
|
|
|
fn record(&self, uid: u64) {
|
|
if !self.own {
|
|
WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert((self.key.clone(), uid));
|
|
}
|
|
}
|
|
|
|
fn forget(&self, uid: u64) {
|
|
WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&(self.key.clone(), uid));
|
|
}
|
|
|
|
/// Refuse an entry of another run before anything is written: this run never replaces it.
|
|
fn check_free(&self, dir: &Path, uid: u64) -> Result<()> {
|
|
if self.owns(uid) {
|
|
return Ok(());
|
|
}
|
|
let (doc, meta) = files(dir, uid);
|
|
for path in [doc, meta] {
|
|
match std::fs::symlink_metadata(&path) {
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
_ => {
|
|
return Err(EngineError::Other(format!(
|
|
"{}: recovery data this run did not write is there; recover it or choose another recovery folder",
|
|
path.display()
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Check what this run may do in `dir`. A folder that is not this run's own may be the folder
|
|
/// of another running app, whose lock that app holds: this run leaves it alone.
|
|
fn access(dir: &Path, created: bool) -> Result<Access> {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let own = relock(dir, created)?;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if !own {
|
|
check_unheld(dir)?;
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
let own = {
|
|
let _ = created;
|
|
false
|
|
};
|
|
Ok(Access { own, key: directory_key(dir)? })
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn check_unheld(dir: &Path) -> Result<()> {
|
|
let path = dir.join(LOCK);
|
|
let in_use =
|
|
|| EngineError::Other(format!("{}: another running DesignCraft keeps its recovery data here; choose another recovery folder", dir.display()));
|
|
match std::fs::symlink_metadata(&path) {
|
|
Ok(metadata) if is_link_like(&metadata) || !metadata.file_type().is_file() => return Err(in_use()),
|
|
Ok(_) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
|
Err(e) => return Err(EngineError::Other(format!("{}: {e}", path.display()))),
|
|
}
|
|
// Opened for writing: over NFS an exclusive lock on a file opened for reading is refused.
|
|
let lock = std::fs::OpenOptions::new().read(true).write(true).open(&path).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
|
match lock.try_lock() {
|
|
// Free: the run that held it has ended. Dropping the handle releases it again.
|
|
Ok(()) => Ok(()),
|
|
Err(std::fs::TryLockError::WouldBlock) => Err(in_use()),
|
|
Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", path.display()))),
|
|
}
|
|
}
|
|
|
|
/// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs
|
|
/// that have ended, and those written straight into the recovery folder before runs had folders.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn take_over(own: &Path) {
|
|
let root = {
|
|
let runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let Some(run) = runs.iter().find(|r| r.dir == own) else { return };
|
|
run.root.clone()
|
|
};
|
|
move_entries(&root, own);
|
|
let Ok(rd) = std::fs::read_dir(&root) else { return };
|
|
for e in rd.flatten() {
|
|
let dir = e.path();
|
|
if dir == own || !e.file_name().to_string_lossy().starts_with("run-") || validate_recovery_dir(&dir).is_err() {
|
|
continue;
|
|
}
|
|
// Opened for writing: over NFS an exclusive lock on a file opened for reading is refused.
|
|
let Ok(metadata) = std::fs::symlink_metadata(dir.join(LOCK)) else { continue };
|
|
if is_link_like(&metadata) || !metadata.file_type().is_file() {
|
|
continue;
|
|
}
|
|
let Ok(lock) = std::fs::OpenOptions::new().read(true).write(true).open(dir.join(LOCK)) else { continue };
|
|
match lock.try_lock() {
|
|
// Free: the run has ended. Holding it keeps another starting app out.
|
|
Ok(()) => {}
|
|
Err(std::fs::TryLockError::WouldBlock) => continue,
|
|
Err(std::fs::TryLockError::Error(e)) => {
|
|
log::warn!("recovery data in {} is left there: {e}", dir.display());
|
|
continue;
|
|
}
|
|
}
|
|
if let Err(e) = validate_lock(&dir, &lock) {
|
|
log::warn!("recovery data in {} is left there: {e}", dir.display());
|
|
continue;
|
|
}
|
|
move_entries(&dir, own);
|
|
remove_emptied(&dir);
|
|
}
|
|
}
|
|
|
|
/// Remove the folder of a run that ended, with what the app left in it beside documents. A
|
|
/// document still in it, or a file the app did not write, keeps the folder as it is.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn remove_emptied(dir: &Path) {
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
|
let mut left = Vec::new();
|
|
for e in rd {
|
|
let Ok(e) = e else { return };
|
|
let path = e.path();
|
|
let name = e.file_name();
|
|
let name = name.to_string_lossy();
|
|
let numbered = path.file_stem().and_then(std::ffi::OsStr::to_str).is_some_and(|s| s.parse::<u64>().is_ok());
|
|
// Metadata whose document has gone, and a file a crash left half written.
|
|
if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) {
|
|
left.push(path);
|
|
} else if name != LOCK {
|
|
return;
|
|
}
|
|
}
|
|
for path in left {
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
// The lock last: until then the folder is still that of a run that ended.
|
|
let _ = std::fs::remove_file(dir.join(LOCK));
|
|
let _ = std::fs::remove_dir(dir);
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn move_entries(from: &Path, own: &Path) {
|
|
for (uid, _) in list(from) {
|
|
let (doc, meta) = files(from, uid);
|
|
// Under a uid no document of this run has or gets.
|
|
let (own_doc, own_meta) = files(own, crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
|
// The document first: its metadata is optional.
|
|
if std::fs::rename(&doc, &own_doc).is_ok() {
|
|
let _ = std::fs::rename(&meta, &own_meta);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
|
(dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json")))
|
|
}
|
|
|
|
/// Remove a document's recovery entry (it was saved or closed). In a folder that is not this
|
|
/// run's own, only an entry this run wrote or recovered is removed.
|
|
pub fn discard(dir: &Path, uid: u64) {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if validate_recovery_dir(dir).is_err() {
|
|
return;
|
|
}
|
|
let Ok(access) = access(dir, false) else { return };
|
|
if !access.owns(uid) {
|
|
return;
|
|
}
|
|
let (d, m) = files(dir, uid);
|
|
let _ = std::fs::remove_file(d);
|
|
let _ = std::fs::remove_file(m);
|
|
access.forget(uid);
|
|
}
|
|
|
|
/// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory
|
|
/// uid may also be another live recovered document's retained source after a failed republish.
|
|
pub(crate) fn discard_document(s: &mut Session, index: usize, default_dir: Option<&Path>) {
|
|
let Some(doc) = s.docs.get_mut(index) else { return };
|
|
let uid = doc.uid;
|
|
// A completed Save/Close/Revert consumes the provenance, even when removal fails. A stale
|
|
// source uid must never become a future claim on another document's checkpoint.
|
|
let mut entries = std::mem::take(&mut doc.recovery_entries);
|
|
if let Some(dir) = default_dir
|
|
&& !entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, dir))
|
|
{
|
|
entries.push((dir.to_path_buf(), uid));
|
|
}
|
|
for (dir, uid) in entries {
|
|
// Session ownership governs deletion as well as writes: never consume another live
|
|
// document's only source merely because its old uid matches this unrelated document.
|
|
if !s.docs.iter().enumerate().any(|(other_index, owner)| {
|
|
other_index != index && owner.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, &dir))
|
|
}) {
|
|
discard(&dir, uid);
|
|
}
|
|
}
|
|
}
|
|
|
|
const MAX_RECOVERY_ENTRIES: usize = 16;
|
|
|
|
fn same_directory(a: &Path, b: &Path) -> bool {
|
|
a == b || std::fs::canonicalize(a).ok().zip(std::fs::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
|
|
}
|
|
|
|
fn has_checkpoint(doc: &DocState, dir: &Path) -> bool {
|
|
doc.recovery_entries.iter().any(|(entry_dir, uid)| *uid == doc.uid && same_directory(entry_dir, dir))
|
|
}
|
|
|
|
fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> {
|
|
for document in &s.docs {
|
|
if let Some(owner) = s.docs.iter().find(|owner| {
|
|
owner.uid != document.uid && owner.recovery_entries.iter().any(|(entry_dir, uid)| *uid == document.uid && same_directory(entry_dir, dir))
|
|
}) {
|
|
return Err(EngineError::Other(format!("save or close recovered document {} before reusing its recovery folder", owner.title())));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn prepare_save(s: &Session, dir: &Path) -> Result<Access> {
|
|
// Check before any write or deletion: a failed republish can retain a source whose uid
|
|
// coincides with an unrelated live document in a manually configured recovery directory.
|
|
protect_retained_sources(s, dir)?;
|
|
let created = secure_recovery_dir(dir)?;
|
|
let access = access(dir, created)?;
|
|
// New Document's preview is never written.
|
|
let preview = s.new_document_preview();
|
|
for document in s.docs.iter().filter(|d| d.is_dirty() && preview != Some(d.uid)) {
|
|
access.check_free(dir, document.uid)?;
|
|
}
|
|
Ok(access)
|
|
}
|
|
|
|
/// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many
|
|
/// were written. Command callers use the tracked variant without configuring automatic recovery.
|
|
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
|
let access = prepare_save(s, dir)?;
|
|
let mut n = 0;
|
|
for d in &s.docs {
|
|
// New Document's preview is not a document until Create makes it one.
|
|
if !d.is_dirty() || s.new_document_preview() == Some(d.uid) {
|
|
discard(dir, d.uid);
|
|
continue;
|
|
}
|
|
write_entry(&access, dir, d)?;
|
|
n += 1;
|
|
}
|
|
Ok(n)
|
|
}
|
|
|
|
/// Command-path checkpointing also records explicitly selected directories, without configuring
|
|
/// automatic recovery. The immutable public helper remains available for fixtures and callers.
|
|
pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
|
|
// Reject the complete batch before writing rather than silently losing old ownership records.
|
|
for document in &s.docs {
|
|
if document.is_dirty() && !has_checkpoint(document, dir) && document.recovery_entries.len() >= MAX_RECOVERY_ENTRIES {
|
|
return Err(EngineError::Other(
|
|
"too many recovery checkpoints; save or close the document before choosing another recovery folder".into(),
|
|
));
|
|
}
|
|
}
|
|
let access = prepare_save(s, dir)?;
|
|
let mut n = 0;
|
|
let preview = s.new_document_preview();
|
|
for index in 0..s.docs.len() {
|
|
// New Document's preview is never written, so it has no checkpoint to discard either.
|
|
if s.docs.get(index).is_some_and(|document| preview == Some(document.uid)) {
|
|
continue;
|
|
}
|
|
if s.docs.get(index).is_some_and(|document| !document.is_dirty()) {
|
|
discard_document(s, index, Some(dir));
|
|
continue;
|
|
}
|
|
let Some(document) = s.docs.get_mut(index) else { continue };
|
|
let (doc, meta) = files(dir, document.uid);
|
|
let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref()))
|
|
.map_err(|e| EngineError::Other(e.to_string()))?;
|
|
// An entry this run did not write is never replaced, even one made since it checked.
|
|
let replace = access.owns(document.uid);
|
|
publish_recovery_file(&doc, &bytes, replace)?;
|
|
access.record(document.uid);
|
|
// The primary is useful even if optional metadata fails next. Track it immediately.
|
|
if !has_checkpoint(document, dir) {
|
|
document.recovery_entries.push((dir.to_path_buf(), document.uid));
|
|
}
|
|
let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()});
|
|
publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)?;
|
|
n += 1;
|
|
}
|
|
Ok(n)
|
|
}
|
|
|
|
/// Publish a document's recovery entry before its optional metadata.
|
|
fn write_entry(access: &Access, dir: &Path, d: &DocState) -> Result<()> {
|
|
let (doc, meta) = files(dir, d.uid);
|
|
let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?;
|
|
let replace = access.owns(d.uid);
|
|
publish_recovery_file(&doc, &bytes, replace)?;
|
|
access.record(d.uid);
|
|
let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()});
|
|
publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn secure_recovery_dir(dir: &Path) -> Result<bool> {
|
|
match std::fs::symlink_metadata(dir) {
|
|
Ok(_) => return validate_recovery_dir(dir).map(|()| false),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
|
}
|
|
|
|
// Create parent directories privately without changing existing caller-owned permissions.
|
|
// Create the leaf separately so a creation race can never be mistaken for our own folder.
|
|
#[cfg(unix)]
|
|
let create_result = {
|
|
use std::os::unix::fs::DirBuilderExt;
|
|
if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) {
|
|
std::fs::DirBuilder::new()
|
|
.recursive(true)
|
|
.mode(0o700)
|
|
.create(parent)
|
|
.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
}
|
|
std::fs::DirBuilder::new().mode(0o700).create(dir)
|
|
};
|
|
#[cfg(not(unix))]
|
|
let create_result = {
|
|
if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) {
|
|
std::fs::create_dir_all(parent).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
}
|
|
std::fs::create_dir(dir)
|
|
};
|
|
let created = match create_result {
|
|
Ok(()) => true,
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false,
|
|
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
|
};
|
|
validate_recovery_dir(dir)?;
|
|
Ok(created)
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn secure_recovery_dir(_dir: &Path) -> Result<bool> {
|
|
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> {
|
|
let Some(dir) = path.parent() else {
|
|
return Err(EngineError::Other(format!("{} has no parent folder", path.display())));
|
|
};
|
|
validate_recovery_dir(dir)?;
|
|
let mut tmp = tempfile::Builder::new()
|
|
.prefix(".designcraft-recovery-")
|
|
.suffix(".tmp")
|
|
.tempfile_in(dir)
|
|
.map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
tmp.as_file().set_permissions(std::fs::Permissions::from_mode(0o600)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
|
}
|
|
tmp.as_file_mut().write_all(bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
|
tmp.as_file_mut().sync_all().map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
|
// Validate the non-followed leaf again immediately before renaming the randomized temporary
|
|
// file to its fixed sibling destination.
|
|
validate_recovery_dir(dir)?;
|
|
let published = if replace { tmp.persist(path) } else { tmp.persist_noclobber(path) };
|
|
published.map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?;
|
|
sync_recovery_dir(dir)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn validate_recovery_dir(dir: &Path) -> Result<()> {
|
|
let metadata = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
if is_link_like(&metadata) {
|
|
return Err(EngineError::Other(format!("{} must not be a symbolic link or reparse point", dir.display())));
|
|
}
|
|
if !metadata.file_type().is_dir() {
|
|
return Err(EngineError::Other(format!("{} is not a directory", dir.display())));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(not(target_arch = "wasm32"), windows))]
|
|
fn is_link_like(metadata: &std::fs::Metadata) -> bool {
|
|
use std::os::windows::fs::MetadataExt;
|
|
|
|
// This catches all reparse-point leaves, including junctions, rather than relying solely on
|
|
// FileType::is_symlink().
|
|
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
|
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
|
}
|
|
|
|
#[cfg(all(not(target_arch = "wasm32"), not(windows)))]
|
|
fn is_link_like(metadata: &std::fs::Metadata) -> bool {
|
|
metadata.file_type().is_symlink()
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn sync_recovery_dir(dir: &Path) -> Result<()> {
|
|
validate_recovery_dir(dir)?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
let directory = std::fs::File::open(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
directory.sync_all().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
{
|
|
// Rust does not expose a portable durable-directory handle. Try where the platform
|
|
// supports opening directories, but do not turn a fully persisted file into a reported
|
|
// failure where it does not.
|
|
if let Ok(directory) = std::fs::File::open(dir) {
|
|
let _ = directory.sync_all();
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> {
|
|
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
|
}
|
|
|
|
/// Canonical keys are only for alias comparison; operational paths stay unchanged so run
|
|
/// ownership checks still find the directory registered by the native profile.
|
|
fn directory_key(dir: &Path) -> Result<PathBuf> {
|
|
std::fs::canonicalize(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))
|
|
}
|
|
|
|
/// Include metadata-only entries as occupied, so publication cannot attach a new document to
|
|
/// somebody else's orphan metadata. No filename-derived maximum changes the uid allocator.
|
|
fn occupied_uids(dir: &Path) -> Result<std::collections::HashSet<u64>> {
|
|
let entries = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
let mut ids = std::collections::HashSet::new();
|
|
for entry in entries {
|
|
let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
|
let path = entry.path();
|
|
if matches!(path.extension().and_then(std::ffi::OsStr::to_str), Some("json" | "designcraft"))
|
|
&& let Some(uid) = path.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse().ok())
|
|
{
|
|
ids.insert(uid);
|
|
}
|
|
}
|
|
Ok(ids)
|
|
}
|
|
|
|
fn reserve_uid(state: &mut DocState, occupied: &mut std::collections::HashSet<u64>) -> Result<()> {
|
|
// At most occupied.len() consecutive candidates can collide. The extra attempt must be free.
|
|
for _ in 0..=occupied.len() {
|
|
if occupied.insert(state.uid) {
|
|
return Ok(());
|
|
}
|
|
state.uid = crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
Err(EngineError::Other("no free recovery document uid".into()))
|
|
}
|
|
|
|
/// Recovery entries in `dir`: (uid, metadata).
|
|
pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
|
let mut out = Vec::new();
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() {
|
|
return out;
|
|
}
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
|
for e in rd.flatten() {
|
|
let p = e.path();
|
|
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("designcraft") {
|
|
continue;
|
|
}
|
|
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
|
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
|
let meta = std::fs::read_to_string(p.with_extension("json"))
|
|
.ok()
|
|
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
|
|
.filter(Value::is_object)
|
|
.unwrap_or(json!({}));
|
|
out.push((uid, meta));
|
|
}
|
|
out.sort_by_key(|e| e.0);
|
|
out
|
|
}
|
|
|
|
/// Reopen recovery entries as unsaved documents. Publish each recovered document under its new
|
|
/// uid before removing the original, so another crash before the next timer preserves the edits.
|
|
/// Entries belonging to documents already open in this session's recovery folder are left alone.
|
|
/// Returns the opened documents' indices.
|
|
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
|
open_with_publisher(s, dir, |path, bytes| publish_recovery_file(path, bytes, false))
|
|
}
|
|
|
|
fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result<Vec<usize>> {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
validate_recovery_dir(dir)?;
|
|
let source = access(dir, false)?;
|
|
let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf());
|
|
let created = secure_recovery_dir(&destination)?;
|
|
let target = access(&destination, created)?;
|
|
let source_key = source.key.clone();
|
|
let destination_key = target.key.clone();
|
|
let mut opened = Vec::new();
|
|
// For this run's own folder, first bring in what runs that ended left in theirs.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
take_over(dir);
|
|
let entries = list(dir);
|
|
// Reserve every source entry before opening the first one: publication must never overwrite
|
|
// a source still waiting in this list, even after a fresh process restarts its allocator at 1.
|
|
let mut occupied = occupied_uids(dir)?;
|
|
if source_key != destination_key {
|
|
occupied.extend(occupied_uids(&destination)?);
|
|
}
|
|
occupied.extend(s.docs.iter().map(|d| d.uid));
|
|
for (uid, meta) in entries {
|
|
if s.docs.iter().any(|d| {
|
|
d.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && directory_key(entry_dir).is_ok_and(|key| key == source_key))
|
|
}) {
|
|
continue;
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
validate_recovery_dir(dir)?;
|
|
let (doc, _) = files(dir, uid);
|
|
let Ok(bytes) = std::fs::read(&doc) else { continue };
|
|
let mut d = match crate::cmd::from_bytes(&bytes) {
|
|
Ok(d) => d,
|
|
Err(_) => continue,
|
|
};
|
|
let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
|
|
// Skipped font files are logged; the document opens without them.
|
|
let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0);
|
|
let mut st = DocState::new(d, path);
|
|
reserve_uid(&mut st, &mut occupied)?;
|
|
st.recovery_entries.push((dir.to_path_buf(), uid));
|
|
// Recovered, the entry is this run's: Save and Close of its document may remove it.
|
|
source.record(uid);
|
|
st.fonts = fonts;
|
|
// Unsaved: the copy on disk (if any) is older than what was recovered.
|
|
st.saved_doc = std::sync::Arc::new((*st.doc).clone());
|
|
st.bump_revision();
|
|
let index = s.add_document(st);
|
|
opened.push(index);
|
|
// New uids are published without replacement, and the original is consumed only after
|
|
// both files succeed. Track a published primary even if metadata fails, so Save/Close can
|
|
// clean up every snapshot this document actually owns rather than an unrelated same uid.
|
|
if let Some(state) = s.docs.get_mut(index) {
|
|
let (document, metadata) = files(&destination, state.uid);
|
|
let published = designcraft_format::save(state.doc.as_ref())
|
|
.map_err(|e| EngineError::Other(e.to_string()))
|
|
.and_then(|bytes| publish(&document, &bytes));
|
|
match published {
|
|
Ok(()) => {
|
|
target.record(state.uid);
|
|
state.recovery_entries.push((destination.clone(), state.uid));
|
|
let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()});
|
|
match publish(&metadata, meta.to_string().as_bytes()) {
|
|
Ok(()) => {
|
|
discard(dir, uid);
|
|
state.recovery_entries = vec![(destination.clone(), state.uid)];
|
|
}
|
|
Err(e) => log::warn!("recovery entry {uid} kept: {e}"),
|
|
}
|
|
}
|
|
Err(e) => log::warn!("recovery entry {uid} kept: {e}"),
|
|
}
|
|
}
|
|
}
|
|
Ok(opened)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
#[cfg(test)]
|
|
mod file_tests {
|
|
use super::*;
|
|
|
|
/// `file.recovery.list` adds `uid` to each entry's metadata: metadata that wasn't an object
|
|
/// (a corrupt `.json` beside the recovery file) panicked on `m["uid"] = …`.
|
|
#[test]
|
|
fn corrupt_recovery_metadata_is_ignored() {
|
|
let dir = std::env::temp_dir().join(format!("dc-recovery-corrupt-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join("7.designcraft"), b"not a document").unwrap();
|
|
std::fs::write(dir.join("7.json"), b"[1, 2]").unwrap();
|
|
let mut s = Session::new();
|
|
s.recovery_dir = Some(dir.clone());
|
|
let r = s.execute("file.recovery.list", &json!({})).unwrap();
|
|
assert_eq!(r[0]["uid"], json!(7));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn revert_and_save_a_copy() {
|
|
let dir = std::env::temp_dir().join(format!("dc-revert-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let (a, copy) = (dir.join("a.designcraft"), dir.join("copy.designcraft"));
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({})).unwrap();
|
|
assert!(s.execute("file.revert", &json!({})).is_err(), "never saved");
|
|
s.execute("file.saveAs", &json!({"path": a.to_string_lossy()})).unwrap();
|
|
s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
|
|
// A copy keeps the document unsaved and on its own file.
|
|
s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap();
|
|
assert!(copy.exists());
|
|
assert!(s.doc().unwrap().is_dirty());
|
|
assert_eq!(s.doc().unwrap().path.as_deref(), Some(a.to_string_lossy().as_ref()));
|
|
// Revert drops the frame.
|
|
s.execute("file.revert", &json!({})).unwrap();
|
|
assert!(s.doc().unwrap().doc.spreads[0].items.is_empty());
|
|
assert!(!s.doc().unwrap().is_dirty());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|