Skip to main content

lanekeep_js/
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 [`lanekeep_core::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
23use std::cell::RefCell;
24use std::collections::BTreeMap;
25use std::path::{Component, Path, PathBuf};
26
27use lanekeep_core::tracked::{ContentHash, TrackedRead};
28use lanekeep_core::{FilePath, tracked};
29use thiserror::Error;
30
31/// Why a read was refused.
32///
33/// Distinct from "nothing was there", which is an ordinary answer a rule handles.
34#[derive(Debug, Clone, PartialEq, Eq, Error)]
35pub enum ReadError {
36    /// The path resolves outside the project root.
37    #[error(
38        "cannot read `{path}`\n  \
39         it resolves outside the project root, and rules may only read files within it"
40    )]
41    EscapesRoot {
42        /// The path as the rule wrote it.
43        path: String,
44    },
45
46    /// The path was absolute.
47    #[error(
48        "cannot read `{path}`\n  \
49         reads are relative to the project root — an absolute path would make the rule \
50         depend on where the project happens to be checked out"
51    )]
52    Absolute {
53        /// The path as the rule wrote it.
54        path: String,
55    },
56
57    /// The file exists but is not text.
58    #[error(
59        "cannot read `{path}` as text: it is not valid UTF-8\n  \
60         use ctx.fileExists if the question is whether it is there"
61    )]
62    NotText {
63        /// The path as the rule wrote it.
64        path: String,
65    },
66}
67
68/// What a resolved path turned out to hold.
69#[derive(Debug, Clone, PartialEq, Eq)]
70enum Outcome {
71    /// The file was read.
72    Text(String, ContentHash),
73    /// Nothing was there.
74    Absent,
75    /// It was there and is not text.
76    Binary,
77}
78
79/// Tracked, confined access to the project's files.
80#[derive(Debug)]
81pub struct FileAccess {
82    root: PathBuf,
83    /// Everything resolved so far this file, keyed by project-relative path.
84    ///
85    /// A `BTreeMap` rather than a hash map: it is small, and iterating it in path order
86    /// makes the recorded dependency list deterministic without a separate sort.
87    seen: RefCell<BTreeMap<String, Outcome>>,
88}
89
90impl FileAccess {
91    /// Anchor reads at a project root, canonicalizing it.
92    ///
93    /// Every containment check compares against the root, so it has to be canonical or a
94    /// symlinked checkout would fail every check. Callers that already hold a canonical
95    /// root should use [`FileAccess::rooted`] instead — this is one syscall, and the engine
96    /// builds an access per file.
97    #[must_use]
98    pub fn new(root: &Path) -> Self {
99        Self::rooted(root.canonicalize().unwrap_or_else(|_| root.to_path_buf()))
100    }
101
102    /// Anchor reads at an already-canonical root.
103    ///
104    /// Cheap enough to call per file, which is what the engine does: a fresh access per
105    /// file makes it structurally impossible for one file's reads to be recorded against
106    /// another's, rather than making it depend on a reset being called in the right place.
107    #[must_use]
108    pub fn rooted(root: PathBuf) -> Self {
109        Self {
110            root,
111            seen: RefCell::new(BTreeMap::new()),
112        }
113    }
114
115    /// The project root reads are confined to.
116    #[must_use]
117    pub fn root(&self) -> &Path {
118        &self.root
119    }
120
121    /// Read a file's text, or `None` if nothing is there.
122    ///
123    /// # Errors
124    ///
125    /// [`ReadError`] if the path escapes the root, is absolute, or holds something that is
126    /// not text. Absence is not an error — a rule asking whether a config is present should
127    /// not have to catch to find out.
128    pub fn read(&self, path: &str) -> Result<Option<String>, ReadError> {
129        match self.resolve(path)? {
130            Outcome::Text(text, _) => Ok(Some(text)),
131            Outcome::Absent => Ok(None),
132            Outcome::Binary => Err(ReadError::NotText {
133                path: path.to_owned(),
134            }),
135        }
136    }
137
138    /// Whether a file is there.
139    ///
140    /// A file that exists but is not text still exists — this answers the question asked,
141    /// where returning `false` would claim something untrue about the filesystem.
142    ///
143    /// # Errors
144    ///
145    /// [`ReadError`] if the path escapes the root or is absolute.
146    pub fn exists(&self, path: &str) -> Result<bool, ReadError> {
147        Ok(!matches!(self.resolve(path)?, Outcome::Absent))
148    }
149
150    /// Everything read so far, in path order.
151    #[must_use]
152    pub fn dependencies(&self) -> Vec<TrackedRead> {
153        let mut reads: Vec<TrackedRead> = self
154            .seen
155            .borrow()
156            .iter()
157            .map(|(path, outcome)| {
158                let file = FilePath::new(path);
159                match outcome {
160                    Outcome::Text(_, hash) => TrackedRead::found(file, *hash),
161                    // A file that is there but unreadable as text is still a dependency: if
162                    // it is replaced with text, the rule's answer changes.
163                    Outcome::Binary => TrackedRead::found(file, ContentHash::new([0; 32])),
164                    Outcome::Absent => TrackedRead::absent(file),
165                }
166            })
167            .collect();
168        tracked::sort(&mut reads);
169        reads
170    }
171
172    /// Forget everything, for an embedder reusing one access across several files.
173    ///
174    /// The engine does not use this — it builds an access per file, so there is nothing to
175    /// forget. Kept because reuse is a reasonable thing for an embedder to want, and a
176    /// half-populated access is not.
177    pub fn clear(&self) {
178        self.seen.borrow_mut().clear();
179    }
180
181    /// Resolve, read and record a path, or return what was already recorded.
182    fn resolve(&self, path: &str) -> Result<Outcome, ReadError> {
183        let key = normalize_key(path);
184        if let Some(outcome) = self.seen.borrow().get(&key) {
185            return Ok(outcome.clone());
186        }
187
188        let outcome = self.load(path)?;
189        self.seen.borrow_mut().insert(key, outcome.clone());
190        Ok(outcome)
191    }
192
193    /// Do the actual filesystem work, having decided the path is allowed.
194    fn load(&self, path: &str) -> Result<Outcome, ReadError> {
195        let relative = Path::new(path);
196        if relative.is_absolute() || relative.has_root() {
197            // `has_root` as well as `is_absolute`, because `\windows\path` is rooted but not
198            // absolute on Windows — and a check that passes on one platform and not the
199            // other is worse than no check.
200            return Err(ReadError::Absolute {
201                path: path.to_owned(),
202            });
203        }
204
205        // Lexically first, so an escape is named as one whether or not the target exists.
206        let normalized = normalize(relative);
207        if normalized
208            .components()
209            .any(|c| matches!(c, Component::ParentDir))
210        {
211            return Err(ReadError::EscapesRoot {
212                path: path.to_owned(),
213            });
214        }
215
216        let full = self.root.join(&normalized);
217        let Ok(canonical) = full.canonicalize() else {
218            // Nothing there. Not an error, and deliberately not distinguished from a
219            // permission failure: either way the rule cannot see it, and a rule that
220            // branched on the difference would give different answers on different machines.
221            return Ok(Outcome::Absent);
222        };
223
224        // And again after canonicalizing, which is what catches a symlink inside the root
225        // pointing outside it. The lexical check above cannot see through one.
226        if !canonical.starts_with(&self.root) {
227            return Err(ReadError::EscapesRoot {
228                path: path.to_owned(),
229            });
230        }
231
232        let Ok(bytes) = std::fs::read(&canonical) else {
233            return Ok(Outcome::Absent);
234        };
235        let hash = ContentHash::new(*blake3::hash(&bytes).as_bytes());
236
237        match String::from_utf8(bytes) {
238            Ok(text) => Ok(Outcome::Text(text, hash)),
239            Err(_) => Ok(Outcome::Binary),
240        }
241    }
242}
243
244/// The key a path is recorded under, so `./a.json` and `a.json` are one dependency.
245fn normalize_key(path: &str) -> String {
246    normalize(Path::new(path))
247        .to_string_lossy()
248        .replace('\\', "/")
249}
250
251/// Resolve `.` and `..` lexically, without consulting the filesystem.
252///
253/// A traversal attempt has to be rejected with a message about escaping the root whether or
254/// not the target happens to exist, which `canonicalize` alone cannot do.
255///
256/// A leading `..` is kept as a marker so the caller's containment check can see it — and,
257/// critically, a later `..` must not pop that marker. `../../etc/passwd` popping its own
258/// first `..` would collapse to `etc/passwd`, which looks contained, and the read would
259/// then resolve to `<root>/etc/passwd`: not an escape, but silently the wrong file. Depth
260/// counts only real segments, so a marker can never be consumed.
261pub(crate) fn normalize(path: &Path) -> PathBuf {
262    let mut out = PathBuf::new();
263    let mut depth = 0usize;
264
265    for component in path.components() {
266        match component {
267            Component::CurDir => {}
268            Component::ParentDir => {
269                if depth > 0 {
270                    out.pop();
271                    depth -= 1;
272                } else {
273                    out.push("..");
274                }
275            }
276            other => {
277                out.push(other.as_os_str());
278                depth += 1;
279            }
280        }
281    }
282
283    out
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    struct Fixture {
291        dir: PathBuf,
292    }
293
294    impl Fixture {
295        fn new(name: &str, files: &[(&str, &str)]) -> Self {
296            let dir =
297                std::env::temp_dir().join(format!("lanekeep-files-{name}-{}", std::process::id()));
298            let _ = std::fs::remove_dir_all(&dir);
299            std::fs::create_dir_all(&dir).expect("creates dir");
300            let fixture = Self { dir };
301            for (path, contents) in files {
302                let full = fixture.dir.join(path);
303                if let Some(parent) = full.parent() {
304                    std::fs::create_dir_all(parent).expect("creates parent");
305                }
306                std::fs::write(full, contents).expect("writes");
307            }
308            fixture
309        }
310
311        fn access(&self) -> FileAccess {
312            FileAccess::new(&self.dir)
313        }
314    }
315
316    impl Drop for Fixture {
317        fn drop(&mut self) {
318            let _ = std::fs::remove_dir_all(&self.dir);
319        }
320    }
321
322    #[test]
323    fn reads_a_file_in_the_root() {
324        let fixture = Fixture::new("read", &[("a.json", "{}")]);
325        let access = fixture.access();
326        assert_eq!(
327            access.read("a.json").expect("allowed"),
328            Some("{}".to_owned())
329        );
330    }
331
332    #[test]
333    fn reads_a_file_in_a_subdirectory() {
334        let fixture = Fixture::new("nested", &[("pkg/a.json", "{\"n\":1}")]);
335        let access = fixture.access();
336        assert_eq!(
337            access.read("pkg/a.json").expect("allowed"),
338            Some("{\"n\":1}".to_owned())
339        );
340    }
341
342    #[test]
343    fn a_missing_file_is_not_an_error() {
344        // A rule asking whether a config is present should not have to catch to find out.
345        let fixture = Fixture::new("missing", &[]);
346        let access = fixture.access();
347        assert_eq!(access.read("nope.json").expect("allowed"), None);
348        assert!(!access.exists("nope.json").expect("allowed"));
349    }
350
351    #[test]
352    fn traversal_out_of_the_root_is_refused() {
353        let fixture = Fixture::new("traversal", &[("a.json", "{}")]);
354        let access = fixture.access();
355        for attempt in ["../outside.json", "../../etc/passwd", "pkg/../../outside"] {
356            let error = access.read(attempt).expect_err("is refused");
357            assert!(
358                matches!(error, ReadError::EscapesRoot { .. }),
359                "`{attempt}` gave {error:?}"
360            );
361        }
362    }
363
364    #[test]
365    fn traversal_that_comes_back_inside_is_allowed() {
366        // `pkg/../a.json` never leaves the root. Refusing it would be a check that punishes
367        // spelling rather than one that protects anything.
368        let fixture = Fixture::new("returns", &[("a.json", "{}"), ("pkg/b.json", "{}")]);
369        let access = fixture.access();
370        assert_eq!(
371            access.read("pkg/../a.json").expect("allowed"),
372            Some("{}".to_owned())
373        );
374    }
375
376    #[test]
377    fn an_absolute_path_is_refused() {
378        // Built from `temp_dir` rather than written literally: `/etc/passwd` is absolute on
379        // Unix and merely rooted on Windows, so a literal takes a different branch on each.
380        let fixture = Fixture::new("absolute", &[]);
381        let access = fixture.access();
382        let outside = std::env::temp_dir().join("lanekeep-absolute-read-probe.json");
383        let error = access
384            .read(&outside.display().to_string())
385            .expect_err("is refused");
386        assert!(matches!(error, ReadError::Absolute { .. }), "{error:?}");
387    }
388
389    #[test]
390    fn a_read_is_recorded_as_a_dependency() {
391        let fixture = Fixture::new("recorded", &[("a.json", "{}")]);
392        let access = fixture.access();
393        access.read("a.json").expect("allowed");
394
395        let deps = access.dependencies();
396        assert_eq!(deps.len(), 1);
397        assert_eq!(deps[0].path.as_str(), "a.json");
398        assert!(deps[0].hash.is_some(), "a file that was read has a hash");
399    }
400
401    #[test]
402    fn a_miss_is_recorded_as_a_dependency() {
403        // The one that makes a cache wrong rather than cold: the answer "not there" has to
404        // be invalidated when the file appears.
405        let fixture = Fixture::new("miss-recorded", &[]);
406        let access = fixture.access();
407        access.exists("tsconfig.json").expect("allowed");
408
409        let deps = access.dependencies();
410        assert_eq!(deps.len(), 1);
411        assert_eq!(deps[0].path.as_str(), "tsconfig.json");
412        assert_eq!(deps[0].hash, None);
413    }
414
415    #[test]
416    fn a_refused_read_is_not_recorded() {
417        // It never produced an answer, so there is nothing for a cache to depend on.
418        let fixture = Fixture::new("refused", &[]);
419        let access = fixture.access();
420        let _ = access.read("../outside.json");
421        assert!(access.dependencies().is_empty());
422    }
423
424    #[test]
425    fn the_same_file_is_one_dependency_however_it_is_spelled() {
426        let fixture = Fixture::new("spelling", &[("a.json", "{}")]);
427        let access = fixture.access();
428        access.read("a.json").expect("allowed");
429        access.read("./a.json").expect("allowed");
430        access.read("pkg/../a.json").expect("allowed");
431        assert_eq!(access.dependencies().len(), 1);
432    }
433
434    #[test]
435    fn a_second_read_returns_what_the_first_one_saw() {
436        // A rule that saw a file change under it could report differently on two runs over
437        // identical input, and the cache would record one hash with no way to say which
438        // answer used it.
439        let fixture = Fixture::new("memoized", &[("a.json", "before")]);
440        let access = fixture.access();
441        assert_eq!(
442            access.read("a.json").expect("allowed").as_deref(),
443            Some("before")
444        );
445
446        std::fs::write(fixture.dir.join("a.json"), "after").expect("rewrites");
447        assert_eq!(
448            access.read("a.json").expect("allowed").as_deref(),
449            Some("before"),
450            "the run must see one version of a file"
451        );
452    }
453
454    #[test]
455    fn a_binary_file_is_refused_as_text_but_exists() {
456        let fixture = Fixture::new("binary", &[]);
457        std::fs::write(fixture.dir.join("blob.bin"), [0xff, 0xfe, 0x00]).expect("writes");
458        let access = fixture.access();
459
460        let error = access.read("blob.bin").expect_err("is refused");
461        assert!(matches!(error, ReadError::NotText { .. }), "{error:?}");
462        assert!(
463            access.exists("blob.bin").expect("allowed"),
464            "it is there, whatever it holds"
465        );
466    }
467
468    #[test]
469    fn dependencies_come_back_in_path_order() {
470        let fixture = Fixture::new("ordered", &[("b.json", "{}"), ("a.json", "{}")]);
471        let access = fixture.access();
472        access.read("b.json").expect("allowed");
473        access.read("a.json").expect("allowed");
474        access.exists("c.json").expect("allowed");
475
476        assert_eq!(
477            access
478                .dependencies()
479                .iter()
480                .map(|r| r.path.as_str())
481                .collect::<Vec<_>>(),
482            vec!["a.json", "b.json", "c.json"]
483        );
484    }
485
486    #[test]
487    fn clearing_forgets_everything() {
488        let fixture = Fixture::new("cleared", &[("a.json", "{}")]);
489        let access = fixture.access();
490        access.read("a.json").expect("allowed");
491        access.clear();
492        assert!(access.dependencies().is_empty());
493    }
494
495    #[cfg(unix)]
496    #[test]
497    fn a_symlink_out_of_the_root_is_refused() {
498        // The reason the check canonicalizes rather than comparing strings: nothing about
499        // `escape.json` looks like traversal.
500        let fixture = Fixture::new("symlink", &[]);
501        let outside = std::env::temp_dir().join("lanekeep-symlink-target.json");
502        std::fs::write(&outside, "secrets").expect("writes target");
503
504        std::os::unix::fs::symlink(&outside, fixture.dir.join("escape.json"))
505            .expect("creates symlink");
506
507        let access = fixture.access();
508        let error = access.read("escape.json").expect_err("is refused");
509        assert!(matches!(error, ReadError::EscapesRoot { .. }), "{error:?}");
510
511        let _ = std::fs::remove_file(&outside);
512    }
513
514    #[test]
515    fn a_second_parent_does_not_consume_the_first() {
516        // The bug this guards: `..` popping the `..` marker its predecessor pushed collapses
517        // `../../etc/passwd` to `etc/passwd`, which looks contained. The read would then
518        // resolve to `<root>/etc/passwd` — not an escape, but silently the wrong file, and
519        // no error anywhere to say so.
520        assert_eq!(
521            normalize(Path::new("../../etc/passwd")),
522            Path::new("../../etc/passwd")
523        );
524        assert_eq!(normalize(Path::new("../../..")), Path::new("../../.."));
525    }
526
527    #[test]
528    fn a_parent_after_a_marker_pops_the_real_segment() {
529        // `../pkg/..` is still one level up, not two. Depth counts real segments only, so
530        // the marker survives and the segment above it does not.
531        assert_eq!(normalize(Path::new("../pkg/..")), Path::new(".."));
532        assert_eq!(normalize(Path::new("../pkg/../a")), Path::new("../a"));
533    }
534
535    #[test]
536    fn traversal_that_returns_is_collapsed() {
537        assert_eq!(normalize(Path::new("pkg/../a.json")), Path::new("a.json"));
538        assert_eq!(normalize(Path::new("./a/./b")), Path::new("a/b"));
539    }
540}