mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-10 14:41:40 +00:00
This commit is contained in:
committed by
skylar taylor-barrick
parent
4a4ec8c1ca
commit
8df3d87d8b
Generated
+174
-6
@@ -144,7 +144,16 @@ version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
|
||||
dependencies = [
|
||||
"bit-vec 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -153,6 +162,16 @@ version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -183,6 +202,30 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -283,6 +326,17 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -371,6 +425,29 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-atomic"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -772,6 +849,12 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -857,6 +940,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1307,7 +1391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bit-set 0.10.0",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -1927,12 +2011,38 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
|
||||
dependencies = [
|
||||
"bit-set 0.11.1",
|
||||
"bit-vec 0.10.1",
|
||||
"bitflags 2.13.2",
|
||||
"chacha20",
|
||||
"core_detect",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -1963,6 +2073,31 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -2025,6 +2160,12 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "renderdoc-sys"
|
||||
version = "1.1.0"
|
||||
@@ -2113,6 +2254,18 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error 1.2.3",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2446,7 +2599,7 @@ dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"quick-error 2.0.1",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
@@ -2555,6 +2708,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
@@ -2640,6 +2799,15 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -2891,8 +3059,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -2964,7 +3132,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set",
|
||||
"bit-set 0.10.0",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
|
||||
@@ -11,6 +11,7 @@ repository = "https://github.com/storytold/craft-libs"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`
|
||||
craft-atomic = { path = "crates/atomic" }
|
||||
craft-print = { path = "crates/print" }
|
||||
craft-quit = { path = "crates/quit" }
|
||||
craft-ui = { path = "crates/ui" }
|
||||
|
||||
@@ -23,6 +23,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
|
||||
## Libraries
|
||||
|
||||
- [`craft-atomic`](crates/atomic/README.md): Crash-safe atomic file writing and replacement with same-directory staging, directory fsync, Apple `F_FULLFSYNC` fallback, transient lock backoff, and fault injection for the Craft suite.
|
||||
- [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery,
|
||||
duplex/copies/colour and driver preferences. PDF generation stays in each app.
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "craft-atomic"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Crash-safe atomic file writing and replacement for the Craft suite"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.4"
|
||||
@@ -0,0 +1,77 @@
|
||||
# Craft Atomic
|
||||
|
||||
`craft-atomic` provides crash-safe atomic file writing and replacement for the Craft suite.
|
||||
|
||||
Extracted from the common storage reliability engine in **PhotoCraft**, **VectorCraft**, and **FilmCraft** (refs #2, #14), it guarantees that a failed save or abnormal termination never leaves user documents truncated, corrupted, or missing.
|
||||
|
||||
## Core Guarantees
|
||||
|
||||
1. **Non-Destructive Replacement**: `atomic_write` never truncates the destination file in place. It writes a temporary file in the **same directory** as the destination, flushes it to non-volatile storage, renames it over the destination atomically, and flushes the directory so the rename survives power failure.
|
||||
2. **Guaranteed Same-Directory Staging**: Staging files are allocated beside the destination (`.{stem}.{pid}-{n}.craft_tmp`), guaranteeing same-filesystem volume rename semantics and eliminating `EXDEV` / `ERROR_NOT_SAME_DEVICE`.
|
||||
3. **Transient Lock Backoff (Windows)**: Antivirus scanners and search indexers hold newly written files momentarily. `RenameRetry` automatically retries renames encountering `PermissionDenied` with exponential backoff.
|
||||
4. **Apple SMB / Network Share Fallback**: On macOS, `sync_all` issues `F_FULLFSYNC`. Where network shares or virtual file systems reject full flush with `ENOTSUP` (45) or `ENOTTY` (25), it falls back to standard `fsync`.
|
||||
5. **Permission Preservation**: On Unix, replaces files while preserving the original file's mode and permissions (e.g. `0600`).
|
||||
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.
|
||||
|
||||
## Usage
|
||||
|
||||
### One-Shot Atomic Write
|
||||
|
||||
```rust
|
||||
use std::path::Path;
|
||||
use craft_atomic::atomic_write;
|
||||
|
||||
let path = Path::new("project.craft");
|
||||
let data = b"precious document state";
|
||||
|
||||
// Atomically replaces project.craft. If writing fails, project.craft retains
|
||||
// its prior bytes and all temporary staging files are unlinked.
|
||||
atomic_write(path, data).expect("safe atomic write");
|
||||
```
|
||||
|
||||
### Streaming Atomic Writer
|
||||
|
||||
```rust
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use craft_atomic::atomic_write_with;
|
||||
|
||||
let path = Path::new("large_project.craft");
|
||||
|
||||
atomic_write_with(path, |file| {
|
||||
file.write_all(b"header...")?;
|
||||
file.write_all(b"payload...")?;
|
||||
Ok(())
|
||||
}).expect("streaming atomic write");
|
||||
```
|
||||
|
||||
### Exclusive Creation (`write_new`)
|
||||
|
||||
```rust
|
||||
use std::path::Path;
|
||||
use craft_atomic::write_new;
|
||||
|
||||
let path = Path::new("export.png");
|
||||
|
||||
// Writes only if the destination does not already exist; fails with AlreadyExists
|
||||
// without modifying existing content.
|
||||
write_new(path, b"png bytes...").expect("exclusive write");
|
||||
```
|
||||
|
||||
### Fault Injection & Testing
|
||||
|
||||
```rust
|
||||
use craft_atomic::{AtomicFs, RenameRetry, atomic_write_custom};
|
||||
|
||||
// Inject simulated failures (e.g. storage full mid-stream)
|
||||
struct FaultyFs;
|
||||
impl AtomicFs for FaultyFs {
|
||||
// ... custom failure simulation ...
|
||||
}
|
||||
```
|
||||
|
||||
## Guarantees & Doctrines
|
||||
|
||||
- **Zero App Code**: Zero dependencies on application crates.
|
||||
- **Never Crash**: `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- **Forbid Unsafe**: `#![forbid(unsafe_code)]`.
|
||||
@@ -0,0 +1,481 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! Crash-safe file replacement: the unified atomic save engine for the Craft suite.
|
||||
//!
|
||||
//! [`atomic_write`] never truncates the destination file in place. It writes a temporary file
|
||||
//! in the **same directory** as the destination, flushes it to non-volatile storage (`sync_all`),
|
||||
//! renames it over the destination atomically, and on Unix, flushes the directory so the rename
|
||||
//! survives a power loss. If any step fails, the destination retains its original bytes and
|
||||
//! the temporary staging file is removed.
|
||||
//!
|
||||
//! ### 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`).
|
||||
//! - **Cross-Device (`EXDEV`)**: Staging files are always created beside the destination in the
|
||||
//! same folder, guaranteeing same-filesystem replace semantics.
|
||||
//! - **Apple Network Shares (macOS)**: `sync_all` issues `F_FULLFSYNC`. Where SMB shares or
|
||||
//! virtual filesystems reject this with `ENOTSUP` or `ENOTTY`, [`sync_file`] falls back to standard
|
||||
//! `fsync`.
|
||||
//! - **Web / WASM**: On `wasm32`, returns standard unsupported `io::Error` rather than panicking.
|
||||
//! - **Failure Injection**: [`atomic_write_custom`] accepts an [`AtomicFs`] implementation so
|
||||
//! callers and test suites can inject disk-full, lock-contention, or permission errors at each stage.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
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 `tmp` (which must not exist), write all `bytes`, and flush to disk.
|
||||
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()>;
|
||||
/// Rename `from` over `to`, replacing it atomically.
|
||||
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
|
||||
/// Remove a file.
|
||||
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<()>;
|
||||
/// Copy the destination's permissions onto the temporary file before replacement.
|
||||
fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()>;
|
||||
}
|
||||
|
||||
/// The production file system implementation.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct RealFs;
|
||||
|
||||
impl AtomicFs for RealFs {
|
||||
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
sync_file(&f)
|
||||
}
|
||||
|
||||
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
|
||||
fn remove(&self, path: &Path) -> io::Result<()> {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
|
||||
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
sync_file(&File::open(dir)?)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = dir;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> {
|
||||
let perms = std::fs::metadata(from)?.permissions();
|
||||
std::fs::set_permissions(to, perms)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush `file` to stable storage (`sync_all`).
|
||||
/// On Apple platforms, this issues an `F_FULLFSYNC`. Where the file system rejects full sync
|
||||
/// (such as SMB network shares or virtual file systems), it falls back to a plain `fsync`.
|
||||
pub fn sync_file(file: &File) -> io::Result<()> {
|
||||
#[cfg(target_vendor = "apple")]
|
||||
{
|
||||
or_plain_fsync(file.sync_all(), || rustix::fs::fsync(file).map_err(io::Error::from))
|
||||
}
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
{
|
||||
file.sync_all()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `full`, or calls `fsync()` if `full` failed only because `F_FULLFSYNC` is unsupported.
|
||||
pub fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
|
||||
match full {
|
||||
Err(e) if full_flush_unsupported(&e) => fsync(),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn full_flush_unsupported(e: &io::Error) -> bool {
|
||||
const ENOTTY: i32 = 25;
|
||||
const ENOTSUP: i32 = 45;
|
||||
cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | ENOTTY))
|
||||
}
|
||||
|
||||
/// Retry configuration for renames that encounter transient file locks (`PermissionDenied`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RenameRetry {
|
||||
/// Number of retries after the initial attempt (0 = no retry).
|
||||
pub attempts: u32,
|
||||
/// Delay before the first retry in milliseconds; doubles on each subsequent attempt.
|
||||
pub first_delay_ms: u64,
|
||||
}
|
||||
|
||||
impl RenameRetry {
|
||||
/// Platform default: retry on Windows (7 attempts with 10ms exponential backoff), no-op elsewhere.
|
||||
pub fn platform() -> Self {
|
||||
if cfg!(windows) { Self { attempts: 7, first_delay_ms: 10 } } else { Self { attempts: 0, first_delay_ms: 0 } }
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically writes `bytes` to `path`, replacing any existing content.
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Atomically writes to `path` using a streaming writer callback `F`.
|
||||
pub fn atomic_write_with<F>(path: &Path, write_fn: F) -> io::Result<()>
|
||||
where
|
||||
F: FnOnce(&mut File) -> io::Result<()>,
|
||||
{
|
||||
let target = resolve_symlink(path);
|
||||
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
|
||||
let dir = match target.parent() {
|
||||
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
|
||||
_ => PathBuf::from("."),
|
||||
};
|
||||
std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?;
|
||||
|
||||
let mut tmp = None;
|
||||
for _ in 0..8 {
|
||||
let candidate = dir.join(temp_name(&name.to_string_lossy()));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&candidate) {
|
||||
Ok(mut f) => {
|
||||
let res = write_fn(&mut f).and_then(|()| sync_file(&f));
|
||||
drop(f);
|
||||
if let Err(e) = res {
|
||||
let _ = std::fs::remove_file(&candidate);
|
||||
return Err(write_error(e, path, &dir));
|
||||
}
|
||||
tmp = Some(candidate);
|
||||
break;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(write_error(e, path, &dir)),
|
||||
}
|
||||
}
|
||||
|
||||
let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?;
|
||||
|
||||
if target.exists() {
|
||||
let _ = RealFs.copy_permissions(&target, &tmp);
|
||||
}
|
||||
|
||||
if let Err(e) = retry_rename(RenameRetry::platform(), || std::fs::rename(&tmp, &target)) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(rename_error(e, path));
|
||||
}
|
||||
|
||||
let _ = RealFs.sync_dir(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically writes `bytes` to `path` through custom [`AtomicFs`] and [`RenameRetry`].
|
||||
pub fn atomic_write_custom(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let target = resolve_symlink(path);
|
||||
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
|
||||
let dir = match target.parent() {
|
||||
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
|
||||
_ => PathBuf::from("."),
|
||||
};
|
||||
std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?;
|
||||
|
||||
let mut tmp = None;
|
||||
for _ in 0..8 {
|
||||
let candidate = dir.join(temp_name(&name.to_string_lossy()));
|
||||
match fs.write_new(&candidate, bytes) {
|
||||
Ok(()) => {
|
||||
tmp = Some(candidate);
|
||||
break;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => {
|
||||
let _ = fs.remove(&candidate);
|
||||
return Err(write_error(e, path, &dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?;
|
||||
|
||||
if target.exists() {
|
||||
let _ = fs.copy_permissions(&target, &tmp);
|
||||
}
|
||||
|
||||
if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) {
|
||||
let _ = fs.remove(&tmp);
|
||||
return Err(rename_error(e, path));
|
||||
}
|
||||
|
||||
let _ = fs.sync_dir(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exclusively creates and writes `path` without replacing existing files.
|
||||
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`.
|
||||
pub fn write_new_with<F>(path: &Path, write_fn: F) -> io::Result<()>
|
||||
where
|
||||
F: FnOnce(&mut File) -> io::Result<()>,
|
||||
{
|
||||
let target = resolve_symlink(path);
|
||||
if target.exists() {
|
||||
return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: file already exists", path.display())));
|
||||
}
|
||||
|
||||
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
|
||||
let dir = match target.parent() {
|
||||
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
|
||||
_ => PathBuf::from("."),
|
||||
};
|
||||
std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?;
|
||||
|
||||
let tmp = dir.join(temp_name(&name.to_string_lossy()));
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
let filled = write_fn(&mut f).and_then(|()| sync_file(&f));
|
||||
drop(f);
|
||||
|
||||
if let Err(e) = filled {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(write_error(e, path, &dir));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let placed = std::fs::hard_link(&tmp, &target).or_else(|e| {
|
||||
if e.kind() == io::ErrorKind::AlreadyExists {
|
||||
Err(e)
|
||||
} else {
|
||||
// Hard link unsupported; fall back to atomic rename if target still missing
|
||||
std::fs::rename(&tmp, &target)
|
||||
}
|
||||
});
|
||||
#[cfg(not(unix))]
|
||||
let placed = std::fs::rename(&tmp, &target);
|
||||
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
placed
|
||||
}
|
||||
|
||||
/// Retries an operation with exponential backoff while it fails with `PermissionDenied`.
|
||||
pub fn retry_rename(retry: RenameRetry, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
|
||||
let mut delay = retry.first_delay_ms;
|
||||
let mut left = retry.attempts;
|
||||
loop {
|
||||
match rename() {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => {
|
||||
left -= 1;
|
||||
sleep_ms(delay);
|
||||
delay = delay.saturating_mul(2);
|
||||
}
|
||||
r => return r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sleep_ms(ms: u64) {
|
||||
if ms > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(ms));
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn sleep_ms(_ms: u64) {}
|
||||
|
||||
fn resolve_symlink(path: &Path) -> PathBuf {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
|
||||
_ => path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a unique, hidden temporary file name beside `file`.
|
||||
pub fn temp_name(file: &str) -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let stem: String = file.chars().take(64).collect();
|
||||
format!(".{stem}.{}-{n}.craft_tmp", std::process::id())
|
||||
}
|
||||
|
||||
fn context(e: io::Error, msg: String) -> io::Error {
|
||||
io::Error::new(e.kind(), format!("{msg}: {e}"))
|
||||
}
|
||||
|
||||
const UNCHANGED: &str = "the original file was not changed";
|
||||
|
||||
fn write_error(e: io::Error, path: &Path, dir: &Path) -> io::Error {
|
||||
let msg = match e.kind() {
|
||||
io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => {
|
||||
format!("cannot save {}: the folder {} is not writable ({e}); {UNCHANGED}", path.display(), dir.display())
|
||||
}
|
||||
io::ErrorKind::StorageFull => format!("cannot save {}: the disk is full ({e}); {UNCHANGED}", path.display()),
|
||||
_ => format!("cannot save {}: {e}; {UNCHANGED}", path.display()),
|
||||
};
|
||||
io::Error::new(e.kind(), msg)
|
||||
}
|
||||
|
||||
fn rename_error(e: io::Error, path: &Path) -> io::Error {
|
||||
let msg = if is_cross_device(&e) {
|
||||
format!("cannot save {}: the temporary file is on a different volume than the destination ({e}); {UNCHANGED}", path.display())
|
||||
} else if e.kind() == io::ErrorKind::PermissionDenied {
|
||||
format!("cannot save {}: the file is locked or read-only ({e}); {UNCHANGED}", path.display())
|
||||
} else {
|
||||
format!("cannot save {}: {e}; {UNCHANGED}", path.display())
|
||||
};
|
||||
io::Error::new(e.kind(), msg)
|
||||
}
|
||||
|
||||
fn is_cross_device(e: &io::Error) -> bool {
|
||||
e.kind() == io::ErrorKind::CrossesDevices || (cfg!(unix) && e.raw_os_error() == Some(18)) || (cfg!(windows) && e.raw_os_error() == Some(17))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let d = std::env::temp_dir().join(format!("craft-atomic-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).ok();
|
||||
Self(d)
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_no_temp(dir: &Path) {
|
||||
let leftovers: Vec<_> = std::fs::read_dir(dir)
|
||||
.ok()
|
||||
.map(|entries| entries.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".craft_tmp")).collect())
|
||||
.unwrap_or_default();
|
||||
assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}");
|
||||
}
|
||||
|
||||
const ORIGINAL: &[u8] = b"the user's precious original document";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Faulty {
|
||||
fail_write_after: Option<usize>,
|
||||
fail_rename: Option<io::ErrorKind>,
|
||||
locked_renames: Cell<u32>,
|
||||
renames: Cell<u32>,
|
||||
}
|
||||
|
||||
impl AtomicFs for Faulty {
|
||||
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if let Some(n) = self.fail_write_after {
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(tmp)?;
|
||||
f.write_all(&bytes[..n.min(bytes.len())])?;
|
||||
return Err(io::Error::new(io::ErrorKind::StorageFull, "injected: no space left on device"));
|
||||
}
|
||||
RealFs.write_new(tmp, bytes)
|
||||
}
|
||||
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
|
||||
self.renames.set(self.renames.get() + 1);
|
||||
if let Some(kind) = self.fail_rename {
|
||||
return Err(io::Error::new(kind, "injected rename failure"));
|
||||
}
|
||||
if self.locked_renames.get() > 0 {
|
||||
self.locked_renames.set(self.locked_renames.get() - 1);
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: sharing violation"));
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const NO_RETRY: RenameRetry = RenameRetry { attempts: 0, first_delay_ms: 0 };
|
||||
|
||||
#[test]
|
||||
fn replaces_and_creates() {
|
||||
let d = TempDir::new("ok");
|
||||
let p = d.0.join("doc.craft");
|
||||
atomic_write(&p, b"first").ok();
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(b"first".to_vec()));
|
||||
atomic_write(&p, b"second, longer").ok();
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(b"second, longer".to_vec()));
|
||||
assert_no_temp(&d.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_missing_parent_folders() {
|
||||
let d = TempDir::new("parent");
|
||||
let p = d.0.join("a/b/out.png");
|
||||
atomic_write(&p, b"x").ok();
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(b"x".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_failure_halfway_keeps_original() {
|
||||
let d = TempDir::new("write");
|
||||
let p = d.0.join("doc.craft");
|
||||
std::fs::write(&p, ORIGINAL).ok();
|
||||
let fs = Faulty { fail_write_after: Some(5), ..Default::default() };
|
||||
let e = atomic_write_custom(&fs, NO_RETRY, &p, &[7u8; 4096]);
|
||||
assert!(e.is_err());
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
|
||||
assert_eq!(fs.renames.get(), 0);
|
||||
assert_no_temp(&d.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_failure_keeps_original() {
|
||||
let d = TempDir::new("rename");
|
||||
let p = d.0.join("doc.craft");
|
||||
std::fs::write(&p, ORIGINAL).ok();
|
||||
let fs = Faulty { fail_rename: Some(io::ErrorKind::Other), ..Default::default() };
|
||||
assert!(atomic_write_custom(&fs, NO_RETRY, &p, b"new").is_err());
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
|
||||
assert_no_temp(&d.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_target_rename_is_retried() {
|
||||
let d = TempDir::new("locked");
|
||||
let p = d.0.join("doc.craft");
|
||||
std::fs::write(&p, ORIGINAL).ok();
|
||||
let fs = Faulty { locked_renames: Cell::new(2), ..Default::default() };
|
||||
atomic_write_custom(&fs, RenameRetry { attempts: 3, first_delay_ms: 1 }, &p, b"new").ok();
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(b"new".to_vec()));
|
||||
assert_eq!(fs.renames.get(), 3);
|
||||
assert_no_temp(&d.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_new_never_overwrites_existing() {
|
||||
let d = TempDir::new("new");
|
||||
let p = d.0.join("file.craft");
|
||||
write_new(&p, b"first").ok();
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(b"first".to_vec()));
|
||||
let res = write_new(&p, b"second");
|
||||
assert!(res.is_err());
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(b"first".to_vec()));
|
||||
assert_no_temp(&d.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use craft_atomic::{atomic_write, atomic_write_with, or_plain_fsync, write_new_with};
|
||||
use std::cell::Cell;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let d = std::env::temp_dir().join(format!("craft-atomic-int-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).ok();
|
||||
Self(d)
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_no_temp(dir: &Path) {
|
||||
let leftovers: Vec<_> = std::fs::read_dir(dir)
|
||||
.ok()
|
||||
.map(|entries| entries.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".craft_tmp")).collect())
|
||||
.unwrap_or_default();
|
||||
assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}");
|
||||
}
|
||||
|
||||
const ORIGINAL: &[u8] = b"original precious document";
|
||||
|
||||
#[test]
|
||||
fn atomic_write_with_streaming_writer() {
|
||||
let d = TempDir::new("stream");
|
||||
let p = d.0.join("doc.dat");
|
||||
std::fs::write(&p, ORIGINAL).ok();
|
||||
|
||||
atomic_write_with(&p, |f| {
|
||||
f.write_all(b"chunk1 ")?;
|
||||
f.write_all(b"chunk2")
|
||||
})
|
||||
.ok();
|
||||
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(b"chunk1 chunk2".to_vec()));
|
||||
assert_no_temp(&d.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_new_with_streaming_writer() {
|
||||
let d = TempDir::new("write_new_stream");
|
||||
let p = d.0.join("new.dat");
|
||||
|
||||
write_new_with(&p, |f| f.write_all(b"new content")).ok();
|
||||
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(b"new content".to_vec()));
|
||||
assert_no_temp(&d.0);
|
||||
|
||||
// Second write to existing file must fail with AlreadyExists
|
||||
let res = write_new_with(&p, |f| f.write_all(b"overwritten?"));
|
||||
assert!(res.is_err());
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(b"new content".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_failure_aborts_and_keeps_original() {
|
||||
let d = TempDir::new("stream_fail");
|
||||
let p = d.0.join("doc.dat");
|
||||
std::fs::write(&p, ORIGINAL).ok();
|
||||
|
||||
let res = atomic_write_with(&p, |f| {
|
||||
f.write_all(b"half a file")?;
|
||||
Err(io::Error::new(io::ErrorKind::StorageFull, "disk full"))
|
||||
});
|
||||
|
||||
assert!(res.is_err());
|
||||
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
|
||||
assert_no_temp(&d.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_full_fsync_fallback_behavior() {
|
||||
let refused = |errno| Err(io::Error::from_raw_os_error(errno));
|
||||
for (errno, name) in [(45, "ENOTSUP (SMB)"), (25, "ENOTTY")] {
|
||||
let fsynced = Cell::new(false);
|
||||
let r = or_plain_fsync(refused(errno), || {
|
||||
fsynced.set(true);
|
||||
Ok(())
|
||||
});
|
||||
let apple = cfg!(target_vendor = "apple");
|
||||
assert_eq!(r.is_ok(), apple, "{name}");
|
||||
assert_eq!(fsynced.get(), apple, "{name}: fsync fallback only runs on Apple");
|
||||
}
|
||||
|
||||
// Unrelated errors (e.g. EIO = 5) must not fallback
|
||||
let r = or_plain_fsync(refused(5), || panic!("should not run for EIO"));
|
||||
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn mode_and_symlink_preservation() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = TempDir::new("mode_symlink");
|
||||
|
||||
// Permissions
|
||||
let p = d.0.join("secret.key");
|
||||
std::fs::write(&p, ORIGINAL).ok();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).ok();
|
||||
atomic_write(&p, b"updated secret").ok();
|
||||
let mode = std::fs::metadata(&p).map(|m| m.permissions().mode() & 0o777).ok();
|
||||
assert_eq!(mode, Some(0o600));
|
||||
|
||||
// Symlink
|
||||
let real = d.0.join("real.txt");
|
||||
let link = d.0.join("link.txt");
|
||||
std::fs::write(&real, ORIGINAL).ok();
|
||||
std::os::unix::fs::symlink(&real, &link).ok();
|
||||
atomic_write(&link, b"through symlink").ok();
|
||||
assert!(std::fs::symlink_metadata(&link).map(|m| m.file_type().is_symlink()).unwrap_or(false));
|
||||
assert_eq!(std::fs::read(&real).ok(), Some(b"through symlink".to_vec()));
|
||||
}
|
||||
Reference in New Issue
Block a user