mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-21 11:22:57 -05:00
Saving writes a temporary file and renames it over the destination. A rename only needs the folder to be writable, so on Linux and macOS a read-only (chmod 444) file was silently replaced, while on Windows the rename failed with a vague "locked or read-only" access error. atomic_write now asks the AtomicFs whether the existing destination is read-only (new `is_readonly`, false by default for other implementors) and refuses before anything is written: "cannot save <file>: the file is read-only; the original file was not changed". New files and writable files save as before. Photoshop refuses the same way; the user saves a copy with Save As. Tests: a fake AtomicFs reporting read-only gets the error with nothing written or renamed; a real file marked read-only keeps its bytes and mode and gets the clear message on every platform. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c9899e9c29
commit
589941a536
2 files changed
+91
No files matched your search
@@ -13,6 +13,10 @@
|
||||
//! if it does anyway, the save fails with a clear error. It never falls back to writing the
|
||||
//! destination in place.
|
||||
//! - **Unwritable folder:** the save fails with a clear error instead of writing in place.
|
||||
//! - **Read-only file:** the save is refused before anything is written (#6600). A rename only
|
||||
//! needs the folder to be writable, so without this check Linux and macOS would silently replace
|
||||
//! a `chmod 444` file while Windows refuses; now every platform refuses, as Photoshop does, and
|
||||
//! the user saves a copy with Save As.
|
||||
//! - **Network shares (macOS):** `sync_all` asks for a full flush to the disk's platters
|
||||
//! (`F_FULLFSYNC`), which SMB shares and some virtual file systems refuse while a plain `fsync`
|
||||
//! works. [`sync_file`] falls back to the plain one there (#1336).
|
||||
@@ -30,6 +34,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
/// The file-system steps [`atomic_write_with`] performs. [`RealFs`] is the real thing; tests
|
||||
/// wrap it to inject failures.
|
||||
pub trait AtomicFs {
|
||||
/// Whether the existing file at `path` is marked read-only (a missing file is not). A
|
||||
/// read-only destination is never replaced (#6600). By default nothing is read-only.
|
||||
fn is_readonly(&self, path: &Path) -> bool {
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
/// Create `tmp` (it must not exist), write all of `bytes` and flush it ([`sync_file`]).
|
||||
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()>;
|
||||
/// [`write_new`](Self::write_new), reporting the fraction of `bytes` written so far to
|
||||
@@ -91,6 +101,9 @@ impl AtomicFs for RealFs {
|
||||
let perms = std::fs::metadata(from)?.permissions();
|
||||
std::fs::set_permissions(to, perms)
|
||||
}
|
||||
fn is_readonly(&self, path: &Path) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly())
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
|
||||
@@ -169,6 +182,9 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: &
|
||||
_ => PathBuf::from("."),
|
||||
};
|
||||
std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?;
|
||||
if fs.is_readonly(&target) {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, format!("cannot save {}: the file is read-only; {UNCHANGED}", path.display())));
|
||||
}
|
||||
|
||||
// Create a fresh temporary file next to the target (same directory, so the rename never
|
||||
// crosses a device). The name is unique per process and call; retry a few on collisions.
|
||||
|
||||
@@ -176,6 +176,81 @@ fn lock_that_outlasts_the_retries_keeps_original() {
|
||||
assert_no_temp(&d.0);
|
||||
}
|
||||
|
||||
/// A file system that reports the target as read-only and records every write and rename.
|
||||
#[derive(Default)]
|
||||
struct ReadOnlyTarget {
|
||||
writes: Cell<u32>,
|
||||
renames: Cell<u32>,
|
||||
}
|
||||
|
||||
impl AtomicFs for ReadOnlyTarget {
|
||||
fn is_readonly(&self, _path: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
self.writes.set(self.writes.get() + 1);
|
||||
RealFs.write_new(tmp, bytes)
|
||||
}
|
||||
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
|
||||
self.renames.set(self.renames.get() + 1);
|
||||
RealFs.rename(from, to)
|
||||
}
|
||||
fn remove(&self, path: &Path) -> io::Result<()> {
|
||||
RealFs.remove(path)
|
||||
}
|
||||
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
|
||||
RealFs.sync_dir(dir)
|
||||
}
|
||||
fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> {
|
||||
RealFs.copy_permissions(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
/// #6600: a read-only destination is refused before anything is written, with an error that says
|
||||
/// so, instead of being replaced by the rename (which only needs the folder to be writable).
|
||||
#[test]
|
||||
fn read_only_target_is_refused_before_anything_is_written() {
|
||||
let d = TempDir::new("rofake");
|
||||
let p = d.0.join("a.psd");
|
||||
std::fs::write(&p, ORIGINAL).unwrap();
|
||||
let fs = ReadOnlyTarget::default();
|
||||
let e = atomic_write_with(&fs, NO_RETRY, &p, b"new").unwrap_err();
|
||||
assert_eq!(e.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(e.to_string().contains("a.psd") && e.to_string().contains("read-only") && e.to_string().contains("not changed"), "{e}");
|
||||
assert_eq!((fs.writes.get(), fs.renames.get()), (0, 0), "nothing is written or renamed");
|
||||
assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL);
|
||||
assert_no_temp(&d.0);
|
||||
}
|
||||
|
||||
/// #6600 on the real file system: a file marked read-only (`chmod 444`, or the Windows read-only
|
||||
/// attribute) keeps its bytes and the save says why; a writable one still saves.
|
||||
#[test]
|
||||
fn read_only_file_on_disk_is_never_replaced() {
|
||||
let d = TempDir::new("rofile");
|
||||
let p = d.0.join("a.psd");
|
||||
std::fs::write(&p, ORIGINAL).unwrap();
|
||||
let mut perms = std::fs::metadata(&p).unwrap().permissions();
|
||||
perms.set_readonly(true);
|
||||
std::fs::set_permissions(&p, perms.clone()).unwrap();
|
||||
let r = atomic_write(&p, b"new");
|
||||
let unchanged = std::fs::read(&p).unwrap();
|
||||
let still_readonly = std::fs::metadata(&p).unwrap().permissions().readonly();
|
||||
// Make it writable again so the folder can be removed (Windows refuses to delete it otherwise).
|
||||
#[allow(clippy::permissions_set_readonly_false)]
|
||||
perms.set_readonly(false);
|
||||
std::fs::set_permissions(&p, perms).unwrap();
|
||||
let e = r.unwrap_err();
|
||||
assert_eq!(e.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(e.to_string().contains("the file is read-only;"), "a clear reason, not a failed rename: {e}");
|
||||
assert_eq!(unchanged, ORIGINAL);
|
||||
assert!(still_readonly);
|
||||
assert_no_temp(&d.0);
|
||||
// New files and writable files are unaffected.
|
||||
atomic_write(&p, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&p).unwrap(), b"new");
|
||||
atomic_write(&d.0.join("fresh.psd"), b"x").unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_only_folder_errors_clearly_and_never_writes_in_place() {
|
||||
|
||||
Reference in new issue
Block a user