Skip to content
43 changes: 43 additions & 0 deletions src/runtime/cli/pack_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> = Vec::new();
let mut end = 0;
loop {
end += match strings::index_of_char_usize(&bin_path[end..], b'/') {
Comment thread
robobun marked this conversation as resolved.
Some(i) => i,
None => bin_path.len() - end,
};
let abs_path = resolve_path::join_z_spill::<resolve_path::platform::Auto>(
&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 {
Expand Down Expand Up @@ -2268,6 +2301,16 @@ pub(crate) fn pack<const FOR_PUBLISH: bool>(
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 {
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/cli/publish_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
130 changes: 129 additions & 1 deletion test/cli/install/bun-pack.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down
40 changes: 39 additions & 1 deletion test/cli/install/bun-publish.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down