Skip to main content

aube_linker/
materialize.rs

1use tracing::{debug, trace, warn};
2
3use crate::patches::apply_multi_file_patch;
4use crate::sweep::{
5    EntryState, classify_entry_state, mkdirp, reconcile_dir_link, try_remove_entry,
6};
7use crate::{Error, LinkStats, LinkStrategy, Linker, sys};
8use aube_lockfile::{LockedPackage, shared_local_dep_path};
9use aube_store::{PackageIndex, StoredFile};
10use std::collections::BTreeMap;
11use std::io;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::thread;
15use std::time::Duration;
16
17enum MaterializePlacement {
18    Placed,
19    LostRace,
20}
21
22fn materialize_tmp_name() -> String {
23    static NEXT_TMP_ID: AtomicU64 = AtomicU64::new(0);
24    let id = NEXT_TMP_ID.fetch_add(1, Ordering::Relaxed);
25    format!(".tmp-{}-{id}", std::process::id())
26}
27
28fn place_materialized_entry(src: &Path, dst: &Path) -> io::Result<MaterializePlacement> {
29    const MAX_ATTEMPTS: u32 = 5;
30    let mut backoff_ms = 20u64;
31    let mut attempt = 0;
32    loop {
33        match std::fs::rename(src, dst) {
34            Ok(()) => return Ok(MaterializePlacement::Placed),
35            Err(_) if dst.exists() => return Ok(MaterializePlacement::LostRace),
36            Err(err) if is_transient_rename_error(&err) && attempt < MAX_ATTEMPTS - 1 => {
37                thread::sleep(Duration::from_millis(backoff_ms));
38                backoff_ms = backoff_ms.saturating_mul(2);
39                attempt += 1;
40            }
41            Err(err) => return Err(err),
42        }
43    }
44}
45
46fn is_transient_rename_error(err: &io::Error) -> bool {
47    matches!(
48        err.kind(),
49        io::ErrorKind::AlreadyExists
50            | io::ErrorKind::PermissionDenied
51            | io::ErrorKind::Interrupted
52            | io::ErrorKind::WouldBlock
53    )
54}
55
56/// Test-only switch that forces the reflink attempt in
57/// [`Linker::link_file_fresh`] to be treated as failed, so the
58/// clonefile-failure fallback path can be exercised deterministically on
59/// any filesystem (CI runs on reflink-capable APFS/btrfs where a real
60/// `clonefile` would otherwise succeed). Compiled out of release builds.
61#[cfg(test)]
62pub(crate) static FORCE_REFLINK_FAILURE: std::sync::atomic::AtomicBool =
63    std::sync::atomic::AtomicBool::new(false);
64
65impl Linker {
66    /// Detect the best linking strategy for the filesystem at the given path.
67    ///
68    /// One-arg form. Probes within one dir. Fine when store and
69    /// project node_modules share the same mount. Use the two-arg
70    /// form for installs where the store lives on a different
71    /// filesystem than the project (USB drives, bind mounts, Docker
72    /// volumes, cross-drive Windows installs). Otherwise the probe
73    /// reports hardlink based on project-FS self-test, then every
74    /// real link call crosses an FS boundary and hits EXDEV. Runtime
75    /// falls back to `fs::copy` per file silently, thousands of
76    /// wasted syscalls, user thinks they got hardlinks.
77    ///
78    /// Returns the same-filesystem strategy `auto` resolves to when the
79    /// probe succeeds, `Copy` otherwise. The same-FS strategy is
80    /// OS-specific: on macOS `auto` resolves to `ReflinkAuto` (APFS
81    /// clonefile benchmarks ~1.91x faster than hardlink), on Linux and
82    /// other targets it resolves to `Hardlink` (btrfs/xfs hardlink
83    /// benchmarks ~2.4-2.6x faster than FICLONE reflink).
84    ///
85    /// The macOS `auto` resolution is conservative on non-APFS volumes:
86    /// `clonefile` is APFS-only, so on an HFS+ volume (external drives,
87    /// Fusion/older disks) the same-FS hardlink probe succeeds and
88    /// resolves `ReflinkAuto`, but the real `clonefile` then fails.
89    /// `link_file_fresh` handles this by falling the reflink back to a
90    /// hardlink (which HFS+ supports) before copy, so a non-APFS same-FS
91    /// target still gets zero-cost links rather than a per-file copy.
92    /// This probe never yields the plain `Reflink` strategy — that is
93    /// reachable only through explicit `packageImportMethod = clone` /
94    /// `clone-or-copy`, which keep a plain copy fallback.
95    pub fn detect_strategy(path: &Path) -> LinkStrategy {
96        Self::detect_strategy_cross(path, path)
97    }
98
99    /// Two-arg probe. src is the store shard (or any dir on the
100    /// store FS), dst is the project modules dir (or any dir on the
101    /// destination FS). Probe creates a real cross-mount src file
102    /// and tries to hardlink into dst, which catches EXDEV up front.
103    /// A successful hardlink proves src and dst share a mount, so it
104    /// doubles as the same-FS probe for reflink too (APFS clonefile /
105    /// btrfs FICLONE require the same FS). Returns the OS-specific
106    /// same-FS strategy when the probe succeeds (`ReflinkAuto` on macOS,
107    /// `Hardlink` elsewhere), `Copy` otherwise.
108    pub fn detect_strategy_cross(src_dir: &Path, dst_dir: &Path) -> LinkStrategy {
109        // Same-FS strategy `auto` resolves to once the probe succeeds.
110        // macOS/APFS clonefile is measurably faster than hardlink there;
111        // Linux btrfs/xfs hardlink is measurably faster than FICLONE.
112        // `ReflinkAuto` (not the plain `Reflink` explicit selections use)
113        // carries the non-APFS hardlink-before-copy fallback.
114        #[cfg(target_os = "macos")]
115        const SAME_FS_STRATEGY: LinkStrategy = LinkStrategy::ReflinkAuto;
116        #[cfg(not(target_os = "macos"))]
117        const SAME_FS_STRATEGY: LinkStrategy = LinkStrategy::Hardlink;
118
119        // Memoize per (src_dir, dst_dir) for the process lifetime.
120        // The probe writes a real test file and tries hardlink,
121        // ~2 syscalls + 2 unlinks. Multiple Linker instances within
122        // one install (prewarm + final + per-workspace) all repeat
123        // the probe; cache the answer.
124        type ProbeKey = (std::path::PathBuf, std::path::PathBuf);
125        static CACHE: std::sync::OnceLock<
126            std::sync::RwLock<std::collections::HashMap<ProbeKey, LinkStrategy>>,
127        > = std::sync::OnceLock::new();
128        let key = (src_dir.to_path_buf(), dst_dir.to_path_buf());
129        let cache = CACHE.get_or_init(Default::default);
130        if let Some(hit) = cache.read().expect("probe cache poisoned").get(&key) {
131            return *hit;
132        }
133
134        let test_src = src_dir.join(".aube-link-test-src");
135        let test_dst = dst_dir.join(".aube-link-test-dst");
136
137        let strategy = if std::fs::write(&test_src, b"test").is_ok() {
138            let result = if std::fs::hard_link(&test_src, &test_dst).is_ok() {
139                SAME_FS_STRATEGY
140            } else {
141                LinkStrategy::Copy
142            };
143            let _ = std::fs::remove_file(&test_src);
144            let _ = std::fs::remove_file(&test_dst);
145            result
146        } else {
147            LinkStrategy::Copy
148        };
149
150        // First-write-wins via `entry().or_insert`. Two concurrent
151        // linker probes (prewarm + final) sharing the same
152        // (src_dir, dst_dir) can race on the test files: one observes
153        // hardlink-ok, the other sees the first writer's leftover and
154        // falls back to Copy. `.insert()` would let the wrong Copy
155        // result clobber the correct Hardlink for the rest of the
156        // process; `or_insert` keeps whichever value landed first. (The
157        // value at stake is the same-FS strategy above, not literally
158        // Hardlink — ReflinkAuto on macOS.)
159        *cache
160            .write()
161            .expect("probe cache poisoned")
162            .entry(key)
163            .or_insert(strategy)
164    }
165
166    /// Materialize a package in the global virtual store if not already present.
167    ///
168    /// Materialize `dep_path` into the shared global virtual store.
169    ///
170    /// Uses atomic rename to avoid TOCTOU races: materializes into a
171    /// PID-stamped temp directory, then renames into place. If another
172    /// process wins the race, its result is kept and the temp dir is
173    /// cleaned up.
174    ///
175    /// Exposed so the install driver can pipeline GVS population into
176    /// the fetch phase: as each tarball finishes importing into the
177    /// CAS, the driver calls this to reflink the package into its
178    /// `~/.cache/aube/virtual-store/<subdir>` entry. Link step 1 then
179    /// hits the `pkg_nm_dir.exists()` fast path and only creates the
180    /// per-project `.aube/<dep_path>` symlink.
181    pub fn ensure_in_virtual_store(
182        &self,
183        dep_path: &str,
184        pkg: &LockedPackage,
185        index: &PackageIndex,
186        stats: &mut LinkStats,
187        // `link:` transitives the resolver pinned (e.g. via root
188        // `pnpm.overrides`) need their on-disk target so the parent's
189        // sibling symlink doesn't dangle into a non-existent
190        // `.aube/<name>@link+...`. `None` means "no nested links in
191        // this graph" and the materialize hot path stays unchanged.
192        nested_link_targets: Option<&BTreeMap<String, PathBuf>>,
193    ) -> Result<(), Error> {
194        // Global-store paths always run through the vstore_key map —
195        // when hashes are installed this folds dep-graph + engine
196        // state into the leaf name, so concurrent builds of the same
197        // package against different toolchains don't collide.
198        let subdir = self.virtual_store_subdir(dep_path);
199        self.ensure_in_virtual_store_with_subdir(
200            dep_path,
201            &subdir,
202            pkg,
203            index,
204            stats,
205            nested_link_targets,
206        )
207    }
208
209    /// `ensure_in_virtual_store` with the virtual-store subdir already
210    /// computed by the caller. The link step's per-package par_iter
211    /// derives `virtual_store_subdir(dep_path)` once to build the
212    /// shared-store entry path, so passing it in here avoids recomputing
213    /// the same `dep_path_to_filename` encode (a `String` alloc plus the
214    /// escape/uppercase scans, and a second alloc + BLAKE3 short-hash on
215    /// long/scoped/peer-context names). `subdir` MUST equal
216    /// `self.virtual_store_subdir(dep_path)`; the public wrapper above
217    /// guarantees that for callers that don't already have it.
218    pub(crate) fn ensure_in_virtual_store_with_subdir(
219        &self,
220        dep_path: &str,
221        subdir: &str,
222        pkg: &LockedPackage,
223        index: &PackageIndex,
224        stats: &mut LinkStats,
225        nested_link_targets: Option<&BTreeMap<String, PathBuf>>,
226    ) -> Result<(), Error> {
227        let _diag =
228            aube_util::diag::Span::new(aube_util::diag::Category::Linker, "ensure_in_vstore")
229                .with_meta_fn(|| {
230                    format!(
231                        r#"{{"name":{},"files":{}}}"#,
232                        aube_util::diag::jstr(&pkg.name),
233                        index.len()
234                    )
235                });
236        let pkg_nm_dir = self
237            .virtual_store
238            .join(subdir)
239            .join("node_modules")
240            .join(&pkg.name);
241
242        if pkg_nm_dir.exists() {
243            self.reconcile_virtual_store_entry(dep_path, pkg, nested_link_targets)?;
244            trace!("virtual store hit: {dep_path}");
245            stats.packages_cached += 1;
246            return Ok(());
247        }
248
249        // Materialize into a temp directory, then atomically rename into place
250        // to avoid TOCTOU races between concurrent `aube install` processes.
251        let tmp_name = materialize_tmp_name();
252        let tmp_base = self.virtual_store.join(&tmp_name);
253
254        let result = self.materialize_into(
255            &tmp_base,
256            &self.virtual_store,
257            dep_path,
258            pkg,
259            index,
260            stats,
261            true,
262            nested_link_targets,
263        );
264
265        if result.is_err() {
266            let _ = std::fs::remove_dir_all(&tmp_base);
267            return result;
268        }
269
270        // Atomically move the dep_path entry from the temp dir to the final location.
271        let tmp_entry = tmp_base.join(subdir);
272        let final_entry = self.virtual_store.join(subdir);
273
274        // Ensure the parent of the final entry exists (e.g. for scoped packages).
275        if let Some(parent) = final_entry.parent()
276            && let Err(e) = mkdirp(parent)
277        {
278            let _ = std::fs::remove_dir_all(&tmp_base);
279            return Err(e);
280        }
281
282        match place_materialized_entry(&tmp_entry, &final_entry) {
283            Ok(MaterializePlacement::Placed) => {
284                trace!("atomically placed {subdir} in virtual store");
285            }
286            Ok(MaterializePlacement::LostRace) => {
287                // Another process won the race — that's fine, use theirs.
288                trace!("lost rename race for {dep_path}, using existing");
289                // Undo the stats from our materialization since we're discarding it
290                stats.packages_linked = stats.packages_linked.saturating_sub(1);
291                stats.files_linked = stats.files_linked.saturating_sub(index.len());
292                stats.packages_cached += 1;
293                // Lost-race path: our `subdir` is still inside
294                // `tmp_base`, so a full recursive delete is needed.
295                let _ = std::fs::remove_dir_all(&tmp_base);
296                return Ok(());
297            }
298            Err(e) => {
299                let _ = std::fs::remove_dir_all(&tmp_base);
300                return Err(Error::Io(final_entry, e));
301            }
302        }
303
304        // Successful rename: `tmp_base` is now an empty wrapper directory
305        // (its single child was the subdir we just renamed out). Use
306        // `remove_dir` instead of `remove_dir_all` — the latter still
307        // does the full `opendir`/`fdopendir`(fcntl)/`readdir`/`close`
308        // walk even on an empty dir, which dtrace shows as ~6 extra
309        // syscalls per package. At 227 packages that's ~1.4k wasted
310        // syscalls on every cold install.
311        //
312        // `remove_dir` fails with `ENOTEMPTY` if a future change to
313        // `materialize_into` starts dropping extra files into
314        // `tmp_base`. Log at debug so the leak is observable without
315        // being fatal; the worst-case outcome is a stray tmp dir, and
316        // concurrent-writer races already use the full
317        // `remove_dir_all` branch above.
318        if let Err(e) = std::fs::remove_dir(&tmp_base) {
319            debug!(
320                "remove_dir({}) failed, leaving tmp in place: {e}",
321                tmp_base.display()
322            );
323        }
324
325        Ok(())
326    }
327
328    /// Validate and repair the dependency links inside an existing global
329    /// virtual-store package entry. The package directory itself can be a
330    /// valid cache hit while one of its sibling links still targets an old
331    /// graph identity.
332    pub(crate) fn reconcile_virtual_store_entry(
333        &self,
334        dep_path: &str,
335        pkg: &LockedPackage,
336        nested_link_targets: Option<&BTreeMap<String, PathBuf>>,
337    ) -> Result<(), Error> {
338        let pkg_nm_parent = self
339            .virtual_store
340            .join(self.virtual_store_subdir(dep_path))
341            .join("node_modules");
342        for (dep_name, dep_version) in &pkg.dependencies {
343            if dep_name == &pkg.name {
344                continue;
345            }
346            validate_package_link_name(dep_name)?;
347            let dep_dep_path = shared_local_dep_path(dep_name, dep_version)
348                .unwrap_or_else(|| format!("{dep_name}@{dep_version}"));
349            let symlink_path = pkg_nm_parent.join(dep_name);
350            let target = if let Some(abs_target) =
351                nested_link_targets.and_then(|targets| targets.get(&dep_dep_path))
352            {
353                abs_target.clone()
354            } else {
355                let sibling_subdir = self.virtual_store_subdir(&dep_dep_path);
356                #[cfg(not(windows))]
357                {
358                    let sibling_abs = self
359                        .virtual_store
360                        .join(sibling_subdir)
361                        .join("node_modules")
362                        .join(dep_name);
363                    let link_parent = symlink_path.parent().unwrap_or(&pkg_nm_parent);
364                    pathdiff::diff_paths(&sibling_abs, link_parent)
365                        .unwrap_or_else(|| sibling_abs.clone())
366                }
367                #[cfg(windows)]
368                {
369                    self.virtual_store
370                        .join(sibling_subdir)
371                        .join("node_modules")
372                        .join(dep_name)
373                }
374            };
375            if reconcile_dir_link(&symlink_path, &target)? {
376                continue;
377            }
378            if let Some(parent) = symlink_path.parent() {
379                mkdirp(parent)?;
380            }
381            if let Err(create_err) = sys::create_dir_link(&target, &symlink_path) {
382                let won_race = create_err.kind() == std::io::ErrorKind::AlreadyExists
383                    && reconcile_dir_link(&symlink_path, &target).unwrap_or(false);
384                if !won_race {
385                    return Err(Error::Io(symlink_path, create_err));
386                }
387            }
388        }
389        Ok(())
390    }
391
392    /// Materialize a globally-reproducible local source (a `git`
393    /// dependency or a remote `.tgz`) into the shared virtual store and
394    /// point the per-project `.aube/<dep_path>` entry at it — the exact
395    /// arrangement Step 1 produces for a registry package.
396    ///
397    /// Used by the isolated linker in global-virtual-store mode. Plain
398    /// `file:` / `link:` / `portal:` / `exec:` sources resolve against
399    /// a path inside the project and are materialized per-project
400    /// instead (see `materialize_into` with `apply_hashes = false`),
401    /// but git and remote-tarball sources are content-pinned and shared
402    /// like registry packages. They MUST live in the shared store when
403    /// it is enabled: a registry dependent in the shared store links
404    /// its dependency siblings to the hashed global path
405    /// (`virtual_store_subdir(dep_path)`), so a git/tarball dep that
406    /// only existed in the per-project `.aube/` would leave that
407    /// sibling symlink dangling — and Node would resolve whatever
408    /// unrelated `<name>` it found walking up the tree.
409    pub(crate) fn ensure_shared_local_in_global_store(
410        &self,
411        aube_dir: &Path,
412        dep_path: &str,
413        pkg: &LockedPackage,
414        index: &PackageIndex,
415        stats: &mut LinkStats,
416        nested_link_targets: Option<&BTreeMap<String, PathBuf>>,
417    ) -> Result<(), Error> {
418        let local_aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path));
419        let global_entry = self.virtual_store.join(self.virtual_store_subdir(dep_path));
420        let state = classify_entry_state(&local_aube_entry, &global_entry);
421        if matches!(state, EntryState::Fresh) {
422            self.reconcile_virtual_store_entry(dep_path, pkg, nested_link_targets)?;
423            stats.packages_cached += 1;
424            return Ok(());
425        }
426        self.ensure_in_virtual_store(dep_path, pkg, index, stats, nested_link_targets)?;
427        if matches!(state, EntryState::Stale) {
428            // A prior install — or an older aube that always
429            // materialized git/remote sources per-project — may have
430            // left a real directory or a stale symlink here. Clear
431            // either shape before pointing the entry at the shared
432            // store (`try_remove_entry` handles dir, symlink, and
433            // dangling-link cases).
434            try_remove_entry(&local_aube_entry);
435        }
436        if let Some(parent) = local_aube_entry.parent() {
437            mkdirp(parent)?;
438        }
439        sys::create_dir_link(&global_entry, &local_aube_entry)
440            .map_err(|e| Error::Io(local_aube_entry.clone(), e))?;
441        Ok(())
442    }
443
444    /// Materialize a single package directly into the per-project
445    /// virtual store at `aube_dir/<dep_path>/node_modules/<name>/`.
446    ///
447    /// Idempotent: if the entry already exists, counts as cached and
448    /// returns. Otherwise materializes into a unique temp directory
449    /// and atomically renames that entry into place so duplicate
450    /// in-process fetch events for the same dep-path cannot race while
451    /// writing `node_modules/.aube/<dep_path>/`. Used by the
452    /// install-time materializer to pipeline the link work into the
453    /// fetch phase under non-GVS mode, so the dedicated link phase only
454    /// has to create top-level `node_modules/<name>` symlinks.
455    pub fn ensure_in_aube_dir(
456        &self,
457        aube_dir: &Path,
458        dep_path: &str,
459        pkg: &LockedPackage,
460        index: &PackageIndex,
461        stats: &mut LinkStats,
462        nested_link_targets: Option<&BTreeMap<String, PathBuf>>,
463    ) -> Result<(), Error> {
464        let subdir = self.aube_dir_entry_name(dep_path);
465        let final_entry = aube_dir.join(&subdir);
466        if final_entry.exists() {
467            stats.packages_cached += 1;
468            return Ok(());
469        }
470
471        let tmp_name = materialize_tmp_name();
472        let tmp_base = aube_dir.join(&tmp_name);
473        let result = self.materialize_into(
474            &tmp_base,
475            aube_dir,
476            dep_path,
477            pkg,
478            index,
479            stats,
480            false,
481            nested_link_targets,
482        );
483
484        if result.is_err() {
485            let _ = std::fs::remove_dir_all(&tmp_base);
486            return result;
487        }
488
489        let tmp_entry = tmp_base.join(&subdir);
490        if let Some(parent) = final_entry.parent()
491            && let Err(e) = mkdirp(parent)
492        {
493            let _ = std::fs::remove_dir_all(&tmp_base);
494            return Err(e);
495        }
496
497        match place_materialized_entry(&tmp_entry, &final_entry) {
498            Ok(MaterializePlacement::Placed) => {}
499            Ok(MaterializePlacement::LostRace) => {
500                stats.packages_linked = stats.packages_linked.saturating_sub(1);
501                stats.files_linked = stats.files_linked.saturating_sub(index.len());
502                stats.packages_cached += 1;
503                let _ = std::fs::remove_dir_all(&tmp_base);
504                return Ok(());
505            }
506            Err(e) => {
507                let _ = std::fs::remove_dir_all(&tmp_base);
508                return Err(Error::Io(final_entry, e));
509            }
510        }
511
512        if let Err(e) = std::fs::remove_dir(&tmp_base) {
513            debug!(
514                "remove_dir({}) failed, leaving tmp in place: {e}",
515                tmp_base.display()
516            );
517        }
518
519        Ok(())
520    }
521
522    /// Materialize a package's files and transitive dep symlinks into a base directory.
523    ///
524    /// `base_dir` is where files are written during materialization.
525    /// `final_base_dir` is where those files will live after any
526    /// wrapper rename. These differ for `.tmp-*` staging dirs; Windows
527    /// junctions need the final root because they persist absolute
528    /// targets at creation time.
529    ///
530    /// `apply_hashes` controls whether per-dep subdir names are run
531    /// through `vstore_key` (the content-addressed name) or used as
532    /// raw `dep_path` strings. Global-store callers pass `true` so
533    /// the shared `~/.cache/aube/virtual-store/` can hold isolated
534    /// copies for each `(deps_hash, engine)` combination;
535    /// per-project `.aube/` callers pass `false` because node's
536    /// runtime module walk resolves by dep_path only.
537    #[allow(clippy::too_many_arguments)]
538    pub(crate) fn materialize_into(
539        &self,
540        base_dir: &Path,
541        final_base_dir: &Path,
542        dep_path: &str,
543        pkg: &LockedPackage,
544        index: &PackageIndex,
545        stats: &mut LinkStats,
546        apply_hashes: bool,
547        // dep_path → absolute on-disk target for any `link:` packages
548        // referenced as transitive deps. When the parent itself is a
549        // `file:` Directory or `link:` Link (workspace-style locals),
550        // its `package.json` may declare `link:./libs/foo` deps that
551        // point inside the parent's source tree. We sidestep the
552        // virtual store for those — there is no `.aube/<dep>@link+...`
553        // entry — and symlink straight to the on-disk path the
554        // resolver pinned. `None` means "no nested link transitives in
555        // this graph", which is the common case.
556        nested_link_targets: Option<&BTreeMap<String, PathBuf>>,
557    ) -> Result<(), Error> {
558        #[cfg(not(windows))]
559        let _ = final_base_dir;
560
561        validate_package_link_name(&pkg.name)?;
562        for dep_name in pkg.dependencies.keys() {
563            validate_package_link_name(dep_name)?;
564        }
565        let subdir = if apply_hashes {
566            self.virtual_store_subdir(dep_path)
567        } else {
568            self.aube_dir_entry_name(dep_path)
569        };
570        let pkg_nm_dir = base_dir.join(&subdir).join("node_modules").join(&pkg.name);
571
572        // Pre-compute the set of unique parent directories across
573        // every file in the index AND every scoped transitive-dep
574        // symlink we're about to create, then mkdir them in a single
575        // pass. Previously each file looped through `mkdirp(parent)`
576        // which always did an `exists()` check (= statx syscall) even
577        // though the same parents were shared by dozens of siblings —
578        // `materialize_into` for a typical 32-file npm package
579        // resulted in ~25 redundant statx calls. Collecting the unique
580        // parents first, sorting by length (so ancestors precede
581        // descendants), and calling `create_dir_all` once each cuts
582        // out the redundant stats entirely. `BTreeSet` sorts
583        // lexicographically, which is good enough because every
584        // ancestor of a directory is a prefix of it.
585        let pkg_nm_parent = base_dir.join(&subdir).join("node_modules");
586        // Collect into Vec + sort + dedup instead of BTreeSet. For a
587        // package with thousands of files (typescript, next), the
588        // BTreeSet's per-insert log-N PathBuf comparison (~50-byte
589        // memcmps) was a measurable cost on top of the redundant
590        // create_dir_all that the set was deduplicating in the first
591        // place.
592        let mut parents: Vec<PathBuf> = Vec::with_capacity(index.len() / 4 + 4);
593        parents.push(pkg_nm_dir.clone());
594        // Validate every key once here. The file-linking loop below
595        // walks the same immutable index, so skipping the check
596        // there is safe.
597        for rel_path in index.keys() {
598            validate_index_key(rel_path)?;
599            let target = pkg_nm_dir.join(rel_path);
600            if let Some(parent) = target.parent() {
601                parents.push(parent.to_path_buf());
602            }
603        }
604        // Scoped transitive deps need `pkg_nm_parent/@scope/` to exist
605        // before the symlink call; include those parents in the batch.
606        for dep_name in pkg.dependencies.keys() {
607            if let Some(slash) = dep_name.find('/')
608                && dep_name.starts_with('@')
609            {
610                parents.push(pkg_nm_parent.join(&dep_name[..slash]));
611            }
612        }
613        parents.sort_unstable();
614        parents.dedup();
615        for parent in &parents {
616            std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.clone(), e))?;
617        }
618
619        // `materialize_into` always writes into a fresh location
620        // (either a `.tmp-<pid>-...` staging dir for the global virtual
621        // store or a per-project `.aube/<dep_path>` just created by
622        // the caller), so we can skip the `remove_file(dst)` that
623        // `link_file` does defensively. Pass `fresh = true` to suppress
624        // the unlink syscall on every file. For a 1.4k-package install
625        // that's ~45k wasted `unlink` calls on the hot path.
626        for (rel_path, stored) in index {
627            // Key already validated in the parent-collection loop
628            // above. The index is immutable between the two loops.
629            let target = pkg_nm_dir.join(rel_path);
630
631            if let Err(e) = self.link_file_fresh(stored, rel_path, &target) {
632                if let Error::MissingStoreFile { .. } = &e {
633                    invalidate_stale_index_for_package(&self.store, pkg);
634                }
635                return Err(e);
636            }
637            stats.files_linked += 1;
638
639            if stored.executable {
640                // `create_cas_file` writes every CAS entry as 0o644
641                // unconditionally; the only place a CAS entry's
642                // shared inode gets the +x bit is the very first
643                // `make_executable` call against a hardlinked or
644                // reflinked target — that `chmod` upgrades the
645                // shared inode for every later linker that points
646                // at it. Skipping the call (an earlier optimization)
647                // produced 0o644 binaries on cold installs and
648                // broke every CLI shipped via npm.
649                #[cfg(unix)]
650                xx::file::make_executable(&target).map_err(|e| Error::Xx(e.to_string()))?;
651            }
652        }
653
654        // Apply any user-supplied patch for this `(name, version)`.
655        // Patches are applied *after* the files have been linked into
656        // the virtual store but *before* transitive symlinks, so the
657        // patched bytes live alongside the unpatched ones at a
658        // distinct subdir (the graph hash callback is responsible for
659        // making sure that's true).
660        if let Some((patch_key, patch_text)) = pkg.lookup_patch(&self.patches) {
661            apply_multi_file_patch(&pkg_nm_dir, patch_text)
662                .map_err(|msg| Error::Patch(patch_key, msg))?;
663        }
664
665        // Create symlinks for transitive dependencies. Parents for
666        // scoped packages were added to the `parents` batch above, so
667        // we no longer need a per-symlink mkdirp. We also skip the
668        // `symlink_metadata().is_ok()` existence check: callers
669        // guarantee the target directory is freshly created (either a
670        // `.tmp-<pid>-...` staging dir for the global virtual store or
671        // a per-project `.aube/<dep_path>` that the caller just
672        // ensured is empty), so nothing can be in the way.
673        for (dep_name, dep_version) in &pkg.dependencies {
674            // Git / remote-tarball deps are recorded by their resolved
675            // URL spec but keyed in the graph under the short
676            // `name@git+<hash>` / `name@url+<hash>` form. Translate so the
677            // sibling symlink targets the same `dep_path` the package was
678            // materialized under; everything else keeps `name@version`.
679            let dep_dep_path = shared_local_dep_path(dep_name, dep_version)
680                .unwrap_or_else(|| format!("{dep_name}@{dep_version}"));
681            // Skip any dep whose name matches the package being
682            // materialized, regardless of version. The symlink would
683            // land at `pkg_nm_parent.join(dep_name)` which is exactly
684            // `pkg_nm_dir` — the directory we just populated with the
685            // package's own files — and `create_dir_link` would fail
686            // EEXIST. The skip used to require version-equality too,
687            // but published packages occasionally declare a *different*
688            // version of themselves as a dep (e.g. `react_ujs@3.3.0`
689            // pins `react_ujs@^2.7.1`, an artifact of how its build
690            // script generates its package.json). Treat that as a
691            // self-reference: `require('<self>')` from inside the
692            // package resolves to its own files, matching what npm /
693            // pnpm / yarn end up with after their hoisting passes.
694            if dep_name == &pkg.name {
695                continue;
696            }
697            let symlink_path = pkg_nm_parent.join(dep_name);
698            // `link:` transitive: the resolver pinned an absolute
699            // on-disk target. Skip the virtual-store sibling lookup
700            // (there is no `.aube/<dep>@link+...` entry for these) and
701            // symlink straight at the source directory.
702            //
703            // Store the absolute target verbatim. A relative path
704            // would have to thread two pitfalls at once: the GVS
705            // tmp→final rename (link's own depth changes by one) AND
706            // macOS `/tmp`→`/private/tmp` symlink expansion (the dir
707            // the OS resolves the link from is one level deeper than
708            // `self.virtual_store` lexically suggests). Either alone
709            // is fixable; together every `pathdiff` variant lands one
710            // component off and the link dangles. Sibling symlinks
711            // get away with relative paths because both endpoints
712            // live inside `base_dir` and move together; nested-link
713            // targets are *external* (under `project_dir`) so the
714            // tricks that work for siblings don't apply. Windows
715            // already uses absolute targets for the same reason (see
716            // the `#[cfg(windows)]` block below).
717            if let Some(map) = nested_link_targets
718                && let Some(abs_target) = map.get(&dep_dep_path)
719            {
720                sys::create_dir_link(abs_target, &symlink_path)
721                    .map_err(|e| Error::Io(symlink_path.clone(), e))?;
722                continue;
723            }
724            // Match the parent's convention: global-store materialization
725            // walks sibling subdirs under their hashed names, while the
726            // per-project `.aube/` layout uses raw dep_paths.
727            let sibling_subdir = if apply_hashes {
728                self.virtual_store_subdir(&dep_dep_path)
729            } else {
730                self.aube_dir_entry_name(&dep_dep_path)
731            };
732            // Compute the relative path from the symlink's parent to
733            // the sibling dep directory. The symlink's parent is
734            // `pkg_nm_parent/` for a bare name but
735            // `pkg_nm_parent/@scope/` for a scoped one, so we can't
736            // hard-code `../..` — doing so would undercount by one
737            // level for every scoped transitive dep and produce a
738            // dangling link. `pathdiff::diff_paths` walks the
739            // difference for us, yielding `../..` for `foo` and
740            // `../../..` for `@vue/shared`, both relative to whatever
741            // parent `symlink_path` ends up with.
742            // `pkg_nm_parent` is `<base_dir>/<subdir>/node_modules/`, so
743            // two parents deep brings us to `<base_dir>/` where all
744            // sibling subdirs live side-by-side.
745            #[cfg(not(windows))]
746            let target = {
747                let virtual_root = pkg_nm_parent
748                    .parent()
749                    .and_then(Path::parent)
750                    .unwrap_or(&pkg_nm_parent);
751                let sibling_abs = virtual_root
752                    .join(&sibling_subdir)
753                    .join("node_modules")
754                    .join(dep_name);
755                let link_parent = symlink_path.parent().unwrap_or(&pkg_nm_parent);
756                pathdiff::diff_paths(&sibling_abs, link_parent)
757                    .unwrap_or_else(|| sibling_abs.clone())
758            };
759
760            // Staged materialization writes into `.tmp-<pid>-<id>/`,
761            // then atomic-renames into `final_base_dir/<subdir>/`.
762            // POSIX symlinks store the relative offset verbatim.
763            // Offset stays invariant under the wrapper rename, so the
764            // link resolves correctly after the move. Windows junctions
765            // resolve the target against `link.parent()` at create time
766            // and persist an absolute path, which binds the junction to
767            // the tmp wrapper. Point Windows at the final root up front
768            // so the stored absolute path survives the rename.
769            #[cfg(windows)]
770            let target = final_base_dir
771                .join(&sibling_subdir)
772                .join("node_modules")
773                .join(dep_name);
774
775            sys::create_dir_link(&target, &symlink_path)
776                .map_err(|e| Error::Io(symlink_path.clone(), e))?;
777        }
778
779        stats.packages_linked += 1;
780        trace!("materialized {dep_path} ({} files)", index.len());
781        Ok(())
782    }
783
784    /// Hardlink-or-copy a file into a freshly-created destination.
785    /// Assumes `dst` does not exist — callers (`materialize_into`)
786    /// always write into a `.tmp-<pid>-...` staging dir or a
787    /// just-wiped per-project `.aube/<dep_path>`, so the defensive
788    /// `remove_file(dst)` an idempotent variant would need is skipped.
789    /// Eliminates one syscall per linked file (~45k on the medium
790    /// benchmark fixture).
791    pub(crate) fn link_file_fresh(
792        &self,
793        stored: &StoredFile,
794        rel_path: &str,
795        dst: &Path,
796    ) -> Result<(), Error> {
797        #[cfg(target_os = "macos")]
798        const SMALL_FILE_COPY_MAX: u64 = 16 * 1024;
799        let map_io = |e: std::io::Error| classify_link_error(stored, rel_path, dst, e);
800        let missing_source = || Error::MissingStoreFile {
801            store_path: stored.store_path.clone(),
802            rel_path: rel_path.to_string(),
803        };
804        // Track the realized strategy (may differ from `self.strategy` when
805        // a reflink or hardlink falls back to copy) for diagnostic
806        // attribution. Diag emits a `linker.link_<strategy>` event with
807        // the per-file duration so the analyzer can break down link cost
808        // by realized path: reflink (zero-copy CoW), hardlink (zero-cost
809        // metadata link), copy (full byte transfer), or the
810        // small-file-copy short circuit on macOS.
811        let diag_t0 = aube_util::diag::enabled().then(std::time::Instant::now);
812        let realized: &'static str;
813        match self.strategy {
814            // Two reflink strategies share the clonefile attempt and the
815            // macOS small-file copy shortcut, but differ in fallback:
816            //   * `Reflink` (explicit `clone` / `clone-or-copy`) — the
817            //     documented contract is reflink with a plain copy
818            //     fallback, so a clonefile failure degrades straight to
819            //     copy.
820            //   * `ReflinkAuto` (`auto` on a same-FS macOS target) — the
821            //     probe already proved the target shares a mount, so on a
822            //     non-APFS same-FS volume (HFS+, where `clonefile` is
823            //     unsupported but hardlinks are not) it tries a zero-cost
824            //     hardlink before copy.
825            LinkStrategy::Reflink | LinkStrategy::ReflinkAuto => {
826                let auto = matches!(self.strategy, LinkStrategy::ReflinkAuto);
827                #[cfg(target_os = "macos")]
828                if matches!(stored.size, Some(size) if size <= SMALL_FILE_COPY_MAX) {
829                    std::fs::copy(&stored.store_path, dst).map_err(map_io)?;
830                    if let Some(t0) = diag_t0 {
831                        aube_util::diag::event(
832                            aube_util::diag::Category::Linker,
833                            "link_macos_small_copy",
834                            t0.elapsed(),
835                            None,
836                        );
837                    }
838                    return Ok(());
839                }
840                let reflink_result = {
841                    #[cfg(test)]
842                    {
843                        if FORCE_REFLINK_FAILURE.load(std::sync::atomic::Ordering::Relaxed) {
844                            Err(std::io::Error::new(
845                                std::io::ErrorKind::Unsupported,
846                                "forced reflink failure (test)",
847                            ))
848                        } else {
849                            reflink_copy::reflink(&stored.store_path, dst)
850                        }
851                    }
852                    #[cfg(not(test))]
853                    {
854                        reflink_copy::reflink(&stored.store_path, dst)
855                    }
856                };
857                if let Err(e) = reflink_result {
858                    // Source-missing short-circuit avoids the misleading
859                    // "fell back to copy" trace and the redundant copy
860                    // attempt that would just ENOENT for the same reason.
861                    if !stored.store_path.exists() {
862                        return Err(missing_source());
863                    }
864                    // `auto` (ReflinkAuto) is the resolved strategy only
865                    // when the same-FS probe succeeded, so the target is
866                    // known same-filesystem. `reflink_copy::reflink` uses
867                    // `clonefile`, which is APFS-only: on an HFS+ volume it
868                    // fails even though the volume is same-FS and supports
869                    // hardlinks. There, try a hardlink before degrading to
870                    // a full per-file copy — a hardlink is zero-cost and
871                    // preserves the dedupe the probe promised, where copy
872                    // silently regresses to a byte transfer per file.
873                    // Explicit `clone` / `clone-or-copy` (Reflink) keep
874                    // their documented copy fallback and skip this step.
875                    //
876                    // Surface the hardlink attempt's OWN error in the copy
877                    // trace: if the auto hardlink itself fails (a cross-mount
878                    // edge the probe missed, a transient permission error), it
879                    // — not the original reflink error — is the proximate
880                    // cause of the copy, so reporting only `e` would point at
881                    // the wrong failure.
882                    let hardlinked = if auto {
883                        match std::fs::hard_link(&stored.store_path, dst) {
884                            Ok(()) => {
885                                trace!("reflink failed, fell back to hardlink: {e}");
886                                true
887                            }
888                            Err(he) => {
889                                trace!(
890                                    "reflink failed ({e}); hardlink fallback also failed ({he}); falling back to copy"
891                                );
892                                false
893                            }
894                        }
895                    } else {
896                        false
897                    };
898                    if hardlinked {
899                        realized = "reflink_fallback_hardlink";
900                    } else {
901                        if !auto {
902                            trace!("reflink failed, falling back to copy: {e}");
903                        }
904                        std::fs::copy(&stored.store_path, dst).map_err(map_io)?;
905                        realized = "reflink_fallback_copy";
906                    }
907                } else {
908                    realized = "reflink";
909                }
910            }
911            LinkStrategy::Hardlink => {
912                if let Err(e) = std::fs::hard_link(&stored.store_path, dst) {
913                    if !stored.store_path.exists() {
914                        return Err(missing_source());
915                    }
916                    // Fall back to copy on cross-filesystem errors (EXDEV)
917                    trace!("hardlink failed, falling back to copy: {e}");
918                    std::fs::copy(&stored.store_path, dst).map_err(map_io)?;
919                    realized = "hardlink_fallback_copy";
920                } else {
921                    realized = "hardlink";
922                }
923            }
924            LinkStrategy::Copy => {
925                std::fs::copy(&stored.store_path, dst).map_err(map_io)?;
926                realized = "copy";
927            }
928        }
929
930        if let Some(t0) = diag_t0 {
931            // `realized` is one of seven static strings; matching is
932            // O(1) and the static `&str` keeps the JSONL category compact.
933            let name = match realized {
934                "reflink" => "link_reflink",
935                "reflink_fallback_hardlink" => "link_reflink_fallback_hardlink",
936                "reflink_fallback_copy" => "link_reflink_fallback",
937                "hardlink" => "link_hardlink",
938                "hardlink_fallback_copy" => "link_hardlink_fallback",
939                "copy" => "link_copy",
940                "macos_small_copy" => "link_macos_small_copy",
941                _ => "link_unknown",
942            };
943            aube_util::diag::event(aube_util::diag::Category::Linker, name, t0.elapsed(), None);
944        }
945        Ok(())
946    }
947}
948
949/// Translate a copy failure into the most informative linker error.
950/// ENOENT can mean either side of the operation is missing — stat the
951/// source CAS shard to attribute it. A missing shard means the cached
952/// package index is out of sync with the on-disk store, which the
953/// caller can recover from by invalidating the cached index and
954/// re-importing the tarball.
955fn classify_link_error(
956    stored: &StoredFile,
957    rel_path: &str,
958    dst: &Path,
959    err: std::io::Error,
960) -> Error {
961    if err.kind() == std::io::ErrorKind::NotFound && !stored.store_path.exists() {
962        return Error::MissingStoreFile {
963            store_path: stored.store_path.clone(),
964            rel_path: rel_path.to_string(),
965        };
966    }
967    Error::Io(dst.to_path_buf(), err)
968}
969
970/// Best-effort drop the cached package index when materialize discovers
971/// its referenced CAS shard is gone. Callers always surface the original
972/// `MissingStoreFile` error first; this side effect just makes sure the
973/// next install miss `load_index` instead of looping on the same dead
974/// reference. If the cache write fails (e.g. permission error), warn
975/// loudly so the user knows the auto-recovery didn't take and they need
976/// to wipe the index dir by hand (run `aube store path` to find it).
977pub(crate) fn invalidate_stale_index_for_package(store: &aube_store::Store, pkg: &LockedPackage) {
978    match store.invalidate_cached_index(pkg.registry_name(), &pkg.version, pkg.integrity.as_deref())
979    {
980        Ok(true) => debug!("invalidated stale index for {}", pkg.spec_key()),
981        Ok(false) => {}
982        Err(e) => warn!(
983            "failed to invalidate stale index for {}: {e}; manual recovery: rm -rf \"$(aube store path)/index\"",
984            pkg.spec_key()
985        ),
986    }
987}
988
989/// Defence in depth for the tarball path-traversal class. The
990/// primary guard lives in `aube_store::import_tarball`, which
991/// refuses malformed entries before they enter the `PackageIndex`.
992/// This helper is the last check before `base.join(key)` is
993/// written through the linker, so an index loaded from a cache
994/// file that predates the store-side validation (or a bug that
995/// lets a traversing key slip past it) still cannot produce a
996/// file outside the package root.
997pub(crate) fn validate_index_key(key: &str) -> Result<(), Error> {
998    if key.is_empty()
999        || key.starts_with('/')
1000        || key.starts_with('\\')
1001        || key.contains('\0')
1002        || key.contains('\\')
1003    {
1004        return Err(Error::UnsafeIndexKey(key.to_string()));
1005    }
1006    // Reject any `..` component or Windows drive prefix like `C:`
1007    // that would make `Path::join` escape the base.
1008    for component in std::path::Path::new(key).components() {
1009        match component {
1010            std::path::Component::ParentDir
1011            | std::path::Component::RootDir
1012            | std::path::Component::Prefix(_) => {
1013                return Err(Error::UnsafeIndexKey(key.to_string()));
1014            }
1015            std::path::Component::Normal(os) => {
1016                #[cfg(windows)]
1017                {
1018                    if let Some(s) = os.to_str()
1019                        && s.contains(':')
1020                    {
1021                        return Err(Error::UnsafeIndexKey(key.to_string()));
1022                    }
1023                }
1024                #[cfg(not(windows))]
1025                {
1026                    let _ = os;
1027                }
1028            }
1029            std::path::Component::CurDir => {}
1030        }
1031    }
1032    Ok(())
1033}
1034
1035/// Validate a package/dependency alias before it becomes a path below
1036/// `node_modules`. npm names allow either `name` or `@scope/name`; every
1037/// other slash shape is a filesystem path, not a package slot.
1038pub(crate) fn validate_package_link_name(name: &str) -> Result<(), Error> {
1039    if name.is_empty() || name.contains('\0') || name.contains('\\') || name.starts_with('/') {
1040        return Err(Error::UnsafePackageName(name.to_string()));
1041    }
1042    let parts: Vec<&str> = name.split('/').collect();
1043    let ok = match parts.as_slice() {
1044        [bare] => is_safe_package_component(bare),
1045        [scope, bare] => {
1046            scope.starts_with('@')
1047                && scope.len() > 1
1048                && is_safe_package_component(scope)
1049                && is_safe_package_component(bare)
1050        }
1051        _ => false,
1052    };
1053    if ok {
1054        Ok(())
1055    } else {
1056        Err(Error::UnsafePackageName(name.to_string()))
1057    }
1058}
1059
1060fn is_safe_package_component(component: &str) -> bool {
1061    if component.is_empty() || matches!(component, "." | "..") {
1062        return false;
1063    }
1064    if component.len() >= 2 && component.as_bytes()[1] == b':' {
1065        return false;
1066    }
1067    !std::path::Path::new(component).components().any(|c| {
1068        matches!(
1069            c,
1070            std::path::Component::ParentDir
1071                | std::path::Component::RootDir
1072                | std::path::Component::Prefix(_)
1073        )
1074    })
1075}
1076
1077#[cfg(test)]
1078mod package_name_tests {
1079    use super::*;
1080
1081    #[test]
1082    fn validate_package_link_name_accepts_npm_slots() {
1083        validate_package_link_name("react").unwrap();
1084        validate_package_link_name("@scope/pkg").unwrap();
1085    }
1086
1087    #[test]
1088    fn validate_package_link_name_rejects_path_shapes() {
1089        for name in [
1090            "",
1091            ".",
1092            "..",
1093            "../evil",
1094            "@scope/../evil",
1095            "@scope/pkg/extra",
1096            "/abs",
1097            "C:evil",
1098            "pkg\\evil",
1099            "pkg\0evil",
1100        ] {
1101            assert!(
1102                matches!(
1103                    validate_package_link_name(name),
1104                    Err(Error::UnsafePackageName(_))
1105                ),
1106                "{name:?} should be rejected"
1107            );
1108        }
1109    }
1110}