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.AtomicWriteOptionsaddsReplaceMode::{Rename, MoveWriteThrough, NoReplace}(WindowsMoveFileExWfor the last two) and aRetryPolicyfor sharing violations. There is noReplaceFileWmode. A failure after the content is published is a typed error withFsError::target_replaced()true (ReplacedButDirSyncFailed,ReplacedButPostStepFailed). Generic sweeps (sweep_stale_temps,StateDir::sweep_temps) retain every ambiguous temporary-looking name, even with a confirmed-dead PID; names & liveness prove no ownership. 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.LockKindchooses POSIX record locks (Fcntl, the default) orflock(2)(Flock) on Unix;probe_ownerreads 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 ACLallowentry (see Owner-only and macOS ACLs). - Stable file identity across renames and atomic replacement (
IdentityStore,StableId;Fingerprintisidentity::Fingerprint, not re-exported at the crate root; the root re-export list is atsrc/lib.rs:65). - macOS
clonefile(2)withCLONE_NOFOLLOW(clone_file_nofollow). It never falls back to a byte copy.clone_or_link_or_copy_nofollowis the explicit ladder for callers that can accept less: clone, then hard link, then a durable copy, reporting which one it was. - Keyed write locks (
KeyedWriteLocks, featurelock): one writer per destination in a process, optionally across processes, with no permanent lock map. - Crash-recoverable multi-file publication (
FileTransaction,PreparedTransaction::commit,recover_transactions, featuretransaction): a journal under the root, identity-checked rollback and recovery, recoverability and serialized writers (not all-file atomic visibility). - macOS
renamex_np(RENAME_SWAP)(swap_paths,is_swap_unsupported) and therightkit-swap A Bbinary: atomic exchange of two paths, never absent in between.
Catalog capability: safe-filesystem. Published to crates.io; 0.4.5 adds the complete-before-lock lease guard and the opt-in Windows access-denied retry (see CHANGELOG). 0.4.6 (unpublished staging) adds KeyedWriteLocks, the transaction feature and FsError::Conflict.
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, digest, blake3 & zip as you use them (the 0.3.x default was all four); testing is never default.
blake3:digest::{Blake3Hasher, blake3_bytes, blake3_reader, blake3_file, blake3_file_with_len, blake3_file_with_progress}. Depends only onblake3; does not enable SHA-256 orfs.zip: names thezipmodule (zip::{write_zip, read_zip, crc_table, crc32_update}besideStoredZipWriter). The module is compiled without any feature;zipis an empty alias that adds no code, no dependency and does not enablefs.digest:digestmodule (sha256_bytes,sha256_reader,sha256_file,sha256_file_with_len,hex). Depends only onsha2.fs: atomic writes, owner-only files,StateDir, netfs checks, clonefile, path swap (and therightkit-swapbinary),FileKey(file_key, nojsonneeded),free_bytes,copy_tree, confined opens (open_confined),handle,publish_dir,trash, and the error type. Depends onlibc(unix) andthiserror. Without any feature the crate still hascanonicalize_plain,contain(is_within,contain,Roots),names(sanitize_file_name) andzip(StoredZipWriter).lock: cross-process locks (FileLock,with_lock) andwrite_locks(KeyedWriteLocks,WriteLockOptions,LockedDestination). Depends onfsandlibc(unix).transaction:FileTransaction,PreparedTransaction,TransactionEntry,TransactionOptions,ExpectedPrior,Publication,TransactionReport,recover_transactions,recover_transactions_with,RecoveryReport. Implieslock; no new dependency (ExpectedPrior::Sha256appears withdigest).json: identity ledger (IdentityStore), lock owner records, and therightkit-clonefilebinary. Depends onlock,serde, andserde_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. It impliestest-seams.test-seams(allowed in release builds):atomic::seamsand, withtransaction,transaction::seams(on_step: a per-thread hook at every commit and recovery boundary that can fail the step or block it to stand in for a kill;force_aside_backup: keep the prior file by rename aside as on a volume without hard links), for a consumer's tests that run in the release profile.fail_write_after(bytes, kind)makes this thread'sAtomicWriter::write_all/AtomicWriter::writer()writes fail afterbytesbytes (the bytes up to the limit are written first);sync_counts(),take_sync_counts()andreset_sync_counts()count the file flushes and directory syncs this thread attempted. Per thread, inert until installed. It does not see a write made throughAtomicWriter::file_mut()or theFileanatomic_write_with*closure receives.
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,removeand a creatingchildflush the file and then the directory, and a failed directory flush is an error (ReplacedButDirSyncFailedfor writes,DirSyncFailedfor removal and creation).StateDir::create("new/deep")andchildpublish 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 withDirSyncFailed. Atomic writes withcreate_parentsanddurable(andatomic::create_dir_all_durable, whichrightkit-app-settingsuses) 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 afterDirSyncFailedis not proof of one. The module docs ofstateandatomiccarry 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:FlushFileBuffersneedsGENERIC_WRITE, and on the pinned list-access handle it fails withERROR_ACCESS_DENIED. There, the file is flushed before publication, and creation (every directorycreatemakes, including missing ancestors), the publishing rename and the delete are performed onFILE_WRITE_THROUGHhandles. 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 toF_BARRIERFSYNCthenfsyncon volumes that refuse it.durable_synckeeps that silent fallback.durable_sync_with(file, policy)returns theSyncLevelreached (Fsync < Barrier < Full), andSyncPolicy::Strict(alsoStateOptions::sync) returns theF_FULLFSYNCerror instead of falling back.write_reportingandwrite_new_reportingreturn the weakest level the file and directory flushes reached. Non-Apple targets always reportFull, 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
childare held toStateOptions::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 withBadNameunder legacy write conventions.AtomicWriter::create_new_privateaccepts native destination leaves for imports. Both generic sweep APIs preserve every ambiguous name. - Reads.
read(name, max_bytes)refuses a file overmax_bytes;u64::MAXmeans 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.
listreturns names.list_entriesreturnsDirEntry { name, kind }from the directory record (d_typeon unix,FILE_ID_BOTH_DIR_INFOattributes on Windows), with no stat per entry; a unix filesystem that reports no type costs one descriptor-relativefstatatfor that entry. Windows reports any reparse point asSymlink. allow_rename_while_open(Windows). Defaultfalse: the pinned directory is opened withoutFILE_SHARE_DELETE, so no process can rename or delete it (or an ancestor) while theStateDiris open, andpath()stays true. The 0.1.1 change that introduced handle-relative operations chose this on purpose (module docs: "without delete sharing"; the Windows-only testrename_while_open_is_refused_by_default_and_allowed_by_the_optioninsrc/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 ofpath().trueshares delete: the directory may be renamed or deleted under the openStateDir. Operations stay handle-relative and keep acting on the original directory, butpath()then names whatever is there now, so use no path-based call on it. Unix descriptors always behave this way.privacy(Windows ACL). DefaultPrivacy::OwnerOnly: directories and files theStateDircreates, including missing ancestors, get a protected owner-only DACL. If theStateDiris 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 passPrivacy::OwnerOnlyUnixInheritWindows(orInherit): no ACL step, so new objects carry the parent's inherited ACL, as for atomic writes. Unix stays0700/0600regardless.
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).
Exclusive publish (ReplaceMode::NoReplace, rename_exclusive), per platform. rename_exclusive_reporting (and ExclusiveMethod) says which mechanism ran.
| Platform and volume | Mechanism | Guarantee |
|---|---|---|
| Linux, Apple | renameat2(RENAME_NOREPLACE), renamex_np(RENAME_EXCL) |
One atomic create-new rename. The name is never visible empty. The loser gets AlreadyExists. |
| Unix without the exclusive rename, with hard links | link(2) then unlink(2) |
Exclusive. Both names exist for an instant; a crash between leaves an ambiguous temp that generic sweeps retain. |
Unix without either (FAT, exFAT, some network shares: link fails with EPERM, ENOTSUP, EMLINK, EINVAL or ENOSYS) |
An O_EXCL placeholder at the destination, then rename(2) of the finished file over it |
Exclusive against every other exclusive publisher. Not atomic for readers: the name holds an empty file between the claim and the rename, and a crash there leaves that empty file. A non-exclusive replace can overwrite the placeholder. A failed rename removes the placeholder. Tested only through the force_no_hard_links seam; no FAT volume has been used. |
| Android | link then unlink, then the placeholder |
As the two rows above. |
| Windows | MoveFileExW without MOVEFILE_REPLACE_EXISTING |
Fails if the name exists; needs no hard links, so there is no fallback. Not exercised on FAT or a share by this crate. |
publish_file_first_free(from, &candidates, options) publishes one finished file under the first free name of a candidate list (for example a.dng, a-1.dng, ...) without rewriting it: each name is tried with the exclusive publish above, a taken name is skipped, the source is synced once, and the published path is returned. Every candidate taken is AlreadyExists for the last one.
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 each directory this call creates is flushed with FlushFileBuffers on a GENERIC_WRITE handle opened with FILE_FLAG_BACKUP_SEMANTICS (a handle opened for listing cannot be flushed), and so is its parent, which holds its link, before the next component; so the parent chain is committed before the file is written below it. A failed flush is DirSyncFailed and the directory stays. A parent this call did not create that refuses the write handle with access denied (a caller that may only add children to it) is skipped. Only directories this call creates are flushed: one that already existed, or that a racing creator made first, is not looked at again, so a directory left by an earlier call that died before its flush is not repaired by later calls (the residual in the atomic module docs, section 3). What is proven: tests observe the flush calls and their order through the testing seam (tests/atomic_publish.rs, module windows). What is not: that the flush reaches the medium, which needs power-loss injection; no such test has been run. A symlinked component is left alone.
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. open_replaced(path), read_replaced(path) and read_to_string_replaced(path) (feature fs) are the readers for a file another process replaces: on Windows they retry an open refused with error 5 or 32, up to 64 opens with a pause of 1 ms growing to 20 ms between them (a little over a second at worst), then return the last error unchanged, so a real permission failure still surfaces. Every other error, NotFound included, is returned at once. On other platforms they are one plain open, because rename(2) leaves an open or opening reader with the old or the new file. Only the open is retried; the bytes read are one complete version. The crate's other reads (StateDir::read, read_to_string) do not retry.
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.
Native imports. AtomicWriter::create_new_private(path) stages an owner-only file (0600 / protected owner DACL), accepts any native destination leaf including .record.rktmp-123-1-0, & requires an existing parent. Commit strictly flushes file bytes before exclusive publication, then flushes directory metadata on Unix; Windows uses write-through MoveFileExW without replacement. It never exposes an empty placeholder: volumes without an exclusive rename or hard links fail before publication. commit_as accepts a different native leaf in the same directory only with NoReplace. A successful publication carries staging permissions into its final name. Ordinary AtomicWriter cleanup checks its captured live staging identity; a foreign replacement is retained.
Names & sweeps. Legacy public writes retain their reserved-name checks. Generic sweeps preserve every ambiguous temp-looking entry, including complete generated names containing a dead PID. Imported committed records & crash orphans cannot be distinguished by names, age, or PID death. sweep_stale_temps & StateDir::sweep_temps therefore return zero; sweep_stale_temps_report classifies retained names by PID liveness on Unix & Windows. Crash leftovers require caller-owned recovery evidence before deletion.
Append, verified copy, executables, and what a write achieved
append_record(path, bytes, AppendOptions): onewriteon an append-mode handle, then a flush (durable, default) that returns theSyncLevelreached; 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 torightkit-logs.copy_verified(src, dest, options, expected_sha256)(featuredigest): copy to a temp besidedest, 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 leavesdestas it was and removes the temp.write_private_executable(path, bytes): atomic owner-only write whose published file is0700on Unix (set on the temp descriptor before publication); Windows gets the owner-only DACL and is executable by file type.AtomicWriter::commit_as(new_target, ReplaceMode): the same publish under a different final name in the writer's directory, for a name known only after the bytes are written (a content hash).NoReplacerefuses an existing name.try_sync_dir(dir)(atomic): the fallible directoryfsync;sync_dirremains the best-effort form.open_private_append(path) -> File(append): an append-mode handle for a caller's own records; a file it creates is owner-only, a symlink or a non-regular file is refused.copy_tree(src, dst, CopyOptions { no_overwrite, keep_symlinks })(tree): recursive copy that refuses to write over an existing entry whenno_overwrite, and recreates symlinks whenkeep_symlinks. Not atomic: a failure leaves the entries already copied.free_bytes(path) -> u64(space): bytes available to the user on the volume holdingpath, fromstatvfs(unix) orGetDiskFreeSpaceExW(Windows); no child process.digest::unhex(text)anddigest::is_hex(text): decode hex (either case) and check hex text.AppDirs::resolve_with_options(app, &Options):resolvewith explicit options, sodev(false)gives the release namespace from a dev build.FileKey::of_pathopens a directory on Windows too (FILE_FLAG_BACKUP_SEMANTICS); before, a directory failed with access denied.AtomicWriter::commit_reporting()andatomic_write_reporting(...)return aWriteReport { sync_level, publication }: the flush level the temp file reached (an Apple volume withoutF_FULLFSYNCshowsBarrierorFsync) and thePublicationDurabilityof the options.is_fully_durable()is true only for aFullflush with a directory flush or write-through publication.sweep_stale_temps_report(dir)returns aSweepReportthat separates removed, live, unknown-liveness and other retained temps. Retained counts are measured on Unix & Windows (retained_countedis true);removedis zero because discovery supplies no ownership proof.
Containment, confined opens and handle checks
One module, contain (std only for the checks; the opens need fs), answers "is this path inside that folder" the same way everywhere: symlinks are followed with canonicalize_plain, a path that does not exist yet is resolved through its deepest existing ancestor, an existing entry that cannot be resolved (a dangling or looping link) is an error and never "absent", whole components are compared (never a string prefix), non-UTF-8 names are compared byte for byte, and case is ignored on Windows and macOS only.
is_within(root, path) -> bool: absolutepathisrootor below it. A relative path, a..or a NUL byte in the missing part, an unresolvable entry and (Windows) an unusable new name (NUL,COM1,a:stream, a trailing dot or space) are allfalse.contain(root, rel)andcontain_lenient(root, rel): resolve a caller-supplied relative path under a root and refuse any escape (ContainError::{InvalidRelative, Escapes, Io, ..}).relmay hold no.., no root or drive or UNC prefix and no NUL; on Windows also no reserved device name (openingroot\NULopens the device, not a file below the root), no alternate-data-stream name and no trailing dot or space.Roots(add,resolve_confined,resolve_confined_lenient,contains): a set of approved folders, resolved when added.- With
fs:open_confined(root, rel, &ConfinedOptions)andRoots::open_confined(path, &ConfinedOptions)open without following a link. Unix opens the root withO_NOFOLLOW, then every component withopenat(O_NOFOLLOW), so no link can be followed at any step (a link anywhere isContainError::Symlink, even one pointing inside); aRootsset also compares the opened root's device and inode with those recorded when it was approved, so a root swapped after approval is refused. The leaf is opened non-blocking and must be a regular file (NotAFile; a FIFO is never waited on). Windows checks each directory withsymlink_metadata, opens the leaf withFILE_FLAG_OPEN_REPARSE_POINT, and rechecks the handle's final path (GetFinalPathNameByHandleW) below the root; that recheck runs after the open, so a creating or truncating open that a race redirected has already had its effect (it is reported, not undone).ConfinedOptions::require_single_link(true)refuses a file with more than one hard link (a hard link to a file outside the folder looks exactly like a file inside it);share_read_only(true)is the Windows read-sharing mode. A created file is0600on Unix. handle(fs):final_path(&File)(macOSF_GETPATH, Linux/proc/self/fd, WindowsGetFinalPathNameByHandleW),link_count(&File),open_read_nofollow(path, &ReadOptions)(no link at the final component, regular files only, optional single-link and Windows read-only sharing; parents are followed, useStateDirorRoots::open_confinedto pin them).FileKey::of_fileis the identity of any handle.StateDir(rightkit#613):open_read(name, &ReadOptions)is the public no-follow handle open;statreports a real volume serial and file index on Windows (dev/inowere 0);open_shared(path)pins a directory that is not private (group- or world-writable accepted, symlinks and non-directories still refused, owner still checked on Unix; the caller owns the consequences, see its docs);remove_dir(name)removes an empty real directory and refuses a link;remove_link(name)removes a symlink or junction itself, never its target, and refuses anything that is not a link (Windows: the reparse point is opened and deleted through that handle).private::is_owner_onlyon Windows is a verify-only trust check that accepts an inherited owner-only ACL: the owner is the token user (or an enabled owner-capable group), and everyallowACE, explicit or inherited, names the current user,SYSTEM,Administrators(or an inherit-onlyCREATOR OWNER). Anyallowfor another user,Everyone,Users,Authenticated Usersor anything unreadable or unknown isfalse;denyACEs are ignored. A runtime directory below%LOCALAPPDATA%therefore verifies without being rewritten (rightkit#676, #517).is_owner_only_strictis the exact protected single-ACE form this crate writes (the form every object it creates is verified against);verify_owner_only(path)is the checked call returningNotOwned,TooPermissiveorSymlink.
File names from outside, directory publish, stored ZIP, trash
sanitize_file_name(raw)(no feature): one safe, portable component from a download or attachment name. Keeps the last segment after/or\; removes controls, invisible and bidirectional-override characters; replaces: * ? " < > |; drops leading and trailing dots and spaces; prefixes_to a reserved Windows device name; cuts to 200 bytes keeping the extension; never empty, never.or...numbered_candidates(name, n)listsname,name (1), ...; withfs,create_unique_file(dir, name)creates the first free one exclusively (create_new, a symlink at the name is skipped, never followed) andcreate_download_file(dir, raw)sanitises first.publish_dir(src, dest)(fs): atomic directory replace.destabsent: exclusive rename.desta directory: macOSRENAME_SWAPor LinuxRENAME_EXCHANGE(atomic), else the old directory is moved aside, the new one published exclusively and the old tree removed (a crash in that window leaves the old tree under.<name>.rkold-...and nodest; a failed publish moves it back).desta file or symlink is refused. The old tree is removed only after the new one is in place; a removal failure isReplacedButPostStepFailed { step: OldTreeCleanup }. It does not flush files insidesrc.StoredZipWriter/stored_zip_bytes/crc32(no feature): a deterministic stored-method ZIP (fixed DOS timestamp, no extra fields, Unix0644/0755, entries in the order added, UTF-8 names), no compression and no ZIP64 (over the limits is an error). Names are checked: no absolute or drive-prefixed names, backslashes,./../empty components, control characters, or duplicates.trash(fs; macOS and Windows):move_to_trash(path) -> TrashIdentity,trash::{list, lookup, restore}. macOS usesFSMoveObjectToTrashSync; Windowsmove_to_trashis refused withFsError::Unavailablebefore anything is touched (SHFileOperationWdeletes permanently when an item cannot be recycled and no recycle-only operation is bound);list/lookup/restoreread the$Recycle.Bin$I/$Rrecords. The identity is theFileKey, the original path and the trashing time; restore is an exclusive rename and never overwrites. Windows record reads cover drive-lettered fixed drives only (see the module docs). Other platforms returnFsError::Unavailable.
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. (Windows: is_owner_only accepts an inherited owner-only ACL and rejects any allow ACE for another principal; see the containment section.) 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).
Keyed write locks
KeyedWriteLocks::with_destination(path, |held| ...) runs the operation while holding the exclusive write lock of one destination file (try_with_destination does not wait; held.atomic_writer(options) makes an AtomicWriter for the locked path). The key is the parent directory's FileKey plus the file name (lowercased on Windows and macOS), never the file's own inode, so a/./x, a/../a/x and a path through a symlinked directory are one key, a destination that does not exist yet is serialized like any other, and an atomic replace (which gives the file a new inode) does not change the key. The registry keeps an entry only while someone holds or waits for it (removed on every exit, panic included; active_destinations() reports the count), and every wait is bounded by WriteLockOptions::timeout (default 30 s; Duration::ZERO tries once) unless you pass a timeout too large for the clock, such as Duration::MAX, which means no deadline and waits as long as the holder holds. WriteLockOptions::process_lock_dir(dir) also takes an exclusive FileLock per destination in dir (name derived from the key, lock_file_for(path) returns it) so cooperating processes exclude each other; slot first, process lock second, released in reverse. Stated limits: a hard-link alias or a case alias on a case-sensitive volume is a second key; NFC and NFD spellings are two keys; the parent must exist. It serializes cooperating writers only. It does not stop an editor or sync client, and a check inside AtomicWriter::commit_checked narrows the window before the rename without closing it, because no platform rename compares content: keep your own source fingerprint and conflict behaviour.
Multi-file transactions
let prepared = prepare?;
match prepared.commit?
prepare validates and changes nothing on disk: each destination is a bounded relative path under root (UTF-8, at most 1024 bytes and 64 components, no .., root, drive or reserved name, no symlink or reparse point on the way, no duplicate or overlapping destination, nothing inside .rktxn), each candidate a regular file, each destination checked against its ExpectedPrior (Any, Absent, Identity(FileKey), with digest Sha256). commit takes the root's operation lock, flushes a journal (owner-only, CRC-checked, under <root>/.rktxn/<id>/) before any live destination is touched, copies each candidate beside its destination (the candidate is read, never moved), flushes the journal again with the identity (file key, size, modification time) of every prior destination and staged copy, then per entry keeps the prior file (hard link, else exclusive rename aside) and publishes the staged copy (atomic replace; exclusive when the destination was absent). Flushing the commit marker is the single decision; after it only cleanup remains (backups, journal, marker, directory, in that order). A failure before the marker rolls back; a crash at any point is resolved by recover_transactions(root), which takes the same lock and reads progress from which names exist, checking each against its recorded identity before touching it. Anything that does not match before the commit marker exists (an edited destination, a modified backup, an unreadable journal, an unknown file, a journal written for another root directory) is retained and reported, never deleted, overwritten or aged out, and blocks the next commit (FsError::Locked) until a person or a later recovery resolves it. After the marker exists the commit is decided: a backup or staged name that no longer matches (an editor wrote through the hard link) is moved to .rktxn/retained/<id>/, reported (CommittedReport::retained, RecoveryOutcome::CompletedSetAside) and blocks nothing. Identity is the device number, file index, size and modification time. A destination an editor changed between the commit's check and its backup is never replaced: the entry rolls back, the editor's content stays at the destination (an aside backup is renamed back exclusively), and the root is free again; only a destination edited after our file was published, or an aside backup that cannot be renamed back because the name was taken, stays retained and blocks the next commit until a person decides. A root or file whose device number differs from the journal's is never assumed to be the same filesystem (a restart can renumber a Unix device, and a copy can reuse a file index): the transaction is retained and reported until a person resolves it. Err from commit means nothing changed; once the journal exists a TransactionReport says what happened, and a failure after publication is never reported as "nothing happened". Private entries are owner-only from the staged copy on (mode 0600 / owner DACL); a non-private replace keeps the prior file's Unix mode.
This is recoverability and serialized writers, not all-file atomic visibility: a reader opening two files can see one old and one new while a commit runs. For coherent multi-file reads publish a generation directory plus an atomic pointer, or use a reader/writer protocol of your own. Limits: identity needs a filesystem whose file index survives a rename (a FAT-class volume makes rollback retain and report); Windows cloud-sync placeholders are refused like symlinks; created parent directories are not removed by a rollback; the replace of a customised Windows DACL takes the directory's inherited one. The operation lock is the last in the crate's lock order and is not re-entrant. rightkit-db's relocation and the daemon's root cutover use one transaction rooted at the new root with the old root's files as candidates; the old root is never touched here.
The writer returned by LockedDestination::atomic_writer is LockedAtomicWriter<'_>: it borrows
its locked destination, so Rust rejects returning it from the operation or committing after lock
release. Process-lock files remain on disk (one stable file per destination ever used); never
unlink them while cooperating writers may run.
On a volume without hard links, a crash inside exclusive rename's empty-name reservation can
leave an empty backup or destination with ambiguous ownership. Recovery retains that file,
journal & rollback material until a person resolves it. It never deletes an empty file by guess.
Rollback retains its journal until destination-parent flushes succeed. Preparation binds root &
existing parent directory identities; substituted directories are refused before mutation.
Journal/marker cleanup checks recorded identity & exact content before each delete attempt;
unclaimed generated record temps are retained for recovery instead of deleted by name.
Destinations & candidates must not carry this process's POSIX fcntl record locks: probing &
staging close descriptors, which releases every record lock this process holds on that inode.
Use transaction's separate operation-lock file for coordination.
Locks
-
LockKind::Fcntl(default) is a POSIX record lock;LockKind::Flockisflock(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 usesLockFileExfor 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
Fcntlregistry 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_NOFOLLOWon unix,FILE_FLAG_OPEN_REPARSE_POINTplus a reparse-attribute check on Windows. A link there isFsError::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 pointFileLockat 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:- Give
FileLockits own path (for example<name>.flock), never the marker path. - For as long as old versions can run, take the
FileLockfirst (it orders new versions among themselves), then create the old marker with the old format (create_private_new), treatingAlreadyExistsby the old version's own staleness rule, and remove the marker before releasing theFileLock. Old versions then see the marker; new versions serialize on the lock. - 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.
- 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.
- Give
-
Lease(fs): a pid + heartbeat-TTL lease file, not an OS lock.Lease::try_acquire(path, &LeaseOptions::new(ttl).label("..."))creates the record exclusively and returnsAcquire::Acquired(lease), orAcquire::Held(info)for a live holder;lease.renew()heartbeats (call it everyttl / 3or sooner;FsError::LockedorNotFoundmeans the lease was lost);lease.release()or drop removes the record only if it is still this lease's;Lease::peek(path)reads it. A record is stale when its pid is confirmed dead or its last heartbeat is older than its TTL; a stale record is taken over, a live and fresh one never. Local machine only, wall-clock based, never sleeps. Seesrc/lease.rsfor the takeover protocol and its stated races. -
migrate_dir(from, to)/AppDirs::migrate_dir(from, to)(fs): move a directory sotois never half-migrated. Same volume: one rename. Different volumes: copy to a hidden sibling ofto, rename it toto, removefrom.tomust be absent or empty (a populatedtoisFsError::AlreadyExists, nothing written). Safe to repeat after an interruption; stale hidden copies of dead processes are swept.
Platform support
- Unix: file operations are descriptor-relative, with
O_NOFOLLOWand*atcalls. - Windows: operations are handle-relative. Reparse points and symlinks are refused (
FsErrorreports them). - macOS:
clone_file_nofollowusesclonefile(2);swap_pathsusesrenamex_np(RENAME_SWAP)(APFS only; ENOTSUP/EINVAL elsewhere). On other platforms they returnCloneError::Unsupported/ErrorKind::Unsupported. - Network filesystems: the
netfsmodule 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-processflocksemantics.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) andprobe_owner.tests/state_durability.rs:StateDirdirectory-sync failures, name caps, sync policy, listing kinds, options, unlimited reads, concurrent listings.tests/state_ancestors.rs:StateDir::createsyncs 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 inheritingallowparent, acceptance of deny-only ACLs,restrict_owner_onlyrefusing anallowentry 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: native-name private imports, contender publication, captured staging identity, & retention by both sweepers.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_metadatagives a writable copy of a read-only source and never runs the clone or link step.tests/clone.rs:clonefilebehaviour.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_SWAPexchange, missing-path error, and therightkit-swapbinary.- Unit tests in
src/contain.rs(containment and confined opens, adversarial.., absolute, NUL, symlink, dangling, loop, hard-link, FIFO and Windows device-name cases),src/handle.rs,src/names.rs,src/publish_dir.rs,src/zip.rs(CRC vectors and a parse of the written layout),src/trash.rs(record parsing; a macOS round trip through the real Trash) and the DACL policy insrc/private.rs.
Version and changes
Published version: see INDEX.md. Version history: CHANGELOG.md.
BLAKE3 identity & verified-copy progress (G8)
= { = "0.4.5", = false, = ["fs", "blake3"] }
use ;
let = blake3_file_with_progress?;
let copied = copy_verified_with_digest?;
assert_eq!;
assert_eq!;
Callbacks run synchronously at zero & after each chunk (up to 64 KiB), with byte count
& initial file length. Empty files report (0, 0) once. Metadata length is a snapshot;
hash functions return bytes actually read. Verified copies reject a changed length.
Copy progress covers writes to temp: the final callback precedes verification & publication.
Treat successful return as completion. Failed verification preserves destination contents;
AtomicWriter can report directory-sync or cleanup errors after replacement.
Existing SHA-256 copy_verified & VerifiedCopy.sha256 stay compatible.
copy_verified_with_progress supplies byte progress for that API. For algorithm selection,
copy_verified_with_digest returns VerifiedDigestCopy { algorithm, digest, bytes }.
Deterministic STORED ZIP (G9)
Call rightkit_fs::zip::write_zip(destination, &entries) with
entries: Vec<(String, PathBuf)>. Order is preserved; sort entries before calling if
ordering starts from directory enumeration. Identical names, order & source bytes produce
identical output regardless of clock, source mtime, permissions or host OS. Encoding matches
CutRight’s former cutright-qa/src/zip.rs: STORED, UTF-8 flag, DOS epoch 1980-01-01,
Unix creator 3.0, mode 0644, no extras, comments or data descriptors.
ZIP64 is unsupported: fewer than 65535 entries, names at most 65535 UTF-8 bytes, sizes
& offsets below 0xffffffff. Oversized inputs return io::ErrorKind::InvalidInput.
Sources must stay unchanged during CRC & copy passes. Names are stored verbatim;
callers select safe archive names. Destination is truncated directly & must not alias
sources; failures may leave partial archives. Writes flush buffers without fsync.
read_zip verifies this writer’s format & CRCs, loading contents into memory.