1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
//! Counted filesystem probe funnel, shared by every ecosystem crate's config-file cache.
//!
//! [`crate::mtime_cache::MtimeFileCache`] claims "one stat, zero reads on a cache hit" — a
//! claim `Arc::ptr_eq` on the returned value cannot verify, since that only proves the
//! parsed *value* was reused, not that no syscall ran to get there. Counting actual
//! `stat`/`read` calls needs a single chokepoint every cache implementation routes through,
//! so the count is trustworthy across crate boundaries.
//!
//! Most wrappers below are a bare passthrough to their `std::fs` equivalent in a shipped
//! build (the exception is [`read_to_string_capped`], which adds a real size bound on top of
//! `File::open`/`Read::take`); the counters are compiled out entirely unless this crate is
//! built for its own tests or with the `test-util` feature, so counting one function's calls
//! costs nothing in production. `cfg(test)` alone cannot gate the public `snapshot` function,
//! because
//! `deps-core` is an ordinary (non-dev) dependency of `deps-cargo`/`deps-npm` — it is never
//! compiled with `cfg(test)` when a downstream crate's own tests build, so the `test-util`
//! feature is what those crates enable in their `dev-dependencies` instead.
//!
//! Any test in `deps-core`, `deps-gradle`, `deps-cargo`, `deps-npm`, `deps-nuget`, or
//! `deps-lsp` that touches `fs_probe` (directly, through [`crate::mtime_cache::MtimeFileCache`],
//! through an ecosystem's own config-file cache, or through `Ecosystem::parse_manifest`) must
//! hold [`snapshot_guard`]/[`snapshot_guard_async`] for the duration of that touch — see
//! [`snapshot_guard`]'s doc for why. Nothing enforces this at compile time; run
//! `scripts/check-fs-probe-race.sh` after adding a new fs_probe-touching test in one of those
//! six crates to catch a missing guard before it reaches CI's i686 cross-test leg (issue #806).
use Read;
use Path;
/// Shared upper bound on how many ancestor directories a config-file discovery walk
/// climbs, independent of whether the filesystem root has been reached.
///
/// `deps-gradle`, `deps-cargo`, `deps-npm`, and `deps-nuget` all import this canonical
/// definition directly for their own workspace-root / config-file ancestor walks, rather
/// than each declaring their own duplicate. 64 directories up is not a realistic project
/// layout — a pathologically deep or hostile tree hits this cap instead of doing unbounded
/// work per parse (CWE-400).
pub const MAX_CONFIG_ANCESTOR_DEPTH: usize = 64;
/// Ancestor directories of `start_dir`, inclusive, capped at [`MAX_CONFIG_ANCESTOR_DEPTH`].
///
/// Yields `start_dir` itself first, then each successive parent, stopping at whichever
/// comes first: the filesystem root, or [`MAX_CONFIG_ANCESTOR_DEPTH`] directories visited.
/// This is the single canonical replacement for the hand-written
/// `while let Some(dir) = current { if depth >= MAX_CONFIG_ANCESTOR_DEPTH { break; } ... }`
/// loop every ancestor-config-file walk in this workspace used to duplicate — the depth
/// bound is enforced by the iterator itself, so a caller cannot forget it.
///
/// # Examples
///
/// ```
/// use deps_core::fs_probe::config_ancestors;
/// use std::path::Path;
///
/// let dirs: Vec<_> = config_ancestors(Path::new("/a/b/c")).collect();
/// assert_eq!(
/// dirs,
/// vec![Path::new("/a/b/c"), Path::new("/a/b"), Path::new("/a"), Path::new("/")]
/// );
/// ```
use ;
static STAT_COUNT: AtomicUsize = new;
static READ_COUNT: AtomicUsize = new;
/// Counted wrapper around [`std::fs::metadata`].
///
/// # Errors
///
/// Returns an error under the same conditions as [`std::fs::metadata`] — most commonly,
/// `path` does not exist or is not accessible.
/// Counted, size-bounded wrapper around [`std::fs::File::open`] + [`Read::take`].
///
/// Reads at most `max_bytes + 1` bytes and returns `Ok(None)` if that read produced more than
/// `max_bytes` — the one extra byte is what distinguishes "exactly `max_bytes` long" from
/// "longer than `max_bytes`" without reading the whole (potentially huge) file. Unlike a
/// `stat`-then-`read_to_string` sequence, this bound is enforced by the read call itself, so
/// it holds even if the file grows, or is swapped via a symlink, between a caller's earlier
/// `stat` and this call.
///
/// # Errors
///
/// Returns an error under the same conditions as [`std::fs::read_to_string`] — most commonly,
/// `path` does not exist, is not accessible, or the bounded content is not valid UTF-8.
/// Whether `path` exists and is a regular file — `false` on any error, including a missing
/// path.
///
/// Deliberately rejects a FIFO, socket, character device, or directory at `path`: unlike a
/// regular file, reading one of those can **block the calling thread indefinitely**, which
/// would stall the parse. [`std::fs::metadata`] follows symlinks, so a symlinked regular
/// file still resolves as a file here.
///
/// Callers that already hold a [`std::fs::Metadata`] from [`metadata`] (as
/// [`crate::mtime_cache::MtimeFileCache`] does) should call `.is_file()` on it directly
/// rather than re-probing through here.
///
/// # Examples
///
/// ```
/// use deps_core::fs_probe::is_file;
/// use std::path::Path;
///
/// assert!(!is_file(Path::new("/nonexistent/path/to/nowhere")));
/// ```
/// Whether `path` exists — `false` on any error, including a missing path.
/// The current `(stat_count, read_count)` totals.
///
/// For a test to snapshot before an operation and diff against afterward — never a global
/// "reset to zero", since `cargo nextest` gives each test its own process but a bare
/// count-from-zero would still race a hypothetical future multi-threaded runner.
/// Backing lock for [`snapshot_guard`]/[`snapshot_guard_async`] — shared so a sync and an
/// async caller serialize against each other, not just against callers of the same function.
static SNAPSHOT_LOCK: Mutex = const_new;
/// Serializes a `snapshot`-before/operation/`snapshot`-after/assert section, run from a plain
/// (non-async) test, against every other test doing the same.
///
/// [`snapshot`] reads the process-global `STAT_COUNT`/`READ_COUNT` counters, so a
/// before/after diff is only race-free when nothing else touches `fs_probe` between the two
/// calls. `cargo nextest run` gives each test its own process, which happens to make that
/// true — but plain `cargo test` runs every test in one multi-threaded process, where a
/// concurrently running `fs_probe`-touching test can bump the same counters mid-section and
/// produce a spurious failure. Hold this guard for the whole section under measurement
/// (both `snapshot()` calls and everything between them), not just around the calls
/// themselves, so no other snapshot-based test can interleave.
///
/// This is not limited to tests that themselves diff a snapshot: **every** test in the same
/// binary that calls an `fs_probe`-touching function (directly or transitively, e.g. through
/// [`crate::mtime_cache::MtimeFileCache`] or an ecosystem's own config-file cache) must also
/// acquire this guard for the duration of that call — otherwise it can still bump the
/// counters mid-diff even though it never calls [`snapshot`] itself. Verified empirically:
/// guarding only the diffing test left `deps-gradle`'s and `deps-core`'s own snapshot-diff
/// tests flaky under plain `cargo test` (siblings in the same file that call the same
/// instrumented function without diffing were still free to interleave) until every
/// fs_probe-touching test in those files took the guard too.
///
/// Use [`snapshot_guard_async`] instead from an `async fn` test.
///
/// # Panics
///
/// Panics if called from inside an async execution context (e.g. from within a
/// `#[tokio::test] async fn` — use [`snapshot_guard_async`] there instead).
///
/// # Examples
///
/// ```
/// use deps_core::fs_probe::{snapshot, snapshot_guard};
///
/// let _guard = snapshot_guard();
/// let (stats_before, _) = snapshot();
/// // ... perform the operation under test ...
/// let (stats_after, _) = snapshot();
/// assert!(stats_after >= stats_before);
/// ```
/// Async counterpart of [`snapshot_guard`], for a test whose measured section spans an
/// `.await` point (e.g. an async `load_document_from_disk` call).
///
/// A `std::sync::MutexGuard` held across an `.await` would stall the runtime for the
/// duration of the wait — this returns a `tokio::sync::MutexGuard` instead, which is safe to
/// hold across `.await`.
///
/// # Panics
///
/// Never panics — unlike [`snapshot_guard`], this is always safe to call from an async
/// execution context, since it yields to the runtime instead of blocking the thread.
///
/// # Examples
///
/// ```
/// use deps_core::fs_probe::{snapshot, snapshot_guard_async};
///
/// # #[tokio::main]
/// # async fn main() {
/// let _guard = snapshot_guard_async().await;
/// let (stats_before, _) = snapshot();
/// // ... perform the async operation under test ...
/// let (stats_after, _) = snapshot();
/// assert!(stats_after >= stats_before);
/// # }
/// ```
pub async