diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index a1ce7dd2c0e1..47316f59b9b8 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1528,6 +1528,39 @@ 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( + 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 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, + }; + 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 +2301,16 @@ 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(abs_workspace_path, bin.path.as_bytes()) + != expected_kind + { + continue; + } + match bin.ty { BinType::File => { pack_queue.add(PackQueueItem { diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index ac6e1736eb84..c9d061c20f5f 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1589,7 +1589,8 @@ impl PublishCommand { let mut iter = DirIterator::iterate(workspace_dir); while let Some(entry) = iter.next().ok().flatten() { - if entry.kind == bun_sys::EntryKind::Directory { + // Symlinks are not packed, so a symlinked README is not 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-pack.test.ts b/test/cli/install/bun-pack.test.ts index 77fe7a55685a..5a9b7a370b5d 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -1,7 +1,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 } from "fs/promises"; +import { exists, mkdir, rm, symlink } from "fs/promises"; import { bunEnv, bunExe, pack, runBunInstall, tempDir, tmpdirSync } from "harness"; import fs from "node:fs/promises"; import { join } from "path"; @@ -1597,6 +1597,134 @@ describe("bins", () => { }, ]); }); + + test("that are not regular files are ignored", async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-bins-not-files", + version: "1.0.0", + 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-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. + 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(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", + "relative-link": "relative-link.js", + "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", + }, + }), + ), + write(join(pkgDir, "index.js"), "console.log('index')"), + write(join(pkgDir, "real-bin.js"), "console.log('real bin')"), + ]); + await Promise.all([ + 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.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("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(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/", + }, + }), + ), + write(join(pkgDir, "index.js"), "console.log('index')"), + ]); + // 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.map(({ pathname }) => pathname)).toEqual(["package/package.json", "package/index.js"]); + }); }); test("unicode", async () => { diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index c7f9df4fc406..d9a7cb131f08 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("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(join("..", "outside", "README.md"), join(packageDir, "README.md"), "file"); + + 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 () => {