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}