From bdc16b4071890a41ebddcd8b8b6b313ffef270e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:50:19 +0000 Subject: [PATCH 1/8] pack: do not pack bins that are symlinks or sit behind a symlinked directory The tree walk never packs symlinks (matching npm-packlist), but bin and directories.bin entries bypass the walk and were opened directly, so a symlinked bin was packed with the contents of its target, including targets outside the package. lstat every component of a bin path and only force-add it when it is a regular file (or a real directory for directories.bin) reached through real directories. A bin entry that names a directory is now ignored instead of failing the pack with EISDIR, which is also what npm does. --- src/runtime/cli/pack_command.rs | 37 +++++++++++++ test/cli/install/bun-pack.test.ts | 91 ++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index a1ce7dd2c0e1..d79184ea385e 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1528,6 +1528,35 @@ fn bin_path_escapes_root(p: &[u8]) -> bool { path::is_absolute_loose(p) || p == b".." || p.starts_with(b"../") } +/// Bins are added to the pack queue directly instead of being found by the +/// tree walk, which never packs symlinks (like npm-packlist). Apply the same +/// rule here: a bin reached through a symlink in any path component would +/// otherwise be packed with the contents of whatever the link points at, +/// possibly outside the package. Returns `Unknown` if the path does not +/// exist or a parent component is not a real directory. +fn bin_kind_without_following_symlinks(root_dir: &Dir, bin_path: &[u8]) -> bun_sys::FileKind { + // A trailing slash would make `lstat` resolve a symlink to a directory. + let bin_path = strings::without_trailing_slash(bin_path); + let mut end = 0; + loop { + end += match strings::index_of_char_usize(&bin_path[end..], b'/') { + Some(i) => i, + None => bin_path.len() - end, + }; + let kind = match bun_sys::lstatat(root_dir, &ZBox::from_bytes(&bin_path[..end])) { + Ok(stat) => bun_sys::kind_from_mode(stat.st_mode as bun_sys::Mode), + Err(_) => return bun_sys::FileKind::Unknown, + }; + if end == bin_path.len() { + return kind; + } + if kind != bun_sys::FileKind::Directory { + return bun_sys::FileKind::Unknown; + } + end += 1; + } +} + fn is_package_bin(bins: &[BinInfo], maybe_bin_path: &[u8]) -> bool { for bin in bins { match bin.ty { @@ -2268,6 +2297,14 @@ pub(crate) fn pack( let bins = get_package_bins(&json.root)?; for bin in &bins { + let expected_kind = match bin.ty { + BinType::File => bun_sys::FileKind::File, + BinType::Dir => bun_sys::FileKind::Directory, + }; + if bin_kind_without_following_symlinks(&root_dir, bin.path.as_bytes()) != expected_kind { + continue; + } + match bin.ty { BinType::File => { pack_queue.add(PackQueueItem { diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 77fe7a55685a..bc8262fc7ef1 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -1,8 +1,8 @@ import { file, spawn, write } from "bun"; import { readTarball } from "bun:internal-for-testing"; import { beforeEach, describe, expect, test } from "bun:test"; -import { exists, mkdir, rm } from "fs/promises"; -import { bunEnv, bunExe, pack, runBunInstall, tempDir, tmpdirSync } from "harness"; +import { exists, mkdir, rm, symlink } from "fs/promises"; +import { bunEnv, bunExe, isWindows, pack, runBunInstall, tempDir, tmpdirSync } from "harness"; import fs from "node:fs/promises"; import { join } from "path"; @@ -1597,6 +1597,93 @@ describe("bins", () => { }, ]); }); + + test("pointing at a directory is ignored", async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-bins-dir-as-file", + version: "1.0.0", + bin: "lib", + }), + ), + write(join(packageDir, "index.js"), "console.log('index')"), + write(join(packageDir, "lib", "a.js"), "console.log('a')"), + ]); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-bins-dir-as-file-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([ + { pathname: "package/package.json" }, + { pathname: "package/index.js" }, + { pathname: "package/lib/a.js" }, + ]); + }); + + // Symlinks are never packed (same as npm). Bins are no exception: packing one + // would copy the link target, which may live outside the package. + test.skipIf(isWindows)("that are symlinks, or behind a symlinked directory, are not packed", async () => { + const pkgDir = join(packageDir, "pkg"); + await Promise.all([ + write(join(packageDir, "outside", "secret.js"), "console.log('outside the package')"), + write(join(packageDir, "outside", "nested", "inner.js"), "console.log('outside the package')"), + write( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "pack-bins-symlink", + version: "1.0.0", + files: ["index.js"], + bin: { + "real": "real-bin.js", + "linked": "linked-bin.js", + "through-link": "linked-dir/inner.js", + }, + }), + ), + write(join(pkgDir, "index.js"), "console.log('index')"), + write(join(pkgDir, "real-bin.js"), "console.log('real bin')"), + ]); + await Promise.all([ + symlink("../outside/secret.js", join(pkgDir, "linked-bin.js")), + symlink("../outside/nested", join(pkgDir, "linked-dir")), + ]); + + await pack(pkgDir, bunEnv); + + const tarball = readTarball(join(pkgDir, "pack-bins-symlink-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([ + { pathname: "package/package.json" }, + { pathname: "package/index.js" }, + { pathname: "package/real-bin.js" }, + ]); + }); + + test.skipIf(isWindows)('"directories.bin" that is a symlink is not packed', async () => { + const pkgDir = join(packageDir, "pkg"); + await Promise.all([ + write(join(packageDir, "outside", "bins", "a.js"), "console.log('outside the package')"), + write(join(packageDir, "outside", "bins", "b.js"), "console.log('outside the package')"), + write( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "pack-bins-dir-symlink", + version: "1.0.0", + directories: { + bin: "bins", + }, + }), + ), + write(join(pkgDir, "index.js"), "console.log('index')"), + ]); + await symlink("../outside/bins", join(pkgDir, "bins")); + + await pack(pkgDir, bunEnv); + + const tarball = readTarball(join(pkgDir, "pack-bins-dir-symlink-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([{ pathname: "package/package.json" }, { pathname: "package/index.js" }]); + }); }); test("unicode", async () => { From 6a77b94b53a612b280ead4101d3a2fcd678f9573 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:50:19 +0000 Subject: [PATCH 2/8] publish: do not read the readme metadata through a symlinked README The tarball never contains a README that is a symlink, so the workspace readme scan must only consider regular files too; otherwise the link target (possibly outside the package) is sent to the registry as the package readme. --- src/runtime/cli/publish_command.rs | 4 ++- test/cli/install/bun-publish.test.ts | 40 +++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index ac6e1736eb84..1d88cff48139 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1589,7 +1589,9 @@ impl PublishCommand { let mut iter = DirIterator::iterate(workspace_dir); while let Some(entry) = iter.next().ok().flatten() { - if entry.kind == bun_sys::EntryKind::Directory { + // Same rule as the tarball: a README that is a symlink is not packed, + // so it must not be published as the readme either. + if entry.kind != bun_sys::EntryKind::File { continue; } // Entry names are UTF-8 on every platform. diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index c7f9df4fc406..8eaae8b29bcb 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -1,7 +1,7 @@ import { file, spawn, write } from "bun"; import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { exists, rm } from "fs/promises"; +import { exists, rm, symlink } from "fs/promises"; import { VerdaccioRegistry, bunExe, @@ -1205,6 +1205,44 @@ describe("readme", () => { readmeFilename: "README.md", }); }); + + // A README that is a symlink is not packed (symlinks never are), so its + // target must not be published as the readme either. + test.skipIf(isWindows)("a README that is a symlink is not sent as readme", async () => { + let captured: any = null; + using mock = Bun.serve({ + port: 0, + async fetch(req) { + if (req.method === "PUT") captured = await req.json(); + return new Response("OK", { status: 200 }); + }, + }); + + const dir = tmpdirSync(); + const packageDir = join(dir, "pkg"); + await Promise.all([ + write(join(dir, "outside", "README.md"), "# outside the package"), + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { + cache: false, + registry: { url: `http://localhost:${mock.port}`, token: "unused" }, + }, + }), + ), + write(join(packageDir, "package.json"), JSON.stringify({ name: "readme-pkg-3", version: "3.0.0" })), + write(join(packageDir, "index.js"), "module.exports = 3;"), + ]); + await symlink("../outside/README.md", join(packageDir, "README.md")); + + const { err, exitCode } = await publish(env, packageDir); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + expect(captured.versions["3.0.0"]).not.toContainAnyKeys(["readme", "readmeFilename"]); + expect(JSON.stringify(captured)).not.toContain("outside the package"); + }); }); test("dist.tarball in the published manifest does not include userinfo from the registry url", async () => { From 71811d43c8b59f5110f52ebda74f4d5fdfb842f0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:33:29 +0000 Subject: [PATCH 3/8] ci: retrigger From 916bb233c7c3a71017df1791aa9af7f53c133c1e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:36:46 +0000 Subject: [PATCH 4/8] pack, publish: shorten the symlink comments --- src/runtime/cli/pack_command.rs | 9 +++------ src/runtime/cli/publish_command.rs | 3 +-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index d79184ea385e..dfbb0174206b 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1528,12 +1528,9 @@ fn bin_path_escapes_root(p: &[u8]) -> bool { path::is_absolute_loose(p) || p == b".." || p.starts_with(b"../") } -/// Bins are added to the pack queue directly instead of being found by the -/// tree walk, which never packs symlinks (like npm-packlist). Apply the same -/// rule here: a bin reached through a symlink in any path component would -/// otherwise be packed with the contents of whatever the link points at, -/// possibly outside the package. Returns `Unknown` if the path does not -/// exist or a parent component is not a real directory. +/// `lstat` kind of `bin_path`, or `Unknown` if it is missing or a parent +/// component is not a real directory. Bins skip the tree walk, which packs +/// nothing reached through a symlink, so they get the same check here. fn bin_kind_without_following_symlinks(root_dir: &Dir, bin_path: &[u8]) -> bun_sys::FileKind { // A trailing slash would make `lstat` resolve a symlink to a directory. let bin_path = strings::without_trailing_slash(bin_path); diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 1d88cff48139..c9d061c20f5f 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1589,8 +1589,7 @@ impl PublishCommand { let mut iter = DirIterator::iterate(workspace_dir); while let Some(entry) = iter.next().ok().flatten() { - // Same rule as the tarball: a README that is a symlink is not packed, - // so it must not be published as the readme either. + // Symlinks are not packed, so a symlinked README is not the readme either. if entry.kind != bun_sys::EntryKind::File { continue; } From b6bb78e81403f1f3e04df2bea1bcd138bab9fea0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:38:06 +0000 Subject: [PATCH 5/8] pack: trim the bin kind helper doc comment --- src/runtime/cli/pack_command.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index dfbb0174206b..81f8a09290be 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1528,9 +1528,7 @@ fn bin_path_escapes_root(p: &[u8]) -> bool { path::is_absolute_loose(p) || p == b".." || p.starts_with(b"../") } -/// `lstat` kind of `bin_path`, or `Unknown` if it is missing or a parent -/// component is not a real directory. Bins skip the tree walk, which packs -/// nothing reached through a symlink, so they get the same check here. +/// `Unknown` if `bin_path` is missing or a parent component is not a real directory. fn bin_kind_without_following_symlinks(root_dir: &Dir, bin_path: &[u8]) -> bun_sys::FileKind { // A trailing slash would make `lstat` resolve a symlink to a directory. let bin_path = strings::without_trailing_slash(bin_path); From 61e01a2e943a8a31303c184eacfcef7939bf6ac8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:01:31 +0000 Subject: [PATCH 6/8] pack: check bin paths by absolute path so the symlink check also works on Windows lstatat reports a reparse point as its target's kind on Windows, so lstat the joined absolute path instead; the tests now run on Windows too and also cover a --dry-run listing, an absolute-target link, a link inside the package, and an empty bin value. --- src/runtime/cli/pack_command.rs | 20 ++++-- test/cli/install/bun-pack.test.ts | 91 ++++++++++++++++++++-------- test/cli/install/bun-publish.test.ts | 4 +- 3 files changed, 83 insertions(+), 32 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 81f8a09290be..f09a3849e7f0 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1528,17 +1528,27 @@ fn bin_path_escapes_root(p: &[u8]) -> bool { path::is_absolute_loose(p) || p == b".." || p.starts_with(b"../") } -/// `Unknown` if `bin_path` is missing or a parent component is not a real directory. -fn bin_kind_without_following_symlinks(root_dir: &Dir, bin_path: &[u8]) -> bun_sys::FileKind { +/// `lstat` kind of `bin_path` (relative to the package root, `/`-separated), or +/// `Unknown` if it is missing or a parent component is not a real directory. +fn bin_kind_without_following_symlinks( + abs_workspace_path: &[u8], + bin_path: &[u8], +) -> bun_sys::FileKind { // A trailing slash would make `lstat` resolve a symlink to a directory. let bin_path = strings::without_trailing_slash(bin_path); + // By absolute path: on Windows `lstatat` reports a reparse point as its target's kind. + let mut spill: Vec = Vec::new(); let mut end = 0; loop { end += match strings::index_of_char_usize(&bin_path[end..], b'/') { Some(i) => i, None => bin_path.len() - end, }; - let kind = match bun_sys::lstatat(root_dir, &ZBox::from_bytes(&bin_path[..end])) { + let abs_path = resolve_path::join_z_spill::( + &mut spill, + &[abs_workspace_path, &bin_path[..end]], + ); + let kind = match bun_sys::lstat(abs_path) { Ok(stat) => bun_sys::kind_from_mode(stat.st_mode as bun_sys::Mode), Err(_) => return bun_sys::FileKind::Unknown, }; @@ -2296,7 +2306,9 @@ pub(crate) fn pack( BinType::File => bun_sys::FileKind::File, BinType::Dir => bun_sys::FileKind::Directory, }; - if bin_kind_without_following_symlinks(&root_dir, bin.path.as_bytes()) != expected_kind { + if bin_kind_without_following_symlinks(abs_workspace_path, bin.path.as_bytes()) + != expected_kind + { continue; } diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index bc8262fc7ef1..ece1eae3eeca 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -2,7 +2,7 @@ import { file, spawn, write } from "bun"; import { readTarball } from "bun:internal-for-testing"; import { beforeEach, describe, expect, test } from "bun:test"; import { exists, mkdir, rm, symlink } from "fs/promises"; -import { bunEnv, bunExe, isWindows, pack, runBunInstall, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, pack, runBunInstall, tempDir, tmpdirSync } from "harness"; import fs from "node:fs/promises"; import { join } from "path"; @@ -1598,47 +1598,60 @@ describe("bins", () => { ]); }); - test("pointing at a directory is ignored", async () => { + test("that are not regular files are ignored", async () => { await Promise.all([ write( join(packageDir, "package.json"), JSON.stringify({ - name: "pack-bins-dir-as-file", + name: "pack-bins-not-files", version: "1.0.0", - bin: "lib", + bin: { + "a-directory": "lib", + "empty": "", + "real": "real-bin.js", + }, }), ), write(join(packageDir, "index.js"), "console.log('index')"), write(join(packageDir, "lib", "a.js"), "console.log('a')"), + write(join(packageDir, "real-bin.js"), "console.log('real bin')"), ]); await pack(packageDir, bunEnv); - const tarball = readTarball(join(packageDir, "pack-bins-dir-as-file-1.0.0.tgz")); - expect(tarball.entries).toMatchObject([ - { pathname: "package/package.json" }, - { pathname: "package/index.js" }, - { pathname: "package/lib/a.js" }, + const tarball = readTarball(join(packageDir, "pack-bins-not-files-1.0.0.tgz")); + expect(tarball.entries.map(({ pathname }) => pathname)).toEqual([ + "package/package.json", + "package/index.js", + "package/lib/a.js", + "package/real-bin.js", ]); + expect(tarball.entries[3].perm & 0o111).toBe(0o111); }); // Symlinks are never packed (same as npm). Bins are no exception: packing one // would copy the link target, which may live outside the package. - test.skipIf(isWindows)("that are symlinks, or behind a symlinked directory, are not packed", async () => { + const OUTSIDE = "console.log('outside the package')"; + + async function writeSymlinkedBins() { + const outside = join(packageDir, "outside"); const pkgDir = join(packageDir, "pkg"); await Promise.all([ - write(join(packageDir, "outside", "secret.js"), "console.log('outside the package')"), - write(join(packageDir, "outside", "nested", "inner.js"), "console.log('outside the package')"), + write(join(outside, "secret.js"), OUTSIDE), + write(join(outside, "nested", "inner.js"), OUTSIDE), write( join(pkgDir, "package.json"), JSON.stringify({ name: "pack-bins-symlink", version: "1.0.0", + // `files` leaves out every bin so that only the bin handling can add them. files: ["index.js"], bin: { "real": "real-bin.js", - "linked": "linked-bin.js", - "through-link": "linked-dir/inner.js", + "relative-link": "relative-link.js", + "absolute-link": "absolute-link.js", + "link-inside-package": "link-to-real.js", + "through-linked-dir": "linked-dir/inner.js", }, }), ), @@ -1646,43 +1659,69 @@ describe("bins", () => { write(join(pkgDir, "real-bin.js"), "console.log('real bin')"), ]); await Promise.all([ - symlink("../outside/secret.js", join(pkgDir, "linked-bin.js")), - symlink("../outside/nested", join(pkgDir, "linked-dir")), + symlink(join("..", "outside", "secret.js"), join(pkgDir, "relative-link.js"), "file"), + symlink(join(outside, "secret.js"), join(pkgDir, "absolute-link.js"), "file"), + symlink("real-bin.js", join(pkgDir, "link-to-real.js"), "file"), + symlink(join("..", "outside", "nested"), join(pkgDir, "linked-dir"), "dir"), ]); + return pkgDir; + } + + test("that are symlinks, or behind a symlinked directory, are not packed", async () => { + const pkgDir = await writeSymlinkedBins(); await pack(pkgDir, bunEnv); const tarball = readTarball(join(pkgDir, "pack-bins-symlink-1.0.0.tgz")); - expect(tarball.entries).toMatchObject([ - { pathname: "package/package.json" }, - { pathname: "package/index.js" }, - { pathname: "package/real-bin.js" }, + expect(tarball.entries.map(({ pathname }) => pathname)).toEqual([ + "package/package.json", + "package/index.js", + "package/real-bin.js", ]); + for (const entry of tarball.entries) { + expect(entry.contents).not.toContain(OUTSIDE); + } + expect(tarball.entries[2].perm & 0o111).toBe(0o111); }); - test.skipIf(isWindows)('"directories.bin" that is a symlink is not packed', async () => { + test("that are symlinks are not listed by --dry-run", async () => { + const pkgDir = await writeSymlinkedBins(); + + const { out } = await pack(pkgDir, bunEnv, "--dry-run"); + + const packed = out + .split("\n") + .filter(line => line.startsWith("packed ")) + .map(line => line.split(" ").at(-1)); + expect(packed).toEqual(["package.json", "index.js", "real-bin.js"]); + expect(out).toContain("Total files: 3"); + }); + + test('"directories.bin" that is a symlink is not packed', async () => { + const outside = join(packageDir, "outside"); const pkgDir = join(packageDir, "pkg"); await Promise.all([ - write(join(packageDir, "outside", "bins", "a.js"), "console.log('outside the package')"), - write(join(packageDir, "outside", "bins", "b.js"), "console.log('outside the package')"), + write(join(outside, "bins", "a.js"), OUTSIDE), + write(join(outside, "bins", "b.js"), OUTSIDE), write( join(pkgDir, "package.json"), JSON.stringify({ name: "pack-bins-dir-symlink", version: "1.0.0", directories: { - bin: "bins", + bin: "./bins/", }, }), ), write(join(pkgDir, "index.js"), "console.log('index')"), ]); - await symlink("../outside/bins", join(pkgDir, "bins")); + // A junction on Windows, where "junction" is ignored elsewhere and makes a plain symlink. + await symlink(join(outside, "bins"), join(pkgDir, "bins"), "junction"); await pack(pkgDir, bunEnv); const tarball = readTarball(join(pkgDir, "pack-bins-dir-symlink-1.0.0.tgz")); - expect(tarball.entries).toMatchObject([{ pathname: "package/package.json" }, { pathname: "package/index.js" }]); + expect(tarball.entries.map(({ pathname }) => pathname)).toEqual(["package/package.json", "package/index.js"]); }); }); diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index 8eaae8b29bcb..d9a7cb131f08 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -1208,7 +1208,7 @@ describe("readme", () => { // A README that is a symlink is not packed (symlinks never are), so its // target must not be published as the readme either. - test.skipIf(isWindows)("a README that is a symlink is not sent as readme", async () => { + test("a README that is a symlink is not sent as readme", async () => { let captured: any = null; using mock = Bun.serve({ port: 0, @@ -1234,7 +1234,7 @@ describe("readme", () => { write(join(packageDir, "package.json"), JSON.stringify({ name: "readme-pkg-3", version: "3.0.0" })), write(join(packageDir, "index.js"), "module.exports = 3;"), ]); - await symlink("../outside/README.md", join(packageDir, "README.md")); + await symlink(join("..", "outside", "README.md"), join(packageDir, "README.md"), "file"); const { err, exitCode } = await publish(env, packageDir); expect(err).not.toContain("error:"); From c41e13002556899baa3550fd3754b7fc3b3502de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:13:16 +0000 Subject: [PATCH 7/8] pack: one line doc comment on the bin kind helper --- src/runtime/cli/pack_command.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index f09a3849e7f0..47316f59b9b8 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1528,8 +1528,7 @@ fn bin_path_escapes_root(p: &[u8]) -> bool { path::is_absolute_loose(p) || p == b".." || p.starts_with(b"../") } -/// `lstat` kind of `bin_path` (relative to the package root, `/`-separated), or -/// `Unknown` if it is missing or a parent component is not a real directory. +/// `Unknown` if `bin_path` is missing or a parent component is not a real directory. fn bin_kind_without_following_symlinks( abs_workspace_path: &[u8], bin_path: &[u8], From 5a0921e3bed975425986ac49c4f88286cd6e9cec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:36:50 +0000 Subject: [PATCH 8/8] test(pack): cover a backslash spelled bin path behind a symlinked directory --- test/cli/install/bun-pack.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index ece1eae3eeca..5a9b7a370b5d 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -1652,6 +1652,8 @@ describe("bins", () => { "absolute-link": "absolute-link.js", "link-inside-package": "link-to-real.js", "through-linked-dir": "linked-dir/inner.js", + // bin paths are normalized to `/` before they are checked + "through-linked-dir-backslash": "linked-dir\\inner.js", }, }), ),