strict-path 0.2.0

Secure path handling for untrusted input. Prevents directory traversal, symlink escapes, and 19+ real-world CVE attack patterns.
Documentation
use crate::PathBoundary;
use std::sync::Mutex;

// Global mutex to prevent race conditions in rename tests
static RENAME_TEST_MUTEX: Mutex<()> = Mutex::new(());

#[test]
fn strict_rename_file_in_same_boundary() {
    let _guard = RENAME_TEST_MUTEX.lock().unwrap();

    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();

    let src = test_dir.strict_join("a.txt").unwrap();
    src.write("hello").unwrap();

    let dest = test_dir.strict_join("b.txt").unwrap();
    src.strict_rename("b.txt").unwrap();

    assert!(dest.exists());
    assert_eq!(dest.read_to_string().unwrap(), "hello");
    assert!(!test_dir.strict_join("a.txt").unwrap().exists());
}

#[cfg(feature = "virtual-path")]
#[test]
fn virtual_rename_file_simple() {
    let _guard = RENAME_TEST_MUTEX.lock().unwrap();

    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();

    let file = test_dir.strict_join("docs/file.txt").unwrap();
    file.create_parent_dir_all().unwrap();
    file.write("v").unwrap();

    let v = file.clone().virtualize();
    v.virtual_rename("renamed.txt").unwrap();
    let renamed = test_dir
        .strict_join("docs/renamed.txt")
        .unwrap()
        .virtualize();
    assert_eq!(
        renamed.virtualpath_display().to_string(),
        "/docs/renamed.txt"
    );
    assert!(renamed.exists());
    assert_eq!(renamed.read_to_string().unwrap(), "v");
}

#[test]
fn strict_rename_nested_relative_and_absolute() {
    let _guard = RENAME_TEST_MUTEX.lock().unwrap();

    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();

    let src = test_dir.strict_join("docs/file.txt").unwrap();
    src.create_parent_dir_all().unwrap();
    src.write("content").unwrap();

    test_dir
        .strict_join("docs/sub")
        .unwrap()
        .create_dir_all()
        .unwrap();

    src.strict_rename("sub/renamed.txt").unwrap();
    let nested = test_dir.strict_join("docs/sub/renamed.txt").unwrap();
    assert_eq!(nested.read_to_string().unwrap(), "content");
    assert!(
        nested
            .strictpath_display()
            .to_string()
            .ends_with("sub/renamed.txt")
            || nested
                .strictpath_display()
                .to_string()
                .ends_with("sub\\renamed.txt")
    );

    let abs_inside = td.path().join("abs_target.txt");
    let current = test_dir.strict_join("docs/sub/renamed.txt").unwrap();
    current.strict_rename(&abs_inside).unwrap();
    let abs_dest = test_dir.strict_join("abs_target.txt").unwrap();
    assert!(abs_dest.exists());
    assert_eq!(abs_dest.read_to_string().unwrap(), "content");
}

#[test]
fn strict_rename_rejects_escape_outside_boundary() {
    let _guard = RENAME_TEST_MUTEX.lock().unwrap();

    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();

    let src = test_dir.strict_join("a.txt").unwrap();
    src.write("x").unwrap();
    let outside = td.path().parent().unwrap().join("oops.txt");
    let err = src.strict_rename(&outside).unwrap_err();
    assert_eq!(err.kind(), std::io::ErrorKind::Other);
}

#[test]
fn strict_rename_directory_tree() {
    let _guard = RENAME_TEST_MUTEX.lock().unwrap();

    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();

    let dir = test_dir.strict_join("dir").unwrap();
    dir.create_dir_all().unwrap();
    let file = test_dir.strict_join("dir/note.txt").unwrap();
    file.write("notes").unwrap();

    dir.strict_rename("dir2").unwrap();
    let moved_file = test_dir.strict_join("dir2/note.txt").unwrap();
    assert_eq!(moved_file.read_to_string().unwrap(), "notes");
}

#[test]
fn strict_rename_destination_exists_behavior() {
    let _guard = RENAME_TEST_MUTEX.lock().unwrap();

    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();

    let src = test_dir.strict_join("src.txt").unwrap();
    src.write("S").unwrap();
    let dst = test_dir.strict_join("dst.txt").unwrap();
    dst.write("D").unwrap();

    match src.strict_rename("dst.txt") {
        Ok(()) => {
            assert_eq!(dst.read_to_string().unwrap(), "S");
        }
        Err(err) => {
            assert!(err.kind() == std::io::ErrorKind::PermissionDenied);
        }
    }
}

#[test]
fn strict_rename_nonexistent_source() {
    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();
    let missing = test_dir.strict_join("missing.txt").unwrap();
    let err = missing.strict_rename("any.txt").unwrap_err();
    assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}

#[cfg(feature = "virtual-path")]
#[test]
fn virtual_rename_relative_sibling_and_absolute() {
    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();
    let file = test_dir.strict_join("docs/file.txt").unwrap();
    file.create_parent_dir_all().unwrap();
    file.write("v").unwrap();
    let v = file.virtualize();

    v.virtual_rename("renamed.txt").unwrap();
    let v2 = test_dir
        .strict_join("docs/renamed.txt")
        .unwrap()
        .virtualize();
    assert_eq!(v2.virtualpath_display().to_string(), "/docs/renamed.txt");

    v2.virtual_rename("/rooted.txt").unwrap();
    let v3 = test_dir.strict_join("rooted.txt").unwrap().virtualize();
    assert_eq!(v3.virtualpath_display().to_string(), "/rooted.txt");
}

#[cfg(feature = "virtual-path")]
#[test]
fn virtual_rename_with_parent_components_is_clamped() {
    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();
    let file = test_dir.strict_join("docs/file.txt").unwrap();
    file.create_parent_dir_all().unwrap();
    file.write("v").unwrap();
    let v = file.virtualize();

    v.virtual_rename("../outside.txt").unwrap();
    let v2 = test_dir.strict_join("outside.txt").unwrap().virtualize();
    assert_eq!(v2.virtualpath_display().to_string(), "/outside.txt");
}

#[cfg(feature = "virtual-path")]
#[test]
fn virtual_rename_fails_when_parent_missing() {
    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();
    let file = test_dir.strict_join("docs/file.txt").unwrap();
    file.create_parent_dir_all().unwrap();
    file.write("v").unwrap();
    let v = file.virtualize();

    let err = v.virtual_rename("sub/renamed.txt").unwrap_err();
    assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}

#[cfg(feature = "virtual-path")]
#[test]
fn virtual_rename_directory() {
    let _guard = RENAME_TEST_MUTEX.lock().unwrap();

    let td = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();

    let dir = test_dir.strict_join("docs").unwrap();
    dir.create_dir_all().unwrap();
    let file = test_dir.strict_join("docs/a.txt").unwrap();
    file.write("x").unwrap();
    let vdir = dir.virtualize();

    vdir.virtual_rename("/docs2").unwrap();
    let moved = test_dir.strict_join("docs2").unwrap().virtualize();
    assert_eq!(moved.virtualpath_display().to_string(), "/docs2");
    let moved_file = moved.as_unvirtual().strict_join("a.txt").unwrap();
    assert_eq!(moved_file.read_to_string().unwrap(), "x");
}