use crate::repo_path::RepoPath;
use anyhow::{Context, anyhow};
use globset::GlobSet;
use ignore::Walk;
use std::path::{Path, PathBuf};
pub trait FileSystem: Send + Sync {
fn read_to_string(&self, path: &Path) -> anyhow::Result<String>;
fn exists(&self, path: &Path) -> bool;
fn walk(&self) -> impl Iterator<Item = anyhow::Result<RepoPath>>;
}
pub trait PathChecker {
fn should_allow(&self, path: &Path) -> bool;
fn should_ignore(&self, path: &Path) -> bool;
}
pub struct FileSystemImpl {
root_path: PathBuf,
}
impl FileSystemImpl {
pub fn new(root_path: &Path) -> anyhow::Result<Self> {
let root_path = std::fs::canonicalize(root_path).with_context(|| {
format!(
"failed to canonicalize repository root: {}",
root_path.display()
)
})?;
Ok(Self { root_path })
}
fn resolve_within_root(&self, path: &Path) -> anyhow::Result<PathBuf> {
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
self.root_path.join(path)
};
let canonical = std::fs::canonicalize(&candidate)
.with_context(|| format!("failed to canonicalize path \"{}\"", path.display()))?;
if !canonical.starts_with(&self.root_path) {
return Err(anyhow!(
"path \"{}\" resolves to \"{}\" which is outside the repository root \"{}\"",
path.display(),
canonical.display(),
self.root_path.display(),
));
}
Ok(canonical)
}
}
impl FileSystem for FileSystemImpl {
fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
let resolved = self.resolve_within_root(path)?;
std::fs::read_to_string(&resolved)
.with_context(|| format!("Failed to read file \"{}\"", path.display()))
}
fn exists(&self, path: &Path) -> bool {
self.resolve_within_root(path)
.is_ok_and(|resolved| resolved.is_file())
}
fn walk(&self) -> impl Iterator<Item = anyhow::Result<RepoPath>> {
let root_path = self.root_path.clone();
Walk::new(&self.root_path).filter_map(move |entry| match entry {
Ok(entry) => {
let path = entry.path();
if path.is_dir() {
return None;
}
let relative_path = path.strip_prefix(&root_path).unwrap_or(path);
RepoPath::from_relative(relative_path).ok().map(Ok)
}
Err(err) => Some(Err(anyhow::Error::from(err))),
})
}
}
pub struct PathCheckerImpl {
glob_set: GlobSet,
ignored_glob_set: GlobSet,
}
impl PathCheckerImpl {
pub fn new(glob_set: GlobSet, ignored_glob_set: GlobSet) -> Self {
Self {
glob_set,
ignored_glob_set,
}
}
}
impl PathChecker for PathCheckerImpl {
fn should_allow(&self, path: &Path) -> bool {
self.glob_set.is_match(path)
}
fn should_ignore(&self, path: &Path) -> bool {
self.ignored_glob_set.is_match(path)
}
}
#[cfg(test)]
mod file_system_impl_tests {
use crate::fs::{FileSystem, FileSystemImpl};
use std::path::{Path, PathBuf};
fn root_with_file(name: &str, content: &str) -> (tempfile::TempDir, PathBuf) {
let root = tempfile::tempdir().unwrap();
let path = root.path().join(name);
std::fs::write(&path, content).unwrap();
(root, path)
}
#[test]
fn read_to_string_reads_relative_path_inside_root() -> anyhow::Result<()> {
let (root, _path) = root_with_file("a.txt", "hello");
let file_system = FileSystemImpl::new(root.path())?;
assert_eq!(file_system.read_to_string(Path::new("a.txt"))?, "hello");
Ok(())
}
#[test]
fn read_to_string_reads_absolute_path_inside_root() -> anyhow::Result<()> {
let (root, abs_path) = root_with_file("a.txt", "hello");
let file_system = FileSystemImpl::new(root.path())?;
assert_eq!(file_system.read_to_string(&abs_path)?, "hello");
Ok(())
}
#[test]
fn read_to_string_rejects_absolute_path_outside_root() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let (_outside_root, outside) = root_with_file("secret.txt", "secret");
let file_system = FileSystemImpl::new(root.path())?;
let err = file_system.read_to_string(&outside).unwrap_err();
assert!(
format!("{err:#}").contains("outside the repository root"),
"unexpected error: {err:#}"
);
Ok(())
}
#[test]
fn read_to_string_rejects_relative_path_escaping_root() -> anyhow::Result<()> {
let parent = tempfile::tempdir()?;
std::fs::write(parent.path().join("evil.txt"), "evil")?;
let root = parent.path().join("repo");
std::fs::create_dir(&root)?;
let file_system = FileSystemImpl::new(&root)?;
let err = file_system
.read_to_string(Path::new("../evil.txt"))
.unwrap_err();
assert!(
format!("{err:#}").contains("outside the repository root"),
"unexpected error: {err:#}"
);
Ok(())
}
#[test]
fn read_to_string_rejects_missing_path() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let file_system = FileSystemImpl::new(root.path())?;
let err = file_system
.read_to_string(Path::new("does_not_exist.txt"))
.unwrap_err();
assert!(
format!("{err:#}").contains("failed to canonicalize path"),
"unexpected error: {err:#}"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn read_to_string_rejects_symlink_escaping_root() -> anyhow::Result<()> {
let parent = tempfile::tempdir()?;
std::fs::write(parent.path().join("secret.txt"), "secret")?;
let root = parent.path().join("repo");
std::fs::create_dir(&root)?;
std::os::unix::fs::symlink(parent.path().join("secret.txt"), root.join("link.txt"))?;
let file_system = FileSystemImpl::new(&root)?;
let err = file_system
.read_to_string(Path::new("link.txt"))
.unwrap_err();
assert!(
format!("{err:#}").contains("outside the repository root"),
"unexpected error: {err:#}"
);
Ok(())
}
}
#[cfg(test)]
pub mod test_utils {
use crate::fs::{FileSystem, PathChecker};
use crate::repo_path::RepoPath;
use globset::GlobSet;
use std::collections::{HashMap, HashSet};
use std::path::Path;
pub(crate) struct FakeFileSystem {
files: HashMap<String, String>,
}
impl FakeFileSystem {
pub(crate) fn new(files: HashMap<String, String>) -> Self {
Self { files }
}
}
impl FileSystem for FakeFileSystem {
fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
self.files
.get(&path.display().to_string())
.cloned()
.ok_or_else(|| anyhow::anyhow!("File {} not found", path.display()))
}
fn exists(&self, path: &Path) -> bool {
self.files.contains_key(&path.display().to_string())
}
fn walk(&self) -> impl Iterator<Item = anyhow::Result<RepoPath>> {
self.files.keys().map(|path| RepoPath::from_reference(path))
}
}
pub(crate) struct FakePathChecker {
allowed_globs: Option<GlobSet>,
ignored_paths: HashSet<String>,
}
impl FakePathChecker {
pub(crate) fn with_ignored_paths(ignored_paths: HashSet<String>) -> Self {
Self {
allowed_globs: None,
ignored_paths,
}
}
pub(crate) fn allow_all() -> Self {
Self::with_ignored_paths(HashSet::new())
}
pub(crate) fn allow_only(glob: &str) -> Self {
let glob_set = GlobSet::builder()
.add(globset::Glob::new(glob).expect("malformed test glob"))
.build()
.expect("failed to build test glob set");
Self {
allowed_globs: Some(glob_set),
ignored_paths: HashSet::new(),
}
}
}
impl PathChecker for FakePathChecker {
fn should_allow(&self, path: &Path) -> bool {
self.allowed_globs
.as_ref()
.is_none_or(|globs| globs.is_match(path))
}
fn should_ignore(&self, path: &Path) -> bool {
self.ignored_paths.contains(&path.display().to_string())
}
}
}