Skip to main content

crates/aube-linker/src/tests.rs

use super::*;
use aube_lockfile::{DepType, DirectDep, LockedPackage, LockfileGraph};
use aube_store::Store;

fn setup_store_with_files(dir: &Path) -> (Store, BTreeMap<String, aube_store::PackageIndex>) {
    let store = Store::at(dir.join("store/files"));

    let mut indices = BTreeMap::new();

    // foo@1.0.0 with index.js
    let foo_stored = store
        .import_bytes(b"module.exports = 'foo';", false)
        .unwrap();
    let mut foo_index = PackageIndex::default();
    foo_index.insert("index.js".to_string(), foo_stored);

    // foo also has package.json
    let foo_pkg = store
        .import_bytes(b"{\"name\":\"foo\",\"version\":\"1.0.0\"}", false)
        .unwrap();
    foo_index.insert("package.json".to_string(), foo_pkg);
    indices.insert("foo@1.0.0".to_string(), foo_index);

    // bar@2.0.0 with index.js
    let bar_stored = store
        .import_bytes(b"module.exports = 'bar';", false)
        .unwrap();
    let mut bar_index = PackageIndex::default();
    bar_index.insert("index.js".to_string(), bar_stored);
    indices.insert("bar@2.0.0".to_string(), bar_index);

    (store, indices)
}

fn make_graph() -> LockfileGraph {
    let mut packages = BTreeMap::new();

    let mut foo_deps = BTreeMap::new();
    foo_deps.insert("bar".to_string(), "2.0.0".to_string());

    packages.insert(
        "foo@1.0.0".to_string(),
        LockedPackage {
            name: "foo".to_string(),
            version: "1.0.0".to_string(),
            integrity: None,
            dependencies: foo_deps,
            dep_path: "foo@1.0.0".to_string(),
            ..Default::default()
        },
    );
    packages.insert(
        "bar@2.0.0".to_string(),
        LockedPackage {
            name: "bar".to_string(),
            version: "2.0.0".to_string(),
            integrity: None,
            dependencies: BTreeMap::new(),
            dep_path: "bar@2.0.0".to_string(),
            ..Default::default()
        },
    );

    let mut importers = BTreeMap::new();
    importers.insert(
        ".".to_string(),
        vec![DirectDep {
            name: "foo".to_string(),
            dep_path: "foo@1.0.0".to_string(),
            dep_type: DepType::Production,
            specifier: None,
        }],
    );

    LockfileGraph {
        importers,
        packages,
        ..Default::default()
    }
}

#[test]
fn test_detect_strategy() {
    let dir = tempfile::tempdir().unwrap();
    let strategy = Linker::detect_strategy(dir.path());
    // Probe returns `Hardlink` or `Copy`; `Reflink` is only
    // reachable through explicit `packageImportMethod =
    // clone`/`clone-or-copy`, so the match guards that contract.
    match strategy {
        LinkStrategy::Hardlink | LinkStrategy::Copy => {}
        LinkStrategy::Reflink => panic!("detect_strategy must not return Reflink"),
    }
}

#[test]
fn test_link_all_handles_self_referential_dep_at_different_version() {
    // `react_ujs@3.3.0` (and other publish-script artifacts)
    // declares its own name as a dep at a *different* version
    // (`react_ujs: ^2.7.1`). The transitive-symlink pass would
    // try to create a symlink at `node_modules/react_ujs`,
    // which is exactly where the package's own files live —
    // EEXIST. Skip self-name deps regardless of version so
    // these install cleanly. `require('<self>')` from inside
    // the package then resolves to its own files, matching how
    // npm / pnpm / yarn end up after their hoisting passes.
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let store = Store::at(dir.path().join("store/files"));

    let mut indices = BTreeMap::new();
    let host_index_js = store.import_bytes(b"/* react_ujs 3.3.0 */", false).unwrap();
    let host_pkg_json = store
        .import_bytes(b"{\"name\":\"react_ujs\",\"version\":\"3.3.0\"}", false)
        .unwrap();
    let mut host_index = PackageIndex::default();
    host_index.insert("index.js".to_string(), host_index_js);
    host_index.insert("package.json".to_string(), host_pkg_json);
    indices.insert("react_ujs@3.3.0".to_string(), host_index);

    let mut host_deps = BTreeMap::new();
    // Self-reference at a different version, the shape that
    // triggered the EEXIST bug.
    host_deps.insert("react_ujs".to_string(), "^2.7.1".to_string());

    let mut packages = BTreeMap::new();
    packages.insert(
        "react_ujs@3.3.0".to_string(),
        LockedPackage {
            name: "react_ujs".to_string(),
            version: "3.3.0".to_string(),
            integrity: None,
            dependencies: host_deps,
            dep_path: "react_ujs@3.3.0".to_string(),
            ..Default::default()
        },
    );

    let mut importers = BTreeMap::new();
    importers.insert(
        ".".to_string(),
        vec![DirectDep {
            name: "react_ujs".to_string(),
            dep_path: "react_ujs@3.3.0".to_string(),
            dep_type: DepType::Production,
            specifier: None,
        }],
    );

    let graph = LockfileGraph {
        importers,
        packages,
        ..Default::default()
    };

    let linker = Linker::new_with_gvs(&store, LinkStrategy::Copy, true);
    let stats = linker
        .link_all(&project_dir, &graph, &indices)
        .expect("install must succeed despite self-named dep");
    assert_eq!(stats.packages_linked, 1);
    let host_index =
        project_dir.join("node_modules/.aube/react_ujs@3.3.0/node_modules/react_ujs/index.js");
    assert!(host_index.exists(), "host package files must be present");
}

#[test]
fn test_link_all_creates_pnpm_virtual_store() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let linker = Linker::new_with_gvs(&store, LinkStrategy::Copy, true);
    let graph = make_graph();

    let stats = linker.link_all(&project_dir, &graph, &indices).unwrap();

    // .aube virtual store should exist
    assert!(project_dir.join("node_modules/.aube").exists());

    // .aube/foo@1.0.0 should be a symlink to the global virtual store
    let aube_foo = project_dir.join("node_modules/.aube/foo@1.0.0");
    assert!(aube_foo.symlink_metadata().unwrap().is_symlink());

    // foo@1.0.0 content should be accessible through the symlink
    let foo_in_pnpm = project_dir.join("node_modules/.aube/foo@1.0.0/node_modules/foo/index.js");
    assert!(foo_in_pnpm.exists());
    assert_eq!(
        std::fs::read_to_string(&foo_in_pnpm).unwrap(),
        "module.exports = 'foo';"
    );

    // bar@2.0.0 should also be accessible
    let bar_in_pnpm = project_dir.join("node_modules/.aube/bar@2.0.0/node_modules/bar/index.js");
    assert!(bar_in_pnpm.exists());

    assert_eq!(stats.packages_linked, 2);
    assert!(stats.files_linked >= 3); // foo has 2 files, bar has 1
}

#[test]
fn test_link_file_fresh_reports_missing_cas_shard_and_invalidates_cache() {
    // Reproduces endevco/aube#393: a partially corrupt CAS leaves the
    // cached package index pointing at a missing shard. Materialize
    // must distinguish "source CAS file missing" from a generic ENOENT
    // and drop the stale index JSON so the next install re-imports
    // the tarball.
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    // Persist foo's index so invalidate_cached_index has something
    // to remove. Real installs save indices via the fetch path.
    let foo_index = indices.get("foo@1.0.0").unwrap();
    store.save_index("foo", "1.0.0", None, foo_index).unwrap();
    let cached_path = store.index_dir().join("foo@1.0.0.json");
    assert!(
        cached_path.exists(),
        "test setup: index cache must be written"
    );

    // Delete the CAS shard for foo's package.json (matches the
    // failure mode in #393 where one shard is missing while others
    // remain).
    let pkgjson_store_path = foo_index.get("package.json").unwrap().store_path.clone();
    std::fs::remove_file(&pkgjson_store_path).unwrap();

    let linker = Linker::new_with_gvs(&store, LinkStrategy::Copy, true);
    let graph = make_graph();
    let err = linker
        .link_all(&project_dir, &graph, &indices)
        .expect_err("link must fail when a referenced CAS shard is gone");
    assert!(
        matches!(&err, Error::MissingStoreFile { rel_path, .. } if rel_path == "package.json"),
        "expected MissingStoreFile {{ rel_path: \"package.json\" }}, got {err:?}"
    );

    // Side effect: cached index dropped, so the next install will
    // miss load_index and re-fetch instead of looping on the same
    // dead shard reference.
    assert!(
        !cached_path.exists(),
        "stale index cache must be invalidated on MissingStoreFile"
    );
}

#[test]
#[cfg(unix)]
fn test_link_file_fresh_hardlink_short_circuits_when_source_missing() {
    // Hardlink path used to silently fall through to `std::fs::copy`
    // on ENOENT and emit a misleading "hardlink failed, falling back
    // to copy" trace, even though the real cause was the source
    // shard going missing. Short-circuit returns MissingStoreFile
    // directly so traces stay accurate.
    let dir = tempfile::tempdir().unwrap();
    let store = Store::at(dir.path().join("store/files"));
    let stored = store.import_bytes(b"hello", false).unwrap();
    // Capture the path before we move `stored` into link_file_fresh.
    let store_path = stored.store_path.clone();
    std::fs::remove_file(&store_path).unwrap();

    let dst_dir = dir.path().join("dst");
    std::fs::create_dir_all(&dst_dir).unwrap();
    let dst = dst_dir.join("hello.txt");

    let linker = Linker::new_with_gvs(&store, LinkStrategy::Hardlink, true);
    let err = linker
        .link_file_fresh(&stored, "hello.txt", &dst)
        .expect_err("source missing must fail");
    assert!(
        matches!(
            &err,
            Error::MissingStoreFile { store_path: p, rel_path } if p == &store_path && rel_path == "hello.txt"
        ),
        "expected MissingStoreFile from Hardlink branch, got {err:?}"
    );
}

#[test]
fn test_link_all_creates_top_level_entries() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let linker = Linker::new(&store, LinkStrategy::Copy);
    let graph = make_graph();

    let stats = linker.link_all(&project_dir, &graph, &indices).unwrap();

    // Top-level foo/ should exist (it's a direct dep)
    let foo_top = project_dir.join("node_modules/foo/index.js");
    assert!(foo_top.exists());
    assert_eq!(
        std::fs::read_to_string(&foo_top).unwrap(),
        "module.exports = 'foo';"
    );

    // bar should NOT be top-level (it's only a transitive dep)
    let bar_top = project_dir.join("node_modules/bar/index.js");
    assert!(!bar_top.exists());

    assert_eq!(stats.top_level_linked, 1);
}

#[test]
fn test_link_all_transitive_symlinks() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let linker = Linker::new(&store, LinkStrategy::Copy);
    let graph = make_graph();

    linker.link_all(&project_dir, &graph, &indices).unwrap();

    // foo's node_modules/bar should be a symlink (inside the global virtual store)
    // The path resolves through the .aube symlink into the global store
    let bar_symlink = project_dir.join("node_modules/.aube/foo@1.0.0/node_modules/bar");
    assert!(bar_symlink.symlink_metadata().unwrap().is_symlink());
}

#[test]
fn test_link_all_cleans_existing_node_modules() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    let nm = project_dir.join("node_modules");
    std::fs::create_dir_all(&nm).unwrap();
    std::fs::write(nm.join("stale-file.txt"), "old").unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let linker = Linker::new(&store, LinkStrategy::Copy);
    let graph = make_graph();

    linker.link_all(&project_dir, &graph, &indices).unwrap();

    // Old file should be gone
    assert!(!nm.join("stale-file.txt").exists());
    // New structure should exist
    assert!(nm.join(".aube").exists());
}

#[test]
fn test_link_all_nested_node_modules_for_direct_deps() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let linker = Linker::new(&store, LinkStrategy::Copy);
    let graph = make_graph();

    linker.link_all(&project_dir, &graph, &indices).unwrap();

    // foo is a direct dep with bar as a transitive dep.
    // The top-level node_modules/foo is a symlink to .aube/foo@1.0.0/node_modules/foo,
    // and bar lives as a sibling at .aube/foo@1.0.0/node_modules/bar (also a symlink
    // pointing to .aube/bar@2.0.0/node_modules/bar). Node's directory walk from inside
    // foo finds bar this way without aube creating any nested node_modules.
    let foo_link = project_dir.join("node_modules/foo");
    assert!(foo_link.symlink_metadata().unwrap().is_symlink());
    let bar_sibling = project_dir.join("node_modules/.aube/foo@1.0.0/node_modules/bar");
    assert!(bar_sibling.symlink_metadata().unwrap().is_symlink());
}

#[test]
fn test_global_virtual_store_is_populated() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let virtual_store = store.virtual_store_dir();
    let linker = Linker::new_with_gvs(&store, LinkStrategy::Copy, true);
    let graph = make_graph();

    linker.link_all(&project_dir, &graph, &indices).unwrap();

    // Global virtual store should contain materialized packages
    let foo_global = virtual_store.join("foo@1.0.0/node_modules/foo/index.js");
    assert!(foo_global.exists());
    assert_eq!(
        std::fs::read_to_string(&foo_global).unwrap(),
        "module.exports = 'foo';"
    );

    let bar_global = virtual_store.join("bar@2.0.0/node_modules/bar/index.js");
    assert!(bar_global.exists());
}

#[test]
fn test_global_virtual_store_gets_hidden_hoist() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let virtual_store = store.virtual_store_dir();
    let linker = Linker::new_with_gvs(&store, LinkStrategy::Copy, true);
    let mut graph = make_graph();
    graph
        .packages
        .get_mut("foo@1.0.0")
        .unwrap()
        .dependencies
        .clear();

    linker.link_all(&project_dir, &graph, &indices).unwrap();

    let project_hidden = project_dir.join("node_modules/.aube/node_modules/bar");
    assert!(project_hidden.symlink_metadata().unwrap().is_symlink());

    let global_hidden = virtual_store.join("node_modules/bar");
    assert!(global_hidden.symlink_metadata().unwrap().is_symlink());

    let from_real_store = virtual_store.join("foo@1.0.0/node_modules/bar/index.js");
    assert!(
        !from_real_store.exists(),
        "bar is not a declared sibling of foo in this fixture"
    );
    let fallback = virtual_store.join("node_modules/bar/index.js");
    assert_eq!(
        std::fs::read_to_string(fallback).unwrap(),
        "module.exports = 'bar';"
    );
}

#[test]
fn test_global_virtual_store_hidden_hoist_prunes_only_dead_entries() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let virtual_store = store.virtual_store_dir();
    let hidden = virtual_store.join("node_modules");
    std::fs::create_dir_all(&hidden).unwrap();
    let dotfile = hidden.join(".sentinel");
    std::fs::write(&dotfile, "shared").unwrap();
    let stale = hidden.join("stale");
    std::fs::write(&stale, "old").unwrap();
    let stale_scope = hidden.join("@stale-scope");
    std::fs::write(&stale_scope, "old").unwrap();
    let external_target = virtual_store.join("external@1.0.0/node_modules/external");
    std::fs::create_dir_all(&external_target).unwrap();
    let external_link = hidden.join("external");
    sys::create_dir_link(
        &pathdiff::diff_paths(&external_target, &hidden).unwrap(),
        &external_link,
    )
    .unwrap();
    let dead_link = hidden.join("dead");
    sys::create_dir_link(
        Path::new("../missing@1.0.0/node_modules/missing"),
        &dead_link,
    )
    .unwrap();

    let linker = Linker::new_with_gvs(&store, LinkStrategy::Copy, true);
    linker
        .link_all(&project_dir, &make_graph(), &indices)
        .unwrap();

    assert_eq!(std::fs::read_to_string(dotfile).unwrap(), "shared");
    assert!(!stale.exists());
    assert!(stale_scope.symlink_metadata().is_err());
    assert!(external_link.symlink_metadata().unwrap().is_symlink());
    assert!(dead_link.symlink_metadata().is_err());
    assert!(hidden.join("bar").symlink_metadata().unwrap().is_symlink());
}

#[test]
fn test_global_virtual_store_hidden_hoist_disabled_keeps_live_shared_links() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let virtual_store = store.virtual_store_dir();
    let linker = Linker::new_with_gvs(&store, LinkStrategy::Copy, true);
    linker
        .link_all(&project_dir, &make_graph(), &indices)
        .unwrap();

    let global_hidden = virtual_store.join("node_modules/bar");
    assert!(global_hidden.symlink_metadata().unwrap().is_symlink());

    Linker::new_with_gvs(&store, LinkStrategy::Copy, true)
        .with_hoist(false)
        .link_all(&project_dir, &make_graph(), &indices)
        .unwrap();

    assert!(global_hidden.symlink_metadata().unwrap().is_symlink());
}

#[test]
fn test_second_install_reuses_global_store() {
    let dir = tempfile::tempdir().unwrap();

    let (store, indices) = setup_store_with_files(dir.path());
    let linker = Linker::new_with_gvs(&store, LinkStrategy::Copy, true);
    let graph = make_graph();

    // First install
    let project1 = dir.path().join("project1");
    std::fs::create_dir_all(&project1).unwrap();
    let stats1 = linker.link_all(&project1, &graph, &indices).unwrap();
    assert_eq!(stats1.packages_linked, 2);
    assert_eq!(stats1.packages_cached, 0);

    // Second install with same deps — should reuse global virtual store
    let project2 = dir.path().join("project2");
    std::fs::create_dir_all(&project2).unwrap();
    let stats2 = linker.link_all(&project2, &graph, &indices).unwrap();
    assert_eq!(stats2.packages_linked, 0);
    assert_eq!(stats2.packages_cached, 2);
    assert_eq!(stats2.files_linked, 0); // no CAS linking needed

    // Both projects should work
    let foo1 = project1.join("node_modules/foo/index.js");
    let foo2 = project2.join("node_modules/foo/index.js");
    assert!(foo1.exists());
    assert!(foo2.exists());
    assert_eq!(
        std::fs::read_to_string(&foo1).unwrap(),
        std::fs::read_to_string(&foo2).unwrap()
    );
}

/// Regression: a version bump keeps the same top-level name
/// (`foo`) but must repoint `node_modules/foo` at the new
/// `.aube/foo@<new>` entry. The old `.aube/foo@<old>/` is left
/// on disk (no one sweeps the virtual store by name), so a
/// plain `path.exists()` check would see a still-resolving
/// stale symlink and keep it. The target-aware
/// `reconcile_top_level_link` compares the expected target
/// string and rewrites the link.
#[test]
fn test_link_all_repoints_symlink_after_version_bump() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();
    let store = Store::at(dir.path().join("store/files"));

    // Install 1: foo@1.0.0 as the root's direct dep.
    let mut indices_v1 = BTreeMap::new();
    let foo_v1 = store
        .import_bytes(b"module.exports = 'foo@1';", false)
        .unwrap();
    let mut foo_v1_index = PackageIndex::default();
    foo_v1_index.insert("index.js".to_string(), foo_v1);
    indices_v1.insert("foo@1.0.0".to_string(), foo_v1_index);

    let mut graph_v1 = LockfileGraph::default();
    graph_v1.packages.insert(
        "foo@1.0.0".to_string(),
        LockedPackage {
            name: "foo".to_string(),
            version: "1.0.0".to_string(),
            dep_path: "foo@1.0.0".to_string(),
            ..Default::default()
        },
    );
    graph_v1.importers.insert(
        ".".to_string(),
        vec![DirectDep {
            name: "foo".to_string(),
            dep_path: "foo@1.0.0".to_string(),
            dep_type: DepType::Production,
            specifier: None,
        }],
    );

    let linker = Linker::new(&store, LinkStrategy::Copy);
    linker
        .link_all(&project_dir, &graph_v1, &indices_v1)
        .unwrap();
    let foo_link = project_dir.join("node_modules/foo");
    assert!(foo_link.symlink_metadata().unwrap().is_symlink());
    assert_eq!(
        std::fs::read_to_string(foo_link.join("index.js")).unwrap(),
        "module.exports = 'foo@1';"
    );

    // Install 2: foo upgraded to 2.0.0. The `.aube/foo@1.0.0/`
    // tree stays on disk (nothing prunes the virtual store by
    // name), so the old `node_modules/foo` symlink still
    // resolves — a naive "does the target exist?" check would
    // keep it.
    let mut indices_v2 = BTreeMap::new();
    let foo_v2 = store
        .import_bytes(b"module.exports = 'foo@2';", false)
        .unwrap();
    let mut foo_v2_index = PackageIndex::default();
    foo_v2_index.insert("index.js".to_string(), foo_v2);
    indices_v2.insert("foo@2.0.0".to_string(), foo_v2_index);

    let mut graph_v2 = LockfileGraph::default();
    graph_v2.packages.insert(
        "foo@2.0.0".to_string(),
        LockedPackage {
            name: "foo".to_string(),
            version: "2.0.0".to_string(),
            dep_path: "foo@2.0.0".to_string(),
            ..Default::default()
        },
    );
    graph_v2.importers.insert(
        ".".to_string(),
        vec![DirectDep {
            name: "foo".to_string(),
            dep_path: "foo@2.0.0".to_string(),
            dep_type: DepType::Production,
            specifier: None,
        }],
    );
    linker
        .link_all(&project_dir, &graph_v2, &indices_v2)
        .unwrap();

    // The top-level symlink must now resolve to foo@2.0.0's
    // bytes, not foo@1.0.0's.
    assert_eq!(
        std::fs::read_to_string(project_dir.join("node_modules/foo/index.js")).unwrap(),
        "module.exports = 'foo@2';"
    );
}

/// Regression: `shamefully_hoist` hoists transitive deps to the
/// top-level `node_modules/<name>`. When the hoisted version
/// changes between installs (transitive bump), the previous
/// implementation kept the stale symlink because
/// `keep_or_reclaim_broken_symlink` only checked "does target
/// resolve?" and the old `.aube/<old-dep-path>/` was still on
/// disk. `reconcile_top_level_link` + the explicit
/// direct-dep/claimed tracking in `hoist_remaining_into` together
/// fix this.
#[test]
fn test_shamefully_hoist_repoints_after_transitive_version_bump() {
    let dir = tempfile::tempdir().unwrap();
    let project_dir = dir.path().join("project");
    std::fs::create_dir_all(&project_dir).unwrap();
    let store = Store::at(dir.path().join("store/files"));

    // Install 1: root → bar@1.0.0 → foo@1.0.0 (transitive).
    let foo_v1 = store
        .import_bytes(b"module.exports = 'foo@1';", false)
        .unwrap();
    let mut foo_v1_idx = PackageIndex::default();
    foo_v1_idx.insert("index.js".to_string(), foo_v1);
    let bar_v1 = store
        .import_bytes(b"module.exports = 'bar@1';", false)
        .unwrap();
    let mut bar_v1_idx = PackageIndex::default();
    bar_v1_idx.insert("index.js".to_string(), bar_v1);
    let mut indices_v1 = BTreeMap::new();
    indices_v1.insert("foo@1.0.0".to_string(), foo_v1_idx);
    indices_v1.insert("bar@1.0.0".to_string(), bar_v1_idx);

    let mut graph_v1 = LockfileGraph::default();
    let mut bar_deps_v1 = BTreeMap::new();
    bar_deps_v1.insert("foo".to_string(), "1.0.0".to_string());
    graph_v1.packages.insert(
        "bar@1.0.0".to_string(),
        LockedPackage {
            name: "bar".to_string(),
            version: "1.0.0".to_string(),
            dep_path: "bar@1.0.0".to_string(),
            dependencies: bar_deps_v1,
            ..Default::default()
        },
    );
    graph_v1.packages.insert(
        "foo@1.0.0".to_string(),
        LockedPackage {
            name: "foo".to_string(),
            version: "1.0.0".to_string(),
            dep_path: "foo@1.0.0".to_string(),
            ..Default::default()
        },
    );
    graph_v1.importers.insert(
        ".".to_string(),
        vec![DirectDep {
            name: "bar".to_string(),
            dep_path: "bar@1.0.0".to_string(),
            dep_type: DepType::Production,
            specifier: None,
        }],
    );

    let linker = Linker::new(&store, LinkStrategy::Copy).with_shamefully_hoist(true);
    linker
        .link_all(&project_dir, &graph_v1, &indices_v1)
        .unwrap();
    assert_eq!(
        std::fs::read_to_string(project_dir.join("node_modules/foo/index.js")).unwrap(),
        "module.exports = 'foo@1';",
        "install 1 should hoist foo@1.0.0"
    );

    // Install 2: bar@1.0.0 → foo@2.0.0 (transitive bump). The
    // stale `.aube/foo@1.0.0/` tree is still on disk (nothing
    // sweeps the virtual store by name), so the old hoisted
    // symlink would still resolve — the old `exists?` check
    // would silently keep it.
    let foo_v2 = store
        .import_bytes(b"module.exports = 'foo@2';", false)
        .unwrap();
    let mut foo_v2_idx = PackageIndex::default();
    foo_v2_idx.insert("index.js".to_string(), foo_v2);
    let mut indices_v2 = BTreeMap::new();
    // Reuse bar's materialized index from v1.
    let bar_v1_for_v2 = store
        .import_bytes(b"module.exports = 'bar@1';", false)
        .unwrap();
    let mut bar_v1_idx_v2 = PackageIndex::default();
    bar_v1_idx_v2.insert("index.js".to_string(), bar_v1_for_v2);
    indices_v2.insert("bar@1.0.0".to_string(), bar_v1_idx_v2);
    indices_v2.insert("foo@2.0.0".to_string(), foo_v2_idx);

    let mut graph_v2 = LockfileGraph::default();
    let mut bar_deps_v2 = BTreeMap::new();
    bar_deps_v2.insert("foo".to_string(), "2.0.0".to_string());
    graph_v2.packages.insert(
        "bar@1.0.0".to_string(),
        LockedPackage {
            name: "bar".to_string(),
            version: "1.0.0".to_string(),
            dep_path: "bar@1.0.0".to_string(),
            dependencies: bar_deps_v2,
            ..Default::default()
        },
    );
    graph_v2.packages.insert(
        "foo@2.0.0".to_string(),
        LockedPackage {
            name: "foo".to_string(),
            version: "2.0.0".to_string(),
            dep_path: "foo@2.0.0".to_string(),
            ..Default::default()
        },
    );
    graph_v2.importers.insert(
        ".".to_string(),
        vec![DirectDep {
            name: "bar".to_string(),
            dep_path: "bar@1.0.0".to_string(),
            dep_type: DepType::Production,
            specifier: None,
        }],
    );

    linker
        .link_all(&project_dir, &graph_v2, &indices_v2)
        .unwrap();
    assert_eq!(
        std::fs::read_to_string(project_dir.join("node_modules/foo/index.js")).unwrap(),
        "module.exports = 'foo@2';",
        "install 2 should repoint the hoisted symlink to foo@2.0.0"
    );
}

// ---------------------------------------------------------------
// `validate_index_key` rejects every shape of index key that
// would make `base.join(key)` escape `base`. Primary defence is
// in `aube-store::import_tarball`; this is the last-chance guard
// before the linker actually writes to disk.
// ---------------------------------------------------------------

#[test]
fn validate_index_key_accepts_normal_keys() {
    validate_index_key("index.js").unwrap();
    validate_index_key("lib/sub/a.js").unwrap();
    validate_index_key("package.json").unwrap();
    validate_index_key("a/b/c/d/e/f.js").unwrap();
}

#[cfg(not(windows))]
#[test]
fn validate_index_key_accepts_posix_colon_filename() {
    validate_index_key("dist/__mocks__/package-json:version.d.ts").unwrap();
}

#[test]
fn validate_index_key_rejects_empty() {
    assert!(matches!(
        validate_index_key(""),
        Err(Error::UnsafeIndexKey(_))
    ));
}

#[test]
fn validate_index_key_rejects_leading_slash() {
    assert!(matches!(
        validate_index_key("/etc/passwd"),
        Err(Error::UnsafeIndexKey(_))
    ));
    assert!(matches!(
        validate_index_key("\\evil"),
        Err(Error::UnsafeIndexKey(_))
    ));
}

#[test]
fn validate_index_key_rejects_parent_dir() {
    assert!(matches!(
        validate_index_key("../../etc/passwd"),
        Err(Error::UnsafeIndexKey(_))
    ));
    assert!(matches!(
        validate_index_key("lib/../../../etc"),
        Err(Error::UnsafeIndexKey(_))
    ));
}

#[test]
fn validate_index_key_rejects_nul_and_backslash() {
    assert!(matches!(
        validate_index_key("lib\0evil"),
        Err(Error::UnsafeIndexKey(_))
    ));
    assert!(matches!(
        validate_index_key("lib\\..\\etc"),
        Err(Error::UnsafeIndexKey(_))
    ));
}

#[cfg(windows)]
#[test]
fn validate_index_key_rejects_windows_drive() {
    assert!(matches!(
        validate_index_key("C:Windows"),
        Err(Error::UnsafeIndexKey(_))
    ));
}