#[cfg(feature = "virtual-path")]
use crate::VirtualRoot;
use crate::{PathBoundary, StrictPathError};
#[test]
#[cfg(windows)]
fn test_case_sensitivity_bypass_attack() {
let temp = tempfile::tempdir().unwrap();
let restriction_dir = temp.path();
let data_dir = restriction_dir.join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let restriction: PathBoundary = PathBoundary::try_new(restriction_dir).unwrap();
let attack_path = r"DATA\..\..\windows";
let result = restriction.strict_join(attack_path);
match result {
Err(StrictPathError::PathEscapesBoundary { .. }) => {
}
Ok(p) => {
panic!("SECURITY FAILURE: Case-sensitivity bypass was not detected. Path: {p:?}");
}
Err(e) => {
panic!("Unexpected error for case-sensitivity attack: {e:?}");
}
}
}
#[test]
#[cfg(windows)]
fn test_ntfs_83_short_name_bypass_attack() {
use std::process::Command;
let temp = tempfile::tempdir().unwrap();
let restriction_dir = temp.path();
let long_name_dir = restriction_dir.join("long-directory-name");
std::fs::create_dir_all(&long_name_dir).unwrap();
let output = Command::new("cmd")
.args(["/C", "dir /X"])
.current_dir(restriction_dir)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let short_name = stdout
.lines()
.find(|line| line.contains("long-directory-name"))
.and_then(|line| {
line.split_whitespace()
.find(|s| s.contains('~') && s.len() <= 12)
});
if let Some(short_name) = short_name {
let restriction: PathBoundary = PathBoundary::try_new(restriction_dir).unwrap();
let attack_path = format!(r"{short_name}\..\..\windows");
let result = restriction.strict_join(&attack_path);
match result {
Err(StrictPathError::PathEscapesBoundary { .. }) => {
}
Ok(p) => {
let p_canon = std::fs::canonicalize(p.interop_path()).unwrap();
assert!(
p_canon.starts_with(restriction_dir),
"Path {p_canon:?} should be inside boundary {restriction_dir:?}"
);
}
Err(e) => {
panic!("Unexpected error for 8.3 short name test: {e:?}");
}
}
} else {
eprintln!("Skipping 8.3 short name test: could not determine short name for 'long-directory-name'.");
}
}
#[cfg(feature = "virtual-path")]
#[test]
fn test_advanced_toctou_read_race_condition() {
let temp = tempfile::tempdir().unwrap();
let restriction_dir = {
let p = temp.path().join("restriction");
std::fs::create_dir_all(&p).unwrap();
#[cfg(not(windows))]
{
std::fs::canonicalize(&p).unwrap()
}
#[cfg(windows)]
{
let canonical = std::fs::canonicalize(&p).unwrap();
let canonical_str = canonical.to_string_lossy();
if let Some(stripped) = canonical_str.strip_prefix(r"\\?\") {
std::path::PathBuf::from(stripped)
} else {
canonical
}
}
};
let safe_dir = restriction_dir.join("safe");
std::fs::create_dir_all(&safe_dir).unwrap();
let outside_dir = temp.path().join("outside");
std::fs::create_dir_all(&outside_dir).unwrap();
let _safe_file = safe_dir.join("file.txt");
std::fs::write(&_safe_file, "safe content").unwrap();
let _outside_file = outside_dir.join("secret.txt");
std::fs::write(&_outside_file, "secret content").unwrap();
let link_path = restriction_dir.join("link");
#[cfg(unix)]
{
let rel_target = std::path::Path::new("safe").join("file.txt");
std::os::unix::fs::symlink(&rel_target, &link_path).unwrap();
}
#[cfg(windows)]
{
let rel_target = std::path::Path::new("safe").join("file.txt");
if let Err(e) = std::os::windows::fs::symlink_file(&rel_target, &link_path) {
eprintln!("Skipping TOCTOU test - symlink creation failed: {e:?}");
return;
}
}
let vroot: VirtualRoot = VirtualRoot::try_new(&restriction_dir).unwrap();
let path_object = vroot.virtual_join("link").unwrap();
match path_object.read_to_string() {
Ok(content) => {
assert_eq!(
content, "safe content",
"Initial TOCTOU read returned unexpected data; expected safe content"
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
}
Err(e) => {
panic!("Unexpected error for initial TOCTOU read: {e:?}");
}
}
#[cfg(unix)]
{
std::fs::remove_file(&link_path).unwrap();
let rel_escape = std::path::Path::new("..")
.join("outside")
.join("secret.txt");
std::os::unix::fs::symlink(&rel_escape, &link_path).unwrap();
}
#[cfg(windows)]
{
std::fs::remove_file(&link_path).unwrap();
let rel_escape = std::path::Path::new("..")
.join("outside")
.join("secret.txt");
if let Err(e) = std::os::windows::fs::symlink_file(&rel_escape, &link_path) {
eprintln!("Skipping TOCTOU test - symlink re-creation failed: {e:?}");
return;
}
}
let result = path_object.read_to_string();
match result {
Err(e) if e.kind() == std::io::ErrorKind::Other => {
let inner_err = e.into_inner().unwrap();
if let Some(strict_err) = inner_err.downcast_ref::<StrictPathError>() {
assert!(
matches!(strict_err, StrictPathError::PathEscapesBoundary { .. }),
"Expected PathEscapesBoundary but got {strict_err:?}",
);
} else {
panic!("Expected StrictPathError but got a different error type.");
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
}
Ok(content) => {
assert_eq!(
content, "safe content",
"TOCTOU read returned unexpected data; possible escape"
);
}
Err(e) => {
panic!("Unexpected error for TOCTOU read race: {e:?}");
}
}
}
#[test]
fn test_environment_variable_injection() {
let temp = tempfile::tempdir().unwrap();
let restriction: PathBoundary = PathBoundary::try_new(temp.path()).unwrap();
let patterns = if cfg!(windows) {
vec![r"..\%WINDIR%\System32", r"..\%TEMP%\file"]
} else {
vec!["../$HOME/.ssh/id_rsa", "../$TMPDIR/file"]
};
for pattern in patterns {
let result = restriction.strict_join(pattern);
match result {
Err(StrictPathError::PathEscapesBoundary { .. }) => {
}
Ok(p) => {
panic!("SECURITY FAILURE: Environment variable was likely expanded. Path: {p:?}");
}
Err(e) => {
assert!(matches!(e, StrictPathError::PathResolutionError { .. }));
}
}
}
}
#[test]
#[cfg(windows)]
fn test_github_runner_short_name_scenario_existing_paths() {
use std::process::Command;
let temp = tempfile::tempdir().unwrap();
let long_name_dir = temp
.path()
.join("very-long-directory-name-that-triggers-8dot3");
std::fs::create_dir_all(&long_name_dir).unwrap();
let output = Command::new("cmd")
.args(["/C", "dir", "/X"])
.current_dir(temp.path())
.output();
let short_name = if let Ok(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.find(|line| line.contains("very-long-directory-name"))
.and_then(|line| {
line.split_whitespace()
.find(|s| s.contains('~') && s.len() <= 12)
})
.map(|s| s.to_string())
} else {
None
};
if let Some(short_name) = short_name {
eprintln!("Found 8.3 short name: {short_name}");
let test_dir: PathBoundary = PathBoundary::try_new(&long_name_dir)
.expect("Should create boundary from existing long-named directory");
let test_file = long_name_dir.join("test.txt");
std::fs::write(&test_file, "content").unwrap();
let joined = test_dir
.strict_join("test.txt")
.expect("Should join to existing file");
assert!(joined.exists());
let short_path = temp.path().join(&short_name).join("test.txt");
eprintln!("Attempting to access via short path: {short_path:?}");
if short_path.exists() {
eprintln!("Short path exists at OS level, testing strict_join...");
let short_name_dir_result: Result<PathBoundary, _> =
PathBoundary::try_new(temp.path().join(&short_name));
match short_name_dir_result {
Ok(short_name_dir) => {
eprintln!("Created boundary via short name (canonicalization expanded it)");
let via_short = short_name_dir.strict_join("test.txt");
assert!(via_short.is_ok(), "Should handle short name in parent path");
}
Err(e) => {
eprintln!("Could not create boundary via short name: {e:?}");
}
}
}
} else {
eprintln!("Skipping test: Could not determine 8.3 short name (might be disabled)");
}
}
#[test]
#[cfg(windows)]
fn test_short_name_patterns_handled_via_canonicalization() {
let temp = tempfile::tempdir().unwrap();
let test_dir: PathBoundary = PathBoundary::try_new(temp.path()).unwrap();
let dir_with_tilde = temp.path().join("TEST~1");
std::fs::create_dir_all(&dir_with_tilde).unwrap();
std::fs::write(dir_with_tilde.join("file.txt"), b"content").unwrap();
let result = test_dir.strict_join("TEST~1/file.txt");
assert!(
result.is_ok(),
"Should accept paths with ~N pattern when they exist: {result:?}"
);
let result = test_dir.strict_join("ABCDEF~1/file.txt");
if let Err(e) = result {
assert!(
matches!(e, StrictPathError::PathResolutionError { .. }),
"Non-existent paths fail during canonicalization: {e:?}"
);
}
}
#[cfg(feature = "virtual-path")]
#[test]
#[cfg(windows)]
fn test_github_runner_clamped_symlink_with_short_names() {
use std::os::windows::fs as winfs;
let temp = tempfile::tempdir().unwrap();
let restriction_dir = temp.path().join("boundary");
let outside_dir = temp.path().join("outside");
std::fs::create_dir_all(&restriction_dir).unwrap();
std::fs::create_dir_all(&outside_dir).unwrap();
let outside_file = outside_dir.join("secret.txt");
std::fs::write(&outside_file, "secret").unwrap();
let link_inside = restriction_dir.join("link");
if let Err(e) = winfs::symlink_file(&outside_file, &link_inside) {
eprintln!("Skipping test - symlink creation failed: {e:?}");
return;
}
let vroot: VirtualRoot = VirtualRoot::try_new(&restriction_dir)
.expect("Should create VirtualRoot even if temp path has short names in parents");
let result = vroot.virtual_join("link");
match result {
Ok(clamped_path) => {
let clamped = clamped_path.virtualpath_display();
eprintln!("Symlink was clamped successfully: {clamped}");
eprintln!("Clamped virtual path: {clamped}");
let read_result = clamped_path.read_to_string();
assert!(
read_result.is_err(),
"Reading clamped symlink should fail (doesn't exist)"
);
eprintln!("Read correctly failed: {:?}", read_result.unwrap_err());
}
Err(StrictPathError::PathResolutionError { .. }) => {
eprintln!("Test passed: PathResolutionError during symlink resolution");
}
Err(e) => {
panic!("Unexpected error: {e:?}");
}
}
}