Skip to main content

rightkit_fs/
lib.rs

1//! Safe filesystem state for Right Suite apps.
2//!
3//! - [`atomic`]: temp + `fsync` + rename replacement, streaming writer, crash-orphan sweep.
4//!   [`atomic_write`] is the one-call entry point.
5//! - [`lock`]: OS advisory cross-process locks with owner/stale reporting. Two primitives:
6//!   [`FileLock::acquire`] / [`FileLock::try_acquire`] take the exclusive lock, and
7//!   [`FileLock::acquire_shared`] takes a shared (reader) lock. [`LockKind`] picks POSIX record
8//!   locks (`Fcntl`, the default) or `flock(2)` (`Flock`) on Unix; [`probe_owner`] reads the
9//!   owner record without locking.
10//! - [`write_locks`] (feature `lock`): [`KeyedWriteLocks`], in-process write locks keyed by destination
11//!   (directory identity plus name, nonexistent leaf allowed, no permanent map) with an optional
12//!   cross-process lock file, to be used with [`AtomicWriter`].
13//! - [`transaction`] (feature `transaction`): [`FileTransaction`] / [`PreparedTransaction`] /
14//!   [`recover_transactions`], a crash-recoverable multi-file publication with a journal under the
15//!   root (recoverability and serialized writers, not all-file atomic visibility).
16//! - [`transient`] (feature `fs`): [`transient::retry_transient`], the one bounded retry (20 times, 100 ms
17//!   apart, Windows only, the original error returned after the bound) for a delete, rename or open that
18//!   Windows refuses with error 5, 32 or 33 while a scanner or indexer holds the file.
19//! - [`lease`] (feature `fs`): [`Lease`], a pid + heartbeat-TTL lease file with stale-holder takeover
20//!   (not an OS lock): `try_acquire`, `renew`, `release`, `peek`.
21//! - [`toolchain_lease`] (feature `toolchain-lease`): [`ToolchainLease`], a digest-bound immutable seal of
22//!   toolchain trees (hash once, freeze with the OS immutable flag, replay by metadata only).
23//! - [`migrate`] (feature `fs`): [`migrate_dir`] (also `AppDirs::migrate_dir`), a directory move that
24//!   never shows a half-migrated destination (rename, or copy to a sibling then rename).
25//! - [`app_dirs`] (feature `app-dirs`): [`AppDirs`], one resolver for an app's data, local, cache and
26//!   home folders, with a dev namespace.
27//! - [`digest`] (features `digest` / `blake3`): SHA-256 / BLAKE3 of bytes or files, & lowercase hex.
28//! - [`plain`]: [`canonicalize_plain`], `std::fs::canonicalize` without the Windows verbatim drive prefix.
29//! - [`contain`](mod@contain): [`is_within`], [`contain()`](contain::contain), [`contain_lenient`] and [`Roots`], path containment that
30//!   follows links but refuses to leave a root; with feature `fs`, [`open_confined`] / [`Roots::open_confined`], a no-follow
31//!   open that stays inside the approved folders.
32//! - [`handle`] (feature `fs`): [`final_path`] and [`link_count`] of an open file, and [`open_read_nofollow`].
33//! - [`names`]: [`sanitize_file_name`] for names that come from outside, [`numbered_candidates`], and with feature `fs`
34//!   [`create_unique_file`] / [`create_download_file`].
35//! - [`publish_dir`](mod@publish_dir) (feature `fs`): [`publish_dir()`](publish_dir::publish_dir), an atomic directory replace.
36//! - [`trash`] (feature `fs`): [`move_to_trash`], and `trash::{list, lookup, restore}` (macOS, Windows and Linux).
37//! - [`journal`] (feature `lock`): [`JsonlJournal`], an append journal that cuts a torn tail back.
38//! - [`retention`], [`confined_copy`], [`rename_case`], [`owned_temp`], [`malware_scan`] (feature `fs`):
39//!   a bounded set with all-or-nothing retirement, a link-refusing confined tree copy, a case-only
40//!   rename, a temp for an external writer with a sweep, and an OS malware-scanner verdict.
41//! - [`zip`]: [`StoredZipWriter`], a deterministic stored-method ZIP writer, and [`crc32`]; also
42//!   `zip::{write_zip, read_zip, crc_table, crc32_update}` for archives built from files on disk.
43//! - [`private`]: owner-only files and directories (0600/0700, Windows DACL).
44//! - [`replaced`]: [`open_replaced`] / [`read_replaced`], readers that retry, on Windows, an open a concurrent
45//!   replace refused (access denied, sharing violation).
46//! - [`state`]: [`StateDir`], a descriptor-pinned state directory (`O_NOFOLLOW`, fd-relative
47//!   `*at` operations) that a swapped or symlinked path cannot redirect.
48//! - [`identity`] (feature `fs`; the ledger [`IdentityStore`] needs `json`): stable file identity
49//!   across renames and atomic replacement.
50//! - [`clone`]: macOS `clonefile(2)` with `CLONE_NOFOLLOW`; [`clone_file_nofollow`] never falls
51//!   back to a byte copy, [`clone_or_link_or_copy_nofollow`] tries clone, hard link, then copy.
52//! - [`swap`]: macOS `renamex_np(RENAME_SWAP)`; atomically exchanges two paths ([`swap_paths`]).
53
54#[cfg(all(feature = "fs", unix))]
55pub(crate) mod acl;
56#[cfg(feature = "app-dirs")]
57pub mod app_dirs;
58#[cfg(feature = "fs")]
59pub mod append;
60#[cfg(feature = "fs")]
61pub mod atomic;
62#[cfg(feature = "fs")]
63pub mod clone;
64#[cfg(feature = "fs")]
65pub mod confined_copy;
66pub mod contain;
67#[cfg(any(feature = "digest", feature = "blake3"))]
68pub mod digest;
69#[cfg(feature = "fs")]
70pub mod error;
71#[cfg(feature = "fs")]
72pub mod file_key;
73#[cfg(feature = "fs")]
74pub mod handle;
75#[cfg(feature = "fs")]
76pub mod identity;
77#[cfg(feature = "lock")]
78pub mod journal;
79#[cfg(feature = "fs")]
80pub mod lease;
81#[cfg(feature = "lock")]
82pub mod lock;
83#[cfg(feature = "fs")]
84pub mod malware_scan;
85#[cfg(feature = "fs")]
86pub mod migrate;
87pub mod names;
88#[cfg(feature = "fs")]
89pub mod netfs;
90#[cfg(feature = "fs")]
91pub mod owned_temp;
92pub mod plain;
93#[cfg(feature = "fs")]
94pub mod private;
95#[cfg(feature = "fs")]
96pub mod private_cached;
97#[cfg(feature = "app-dirs")]
98pub mod profile_home;
99#[cfg(feature = "fs")]
100pub mod publish_dir;
101#[cfg(feature = "fs")]
102pub mod rename_case;
103#[cfg(feature = "fs")]
104pub mod replaced;
105#[cfg(feature = "fs")]
106pub mod retention;
107#[cfg(feature = "fs")]
108pub mod secret_file;
109#[cfg(feature = "fs")]
110pub mod space;
111#[cfg(feature = "fs")]
112pub mod state;
113#[cfg(feature = "fs")]
114pub mod swap;
115#[cfg(feature = "toolchain-lease")]
116pub mod toolchain_lease;
117#[cfg(feature = "transaction")]
118pub mod transaction;
119#[cfg(feature = "fs")]
120pub mod transient;
121#[cfg(feature = "fs")]
122pub mod trash;
123#[cfg(feature = "fs")]
124pub mod tree;
125#[cfg(all(feature = "fs", any(feature = "digest", feature = "blake3")))]
126pub mod verified;
127#[cfg(all(feature = "fs", windows))]
128mod windows_temp_sweep;
129#[cfg(feature = "lock")]
130pub mod write_locks;
131pub mod zip;
132
133#[cfg(feature = "app-dirs")]
134pub use app_dirs::{
135    account_home, AppDirs, AppDirsError, AppId, Options as AppDirsOptions, Os as AppDirsOs,
136};
137#[cfg(feature = "fs")]
138pub use append::{append_record, open_private_append, AppendOptions};
139#[cfg(all(feature = "fs", feature = "digest"))]
140pub use atomic::atomic_write_with_digest;
141#[cfg(feature = "fs")]
142pub use atomic::{
143    atomic_write, atomic_write_private, atomic_write_reporting, atomic_write_with,
144    atomic_write_with_access_denied_retry, atomic_write_with_options, durable_sync,
145    durable_sync_with, publication_durability, publish_file, publish_file_first_free,
146    publish_file_with, publish_file_with_access_denied_retry, rename_exclusive,
147    rename_exclusive_reporting, sweep_stale_temps, sweep_stale_temps_report, try_sync_dir,
148    AccessDeniedRetry, AtomicOptions, AtomicWriteOptions, AtomicWriter, ExclusiveMethod, Privacy,
149    PublicationDurability, ReplaceMode, RetryPolicy, SweepReport, SyncLevel, SyncPolicy,
150    TempWriter, WriteReport,
151};
152#[cfg(feature = "fs")]
153pub use clone::{
154    clone_file_nofollow, clone_or_link_or_copy_nofollow, clone_or_link_or_copy_nofollow_with,
155    CloneError, CloneOptions, CloneOutcome,
156};
157#[cfg(feature = "fs")]
158pub use confined_copy::{copy_tree_confined, ConfinedCopy};
159pub use contain::{contain, contain_lenient, is_within, ContainError, Roots};
160#[cfg(feature = "fs")]
161pub use contain::{open_confined, ConfinedOptions};
162#[cfg(feature = "fs")]
163pub use error::{FsError, PostPublishStep, Result};
164#[cfg(feature = "fs")]
165pub use file_key::FileKey;
166#[cfg(feature = "fs")]
167pub use handle::{final_path, link_count, open_read_nofollow, ReadOptions};
168#[cfg(feature = "json")]
169pub use identity::IdentityStore;
170#[cfg(feature = "fs")]
171pub use identity::{Resolution, SearchLimits, StableId, Tracked};
172#[cfg(feature = "lock")]
173pub use journal::{JsonlJournal, OpenReport};
174#[cfg(feature = "fs")]
175pub use lease::{Acquire, Lease, LeaseInfo, LeaseOptions, Staleness};
176#[cfg(feature = "lock")]
177pub use lock::{probe_owner, with_lock, FileLock, LockKind, LockOptions, LockOwner};
178#[cfg(feature = "fs")]
179pub use malware_scan::{scan_file, ScanVerdict};
180#[cfg(feature = "fs")]
181pub use migrate::{migrate_dir, MigrateMethod, Migrated};
182#[cfg(feature = "fs")]
183pub use names::{create_download_file, create_unique_file};
184pub use names::{
185    is_windows_reserved_name, numbered_candidates, sanitize_file_name, sanitize_file_name_with,
186    NameOptions,
187};
188#[cfg(feature = "fs")]
189pub use netfs::is_network_filesystem;
190#[cfg(feature = "fs")]
191pub use owned_temp::{sweep_owned_temps, OwnedTemp};
192pub use plain::{canonicalize_plain, plain_path};
193#[cfg(feature = "fs")]
194pub use private::{
195    create_private_new, ensure_private_dir, restrict_owner_only, sweep_owner_only,
196    write_private_executable, write_private_file, write_private_text,
197};
198#[cfg(feature = "fs")]
199pub use publish_dir::{
200    pending_retained, publish_dir, publish_dir_retaining, publish_dir_with, DirPublishMethod,
201    DirPublishPolicy, PublishedDir, RetainedPublish,
202};
203#[cfg(feature = "fs")]
204pub use rename_case::rename_case_only;
205#[cfg(feature = "fs")]
206pub use replaced::{open_replaced, read_replaced, read_to_string_replaced};
207#[cfg(feature = "fs")]
208pub use retention::{quarantine, quarantine_hidden, Retention, RetentionReport};
209#[cfg(feature = "fs")]
210pub use secret_file::{read_secret_file, SecretFileError};
211#[cfg(feature = "fs")]
212pub use space::free_bytes;
213#[cfg(feature = "fs")]
214pub use state::{DirEntry, EntryKind, EntryStat, StateDir, StateOptions};
215#[cfg(feature = "fs")]
216pub use swap::{is_swap_unsupported, swap_paths};
217#[cfg(feature = "toolchain-lease")]
218pub use toolchain_lease::{
219    Enforcement, LeaseEvidence, RootEvidence, SealSpec, ToolchainLease, VerifyReport,
220};
221#[cfg(feature = "transaction")]
222pub use transaction::{
223    recover_transactions, recover_transactions_with, CommittedReport, ExpectedPrior,
224    FileTransaction, PreparedTransaction, Publication, RecoveredTransaction, RecoveryOutcome,
225    RecoveryReport, RecoveryRequiredReport, RetainedItem, RolledBackReport, TransactionEntry,
226    TransactionOptions, TransactionReport,
227};
228#[cfg(feature = "fs")]
229pub use trash::{move_to_trash, TrashEntry, TrashIdentity};
230#[cfg(feature = "fs")]
231pub use tree::{copy_tree, remove_tree, CopyOptions, RemoveOptions, RemoveReport};
232#[cfg(all(feature = "fs", feature = "digest"))]
233pub use verified::{copy_verified, copy_verified_with_progress, VerifiedCopy};
234#[cfg(all(feature = "fs", any(feature = "digest", feature = "blake3")))]
235pub use verified::{copy_verified_with_digest, DigestAlgorithm, VerifiedDigestCopy};
236#[cfg(feature = "lock")]
237pub use write_locks::{KeyedWriteLocks, LockedAtomicWriter, LockedDestination, WriteLockOptions};
238pub use zip::{crc32, stored_zip_bytes, StoredZipWriter};
239
240/// Conservative liveness result used by crash recovery.
241///
242/// `Unknown` is deliberately distinct from `Alive`: permission and query failures do not prove
243/// either state, and cleanup must never turn them into a deletion decision.
244#[cfg(feature = "fs")]
245#[derive(Clone, Copy, Debug, PartialEq, Eq)]
246pub(crate) enum ProcessLiveness {
247    Alive,
248    Dead,
249    Unknown,
250}
251
252/// Probe `pid` without treating a failed probe as proof of death.
253#[cfg(feature = "fs")]
254pub(crate) fn process_liveness(pid: u32) -> ProcessLiveness {
255    #[cfg(unix)]
256    {
257        if pid == 0 || pid > libc::pid_t::MAX as u32 {
258            return ProcessLiveness::Unknown;
259        }
260        // SAFETY: signal 0 performs only the existence/permission check.
261        let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
262        if rc == 0 {
263            ProcessLiveness::Alive
264        } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
265            ProcessLiveness::Dead
266        } else {
267            ProcessLiveness::Unknown
268        }
269    }
270    #[cfg(windows)]
271    {
272        if pid == 0 {
273            return ProcessLiveness::Unknown;
274        }
275        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
276        const STILL_ACTIVE: u32 = 259;
277        // SAFETY: the PID is scalar; the exit-code pointer stays live through the call.
278        unsafe {
279            let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
280            if handle.is_null() {
281                // Error 87 alone is not proof that the process vanished. A complete bounded
282                // process snapshot may prove absence; all other failed probes remain Unknown.
283                if std::io::Error::last_os_error().raw_os_error() == Some(ERROR_INVALID_PARAMETER) {
284                    return snapshot_liveness(pid);
285                }
286                return ProcessLiveness::Unknown;
287            }
288            let mut code = 0u32;
289            let ok = GetExitCodeProcess(handle, &mut code);
290            CloseHandle(handle);
291            if ok == 0 {
292                ProcessLiveness::Unknown
293            } else if code == STILL_ACTIVE {
294                ProcessLiveness::Alive
295            } else {
296                ProcessLiveness::Dead
297            }
298        }
299    }
300    #[cfg(not(any(unix, windows)))]
301    {
302        let _ = pid;
303        ProcessLiveness::Unknown
304    }
305}
306
307#[cfg(all(feature = "fs", windows))]
308const ERROR_INVALID_PARAMETER: i32 = 87;
309#[cfg(all(feature = "fs", windows))]
310const ERROR_NO_MORE_FILES: i32 = 18;
311#[cfg(all(feature = "fs", windows))]
312const PROCESS_SNAPSHOT_LIMIT: usize = 4096;
313
314#[cfg(all(feature = "fs", windows))]
315#[repr(C)]
316struct ProcessEntry32W {
317    size: u32,
318    usage: u32,
319    process_id: u32,
320    default_heap_id: usize,
321    module_id: u32,
322    threads: u32,
323    parent_process_id: u32,
324    priority_class: i32,
325    flags: u32,
326    executable: [u16; 260],
327}
328
329/// Return `Dead` only after a complete, bounded snapshot did not list `pid`.
330#[cfg(all(feature = "fs", windows))]
331fn snapshot_liveness_from_pids(pid: u32, pids: &[u32], complete: bool) -> ProcessLiveness {
332    if complete && pids.len() < PROCESS_SNAPSHOT_LIMIT && !pids.contains(&pid) {
333        ProcessLiveness::Dead
334    } else {
335        ProcessLiveness::Unknown
336    }
337}
338
339/// A failed `OpenProcess` with ERROR_INVALID_PARAMETER still needs independent absence proof.
340#[cfg(all(feature = "fs", windows))]
341fn snapshot_liveness(pid: u32) -> ProcessLiveness {
342    const TH32CS_SNAPPROCESS: u32 = 0x0000_0002;
343    // SAFETY: process ID zero selects every process; no pointers are dereferenced here.
344    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
345    if snapshot as isize == -1 {
346        return ProcessLiveness::Unknown;
347    }
348    let mut pids = Vec::with_capacity(64);
349    // SAFETY: zero initialization is valid for this C record after setting its required size.
350    let mut entry: ProcessEntry32W = unsafe { std::mem::zeroed() };
351    entry.size = std::mem::size_of::<ProcessEntry32W>() as u32;
352    // SAFETY: snapshot is valid and `entry` remains valid for both enumeration calls.
353    if unsafe { Process32FirstW(snapshot, &mut entry) } == 0 {
354        // SAFETY: snapshot came from CreateToolhelp32Snapshot and is closed exactly once.
355        unsafe { CloseHandle(snapshot) };
356        return ProcessLiveness::Unknown;
357    }
358    let complete = loop {
359        pids.push(entry.process_id);
360        if pids.len() >= PROCESS_SNAPSHOT_LIMIT {
361            break false;
362        }
363        entry.size = std::mem::size_of::<ProcessEntry32W>() as u32;
364        // SAFETY: snapshot is valid and `entry` remains valid for the call.
365        if unsafe { Process32NextW(snapshot, &mut entry) } == 0 {
366            break std::io::Error::last_os_error().raw_os_error() == Some(ERROR_NO_MORE_FILES);
367        }
368    };
369    // SAFETY: snapshot came from CreateToolhelp32Snapshot and is closed exactly once.
370    unsafe { CloseHandle(snapshot) };
371    snapshot_liveness_from_pids(pid, &pids, complete)
372}
373
374#[cfg(all(feature = "fs", windows))]
375#[link(name = "kernel32")]
376unsafe extern "system" {
377    fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut std::ffi::c_void;
378    fn GetExitCodeProcess(handle: *mut std::ffi::c_void, code: *mut u32) -> i32;
379    fn CloseHandle(handle: *mut std::ffi::c_void) -> i32;
380    fn CreateToolhelp32Snapshot(flags: u32, process_id: u32) -> *mut std::ffi::c_void;
381    fn Process32FirstW(snapshot: *mut std::ffi::c_void, entry: *mut ProcessEntry32W) -> i32;
382    fn Process32NextW(snapshot: *mut std::ffi::c_void, entry: *mut ProcessEntry32W) -> i32;
383}
384
385/// Whether process `pid` is not confirmed dead.
386///
387/// This remains a boolean compatibility helper. `true` includes an unavailable liveness probe;
388/// callers that reclaim files must use the internal tri-state probe and require `Dead`.
389#[cfg(feature = "fs")]
390pub fn process_alive(pid: u32) -> bool {
391    !matches!(process_liveness(pid), ProcessLiveness::Dead)
392}