leviath_sys/disk.rs
1//! How much room is left on the filesystem a path lives on.
2//!
3//! Leviath asks this before letting an agent write, because the failure it
4//! guards against is not a bad write but a full disk: a run that filled `C:`
5//! took the machine down with it, and every other process on it (issue #252).
6//!
7//! There is no free-space API in `std`, and this workspace forbids `unsafe`, so
8//! the syscall is delegated to `fs4` - the same arrangement `nix` has here for
9//! process and permission calls.
10
11use std::path::Path;
12
13/// Bytes still writable on the filesystem containing `path`, or `None` when the
14/// question cannot be answered.
15///
16/// `None` is not "no space". It means the probe failed - an unmounted path, a
17/// filesystem that does not report statistics, a permissions error - and a
18/// caller must treat it as "unknown" rather than as either extreme. Refusing on
19/// an unanswerable probe would block writes on any filesystem `fs4` cannot
20/// read; allowing on it is what the caller does, because a guard that cannot
21/// measure has nothing to say.
22///
23/// # A path that does not exist answers differently per platform
24///
25/// On Windows `fs4` resolves the path to its volume first
26/// (`GetVolumePathNameW`), which succeeds for a path nothing has created yet, so
27/// `C:\nope\nope` reports `C:`'s free space. On Unix `statvfs` needs the path
28/// itself and fails.
29///
30/// Neither is wrong - "how much room is there where this would go" is a
31/// reasonable question to answer for a path that does not exist yet - so this
32/// does not normalize them. Nothing depends on it: the only caller asks about a
33/// run's working directory, which exists by the time anything writes into it.
34/// Do not build a "does this path exist" check on top of this.
35pub fn available_bytes(path: &Path) -> Option<u64> {
36 fs4::available_space(path).ok()
37}
38
39/// Total size of the filesystem containing `path`, or `None` when the question
40/// cannot be answered.
41///
42/// Only used to check [`available_bytes`] against something: a free-space probe
43/// wired to the wrong syscall still returns a plausible number, and "is it less
44/// than the disk it is on" is the cheapest question that catches that.
45pub fn total_bytes(path: &Path) -> Option<u64> {
46 fs4::total_space(path).ok()
47}
48
49#[cfg(test)]
50mod tests {
51 use super::*;
52
53 /// The probe answers for a real directory. Asserted as "some plausible
54 /// number" rather than a value: the point is that the syscall works and is
55 /// wired to the right path, and any concrete figure would be a test that
56 /// fails when someone's disk fills.
57 #[test]
58 fn available_bytes_answers_for_a_real_directory() {
59 let dir = tempfile::tempdir().expect("tempdir");
60 let free = available_bytes(dir.path()).expect("a temp dir reports its free space");
61 // A machine that genuinely has under a megabyte free cannot have
62 // created the tempdir above, so this is a wiring check, not a
63 // disk-capacity assumption.
64 assert!(free > 1024 * 1024, "implausible free space: {free}");
65 }
66
67 /// Free space cannot exceed the disk it is on.
68 ///
69 /// The cheapest assertion that catches a probe wired to the wrong syscall:
70 /// a wrong one still returns a plausible-looking number, and "greater than
71 /// a megabyte" would not notice. Runs on every platform in CI, which is the
72 /// point - the three OSes reach three different system calls underneath.
73 #[test]
74 fn available_space_never_exceeds_the_filesystem_it_is_on() {
75 let dir = tempfile::tempdir().expect("tempdir");
76 let free = available_bytes(dir.path()).expect("free space");
77 let total = total_bytes(dir.path()).expect("total space");
78 assert!(total > 0, "a filesystem with no size");
79 assert!(free <= total, "{free} free on a {total} filesystem");
80 }
81
82 /// A directory and a file inside it are on the same filesystem, so they
83 /// report the same space. Catches a probe that answers about the *path*
84 /// rather than the filesystem containing it, which would be a different
85 /// (and wrong) question.
86 ///
87 /// Compared with a tolerance rather than for equality: the two calls are
88 /// not atomic, and anything else on the machine may write between them.
89 #[test]
90 fn a_file_and_its_directory_report_the_same_filesystem() {
91 let dir = tempfile::tempdir().expect("tempdir");
92 let file = dir.path().join("probe.txt");
93 std::fs::write(&file, b"probe").expect("write");
94
95 let from_dir = available_bytes(dir.path()).expect("free space via the directory");
96 let from_file = available_bytes(&file).expect("free space via the file");
97 let drift = from_dir.abs_diff(from_file);
98 // 64 MiB of slack: generous enough that an unrelated process writing
99 // during the test cannot fail it, tight enough that two different
100 // filesystems would not pass.
101 assert!(
102 drift < 64 * 1024 * 1024,
103 "{from_dir} vs {from_file} differ by {drift}"
104 );
105 }
106
107 /// An unmeasurable path answers `None`, and the caller has to be able to
108 /// tell that apart from "measured, and it is zero".
109 ///
110 /// The empty path rather than a missing one, and that distinction is the
111 /// whole reason this comment exists. A *missing* path is unmeasurable on
112 /// Unix and perfectly measurable on Windows, where `fs4` resolves to the
113 /// volume first - so asserting `None` for one passed on three CI legs and
114 /// failed on the fourth, and left this branch unreachable there. An empty
115 /// path has no volume to resolve to on either.
116 #[test]
117 fn an_unmeasurable_path_answers_none() {
118 assert_eq!(available_bytes(Path::new("")), None);
119 assert_eq!(total_bytes(Path::new("")), None);
120 }
121}