Skip to main content

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}