1use 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#[derive(Debug, Clone, PartialEq, Eq, Error)]
35pub enum ReadError {
36 #[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 path: String,
44 },
45
46 #[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 path: String,
55 },
56
57 #[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 path: String,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70enum Outcome {
71 Text(String, ContentHash),
73 Absent,
75 Binary,
77}
78
79#[derive(Debug)]
81pub struct FileAccess {
82 root: PathBuf,
83 seen: RefCell<BTreeMap<String, Outcome>>,
88}
89
90impl FileAccess {
91 #[must_use]
98 pub fn new(root: &Path) -> Self {
99 Self::rooted(root.canonicalize().unwrap_or_else(|_| root.to_path_buf()))
100 }
101
102 #[must_use]
108 pub fn rooted(root: PathBuf) -> Self {
109 Self {
110 root,
111 seen: RefCell::new(BTreeMap::new()),
112 }
113 }
114
115 #[must_use]
117 pub fn root(&self) -> &Path {
118 &self.root
119 }
120
121 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 pub fn exists(&self, path: &str) -> Result<bool, ReadError> {
147 Ok(!matches!(self.resolve(path)?, Outcome::Absent))
148 }
149
150 #[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 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 pub fn clear(&self) {
178 self.seen.borrow_mut().clear();
179 }
180
181 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 fn load(&self, path: &str) -> Result<Outcome, ReadError> {
195 let relative = Path::new(path);
196 if relative.is_absolute() || relative.has_root() {
197 return Err(ReadError::Absolute {
201 path: path.to_owned(),
202 });
203 }
204
205 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 return Ok(Outcome::Absent);
222 };
223
224 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
244fn normalize_key(path: &str) -> String {
246 normalize(Path::new(path))
247 .to_string_lossy()
248 .replace('\\', "/")
249}
250
251pub(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 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 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 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 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 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 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 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 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 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}