strict-path 0.1.0-beta.1

More than path comparisons: full, cross-platform path security with type-level guarantees
Documentation
use crate::{PathBoundary, StrictPathError, VirtualRoot};

#[cfg(windows)]
fn symlink_permission_denied(err: &std::io::Error) -> bool {
    const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
    err.kind() == std::io::ErrorKind::PermissionDenied
        || err.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD)
}

#[cfg(not(windows))]
fn symlink_permission_denied(_err: &std::io::Error) -> bool {
    false
}

fn hard_link_unsupported(err: &std::io::Error) -> bool {
    matches!(
        err.kind(),
        std::io::ErrorKind::Unsupported | std::io::ErrorKind::PermissionDenied
    )
}

#[test]
fn strict_symlink_helpers_create_links_within_boundary() {
    let td = tempfile::tempdir().unwrap();
    let boundary: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();

    let target = boundary.strict_join("data/target.txt").unwrap();
    target.create_parent_dir_all().unwrap();
    target.write(b"hello").unwrap();

    let direct_link = boundary.strict_join("links/direct.txt").unwrap();
    direct_link.create_parent_dir_all().unwrap();
    if let Err(err) = target.strict_symlink(&direct_link) {
        if symlink_permission_denied(&err) {
            eprintln!("Skipping strict symlink test due to missing privileges: {err:?}");
            return;
        }
        panic!("strict_symlink failed unexpectedly: {err:?}");
    }
    assert!(direct_link.exists());

    #[cfg(unix)]
    {
        let link_target = std::fs::read_link(direct_link.interop_path()).unwrap();
        assert!(link_target.ends_with("data/target.txt"));
    }

    #[cfg(windows)]
    {
        let link_target = std::fs::read_link(direct_link.interop_path()).unwrap();
        let normalized = link_target.to_string_lossy().replace("\\", "/");
        assert!(normalized.ends_with("data/target.txt"));
    }

    let boundary_link = boundary.strict_join("links/from_boundary.txt").unwrap();
    boundary_link.create_parent_dir_all().unwrap();
    if let Err(err) = boundary.strict_symlink(&boundary_link) {
        if symlink_permission_denied(&err) {
            eprintln!("Skipping PathBoundary symlink test due to missing privileges: {err:?}");
            return;
        }
        panic!("PathBoundary::strict_symlink failed unexpectedly: {err:?}");
    }
    assert!(boundary_link.exists());
}

#[test]
fn strict_hard_link_helpers_create_links_within_boundary() {
    let td = tempfile::tempdir().unwrap();
    let boundary: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();

    let target = boundary.strict_join("data/target.txt").unwrap();
    target.create_parent_dir_all().unwrap();
    target.write(b"hello").unwrap();

    let hard_link = boundary.strict_join("links/direct.txt").unwrap();
    hard_link.create_parent_dir_all().unwrap();
    if let Err(err) = target.strict_hard_link(&hard_link) {
        if hard_link_unsupported(&err) {
            eprintln!("Skipping hard link test: not supported ({err:?})");
            return;
        }
        panic!("strict_hard_link failed unexpectedly: {err:?}");
    }
    assert_eq!(hard_link.read_to_string().unwrap(), "hello");

    let boundary_link = boundary.strict_join("links/from_boundary.txt").unwrap();
    boundary_link.create_parent_dir_all().unwrap();
    if let Err(err) = boundary.strict_hard_link(&boundary_link) {
        if hard_link_unsupported(&err) {
            eprintln!("Skipping PathBoundary hard link test: not supported ({err:?})");
            return;
        }
        panic!("PathBoundary::strict_hard_link failed unexpectedly: {err:?}");
    }
    assert_eq!(boundary_link.read_to_string().unwrap(), "hello");
}

#[test]
fn strict_symlink_rejects_escape_targets() {
    let td = tempfile::tempdir().unwrap();
    let boundary: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();
    let link = boundary.strict_join("link.txt").unwrap();
    link.create_parent_dir_all().unwrap();

    let outside_dir = tempfile::tempdir().unwrap();
    let outside_boundary: PathBoundary = PathBoundary::try_new_create(outside_dir.path()).unwrap();
    let outside_target = outside_boundary.strict_join("secret.txt").unwrap();

    let err = outside_target
        .strict_symlink(&link)
        .expect_err("escape symlink should be rejected");
    let inner = err
        .get_ref()
        .and_then(|e| e.downcast_ref::<StrictPathError>());
    assert!(matches!(
        inner,
        Some(StrictPathError::PathEscapesBoundary { .. })
    ));
}

#[test]
fn strict_hard_link_rejects_escape_targets() {
    let td = tempfile::tempdir().unwrap();
    let boundary: PathBoundary = PathBoundary::try_new_create(td.path()).unwrap();
    let link = boundary.strict_join("link.txt").unwrap();
    link.create_parent_dir_all().unwrap();

    let outside_dir = tempfile::tempdir().unwrap();
    let outside_boundary: PathBoundary = PathBoundary::try_new_create(outside_dir.path()).unwrap();
    let outside_target = outside_boundary.strict_join("secret.txt").unwrap();
    outside_target.write(b"secret").unwrap();

    let err = outside_target
        .strict_hard_link(&link)
        .expect_err("escape hard link should be rejected");
    let inner = err
        .get_ref()
        .and_then(|e| e.downcast_ref::<StrictPathError>());
    assert!(matches!(
        inner,
        Some(StrictPathError::PathEscapesBoundary { .. })
    ));
}

#[test]
fn virtual_symlink_helpers_create_links_within_root() {
    let td = tempfile::tempdir().unwrap();
    let vroot: VirtualRoot = VirtualRoot::try_new_create(td.path()).unwrap();

    let target = vroot.virtual_join("data/target.txt").unwrap();
    target.create_parent_dir_all().unwrap();
    target.write(b"ok").unwrap();

    let link = vroot.virtual_join("links/alias.txt").unwrap();
    link.create_parent_dir_all().unwrap();
    if let Err(err) = target.virtual_symlink(&link) {
        if symlink_permission_denied(&err) {
            eprintln!("Skipping virtual symlink test due to missing privileges: {err:?}");
            return;
        }
        panic!("virtual_symlink failed unexpectedly: {err:?}");
    }
    assert!(link.exists());

    let root_link = vroot.virtual_join("links/from_root.txt").unwrap();
    root_link.create_parent_dir_all().unwrap();
    if let Err(err) = vroot.virtual_symlink(&root_link) {
        if symlink_permission_denied(&err) {
            eprintln!("Skipping VirtualRoot symlink test due to missing privileges: {err:?}");
            return;
        }
        panic!("VirtualRoot::virtual_symlink failed unexpectedly: {err:?}");
    }
    assert!(root_link.exists());
}

#[test]
fn virtual_hard_link_helpers_create_links_within_root() {
    let td = tempfile::tempdir().unwrap();
    let vroot: VirtualRoot = VirtualRoot::try_new_create(td.path()).unwrap();

    let target = vroot.virtual_join("data/target.txt").unwrap();
    target.create_parent_dir_all().unwrap();
    target.write(b"ok").unwrap();

    let link = vroot.virtual_join("links/alias.txt").unwrap();
    link.create_parent_dir_all().unwrap();
    if let Err(err) = target.virtual_hard_link(&link) {
        if hard_link_unsupported(&err) {
            eprintln!("Skipping virtual hard link test: not supported ({err:?})");
            return;
        }
        panic!("virtual_hard_link failed unexpectedly: {err:?}");
    }
    assert_eq!(link.read_to_string().unwrap(), "ok");

    let root_link = vroot.virtual_join("links/from_root.txt").unwrap();
    root_link.create_parent_dir_all().unwrap();
    if let Err(err) = vroot.virtual_hard_link(&root_link) {
        if hard_link_unsupported(&err) {
            eprintln!("Skipping VirtualRoot hard link test: not supported ({err:?})");
            return;
        }
        panic!("VirtualRoot::virtual_hard_link failed unexpectedly: {err:?}");
    }
    assert_eq!(root_link.read_to_string().unwrap(), "ok");
}