rightkit-fs 0.4.1

Safe state directories, atomic writes, cross-process locks, owner-only files, and stable file identity for Right Suite apps.
Documentation

rightkit-fs

Safe filesystem state for Right Suite apps:

  • Descriptor-pinned state directories (StateDir). A swapped or symlinked path cannot redirect a pinned directory.
  • Atomic writes (atomic_write, atomic_write_private, atomic_write_with, atomic_write_with_options, AtomicWriter): temp file, fsync, then rename. AtomicWriteOptions adds ReplaceMode::{Rename, MoveWriteThrough, NoReplace} (Windows MoveFileExW for the last two) and a RetryPolicy for sharing violations. There is no ReplaceFileW mode. A failure after the content is published is a typed error with FsError::target_replaced() true (ReplacedButDirSyncFailed, ReplacedButPostStepFailed). Temp files left by crashed writers are swept (sweep_stale_temps) only when the writer is confirmed dead (age never overrides a live or unqueryable PID), and only complete generated temp names are ever swept. What a durable write guarantees differs by platform: see Atomic writes.
  • Cross-process advisory locks (FileLock, LockOptions, with_lock), with owner and stale-owner reporting. LockKind chooses POSIX record locks (Fcntl, the default) or flock(2) (Flock) on Unix; probe_owner reads the owner record without locking.
  • Owner-only files and directories (write_private_file, ensure_private_dir, create_private_new): mode 0600 and 0700 on Unix, a restricted DACL on Windows, and on macOS no extended ACL allow entry (see Owner-only and macOS ACLs).
  • Stable file identity across renames and atomic replacement (IdentityStore, StableId; Fingerprint is identity::Fingerprint, not re-exported at the crate root; the root re-export list is at src/lib.rs:65).
  • macOS clonefile(2) with CLONE_NOFOLLOW (clone_file_nofollow). It never falls back to a byte copy. clone_or_link_or_copy_nofollow is the explicit ladder for callers that can accept less: clone, then hard link, then a durable copy, reporting which one it was.
  • macOS renamex_np(RENAME_SWAP) (swap_paths, is_swap_unsupported) and the rightkit-swap A B binary: atomic exchange of two paths, never absent in between.

Catalog capability: safe-filesystem. Published to crates.io; the registry record is 0.3.3 and the source is 0.4.0 (a breaking release, not yet published).

When to use it

  • Use it for any app state file that must not be torn by a crash, redirected by a symlink, or read by other users.
  • Use it for cross-process locks on app-owned state.

Do not use it as a substitute for a database transaction. It guards single files and state directories.

Cargo features

No feature is on by default (0.4.0). Name fs, lock, json and digest as you use them (the 0.3.x default was all four); testing is never default.

  • digest: digest module (sha256_bytes, sha256_reader, sha256_file, sha256_file_with_len, hex). Depends only on sha2.
  • fs: atomic writes, owner-only files, StateDir, netfs checks, clonefile, path swap (and the rightkit-swap binary), and the error type. Depends on libc (unix) and thiserror.
  • lock: cross-process locks (FileLock, with_lock). Depends on fs and libc (unix).
  • json: identity ledger (IdentityStore), lock owner records, and the rightkit-clonefile binary. Depends on lock, serde, and serde_json.

Without json, locks write no owner file, so recovered_stale and current_owner return None. The lock itself behaves the same. rightkit-db uses features = ["fs"]. Digest-only callers use features = ["digest"]. Every integration test names the features it imports (required-features), so --no-default-features and each single feature compile.

  • testing (debug builds only): fault and event seams for consumer tests: atomic::testing, state::testing, clone::testing, lock::testing.

State directories (StateDir)

StateDir::open/create use StateOptions::default(); open_with/create_with take explicit options. Children from child inherit them.

  • Durability. write, write_new, remove and a creating child flush the file and then the directory, and a failed directory flush is an error (ReplacedButDirSyncFailed for writes, DirSyncFailed for removal and creation). StateDir::create("new/deep") and child publish the link of every directory they create: on Unix the parent of each is synced before descending (so a later durable write cannot lose a link this call made). A directory that already exists is not synced, and neither is one a racing creator made first; there is no record of earlier syncs and no walk over existing directories, so a call whose directories all exist makes no ancestor sync. A failed sync stops with DirSyncFailed. Atomic writes with create_parents and durable (and atomic::create_dir_all_durable, which rightkit-app-settings uses) follow the same rule. Residual: a directory created by an earlier call that died before its parent sync, or whose parent sync failed (DirSyncFailed, the directory stays), is not synced by later calls, however it got there, and a symlinked component is left alone with the ancestors of its target unwalked. That is still strictly more than 0.3.3, which synced no ancestor, but it is not a guarantee that every directory on the path has a durable link; a successful retry after DirSyncFailed is not proof of one. The module docs of state and atomic carry a table of every creation and publish path, what it syncs, what a crash leaves and which test covers it. Windows has no directory flush: FlushFileBuffers needs GENERIC_WRITE, and on the pinned list-access handle it fails with ERROR_ACCESS_DENIED. There, the file is flushed before publication, and creation (every directory create makes, including missing ancestors), the publishing rename and the delete are performed on FILE_WRITE_THROUGH handles. That asks the filesystem to commit each operation before the call returns; it is the strongest equivalent available, Microsoft documents no guarantee for directory metadata, and no power-loss test has been run.
  • Sync level. On Apple targets the flush is F_FULLFSYNC, falling back to F_BARRIERFSYNC then fsync on volumes that refuse it. durable_sync keeps that silent fallback. durable_sync_with(file, policy) returns the SyncLevel reached (Fsync < Barrier < Full), and SyncPolicy::Strict (also StateOptions::sync) returns the F_FULLFSYNC error instead of falling back. write_reporting and write_new_reporting return the weakest level the file and directory flushes reached. Non-Apple targets always report Full, their strongest flush.
  • Names. Reads, stats, removals and listings accept any name up to the platform limit (255 bytes on unix, 255 UTF-16 units on Windows), so an existing record longer than the write cap never fails a listing. Writes and child are held to StateOptions::max_name_len (default 200 bytes on unix, 255 units on Windows; clamped to the platform limit). A temp name carries at most 64 bytes of the real name, so a 255-byte name can be written. A write target shaped like a generated temp name (.<name>.rktmp-<pid>-<n>-<attempt>) is refused with BadName, because sweep_temps deletes such names; a name that merely contains .rktmp- is an ordinary record and is never swept.
  • Reads. read(name, max_bytes) refuses a file over max_bytes; u64::MAX means no limit.
  • Listing. Each listing opens its own directory description (openat(dirfd, ".") on Unix; Windows serialises enumeration), so concurrent listings from several threads each see every entry.
  • Listing. list returns names. list_entries returns DirEntry { name, kind } from the directory record (d_type on unix, FILE_ID_BOTH_DIR_INFO attributes on Windows), with no stat per entry; a unix filesystem that reports no type costs one descriptor-relative fstatat for that entry. Windows reports any reparse point as Symlink.
  • allow_rename_while_open (Windows). Default false: the pinned directory is opened without FILE_SHARE_DELETE, so no process can rename or delete it (or an ancestor) while the StateDir is open, and path() stays true. The 0.1.1 change that introduced handle-relative operations chose this on purpose (module docs: "without delete sharing"; the Windows-only test rename_while_open_is_refused_by_default_and_allowed_by_the_option in src/state/windows_tests.rs) and records no further reason. The git history attributes the handle-relative state change to the 0.1.1 release. The guarantee it gives: the pinned path cannot be renamed away and replaced (for example by a junction) underneath users of path(). true shares delete: the directory may be renamed or deleted under the open StateDir. Operations stay handle-relative and keep acting on the original directory, but path() then names whatever is there now, so use no path-based call on it. Unix descriptors always behave this way.
  • privacy (Windows ACL). Default Privacy::OwnerOnly: directories and files the StateDir creates, including missing ancestors, get a protected owner-only DACL. If the StateDir is the first creator of an application data root, that root is protected and its inheritable ACE propagates to everything created under it later (other accounts, services and backup agents lose access). Either create the data root first, or pass Privacy::OwnerOnlyUnixInheritWindows (or Inherit): no ACL step, so new objects carry the parent's inherited ACL, as for atomic writes. Unix stays 0700/0600 regardless.

Atomic writes

A write is two phases. Until the replace call succeeds nothing is published: a failure leaves the destination as it was and removes the temp file. After it, the content is in place; the required directory sync still runs, and a later failure is a typed error with target_replaced() true: ReplacedButDirSyncFailed, or ReplacedButPostStepFailed { step } (TempCleanup: a leftover second name after the link fallback). The most serious is reported: directory sync, then cleanup. FsError is #[non_exhaustive]. The atomic module docs hold a table of every publish path (ReplaceMode x Privacy x durable x platform x destination exists) with how the temp is created, what is synced when, which OS call publishes, what a crash at each step leaves, the error returned and the test that covers it (or the seam that stands in for the OS).

To gate the content before it is published, use AtomicWriter::commit_checked(|temp| ...): the check runs on the temp file just before the replace. The temp file is flushed first when the write is durable, and commit_checked always sync_alls it, in every mode, before the check. An error from the check aborts the write, leaves the destination as it was and removes the temp file.

What a durable write (durable: true, the default) guarantees when it returns Ok, by platform and mode. publication_durability(&options, destination_exists) returns the same answer as a value (PublicationDurability; destination_exists is accepted and ignored, no mode's answer depends on it):

Platform and mode Guarantee
Unix, any mode The temp file is flushed (F_FULLFSYNC first on Apple), the replace runs, the parent directory is flushed: DirectoryFlushed.
Windows MoveWriteThrough and NoReplace The temp file is flushed (FlushFileBuffers), then MoveFileExW with MOVEFILE_WRITE_THROUGH, which Microsoft documents as not returning until the move is on disk: WriteThroughMove. No power-loss test has been run.
Windows Rename (the default mode) The temp file is flushed, then std::fs::rename. The rename is not write-through and there is no directory flush, so after a power loss the previous version can reappear (the file is never torn): FileFlushedOnly.
durable: false, any platform Nothing is flushed: None.

Parent directories made by a durable create_parents write. On Unix each directory this call creates has its link flushed (its parent fsynced) before the next one. On Windows the file and its replace are as the table says (write-through in MoveWriteThrough and NoReplace), but newly created parent directories are not flushed: they are made with ordinary directory creation, and Windows has no directory flush this crate calls. After a power loss right after such a write, a parent directory created by it, and so the file in it, can be absent. A directory that already existed is unaffected. This crate has no flush for them.

Readers during a replace on Windows. A replace never shows a reader a truncated file or a mix of two versions. On Windows it can refuse a reader for a moment: opening the destination while another process is replacing it can fail with access denied (error 5) or a sharing violation (error 32). Seen on the windows-2025 CI runner with three processes replacing one file in a loop (tests/e2e.rs::concurrent_processes_never_expose_a_mixed_file); the replace call there is std::fs::rename, the same call 0.3.3 used. Why the open is refused is not established: only the error code was observed. This crate's reads do not retry it. A reader of a file that another process replaces should try again a bounded number of times on those two errors. Unix readers are not refused: rename(2) leaves an open or opening reader with the old or the new file.

Private writes on Windows. Privacy::OwnerOnly (and private: true) creates the temp file with its owner-only security descriptor passed to CreateFileW, so it never exists with the parent's inherited ACL. Every publish moves the temp file, so its owner-only DACL is the published file's; the destination is never narrowed or inspected. Owner-only hardening of an existing path fails closed: there is no icacls fallback, because it would leave explicit ACEs for other principals in place. Unix creates the temp with mode 0600.

Names. Public writes (and rename_exclusive, create_private_new, the clone ladder, lock files) refuse a target whose name is a complete generated temp name; sweep_stale_temps deletes only such names. On Windows the check also runs on the name the filesystem stores: Win32 drops leading spaces, trailing spaces and periods, and a :stream suffix, so .record.rktmp-123-1-0 , .record.rktmp-123-1-0 and .record.rktmp-123-1-0:s are refused too. (A name Windows would normalise to something that is not a temp name is accepted as it is. swap_paths does not call this check.)

Append, verified copy, executables, and what a write achieved

  • append_record(path, bytes, AppendOptions): one write on an append-mode handle, then a flush (durable, default) that returns the SyncLevel reached; a file this call creates also has its directory flushed and can be created owner-only. No lock is taken and a failed append can leave a torn final record, so readers must tolerate one (torn-tail repair stays in the app). A symlink or a generated-temp name is refused. Log rotation belongs to rightkit-logs.
  • copy_verified(src, dest, options, expected_sha256) (feature digest): copy to a temp beside dest, hash the bytes read, compare with the expected digest, re-hash the written temp, then publish atomically. Any mismatch (FsError::DigestMismatch) or a source that changed length leaves dest as it was and removes the temp.
  • write_private_executable(path, bytes): atomic owner-only write whose published file is 0700 on Unix (set on the temp descriptor before publication); Windows gets the owner-only DACL and is executable by file type.
  • AtomicWriter::commit_reporting() and atomic_write_reporting(...) return a WriteReport { sync_level, publication }: the flush level the temp file reached (an Apple volume without F_FULLFSYNC shows Barrier or Fsync) and the PublicationDurability of the options. is_fully_durable() is true only for a Full flush with a directory flush or write-through publication.
  • sweep_stale_temps_report(dir) returns a SweepReport that separates removed, live, unknown-liveness and other retained temps. On Windows only removed is counted (retained_counted is false).

Owner-only and macOS ACLs

0600/0700 constrain the BSD mode bits only. macOS evaluates ACL allow entries before them, and a new file or directory inherits its parent's inheritable entries. So on macOS every private creation (Privacy::OwnerOnly, OwnerOnlyUnixInheritWindows, private: true, create_private_new, ensure_private_dir, StateDir files and directories, lock files) first reads the parent's ACL and, if it holds an inheritable allow entry, refuses with FsError::UnsafeAcl and creates nothing. A new entry that carries an allow entry anyway (the parent's ACL changed between the read and the creation) is removed while still empty, before any byte is written, and refused with the same error. The one exception is a lock file: its open is not exclusive, so the file may be one another process created and holds, and it is refused and left in place, never unlinked. One predicate decides all of it: an ACL is unsafe iff it has an allow entry. A deny entry cannot widen access, so a deny-only ACL (on the parent, on a new entry, or on an existing one) passes everywhere; an entry whose type cannot be read counts as allow. A path taken for a directory is opened with O_DIRECTORY, so a regular file passed by mistake is refused without being opened. Functions that read a file (StateDir::read, the digests, FileKey::of_path, Fingerprint::of_path, the clone ladder's source) do open and close it: never call them on a file this process holds a POSIX record lock on. An entry that already exists is never opened to read its ACL: restrict_owner_only and private::is_owner_only read it by path (acl_get_link_np), because closing a descriptor releases every POSIX record lock the process holds on that file, SQLite's on a live database and its -wal/-shm included (tests/private_keeps_locks.rs). No ACL is ever removed from an inode: that cannot revoke access through a descriptor somebody already holds open, so a "repair" would report success while the exposure stood. restrict_owner_only narrows an existing entry's mode and then refuses an entry that has an allow entry (UnsafeAcl, the ACL left as it is), and private::is_owner_only is false for an entry with one. Non-inheritable entries on a parent are accepted: the new entry does not receive them. Linux masks default-ACL entries with the creation mode, so a 0600 creation grants nothing extra; other ACL models are not handled. clone_file_nofollow copies the source's mode and receives the destination directory's inheritable ACL entries; it is not an owner-only creation. The macOS ACL calls are hand-declared against <sys/acl.h>, and the source records that none of them has been compiled or run in this tree (src/acl.rs, module docs).

Clone, link, copy

clone_or_link_or_copy_nofollow(src, dst) (and _with(src, dst, &CloneOptions)) creates dst from the regular file src by the first step that works and returns which one: CloneOutcome::Cloned (macOS clonefile, Linux FICLONE where the filesystem has reflinks; Windows has no clone step), HardLinked (same volume), or Copied (a durable NoReplace atomic write; Unix permission bits are copied). On macOS a source with no group or other permission bit is never cloned natively: clonefile would create the destination with the source's mode and the destination directory's inheritable ACL, so owner-only cannot be established; such a source goes to the hard link, then to the copy, which creates its destination 0600 first. A source readable by others is cloned as before. A link at src or dst is refused (FsError::Symlink), never followed; an existing dst is FsError::AlreadyExists and never overwritten. A hard link shares content with the source: an in-place write through either name changes both, and deleting the destination removes only a name, so a caller that tracks copies should treat HardLinked ones as one item. Set CloneOptions::allow_hard_link = false (or .without_hard_link()) when the copy will be modified in place. Set CloneOptions::fresh_metadata (or .fresh_metadata(true)) to make an export that does not inherit the source's mode or extended attributes: the clone and link steps are skipped and the result is always Copied, with the default mode for a new file (honouring the umask).

Locks

  • LockKind::Fcntl (default) is a POSIX record lock; LockKind::Flock is flock(2). Both are public (FileLock::acquire_kind, acquire_shared_kind, try_lock_with) and tested (tests/lock_spawn.rs, tests/lock_cross_process.rs). Windows uses LockFileEx for both. Fcntl and flock do not promise to exclude each other. The source records a probe on macOS (Darwin 27) that saw each refuse the other across processes (src/lock.rs:14-18); no test in the crate covers it, it is an observation, not a guarantee, and other kernels differ (Linux treats them as independent locks). Give one lock file one kind for every user.

  • One file is one lock, whatever it is called. The Unix Fcntl registry is keyed by the opened file's (dev, ino), not by a path: a hard link, a case alias on a case-insensitive volume and a symlinked directory all reach the same entry. POSIX record locks belong to the process and the file, so a second descriptor would neither conflict with the first nor be safe to close; the crate keeps any alias descriptor open until the entry's last holder releases, so a refused or released alias never drops a lock someone still holds. (tests/lock_alias.rs.)

  • The lock file is opened without following a link at its name: O_NOFOLLOW on unix, FILE_FLAG_OPEN_REPARSE_POINT plus a reparse-attribute check on Windows. A link there is FsError::Symlink. Only the final component is checked.

  • Migrating from a create-new marker lock. Not implemented as an API; the guidance is below. A consumer whose old versions take a lock by creating a marker file (O_EXCL/create_new, removed on release) cannot simply point FileLock at the same path during an update overlap. The new lock never deletes its file and holds no marker semantics, so the old version's staleness rule would see a permanent file and could delete it, and the old version would not see the new lock as held. To migrate:

    1. Give FileLock its own path (for example <name>.flock), never the marker path.
    2. For as long as old versions can run, take the FileLock first (it orders new versions among themselves), then create the old marker with the old format (create_private_new), treating AlreadyExists by the old version's own staleness rule, and remove the marker before releasing the FileLock. Old versions then see the marker; new versions serialize on the lock.
    3. A crash between the two steps leaves a marker only the old staleness rule can clear, exactly as before; the new lock needs no cleanup.
    4. Once old versions are gone, delete the marker code and the marker path.

    The marker format and staleness rule belong to the consumer, so a generic mode would need both as parameters and would still leave orphaned markers to the consumer's policy; that is why this is guidance, not a mode.

Platform support

  • Unix: file operations are descriptor-relative, with O_NOFOLLOW and *at calls.
  • Windows: operations are handle-relative. Reparse points and symlinks are refused (FsError reports them).
  • macOS: clone_file_nofollow uses clonefile(2); swap_paths uses renamex_np(RENAME_SWAP) (APFS only; ENOTSUP/EINVAL elsewhere). On other platforms they return CloneError::Unsupported / ErrorKind::Unsupported.
  • Network filesystems: the netfs module reports whether a path is on one (is_network_filesystem). Check it where lock semantics matter.

Tests

  • tests/e2e.rs: real files, real child processes, real symlink attacks and real SIGKILLs. Nothing is mocked.
  • tests/lock_spawn.rs: locks across forking threads, and in-process flock semantics.
  • tests/lock_cross_process.rs: locks held by other processes, for both kinds.
  • tests/lock_owner.rs: owner record shapes (RFC 3339 and unix-millis) and probe_owner.
  • tests/state_durability.rs: StateDir directory-sync failures, name caps, sync policy, listing kinds, options, unlimited reads, concurrent listings.
  • tests/state_ancestors.rs: StateDir::create syncs the parent of each directory it creates before descending (event order), syncs nothing for directories that exist (made by another process, moved into place, or by an earlier call), and a failed sync stops the walk.
  • tests/acl_macos.rs: macOS ACLs under the owner-only guarantee (refusal under an inheriting allow parent, acceptance of deny-only ACLs, restrict_owner_only refusing an allow entry and not repairing it, verification).
  • tests/atomic_publish.rs: order of flush, replace and directory sync; owner-only before publication; Windows fail-closed hardening; typed post-publication errors.
  • tests/atomic_namespace.rs: the temp namespace is reserved; a sweep never deletes a committed record.
  • tests/lock_alias.rs: hard links, case aliases and symlinked directories are one lock.
  • tests/lock_reparse.rs: a link at the lock file's name is refused.
  • tests/clone_fallback.rs: clone, link and copy outcomes through the step seam; refusals; fresh_metadata gives a writable copy of a read-only source and never runs the clone or link step.
  • tests/clone.rs: clonefile behaviour.
  • tests/owner_append.rs, tests/owner_executable.rs, tests/owner_report.rs, tests/owner_verified.rs: the M8 additions above, each with its refusal and failure cases.
  • tests/swap.rs: RENAME_SWAP exchange, missing-path error, and the rightkit-swap binary.

Version and changes

Published version: see INDEX.md. Version history: CHANGELOG.md.