ic-host-fs 0.20.0

Local host filesystem reads, durable publication and file locking
Documentation
use super::{canonicalize_allow_missing, canonicalize_allow_missing_with_symlink_limit};
use crate::test_support::Fixture;
use std::{fs, io, path::Path};

#[test]
fn missing_parent_traversal_resumes_symlink_resolution() {
    let fixture = Fixture::new();
    let target = fixture.root.join("real");
    fs::create_dir(&target).unwrap();
    std::os::unix::fs::symlink(&target, fixture.root.join("alias")).unwrap();
    assert_eq!(
        canonicalize_allow_missing(
            Path::new("missing/../alias/generated/nested/../output"),
            &fixture.root
        )
        .unwrap(),
        target.join("generated/output")
    );
    assert_eq!(
        canonicalize_allow_missing(
            &fixture.root.join("alias/../other/output"),
            Path::new("ignored")
        )
        .unwrap(),
        fixture.root.join("other/output")
    );
}

#[test]
fn explicit_base_errors_and_native_names_are_preserved() {
    use std::{ffi::OsString, os::unix::ffi::OsStringExt as _};
    let fixture = Fixture::new();
    assert_eq!(
        canonicalize_allow_missing(Path::new("output"), Path::new("relative"))
            .unwrap_err()
            .kind(),
        io::ErrorKind::InvalidInput
    );
    let native = OsString::from_vec(vec![b'n', 0xff]);
    assert_eq!(
        canonicalize_allow_missing(Path::new(&native), &fixture.root).unwrap(),
        fixture.root.join(&native)
    );
    std::os::unix::fs::symlink(&native, fixture.root.join("native-alias")).unwrap();
    assert_eq!(
        canonicalize_allow_missing(Path::new("native-alias"), &fixture.root).unwrap(),
        fixture.root.join(native)
    );
    fs::write(fixture.root.join("file"), b"contents").unwrap();
    assert_eq!(
        canonicalize_allow_missing(Path::new("file/child"), &fixture.root)
            .unwrap_err()
            .kind(),
        io::ErrorKind::NotADirectory
    );
    assert_eq!(
        canonicalize_allow_missing(Path::new("."), &fixture.root).unwrap(),
        fixture.root
    );
}

#[test]
fn existing_file_traversal_errors_survive_missing_suffix_normalization() {
    let fixture = Fixture::new();
    fs::write(fixture.root.join("file"), b"contents").unwrap();
    // realpath/canonicalize may normalize file/.. on macOS where Linux rejects
    // it. The contract retains the host's result, including after a missing
    // prefix is normalized; it does not impose Linux traversal on every host.
    for (path, existing, suffix) in [
        ("file/..", "file/..", ""),
        ("file/.", "file/.", ""),
        ("file/", "file/", ""),
        ("missing/../file/../output", "file/..", "output"),
        ("missing/../file/.", "file/.", ""),
        ("missing/../file/", "file/", ""),
    ] {
        let native = fixture.root.join(existing).canonicalize().map(|resolved| {
            if suffix.is_empty() {
                resolved
            } else {
                resolved.join(suffix)
            }
        });
        let actual = canonicalize_allow_missing(Path::new(path), &fixture.root);
        assert_eq!(
            actual.map_err(|error| error.kind()),
            native.map_err(|error| error.kind()),
            "native directory traversal: {path}"
        );
    }
}

#[test]
fn dangling_symlink_targets_chains_and_parent_traversal_share_one_identity() {
    use std::os::unix::fs::symlink;
    let fixture = Fixture::new();
    fs::create_dir(fixture.root.join("real")).unwrap();
    symlink("real/generated", fixture.root.join("relative")).unwrap();
    symlink(
        fixture.root.join("real/generated"),
        fixture.root.join("absolute"),
    )
    .unwrap();
    symlink("relative", fixture.root.join("chain")).unwrap();
    symlink("missing/../chain", fixture.root.join("rewind")).unwrap();
    for path in ["relative", "absolute", "chain", "rewind", "chain/nested/.."] {
        assert_eq!(
            canonicalize_allow_missing(Path::new(path), &fixture.root).unwrap(),
            fixture.root.join("real/generated")
        );
    }
    assert_eq!(
        canonicalize_allow_missing(Path::new("chain/../../chain/output"), &fixture.root).unwrap(),
        fixture.root.join("real/generated/output")
    );
}

#[test]
fn symlink_cycles_reject_without_confusing_repeated_completed_expansions() {
    use std::os::unix::fs::symlink;
    let fixture = Fixture::new();
    symlink("missing/../cycle", fixture.root.join("cycle")).unwrap();
    assert_eq!(
        canonicalize_allow_missing(Path::new("cycle"), &fixture.root)
            .unwrap_err()
            .kind(),
        io::ErrorKind::InvalidInput
    );
    symlink("second", fixture.root.join("first")).unwrap();
    symlink("first", fixture.root.join("second")).unwrap();
    let native = fixture.root.join("first").canonicalize().unwrap_err();
    assert_eq!(
        canonicalize_allow_missing(Path::new("first"), &fixture.root)
            .unwrap_err()
            .kind(),
        native.kind()
    );
    symlink("missing", fixture.root.join("alias")).unwrap();
    assert_eq!(
        canonicalize_allow_missing(Path::new("alias/../alias/../output"), &fixture.root).unwrap(),
        fixture.root.join("output")
    );
}

#[test]
fn long_acyclic_missing_target_chains_use_iterative_resolution() {
    use std::os::unix::fs::symlink;
    let fixture = Fixture::new();
    for index in 0..100 {
        symlink(
            format!("missing/../chain-{}", index + 1),
            fixture.root.join(format!("chain-{index}")),
        )
        .unwrap();
    }
    symlink("generated/output", fixture.root.join("chain-100")).unwrap();
    assert_eq!(
        canonicalize_allow_missing(Path::new("chain-0"), &fixture.root).unwrap(),
        fixture.root.join("generated/output")
    );
}

#[test]
fn missing_target_depth_is_caller_selected_and_completed_links_do_not_accumulate() {
    use std::os::unix::fs::symlink;
    let fixture = Fixture::new();
    symlink("missing", fixture.root.join("alias")).unwrap();
    symlink("alias", fixture.root.join("chain")).unwrap();
    for (path, depth) in [("alias", 0), ("chain", 1)] {
        assert_eq!(
            canonicalize_allow_missing_with_symlink_limit(Path::new(path), &fixture.root, depth)
                .unwrap_err()
                .kind(),
            io::ErrorKind::InvalidInput
        );
    }
    for (path, depth, expected) in [
        ("plain/output", 0, "plain/output"),
        ("chain", 2, "missing"),
        ("alias/../alias/../output", 1, "output"),
    ] {
        assert_eq!(
            canonicalize_allow_missing_with_symlink_limit(Path::new(path), &fixture.root, depth)
                .unwrap(),
            fixture.root.join(expected)
        );
    }
}