collective

Browse / github.com/storytold/craft-libs / patch

atomic: skip redundant chmod for matching replacement modes

atomic: skip redundant chmod for matching replacement modes Saving an existing document on a fixed-mode GVfs SMB mount can fail with `Operation not supported` (errno 95). The staging file already reports the original's Unix mode, but the writer still attempts to set it; the filesystem rejects that redundant operation before replacement. Compare the staging file's current mode with the original's mode after the streaming callback and skip `set_permissions` only when they match. Required mode changes still propagate errors and retain the original. Same-directory staging, private creation requests, flushing, atomic rename, and cleanup remain in place. No

Status
open
Score
0.00
Adopters
0 other operators
Author
bobbyjnichols (imported from pull request #180, open)
Base
ae2f796b673f29cf3161f36eb6a8933f0a3fe028
Size
+120 −15 in 2 files
Review
pending
Shared
2026-10-28
Id
a59cbdaf25cb05c81bd75e90

How it went

No other operator has reported using it yet.

Diff

raw · crates/atomic/README.md crates/atomic/src/lib.rs

diff --git a/crates/atomic/README.md b/crates/atomic/README.md--- a/crates/atomic/README.md+++ b/crates/atomic/README.md@@ -97,8 +97,11 @@ sensitive payloads; changing permissions inside a callback is too late to guaran other process opened the stage. Private stages request `0600`; actual enforcement depends on the filesystem's permission support. Neither policy adds Windows ACL guarantees. -Replacements still stage privately and restore the existing Unix mode after writing. A new-file keeps any mode selected by its callback. In the normal exclusive-copy fallback, a changed+Replacements still stage privately and restore the existing Unix mode after writing. When the+stage already has that mode, no `chmod` is attempted. This lets replacements work on fixed-mode+GVfs/SMB mounts and document portals that reject permission changes even when the reported mode+already matches. The comparison happens after the callback, so its mode changes are still+restored. A new file keeps any mode selected by its callback. In the normal exclusive-copy fallback, a changed mode is applied before copying bytes; explicit private copies stay private during copying and apply callback-selected modes afterward. A required mode change that fails is reported, not silently ignored. Existing-file replacements and deliberate callback changes can thereforediff --git a/crates/atomic/src/lib.rs b/crates/atomic/src/lib.rs--- a/crates/atomic/src/lib.rs+++ b/crates/atomic/src/lib.rs@@ -31,7 +31,8 @@ //! ### Permissions //! New documents and exports use normal OS creation permissions, including while staging. //! Use [`WriteOptions`] with [`NewFilePermissions::Private`] for settings or sensitive payloads.-//! Existing destinations always stage privately and retain their original Unix mode.+//! Existing destinations always stage privately and retain their original Unix mode. Restoring+//! an already-matching mode needs no chmod, including on fixed-mode network filesystems. //! //! ### Platform Characteristics //! - **Windows**: Antivirus scanners, search indexers, and backup utilities briefly hold files@@ -342,8 +343,9 @@ fn split(target: &Path, path: &Path) -> io::Result<(PathBuf, String)> { }  /// Create a temporary file in `dir`, fill it with `fill`, give it the permissions of the file it-/// replaces (`replaced`, Unix) and flush it. A leftover file with the same name is skipped. When-/// any step fails, the temporary file is removed.+/// replaces (`replaced`, Unix) and flush it. Permission restoration is skipped when the mode+/// already matches after `fill`. A leftover file with the same name is skipped. When any step+/// fails, the temporary file is removed. fn stage(     fs: &dyn AtomicFs,     dir: &Path,@@ -368,7 +370,24 @@ fn stage(         };         let written = fill(&mut file)             .map_err(|e| write_error(e, path, dir, existed))-            .and_then(|()| perms.map_or(Ok(()), |p| fs.set_permissions(&file, p).map_err(|e| permissions_error(e, path))))+            .and_then(|()| {+                perms+                    .map_or(Ok(()), |p| {+                        #[cfg(unix)]+                        {+                            use std::os::unix::fs::PermissionsExt;+                            // GVfs/SMB and document portals can expose a fixed mode but reject+                            // chmod, even when the requested mode is already in effect. Check+                            // after fill: a streaming callback may have changed the stage's mode.+                            // Compare permission and special bits, not file-type bits.+                            if (file.metadata()?.permissions().mode() & 0o7777) == (p.mode() & 0o7777) {+                                return Ok(());+                            }+                        }+                        fs.set_permissions(&file, p)+                    })+                    .map_err(|e| permissions_error(e, path))+            })             .and_then(|()| fs.sync(&file).map_err(|e| write_error(e, path, dir, existed)));         drop(file);         if written.is_err() {@@ -645,6 +664,10 @@ mod tests {         renames: Cell<u32>,         no_hard_links: bool,         fail_permissions: bool,+        permission_error: Option<io::ErrorKind>,+        /// Simulate a network filesystem exposing the same fixed mode on every new file.+        #[cfg(unix)]+        fixed_mode: Option<u32>,         fail_sync_dir: bool,         fail_normal_create: bool,         normal_collisions: Cell<u32>,@@ -653,6 +676,8 @@ mod tests {     }      impl AtomicFs for Faulty {+        /// Create a real stage with injected failures or a simulated fixed network mode,+        /// recording the creation policy and the Unix mode exposed by the filesystem.         fn create_new(&self, path: &Path, permissions: NewFilePermissions) -> io::Result<File> {             let normal = permissions == NewFilePermissions::PlatformDefault;             self.events.borrow_mut().push(if normal { "create_new_normal" } else { "create_new" });@@ -667,6 +692,9 @@ mod tests {             #[cfg(unix)]             {                 use std::os::unix::fs::PermissionsExt;+                if let Some(mode) = self.fixed_mode {+                    file.set_permissions(std::fs::Permissions::from_mode(mode))?;+                }                 self.created_modes.borrow_mut().push(file.metadata()?.permissions().mode() & 0o777);             }             Ok(file)@@ -712,10 +740,15 @@ mod tests {             }             RealFs.sync_dir(dir)         }+        /// Record permission restoration and payload length, then inject a configured error+        /// or delegate the mode change to the real filesystem.         fn set_permissions(&self, file: &File, perms: std::fs::Permissions) -> io::Result<()> {             self.events.borrow_mut().push("set_permissions");             #[cfg(unix)]             self.permission_lengths.borrow_mut().push(file.metadata()?.len());+            if let Some(kind) = self.permission_error {+                return Err(io::Error::new(kind, "injected permission failure"));+            }             if self.fail_permissions {                 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: operation not permitted"));             }@@ -917,18 +950,87 @@ mod tests {         Ok(())     } +    /// Matching fixed modes permit byte and streaming replacements under both creation+    /// policies without calling a permission operation that the mount rejects.+    #[cfg(unix)]+    #[test]+    fn unchanged_replacement_modes_need_no_chmod() -> io::Result<()> {+        use std::os::unix::fs::PermissionsExt;+        let d = TempDir::new("fixed-replacement-mode");+        // GVfs SMB reports 0700 regardless of the requested creation mode; also cover a+        // private stage whose normal 0600 mode already matches the original.+        for mode in [0o600, 0o700] {+            for policy in [NewFilePermissions::Private, NewFilePermissions::PlatformDefault] {+                for streaming in [false, true] {+                    let p = d.0.join(format!("{mode:o}-{policy:?}-{streaming}"));+                    std::fs::write(&p, ORIGINAL)?;+                    std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode))?;+                    let fs = Faulty { fixed_mode: Some(mode), permission_error: Some(io::ErrorKind::Unsupported), ..Default::default() };+                    let options = WriteOptions::default().with_new_file_permissions(policy);+                    if streaming {+                        options.atomic_write_with_custom(&fs, NO_RETRY, &p, |f| f.write_all(b"replacement"))?;+                    } else {+                        options.atomic_write_custom(&fs, NO_RETRY, &p, b"replacement")?;+                    }+                    assert_eq!(std::fs::read(&p)?, b"replacement");+                    assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o7777, mode);+                    assert!(!fs.events.borrow().contains(&"set_permissions"));+                    assert_eq!(fs.renames.get(), 1);+                    assert_no_temp(&d.0);+                }+            }+        }+        Ok(())+    }++    /// A required mode change that fails must preserve the original bytes and mode,+    /// report the error, and remove the stage without publishing it.     #[cfg(unix)]     #[test]-    fn failing_to_keep_permissions_fails_the_save() {+    fn failing_to_keep_permissions_fails_the_save() -> io::Result<()> {+        use std::os::unix::fs::PermissionsExt;         let d = TempDir::new("perms");-        let p = d.0.join("doc.craft");-        std::fs::write(&p, ORIGINAL).ok();-        let fs = Faulty { fail_permissions: true, ..Default::default() };-        let msg = atomic_write_custom(&fs, NO_RETRY, &p, b"new").err().map(|e| e.to_string()).unwrap_or_default();-        assert!(msg.contains("permissions"), "{msg}");-        assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));-        assert_eq!(fs.renames.get(), 0);-        assert_no_temp(&d.0);+        for kind in [io::ErrorKind::Unsupported, io::ErrorKind::PermissionDenied, io::ErrorKind::Other] {+            let p = d.0.join(format!("{kind:?}.craft"));+            std::fs::write(&p, ORIGINAL)?;+            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o640))?;+            let fs = Faulty { permission_error: Some(kind), ..Default::default() };+            let error =+                atomic_write_custom(&fs, NO_RETRY, &p, b"new").err().ok_or_else(|| io::Error::other("a required mode change must succeed"))?;+            assert_eq!(error.kind(), kind);+            assert!(error.to_string().contains("permissions"), "{error}");+            assert_eq!(std::fs::read(&p)?, ORIGINAL);+            assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o7777, 0o640);+            assert_eq!(fs.renames.get(), 0);+            assert_no_temp(&d.0);+        }+        Ok(())+    }++    /// A streaming callback that changes permissions or setuid/setgid/sticky bits must not+    /// bypass restoration of the original mode, even when the modes matched at creation.+    #[cfg(unix)]+    #[test]+    fn replacement_checks_mode_after_streaming_callback() -> io::Result<()> {+        use std::os::unix::fs::PermissionsExt;+        let d = TempDir::new("callback-mode-change");+        for callback_mode in [0o640, 0o1600, 0o2600, 0o4600] {+            let p = d.0.join(format!("doc-{callback_mode:o}.craft"));+            std::fs::write(&p, ORIGINAL)?;+            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600))?;+            let fs = Faulty::default();+            WriteOptions::default().atomic_write_with_custom(&fs, NO_RETRY, &p, |f| {+                f.write_all(b"replacement")?;+                f.set_permissions(std::fs::Permissions::from_mode(callback_mode))?;+                assert_eq!(f.metadata()?.permissions().mode() & 0o7777, callback_mode);+                Ok(())+            })?;+            assert_eq!(std::fs::read(&p)?, b"replacement");+            assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o7777, 0o600);+            assert!(fs.events.borrow().contains(&"set_permissions"));+            assert_no_temp(&d.0);+        }+        Ok(())     }      #[cfg(unix)]