use std::path::{Component, Path, PathBuf};
pub(crate) fn is_repo_relative(path: &str) -> bool {
if path.is_empty() || path.contains('\0') {
return false;
}
let p = Path::new(path);
if p.is_absolute() {
return false;
}
!p.components().any(|c| matches!(c, Component::ParentDir))
}
pub(crate) fn resolve_repo_path(root: &Path, path: &str) -> Option<PathBuf> {
if !is_repo_relative(path) {
return None;
}
let root_c = root.canonicalize().ok()?;
let canon = root_c.join(path).canonicalize().ok()?;
canon.starts_with(&root_c).then_some(canon)
}
pub(crate) fn read_repo_file(root: &Path, path: &str) -> Option<Vec<u8>> {
let canon = resolve_repo_path(root, path)?;
std::fs::read(canon).ok()
}
pub(crate) fn read_repo_text(root: &Path, path: &str) -> Option<String> {
String::from_utf8(read_repo_file(root, path)?).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_absolute_and_dotdot_paths() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
std::fs::write(root.join("ok.py"), "x\n").unwrap();
assert!(resolve_repo_path(root, "ok.py").is_some());
assert!(resolve_repo_path(root, "/etc/passwd").is_none());
assert!(resolve_repo_path(root, "../secret").is_none());
assert!(!is_repo_relative("/etc/passwd"));
assert!(!is_repo_relative("../../secret"));
}
}