Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.

Commit 481c571

Browse files
committed
fix(cmd-shim): apply review findings #4 and #5
#4 (cmd-shim — chmod target swallowed all errors): the post-write \`ensure_executable_bits\` call used \`let _ = ...\` which dropped \`PermissionDenied\`, \`EROFS\`, AppArmor deny and other real failures alongside the intentionally-ignored \`NotFound\`. Match \`NotFound\` explicitly and propagate everything else as \`LinkBinsError::Chmod\`, mirroring pnpm's \`fixBin\` ENOENT guard. Adds two DI-driven regression tests: - \`link_bins_propagates_target_chmod_error_via_di\` — \`PermissionDenied\` must surface as \`Chmod\`. - \`link_bins_swallows_target_chmod_not_found_via_di\` — \`NotFound\` must remain swallowed. #5 (package-manager — peer-resolved slot name parsing): \`find_slot_ own_package_dir\` used \`slot_name.rfind('@')\` to strip the version tail. That works for \`parent@1.0.0\` and \`@scope+parent@1.0.0\` but breaks for peer-resolved slots: \`to_virtual_store_name\` produces shapes like \`ts-node@10.9.1_@types+node@18.7.19_typescript@5.1.6\` where the last \`@\` is inside a peer's version, not at the package-name boundary. \`rfind\` then split inside the peer spec, the slot's own package wasn't found, and the slot was silently skipped — bins of children of every peer-resolved slot were never linked. Parse from the left instead, skipping a single leading \`@\` that belongs to a scoped package. The package-name half can never contain \`@\` after \`to_virtual_store_name\`'s scope normalization (\`/\` → \`+\`), so the first non-leading \`@\` is the right boundary. Adds \`link_virtual_store_bins_handles_peer_resolved_slot_name\` using the exact \`ts-node@10.9.1_@types+node@18.7.19_typescript@5.1.6\` shape verified by \`pacquet_lockfile::pkg_name_ver_peer::tests::to_virtual_ store_name\`. Both fixes verified by re-breaking the relevant code path and confirming the regression tests fail, then reverting. Drive-by from #9 (style nit on the chain split in \`SymlinkDirectDependencies::run\`): the chain was broken in the earlier \`feat(cmd-shim): port directories.bin discovery and Windows shim formats\` commit (dfdd33a) because \`direct_deps\` is consumed twice — once by \`symlink_direct_deps_into_node_modules\`, once by \`link_direct_dep_bins\`'s \`dep_names\` derivation. That can't sit inside a single chain. Documenting it here in lieu of amending the earlier commit. Test count: cmd-shim 70 → 72, package-manager (lib) 38 → 39.
1 parent 9ad2112 commit 481c571

4 files changed

Lines changed: 204 additions & 13 deletions

File tree

crates/cmd-shim/src/link_bins.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -296,11 +296,19 @@ where
296296
// mode without rewriting CRLF shebangs (a feature pnpm inherits from
297297
// npm's `bin-links/lib/fix-bin.js`). Targets shipped by npm already
298298
// use LF in practice, so the simpler chmod-only path is enough for
299-
// the install tests this PR ports. Errors here are swallowed —
300-
// a missing target shouldn't fail the install (this is post-warm-skip
301-
// territory) and pacquet has already verified `target_path` exists
302-
// upstream of `write_shim`.
303-
let _ = Api::ensure_executable_bits(target_path);
299+
// the install tests this PR ports. `NotFound` is swallowed because
300+
// the target may legitimately have been removed by an unrelated
301+
// process between extraction and shim linking; everything else
302+
// (`PermissionDenied`, `EROFS`, AppArmor deny, foreign uid)
303+
// surfaces as `LinkBinsError::Chmod` so real failures don't
304+
// disappear silently. Mirrors pnpm's `fixBin` ENOENT guard.
305+
match Api::ensure_executable_bits(target_path) {
306+
Ok(()) => {}
307+
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
308+
Err(error) => {
309+
return Err(LinkBinsError::Chmod { path: target_path.to_path_buf(), error });
310+
}
311+
}
304312

305313
Ok(())
306314
}

crates/cmd-shim/src/link_bins/tests.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,130 @@ fn link_bins_propagates_chmod_error_via_di() {
449449
assert!(matches!(err, LinkBinsError::Chmod { .. }));
450450
}
451451

452+
/// `write_shim` propagates a non-`NotFound` IO error from
453+
/// `ensure_executable_bits` (chmod on the *target* binary, not the
454+
/// shim). `NotFound` is swallowed by design — the target may have
455+
/// been removed concurrently — but `PermissionDenied` and friends
456+
/// must surface as `LinkBinsError::Chmod`. Pins the
457+
/// guard added in this PR (review finding #4).
458+
#[test]
459+
fn link_bins_propagates_target_chmod_error_via_di() {
460+
use std::io;
461+
struct FailingTargetChmod;
462+
impl FsReadDir for FailingTargetChmod {
463+
fn read_dir(_: &Path) -> io::Result<Vec<std::path::PathBuf>> {
464+
Ok(vec![])
465+
}
466+
}
467+
impl FsReadFile for FailingTargetChmod {
468+
fn read_file(_: &Path) -> io::Result<Vec<u8>> {
469+
unreachable!()
470+
}
471+
}
472+
impl FsReadString for FailingTargetChmod {
473+
fn read_to_string(_: &Path) -> io::Result<String> {
474+
Err(io::Error::from(io::ErrorKind::NotFound))
475+
}
476+
}
477+
impl FsReadHead for FailingTargetChmod {
478+
fn read_head(_: &Path, _: &mut [u8]) -> io::Result<usize> {
479+
Ok(0)
480+
}
481+
}
482+
impl FsCreateDirAll for FailingTargetChmod {
483+
fn create_dir_all(_: &Path) -> io::Result<()> {
484+
Ok(())
485+
}
486+
}
487+
impl FsWrite for FailingTargetChmod {
488+
fn write(_: &Path, _: &[u8]) -> io::Result<()> {
489+
Ok(())
490+
}
491+
}
492+
impl FsSetPermissions for FailingTargetChmod {
493+
fn set_executable(_: &Path) -> io::Result<()> {
494+
Ok(())
495+
}
496+
fn ensure_executable_bits(_: &Path) -> io::Result<()> {
497+
// The target chmod returns a non-`NotFound` error; the
498+
// implementation must surface it rather than silently
499+
// dropping it.
500+
Err(io::Error::from(io::ErrorKind::PermissionDenied))
501+
}
502+
}
503+
504+
let manifest = serde_json::json!({"name": "foo", "bin": "cli.js"});
505+
let tmp = tempdir().unwrap();
506+
let pkg = tmp.path().join("foo");
507+
fs::create_dir_all(&pkg).unwrap();
508+
fs::write(pkg.join("cli.js"), "").unwrap();
509+
let err = link_bins_of_packages::<FailingTargetChmod>(
510+
&[PackageBinSource { location: pkg, manifest }],
511+
&tmp.path().join(".bin"),
512+
)
513+
.expect_err("non-NotFound target chmod error must propagate as Chmod");
514+
assert!(matches!(err, LinkBinsError::Chmod { .. }));
515+
}
516+
517+
/// `write_shim` swallows `NotFound` from `ensure_executable_bits`
518+
/// because the target may legitimately be missing (concurrent removal,
519+
/// race with another install). Pins this distinction so a future
520+
/// regression that propagates `NotFound` here would fail the test.
521+
#[test]
522+
fn link_bins_swallows_target_chmod_not_found_via_di() {
523+
use std::io;
524+
struct NotFoundTargetChmod;
525+
impl FsReadDir for NotFoundTargetChmod {
526+
fn read_dir(_: &Path) -> io::Result<Vec<std::path::PathBuf>> {
527+
Ok(vec![])
528+
}
529+
}
530+
impl FsReadFile for NotFoundTargetChmod {
531+
fn read_file(_: &Path) -> io::Result<Vec<u8>> {
532+
unreachable!()
533+
}
534+
}
535+
impl FsReadString for NotFoundTargetChmod {
536+
fn read_to_string(_: &Path) -> io::Result<String> {
537+
Err(io::Error::from(io::ErrorKind::NotFound))
538+
}
539+
}
540+
impl FsReadHead for NotFoundTargetChmod {
541+
fn read_head(_: &Path, _: &mut [u8]) -> io::Result<usize> {
542+
Ok(0)
543+
}
544+
}
545+
impl FsCreateDirAll for NotFoundTargetChmod {
546+
fn create_dir_all(_: &Path) -> io::Result<()> {
547+
Ok(())
548+
}
549+
}
550+
impl FsWrite for NotFoundTargetChmod {
551+
fn write(_: &Path, _: &[u8]) -> io::Result<()> {
552+
Ok(())
553+
}
554+
}
555+
impl FsSetPermissions for NotFoundTargetChmod {
556+
fn set_executable(_: &Path) -> io::Result<()> {
557+
Ok(())
558+
}
559+
fn ensure_executable_bits(_: &Path) -> io::Result<()> {
560+
Err(io::Error::from(io::ErrorKind::NotFound))
561+
}
562+
}
563+
564+
let manifest = serde_json::json!({"name": "foo", "bin": "cli.js"});
565+
let tmp = tempdir().unwrap();
566+
let pkg = tmp.path().join("foo");
567+
fs::create_dir_all(&pkg).unwrap();
568+
fs::write(pkg.join("cli.js"), "").unwrap();
569+
link_bins_of_packages::<NotFoundTargetChmod>(
570+
&[PackageBinSource { location: pkg, manifest }],
571+
&tmp.path().join(".bin"),
572+
)
573+
.expect("NotFound on target chmod must be swallowed silently");
574+
}
575+
452576
/// `link_bins_of_packages` propagates a non-`NotFound` IO error from
453577
/// `search_script_runtime` (the `ProbeShimSource` variant). Forced via
454578
/// a fake `FsReadHead` that fails with permission-denied — the wider

crates/package-manager/src/link_bins.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,29 @@ impl<'a> LinkVirtualStoreBins<'a> {
152152
/// Locate the slot's own package directory inside `<slot>/node_modules`.
153153
///
154154
/// The slot directory's name encodes the package name as
155-
/// `<scope>+<name>@<version>` (see [`pacquet_lockfile::PkgNameVerPeer::to_virtual_store_name`]).
156-
/// The own-package directory is the one whose location is a real directory
157-
/// (not a symlink) and whose path matches the slot name decoded back to
158-
/// `<scope>/<name>` form.
155+
/// `<scope>+<name>@<version>` for the simple case (see
156+
/// [`pacquet_lockfile::PkgNameVerPeer::to_virtual_store_name`]). For
157+
/// peer-resolved slots the version segment itself contains additional
158+
/// `@`-separated peer specs joined by `_`, e.g.
159+
/// `ts-node@10.9.1_@types+node@18.7.19_typescript@5.1.6` — the `@` after
160+
/// `typescript` is part of a peer's version, not the package-name
161+
/// boundary. Parsing from the right (`rfind('@')`) would split there
162+
/// and silently break peer-resolved slots; parse from the left
163+
/// instead, skipping a leading `@` that belongs to a scoped package.
159164
fn find_slot_own_package_dir(slot_dir: &Path, modules_dir: &Path) -> Option<PathBuf> {
160165
let slot_name = slot_dir.file_name()?.to_str()?;
161-
// Strip the `@<version>` tail.
162-
let at = slot_name.rfind('@')?;
166+
167+
// The package-name half is everything before the **first** `@`,
168+
// ignoring a single leading `@` that belongs to a scoped name
169+
// (`@scope+pkg@...` → start the `@` search at offset 1).
170+
// After `to_virtual_store_name`, `/` in scoped names becomes `+`,
171+
// so the package-name half can never contain `@` itself.
172+
let search_start = if slot_name.starts_with('@') { 1 } else { 0 };
173+
let at = search_start + slot_name[search_start..].find('@')?;
163174
let name_part = &slot_name[..at];
164-
// `+` separates `<scope>+<name>` for scoped packages; non-scoped names
165-
// contain no `+`.
175+
176+
// `+` separates `<scope>+<name>` for scoped packages; non-scoped
177+
// names contain no `+`.
166178
let pkg_dir = match name_part.split_once('+') {
167179
Some((scope, name)) => modules_dir.join(scope).join(name),
168180
None => modules_dir.join(name_part),

crates/package-manager/src/link_bins/tests.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,53 @@ fn link_virtual_store_bins_handles_scoped_slot_name() {
147147
assert!(shim.exists(), "scoped-slot bin linking must produce a shim at {shim:?}");
148148
}
149149

150+
/// Peer-resolved slots have version segments that contain additional
151+
/// `@` characters (one per peer spec). `find_slot_own_package_dir`
152+
/// must parse the package-name boundary from the **left**, not the
153+
/// right, otherwise it splits inside a peer spec and silently fails
154+
/// to locate the own package — bins of children of the slot then
155+
/// never get linked.
156+
///
157+
/// Slot name shape verified against
158+
/// `pacquet_lockfile::pkg_name_ver_peer::tests::to_virtual_store_name`.
159+
/// Pins review finding #5.
160+
#[test]
161+
fn link_virtual_store_bins_handles_peer_resolved_slot_name() {
162+
let tmp = tempdir().unwrap();
163+
let virtual_dir = tmp.path().join(".pacquet");
164+
165+
// pnpm format: `ts-node@10.9.1(@types/node@18.7.19)(typescript@5.1.6)`.
166+
// Pacquet's `to_virtual_store_name`: `_` joins peers, `(` and `)`
167+
// are stripped, `/` becomes `+`.
168+
let slot = virtual_dir.join("ts-node@10.9.1_@types+node@18.7.19_typescript@5.1.6");
169+
let modules = slot.join("node_modules");
170+
let pkg_dir = modules.join("ts-node");
171+
let child_dir = modules.join("child");
172+
fs::create_dir_all(&pkg_dir).unwrap();
173+
fs::create_dir_all(&child_dir).unwrap();
174+
175+
fs::write(
176+
pkg_dir.join("package.json"),
177+
json!({"name": "ts-node", "version": "10.9.1"}).to_string(),
178+
)
179+
.unwrap();
180+
fs::write(
181+
child_dir.join("package.json"),
182+
json!({"name": "child", "version": "1.0.0", "bin": "cli.js"}).to_string(),
183+
)
184+
.unwrap();
185+
fs::write(child_dir.join("cli.js"), "#!/usr/bin/env node\n").unwrap();
186+
187+
LinkVirtualStoreBins { virtual_store_dir: &virtual_dir }.run().unwrap();
188+
189+
let shim = pkg_dir.join("node_modules/.bin/child");
190+
assert!(
191+
shim.exists(),
192+
"peer-resolved slot's child bin must be linked under the own package's `node_modules/.bin`, \
193+
not silently dropped because the slot-name parser split inside a peer spec",
194+
);
195+
}
196+
150197
/// A virtual-store slot whose `node_modules/` is missing must be skipped
151198
/// without error.
152199
#[test]

0 commit comments

Comments
 (0)