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}