use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use walkdir::WalkDir;
pub const MAX_CONTRACT_BYTES: u64 = 5 * 1024 * 1024;
pub fn to_slash(path: &Path) -> String {
path.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/")
}
pub enum FindFileResult {
Found(Vec<PathBuf>),
NotFound,
}
pub fn find_file_by_ext_downward(start: PathBuf, extensions: &[&str]) -> FindFileResult {
let pwd = start.to_path_buf();
let mut files = Vec::new();
for entry in WalkDir::new(&pwd)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
for ext in extensions {
if entry.path().extension() == Some(ext.as_ref()) {
files.push(entry.path().to_path_buf());
}
}
}
if !files.is_empty() {
return FindFileResult::Found(files);
}
FindFileResult::NotFound
}
pub fn find_file_downward(start: PathBuf, names: &[PathBuf]) -> FindFileResult {
let mut files = Vec::new();
for entry in WalkDir::new(&start)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_dir())
{
for name in names {
let candidate = entry.path().join(name);
if candidate.exists() {
files.push(candidate);
}
}
}
if !files.is_empty() {
return FindFileResult::Found(files);
}
FindFileResult::NotFound
}
pub fn find_file_upward(start: PathBuf, names: &[PathBuf]) -> FindFileResult {
let mut pwd = start;
let mut files = Vec::new();
let mut remaining: Vec<&PathBuf> = names.iter().collect();
loop {
remaining.retain(|name| {
let candidate = pwd.join(name);
if candidate.exists() {
files.push(candidate);
false
} else {
true
}
});
if remaining.is_empty() || !pwd.pop() {
break;
}
}
if !files.is_empty() {
return FindFileResult::Found(files);
}
FindFileResult::NotFound
}
pub fn read_file(path: &Path) -> Result<String, io::Error> {
let meta = fs::metadata(path)?;
if meta.len() > MAX_CONTRACT_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"file is {} bytes, larger than the {} byte limit",
meta.len(),
MAX_CONTRACT_BYTES
),
));
}
fs::read_to_string(path)
}
fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
pub fn confine_to_dir(base: &Path, target: &Path) -> Result<PathBuf, String> {
let joined = if target.is_absolute() {
target.to_path_buf()
} else {
base.join(target)
};
let normalized = normalize_lexical(&joined);
let base_norm = normalize_lexical(base);
if normalized.starts_with(&base_norm) {
Ok(normalized)
} else {
Err(format!(
"refusing to use a path outside the working directory: {}",
target.display()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_slash_renders_components_with_forward_slashes() {
let p: PathBuf = ["user", "profile", "user.json"].iter().collect();
assert_eq!(to_slash(&p), "user/profile/user.json");
}
#[test]
fn to_slash_leaves_a_plain_name_untouched() {
assert_eq!(to_slash(Path::new("login.json")), "login.json");
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("apic_test_file_{tag}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn read_file_preserves_multibyte_across_old_chunk_boundary() {
let root = temp_dir("utf8");
let path = root.join("c.txt");
let content = format!("{}é tail", "x".repeat(1023));
fs::write(&path, &content).unwrap();
assert_eq!(read_file(&path).unwrap(), content);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn read_file_errors_instead_of_panicking_on_missing_file() {
let missing = std::env::temp_dir().join("apic_test_file_does_not_exist.txt");
let _ = fs::remove_file(&missing);
assert!(read_file(&missing).is_err());
}
#[test]
fn read_file_rejects_oversized_files() {
let root = temp_dir("oversize");
let path = root.join("big.json");
let f = fs::File::create(&path).unwrap();
f.set_len(MAX_CONTRACT_BYTES + 1).unwrap();
assert!(read_file(&path).is_err());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn confine_accepts_paths_inside_base() {
let base = Path::new("/home/u/project");
assert_eq!(
confine_to_dir(base, Path::new("auth/login.json")).unwrap(),
PathBuf::from("/home/u/project/auth/login.json")
);
}
#[test]
fn confine_rejects_parent_dir_escape() {
let base = Path::new("/home/u/project");
assert!(confine_to_dir(base, Path::new("../../etc/passwd")).is_err());
}
#[test]
fn confine_rejects_absolute_path_outside_base() {
let base = Path::new("/home/u/project");
assert!(confine_to_dir(base, Path::new("/etc/passwd")).is_err());
}
#[test]
fn confine_normalizes_interior_dotdot() {
let base = Path::new("/home/u/project");
assert_eq!(
confine_to_dir(base, Path::new("auth/../user/x.json")).unwrap(),
PathBuf::from("/home/u/project/user/x.json")
);
}
#[test]
fn find_by_ext_downward_finds_json_and_reports_not_found() {
let root = temp_dir("byext");
fs::create_dir_all(root.join("sub")).unwrap();
fs::write(root.join("sub/a.json"), "{}").unwrap();
fs::write(root.join("b.txt"), "x").unwrap();
match find_file_by_ext_downward(root.clone(), &["json"]) {
FindFileResult::Found(files) => {
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("a.json"));
}
FindFileResult::NotFound => panic!("expected to find a.json"),
}
let empty = temp_dir("byext_empty");
assert!(matches!(
find_file_by_ext_downward(empty.clone(), &["json"]),
FindFileResult::NotFound
));
fs::remove_dir_all(&root).unwrap();
fs::remove_dir_all(&empty).unwrap();
}
#[test]
fn find_upward_locates_marker_in_an_ancestor() {
let root = temp_dir("upward");
let nested = root.join("a/b/c");
fs::create_dir_all(&nested).unwrap();
fs::write(root.join("marker"), "x").unwrap();
match find_file_upward(nested, &[PathBuf::from("marker")]) {
FindFileResult::Found(files) => {
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("marker"));
}
FindFileResult::NotFound => panic!("expected to find marker upward"),
}
fs::remove_dir_all(&root).unwrap();
}
}