deps_core/fs_probe.rs
1//! Counted filesystem probe funnel, shared by every ecosystem crate's config-file cache.
2//!
3//! [`crate::mtime_cache::MtimeFileCache`] claims "one stat, zero reads on a cache hit" — a
4//! claim `Arc::ptr_eq` on the returned value cannot verify, since that only proves the
5//! parsed *value* was reused, not that no syscall ran to get there. Counting actual
6//! `stat`/`read` calls needs a single chokepoint every cache implementation routes through,
7//! so the count is trustworthy across crate boundaries.
8//!
9//! Most wrappers below are a bare passthrough to their `std::fs` equivalent in a shipped
10//! build (the exception is [`read_to_string_capped`], which adds a real size bound on top of
11//! `File::open`/`Read::take`); the counters are compiled out entirely unless this crate is
12//! built for its own tests or with the `test-util` feature, so counting one function's calls
13//! costs nothing in production. `cfg(test)` alone cannot gate the public `snapshot` function,
14//! because
15//! `deps-core` is an ordinary (non-dev) dependency of `deps-cargo`/`deps-npm` — it is never
16//! compiled with `cfg(test)` when a downstream crate's own tests build, so the `test-util`
17//! feature is what those crates enable in their `dev-dependencies` instead.
18
19use std::io::Read;
20use std::path::Path;
21
22/// Shared upper bound on how many ancestor directories a config-file discovery walk
23/// climbs, independent of whether the filesystem root has been reached.
24///
25/// `deps-gradle`, `deps-cargo`, `deps-npm`, and `deps-nuget` all import this canonical
26/// definition directly for their own workspace-root / config-file ancestor walks, rather
27/// than each declaring their own duplicate. 64 directories up is not a realistic project
28/// layout — a pathologically deep or hostile tree hits this cap instead of doing unbounded
29/// work per parse (CWE-400).
30pub const MAX_CONFIG_ANCESTOR_DEPTH: usize = 64;
31
32#[cfg(any(test, feature = "test-util"))]
33use std::sync::atomic::{AtomicUsize, Ordering};
34
35#[cfg(any(test, feature = "test-util"))]
36static STAT_COUNT: AtomicUsize = AtomicUsize::new(0);
37#[cfg(any(test, feature = "test-util"))]
38static READ_COUNT: AtomicUsize = AtomicUsize::new(0);
39
40/// Counted wrapper around [`std::fs::metadata`].
41///
42/// # Errors
43///
44/// Returns an error under the same conditions as [`std::fs::metadata`] — most commonly,
45/// `path` does not exist or is not accessible.
46pub fn metadata(path: &Path) -> std::io::Result<std::fs::Metadata> {
47 #[cfg(any(test, feature = "test-util"))]
48 STAT_COUNT.fetch_add(1, Ordering::Relaxed);
49 std::fs::metadata(path)
50}
51
52/// Counted, size-bounded wrapper around [`std::fs::File::open`] + [`Read::take`].
53///
54/// Reads at most `max_bytes + 1` bytes and returns `Ok(None)` if that read produced more than
55/// `max_bytes` — the one extra byte is what distinguishes "exactly `max_bytes` long" from
56/// "longer than `max_bytes`" without reading the whole (potentially huge) file. Unlike a
57/// `stat`-then-`read_to_string` sequence, this bound is enforced by the read call itself, so
58/// it holds even if the file grows, or is swapped via a symlink, between a caller's earlier
59/// `stat` and this call.
60///
61/// # Errors
62///
63/// Returns an error under the same conditions as [`std::fs::read_to_string`] — most commonly,
64/// `path` does not exist, is not accessible, or the bounded content is not valid UTF-8.
65pub fn read_to_string_capped(path: &Path, max_bytes: u64) -> std::io::Result<Option<String>> {
66 #[cfg(any(test, feature = "test-util"))]
67 READ_COUNT.fetch_add(1, Ordering::Relaxed);
68 let file = std::fs::File::open(path)?;
69 let mut buf = Vec::new();
70 file.take(max_bytes.saturating_add(1))
71 .read_to_end(&mut buf)?;
72 if buf.len() as u64 > max_bytes {
73 return Ok(None);
74 }
75 String::from_utf8(buf)
76 .map(Some)
77 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.utf8_error()))
78}
79
80/// Whether `path` exists and is a regular file — `false` on any error, including a missing
81/// path.
82///
83/// Deliberately rejects a FIFO, socket, character device, or directory at `path`: unlike a
84/// regular file, reading one of those can **block the calling thread indefinitely**, which
85/// would stall the parse. [`std::fs::metadata`] follows symlinks, so a symlinked regular
86/// file still resolves as a file here.
87///
88/// Callers that already hold a [`std::fs::Metadata`] from [`metadata`] (as
89/// [`crate::mtime_cache::MtimeFileCache`] does) should call `.is_file()` on it directly
90/// rather than re-probing through here.
91///
92/// # Examples
93///
94/// ```
95/// use deps_core::fs_probe::is_file;
96/// use std::path::Path;
97///
98/// assert!(!is_file(Path::new("/nonexistent/path/to/nowhere")));
99/// ```
100#[must_use]
101pub fn is_file(path: &Path) -> bool {
102 metadata(path).is_ok_and(|m| m.is_file())
103}
104
105/// Whether `path` exists — `false` on any error, including a missing path.
106#[must_use]
107pub fn exists(path: &Path) -> bool {
108 metadata(path).is_ok()
109}
110
111/// The current `(stat_count, read_count)` totals.
112///
113/// For a test to snapshot before an operation and diff against afterward — never a global
114/// "reset to zero", since `cargo nextest` gives each test its own process but a bare
115/// count-from-zero would still race a hypothetical future multi-threaded runner.
116#[cfg(feature = "test-util")]
117#[must_use]
118pub fn snapshot() -> (usize, usize) {
119 (
120 STAT_COUNT.load(Ordering::Relaxed),
121 READ_COUNT.load(Ordering::Relaxed),
122 )
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn read_to_string_capped_returns_content_under_cap() {
131 let dir = tempfile::tempdir().unwrap();
132 let path = dir.path().join("small.txt");
133 std::fs::write(&path, "hello").unwrap();
134
135 assert_eq!(
136 read_to_string_capped(&path, 1024).unwrap().as_deref(),
137 Some("hello")
138 );
139 }
140
141 /// A file exactly at the cap must be read in full, not rejected as one byte too many —
142 /// an off-by-one here would falsely reject every file that happens to land exactly on
143 /// the boundary.
144 #[test]
145 fn read_to_string_capped_accepts_content_exactly_at_cap() {
146 let dir = tempfile::tempdir().unwrap();
147 let path = dir.path().join("exact.txt");
148 std::fs::write(&path, "abcde").unwrap();
149
150 assert_eq!(
151 read_to_string_capped(&path, 5).unwrap().as_deref(),
152 Some("abcde")
153 );
154 }
155
156 /// The read itself must reject content over the cap — this holds regardless of what any
157 /// separate `stat` call reported for the same path, which is the property that closes the
158 /// TOCTOU gap (CWE-367) between a size check and a subsequent unbounded read.
159 #[test]
160 fn read_to_string_capped_rejects_content_over_cap() {
161 let dir = tempfile::tempdir().unwrap();
162 let path = dir.path().join("over.txt");
163 std::fs::write(&path, "abcdef").unwrap();
164
165 assert_eq!(read_to_string_capped(&path, 5).unwrap(), None);
166 }
167
168 #[test]
169 fn read_to_string_capped_missing_path_errors() {
170 assert!(read_to_string_capped(Path::new("/nonexistent/path/file.txt"), 1024).is_err());
171 }
172}