use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use lanekeep_core::tracked::{ContentHash, TrackedRead};
use lanekeep_core::{FilePath, tracked};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ReadError {
#[error(
"cannot read `{path}`\n \
it resolves outside the project root, and rules may only read files within it"
)]
EscapesRoot {
path: String,
},
#[error(
"cannot read `{path}`\n \
reads are relative to the project root — an absolute path would make the rule \
depend on where the project happens to be checked out"
)]
Absolute {
path: String,
},
#[error(
"cannot read `{path}` as text: it is not valid UTF-8\n \
use ctx.fileExists if the question is whether it is there"
)]
NotText {
path: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Outcome {
Text(String, ContentHash),
Absent,
Binary,
}
#[derive(Debug)]
pub struct FileAccess {
root: PathBuf,
seen: RefCell<BTreeMap<String, Outcome>>,
}
impl FileAccess {
#[must_use]
pub fn new(root: &Path) -> Self {
Self::rooted(root.canonicalize().unwrap_or_else(|_| root.to_path_buf()))
}
#[must_use]
pub fn rooted(root: PathBuf) -> Self {
Self {
root,
seen: RefCell::new(BTreeMap::new()),
}
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn read(&self, path: &str) -> Result<Option<String>, ReadError> {
match self.resolve(path)? {
Outcome::Text(text, _) => Ok(Some(text)),
Outcome::Absent => Ok(None),
Outcome::Binary => Err(ReadError::NotText {
path: path.to_owned(),
}),
}
}
pub fn exists(&self, path: &str) -> Result<bool, ReadError> {
Ok(!matches!(self.resolve(path)?, Outcome::Absent))
}
#[must_use]
pub fn dependencies(&self) -> Vec<TrackedRead> {
let mut reads: Vec<TrackedRead> = self
.seen
.borrow()
.iter()
.map(|(path, outcome)| {
let file = FilePath::new(path);
match outcome {
Outcome::Text(_, hash) => TrackedRead::found(file, *hash),
Outcome::Binary => TrackedRead::found(file, ContentHash::new([0; 32])),
Outcome::Absent => TrackedRead::absent(file),
}
})
.collect();
tracked::sort(&mut reads);
reads
}
pub fn clear(&self) {
self.seen.borrow_mut().clear();
}
fn resolve(&self, path: &str) -> Result<Outcome, ReadError> {
let key = normalize_key(path);
if let Some(outcome) = self.seen.borrow().get(&key) {
return Ok(outcome.clone());
}
let outcome = self.load(path)?;
self.seen.borrow_mut().insert(key, outcome.clone());
Ok(outcome)
}
fn load(&self, path: &str) -> Result<Outcome, ReadError> {
let relative = Path::new(path);
if relative.is_absolute() || relative.has_root() {
return Err(ReadError::Absolute {
path: path.to_owned(),
});
}
let normalized = normalize(relative);
if normalized
.components()
.any(|c| matches!(c, Component::ParentDir))
{
return Err(ReadError::EscapesRoot {
path: path.to_owned(),
});
}
let full = self.root.join(&normalized);
let Ok(canonical) = full.canonicalize() else {
return Ok(Outcome::Absent);
};
if !canonical.starts_with(&self.root) {
return Err(ReadError::EscapesRoot {
path: path.to_owned(),
});
}
let Ok(bytes) = std::fs::read(&canonical) else {
return Ok(Outcome::Absent);
};
let hash = ContentHash::new(*blake3::hash(&bytes).as_bytes());
match String::from_utf8(bytes) {
Ok(text) => Ok(Outcome::Text(text, hash)),
Err(_) => Ok(Outcome::Binary),
}
}
}
fn normalize_key(path: &str) -> String {
normalize(Path::new(path))
.to_string_lossy()
.replace('\\', "/")
}
pub(crate) fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
let mut depth = 0usize;
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if depth > 0 {
out.pop();
depth -= 1;
} else {
out.push("..");
}
}
other => {
out.push(other.as_os_str());
depth += 1;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixture {
dir: PathBuf,
}
impl Fixture {
fn new(name: &str, files: &[(&str, &str)]) -> Self {
let dir =
std::env::temp_dir().join(format!("lanekeep-files-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("creates dir");
let fixture = Self { dir };
for (path, contents) in files {
let full = fixture.dir.join(path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("creates parent");
}
std::fs::write(full, contents).expect("writes");
}
fixture
}
fn access(&self) -> FileAccess {
FileAccess::new(&self.dir)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[test]
fn reads_a_file_in_the_root() {
let fixture = Fixture::new("read", &[("a.json", "{}")]);
let access = fixture.access();
assert_eq!(
access.read("a.json").expect("allowed"),
Some("{}".to_owned())
);
}
#[test]
fn reads_a_file_in_a_subdirectory() {
let fixture = Fixture::new("nested", &[("pkg/a.json", "{\"n\":1}")]);
let access = fixture.access();
assert_eq!(
access.read("pkg/a.json").expect("allowed"),
Some("{\"n\":1}".to_owned())
);
}
#[test]
fn a_missing_file_is_not_an_error() {
let fixture = Fixture::new("missing", &[]);
let access = fixture.access();
assert_eq!(access.read("nope.json").expect("allowed"), None);
assert!(!access.exists("nope.json").expect("allowed"));
}
#[test]
fn traversal_out_of_the_root_is_refused() {
let fixture = Fixture::new("traversal", &[("a.json", "{}")]);
let access = fixture.access();
for attempt in ["../outside.json", "../../etc/passwd", "pkg/../../outside"] {
let error = access.read(attempt).expect_err("is refused");
assert!(
matches!(error, ReadError::EscapesRoot { .. }),
"`{attempt}` gave {error:?}"
);
}
}
#[test]
fn traversal_that_comes_back_inside_is_allowed() {
let fixture = Fixture::new("returns", &[("a.json", "{}"), ("pkg/b.json", "{}")]);
let access = fixture.access();
assert_eq!(
access.read("pkg/../a.json").expect("allowed"),
Some("{}".to_owned())
);
}
#[test]
fn an_absolute_path_is_refused() {
let fixture = Fixture::new("absolute", &[]);
let access = fixture.access();
let outside = std::env::temp_dir().join("lanekeep-absolute-read-probe.json");
let error = access
.read(&outside.display().to_string())
.expect_err("is refused");
assert!(matches!(error, ReadError::Absolute { .. }), "{error:?}");
}
#[test]
fn a_read_is_recorded_as_a_dependency() {
let fixture = Fixture::new("recorded", &[("a.json", "{}")]);
let access = fixture.access();
access.read("a.json").expect("allowed");
let deps = access.dependencies();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].path.as_str(), "a.json");
assert!(deps[0].hash.is_some(), "a file that was read has a hash");
}
#[test]
fn a_miss_is_recorded_as_a_dependency() {
let fixture = Fixture::new("miss-recorded", &[]);
let access = fixture.access();
access.exists("tsconfig.json").expect("allowed");
let deps = access.dependencies();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].path.as_str(), "tsconfig.json");
assert_eq!(deps[0].hash, None);
}
#[test]
fn a_refused_read_is_not_recorded() {
let fixture = Fixture::new("refused", &[]);
let access = fixture.access();
let _ = access.read("../outside.json");
assert!(access.dependencies().is_empty());
}
#[test]
fn the_same_file_is_one_dependency_however_it_is_spelled() {
let fixture = Fixture::new("spelling", &[("a.json", "{}")]);
let access = fixture.access();
access.read("a.json").expect("allowed");
access.read("./a.json").expect("allowed");
access.read("pkg/../a.json").expect("allowed");
assert_eq!(access.dependencies().len(), 1);
}
#[test]
fn a_second_read_returns_what_the_first_one_saw() {
let fixture = Fixture::new("memoized", &[("a.json", "before")]);
let access = fixture.access();
assert_eq!(
access.read("a.json").expect("allowed").as_deref(),
Some("before")
);
std::fs::write(fixture.dir.join("a.json"), "after").expect("rewrites");
assert_eq!(
access.read("a.json").expect("allowed").as_deref(),
Some("before"),
"the run must see one version of a file"
);
}
#[test]
fn a_binary_file_is_refused_as_text_but_exists() {
let fixture = Fixture::new("binary", &[]);
std::fs::write(fixture.dir.join("blob.bin"), [0xff, 0xfe, 0x00]).expect("writes");
let access = fixture.access();
let error = access.read("blob.bin").expect_err("is refused");
assert!(matches!(error, ReadError::NotText { .. }), "{error:?}");
assert!(
access.exists("blob.bin").expect("allowed"),
"it is there, whatever it holds"
);
}
#[test]
fn dependencies_come_back_in_path_order() {
let fixture = Fixture::new("ordered", &[("b.json", "{}"), ("a.json", "{}")]);
let access = fixture.access();
access.read("b.json").expect("allowed");
access.read("a.json").expect("allowed");
access.exists("c.json").expect("allowed");
assert_eq!(
access
.dependencies()
.iter()
.map(|r| r.path.as_str())
.collect::<Vec<_>>(),
vec!["a.json", "b.json", "c.json"]
);
}
#[test]
fn clearing_forgets_everything() {
let fixture = Fixture::new("cleared", &[("a.json", "{}")]);
let access = fixture.access();
access.read("a.json").expect("allowed");
access.clear();
assert!(access.dependencies().is_empty());
}
#[cfg(unix)]
#[test]
fn a_symlink_out_of_the_root_is_refused() {
let fixture = Fixture::new("symlink", &[]);
let outside = std::env::temp_dir().join("lanekeep-symlink-target.json");
std::fs::write(&outside, "secrets").expect("writes target");
std::os::unix::fs::symlink(&outside, fixture.dir.join("escape.json"))
.expect("creates symlink");
let access = fixture.access();
let error = access.read("escape.json").expect_err("is refused");
assert!(matches!(error, ReadError::EscapesRoot { .. }), "{error:?}");
let _ = std::fs::remove_file(&outside);
}
#[test]
fn a_second_parent_does_not_consume_the_first() {
assert_eq!(
normalize(Path::new("../../etc/passwd")),
Path::new("../../etc/passwd")
);
assert_eq!(normalize(Path::new("../../..")), Path::new("../../.."));
}
#[test]
fn a_parent_after_a_marker_pops_the_real_segment() {
assert_eq!(normalize(Path::new("../pkg/..")), Path::new(".."));
assert_eq!(normalize(Path::new("../pkg/../a")), Path::new("../a"));
}
#[test]
fn traversal_that_returns_is_collapsed() {
assert_eq!(normalize(Path::new("pkg/../a.json")), Path::new("a.json"));
assert_eq!(normalize(Path::new("./a/./b")), Path::new("a/b"));
}
}