Merge pull request #43 from storytold/codex/atomic-normal-permissions-20261013

craft-atomic: default to normal file permissions with explicit private writes
This commit is contained in:
skylar taylor-barrick authored and GitHub committed 2026-10-12 23:38:37 -06:00
commit e9546e191d
7 files changed
+638 -48

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
name: Atomic file writes
on:
workflow_dispatch:
push:
branches: [main]
paths: ['crates/atomic/**', 'crates/settings/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/atomic.yml']
pull_request:
paths: ['crates/atomic/**', 'crates/settings/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/atomic.yml']
permissions:
contents: read
jobs:
atomic:
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Install ACL tools
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y acl
- run: cargo fmt --check
- run: cargo clippy -p craft-atomic -p craft-settings --all-targets --locked -- -D warnings
- run: cargo test -p craft-atomic -p craft-settings --locked -- --nocapture
- name: Check inherited Linux ACLs
if: runner.os == 'Linux'
run: cargo test -p craft-atomic --locked -- --ignored --nocapture
+64 -7
View File
@@ -12,7 +12,7 @@ Extracted from the storage engine in **PhotoCraft** (refs #2, #14), it guarantee
- **Directory Flush (Unix)**: After rename, syncing the parent directory table (`sync_dir`) is attempted on a best-effort basis. If directory sync fails, the error is ignored because the rename has already committed and the destination has already been replaced.
- **Hardware Durability Limits**: The sequence `sync_file < rename < sync_dir` proves ordering and kernel dispatch. Software tests do not prove survival across physical power interruption or unbuffered drive controller write caches.
3. **Transient Lock Backoff (Windows)**: Antivirus scanners and search indexers hold newly written files momentarily. `RenameRetry` automatically retries renames encountering `PermissionDenied`, sharing violations (32), or lock violations (33) with exponential backoff capped at 200ms.
4. **Permission & Symlink Invariants**: On Unix, staging files are created with mode `0600` (restricted by the process umask), so the payload starts private even in a shared folder. Existing destination permissions are restored after the streaming callback. New files remain private by default; a callback may select their final permissions, which are kept both with hard links and the exclusive-copy fallback. Symlink chains are resolved with a hop limit, writing through the symlink to the ultimate target without replacing the link itself.
4. **Permission & Symlink Invariants**: On Unix, new documents and exports use normal creation permissions (`0666`, subject to umask/default ACL), including their staging files. Private data must explicitly select `NewFilePermissions::Private` to create staging files with mode `0600`. Replacements always stage privately and restore the existing destination mode after the streaming callback. New files keep callback-selected modes with both hard links and the exclusive-copy fallback. Symlink chains are resolved with a hop limit, writing through the symlink to the ultimate target without replacing the link itself.
5. **Safe Exclusive Creation (`write_new`)**: Uses hard links where supported, or exclusive create (`O_EXCL`), guaranteeing that an existing file is never replaced under race conditions.
6. **Fault-Injection Testability**: The `AtomicFs` trait enables unit and integration test suites to simulate disk-full, locked files, and permission failures without physical disk I/O.
@@ -63,18 +63,75 @@ let path = Path::new("export.png");
write_new(path, b"png bytes...").expect("exclusive write");
```
### Fault Injection & Testing
### Document Permissions and Private Data
The free functions and `WriteOptions::default()` use `NewFilePermissions::PlatformDefault`.
On Unix, a new document/export stage is created directly with mode `0666`; the OS applies the
process umask, setgid directory group and default ACL. This matches ordinary file creation
(for example, `0644` under umask `022`, or `0664` under `002`). No process-global umask changes,
empty permission probe, or `chmod` is needed for an unchanged new-file mode. The same policy
applies when exclusive creation falls back to copying on volumes without hard links.
This is a deliberate change from the earlier untagged revision's private default. Settings,
credentials and recovery data must opt into private creation **before writing any bytes**:
```rust
use craft_atomic::{AtomicFs, RenameRetry, atomic_write_custom};
use std::path::Path;
use craft_atomic::{NewFilePermissions, WriteOptions};
// Inject simulated failures (e.g. storage full mid-stream)
struct FaultyFs;
impl AtomicFs for FaultyFs {
// ... custom failure simulation ...
fn save_private_data() -> std::io::Result<()> {
let private = WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private);
private.atomic_write(Path::new("settings.toml"), b"theme = 'dark'")?;
private.write_new(Path::new("recovery.bin"), b"recovery bytes")
}
```
`WriteOptions` and `NewFilePermissions` are non-exhaustive: start with `default()` and select
the policy with the builder, so future options do not break callers. Downstream matches on the
enum must include a fallback arm. Byte and streaming methods support both policies.
`craft-settings` explicitly chooses `Private` for its config and backup writes.
Normal stages can be readable (or writable) by others allowed by the directory, umask and ACL
while the callback is running, and can remain after a crash. Choose `Private` up front for
sensitive payloads; changing permissions inside a callback is too late to guarantee that no
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
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 therefore
still require `chmod`; this is not a claim that every save works on every external filesystem.
These policies do not promise preservation of existing ACLs, ownership or other metadata
across replacements. The copy fallback never overwrites an existing target, but a reader may
see the newly created target while it is being copied. A failed copy is removed.
### Fault Injection & Testing
`AtomicFs` lets callers simulate disk-full, locked files and permission failures. Its
`create_new(path, permissions)` operation receives the selected policy; there is no separate
probe operation or unsupported-probe fallback. Custom implementations migrating from an earlier
untagged revision must accept the policy argument and implement both creation modes.
The options API supports the same injection for byte and streaming writes:
```rust
use std::path::Path;
use craft_atomic::{AtomicFs, NewFilePermissions, RenameRetry, WriteOptions};
fn save_with_faults(fs: &dyn AtomicFs, path: &Path) -> std::io::Result<()> {
let options = WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private);
let retry = RenameRetry { attempts: 0, first_delay_ms: 0 };
options.atomic_write_custom(fs, retry, path, b"private payload")
}
```
Use `atomic_write_with_custom` for streaming replacements, or `write_new_custom` and
`write_new_with_custom` for exclusive creation. The free `atomic_write_custom` and
`write_new_custom` functions select the default document policy.
## Guarantees & Doctrines
- **Zero App Code**: Zero dependencies on application crates.
+379 -29
View File
@@ -10,7 +10,7 @@
//!
//! ### Guarantees: Atomicity vs. Durability
//! - **Atomicity Guarantee (All-or-Nothing)**:
//! [`atomic_write`] guarantees that external readers never observe a partial write. Either the target
//! [`atomic_write`] guarantees that readers of the destination never observe a partial write. Either the target
//! file retains its previous bytes, or it is replaced completely with the new contents. If an error occurs
//! during staging or rename, the target remains unmodified and the staging file is removed in user space.
//! An abnormal process termination (e.g. `SIGKILL`, crash, power loss) bypasses user-space cleanup and
@@ -28,6 +28,11 @@
//! (`sync_file < rename < sync_dir`), software flushes cannot guarantee data survival across
//! unbuffered drive controller write-caching or physical power cutoffs.
//!
//! ### 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.
//!
//! ### Platform Characteristics
//! - **Windows**: Antivirus scanners, search indexers, and backup utilities briefly hold files
//! open with sharing violations. The rename is retried with exponential backoff (`RenameRetry`).
@@ -48,9 +53,10 @@ use std::sync::atomic::{AtomicU64, Ordering};
/// File-system operations performed by atomic writes.
/// [`RealFs`] provides the production implementation; test suites implement this to inject faults.
pub trait AtomicFs {
/// Create `path`, which must not exist, and open it for writing.
/// On Unix, create it with owner-only permissions so payload written afterward is private.
fn create_new(&self, path: &Path) -> io::Result<File>;
/// Exclusively create a payload file with the requested permission policy. On Unix,
/// request mode `0600` for `Private` or `0666` for `PlatformDefault`; the OS applies its
/// umask/default ACL. Other platforms keep their native creation behavior.
fn create_new(&self, path: &Path, permissions: NewFilePermissions) -> io::Result<File>;
/// Flush a written file to durable storage.
fn sync(&self, file: &File) -> io::Result<()>;
/// Rename `from` over `to`, replacing it atomically.
@@ -61,7 +67,7 @@ pub trait AtomicFs {
fn remove(&self, path: &Path) -> io::Result<()>;
/// Flush a directory entry change to durable storage (Unix); no-op elsewhere.
fn sync_dir(&self, dir: &Path) -> io::Result<()>;
/// Give the open temporary file the permissions of the file it replaces.
/// Give the open temporary file its final permissions.
fn set_permissions(&self, file: &File, perms: std::fs::Permissions) -> io::Result<()>;
}
@@ -70,16 +76,21 @@ pub trait AtomicFs {
pub struct RealFs;
impl AtomicFs for RealFs {
fn create_new(&self, path: &Path) -> io::Result<File> {
fn create_new(&self, path: &Path, permissions: NewFilePermissions) -> io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// Set the mode at creation: chmod afterward leaves a window in which another
// user can open the stage, including a stage left behind by an abrupt exit.
options.mode(0o600);
// Apply the policy at creation: chmod afterward cannot revoke an already-open
// handle to private payload, and ordinary new documents need no chmod at all.
options.mode(match permissions {
NewFilePermissions::Private => 0o600,
NewFilePermissions::PlatformDefault => 0o666,
});
}
#[cfg(not(unix))]
let _ = permissions;
options.open(path)
}
@@ -161,8 +172,96 @@ impl RenameRetry {
}
}
/// Creation permissions for a new file on Unix. Existing destination modes are preserved.
/// Other platforms keep their existing creation behavior. Streaming callbacks may change a
/// new file's mode; choose `Private` up front when payload must not start with broader access.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum NewFilePermissions {
/// Keep the private staging mode (`0600`, subject to umask/default ACL), unless the streaming
/// callback selects another mode. Use this for settings, credentials and recovery data.
Private,
/// Use the permissions normal creation with mode `0666` would receive in the destination
/// directory. The OS applies its umask/default ACL; the library never changes process umask.
/// The stage has these permissions while being written, just like ordinary file creation.
#[default]
PlatformDefault,
}
/// Permission policy for document saves and exports. Defaults to normal new-file creation,
/// matching [`atomic_write`] and [`write_new`]. Select [`NewFilePermissions::Private`] explicitly
/// for settings and other private data. Existing files always stage privately before replacement.
///
/// ```
/// use craft_atomic::{NewFilePermissions, WriteOptions};
/// let private = WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private);
/// # let _ = private;
/// ```
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default)]
pub struct WriteOptions {
/// Final permissions for new files; ignored when replacing an existing Unix file.
pub new_file_permissions: NewFilePermissions,
}
impl WriteOptions {
/// Select creation permissions without depending on the complete set of options.
pub const fn with_new_file_permissions(mut self, permissions: NewFilePermissions) -> Self {
self.new_file_permissions = permissions;
self
}
/// [`atomic_write`] with this new-file permission policy.
pub fn atomic_write(self, path: &Path, bytes: &[u8]) -> io::Result<()> {
self.atomic_write_custom(&RealFs, RenameRetry::platform(), path, bytes)
}
/// [`atomic_write_with`] with this policy. Existing modes are restored after the callback;
/// for new files, callback-selected modes are kept.
pub fn atomic_write_with(self, path: &Path, write_fn: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
self.atomic_write_with_custom(&RealFs, RenameRetry::platform(), path, write_fn)
}
/// [`atomic_write_custom`] with this policy, an injected filesystem and caller-selected retries.
pub fn atomic_write_custom(self, fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> {
self.atomic_write_with_custom(fs, retry, path, |f| f.write_all(bytes))
}
/// Stream an atomic replacement through an injected filesystem with this policy.
pub fn atomic_write_with_custom(
self,
fs: &dyn AtomicFs,
retry: RenameRetry,
path: &Path,
write_fn: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<()> {
replace_with_options(fs, retry, path, self.new_file_permissions, write_fn)
}
/// [`write_new`] with this new-file permission policy; never replaces an existing file.
pub fn write_new(self, path: &Path, bytes: &[u8]) -> io::Result<()> {
self.write_new_custom(&RealFs, path, bytes)
}
/// [`write_new_with`] with this policy. Callback-selected modes are kept.
pub fn write_new_with(self, path: &Path, write_fn: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
self.write_new_with_custom(&RealFs, path, write_fn)
}
/// [`write_new_custom`] with this policy and an injected filesystem.
pub fn write_new_custom(self, fs: &dyn AtomicFs, path: &Path, bytes: &[u8]) -> io::Result<()> {
self.write_new_with_custom(fs, path, |f| f.write_all(bytes))
}
/// Stream exclusive creation through an injected filesystem with this policy.
pub fn write_new_with_custom(self, fs: &dyn AtomicFs, path: &Path, write_fn: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
create_with_options(fs, path, self.new_file_permissions, write_fn)
}
}
/// Atomically writes `bytes` to `path`, replacing any existing content.
///
/// New files use normal OS creation permissions. Use [`WriteOptions`] for private data.
/// If write or rename fails, the target file is untouched and any temporary staging file is removed.
pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
atomic_write_custom(&RealFs, RenameRetry::platform(), path, bytes)
@@ -170,9 +269,9 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
/// Atomically writes to `path` using a streaming writer callback `F`.
///
/// On Unix, the staging file starts with mode `0600` (restricted by the process umask).
/// Existing destination permissions are restored after the callback. For a new destination,
/// callback-selected permissions are kept, or the file remains private by default.
/// On Unix, a new destination is staged with mode `0666`, subject to umask/default ACL.
/// Replacements stage privately (`0600`) and restore the existing mode after the callback.
/// New files keep callback-selected modes. Use [`WriteOptions`] for private new files.
pub fn atomic_write_with<F>(path: &Path, write_fn: F) -> io::Result<()>
where
F: FnOnce(&mut File) -> io::Result<()>,
@@ -188,6 +287,16 @@ pub fn atomic_write_custom(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, b
/// The replace path behind every `atomic_write*` function: stage the new content beside the
/// target, then rename it over the target.
fn replace(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
replace_with_options(fs, retry, path, NewFilePermissions::default(), fill)
}
fn replace_with_options(
fs: &dyn AtomicFs,
retry: RenameRetry,
path: &Path,
new_permissions: NewFilePermissions,
fill: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<()> {
let target = resolve_links(path)?;
let (dir, name) = split(&target, path)?;
let existing = std::fs::metadata(&target).ok();
@@ -196,7 +305,7 @@ fn replace(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, fill: impl FnOnce
check_writable(&target).map_err(|e| read_only_error(e, path))?;
}
let tmp = stage(fs, &dir, &name, path, existing.as_ref(), fill)?;
let tmp = stage(fs, &dir, &name, path, existing.as_ref(), new_permissions, fill)?;
if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) {
let _ = fs.remove(&tmp);
@@ -241,14 +350,18 @@ fn stage(
name: &str,
path: &Path,
replaced: Option<&std::fs::Metadata>,
new_permissions: NewFilePermissions,
fill: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<PathBuf> {
let existed = replaced.is_some();
// Windows files carry only a read-only flag, and a read-only file can't be replaced.
let perms = replaced.filter(|_| cfg!(unix)).map(|m| m.permissions());
// New documents inherit the directory's policy directly: no probe or chmod is needed.
// Existing destinations still stage privately, including when their final mode is shared.
let creation = if existed { NewFilePermissions::Private } else { new_permissions };
for _ in 0..8 {
let tmp = dir.join(temp_name(name));
let mut file = match fs.create_new(&tmp) {
let mut file = match fs.create_new(&tmp, creation) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(write_error(e, path, dir, existed)),
@@ -266,14 +379,15 @@ fn stage(
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))
}
/// Exclusively creates and writes `path` without replacing existing files.
/// Exclusively creates and writes `path` with normal OS permissions, without replacing existing files.
/// Use [`WriteOptions`] for private data.
pub fn write_new(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_new_with(path, |f| f.write_all(bytes))
}
/// Exclusively creates and writes `path` with a streaming writer callback `F`.
/// On Unix, the file starts private (`0600`, restricted by the process umask); the callback
/// may choose its final permissions.
/// On Unix, new files start with normal creation permissions (`0666`, subject to umask/default
/// ACL); the callback may change the mode. Use [`WriteOptions`] for private new files.
pub fn write_new_with<F>(path: &Path, write_fn: F) -> io::Result<()>
where
F: FnOnce(&mut File) -> io::Result<()>,
@@ -290,16 +404,25 @@ pub fn write_new_custom(fs: &dyn AtomicFs, path: &Path, bytes: &[u8]) -> io::Res
/// then give it the target's name with a hard link, which fails rather than replace a file that
/// appeared in the meantime. Volumes without hard links get a copy into a newly created file.
fn create(fs: &dyn AtomicFs, path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
create_with_options(fs, path, NewFilePermissions::default(), fill)
}
fn create_with_options(
fs: &dyn AtomicFs,
path: &Path,
new_permissions: NewFilePermissions,
fill: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<()> {
let target = resolve_links(path)?;
let (dir, name) = split(&target, path)?;
if target.exists() {
return Err(write_error(io::Error::from(io::ErrorKind::AlreadyExists), path, &dir, true));
}
let tmp = stage(fs, &dir, &name, path, None, fill)?;
let tmp = stage(fs, &dir, &name, path, None, new_permissions, fill)?;
let placed = match fs.hard_link(&tmp, &target) {
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(fs, &tmp, &target),
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(fs, &tmp, &target, new_permissions),
linked => linked,
};
let _ = fs.remove(&tmp);
@@ -316,14 +439,34 @@ fn create(fs: &dyn AtomicFs, path: &Path, fill: impl FnOnce(&mut File) -> io::Re
/// Copy `tmp` into `target`, which must not exist yet, and flush it. A partly written `target` is
/// removed.
fn copy_new(fs: &dyn AtomicFs, tmp: &Path, target: &Path) -> io::Result<()> {
let mut to = fs.create_new(target)?;
fn copy_new(fs: &dyn AtomicFs, tmp: &Path, target: &Path, permissions: NewFilePermissions) -> io::Result<()> {
let mut to = fs.create_new(target, permissions)?;
let copied = File::open(tmp).and_then(|mut from| {
io::copy(&mut from, &mut to)?;
// Keep callback-selected modes just as hard-link publication does, but only
// after copying the payload into a destination that was created privately.
#[cfg(unix)]
fs.set_permissions(&to, from.metadata()?.permissions())?;
let final_permissions = {
use std::os::unix::fs::PermissionsExt;
let source = from.metadata()?.permissions();
let current = to.metadata()?.permissions();
(source.mode() != current.mode()).then_some(source)
};
// Normal creation already inherits the correct mode/ACL. Avoid a redundant chmod on
// volumes that reject it. If the callback narrowed the stage, narrow the destination
// before copying any payload, so it is never exposed with the default broader mode.
#[cfg(unix)]
if permissions == NewFilePermissions::PlatformDefault
&& let Some(ref mode) = final_permissions
{
fs.set_permissions(&to, mode.clone())?;
}
io::copy(&mut from, &mut to)?;
// Explicit private writes keep their copy destination private until writing finishes,
// then preserve any mode the callback deliberately selected, as the hard link does.
#[cfg(unix)]
if permissions == NewFilePermissions::Private
&& let Some(mode) = final_permissions
{
fs.set_permissions(&to, mode)?;
}
fs.sync(&to)
});
drop(to);
@@ -491,6 +634,8 @@ mod tests {
events: std::cell::RefCell<Vec<&'static str>>,
#[cfg(unix)]
created_modes: std::cell::RefCell<Vec<u32>>,
#[cfg(unix)]
permission_lengths: std::cell::RefCell<Vec<u64>>,
fail_sync: bool,
/// Syncs fail once this many have succeeded.
syncs_before_failure: Option<u32>,
@@ -501,14 +646,24 @@ mod tests {
no_hard_links: bool,
fail_permissions: bool,
fail_sync_dir: bool,
fail_normal_create: bool,
normal_collisions: Cell<u32>,
/// Another process creates the target just before the hard link.
created_meanwhile: Option<&'static [u8]>,
}
impl AtomicFs for Faulty {
fn create_new(&self, path: &Path) -> io::Result<File> {
self.events.borrow_mut().push("create_new");
let file = RealFs.create_new(path)?;
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" });
if normal && self.fail_normal_create {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: normal creation failed"));
}
if normal && self.normal_collisions.get() > 0 {
self.normal_collisions.set(self.normal_collisions.get() - 1);
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
let file = RealFs.create_new(path, permissions)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -559,6 +714,8 @@ mod tests {
}
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 self.fail_permissions {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: operation not permitted"));
}
@@ -648,6 +805,40 @@ mod tests {
assert_no_temp(&d.0);
}
#[test]
fn options_inject_policy_failures_and_retry_budget() -> io::Result<()> {
let d = TempDir::new("options-injection");
for policy in [NewFilePermissions::Private, NewFilePermissions::PlatformDefault] {
let options = WriteOptions::default().with_new_file_permissions(policy);
for streaming in [false, true] {
let path = d.0.join(format!("{policy:?}-{streaming}"));
let fs = Faulty { locked_renames: Cell::new(1), ..Default::default() };
let retry = RenameRetry { attempts: 1, first_delay_ms: 0 };
if streaming {
options.atomic_write_with_custom(&fs, retry, &path, |f| f.write_all(b"payload"))?;
} else {
options.atomic_write_custom(&fs, retry, &path, b"payload")?;
}
assert_eq!(fs.renames.get(), 2);
assert_eq!(std::fs::read(&path)?, b"payload");
let event = if policy == NewFilePermissions::Private { "create_new" } else { "create_new_normal" };
assert_eq!(fs.events.borrow()[0], event);
std::fs::remove_file(&path)?;
let fs = Faulty { fail_sync: true, ..Default::default() };
let result = if streaming {
options.write_new_with_custom(&fs, &path, |f| f.write_all(b"payload"))
} else {
options.write_new_custom(&fs, &path, b"payload")
};
assert_eq!(result.err().map(|e| e.kind()), Some(io::ErrorKind::StorageFull));
assert_eq!(fs.events.borrow()[0], event);
assert!(!path.exists());
assert_no_temp(&d.0);
}
}
Ok(())
}
#[test]
fn write_new_never_overwrites_existing() {
let d = TempDir::new("new");
@@ -713,7 +904,7 @@ mod tests {
for mode in [0o600, 0o640] {
let p = d.0.join(format!("export-{mode:o}.craft"));
let fs = Faulty { no_hard_links: true, ..Default::default() };
create(&fs, &p, |f| {
create_with_options(&fs, &p, NewFilePermissions::Private, |f| {
f.write_all(b"private export")?;
f.set_permissions(Permissions::from_mode(mode))
})?;
@@ -740,6 +931,165 @@ mod tests {
assert_no_temp(&d.0);
}
#[cfg(unix)]
#[test]
fn normal_creation_needs_no_chmod_even_when_hard_links_fail() -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
if std::env::var_os("CRAFT_ATOMIC_COPY_CHILD").is_none() {
// Test both publication paths under real umasks without changing the test runner's
// process-global mask. In particular, 002 must produce a shared 0664 final file.
for mask in ["000", "002", "022", "027", "077"] {
let output = Command::new("sh")
.args([
"-c",
"umask \"$1\"; exec \"$2\" --exact tests::normal_creation_needs_no_chmod_even_when_hard_links_fail --nocapture",
"copy-permissions",
mask,
])
.arg(std::env::current_exe()?)
.env("CRAFT_ATOMIC_COPY_CHILD", "1")
.output()?;
assert!(output.status.success(), "umask {mask}: {}", String::from_utf8_lossy(&output.stdout));
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
}
return Ok(());
}
let d = TempDir::new("normal-copy");
let reference = d.0.join("reference");
std::fs::write(&reference, b"normal creation")?;
let expected = std::fs::metadata(reference)?.permissions().mode() & 0o777;
for no_hard_links in [false, true] {
let p = d.0.join(format!("export-{no_hard_links}"));
let fs = Faulty { no_hard_links, normal_collisions: Cell::new(2), fail_permissions: true, ..Default::default() };
create_with_options(&fs, &p, NewFilePermissions::PlatformDefault, |f| {
assert_eq!(f.metadata()?.permissions().mode() & 0o777, expected);
f.write_all(b"document payload")
})?;
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, expected);
assert_eq!(std::fs::read(&p)?, b"document payload");
assert!(fs.created_modes.borrow().iter().all(|m| *m == expected), "stage and copy match ordinary creation");
assert!(!fs.events.borrow().contains(&"set_permissions"));
}
let p = d.0.join("new-document");
let fs = Faulty { fail_permissions: true, ..Default::default() };
atomic_write_custom(&fs, NO_RETRY, &p, b"document")?;
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, expected);
assert!(!fs.events.borrow().contains(&"set_permissions"));
assert_no_temp(&d.0);
Ok(())
}
#[cfg(unix)]
#[test]
fn normal_policy_preserves_existing_modes_with_private_staging() -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("normal-existing");
for mode in [0o600, 0o640, 0o664] {
let p = d.0.join(format!("existing-{mode:o}"));
std::fs::write(&p, ORIGINAL)?;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode))?;
let fs = Faulty { fail_normal_create: true, ..Default::default() };
replace_with_options(&fs, NO_RETRY, &p, NewFilePermissions::PlatformDefault, |f| f.write_all(b"replacement"))?;
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, mode);
assert!(!fs.events.borrow().contains(&"create_new_normal"));
assert_eq!(*fs.created_modes.borrow(), [0o600]);
}
assert_no_temp(&d.0);
Ok(())
}
#[cfg(unix)]
#[test]
fn normal_permission_failures_abort_before_publication() -> io::Result<()> {
let d = TempDir::new("normal-failures");
for exclusive in [false, true] {
for (tag, fs, kind, callback_runs) in [
("create", Faulty { fail_normal_create: true, ..Default::default() }, io::ErrorKind::PermissionDenied, false),
("collisions", Faulty { normal_collisions: Cell::new(8), ..Default::default() }, io::ErrorKind::AlreadyExists, false),
("sync", Faulty { fail_sync: true, ..Default::default() }, io::ErrorKind::StorageFull, true),
] {
let p = d.0.join(format!("{tag}-{exclusive}"));
let called = Cell::new(false);
let fill = |f: &mut File| {
called.set(true);
f.write_all(b"payload")
};
let result = if exclusive {
create_with_options(&fs, &p, NewFilePermissions::PlatformDefault, fill)
} else {
replace_with_options(&fs, NO_RETRY, &p, NewFilePermissions::PlatformDefault, fill)
};
assert_eq!(result.map_err(|e| e.kind()), Err(kind), "{tag}");
assert_eq!(called.get(), callback_runs, "{tag}");
assert!(!p.exists(), "{tag}");
assert_no_temp(&d.0);
}
}
Ok(())
}
#[cfg(unix)]
#[test]
fn normal_copy_preserves_callback_modes_and_refuses_permission_failures() -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("normal-callback-copy");
for mode in [0o600, 0o640, 0o660] {
for no_hard_links in [false, true] {
let p = d.0.join(format!("{mode:o}-{no_hard_links}"));
let fs = Faulty { no_hard_links, ..Default::default() };
create(&fs, &p, |f| {
f.set_permissions(std::fs::Permissions::from_mode(mode))?;
f.write_all(b"callback payload")
})?;
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, mode);
assert_eq!(std::fs::read(&p)?, b"callback payload");
assert!(fs.permission_lengths.borrow().iter().all(|len| *len == 0), "adjust normal copies before exposing any payload");
}
}
let p = d.0.join("permission-failure");
let fs = Faulty { no_hard_links: true, fail_permissions: true, ..Default::default() };
let result = create(&fs, &p, |f| {
// Toggle a bit from ordinary creation so the fallback really needs a mode change,
// independently of the test runner's umask.
let mode = f.metadata()?.permissions().mode() ^ 0o040;
f.set_permissions(std::fs::Permissions::from_mode(mode))?;
f.write_all(b"must not survive")
});
assert_eq!(result.map_err(|e| e.kind()), Err(io::ErrorKind::PermissionDenied));
assert_eq!(*fs.permission_lengths.borrow(), [0], "refuse before copying payload");
assert!(!p.exists());
assert_no_temp(&d.0);
Ok(())
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires setfacl/getfacl and a filesystem with POSIX ACL support"]
fn normal_permissions_follow_inherited_acl_with_hard_links_and_copy() -> io::Result<()> {
use std::process::Command;
let d = TempDir::new("normal-acl");
let output = Command::new("setfacl").args(["-m", "d:u::rwx,d:u:12345:r--,d:g::r-x,d:m::r-x,d:o::---"]).arg(&d.0).output()?;
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let acl = |path: &Path| -> io::Result<Vec<u8>> {
let output = Command::new("getfacl").args(["-c", "-n"]).arg(path).output()?;
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
Ok(output.stdout)
};
let reference = d.0.join("reference");
std::fs::write(&reference, b"normal creation")?;
let expected = acl(&reference)?;
for no_hard_links in [false, true] {
let fs = Faulty { no_hard_links, fail_permissions: true, ..Default::default() };
let p = d.0.join(format!("export-{no_hard_links}"));
create_with_options(&fs, &p, NewFilePermissions::PlatformDefault, |f| f.write_all(b"export"))?;
assert_eq!(acl(&p)?, expected, "same inherited ACL as ordinary creation");
assert_eq!(std::fs::read(&p)?, b"export");
}
assert_no_temp(&d.0);
Ok(())
}
#[test]
fn retry_delays_are_capped() {
let d = TempDir::new("cap");
+7 -6
View File
@@ -1,5 +1,5 @@
#[cfg(unix)]
use craft_atomic::{atomic_write, write_new};
use craft_atomic::{NewFilePermissions, WriteOptions, atomic_write, write_new};
use craft_atomic::{atomic_write_with, or_plain_fsync, write_new_with};
use std::cell::Cell;
use std::io::{self, Write};
@@ -204,11 +204,12 @@ fn private_staging_preserves_existing_modes_after_callback() -> io::Result<()> {
#[cfg(unix)]
#[test]
fn new_files_start_private_and_keep_callback_permissions() -> io::Result<()> {
fn explicit_private_files_keep_callback_permissions() -> io::Result<()> {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("private_new");
let options = WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private);
for exclusive in [false, true] {
let p = d.0.join(format!("new-{exclusive}.craft"));
let fill = |f: &mut std::fs::File| {
@@ -218,9 +219,9 @@ fn new_files_start_private_and_keep_callback_permissions() -> io::Result<()> {
f.set_permissions(Permissions::from_mode(0o640))
};
if exclusive {
write_new_with(&p, fill)?;
options.write_new_with(&p, fill)?;
} else {
atomic_write_with(&p, fill)?;
options.atomic_write_with(&p, fill)?;
}
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, 0o640);
assert_eq!(std::fs::read(&p)?, b"new payload");
@@ -228,9 +229,9 @@ fn new_files_start_private_and_keep_callback_permissions() -> io::Result<()> {
for exclusive in [false, true] {
let p = d.0.join(format!("default-{exclusive}.craft"));
if exclusive {
write_new(&p, b"private by default")?;
options.write_new(&p, b"explicitly private")?;
} else {
atomic_write(&p, b"private by default")?;
options.atomic_write(&p, b"explicitly private")?;
}
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, 0o600);
}
+140
View File
@@ -0,0 +1,140 @@
#![cfg(unix)]
use craft_atomic::{NewFilePermissions, WriteOptions, atomic_write, write_new};
use std::fs::{self, File, Permissions};
use std::io::{self, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
struct TempDir(PathBuf);
impl TempDir {
fn new() -> io::Result<Self> {
static N: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!("craft-permissions-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
fs::create_dir(&path)?;
Ok(Self(path))
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn mode(path: &Path) -> io::Result<u32> {
Ok(fs::metadata(path)?.permissions().mode() & 0o777)
}
/// Set umask only in a separate shell process; changing it in a parallel test process races.
fn child(dir: &Path, mask: &str, expected: &str, check_acl: bool) -> io::Result<()> {
let output = Command::new("sh")
.args(["-c", "umask \"$1\"; exec \"$2\" --exact permission_child --nocapture", "permissions", mask])
.arg(std::env::current_exe()?)
.env("CRAFT_PERMISSION_TEST_DIR", dir)
.env("CRAFT_PERMISSION_TEST_EXPECTED", expected)
.env("CRAFT_PERMISSION_TEST_ACL", if check_acl { "1" } else { "0" })
.output()?;
assert!(output.status.success(), "umask {mask}:\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"), "child must execute its test");
Ok(())
}
#[test]
fn normal_creation_matches_os_permissions() -> io::Result<()> {
for (mask, expected) in [("000", "666"), ("002", "664"), ("022", "644"), ("027", "640"), ("077", "600")] {
let dir = TempDir::new()?;
child(&dir.0, mask, expected, false)?;
}
Ok(())
}
#[test]
fn permission_child() -> io::Result<()> {
let Some(dir) = std::env::var_os("CRAFT_PERMISSION_TEST_DIR") else { return Ok(()) };
let dir = Path::new(&dir);
let expected = u32::from_str_radix(&std::env::var("CRAFT_PERMISSION_TEST_EXPECTED").map_err(io::Error::other)?, 8).map_err(io::Error::other)?;
let ordinary = dir.join("ordinary");
fs::write(&ordinary, b"ordinary")?;
assert_eq!(mode(&ordinary)?, expected);
let options = WriteOptions::default();
let payload = b"document bytes";
for (tag, streaming, exclusive) in
[("replace", false, false), ("replace-stream", true, false), ("exclusive", false, true), ("exclusive-stream", true, true)]
{
let path = dir.join(tag);
let fill = |f: &mut File| {
assert_eq!(f.metadata()?.permissions().mode() & 0o777, expected, "normal creation before writing");
f.write_all(payload)?;
assert_eq!(f.metadata()?.permissions().mode() & 0o777, expected, "normal creation during writing");
Ok(())
};
match (streaming, exclusive) {
(false, false) => options.atomic_write(&path, payload)?,
(false, true) => options.write_new(&path, payload)?,
(true, false) => options.atomic_write_with(&path, fill)?,
(true, true) => options.write_new_with(&path, fill)?,
}
assert_eq!(mode(&path)?, expected, "{tag}");
assert_eq!(fs::read(&path)?, payload);
if std::env::var("CRAFT_PERMISSION_TEST_ACL").as_deref() == Ok("1") {
let acl = |p: &Path| -> io::Result<Vec<u8>> {
let output = Command::new("getfacl").args(["-c", "-n"]).arg(p).output()?;
assert!(output.status.success());
Ok(output.stdout)
};
assert_eq!(acl(&path)?, acl(&ordinary)?, "same inherited ACL for {tag}");
}
if exclusive {
assert_eq!(options.write_new(&path, b"must not replace").map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
assert_eq!(fs::read(&path)?, payload);
} else {
fs::set_permissions(&path, Permissions::from_mode(0o660))?;
options.atomic_write(&path, b"replacement")?;
assert_eq!(mode(&path)?, 0o660, "preserve existing mode regardless of current umask");
}
}
for (tag, write) in [("default", atomic_write as fn(&Path, &[u8]) -> io::Result<()>), ("default-exclusive", write_new)] {
let p = dir.join(tag);
write(&p, b"document")?;
assert_eq!(mode(&p)?, expected);
}
let private = WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private);
for exclusive in [false, true] {
let p = dir.join(format!("private-{exclusive}"));
let fill = |f: &mut File| {
assert_eq!(f.metadata()?.permissions().mode() & 0o077, 0, "private from creation");
f.write_all(b"private")
};
if exclusive {
private.write_new_with(&p, fill)?;
} else {
private.atomic_write_with(&p, fill)?;
}
assert_eq!(mode(&p)?, 0o600);
}
for exclusive in [false, true] {
let path = dir.join(format!("callback-error-{exclusive}"));
let fill = |f: &mut File| {
f.write_all(b"partial")?;
Err(io::Error::from(io::ErrorKind::StorageFull))
};
let result = if exclusive { options.write_new_with(&path, fill) } else { options.atomic_write_with(&path, fill) };
assert_eq!(result.map_err(|e| e.kind()), Err(io::ErrorKind::StorageFull));
assert!(!path.exists());
}
assert!(fs::read_dir(dir)?.all(|e| e.is_ok_and(|e| !e.file_name().to_string_lossy().ends_with(".craft_tmp"))));
Ok(())
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires setfacl/getfacl and a filesystem with POSIX ACL support"]
fn directory_default_acl_takes_precedence_over_restrictive_umask() -> io::Result<()> {
let dir = TempDir::new()?;
let output = Command::new("setfacl").args(["-m", "d:u::rwx,d:u:12345:r--,d:g::r-x,d:m::r-x,d:o::---"]).arg(&dir.0).output()?;
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
// With this default ACL, ordinary creation yields 0640 even under umask 077.
child(&dir.0, "077", "640", true)
}
+6 -2
View File
@@ -50,12 +50,16 @@ pub fn write_backup(path: &Path, text: &str) -> io::Result<()> {
if backup.exists() {
std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(mode))?;
}
craft_atomic::atomic_write(&backup, text.as_bytes())?;
craft_atomic::WriteOptions::default()
.with_new_file_permissions(craft_atomic::NewFilePermissions::Private)
.atomic_write(&backup, text.as_bytes())?;
std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
{
craft_atomic::atomic_write(&backup, text.as_bytes())
craft_atomic::WriteOptions::default()
.with_new_file_permissions(craft_atomic::NewFilePermissions::Private)
.atomic_write(&backup, text.as_bytes())
}
}
+7 -4
View File
@@ -355,10 +355,13 @@ impl<T: Settings> SettingsStore<T> {
merge_into(&mut self.doc, &self.value).map_err(|e| SettingsError::Serialize(e.to_string()))?;
set_schema_version(&mut self.doc, T::SCHEMA_VERSION);
let text = document::render(&self.doc);
craft_atomic::atomic_write_with(&path, |file| {
restrict_permissions(file)?;
file.write_all(text.as_bytes())
})?;
craft_atomic::WriteOptions::default().with_new_file_permissions(craft_atomic::NewFilePermissions::Private).atomic_write_with(
&path,
|file| {
restrict_permissions(file)?;
file.write_all(text.as_bytes())
},
)?;
self.seen_text = Some(text);
self.dirty = false;
self.written = true;