Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions src/install/PackageInstaller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,25 +364,26 @@ fn abs_node_modules_path(
abs
}

/// A dependency alias becomes the install destination inside `node_modules`
/// (the existing entry is renamed aside, deleted, and re-created). Reject
/// anything that could escape `node_modules`: empty names, `.`/`..`
/// components, absolute paths, drive letters, backslashes, NUL bytes, and any
/// separator other than the single `/` in a scoped name (`@scope/name`).
/// A dependency alias is the install destination inside `node_modules`.
pub(crate) fn alias_is_safe_install_target(alias: &[u8]) -> bool {
if alias.is_empty() || alias.len() >= MAX_PATH_BYTES || strings::contains_any(alias, b"\\:\0") {
alias.len() < MAX_PATH_BYTES && crate::dependency::is_valid_node_modules_entry_name(alias)
}

/// The path rules alone, for the own name of the project, a workspace or a folder dependency.
pub(crate) fn name_is_single_path_entry(name: &[u8]) -> bool {
if name.is_empty() || name.len() >= MAX_PATH_BYTES || strings::contains_any(name, b"\\:\0") {
return false;
}

let mut component_count = 0usize;
for component in strings::split(alias, b"/") {
for component in strings::split(name, b"/") {
component_count += 1;
if component.is_empty() || component == b"." || component == b".." {
return false;
}
}

component_count == 1 || (component_count == 2 && alias[0] == b'@')
component_count == 1 || (component_count == 2 && name[0] == b'@')
}

/// Formats the version label `PackageInstall` verifies and hashes patches
Expand Down
4 changes: 4 additions & 0 deletions src/install/PackageManager/install_with_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,10 @@ pub fn install_with_manager(
}
};
let lockfile_before_clean = core::mem::replace(&mut manager.lockfile, new_lockfile);
// The log was reset above, so this is a name the tree builder refused: stop before the linker.
if manager.log_mut().has_errors() {
manager.crash();
}
if manager.subcommand == Subcommand::Update && !manager.options.dry_run {
Output::flush();
crate::update_transitive::warn_orphaned_patches(manager);
Expand Down
21 changes: 21 additions & 0 deletions src/install/dependency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,27 @@ pub(crate) fn is_safe_install_folder_name(name: &[u8]) -> bool {
true
}

/// `name` or `@scope/name`, where neither half is a directory the installer owns.
pub(crate) fn is_valid_node_modules_entry_name(name: &[u8]) -> bool {
if !is_safe_install_folder_name(name) {
return false;
}

let package = match name.strip_prefix(b"@") {
Some(scoped) => match strings::index_of_char_usize(scoped, b'/') {
Some(slash) if slash > 0 => &scoped[slash + 1..],
_ => return false,
},
None => name,
};

// On a case-insensitive volume `NODE_MODULES` is `node_modules`.
!strings::contains_char(package, b'/')
&& !name.starts_with(b".")
&& !package.starts_with(b".")
&& !package.eq_ignore_ascii_case(b"node_modules")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: On a case-insensitive macOS volume a registry package can still land its payload at node_modules/node_modules and have a trusted postinstall run its node. The new gate at src/install/dependency.rs:572 compares the package half with eq_ignore_ascii_case, but APFS folds Unicode too: the key node_moduleſ (U+017F long s) passes the check, yet the filesystem treats node_modules/node_moduleſ/.bin as node_modules/node_modules/.bin, which src/install/PackageManager/PackageManagerLifecycle.rs:386 puts on every lifecycle script's PATH. Fix: compare the package half against node_modules after Unicode simple case folding, not ASCII folding only.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A registry package carrier declares "node_moduleſ": "npm:payload@ 1.0.0" where the last character is U+017F; payload ships .bin/node. The project installs carrier and a trusted package with a node-invoking postinstall on macOS, whose default APFS volume is case-insensitive and folds names with Unicode CaseFolding. is_valid_node_modules_entry_name at src/install/dependency.rs:560-572 takes package = name, and package.eq_ignore_ascii_case(b"node_modules") is false, so the name is accepted by the tree builder (src/install/lockfile/Tree.rs:749) and the hoisted alias gate. PackageManagerLifecycle.rs:383-390 appends <dir>/node_modules/.bin for every ancestor of the script cwd, so a script in node_modules/native has <project>/node_modules/node_modules/.bin on PATH; the APFS lookup matches the folded node_moduleſ entry, and node resolves to the payload. The base branch has the same exposure for the plain node_modules key, which this PR closes; the stated case-insensitivity defense (comment at dependency.rs:568) does not hold on the volumes it targets.

Verification: src/install/dependency.rs:572 checks !package.eq_ignore_ascii_case(b"node_modules"), which only folds ASCII, so node_moduleſ (U+017F) passes and is accepted by Tree.rs:749, PackageInstaller.rs:368-370 and extract_tarball.rs:474. PackageManagerLifecycle.rs:381-390 puts <dir>/node_modules/.bin for every ancestor of the script cwd on PATH; APFS folds U+017F to s, so the payload's node runs.

}

/// assumes version is valid
pub fn without_build_tag(version: &[u8]) -> &[u8] {
if let Some(plus) = strings::index_of_char(version, b'+') {
Expand Down
2 changes: 1 addition & 1 deletion src/install/extract_tarball.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ impl ExtractTarball {
// The entire body lives inside the thread_local borrow closure.
let folder_name: &[u8] = match self.resolution.tag {
ResolutionTag::Npm => {
if !bun_install::dependency::is_safe_install_folder_name(name) {
if !bun_install::dependency::is_valid_node_modules_entry_name(name) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Pre-existing, security: an untrusted registry package can still get its own node onto a trusted postinstall's PATH by shipping node_modules/.bin/node inside its tarball, so the boundary this PR closes stays open on that route. The gate at extract_tarball.rs:474 checks only the package name; npm-mode extraction (src/libarchive/lib.rs:1486) writes every file entry with its exec bits, including node_modules/.bin/node under the package folder. Fix: during npm extraction skip or strip exec bits from entries under any node_modules/.bin (or any node_modules/ subtree not listed in bundleDependencies), so no tarball content can land on a lifecycle PATH.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A registry package carrier publishes a tarball containing package/node_modules/.bin/node with mode 755 and declares "dependencies": {"esbuild": "0.17.0"} while the project already resolves a different esbuild version. Extraction in npm mode keeps every regular file entry (src/libarchive/lib.rs:1486-1488) and applies (perm & 0o777) | 0o666 (src/libarchive/lib.rs:1685), so the cache folder and then node_modules/carrier/ hold an executable node_modules/.bin/node. The hoisted tree nests the conflicting copy at node_modules/carrier/node_modules/esbuild; esbuild is in src/install/default-trusted-dependencies.txt and is trusted by name at any depth. Its postinstall node install.js runs with PATH built from every ancestor's node_modules/.bin (src/install/PackageManager/PackageManagerLifecycle.rs:383-390), which includes node_modules/carrier/node_modules/.bin, so the payload executes as node. The new is_valid_node_modules_entry_name gates only dependency keys and package names (src/install/extract_tarball.rs:474), never tarball contents, so the base branch and this PR behave the same here.

Verification: pre-existing. Trigger: an untrusted registry tarball contains package/node_modules/.bin/node (mode 755) and a default-trusted package gets nested beneath it in the hoisted tree. The gate at src/install/extract_tarball.rs:474 checks the package NAME; npm-mode extraction (src/libarchive/lib.rs:1483-1488) only drops non-file entries, so node_modules/.bin/node lands executable.

log.add_error_fmt(
None,
bun_ast::Loc::EMPTY,
Comment on lines +474 to 477

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) A registry (private mirror, proxy, or compromised registry) serving a package named .bin, node_modules or @ scope is refused at extraction, but the install still runs the full linker and every trusted lifecycle script before exiting 1, so the PR's "stops before the linker, no script" guarantee does not hold for this gate. extract_tarball.rs:474-483 adds the error during resolution; install_with_manager.rs:661-662 prints and resets the log, and only :926 fails the command after install_hoisted_packages/install_isolated_packages ran. Fix: treat a name refusal from extraction like the tree gate and crash before the linker (check had_errors_before_cleaning_lockfile at :689, or record the refusal and abort before :863). [also at: src/install/isolated_install.rs:2155 - Isolated-linker users whose git or tarball dependency names itself .bin, node_modules or @ scope get a half-written node_modules/.bun and exit 1, not the clean stop the PR promises.]

Why this was flagged

A dependency "x": "1.0.0" whose registry tarball manifest resolves to a package whose name is .bin or @ acme/node_modules (only a non-npm registry can serve one). During resolve_pending_tasks the extraction reaches move_to_cache_directory, is_valid_node_modules_entry_name fails at extract_tarball.rs:474 and the error goes to the log. At install_with_manager.rs:658 had_errors_before_cleaning_lockfile is set, the log is printed and reset at :661-662, and the new check at :689 sees no errors so the install proceeds to the linker at :863-919, laying out every other package and running trusted postinstalls, then exits 1 at :926-928. The base branch behaved the same for path-unsafe names, but this PR newly routes .bin/node_modules/@ scope registry names through this gate and the description claims the refusal halts before linking. Remedy: abort at :689 when had_errors_before_cleaning_lockfile is true for a name refusal.

Verification: nit — triggered when a dependency's resolved name is refused, e.g. "helper": "npm:.bin@ 0.0.3". .bin is only refused in src/install/extract_tarball.rs:474-483. install_with_manager.rs:661-662 prints and resets the log, so the early stop at :689-691 sees no errors, the linker runs, lifecycle scripts of trusted packages run, and only :926-928 crashes. Nothing new breaks relative to base.

Expand Down
15 changes: 13 additions & 2 deletions src/install/isolated_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2121,8 +2121,19 @@ pub(crate) fn install_isolated_packages(
{
let mut unsafe_folder_name: Option<&[u8]> = None;
let name = pkg_name.slice(string_buf);
if !name.is_empty() && !crate::package_installer::alias_is_safe_install_target(name)
{
// Only these kinds are also linked under their own name, in `.bun/node_modules`.
let own_name_is_safe = name.is_empty()
|| match pkg_res.tag {
ResolutionTag::Npm
| ResolutionTag::Git
| ResolutionTag::Github
| ResolutionTag::LocalTarball
| ResolutionTag::RemoteTarball => {
crate::package_installer::alias_is_safe_install_target(name)
}
_ => crate::package_installer::name_is_single_path_entry(name),
};
if !own_name_is_safe {
unsafe_folder_name = Some(name);
} else {
for dep in entry_dependencies[entry_id.get() as usize].slice() {
Expand Down
21 changes: 11 additions & 10 deletions src/install/lockfile/Tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,7 @@ impl<'a, const PATH_STYLE: IteratorPathStyle> Iterator<'a, PATH_STYLE> {
}
}

/// Tree folder names are joined into install destinations as
/// `node_modules/<name>/...`; this path and the tree builder must agree on the
/// same validator.
/// A tree folder name is joined onto `node_modules/`, so it has to stay inside it.
pub(crate) fn folder_name_is_safe(name: &[u8]) -> bool {
crate::dependency::is_safe_install_folder_name(name)
}
Expand Down Expand Up @@ -748,14 +746,17 @@ impl Tree {
.name
.slice(lockfile.buffers.string_bytes.as_slice());
if !dependency_name.is_empty()
&& !crate::dependency::is_safe_install_folder_name(dependency_name)
&& !crate::dependency::is_valid_node_modules_entry_name(dependency_name)
{
builder.maybe_report_error(format_args!(
"Invalid dependency name \"{}\"",
dependency
.name
.fmt(lockfile.buffers.string_bytes.as_slice()),
));
// An unresolved dependency gets no folder, and the resolver has reported it.
if pkg_id != invalid_package_id {
Comment on lines 748 to +752

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Users with a refused dependency key that did not resolve now get no Invalid dependency name message at all, so the key survives in package.json and bun.lock unnoticed. Tree.rs:748-761 skips the report whenever pkg_id == invalid_package_id, but the resolver only reports non-optional failures; an optionalDependencies key like .bin or @ acme that fails to resolve, or a dev key under --production, is silent. Fix: report the refused name regardless of pkg_id (or downgrade to a warning for unresolved entries) so the user learns the key is invalid before it later resolves and aborts the install.

Why this was flagged

package.json holds "optionalDependencies": {".bin": "npm:payload@ 9.9.9"} (or "@ acme": "...") where the version does not exist yet, or a devDependency with a refused key installed with --production. In Resolvable mode (lockfile.rs:1384 via clean_with_logger) builder.resolutions[dep_id] is invalid_package_id, so the new guard at Tree.rs:752 suppresses maybe_report_error and continue 'dep at :760 hides it; the resolver emitted at most a warning for the optional failure. On the base branch Tree.rs printed Invalid dependency name ".bin" for every such key and the install failed. Now the install succeeds and the key is written to bun.lock; when the package later becomes resolvable (publish, or a non-production install) the same project suddenly exits 1 at install_with_manager.rs:689 with no earlier signal. Ten finders flagged this and dismissed it on the ground that no folder is created, which addresses the security property but not the lost diagnostic the author relied on for user-written keys.

Verification: Triggered when a refused key (.bin, @ acme) in optionalDependencies fails to resolve. src/install/lockfile/Tree.rs:748-761 reports Invalid dependency name only if pkg_id != invalid_package_id, but PackageManagerResolution.rs:333-336 skips optional failures silently, so no message appears; on the base branch the report was unconditional on pkg_id.

builder.maybe_report_error(format_args!(
"Invalid dependency name \"{}\"",
dependency
.name
.fmt(lockfile.buffers.string_bytes.as_slice()),
));
}
continue 'dep;
}

Expand Down
4 changes: 3 additions & 1 deletion src/install/lockfile/bun.lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3262,7 +3262,9 @@ pub(crate) fn parse_into_binary_lockfile(
.is_none_or(|manager| manager.options.link_workspace_packages),
);

if let Err(tree::SubtreeError::OutOfMemory) = lockfile.resolve(log) {
// The install reports a refused name itself, after it applies package.json.
let mut refused_names = bun_ast::Log::init();
if let Err(tree::SubtreeError::OutOfMemory) = lockfile.resolve(&mut refused_names) {
Comment on lines +3265 to +3267

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Users migrating a package-lock.json or pnpm-lock.yaml whose root entry still holds a name the new rule refuses, after package.json dropped it, get a full install, then exit 1 and no bun.lock, on every run. Only the bun.lock loader got the throwaway log; migration.rs:406 and pnpm.rs:1621 still pass the shared log to resolve, so install_with_manager.rs:658 records the stale error and :926 crashes after the linker and lifecycle scripts ran. Fix: every lockfile loader that rebuilds the tree keeps the name report to itself, as bun.lock.rs:3267 does, so only the re-check in clean_with_logger decides; names package.json still holds must keep failing at :689.

Why this was flagged

Trigger: a project with no bun.lock but a package-lock.json (or pnpm-lock.yaml) whose root entry still declares a key the new predicate refuses but npm/pnpm accept, e.g. "@ acme/node_modules", while package.json no longer has it. Entry point: this.resolve(log) at src/install/migration.rs:406 (pnpm: src/install/pnpm.rs:1621). The tree builder at src/install/lockfile/Tree.rs:753 adds "Invalid dependency name" to the shared log; report_migrated (migration.rs:237) only resets the log when it has no errors, so the error survives. In install_with_manager.rs:658 had_errors_before_cleaning_lockfile becomes true. The diff and clean drop the stale dependency, so the new gate at :689 does not fire; the hoisted or isolated linker runs, lifecycle scripts run, then :926 crashes on the stale flag: exit 1, no bun.lock written. The PR fixed exactly this recovery problem for bun.lock (throwaway Log at bun.lock.rs:3266-3267) but not for the two migration loaders, which call the same resolve(). On the base branch these names were accepted, so the migration succeeded.

Verification: The migration loaders hand the shared log to the tree builder: src/install/migration.rs:406 this.resolve(log)?;, src/install/pnpm.rs:1621 lockfile.resolve(log)?;. Tree.rs:753-760 adds Invalid dependency name to it, so install_with_manager.rs:658 sets had_errors_before_cleaning_lockfile = true, the early stop at 688-691 does NOT fire, and install_with_manager.rs:926-928 exits 1 after the linker, before save_lockfile at 961-962.

return Err(ParseError::OutOfMemory);
}
}
Expand Down
221 changes: 221 additions & 0 deletions test/cli/install/bun-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10275,6 +10275,227 @@ it("rejects dependency aliases containing '..' path segments", async () => {
});
});

// Serves `bar` as a registry package whose manifest declares `dependencies`,
// and every other name as a package with no dependencies.
function registryWhereBarDependsOn(ctx: TestContext, urls: string[], dependencies: Record<string, string>) {
const bar = dummyRegistryForContext(ctx, urls, { "0.0.2": { dependencies } });
const others = dummyRegistryForContext(ctx, urls, { "0.0.3": {} });
return (request: Request) => (new URL(request.url).pathname.endsWith("/bar") ? bar(request) : others(request));
}

// The key of a dependency becomes a path under node_modules, and a registry
// package chooses the keys of its own dependencies. A key that names an entry
// the installer owns would unpack the target there: `.bin` is on the PATH of
// every lifecycle script, so is the `.bin` of a nested `node_modules`, and a
// bare `@scope` is the directory the project's own `@scope/*` packages live in.
// Every key here is a directory the installer owns, or a shape that would make
// one. `@barn/node_modules` matters for the same reason `node_modules` does: a
// lifecycle script runs with the `node_modules/.bin` of every directory above
// it on its PATH, so the payload would reach the scripts of `@barn/*`.
describe.concurrent.each([
".bin",
"@barn/.bin",
"node_modules",
"Node_Modules",
"@barn/node_modules",
"@barn",
"@/baz",
"@barn/baz/extra",
])("a dependency named %p", key => {
it.each([
["a registry package", "hoisted"],
["a registry package", "isolated"],
["the project", "hoisted"],
["the project", "isolated"],
])("declared by %s is refused before anything is installed (%s linker)", async (declarer, linker) => {
await withContext({ linker: linker as "hoisted" | "isolated" }, async ctx => {
const urls: string[] = [];
const target = { [key]: "npm:baz@0.0.3" };
const fromRegistry = declarer === "a registry package";
setContextHandler(ctx, registryWhereBarDependsOn(ctx, urls, fromRegistry ? target : {}));
await Promise.all([
writeFile(
join(ctx.package_dir, "package.json"),
JSON.stringify({
name: "foo",
version: "0.0.1",
// `bar` is a normal package that must install when nothing is refused
dependencies: fromRegistry ? { bar: "0.0.2" } : { ...target, bar: "0.0.2" },
scripts: { postinstall: [bunExe(), "foo-postinstall.js"].join(" ") },
}),
),
writeFile(
join(ctx.package_dir, "foo-postinstall.js"),
'require("fs").writeFileSync("foo-postinstall.txt", "ran");',
),
]);

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: ctx.package_dir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect({
errors: [...new Set(err.split(/\r?\n/).filter(line => line.startsWith("error:")))],
// nothing is unpacked at the reserved path
unpacked: await exists(join(ctx.package_dir, "node_modules", key, "package.json")),
// the install stops before the linker, so the good package beside the
// refused one is not laid out either
otherInstalled: await exists(join(ctx.package_dir, "node_modules", "bar", "package.json")),
// and no lifecycle script runs
postinstall: await exists(join(ctx.package_dir, "foo-postinstall.txt")),
installed: out.includes("installed"),
exitCode,
}).toEqual({
errors: [`error: Invalid dependency name "${key}"`],
unpacked: false,
otherInstalled: false,
postinstall: false,
installed: false,
exitCode: 1,
});
});
});
});

it("installs after package.json drops a name that bun.lock still holds", async () => {
// bun.lock as an older bun wrote it, when the key `@src` was accepted. The
// project has since renamed that dependency. The stale name is in the file
// that is loaded, not in the graph that is installed.
using dir = tempDir("stale-lockfile-name", {
"package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "src-alias": "file:./src" } }),
"src/package.json": JSON.stringify({ name: "src", version: "1.0.0" }),
"bun.lock": `{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "app",
"dependencies": {
"@src": "file:./src",
},
},
},
"packages": {
"@src": ["src@file:src", {}],
}
}
`,
});

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env,
});
const [, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect({
errors: err.split(/\r?\n/).filter(line => line.startsWith("error:")),
linked: await exists(join(String(dir), "node_modules", "src-alias", "package.json")),
// the lockfile is written again, without the name
lockfileHoldsStaleName: (await file(join(String(dir), "bun.lock")).text()).includes('"@src"'),
exitCode,
}).toEqual({ errors: [], linked: true, lockfileHoldsStaleName: false, exitCode: 0 });
});

it("refuses a registry package whose own name is an entry of the installer", async () => {
await withContext(defaultOpts, async ctx => {
const urls: string[] = [];
// The alias is fine, so the tree builder accepts it. The name the registry
// serves is the one that would become the cache folder.
const manifests = dummyRegistryForContext(ctx, urls, { "0.0.3": {} });
setContextHandler(ctx, request =>
request.url.endsWith(".tgz") ? new Response(file(join(import.meta.dir, "baz-0.0.3.tgz"))) : manifests(request),
);
await writeFile(
join(ctx.package_dir, "package.json"),
JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { helper: "npm:.bin@0.0.3" } }),
);

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: ctx.package_dir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(err).toContain('Refusing to install package with invalid name ".bin"');
expect(out).not.toContain("installed");
expect(exitCode).not.toBe(0);
});
});

it("still installs an alias that names no entry of the installer", async () => {
await withContext(defaultOpts, async ctx => {
const urls: string[] = [];
setContextHandler(ctx, dummyRegistryForContext(ctx, urls, { "0.0.3": {} }));
// npm refuses a leading underscore and a leading hyphen too. Neither names
// a directory the installer uses, so these keep working.
const aliases = ["_baz", "-baz", "baz.js", "@barn/baz"];
await writeFile(
join(ctx.package_dir, "package.json"),
JSON.stringify({
name: "foo",
version: "0.0.1",
dependencies: Object.fromEntries(aliases.map(alias => [alias, "npm:baz@0.0.3"])),
}),
);

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: ctx.package_dir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(err).not.toContain("error:");
// One package, under four names.
expect(out).toContain("1 package installed");
expect(
await Promise.all(aliases.map(alias => exists(join(ctx.package_dir, "node_modules", alias, "package.json")))),
).toEqual([true, true, true, true]);
expect(exitCode).toBe(0);
});
});

it("still links a bin whose name starts with a period", async () => {
await withContext(defaultOpts, async ctx => {
const urls: string[] = [];
// A bin name is not a node_modules entry. npm links this one, and so does bun.
setContextHandler(ctx, dummyRegistryForContext(ctx, urls, { "0.0.3": { bin: { ".baz-run": "index.js" } } }));
await writeFile(
join(ctx.package_dir, "package.json"),
JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { baz: "0.0.3" } }),
);

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: ctx.package_dir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(err).not.toContain("error:");
expect(out).toContain("1 package installed");
expect(await readdirSorted(join(ctx.package_dir, "node_modules", ".bin"))).toHaveBins([".baz-run"]);
expect(join(ctx.package_dir, "node_modules", ".bin", ".baz-run")).toBeValidBin(join("..", "baz", "index.js"));
expect(exitCode).toBe(0);
});
});

it("does not extract a tarball for a dependency alias containing '..' path segments", async () => {
await withContext(defaultOpts, async ctx => {
const urls: string[] = [];
Expand Down
Loading
Loading