Files
wordcraft/crates/engine/src/io/safe_save.rs
T
Jayesh Prajapati fdc325fa89 #3003: Save to a NAS share mounted by GVFS keeps working when it refuses a mode change
A replaced document's temporary file takes the original's Unix mode before
it replaces it. A GVFS SMB mount (smb:// in the Linux file manager) refuses
every mode change with ENOTSUP, so every save of an existing file there
failed and the close prompt's Save never closed. A folder that keeps no
Unix modes has no mode to keep, so the save goes on; any other failure to
set the mode still stops it and keeps the original.
2026-10-20 19:24:45 +05:30

452 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sibling temporary files owned by one save. The destination changes only after preparation
//! succeeds; concurrent saves have separate files and the last successful replacement wins.
//!
//! When the folder refuses the temporary file (macOS privacy protection, #2138, or a read-only
//! folder), an existing destination is never written in place: it is left exactly as it was and
//! the error says to use Save As to another folder or to give WordCraft access to this one. Only
//! a destination that doesn't exist yet, so has no previous version to lose, is created directly;
//! a failed direct write removes it again.
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
let permissions = match fs::metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
Ok(_) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
// A new document gets the usual mode (0666 less the umask); a replaced one keeps its own,
// and its temporary file stays private until that mode is applied.
let private = permissions.is_some();
let written = replace_with(path, private, |file| {
file.write_all(bytes)?;
if let Some(permissions) = permissions {
keep_permissions(file.set_permissions(permissions))?;
}
// Synchronize the prepared file before replacing the destination. Directory durability
// and network-filesystem/power-loss guarantees are outside this operation's contract.
file.sync_all()
});
written.map_err(explain)
}
/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS
/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003).
/// There is no mode to keep there, so the save goes on; any other failure still stops it.
fn keep_permissions(set: io::Result<()>) -> io::Result<()> {
match set {
Err(e) if not_supported(&e) => Ok(()),
set => set,
}
}
/// ENOTSUP and EOPNOTSUPP, which std doesn't count as `Unsupported` (only ENOSYS), plus that kind.
fn not_supported(e: &io::Error) -> bool {
const CODES: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) {
&[95]
} else if cfg!(any(target_os = "macos", target_os = "ios")) {
&[45, 102]
} else if cfg!(target_os = "openbsd") {
&[45, 91]
} else if cfg!(target_os = "netbsd") {
&[45, 86]
} else if cfg!(any(target_os = "freebsd", target_os = "dragonfly")) {
&[45]
} else {
&[]
};
e.kind() == io::ErrorKind::Unsupported || e.raw_os_error().is_some_and(|code| CODES.contains(&code))
}
fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let mut temp = match Temporary::create(path, private) {
Ok(temp) => temp,
// macOS only lets an app write the file picked in the Save panel inside a protected
// folder (Desktop, Documents, Downloads, iCloud Drive), not a sibling temporary file
// (#2138); a read-only folder refuses it too.
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return create_directly(path, e, prepare),
Err(e) => return Err(e),
};
prepare(temp.file()?)?;
temp.commit(path)
}
/// No temporary file may be created beside `path` (`refused` says why). A file that doesn't exist
/// yet is created directly (there is no previous version to lose) and removed again if the save
/// fails. Anything already at `path` is left untouched: overwriting it in place could destroy the
/// only copy, so the error says how to save safely instead.
fn create_directly(path: &Path, refused: io::Error, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
// `create_new` never opens an existing file or follows a link, so it can't touch one.
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
return Err(guided(
refused,
"This folder doesn't let WordCraft create the temporary file a safe save needs, so the existing file was left unchanged.",
));
}
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return Err(guided(e, "WordCraft can't create files in this folder.")),
Err(e) => return Err(e),
};
let Err(failed) = prepare(&mut file) else {
return Ok(());
};
drop(file);
match fs::remove_file(path) {
Ok(()) => Err(failed),
Err(e) => Err(io::Error::new(failed.kind(), format!("{failed}; removing the incomplete new file also failed ({e})"))),
}
}
/// A save refused for want of folder access, already worded with what to do about it.
#[derive(Debug)]
struct Guided(String);
impl std::fmt::Display for Guided {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Guided {}
/// `e`, then `why`, then how to save anyway: Save As to another folder, or access to this one.
fn guided(e: io::Error, why: &str) -> io::Error {
let access = if cfg!(target_os = "macos") {
"allow WordCraft to use this folder in System Settings › Privacy & Security › Files and Folders"
} else {
"get write access to this folder"
};
io::Error::new(e.kind(), Guided(format!("{e}. {why} Use Save As to save the document in another folder, or {access}, then save again.")))
}
/// A permission error on macOS usually means its privacy protection keeps WordCraft out of the
/// folder; say where to allow it (errors from [`guided`] already do).
fn explain(e: io::Error) -> io::Error {
let worded = e.get_ref().is_some_and(|inner| inner.is::<Guided>());
if cfg!(target_os = "macos") && e.kind() == io::ErrorKind::PermissionDenied && !worded {
return io::Error::new(
e.kind(),
format!(
"{e}. macOS may be blocking WordCraft from this folder: allow it in System Settings › Privacy & Security › Files and Folders, or save somewhere else"
),
);
}
e
}
struct Temporary {
path: PathBuf,
file: Option<File>,
committed: bool,
}
impl Temporary {
/// `private`: create the file readable by its owner only (0600) rather than with the default
/// mode (0666 less the umask).
fn create(destination: &Path, private: bool) -> io::Result<Self> {
static NEXT: AtomicU64 = AtomicU64::new(0);
if destination.file_name().is_none() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "save destination needs a file name"));
}
// A short name also works beside a destination already at the filename-length limit.
for _ in 0..128 {
let id = NEXT.fetch_add(1, Ordering::Relaxed);
let path = destination.with_file_name(format!(".wordcraft-save-{}-{id}.tmp", std::process::id()));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
if private {
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
#[cfg(not(unix))]
let _ = private;
match options.open(&path) {
Ok(file) => return Ok(Self { path, file: Some(file), committed: false }),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, "could not create a save temporary file"))
}
fn file(&mut self) -> io::Result<&mut File> {
self.file.as_mut().ok_or_else(|| io::Error::other("save temporary file is closed"))
}
fn commit(mut self, destination: &Path) -> io::Result<()> {
// Close before replacement, including on Windows.
drop(self.file.take());
fs::rename(&self.path, destination)?;
self.committed = true;
Ok(())
}
}
impl Drop for Temporary {
fn drop(&mut self) {
drop(self.file.take());
if !self.committed {
// If filesystem permissions change, cleanup can fail too. Never remove a neighbour
// or the destination to compensate for a failed save.
let _ = fs::remove_file(&self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let path =
std::env::temp_dir().join(format!("wordcraft-save-owner-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
fs::create_dir(&path).unwrap();
Self(path)
}
fn target(&self) -> PathBuf {
self.0.join("original.docx")
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn partial_and_complete_preparation_failures_leave_original_and_no_owned_temp() {
for partial in [true, false] {
let d = Scratch::new();
let path = d.target();
fs::write(&path, b"Original bytes.").unwrap();
let err = replace_with(&path, true, |file| {
file.write_all(if partial { b"Partial" } else { b"Complete replacement bytes." })?;
Err(io::Error::other("injected preparation failure"))
})
.unwrap_err();
assert!(err.to_string().contains("injected"));
assert_eq!(fs::read(&path).unwrap(), b"Original bytes.");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
}
}
#[test]
fn overlapping_prepared_saves_keep_separate_bytes_and_commit_complete_files() {
let d = Scratch::new();
let path = d.target();
fs::write(&path, b"Original bytes.").unwrap();
let mut first = Temporary::create(&path, true).unwrap();
let mut second = Temporary::create(&path, true).unwrap();
assert_ne!(first.path, second.path);
first.file().unwrap().write_all(b"First complete save.").unwrap();
second.file().unwrap().write_all(b"Second complete save.").unwrap();
first.file().unwrap().sync_all().unwrap();
second.file().unwrap().sync_all().unwrap();
assert_eq!(fs::read(&path).unwrap(), b"Original bytes.");
first.commit(&path).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"First complete save.");
second.commit(&path).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"Second complete save.");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
}
/// Exports (`write_file`) replace the destination instead of truncating it in place: another
/// hard link to the old file keeps the old bytes, and no temporary file is left behind.
#[test]
fn write_file_replaces_the_destination_instead_of_truncating_it() {
let d = Scratch::new();
let path = d.target();
let alias = d.0.join("alias.docx");
fs::write(&path, b"Original bytes.").unwrap();
fs::hard_link(&path, &alias).unwrap();
crate::io::write_file(&path.to_string_lossy(), b"Exported bytes.").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"Exported bytes.");
assert_eq!(fs::read(&alias).unwrap(), b"Original bytes.");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2);
}
/// #3003: a folder that refuses mode changes (a GVFS-mounted NAS share) still takes the
/// save; a refused mode change for any other reason still stops it and keeps the original.
#[test]
fn a_folder_without_unix_modes_still_takes_the_save() {
let d = Scratch::new();
let path = d.target();
fs::write(&path, b"Original bytes.").unwrap();
replace_with(&path, true, |file| {
file.write_all(b"Saved on the share.")?;
keep_permissions(Err(io::Error::from(io::ErrorKind::Unsupported)))?;
file.sync_all()
})
.unwrap();
assert_eq!(fs::read(&path).unwrap(), b"Saved on the share.");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
let err = replace_with(&path, true, |file| {
file.write_all(b"Refused.")?;
keep_permissions(Err(io::Error::from(io::ErrorKind::PermissionDenied)))
})
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(fs::read(&path).unwrap(), b"Saved on the share.");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
}
/// GVFS reports a refused mode change as ENOTSUP, which std leaves uncategorized; other OS
/// errors still stop the save.
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn enotsup_from_the_os_is_an_unsupported_mode_change() {
let enotsup = if cfg!(target_os = "linux") { 95 } else { 45 };
assert!(keep_permissions(Err(io::Error::from_raw_os_error(enotsup))).is_ok());
let eacces = 13;
assert!(keep_permissions(Err(io::Error::from_raw_os_error(eacces))).is_err());
}
/// Makes `dir` read-only while `f` runs. `None` when running as root, where the folder isn't
/// read-only to us.
#[cfg(unix)]
fn in_read_only<T>(dir: &Path, f: impl FnOnce() -> T) -> Option<T> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o555)).unwrap();
let probe = fs::write(dir.join("probe"), b"");
let out = f();
fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap();
probe.is_err().then_some(out)
}
/// #2138 review: where no sibling temporary file may be created (macOS privacy protection,
/// or a read-only folder), an existing document is left byte for byte as it was, and the
/// error says to use Save As or get access to the folder.
#[cfg(unix)]
#[test]
fn an_existing_file_in_a_folder_refusing_the_temporary_file_is_left_untouched() {
let d = Scratch::new();
let path = d.target();
fs::write(&path, b"Original bytes.").unwrap();
let Some(saved) = in_read_only(&d.0, || write(&path, b"Saved bytes.")) else { return };
let err = saved.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
let message = err.to_string();
assert!(message.contains("left unchanged") && message.contains("Save As") && message.contains("this folder"), "{message}");
assert_eq!(message.matches("Privacy & Security").count(), usize::from(cfg!(target_os = "macos")), "{message}");
assert_eq!(fs::read(&path).unwrap(), b"Original bytes.");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
}
/// FredApps' #2138 regression: a save that would fail partway through still leaves the
/// previous document byte-identical when the folder refuses the temporary file.
#[cfg(unix)]
#[test]
fn a_failed_save_in_a_folder_refusing_the_temporary_file_keeps_the_original() {
let d = Scratch::new();
let path = d.target();
fs::write(&path, b"Original bytes.").unwrap();
let Some(result) = in_read_only(&d.0, || {
replace_with(&path, true, |file| {
file.write_all(b"Partial")?;
Err(io::Error::other("injected partial write failure"))
})
}) else {
return;
};
assert!(result.is_err());
assert_eq!(fs::read(&path).unwrap(), b"Original bytes.", "failed save must preserve the previous document");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
}
/// A folder that refuses new files altogether refuses a new document too, with the same
/// guidance, and leaves nothing behind.
#[cfg(unix)]
#[test]
fn a_new_file_in_a_read_only_folder_is_refused_with_guidance() {
let d = Scratch::new();
let path = d.target();
let Some(saved) = in_read_only(&d.0, || write(&path, b"New document.")) else { return };
let message = saved.unwrap_err().to_string();
assert!(message.contains("Save As"), "{message}");
assert!(!path.exists());
}
/// When only the temporary file is refused (macOS lets the app write the file picked in the
/// Save panel), a file that doesn't exist yet is created directly; nothing is replaced.
#[test]
fn a_new_file_is_created_directly_when_only_the_temporary_file_is_refused() {
let d = Scratch::new();
let path = d.target();
let refused = io::Error::from(io::ErrorKind::PermissionDenied);
create_directly(&path, refused, |file| file.write_all(b"New document.")).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"New document.");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
}
#[test]
fn a_failed_direct_save_of_a_new_file_leaves_nothing() {
let d = Scratch::new();
let path = d.target();
let refused = io::Error::from(io::ErrorKind::PermissionDenied);
let err = create_directly(&path, refused, |file| {
file.write_all(b"Partial")?;
Err(io::Error::other("injected"))
})
.unwrap_err();
assert!(err.to_string().contains("injected"));
assert!(!path.exists());
}
/// The direct path never opens an existing file and never runs the save: the original is
/// untouched and the error carries the Save As guidance.
#[test]
fn an_existing_file_is_never_written_directly() {
let d = Scratch::new();
let path = d.target();
fs::write(&path, b"Original bytes.").unwrap();
let refused = io::Error::from(io::ErrorKind::PermissionDenied);
let mut ran = false;
let err = create_directly(&path, refused, |file| {
ran = true;
file.write_all(b"New")
})
.unwrap_err();
assert!(!ran);
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
assert!(err.to_string().contains("Save As") && err.to_string().contains("left unchanged"), "{err}");
assert_eq!(fs::read(&path).unwrap(), b"Original bytes.");
}
#[cfg(unix)]
#[test]
fn new_temporary_files_are_private_until_prepared() {
use std::os::unix::fs::PermissionsExt;
let d = Scratch::new();
let temp = Temporary::create(&d.target(), true).unwrap();
assert_eq!(fs::metadata(&temp.path).unwrap().permissions().mode() & 0o777, 0o600);
}
/// A new document gets the default mode, as `fs::write` would give it — not 0600; a replaced
/// document keeps its own mode.
#[cfg(unix)]
#[test]
fn new_documents_get_the_default_mode_and_replaced_ones_keep_theirs() {
use std::os::unix::fs::PermissionsExt;
let d = Scratch::new();
let reference = d.0.join("reference.docx");
fs::write(&reference, b"x").unwrap();
let default_mode = fs::metadata(&reference).unwrap().permissions().mode() & 0o777;
let path = d.target();
write(&path, b"New document.").unwrap();
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, default_mode);
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
write(&path, b"Saved again.").unwrap();
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640);
assert_eq!(fs::read(&path).unwrap(), b"Saved again.");
}
}