use std::path::{Component, Path, PathBuf};
use crate::error::GitError;
#[must_use]
pub fn extract_repo_slug(url_path: &str) -> String {
let mut clean = url_path.to_string();
if let Some(q) = clean.find('?') {
clean.truncate(q);
}
for suffix in ["/info/refs", "/git-upload-pack", "/git-receive-pack"] {
if let Some(idx) = clean.rfind(suffix) {
if idx + suffix.len() == clean.len() {
clean.truncate(idx);
break;
}
}
}
clean = clean.trim_start_matches('/').to_string();
loop {
let stripped = clean.replace("..", "");
if stripped == clean {
break;
}
clean = stripped;
}
if clean.is_empty() {
".".into()
} else {
clean
}
}
pub fn path_safe(repo_root: &Path, requested: &str) -> Result<PathBuf, GitError> {
let req = Path::new(requested);
if req.is_absolute() {
return Err(GitError::PathTraversal(format!(
"absolute path rejected: {requested}"
)));
}
for component in req.components() {
if matches!(component, Component::ParentDir) {
return Err(GitError::PathTraversal(format!(
"`..` component rejected: {requested}"
)));
}
}
let root_canon = repo_root
.canonicalize()
.map_err(|e| GitError::PathTraversal(format!("root canonicalize: {e}")))?;
let candidate = root_canon.join(req);
if !candidate.starts_with(&root_canon) {
return Err(GitError::PathTraversal(format!(
"resolved path escapes root: {}",
candidate.display()
)));
}
Ok(candidate)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn extract_slug_strips_info_refs() {
assert_eq!(extract_repo_slug("/alice/repo/info/refs"), "alice/repo");
}
#[test]
fn extract_slug_strips_upload_pack() {
assert_eq!(
extract_repo_slug("/alice/repo/git-upload-pack"),
"alice/repo"
);
}
#[test]
fn extract_slug_strips_receive_pack() {
assert_eq!(
extract_repo_slug("/alice/repo/git-receive-pack"),
"alice/repo"
);
}
#[test]
fn extract_slug_empty_returns_dot() {
assert_eq!(extract_repo_slug("/info/refs"), ".");
}
#[test]
fn extract_slug_removes_parent_dirs() {
let slug = extract_repo_slug("/..%2F..%2Fetc/info/refs");
let slug2 = extract_repo_slug("/../../etc/info/refs");
assert!(!slug2.contains(".."), "slug still has `..`: {slug2}");
assert!(slug.contains('%'), "slug={slug}");
}
#[test]
fn path_safe_accepts_child() {
let td = TempDir::new().unwrap();
let result = path_safe(td.path(), "alice/repo").unwrap();
assert!(result.starts_with(td.path().canonicalize().unwrap()));
}
#[test]
fn path_safe_rejects_absolute() {
let td = TempDir::new().unwrap();
let err = path_safe(td.path(), "/etc/passwd").unwrap_err();
assert!(matches!(err, GitError::PathTraversal(_)));
}
#[test]
fn path_safe_rejects_parent_dir() {
let td = TempDir::new().unwrap();
let err = path_safe(td.path(), "../etc").unwrap_err();
assert!(matches!(err, GitError::PathTraversal(_)));
}
#[test]
fn path_safe_rejects_nested_parent() {
let td = TempDir::new().unwrap();
let err = path_safe(td.path(), "alice/../../etc").unwrap_err();
assert!(matches!(err, GitError::PathTraversal(_)));
}
}