#[cfg(feature = "virtual-path")]
use crate::PathBoundary;
#[cfg(all(feature = "virtual-path", unix))]
use crate::VirtualRoot;
#[cfg(feature = "virtual-path")]
#[test]
#[cfg(unix)]
fn test_symlink_escape_is_rejected() {
use std::fs;
use std::os::unix::fs as unixfs;
let td = tempfile::tempdir().unwrap();
let base = td.path();
let restriction_dir = base.join("PathBoundary");
let outside_dir = base.join("outside");
fs::create_dir_all(&restriction_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
let link_in_restriction = restriction_dir.join("link");
unixfs::symlink(&outside_dir, &link_in_restriction).unwrap();
let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();
let err = restriction.strict_join("link/escape.txt").unwrap_err();
match err {
crate::StrictPathError::PathEscapesBoundary { .. } => {}
other => panic!("Expected PathEscapesBoundary, got {other:?}"),
}
let vroot: VirtualRoot = VirtualRoot::try_new(&restriction_dir).unwrap();
let clamped = vroot
.virtual_join("link/escape.txt")
.expect("Virtual paths should clamp symlink targets to virtual root");
let clamped_system = clamped.interop_path();
let canonical_restriction = fs::canonicalize(&restriction_dir).unwrap();
assert!(
AsRef::<std::path::Path>::as_ref(clamped_system).starts_with(&canonical_restriction),
"Virtual path should be clamped within virtual root. Got: {:?}, Expected to start with: {:?}",
clamped_system,
canonical_restriction
);
}
#[cfg(feature = "virtual-path")]
#[test]
#[cfg(unix)]
fn test_relative_symlink_escape_is_rejected() {
use std::fs;
use std::os::unix::fs as unixfs;
let td = tempfile::tempdir().unwrap();
let base = td.path();
let restriction_dir = base.join("PathBoundary");
let sibling = base.join("sibling");
let outside_dir = sibling.join("outside");
fs::create_dir_all(&restriction_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
let link_in_restriction = restriction_dir.join("rel");
unixfs::symlink("../sibling/outside", &link_in_restriction).unwrap();
let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();
let err = restriction.strict_join("rel/escape.txt").unwrap_err();
match err {
crate::StrictPathError::PathEscapesBoundary { .. } => {}
other => panic!("Expected PathEscapesBoundary, got {other:?}"),
}
}
#[cfg(feature = "virtual-path")]
#[test]
#[cfg(windows)]
fn test_symlink_escape_is_rejected() {
use std::fs;
use std::os::windows::fs as winfs;
let td = tempfile::tempdir().unwrap();
let base = td.path();
let restriction_dir = {
let p = base.join("PathBoundary");
fs::create_dir_all(&p).unwrap();
fs::canonicalize(&p).unwrap()
};
let outside_dir = {
let p = base.join("outside");
fs::create_dir_all(&p).unwrap();
fs::canonicalize(&p).unwrap()
};
let link_in_restriction = restriction_dir.join("link");
if let Err(e) = winfs::symlink_dir(&outside_dir, &link_in_restriction) {
if e.kind() == std::io::ErrorKind::PermissionDenied || e.raw_os_error() == Some(1314) {
return;
}
panic!("failed to create symlink: {e:?}");
}
let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();
let err = restriction.strict_join("link/escape.txt").unwrap_err();
match err {
crate::StrictPathError::PathEscapesBoundary { .. } => {}
other => panic!("Expected PathEscapesBoundary, got {other:?}"),
}
let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();
let clamped = vroot.virtual_join("link/escape.txt").unwrap();
let clamped_path = clamped.as_unvirtual().interop_path();
let mut check_path = std::path::PathBuf::from(clamped_path);
while !check_path.exists() && check_path.pop() {
}
if check_path.exists() {
let check_canonical = fs::canonicalize(&check_path).unwrap();
let restriction_canonical = fs::canonicalize(&restriction_dir).unwrap();
assert!(
check_canonical.starts_with(&restriction_canonical),
"Virtual path should be clamped within virtual root. Got: {check_canonical:?}, Expected to start with: {restriction_canonical:?}"
);
}
}
#[cfg(feature = "virtual-path")]
#[test]
#[cfg(windows)]
fn test_junction_escape_is_rejected() {
use std::fs;
use std::process::Command;
let td = tempfile::tempdir().unwrap();
let base = td.path();
let restriction_dir = base.join("PathBoundary");
let outside_dir = base.join("outside");
fs::create_dir_all(&restriction_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
let link_in_restriction = restriction_dir.join("jlink");
let status = Command::new("cmd")
.args([
"/C",
"mklink",
"/J",
&link_in_restriction.to_string_lossy(),
&outside_dir.to_string_lossy(),
])
.status();
match status {
Ok(s) if s.success() => {}
_ => {
return;
}
}
let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();
let err = restriction.strict_join("jlink/escape.txt").unwrap_err();
match err {
crate::StrictPathError::PathEscapesBoundary { .. } => {}
other => panic!("Expected PathEscapesBoundary via junction, got {other:?}"),
}
let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();
let clamped = vroot
.virtual_join("jlink/escape.txt")
.expect("VirtualPath should clamp junction target to virtual root");
let system_path = clamped.interop_path();
let vroot_canonical = std::fs::canonicalize(&restriction_dir).unwrap();
assert!(
AsRef::<std::path::Path>::as_ref(system_path).starts_with(&vroot_canonical),
"Junction target should be clamped within virtual root. Got: {system_path:?}, Expected within: {vroot_canonical:?}"
);
}
#[cfg(feature = "virtual-path")]
#[test]
fn test_toctou_symlink_parent_attack() {
let temp = tempfile::tempdir().unwrap();
let restriction_dir = temp.path().join("PathBoundary");
let outside_dir = temp.path().join("outside");
std::fs::create_dir_all(&restriction_dir).unwrap();
std::fs::create_dir_all(&outside_dir).unwrap();
let subdir = restriction_dir.join("subdir");
std::fs::create_dir(&subdir).unwrap();
std::fs::write(subdir.join("file.txt"), "content").unwrap();
let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();
let file_path = restriction.strict_join("subdir/file.txt").unwrap();
assert!(file_path.exists());
let initial_parent = file_path.strictpath_parent().unwrap();
assert!(initial_parent.is_some());
std::fs::remove_dir_all(&subdir).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs as unixfs;
unixfs::symlink(&outside_dir, &subdir).unwrap();
}
#[cfg(windows)]
{
use std::os::windows::fs as winfs;
if let Err(e) = winfs::symlink_dir(&outside_dir, &subdir) {
eprintln!("Skipping TOCTOU test - symlink creation failed: {e:?}");
return;
}
}
let parent_result = file_path.strictpath_parent();
match parent_result {
Err(crate::StrictPathError::PathEscapesBoundary { .. }) => {
}
Err(crate::StrictPathError::PathResolutionError { .. }) => {
}
Ok(_) => {
panic!("SECURITY FAILURE: strictpath_parent() should have detected symlink escape!");
}
Err(other) => {
panic!("Unexpected error type: {other:?}");
}
}
}
#[cfg(feature = "virtual-path")]
#[test]
fn test_toctou_virtual_parent_attack() {
let temp = tempfile::tempdir().unwrap();
let restriction_dir = temp.path().join("PathBoundary");
let outside_dir = temp.path().join("outside");
std::fs::create_dir_all(&restriction_dir).unwrap();
std::fs::create_dir_all(&outside_dir).unwrap();
let subdir = restriction_dir.join("subdir");
std::fs::create_dir(&subdir).unwrap();
std::fs::write(subdir.join("file.txt"), "content").unwrap();
let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();
let vfile_path = vroot.virtual_join("subdir/file.txt").unwrap();
assert!(vfile_path.exists());
let initial_parent = vfile_path.virtualpath_parent().unwrap();
assert!(initial_parent.is_some());
std::fs::remove_dir_all(&subdir).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs as unixfs;
unixfs::symlink(&outside_dir, &subdir).unwrap();
}
#[cfg(windows)]
{
use std::os::windows::fs as winfs;
if let Err(e) = winfs::symlink_dir(&outside_dir, &subdir) {
eprintln!("Skipping virtual TOCTOU test - symlink creation failed: {e:?}");
return;
}
}
let parent_result = vfile_path.virtualpath_parent();
match parent_result {
Ok(Some(parent)) => {
let parent_system = parent.interop_path();
let canonical_restriction = std::fs::canonicalize(&restriction_dir).unwrap();
assert!(
AsRef::<std::path::Path>::as_ref(parent_system).starts_with(&canonical_restriction),
"Parent should be clamped within virtual root. Got: {parent_system:?}, Expected to start with: {canonical_restriction:?}"
);
}
Err(crate::StrictPathError::PathResolutionError { .. }) => {
}
Ok(None) => {
panic!("SECURITY FAILURE: virtualpath_parent() returned None unexpectedly!");
}
Err(other) => {
panic!("Unexpected error type: {other:?}");
}
}
}
#[cfg(feature = "virtual-path")]
#[test]
fn test_zip_slip_style_extraction() {
let td = tempfile::tempdir().unwrap();
let base = td.path();
let restriction_dir = base.join("PathBoundary");
std::fs::create_dir_all(&restriction_dir).unwrap();
let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();
let mut entries = vec![
("ok/file.txt", true),
("nested/dir/ok2.bin", true),
("../escape.txt", true),
("../../outside/evil.txt", true),
("/abs/should/fail", true),
("..\\..\\win\\escape.txt", true),
];
if cfg!(windows) {
entries.push(("C:..\\Windows\\win.ini", true));
entries.push(("C:\\Windows\\win.ini", true));
}
for (name, should_succeed) in entries {
let res = vroot.virtual_join(name);
match res {
Ok(vp) => {
if should_succeed {
vp.create_parent_dir_all().unwrap();
vp.write("data").unwrap();
assert!(vp.exists());
assert!(vp
.as_unvirtual()
.strictpath_starts_with(vroot.interop_path()));
} else {
panic!(
"Expected rejection for '{name}', but joined to {}",
vp.as_unvirtual().strictpath_to_string_lossy()
);
}
}
Err(e) => {
if should_succeed {
panic!("Expected success for '{name}', got {e:?}");
}
}
}
}
assert!(!base.join("escape.txt").exists());
assert!(!base.join("outside/evil.txt").exists());
}
#[cfg(feature = "virtual-path")]
#[test]
fn test_tar_slip_style_extraction() {
let td = tempfile::tempdir().unwrap();
let base = td.path();
let restriction_dir = base.join("PathBoundary");
std::fs::create_dir_all(&restriction_dir).unwrap();
let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();
let entries = vec![
("./ok.txt", true),
("./nested/./dir/file.bin", true),
("/abs/should/not/escape", true),
("../../outside/evil.txt", true),
("./../sneaky", true),
];
for (name, should_succeed) in entries {
match vroot.virtual_join(name) {
Ok(vp) => {
if should_succeed {
vp.create_parent_dir_all().unwrap();
vp.write("data").unwrap();
assert!(vp
.as_unvirtual()
.strictpath_starts_with(vroot.interop_path()));
} else {
panic!("unexpected success for {name}");
}
}
Err(e) => {
if should_succeed {
panic!("expected success for {name}, got {e:?}");
}
}
}
}
assert!(!base.join("outside/evil.txt").exists());
}
#[cfg(feature = "virtual-path")]
#[test]
#[cfg(unix)]
fn test_hard_link_inside_to_outside_documents_limitation() {
use std::fs::hard_link;
let td = tempfile::tempdir().unwrap();
let base = td.path();
let restriction_dir = base.join("PathBoundary");
let outside_dir = base.join("outside");
std::fs::create_dir_all(&restriction_dir).unwrap();
std::fs::create_dir_all(&outside_dir).unwrap();
let outside_file = outside_dir.join("target.txt");
std::fs::write(&outside_file, b"original").unwrap();
let inside_link = restriction_dir.join("alias.txt");
hard_link(&outside_file, &inside_link).unwrap();
let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();
let vp = restriction
.clone()
.virtualize()
.virtual_join("alias.txt")
.expect("join should succeed within PathBoundary");
vp.write("modified").unwrap();
let out = std::fs::read_to_string(&outside_file).unwrap();
assert_eq!(out, "modified");
assert!(vp
.as_unvirtual()
.strictpath_starts_with(restriction.interop_path()));
}