use std::path::PathBuf;
use crate::error::FixupError;
use crate::paths::{SandboxConfig, SandboxError, SandboxRoot};
pub fn validate_fixup_target(
path: &std::path::Path,
repo_root: &std::path::Path,
allow_links: bool,
) -> Result<(), FixupError> {
let config = SandboxConfig {
allow_symlinks: allow_links,
allow_hardlinks: allow_links,
};
let sandbox_root = SandboxRoot::new(repo_root, config).map_err(map_root_err)?;
let sandbox_path = sandbox_root.join(path).map_err(map_join_err)?;
if !sandbox_path.as_path().exists() {
return Err(FixupError::TargetFileNotFound {
path: path.display().to_string(),
});
}
Ok(())
}
fn map_root_err(err: SandboxError) -> FixupError {
match err {
SandboxError::RootNotFound { path } | SandboxError::RootNotDirectory { path } => {
FixupError::CanonicalizationError(format!("Invalid repo root: {path}"))
}
SandboxError::RootCanonicalizationFailed { path, reason }
| SandboxError::PathCanonicalizationFailed { path, reason } => {
FixupError::CanonicalizationError(format!(
"Failed to canonicalize repo root {path}: {reason}"
))
}
SandboxError::AbsolutePath { path } => FixupError::AbsolutePath(PathBuf::from(path)),
SandboxError::ParentTraversal { path } => FixupError::ParentDirEscape(PathBuf::from(path)),
SandboxError::EscapeAttempt { path, .. } => FixupError::OutsideRepo(PathBuf::from(path)),
SandboxError::SymlinkNotAllowed { path } => {
FixupError::SymlinkNotAllowed(PathBuf::from(path))
}
SandboxError::HardlinkNotAllowed { path } => {
FixupError::HardlinkNotAllowed(PathBuf::from(path))
}
}
}
fn map_join_err(err: SandboxError) -> FixupError {
match err {
SandboxError::AbsolutePath { path } => FixupError::AbsolutePath(PathBuf::from(path)),
SandboxError::ParentTraversal { path } => FixupError::ParentDirEscape(PathBuf::from(path)),
SandboxError::EscapeAttempt { path, .. } => FixupError::OutsideRepo(PathBuf::from(path)),
SandboxError::SymlinkNotAllowed { path } => {
FixupError::SymlinkNotAllowed(PathBuf::from(path))
}
SandboxError::HardlinkNotAllowed { path } => {
FixupError::HardlinkNotAllowed(PathBuf::from(path))
}
SandboxError::RootNotFound { path } | SandboxError::RootNotDirectory { path } => {
FixupError::CanonicalizationError(format!("Invalid repo root: {path}"))
}
SandboxError::RootCanonicalizationFailed { path, reason }
| SandboxError::PathCanonicalizationFailed { path, reason } => {
FixupError::CanonicalizationError(format!("Failed to canonicalize {path}: {reason}"))
}
}
}
#[cfg(test)]
mod tests {
use super::validate_fixup_target;
use crate::error::FixupError;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_validate_fixup_target_rejects_absolute_paths() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let test_file = repo_root.join("test.txt");
fs::write(&test_file, "test content").unwrap();
#[cfg(unix)]
let absolute_path = std::path::Path::new("/etc/passwd");
#[cfg(windows)]
let absolute_path = std::path::Path::new("C:\\Windows\\System32\\config\\sam");
let result = validate_fixup_target(absolute_path, repo_root, false);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), FixupError::AbsolutePath(_)));
}
#[test]
fn test_validate_fixup_target_rejects_parent_dir_escapes() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let test_file = repo_root.join("test.txt");
fs::write(&test_file, "test content").unwrap();
let escape_path = std::path::Path::new("../../../etc/passwd");
let result = validate_fixup_target(escape_path, repo_root, false);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
FixupError::ParentDirEscape(_)
));
let escape_path2 = std::path::Path::new("subdir/../../outside.txt");
let result2 = validate_fixup_target(escape_path2, repo_root, false);
assert!(result2.is_err());
assert!(matches!(
result2.unwrap_err(),
FixupError::ParentDirEscape(_)
));
}
#[test]
fn test_validate_fixup_target_accepts_valid_relative_paths() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let test_file = repo_root.join("test.txt");
fs::write(&test_file, "test content").unwrap();
let subdir = repo_root.join("subdir");
fs::create_dir(&subdir).unwrap();
let nested_file = subdir.join("nested.txt");
fs::write(&nested_file, "nested content").unwrap();
let valid_path1 = std::path::Path::new("test.txt");
assert!(validate_fixup_target(valid_path1, repo_root, false).is_ok());
let valid_path2 = std::path::Path::new("subdir/nested.txt");
assert!(validate_fixup_target(valid_path2, repo_root, false).is_ok());
}
#[test]
fn test_validate_fixup_target_rejects_symlinks_by_default() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let target_file = repo_root.join("target.txt");
fs::write(&target_file, "target content").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let symlink_path = repo_root.join("link_to_target");
symlink(&target_file, &symlink_path).unwrap();
let result =
validate_fixup_target(std::path::Path::new("link_to_target"), repo_root, false);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
FixupError::SymlinkNotAllowed(_)
));
}
#[cfg(windows)]
{
use std::os::windows::fs::symlink_file;
let symlink_path = repo_root.join("link_to_target");
if symlink_file(&target_file, &symlink_path).is_ok() {
let result =
validate_fixup_target(std::path::Path::new("link_to_target"), repo_root, false);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
FixupError::SymlinkNotAllowed(_)
));
}
}
}
#[test]
fn test_validate_fixup_target_allows_symlinks_with_flag() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let target_file = repo_root.join("target.txt");
fs::write(&target_file, "target content").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let symlink_path = repo_root.join("link_to_target");
symlink(&target_file, &symlink_path).unwrap();
let result =
validate_fixup_target(std::path::Path::new("link_to_target"), repo_root, true);
assert!(result.is_ok());
}
#[cfg(windows)]
{
use std::os::windows::fs::symlink_file;
let symlink_path = repo_root.join("link_to_target");
if symlink_file(&target_file, &symlink_path).is_ok() {
let result =
validate_fixup_target(std::path::Path::new("link_to_target"), repo_root, true);
assert!(result.is_ok());
}
}
}
#[test]
fn test_validate_fixup_target_rejects_hardlinks_by_default() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let target_file = repo_root.join("target.txt");
fs::write(&target_file, "target content").unwrap();
#[cfg(unix)]
{
let hardlink_path = repo_root.join("hardlink_to_target");
std::fs::hard_link(&target_file, &hardlink_path).unwrap();
let result =
validate_fixup_target(std::path::Path::new("hardlink_to_target"), repo_root, false);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
FixupError::HardlinkNotAllowed(_)
));
}
#[cfg(windows)]
{
use std::fs::hard_link;
let hardlink_path = repo_root.join("hardlink_to_target");
if hard_link(&target_file, &hardlink_path).is_ok() {
let result = validate_fixup_target(
std::path::Path::new("hardlink_to_target"),
repo_root,
false,
);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
FixupError::HardlinkNotAllowed(_)
));
} else {
println!(
"Skipping hardlink rejection test on Windows (creating hardlink requires elevated permissions)"
);
}
}
}
#[test]
fn test_validate_fixup_target_allows_hardlinks_with_flag() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let target_file = repo_root.join("target.txt");
fs::write(&target_file, "target content").unwrap();
#[cfg(unix)]
{
let hardlink_path = repo_root.join("hardlink_to_target");
std::fs::hard_link(&target_file, &hardlink_path).unwrap();
let result =
validate_fixup_target(std::path::Path::new("hardlink_to_target"), repo_root, true);
assert!(result.is_ok());
}
#[cfg(windows)]
{
use std::fs::hard_link;
let hardlink_path = repo_root.join("hardlink_to_target");
if hard_link(&target_file, &hardlink_path).is_ok() {
let result = validate_fixup_target(
std::path::Path::new("hardlink_to_target"),
repo_root,
true,
);
assert!(result.is_ok());
} else {
println!(
"Skipping hardlink allow test on Windows (creating hardlink requires elevated permissions)"
);
}
}
}
#[test]
fn test_validate_fixup_target_symlink_escape() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let outside_dir = temp_dir.path().parent().unwrap().join("outside");
fs::create_dir_all(&outside_dir).unwrap();
let outside_file = outside_dir.join("secret.txt");
fs::write(&outside_file, "secret content").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let symlink_path = repo_root.join("escape_link");
let _ = symlink(&outside_file, &symlink_path);
let result =
validate_fixup_target(std::path::Path::new("escape_link"), repo_root, false);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
FixupError::SymlinkNotAllowed(_)
));
let result_with_links =
validate_fixup_target(std::path::Path::new("escape_link"), repo_root, true);
assert!(result_with_links.is_err());
assert!(matches!(
result_with_links.unwrap_err(),
FixupError::OutsideRepo(_)
));
}
#[cfg(windows)]
{
use std::os::windows::fs::symlink_file;
let symlink_path = repo_root.join("escape_link");
if symlink_file(&outside_file, &symlink_path).is_ok() {
let result =
validate_fixup_target(std::path::Path::new("escape_link"), repo_root, false);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
FixupError::SymlinkNotAllowed(_)
));
let result_with_links =
validate_fixup_target(std::path::Path::new("escape_link"), repo_root, true);
assert!(result_with_links.is_err());
assert!(matches!(
result_with_links.unwrap_err(),
FixupError::OutsideRepo(_)
));
}
}
}
#[test]
#[cfg(windows)]
fn test_validate_fixup_target_windows_case_insensitive() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let test_file = repo_root.join("Test.txt");
fs::write(&test_file, "test content").unwrap();
let lower_case = std::path::Path::new("test.txt");
let result = validate_fixup_target(lower_case, repo_root, false);
assert!(result.is_ok());
let upper_case = std::path::Path::new("TEST.TXT");
let result2 = validate_fixup_target(upper_case, repo_root, false);
assert!(result2.is_ok());
}
#[test]
fn test_validate_fixup_target_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let nonexistent = std::path::Path::new("does_not_exist.txt");
let result = validate_fixup_target(nonexistent, repo_root, false);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
FixupError::TargetFileNotFound { .. }
));
}
#[test]
fn test_validate_fixup_target_with_dot_components() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let test_file = repo_root.join("test.txt");
fs::write(&test_file, "test content").unwrap();
let dot_path = std::path::Path::new("./test.txt");
let result = validate_fixup_target(dot_path, repo_root, false);
assert!(result.is_ok());
let nested_dot = std::path::Path::new("./subdir/../test.txt");
let result2 = validate_fixup_target(nested_dot, repo_root, false);
assert!(result2.is_err());
}
}