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