lanekeep_core/files.rs
1//! Tracked, confined filesystem reads.
2//!
3//! The only way a rule reaches a file other than the one it is checking. Two properties have
4//! to hold together, and neither is optional:
5//!
6//! **Confinement.** A read resolves inside the project root or it fails. Traversal is
7//! rejected lexically before the filesystem is touched, so `../../../etc/passwd` produces a
8//! message about escaping the root rather than a confusing "not found" — and the resolved
9//! path is canonicalized and re-checked, so a symlink inside the root pointing outside it is
10//! rejected too. A lexical check alone would see an innocent relative path and allow it.
11//!
12//! **Tracking.** Every read is recorded as `(path, content_hash)`, including reads that
13//! found nothing. That record is what a cache entry needs to know when it has gone stale;
14//! see [`crate::tracked`].
15//!
16//! # Reads are memoized within a run
17//!
18//! Reading the same path twice returns the same bytes, even if something rewrote the file in
19//! between. A rule that saw a file change under it could report differently on two runs over
20//! identical input, which is the determinism invariant — and the cache would record one of
21//! the two hashes with no way to say which was used.
22//!
23//! # Why this lives in `lanekeep-core` rather than in an engine crate
24//!
25//! Every engine that runs a rule needs the same confinement and the same tracking — a read
26//! `lanekeep-wasm`'s component runtime allows that `lanekeep-js`'s sandbox forbids, or
27//! records differently, would make a cache entry mean something different depending on
28//! which engine happened to produce it. Defining `FileAccess` once, below every engine
29//! rather than inside one of them, is what keeps that question from being askable.
30
31use std::collections::BTreeMap;
32use std::path::{Component, Path, PathBuf};
33use std::sync::Mutex;
34
35use crate::tracked::{ContentHash, TrackedRead};
36use crate::{FilePath, tracked};
37use thiserror::Error;
38
39/// Why a read was refused.
40///
41/// Distinct from "nothing was there", which is an ordinary answer a rule handles.
42#[derive(Debug, Clone, PartialEq, Eq, Error)]
43pub enum ReadError {
44 /// The path resolves outside the project root.
45 #[error(
46 "cannot read `{path}`\n \
47 it resolves outside the project root, and rules may only read files within it"
48 )]
49 EscapesRoot {
50 /// The path as the rule wrote it.
51 path: String,
52 },
53
54 /// The path was absolute.
55 #[error(
56 "cannot read `{path}`\n \
57 reads are relative to the project root — an absolute path would make the rule \
58 depend on where the project happens to be checked out"
59 )]
60 Absolute {
61 /// The path as the rule wrote it.
62 path: String,
63 },
64
65 /// The file exists but is not text.
66 #[error(
67 "cannot read `{path}` as text: it is not valid UTF-8\n \
68 use ctx.fileExists if the question is whether it is there"
69 )]
70 NotText {
71 /// The path as the rule wrote it.
72 path: String,
73 },
74}
75
76/// What a resolved path turned out to hold.
77#[derive(Debug, Clone, PartialEq, Eq)]
78enum Outcome {
79 /// The file was read.
80 Text(String, ContentHash),
81 /// Nothing was there.
82 Absent,
83 /// It was there and is not text, and these are its bytes' digest.
84 ///
85 /// The hash is carried even though nothing can parse these bytes, because a *dependency*
86 /// on a binary file is a real one — replace an image with text and a rule's answer can
87 /// change — and the validator recomputes the digest of whatever is at the path now. A
88 /// zero placeholder was recorded here once, which no real file ever hashes to, so every
89 /// entry naming a binary dependency invalidated on every run.
90 Binary(ContentHash),
91 /// It resolved out of the root through a symlink, so it was refused unread.
92 ///
93 /// Recorded rather than discarded, and this is the whole reason the enum has a fourth
94 /// variant: a refusal is an answer a cached result depends on. A provider probing
95 /// `node_modules/pkg/index.d.ts`, where `node_modules/pkg` is a pnpm symlink out of the
96 /// tree, is refused, answers `undefined`, and that `undefined` has to be reconsidered the
97 /// day the path becomes a real in-root file. With the refusal unrecorded the path is in no
98 /// dependency list and nothing ever invalidates.
99 ///
100 /// Only the symlink case, and the difference is whether the answer can ever change — for
101 /// two different reasons, which the two other refusals do not share. A lexically escaping
102 /// path (`../secrets`) names nothing inside the root under any state of the filesystem: no
103 /// rename, install or `mkdir` can make it resolve in-root. An absolute path may well name
104 /// an in-root file — `/home/me/project/a.ts` under that very root does — and is refused
105 /// anyway, by a rule of [`Self::load`]'s that no state of the filesystem can change. So
106 /// for both the answer is fixed, and recording them would add a path to every entry that
107 /// nothing could ever invalidate on. A symlink escape is the opposite: `npm install`
108 /// replacing the link with a real directory is the ordinary case, and that is a change the
109 /// entry depends on.
110 ///
111 /// Recorded as its own outcome rather than folded into [`Outcome::Absent`], because a
112 /// validator has to reproduce *this* decision: it re-resolves the path under the same
113 /// confinement and holds the entry while it still escapes. See `lanekeep_cache::validate`.
114 Refused,
115}
116
117/// Tracked, confined access to the project's files.
118#[derive(Debug)]
119pub struct FileAccess {
120 root: PathBuf,
121 /// Everything resolved so far this file, keyed by project-relative path.
122 ///
123 /// A `BTreeMap` rather than a hash map: it is small, and iterating it in path order
124 /// makes the recorded dependency list deterministic without a separate sort.
125 ///
126 /// # A [`Mutex`] rather than a `RefCell`, so *one* memo can serve both engines
127 ///
128 /// It was a `RefCell` until two rule-execution engines had to share one of these. A
129 /// `RefCell` is `Send` and not `Sync`, so `Arc<FileAccess>` was not `Send` — and
130 /// `lanekeep_wasm::host::CheckContext` is required to be `Send`, because it lives in a
131 /// [`wasmtime::Store`] that rayon moves. That left the component engine no way to hold a
132 /// shared access, so it would have owned a second one per file, with a second memo.
133 ///
134 /// **Two memos over one file is not a tidiness problem, it is the determinism invariant.**
135 /// The memo exists so that a file rewritten mid-run cannot be seen two ways; a second one
136 /// beside it reintroduces exactly that, across engines rather than within one. And the
137 /// dependency lists cannot be merged afterwards to repair it: [`tracked::sort`] orders by
138 /// path and does **not** dedupe, so two lists disagreeing about one path's hash concatenate
139 /// into two contradictory entries for it, which is a cache entry that can never be
140 /// validated.
141 ///
142 /// The lock is uncontended by construction — an access belongs to one file, and a file
143 /// belongs to one worker — so it costs an atomic swap on a path that already touches the
144 /// filesystem. Poisoning is treated as "take the value anyway": nothing under this lock can
145 /// panic, and refusing to read a memo because an unrelated thread died would turn a rule's
146 /// read into a failure for a reason that has nothing to do with it.
147 seen: Mutex<BTreeMap<String, Outcome>>,
148}
149
150/// One access can be shared by both engines, checked at compile time rather than believed.
151///
152/// `Arc<FileAccess>: Send` needs `FileAccess: Send + Sync`, and that is the whole reason the
153/// memo is a [`Mutex`] — see the field. Without it the component engine cannot hold a shared
154/// access at all, because `lanekeep_wasm::host::CheckContext` is required to be `Send`, and it
155/// would silently fall back to a second memo per file.
156///
157/// A `const` block rather than a test, for the reason `lanekeep-wasm`'s equivalent is one: this
158/// is a property of the type, and a violation should stop the build at the field that caused it
159/// rather than surface as an unsatisfied bound in another crate.
160const _: () = {
161 const fn assert_shareable<T: Send + Sync>() {}
162 assert_shareable::<FileAccess>();
163};
164
165impl FileAccess {
166 /// Anchor reads at a project root, canonicalizing it.
167 ///
168 /// Every containment check compares against the root, so it has to be canonical or a
169 /// symlinked checkout would fail every check. Callers that already hold a canonical
170 /// root should use [`FileAccess::rooted`] instead — this is one syscall, and the engine
171 /// builds an access per file.
172 #[must_use]
173 pub fn new(root: &Path) -> Self {
174 Self::rooted(root.canonicalize().unwrap_or_else(|_| root.to_path_buf()))
175 }
176
177 /// Anchor reads at an already-canonical root.
178 ///
179 /// Cheap enough to call per file, which is what the engine does: a fresh access per
180 /// file makes it structurally impossible for one file's reads to be recorded against
181 /// another's, rather than making it depend on a reset being called in the right place.
182 #[must_use]
183 pub fn rooted(root: PathBuf) -> Self {
184 Self {
185 root,
186 seen: Mutex::new(BTreeMap::new()),
187 }
188 }
189
190 /// The memo, whether or not another thread died holding it.
191 ///
192 /// See the field's own documentation: nothing under this lock can panic, and a rule's read
193 /// must not fail because of something that happened elsewhere.
194 fn memo(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Outcome>> {
195 self.seen
196 .lock()
197 .unwrap_or_else(std::sync::PoisonError::into_inner)
198 }
199
200 /// The project root reads are confined to.
201 #[must_use]
202 pub fn root(&self) -> &Path {
203 &self.root
204 }
205
206 /// Read a file's text, or `None` if nothing is there.
207 ///
208 /// # Errors
209 ///
210 /// [`ReadError`] if the path escapes the root, is absolute, or holds something that is
211 /// not text. Absence is not an error — a rule asking whether a config is present should
212 /// not have to catch to find out.
213 pub fn read(&self, path: &str) -> Result<Option<String>, ReadError> {
214 match self.resolve(path)? {
215 Outcome::Text(text, _) => Ok(Some(text)),
216 Outcome::Absent => Ok(None),
217 Outcome::Binary(_) => Err(ReadError::NotText {
218 path: path.to_owned(),
219 }),
220 // Never reached: `resolve` turns a refusal into `ReadError::EscapesRoot` before
221 // it returns. Spelled out rather than left to a wildcard so that a fifth outcome
222 // has to be decided here rather than silently reading as absent.
223 Outcome::Refused => Err(ReadError::EscapesRoot {
224 path: path.to_owned(),
225 }),
226 }
227 }
228
229 /// The hash of a file's bytes, or `None` if nothing readable is there.
230 ///
231 /// Goes through the same resolution every other read does, so it is confined the
232 /// same way and **recorded as a dependency exactly as [`Self::read`] would be** — a caller
233 /// that asked only for the hash still depended on the file, and an entry that did not list
234 /// it would validate after the file changed.
235 ///
236 /// What it saves is the *text*: a caller holding a parse of these bytes wants to know
237 /// whether the parse is still the right one, and that is a comparison against a digest the
238 /// memo already computed. Returning the `String` for it would clone a whole declaration
239 /// file per importer to answer a question about thirty-two bytes.
240 ///
241 /// `None` covers absence and a file that is there but is not text. A binary file *is*
242 /// hashed — the dependency it becomes carries that digest — but it cannot be parsed, and
243 /// this method answers a caller asking whether it holds the current parse of these bytes.
244 /// For a file no parse can be made of, the answer is no however the bytes hash.
245 ///
246 /// # Errors
247 ///
248 /// [`ReadError`] if the path escapes the root or is absolute — the same refusals
249 /// [`Self::read`] makes, for the same reasons.
250 pub fn hash_of(&self, path: &str) -> Result<Option<ContentHash>, ReadError> {
251 match self.resolve(path)? {
252 Outcome::Text(_, hash) => Ok(Some(hash)),
253 Outcome::Absent | Outcome::Binary(_) => Ok(None),
254 // Never reached: `resolve` turns a refusal into `ReadError::EscapesRoot` before it
255 // returns. Spelled out rather than left to a wildcard, so that a fifth outcome has
256 // to be decided here rather than silently reading as absent.
257 Outcome::Refused => Err(ReadError::EscapesRoot {
258 path: path.to_owned(),
259 }),
260 }
261 }
262
263 /// Whether a file is there.
264 ///
265 /// A file that exists but is not text still exists — this answers the question asked,
266 /// where returning `false` would claim something untrue about the filesystem.
267 ///
268 /// # Errors
269 ///
270 /// [`ReadError`] if the path escapes the root or is absolute.
271 pub fn exists(&self, path: &str) -> Result<bool, ReadError> {
272 Ok(!matches!(self.resolve(path)?, Outcome::Absent))
273 }
274
275 /// Everything read so far, in path order.
276 #[must_use]
277 pub fn dependencies(&self) -> Vec<TrackedRead> {
278 let mut reads: Vec<TrackedRead> = self
279 .memo()
280 .iter()
281 .map(|(path, outcome)| {
282 let file = FilePath::new(path);
283 match outcome {
284 // One arm for both, because both were read and both hashed. A file that
285 // is there but unreadable as text is a dependency exactly as a readable
286 // one is: replace it with text and the rule's answer changes, and the
287 // digest of the bytes is what says whether it has been. Clippy refuses
288 // the two written separately as `match_same_arms`, and they are.
289 Outcome::Text(_, hash) | Outcome::Binary(hash) => {
290 TrackedRead::found(file, *hash)
291 }
292 // Nothing was read, so there is no hash — and the entry has to be
293 // reconsidered if the path ever becomes readable, which is exactly what an
294 // absent dependency means.
295 Outcome::Absent => TrackedRead::absent(file),
296 // Recorded as *refused* rather than as absent, because the two are checked
297 // differently: absence is rechecked by looking for the file, and a
298 // validator that did that here would follow the symlink, find the target,
299 // and invalidate on every run — after reading bytes outside the root to
300 // decide it. See `lanekeep_cache::validate`.
301 Outcome::Refused => TrackedRead::refused(file),
302 }
303 })
304 .collect();
305 tracked::sort(&mut reads);
306 reads
307 }
308
309 /// Forget everything, for an embedder reusing one access across several files.
310 ///
311 /// The engine does not use this — it builds an access per file, so there is nothing to
312 /// forget. Kept because reuse is a reasonable thing for an embedder to want, and a
313 /// half-populated access is not.
314 pub fn clear(&self) {
315 self.memo().clear();
316 }
317
318 /// Resolve, read and record a path, or return what was already recorded.
319 ///
320 /// **The lock is dropped between the miss and the insert, so check-then-insert is not
321 /// atomic.** That is deliberate — holding it across [`Self::load`] would hold a lock across
322 /// a filesystem read, which is the shape that turns an uncontended mutex into a contended
323 /// one — and it is sound only under the construction described on the `seen` field: one
324 /// access per file, one worker per file. Two threads racing the same access would both read
325 /// and the second would overwrite the first, so the memo would still hold *an* answer and
326 /// still return one consistently, but the guarantee "a file rewritten mid-run is seen one
327 /// way" would rest on which write landed last rather than on the memo.
328 ///
329 /// So the invariant now rests on the caller's construction rather than on the type. If an
330 /// embedder ever shares one access across threads, this wants an entry API — `load` inside
331 /// the guard, or a per-key lock — rather than a comment.
332 fn resolve(&self, path: &str) -> Result<Outcome, ReadError> {
333 let key = normalize_key(path);
334 if let Some(outcome) = self.memo().get(&key) {
335 return Self::answer(path, outcome.clone());
336 }
337
338 let outcome = self.load(path)?;
339 self.memo().insert(key, outcome.clone());
340 Self::answer(path, outcome)
341 }
342
343 /// Turn a recorded outcome into what the caller asked for.
344 ///
345 /// [`Outcome::Refused`] is recorded and *then* refused, in that order: the memo is what
346 /// puts the path into [`Self::dependencies`], and the error is what the caller has always
347 /// been told. Doing it the other way round — returning the error from [`Self::load`]
348 /// before the insert — is the bug this exists to close, and it is invisible from the
349 /// caller's side, since the message it gets is identical either way.
350 fn answer(path: &str, outcome: Outcome) -> Result<Outcome, ReadError> {
351 match outcome {
352 Outcome::Refused => Err(ReadError::EscapesRoot {
353 path: path.to_owned(),
354 }),
355 other => Ok(other),
356 }
357 }
358
359 /// Do the actual filesystem work, having decided the path is allowed.
360 fn load(&self, path: &str) -> Result<Outcome, ReadError> {
361 let relative = Path::new(path);
362 if relative.is_absolute() || relative.has_root() {
363 // `has_root` as well as `is_absolute`, because `\windows\path` is rooted but not
364 // absolute on Windows — and a check that passes on one platform and not the
365 // other is worse than no check.
366 return Err(ReadError::Absolute {
367 path: path.to_owned(),
368 });
369 }
370
371 // Lexically first, so an escape is named as one whether or not the target exists.
372 let normalized = normalize(relative);
373 if normalized
374 .components()
375 .any(|c| matches!(c, Component::ParentDir))
376 {
377 return Err(ReadError::EscapesRoot {
378 path: path.to_owned(),
379 });
380 }
381
382 let full = self.root.join(&normalized);
383 let Ok(canonical) = full.canonicalize() else {
384 // Nothing there. Not an error, and deliberately not distinguished from a
385 // permission failure: either way the rule cannot see it, and a rule that
386 // branched on the difference would give different answers on different machines.
387 return Ok(Outcome::Absent);
388 };
389
390 // And again after canonicalizing, which is what catches a symlink inside the root
391 // pointing outside it. The lexical check above cannot see through one. Refused, and
392 // recorded as refused — see `Outcome::Refused`: this path is one the filesystem can
393 // later make readable, so the answer that rested on the refusal has to be
394 // invalidated when it does.
395 if !canonical.starts_with(&self.root) {
396 return Ok(Outcome::Refused);
397 }
398
399 let Ok(bytes) = std::fs::read(&canonical) else {
400 return Ok(Outcome::Absent);
401 };
402 let hash = ContentHash::new(*blake3::hash(&bytes).as_bytes());
403
404 match String::from_utf8(bytes) {
405 Ok(text) => Ok(Outcome::Text(text, hash)),
406 Err(_) => Ok(Outcome::Binary(hash)),
407 }
408 }
409}
410
411/// The key a path is recorded under, so `./a.json` and `a.json` are one dependency.
412fn normalize_key(path: &str) -> String {
413 normalize(Path::new(path))
414 .to_string_lossy()
415 .replace('\\', "/")
416}
417
418/// Resolve `.` and `..` lexically, without consulting the filesystem.
419///
420/// A traversal attempt has to be rejected with a message about escaping the root whether or
421/// not the target happens to exist, which `canonicalize` alone cannot do.
422///
423/// A leading `..` is kept as a marker so the caller's containment check can see it — and,
424/// critically, a later `..` must not pop that marker. `../../etc/passwd` popping its own
425/// first `..` would collapse to `etc/passwd`, which looks contained, and the read would
426/// then resolve to `<root>/etc/passwd`: not an escape, but silently the wrong file. Depth
427/// counts only real segments, so a marker can never be consumed.
428///
429/// `pub` rather than `pub(crate)`: `lanekeep-js`'s module loader resolves rule specifiers
430/// against the same lexical rule (a different root, a different reason to reject `..`, the
431/// identical algorithm), and sharing this one function is what keeps that algorithm defined
432/// once rather than copied at its second call site.
433#[must_use]
434pub fn normalize(path: &Path) -> PathBuf {
435 let mut out = PathBuf::new();
436 let mut depth = 0usize;
437
438 for component in path.components() {
439 match component {
440 Component::CurDir => {}
441 Component::ParentDir => {
442 if depth > 0 {
443 out.pop();
444 depth -= 1;
445 } else {
446 out.push("..");
447 }
448 }
449 other => {
450 out.push(other.as_os_str());
451 depth += 1;
452 }
453 }
454 }
455
456 out
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 struct Fixture {
464 dir: PathBuf,
465 }
466
467 impl Fixture {
468 fn new(name: &str, files: &[(&str, &str)]) -> Self {
469 let dir =
470 std::env::temp_dir().join(format!("lanekeep-files-{name}-{}", std::process::id()));
471 let _ = std::fs::remove_dir_all(&dir);
472 std::fs::create_dir_all(&dir).expect("creates dir");
473 let fixture = Self { dir };
474 for (path, contents) in files {
475 let full = fixture.dir.join(path);
476 if let Some(parent) = full.parent() {
477 std::fs::create_dir_all(parent).expect("creates parent");
478 }
479 std::fs::write(full, contents).expect("writes");
480 }
481 fixture
482 }
483
484 fn access(&self) -> FileAccess {
485 FileAccess::new(&self.dir)
486 }
487 }
488
489 impl Drop for Fixture {
490 fn drop(&mut self) {
491 let _ = std::fs::remove_dir_all(&self.dir);
492 }
493 }
494
495 #[test]
496 fn reads_a_file_in_the_root() {
497 let fixture = Fixture::new("read", &[("a.json", "{}")]);
498 let access = fixture.access();
499 assert_eq!(
500 access.read("a.json").expect("allowed"),
501 Some("{}".to_owned())
502 );
503 }
504
505 #[test]
506 fn reads_a_file_in_a_subdirectory() {
507 let fixture = Fixture::new("nested", &[("pkg/a.json", "{\"n\":1}")]);
508 let access = fixture.access();
509 assert_eq!(
510 access.read("pkg/a.json").expect("allowed"),
511 Some("{\"n\":1}".to_owned())
512 );
513 }
514
515 #[test]
516 fn a_missing_file_is_not_an_error() {
517 // A rule asking whether a config is present should not have to catch to find out.
518 let fixture = Fixture::new("missing", &[]);
519 let access = fixture.access();
520 assert_eq!(access.read("nope.json").expect("allowed"), None);
521 assert!(!access.exists("nope.json").expect("allowed"));
522 }
523
524 #[test]
525 fn traversal_out_of_the_root_is_refused() {
526 let fixture = Fixture::new("traversal", &[("a.json", "{}")]);
527 let access = fixture.access();
528 for attempt in ["../outside.json", "../../etc/passwd", "pkg/../../outside"] {
529 let error = access.read(attempt).expect_err("is refused");
530 assert!(
531 matches!(error, ReadError::EscapesRoot { .. }),
532 "`{attempt}` gave {error:?}"
533 );
534 }
535 }
536
537 #[test]
538 fn traversal_that_comes_back_inside_is_allowed() {
539 // `pkg/../a.json` never leaves the root. Refusing it would be a check that punishes
540 // spelling rather than one that protects anything.
541 let fixture = Fixture::new("returns", &[("a.json", "{}"), ("pkg/b.json", "{}")]);
542 let access = fixture.access();
543 assert_eq!(
544 access.read("pkg/../a.json").expect("allowed"),
545 Some("{}".to_owned())
546 );
547 }
548
549 #[test]
550 fn an_absolute_path_is_refused() {
551 // Built from `temp_dir` rather than written literally: `/etc/passwd` is absolute on
552 // Unix and merely rooted on Windows, so a literal takes a different branch on each.
553 let fixture = Fixture::new("absolute", &[]);
554 let access = fixture.access();
555 let outside = std::env::temp_dir().join("lanekeep-absolute-read-probe.json");
556 let error = access
557 .read(&outside.display().to_string())
558 .expect_err("is refused");
559 assert!(matches!(error, ReadError::Absolute { .. }), "{error:?}");
560 }
561
562 #[test]
563 fn a_read_is_recorded_as_a_dependency() {
564 let fixture = Fixture::new("recorded", &[("a.json", "{}")]);
565 let access = fixture.access();
566 access.read("a.json").expect("allowed");
567
568 let deps = access.dependencies();
569 assert_eq!(deps.len(), 1);
570 assert_eq!(deps[0].path.as_str(), "a.json");
571 assert!(deps[0].hash().is_some(), "a file that was read has a hash");
572 }
573
574 #[test]
575 fn a_hash_lookup_is_recorded_exactly_as_a_read_is() {
576 // The whole reason it goes through `resolve`: a caller that asked only for the hash
577 // still depended on the file, and an entry that did not list it would validate after
578 // the file changed.
579 let fixture = Fixture::new("hash-recorded", &[("a.json", "{}")]);
580 let access = fixture.access();
581 let hashed = access
582 .hash_of("a.json")
583 .expect("allowed")
584 .expect("is there");
585
586 let deps = access.dependencies();
587 assert_eq!(deps.len(), 1);
588 assert_eq!(deps[0].path.as_str(), "a.json");
589 assert_eq!(
590 deps[0].hash(),
591 Some(hashed),
592 "the recorded dependency carries the hash that was answered"
593 );
594 }
595
596 #[test]
597 fn a_hash_lookup_answers_what_a_read_would_hash() {
598 // A caller compares this against the digest of a parse it already holds, so the two
599 // have to be the same function of the same bytes.
600 let fixture = Fixture::new("hash-agrees", &[("a.json", "{\"a\": 1}")]);
601 let access = fixture.access();
602 let hashed = access.hash_of("a.json").expect("allowed");
603 let text = access.read("a.json").expect("allowed").expect("is there");
604 assert_eq!(
605 hashed,
606 Some(ContentHash::new(*blake3::hash(text.as_bytes()).as_bytes()))
607 );
608 }
609
610 #[test]
611 fn a_binary_file_is_recorded_with_the_hash_of_its_bytes() {
612 // It is a dependency — replace an image with text and a rule's answer can change — so
613 // it is recorded as found, and what it is found with has to be the digest a validator
614 // recomputes from the same bytes. A zero placeholder stood here, which no real file
615 // hashes to, so every entry naming a binary dependency invalidated on every run.
616 let fixture = Fixture::new("binary-hash", &[]);
617 let bytes = [0xff_u8, 0xfe, 0x00, 0x01];
618 std::fs::write(fixture.dir.join("logo.png"), bytes).expect("writes");
619 let access = fixture.access();
620
621 access.read("logo.png").expect_err("is not text");
622
623 let recorded = access
624 .dependencies()
625 .into_iter()
626 .find(|read| read.path.as_str() == "logo.png")
627 .expect("the binary file is a dependency");
628 assert_eq!(
629 recorded.outcome,
630 tracked::ReadOutcome::Found(ContentHash::new(*blake3::hash(&bytes).as_bytes()))
631 );
632 }
633
634 #[test]
635 fn a_hash_lookup_answers_nothing_for_what_cannot_be_parsed() {
636 // Absent and binary alike: neither can be the input to a parse, so neither has a hash
637 // a caller could compare its parse against.
638 let fixture = Fixture::new("hash-absent", &[]);
639 std::fs::write(fixture.dir.join("image.png"), [0xff, 0xfe, 0x00]).expect("writes");
640 let access = fixture.access();
641 assert_eq!(access.hash_of("nothing.json").expect("allowed"), None);
642 assert_eq!(access.hash_of("image.png").expect("allowed"), None);
643 assert_eq!(
644 access.dependencies().len(),
645 2,
646 "both are still dependencies: {:?}",
647 access.dependencies()
648 );
649 }
650
651 #[test]
652 fn a_hash_lookup_is_confined_like_every_other_read() {
653 let fixture = Fixture::new("hash-confined", &[]);
654 let access = fixture.access();
655 let error = access.hash_of("../outside.json").expect_err("is refused");
656 assert!(matches!(error, ReadError::EscapesRoot { .. }), "{error:?}");
657 }
658
659 #[test]
660 fn a_miss_is_recorded_as_a_dependency() {
661 // The one that makes a cache wrong rather than cold: the answer "not there" has to
662 // be invalidated when the file appears.
663 let fixture = Fixture::new("miss-recorded", &[]);
664 let access = fixture.access();
665 access.exists("tsconfig.json").expect("allowed");
666
667 let deps = access.dependencies();
668 assert_eq!(deps.len(), 1);
669 assert_eq!(deps[0].path.as_str(), "tsconfig.json");
670 assert_eq!(deps[0].hash(), None);
671 }
672
673 #[test]
674 fn a_refused_read_is_not_recorded() {
675 // It never produced an answer, so there is nothing for a cache to depend on.
676 let fixture = Fixture::new("refused", &[]);
677 let access = fixture.access();
678 let _ = access.read("../outside.json");
679 assert!(access.dependencies().is_empty());
680 }
681
682 #[test]
683 fn the_same_file_is_one_dependency_however_it_is_spelled() {
684 let fixture = Fixture::new("spelling", &[("a.json", "{}")]);
685 let access = fixture.access();
686 access.read("a.json").expect("allowed");
687 access.read("./a.json").expect("allowed");
688 access.read("pkg/../a.json").expect("allowed");
689 assert_eq!(access.dependencies().len(), 1);
690 }
691
692 #[test]
693 fn a_second_read_returns_what_the_first_one_saw() {
694 // A rule that saw a file change under it could report differently on two runs over
695 // identical input, and the cache would record one hash with no way to say which
696 // answer used it.
697 let fixture = Fixture::new("memoized", &[("a.json", "before")]);
698 let access = fixture.access();
699 assert_eq!(
700 access.read("a.json").expect("allowed").as_deref(),
701 Some("before")
702 );
703
704 std::fs::write(fixture.dir.join("a.json"), "after").expect("rewrites");
705 assert_eq!(
706 access.read("a.json").expect("allowed").as_deref(),
707 Some("before"),
708 "the run must see one version of a file"
709 );
710 }
711
712 #[test]
713 fn a_binary_file_is_refused_as_text_but_exists() {
714 let fixture = Fixture::new("binary", &[]);
715 std::fs::write(fixture.dir.join("blob.bin"), [0xff, 0xfe, 0x00]).expect("writes");
716 let access = fixture.access();
717
718 let error = access.read("blob.bin").expect_err("is refused");
719 assert!(matches!(error, ReadError::NotText { .. }), "{error:?}");
720 assert!(
721 access.exists("blob.bin").expect("allowed"),
722 "it is there, whatever it holds"
723 );
724 }
725
726 #[test]
727 fn dependencies_come_back_in_path_order() {
728 let fixture = Fixture::new("ordered", &[("b.json", "{}"), ("a.json", "{}")]);
729 let access = fixture.access();
730 access.read("b.json").expect("allowed");
731 access.read("a.json").expect("allowed");
732 access.exists("c.json").expect("allowed");
733
734 assert_eq!(
735 access
736 .dependencies()
737 .iter()
738 .map(|r| r.path.as_str())
739 .collect::<Vec<_>>(),
740 vec!["a.json", "b.json", "c.json"]
741 );
742 }
743
744 #[test]
745 fn clearing_forgets_everything() {
746 let fixture = Fixture::new("cleared", &[("a.json", "{}")]);
747 let access = fixture.access();
748 access.read("a.json").expect("allowed");
749 access.clear();
750 assert!(access.dependencies().is_empty());
751 }
752
753 #[cfg(unix)]
754 #[test]
755 fn a_symlink_out_of_the_root_is_refused() {
756 // The reason the check canonicalizes rather than comparing strings: nothing about
757 // `escape.json` looks like traversal.
758 let fixture = Fixture::new("symlink", &[]);
759 let outside = std::env::temp_dir().join("lanekeep-symlink-target.json");
760 std::fs::write(&outside, "secrets").expect("writes target");
761
762 std::os::unix::fs::symlink(&outside, fixture.dir.join("escape.json"))
763 .expect("creates symlink");
764
765 let access = fixture.access();
766 let error = access.read("escape.json").expect_err("is refused");
767 assert!(matches!(error, ReadError::EscapesRoot { .. }), "{error:?}");
768
769 let _ = std::fs::remove_file(&outside);
770 }
771
772 #[cfg(unix)]
773 #[test]
774 fn a_symlink_out_of_the_root_is_recorded_as_a_refusal() {
775 // The refusal above is the whole answer only if nothing depends on it. A provider
776 // probing `node_modules/pkg/index.d.ts` where `node_modules/pkg` is a pnpm symlink out
777 // of the tree gets `EscapesRoot`, answers `undefined`, and — with the refusal
778 // unrecorded — that answer is cached against a dependency list the path does not
779 // appear in. The day the symlink becomes a real in-root directory, nothing
780 // invalidates.
781 let fixture = Fixture::new("symlink-recorded", &[]);
782 let outside = std::env::temp_dir().join("lanekeep-symlink-recorded-target.json");
783 std::fs::write(&outside, "secrets").expect("writes target");
784 std::os::unix::fs::symlink(&outside, fixture.dir.join("escape.json"))
785 .expect("creates symlink");
786
787 let access = fixture.access();
788 access.read("escape.json").expect_err("is refused");
789 access.exists("escape.json").expect_err("is refused");
790
791 let reads = access.dependencies();
792 let recorded = reads
793 .iter()
794 .find(|read| read.path.as_str() == "escape.json")
795 .expect("the refused path is a dependency");
796 assert_eq!(
797 recorded.outcome,
798 tracked::ReadOutcome::Refused,
799 concat!(
800 "refused, not absent: a validator rechecking absence would follow the link, ",
801 "find the target and invalidate on every run — see `lanekeep_cache::validate`"
802 )
803 );
804
805 let _ = std::fs::remove_file(&outside);
806 }
807
808 #[test]
809 fn a_path_that_can_never_be_in_root_is_not_recorded() {
810 // The other half, on the two grounds `Outcome::Refused` separates: `../secrets` names
811 // nothing inside the root under any future state of the filesystem, and an absolute
812 // path — which may perfectly well name an in-root file — is refused by a rule instead.
813 // Either way the answer is fixed, so recording them would put a path in every cache
814 // entry that the validator would then have to read on every run.
815 let fixture = Fixture::new("refused-unrecorded", &[]);
816 let access = fixture.access();
817 access.read("../secrets.json").expect_err("is refused");
818 access.read("/etc/passwd").expect_err("is refused");
819 assert!(
820 access.dependencies().is_empty(),
821 "{:?}",
822 access.dependencies()
823 );
824 }
825
826 #[test]
827 fn a_second_parent_does_not_consume_the_first() {
828 // The bug this guards: `..` popping the `..` marker its predecessor pushed collapses
829 // `../../etc/passwd` to `etc/passwd`, which looks contained. The read would then
830 // resolve to `<root>/etc/passwd` — not an escape, but silently the wrong file, and
831 // no error anywhere to say so.
832 assert_eq!(
833 normalize(Path::new("../../etc/passwd")),
834 Path::new("../../etc/passwd")
835 );
836 assert_eq!(normalize(Path::new("../../..")), Path::new("../../.."));
837 }
838
839 #[test]
840 fn a_parent_after_a_marker_pops_the_real_segment() {
841 // `../pkg/..` is still one level up, not two. Depth counts real segments only, so
842 // the marker survives and the segment above it does not.
843 assert_eq!(normalize(Path::new("../pkg/..")), Path::new(".."));
844 assert_eq!(normalize(Path::new("../pkg/../a")), Path::new("../a"));
845 }
846
847 #[test]
848 fn traversal_that_returns_is_collapsed() {
849 assert_eq!(normalize(Path::new("pkg/../a.json")), Path::new("a.json"));
850 assert_eq!(normalize(Path::new("./a/./b")), Path::new("a/b"));
851 }
852}