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.
84    Binary,
85}
86
87/// Tracked, confined access to the project's files.
88#[derive(Debug)]
89pub struct FileAccess {
90    root: PathBuf,
91    /// Everything resolved so far this file, keyed by project-relative path.
92    ///
93    /// A `BTreeMap` rather than a hash map: it is small, and iterating it in path order
94    /// makes the recorded dependency list deterministic without a separate sort.
95    ///
96    /// # A [`Mutex`] rather than a `RefCell`, so *one* memo can serve both engines
97    ///
98    /// It was a `RefCell` until two rule-execution engines had to share one of these. A
99    /// `RefCell` is `Send` and not `Sync`, so `Arc<FileAccess>` was not `Send` — and
100    /// `lanekeep_wasm::host::CheckContext` is required to be `Send`, because it lives in a
101    /// [`wasmtime::Store`] that rayon moves. That left the component engine no way to hold a
102    /// shared access, so it would have owned a second one per file, with a second memo.
103    ///
104    /// **Two memos over one file is not a tidiness problem, it is the determinism invariant.**
105    /// The memo exists so that a file rewritten mid-run cannot be seen two ways; a second one
106    /// beside it reintroduces exactly that, across engines rather than within one. And the
107    /// dependency lists cannot be merged afterwards to repair it: [`tracked::sort`] orders by
108    /// path and does **not** dedupe, so two lists disagreeing about one path's hash concatenate
109    /// into two contradictory entries for it, which is a cache entry that can never be
110    /// validated.
111    ///
112    /// The lock is uncontended by construction — an access belongs to one file, and a file
113    /// belongs to one worker — so it costs an atomic swap on a path that already touches the
114    /// filesystem. Poisoning is treated as "take the value anyway": nothing under this lock can
115    /// panic, and refusing to read a memo because an unrelated thread died would turn a rule's
116    /// read into a failure for a reason that has nothing to do with it.
117    seen: Mutex<BTreeMap<String, Outcome>>,
118}
119
120/// One access can be shared by both engines, checked at compile time rather than believed.
121///
122/// `Arc<FileAccess>: Send` needs `FileAccess: Send + Sync`, and that is the whole reason the
123/// memo is a [`Mutex`] — see the field. Without it the component engine cannot hold a shared
124/// access at all, because `lanekeep_wasm::host::CheckContext` is required to be `Send`, and it
125/// would silently fall back to a second memo per file.
126///
127/// A `const` block rather than a test, for the reason `lanekeep-wasm`'s equivalent is one: this
128/// is a property of the type, and a violation should stop the build at the field that caused it
129/// rather than surface as an unsatisfied bound in another crate.
130const _: () = {
131    const fn assert_shareable<T: Send + Sync>() {}
132    assert_shareable::<FileAccess>();
133};
134
135impl FileAccess {
136    /// Anchor reads at a project root, canonicalizing it.
137    ///
138    /// Every containment check compares against the root, so it has to be canonical or a
139    /// symlinked checkout would fail every check. Callers that already hold a canonical
140    /// root should use [`FileAccess::rooted`] instead — this is one syscall, and the engine
141    /// builds an access per file.
142    #[must_use]
143    pub fn new(root: &Path) -> Self {
144        Self::rooted(root.canonicalize().unwrap_or_else(|_| root.to_path_buf()))
145    }
146
147    /// Anchor reads at an already-canonical root.
148    ///
149    /// Cheap enough to call per file, which is what the engine does: a fresh access per
150    /// file makes it structurally impossible for one file's reads to be recorded against
151    /// another's, rather than making it depend on a reset being called in the right place.
152    #[must_use]
153    pub fn rooted(root: PathBuf) -> Self {
154        Self {
155            root,
156            seen: Mutex::new(BTreeMap::new()),
157        }
158    }
159
160    /// The memo, whether or not another thread died holding it.
161    ///
162    /// See the field's own documentation: nothing under this lock can panic, and a rule's read
163    /// must not fail because of something that happened elsewhere.
164    fn memo(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Outcome>> {
165        self.seen
166            .lock()
167            .unwrap_or_else(std::sync::PoisonError::into_inner)
168    }
169
170    /// The project root reads are confined to.
171    #[must_use]
172    pub fn root(&self) -> &Path {
173        &self.root
174    }
175
176    /// Read a file's text, or `None` if nothing is there.
177    ///
178    /// # Errors
179    ///
180    /// [`ReadError`] if the path escapes the root, is absolute, or holds something that is
181    /// not text. Absence is not an error — a rule asking whether a config is present should
182    /// not have to catch to find out.
183    pub fn read(&self, path: &str) -> Result<Option<String>, ReadError> {
184        match self.resolve(path)? {
185            Outcome::Text(text, _) => Ok(Some(text)),
186            Outcome::Absent => Ok(None),
187            Outcome::Binary => Err(ReadError::NotText {
188                path: path.to_owned(),
189            }),
190        }
191    }
192
193    /// Whether a file is there.
194    ///
195    /// A file that exists but is not text still exists — this answers the question asked,
196    /// where returning `false` would claim something untrue about the filesystem.
197    ///
198    /// # Errors
199    ///
200    /// [`ReadError`] if the path escapes the root or is absolute.
201    pub fn exists(&self, path: &str) -> Result<bool, ReadError> {
202        Ok(!matches!(self.resolve(path)?, Outcome::Absent))
203    }
204
205    /// Everything read so far, in path order.
206    #[must_use]
207    pub fn dependencies(&self) -> Vec<TrackedRead> {
208        let mut reads: Vec<TrackedRead> = self
209            .memo()
210            .iter()
211            .map(|(path, outcome)| {
212                let file = FilePath::new(path);
213                match outcome {
214                    Outcome::Text(_, hash) => TrackedRead::found(file, *hash),
215                    // A file that is there but unreadable as text is still a dependency: if
216                    // it is replaced with text, the rule's answer changes.
217                    Outcome::Binary => TrackedRead::found(file, ContentHash::new([0; 32])),
218                    Outcome::Absent => TrackedRead::absent(file),
219                }
220            })
221            .collect();
222        tracked::sort(&mut reads);
223        reads
224    }
225
226    /// Forget everything, for an embedder reusing one access across several files.
227    ///
228    /// The engine does not use this — it builds an access per file, so there is nothing to
229    /// forget. Kept because reuse is a reasonable thing for an embedder to want, and a
230    /// half-populated access is not.
231    pub fn clear(&self) {
232        self.memo().clear();
233    }
234
235    /// Resolve, read and record a path, or return what was already recorded.
236    ///
237    /// **The lock is dropped between the miss and the insert, so check-then-insert is not
238    /// atomic.** That is deliberate — holding it across [`Self::load`] would hold a lock across
239    /// a filesystem read, which is the shape that turns an uncontended mutex into a contended
240    /// one — and it is sound only under the construction described on the `seen` field: one
241    /// access per file, one worker per file. Two threads racing the same access would both read
242    /// and the second would overwrite the first, so the memo would still hold *an* answer and
243    /// still return one consistently, but the guarantee "a file rewritten mid-run is seen one
244    /// way" would rest on which write landed last rather than on the memo.
245    ///
246    /// So the invariant now rests on the caller's construction rather than on the type. If an
247    /// embedder ever shares one access across threads, this wants an entry API — `load` inside
248    /// the guard, or a per-key lock — rather than a comment.
249    fn resolve(&self, path: &str) -> Result<Outcome, ReadError> {
250        let key = normalize_key(path);
251        if let Some(outcome) = self.memo().get(&key) {
252            return Ok(outcome.clone());
253        }
254
255        let outcome = self.load(path)?;
256        self.memo().insert(key, outcome.clone());
257        Ok(outcome)
258    }
259
260    /// Do the actual filesystem work, having decided the path is allowed.
261    fn load(&self, path: &str) -> Result<Outcome, ReadError> {
262        let relative = Path::new(path);
263        if relative.is_absolute() || relative.has_root() {
264            // `has_root` as well as `is_absolute`, because `\windows\path` is rooted but not
265            // absolute on Windows — and a check that passes on one platform and not the
266            // other is worse than no check.
267            return Err(ReadError::Absolute {
268                path: path.to_owned(),
269            });
270        }
271
272        // Lexically first, so an escape is named as one whether or not the target exists.
273        let normalized = normalize(relative);
274        if normalized
275            .components()
276            .any(|c| matches!(c, Component::ParentDir))
277        {
278            return Err(ReadError::EscapesRoot {
279                path: path.to_owned(),
280            });
281        }
282
283        let full = self.root.join(&normalized);
284        let Ok(canonical) = full.canonicalize() else {
285            // Nothing there. Not an error, and deliberately not distinguished from a
286            // permission failure: either way the rule cannot see it, and a rule that
287            // branched on the difference would give different answers on different machines.
288            return Ok(Outcome::Absent);
289        };
290
291        // And again after canonicalizing, which is what catches a symlink inside the root
292        // pointing outside it. The lexical check above cannot see through one.
293        if !canonical.starts_with(&self.root) {
294            return Err(ReadError::EscapesRoot {
295                path: path.to_owned(),
296            });
297        }
298
299        let Ok(bytes) = std::fs::read(&canonical) else {
300            return Ok(Outcome::Absent);
301        };
302        let hash = ContentHash::new(*blake3::hash(&bytes).as_bytes());
303
304        match String::from_utf8(bytes) {
305            Ok(text) => Ok(Outcome::Text(text, hash)),
306            Err(_) => Ok(Outcome::Binary),
307        }
308    }
309}
310
311/// The key a path is recorded under, so `./a.json` and `a.json` are one dependency.
312fn normalize_key(path: &str) -> String {
313    normalize(Path::new(path))
314        .to_string_lossy()
315        .replace('\\', "/")
316}
317
318/// Resolve `.` and `..` lexically, without consulting the filesystem.
319///
320/// A traversal attempt has to be rejected with a message about escaping the root whether or
321/// not the target happens to exist, which `canonicalize` alone cannot do.
322///
323/// A leading `..` is kept as a marker so the caller's containment check can see it — and,
324/// critically, a later `..` must not pop that marker. `../../etc/passwd` popping its own
325/// first `..` would collapse to `etc/passwd`, which looks contained, and the read would
326/// then resolve to `<root>/etc/passwd`: not an escape, but silently the wrong file. Depth
327/// counts only real segments, so a marker can never be consumed.
328///
329/// `pub` rather than `pub(crate)`: `lanekeep-js`'s module loader resolves rule specifiers
330/// against the same lexical rule (a different root, a different reason to reject `..`, the
331/// identical algorithm), and sharing this one function is what keeps that algorithm defined
332/// once rather than copied at its second call site.
333#[must_use]
334pub fn normalize(path: &Path) -> PathBuf {
335    let mut out = PathBuf::new();
336    let mut depth = 0usize;
337
338    for component in path.components() {
339        match component {
340            Component::CurDir => {}
341            Component::ParentDir => {
342                if depth > 0 {
343                    out.pop();
344                    depth -= 1;
345                } else {
346                    out.push("..");
347                }
348            }
349            other => {
350                out.push(other.as_os_str());
351                depth += 1;
352            }
353        }
354    }
355
356    out
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    struct Fixture {
364        dir: PathBuf,
365    }
366
367    impl Fixture {
368        fn new(name: &str, files: &[(&str, &str)]) -> Self {
369            let dir =
370                std::env::temp_dir().join(format!("lanekeep-files-{name}-{}", std::process::id()));
371            let _ = std::fs::remove_dir_all(&dir);
372            std::fs::create_dir_all(&dir).expect("creates dir");
373            let fixture = Self { dir };
374            for (path, contents) in files {
375                let full = fixture.dir.join(path);
376                if let Some(parent) = full.parent() {
377                    std::fs::create_dir_all(parent).expect("creates parent");
378                }
379                std::fs::write(full, contents).expect("writes");
380            }
381            fixture
382        }
383
384        fn access(&self) -> FileAccess {
385            FileAccess::new(&self.dir)
386        }
387    }
388
389    impl Drop for Fixture {
390        fn drop(&mut self) {
391            let _ = std::fs::remove_dir_all(&self.dir);
392        }
393    }
394
395    #[test]
396    fn reads_a_file_in_the_root() {
397        let fixture = Fixture::new("read", &[("a.json", "{}")]);
398        let access = fixture.access();
399        assert_eq!(
400            access.read("a.json").expect("allowed"),
401            Some("{}".to_owned())
402        );
403    }
404
405    #[test]
406    fn reads_a_file_in_a_subdirectory() {
407        let fixture = Fixture::new("nested", &[("pkg/a.json", "{\"n\":1}")]);
408        let access = fixture.access();
409        assert_eq!(
410            access.read("pkg/a.json").expect("allowed"),
411            Some("{\"n\":1}".to_owned())
412        );
413    }
414
415    #[test]
416    fn a_missing_file_is_not_an_error() {
417        // A rule asking whether a config is present should not have to catch to find out.
418        let fixture = Fixture::new("missing", &[]);
419        let access = fixture.access();
420        assert_eq!(access.read("nope.json").expect("allowed"), None);
421        assert!(!access.exists("nope.json").expect("allowed"));
422    }
423
424    #[test]
425    fn traversal_out_of_the_root_is_refused() {
426        let fixture = Fixture::new("traversal", &[("a.json", "{}")]);
427        let access = fixture.access();
428        for attempt in ["../outside.json", "../../etc/passwd", "pkg/../../outside"] {
429            let error = access.read(attempt).expect_err("is refused");
430            assert!(
431                matches!(error, ReadError::EscapesRoot { .. }),
432                "`{attempt}` gave {error:?}"
433            );
434        }
435    }
436
437    #[test]
438    fn traversal_that_comes_back_inside_is_allowed() {
439        // `pkg/../a.json` never leaves the root. Refusing it would be a check that punishes
440        // spelling rather than one that protects anything.
441        let fixture = Fixture::new("returns", &[("a.json", "{}"), ("pkg/b.json", "{}")]);
442        let access = fixture.access();
443        assert_eq!(
444            access.read("pkg/../a.json").expect("allowed"),
445            Some("{}".to_owned())
446        );
447    }
448
449    #[test]
450    fn an_absolute_path_is_refused() {
451        // Built from `temp_dir` rather than written literally: `/etc/passwd` is absolute on
452        // Unix and merely rooted on Windows, so a literal takes a different branch on each.
453        let fixture = Fixture::new("absolute", &[]);
454        let access = fixture.access();
455        let outside = std::env::temp_dir().join("lanekeep-absolute-read-probe.json");
456        let error = access
457            .read(&outside.display().to_string())
458            .expect_err("is refused");
459        assert!(matches!(error, ReadError::Absolute { .. }), "{error:?}");
460    }
461
462    #[test]
463    fn a_read_is_recorded_as_a_dependency() {
464        let fixture = Fixture::new("recorded", &[("a.json", "{}")]);
465        let access = fixture.access();
466        access.read("a.json").expect("allowed");
467
468        let deps = access.dependencies();
469        assert_eq!(deps.len(), 1);
470        assert_eq!(deps[0].path.as_str(), "a.json");
471        assert!(deps[0].hash.is_some(), "a file that was read has a hash");
472    }
473
474    #[test]
475    fn a_miss_is_recorded_as_a_dependency() {
476        // The one that makes a cache wrong rather than cold: the answer "not there" has to
477        // be invalidated when the file appears.
478        let fixture = Fixture::new("miss-recorded", &[]);
479        let access = fixture.access();
480        access.exists("tsconfig.json").expect("allowed");
481
482        let deps = access.dependencies();
483        assert_eq!(deps.len(), 1);
484        assert_eq!(deps[0].path.as_str(), "tsconfig.json");
485        assert_eq!(deps[0].hash, None);
486    }
487
488    #[test]
489    fn a_refused_read_is_not_recorded() {
490        // It never produced an answer, so there is nothing for a cache to depend on.
491        let fixture = Fixture::new("refused", &[]);
492        let access = fixture.access();
493        let _ = access.read("../outside.json");
494        assert!(access.dependencies().is_empty());
495    }
496
497    #[test]
498    fn the_same_file_is_one_dependency_however_it_is_spelled() {
499        let fixture = Fixture::new("spelling", &[("a.json", "{}")]);
500        let access = fixture.access();
501        access.read("a.json").expect("allowed");
502        access.read("./a.json").expect("allowed");
503        access.read("pkg/../a.json").expect("allowed");
504        assert_eq!(access.dependencies().len(), 1);
505    }
506
507    #[test]
508    fn a_second_read_returns_what_the_first_one_saw() {
509        // A rule that saw a file change under it could report differently on two runs over
510        // identical input, and the cache would record one hash with no way to say which
511        // answer used it.
512        let fixture = Fixture::new("memoized", &[("a.json", "before")]);
513        let access = fixture.access();
514        assert_eq!(
515            access.read("a.json").expect("allowed").as_deref(),
516            Some("before")
517        );
518
519        std::fs::write(fixture.dir.join("a.json"), "after").expect("rewrites");
520        assert_eq!(
521            access.read("a.json").expect("allowed").as_deref(),
522            Some("before"),
523            "the run must see one version of a file"
524        );
525    }
526
527    #[test]
528    fn a_binary_file_is_refused_as_text_but_exists() {
529        let fixture = Fixture::new("binary", &[]);
530        std::fs::write(fixture.dir.join("blob.bin"), [0xff, 0xfe, 0x00]).expect("writes");
531        let access = fixture.access();
532
533        let error = access.read("blob.bin").expect_err("is refused");
534        assert!(matches!(error, ReadError::NotText { .. }), "{error:?}");
535        assert!(
536            access.exists("blob.bin").expect("allowed"),
537            "it is there, whatever it holds"
538        );
539    }
540
541    #[test]
542    fn dependencies_come_back_in_path_order() {
543        let fixture = Fixture::new("ordered", &[("b.json", "{}"), ("a.json", "{}")]);
544        let access = fixture.access();
545        access.read("b.json").expect("allowed");
546        access.read("a.json").expect("allowed");
547        access.exists("c.json").expect("allowed");
548
549        assert_eq!(
550            access
551                .dependencies()
552                .iter()
553                .map(|r| r.path.as_str())
554                .collect::<Vec<_>>(),
555            vec!["a.json", "b.json", "c.json"]
556        );
557    }
558
559    #[test]
560    fn clearing_forgets_everything() {
561        let fixture = Fixture::new("cleared", &[("a.json", "{}")]);
562        let access = fixture.access();
563        access.read("a.json").expect("allowed");
564        access.clear();
565        assert!(access.dependencies().is_empty());
566    }
567
568    #[cfg(unix)]
569    #[test]
570    fn a_symlink_out_of_the_root_is_refused() {
571        // The reason the check canonicalizes rather than comparing strings: nothing about
572        // `escape.json` looks like traversal.
573        let fixture = Fixture::new("symlink", &[]);
574        let outside = std::env::temp_dir().join("lanekeep-symlink-target.json");
575        std::fs::write(&outside, "secrets").expect("writes target");
576
577        std::os::unix::fs::symlink(&outside, fixture.dir.join("escape.json"))
578            .expect("creates symlink");
579
580        let access = fixture.access();
581        let error = access.read("escape.json").expect_err("is refused");
582        assert!(matches!(error, ReadError::EscapesRoot { .. }), "{error:?}");
583
584        let _ = std::fs::remove_file(&outside);
585    }
586
587    #[test]
588    fn a_second_parent_does_not_consume_the_first() {
589        // The bug this guards: `..` popping the `..` marker its predecessor pushed collapses
590        // `../../etc/passwd` to `etc/passwd`, which looks contained. The read would then
591        // resolve to `<root>/etc/passwd` — not an escape, but silently the wrong file, and
592        // no error anywhere to say so.
593        assert_eq!(
594            normalize(Path::new("../../etc/passwd")),
595            Path::new("../../etc/passwd")
596        );
597        assert_eq!(normalize(Path::new("../../..")), Path::new("../../.."));
598    }
599
600    #[test]
601    fn a_parent_after_a_marker_pops_the_real_segment() {
602        // `../pkg/..` is still one level up, not two. Depth counts real segments only, so
603        // the marker survives and the segment above it does not.
604        assert_eq!(normalize(Path::new("../pkg/..")), Path::new(".."));
605        assert_eq!(normalize(Path::new("../pkg/../a")), Path::new("../a"));
606    }
607
608    #[test]
609    fn traversal_that_returns_is_collapsed() {
610        assert_eq!(normalize(Path::new("pkg/../a.json")), Path::new("a.json"));
611        assert_eq!(normalize(Path::new("./a/./b")), Path::new("a/b"));
612    }
613}