use crate::anchored_canonicalize;
use std::fs;
use tempfile::TempDir;
#[cfg(unix)]
#[test]
fn test_absolute_symlink_to_external_path_is_clamped() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let external_td = TempDir::new().unwrap();
let external_file = external_td.path().join("external_secret.txt");
fs::write(&external_file, b"external data").unwrap();
let abs_external = fs::canonicalize(&external_file).unwrap();
let symlink_path = abs_anchor.join("link_to_external");
symlink(&abs_external, symlink_path).unwrap();
let result = anchored_canonicalize(&abs_anchor, "link_to_external").unwrap();
assert!(
result.starts_with(&abs_anchor),
"Symlink target should be clamped to anchor. Got: {:?}, Anchor: {:?}",
result,
abs_anchor
);
let external_stripped = abs_external.strip_prefix("/").unwrap();
let expected = abs_anchor.join(external_stripped);
assert_eq!(
result, expected,
"Should clamp by stripping root prefix and rejoining to anchor"
);
}
#[cfg(unix)]
#[test]
fn test_absolute_symlink_to_root_path_is_clamped() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let symlink_path = abs_anchor.join("passwd_link");
symlink("/etc/passwd", symlink_path).unwrap();
let result = anchored_canonicalize(&abs_anchor, "passwd_link").unwrap();
assert!(
result.starts_with(&abs_anchor),
"Should clamp to anchor. Got: {:?}, Anchor: {:?}",
result,
abs_anchor
);
assert!(
result.ends_with("etc/passwd"),
"Should clamp /etc/passwd to anchor/etc/passwd. Got: {:?}",
result
);
let expected = abs_anchor.join("etc/passwd");
assert_eq!(result, expected);
}
#[cfg(unix)]
#[test]
fn test_chained_symlinks_with_absolute_target_all_clamped() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let link1 = abs_anchor.join("link1");
let link2 = abs_anchor.join("link2");
symlink("link2", link1).unwrap(); symlink("/absolute/target", link2).unwrap();
let result = anchored_canonicalize(&abs_anchor, "link1").unwrap();
assert!(result.starts_with(&abs_anchor));
assert!(result.ends_with("absolute/target"));
let expected = abs_anchor.join("absolute/target");
assert_eq!(result, expected);
}
#[cfg(unix)]
#[test]
fn test_relative_symlinks_continue_working() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let target = abs_anchor.join("data/target.txt");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, b"target content").unwrap();
let link_dir = abs_anchor.join("links");
fs::create_dir(&link_dir).unwrap();
let symlink_path = link_dir.join("relative_link");
symlink("../data/target.txt", symlink_path).unwrap();
let result = anchored_canonicalize(&abs_anchor, "links/relative_link").unwrap();
let expected = fs::canonicalize(&target).unwrap();
assert_eq!(result, expected);
}
#[cfg(unix)]
#[test]
fn test_dotdot_after_absolute_symlink_stays_clamped() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let link = abs_anchor.join("mylink");
symlink("/etc/config/subdir", link).unwrap();
let result = anchored_canonicalize(&abs_anchor, "mylink/../../passwd").unwrap();
assert!(result.starts_with(&abs_anchor));
assert!(result.ends_with("etc/passwd"));
let expected = abs_anchor.join("etc/passwd");
assert_eq!(result, expected);
}
#[cfg(unix)]
#[test]
fn test_excessive_dotdot_after_absolute_symlink_clamps_to_anchor() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let link = abs_anchor.join("mylink");
symlink("/a/b/c", link).unwrap();
let result =
anchored_canonicalize(&abs_anchor, "mylink/../../../../../../../../etc/passwd").unwrap();
assert!(result.starts_with(&abs_anchor));
assert!(result.ends_with("etc/passwd"));
let expected = abs_anchor.join("etc/passwd");
assert_eq!(result, expected);
}
#[cfg(unix)]
#[test]
fn test_symlink_to_nonexistent_absolute_path_is_clamped() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let link = abs_anchor.join("future_link");
symlink("/future/file/that/does/not/exist", link).unwrap();
let result = anchored_canonicalize(&abs_anchor, "future_link").unwrap();
assert!(result.starts_with(&abs_anchor));
let expected = abs_anchor.join("future/file/that/does/not/exist");
assert_eq!(result, expected);
}
#[cfg(unix)]
#[test]
fn test_mixed_absolute_and_relative_symlink_chain() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
fs::create_dir_all(abs_anchor.join("data")).unwrap();
let link1 = abs_anchor.join("link1");
let link2 = abs_anchor.join("data/link2");
let link3_target = abs_anchor.join("data/final");
fs::create_dir_all(&link3_target).unwrap();
symlink("data/link2", link1).unwrap(); symlink("/etc/link3", link2).unwrap();
let link3 = abs_anchor.join("etc/link3");
fs::create_dir_all(link3.parent().unwrap()).unwrap();
symlink("../data/final", &link3).unwrap();
let result = anchored_canonicalize(&abs_anchor, "link1").unwrap();
assert!(result.starts_with(&abs_anchor));
let expected = fs::canonicalize(&link3_target).unwrap();
assert_eq!(result, expected);
}
#[cfg(windows)]
#[test]
fn test_windows_absolute_symlink_clamping() {
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = crate::soft_canonicalize(&anchor).unwrap();
let result = anchored_canonicalize(&abs_anchor, r"C:\Windows\System32").unwrap();
assert!(result.starts_with(&abs_anchor));
assert!(result.to_string_lossy().contains("Windows"));
assert!(result.to_string_lossy().contains("System32"));
}
#[cfg(unix)]
#[test]
fn test_archive_extraction_scenario() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let sandbox = td.path().join("extract_zone");
fs::create_dir_all(&sandbox).unwrap();
let abs_sandbox = fs::canonicalize(&sandbox).unwrap();
fs::create_dir_all(abs_sandbox.join("data")).unwrap();
fs::write(abs_sandbox.join("data/config.txt"), b"config data").unwrap();
fs::create_dir_all(abs_sandbox.join("shortcuts")).unwrap();
let cfg_link = abs_sandbox.join("shortcuts/cfg");
symlink("/etc/config", cfg_link).unwrap();
let result = anchored_canonicalize(&abs_sandbox, "shortcuts/cfg").unwrap();
assert!(result.starts_with(&abs_sandbox));
let expected = abs_sandbox.join("etc/config");
assert_eq!(result, expected);
assert!(!result.to_string_lossy().contains("/../"));
assert!(!result.starts_with("/etc"));
}
#[cfg(unix)]
#[test]
fn test_symlink_cycle_detection_still_works_with_clamping() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("virtual_root");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let link_a = abs_anchor.join("a");
let link_b = abs_anchor.join("b");
symlink(abs_anchor.join("b"), link_a).unwrap(); symlink(abs_anchor.join("a"), link_b).unwrap();
let err = anchored_canonicalize(&abs_anchor, "a").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(err.to_string().contains("symbolic link"));
}
#[cfg(unix)]
#[test]
fn test_symlink_first_resolution_order_prevents_premature_dotdot() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("jail");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let home_dir = abs_anchor.join("home");
let opt_subdir = abs_anchor.join("opt/subdir");
let special_target = opt_subdir.join("special");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&special_target).unwrap();
let wrong_file = home_dir.join("wrongfile.txt");
let right_file = special_target.join("rightfile.txt");
fs::write(wrong_file, b"WRONG: lexical .. applied first").unwrap();
fs::write(right_file, b"RIGHT: symlink resolved first").unwrap();
let symlink_path = home_dir.join("special");
symlink("/opt/subdir/special", symlink_path).unwrap();
let right_file_correct = opt_subdir.join("rightfile.txt");
fs::write(right_file_correct, b"RIGHT: symlink resolved first").unwrap();
let result = anchored_canonicalize(&abs_anchor, "home/special/../rightfile.txt").unwrap();
let expected = abs_anchor.join("opt/subdir/rightfile.txt");
assert_eq!(
result, expected,
"Should resolve symlink BEFORE applying .., ending at opt/subdir/rightfile.txt"
);
let wrong_location = abs_anchor.join("home/rightfile.txt");
assert_ne!(
result, wrong_location,
"Should NOT end at home/rightfile.txt (lexical .. would put us there)"
);
if result.exists() {
let content = fs::read_to_string(&result).unwrap();
assert!(
content.contains("RIGHT"),
"Should read the RIGHT file, got: {}",
content
);
}
}
#[cfg(unix)]
#[test]
fn test_relative_symlink_with_dotdot_resolution_order() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let anchor = td.path().join("base");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = fs::canonicalize(&anchor).unwrap();
let dir1 = abs_anchor.join("dir1");
let dir2 = abs_anchor.join("dir2");
let target_dir = dir2.join("target");
fs::create_dir_all(&dir1).unwrap();
fs::create_dir_all(&target_dir).unwrap();
fs::write(dir1.join("wrongmarker.txt"), b"wrong").unwrap();
fs::write(dir2.join("rightmarker.txt"), b"right").unwrap();
fs::write(target_dir.join("data.txt"), b"data").unwrap();
let link_path = dir1.join("link");
symlink("../dir2/target", link_path).unwrap();
let result = anchored_canonicalize(&abs_anchor, "dir1/link/../rightmarker.txt").unwrap();
let expected = abs_anchor.join("dir2/rightmarker.txt");
assert_eq!(
result, expected,
"Should resolve to dir2/rightmarker.txt (symlink-first semantics)"
);
let wrong_location = abs_anchor.join("dir1/rightmarker.txt");
assert_ne!(
result, wrong_location,
"Should NOT resolve to dir1/rightmarker.txt"
);
if result.exists() {
let content = fs::read_to_string(&result).unwrap();
assert_eq!(content, "right", "Should read the right marker file");
}
}
#[cfg(windows)]
#[test]
fn test_windows_lexical_symlink_first_verification() {
use std::io;
use std::os::windows::fs::symlink_dir;
let td = TempDir::new().unwrap();
let anchor = td.path().join("jail");
fs::create_dir_all(&anchor).unwrap();
let abs_anchor = crate::soft_canonicalize(&anchor).unwrap();
let home_dir = abs_anchor.join("home");
let opt_subdir = abs_anchor.join("opt\\subdir");
let special_target = opt_subdir.join("special");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(special_target).unwrap();
let symlink_path = home_dir.join("special");
let relative_target = r"..\opt\subdir\special";
match symlink_dir(relative_target, symlink_path) {
Ok(_) => {
let result =
anchored_canonicalize(&abs_anchor, r"home\special\..\rightfile.txt").unwrap();
let expected = abs_anchor.join(r"opt\subdir\rightfile.txt");
#[cfg(not(feature = "dunce"))]
{
assert_eq!(result, expected);
}
#[cfg(feature = "dunce")]
{
let result_str = result.to_string_lossy();
let expected_str = expected.to_string_lossy();
assert!(!result_str.starts_with(r"\\?\"), "dunce should simplify");
assert_eq!(
result_str.trim_start_matches(r"\\?\"),
expected_str.trim_start_matches(r"\\?\")
);
}
let wrong_location = abs_anchor.join(r"home\rightfile.txt");
#[cfg(not(feature = "dunce"))]
{
assert_ne!(result, wrong_location);
}
#[cfg(feature = "dunce")]
{
let result_str = result.to_string_lossy();
let wrong_str = wrong_location.to_string_lossy();
assert_ne!(
result_str.trim_start_matches(r"\\?\"),
wrong_str.trim_start_matches(r"\\?\")
);
}
}
Err(e) if e.kind() == io::ErrorKind::PermissionDenied || e.raw_os_error() == Some(1314) => {
eprintln!(
"Skipping Windows symlink test: insufficient privileges (error 1314). \
This test will run fully on GitHub Actions with elevated permissions."
);
}
Err(e) => {
panic!("Unexpected error creating symlink: {:?}", e);
}
}
}