Skip to content
37 changes: 37 additions & 0 deletions src/runtime/cli/pack_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
Outdated
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'/') {
Comment thread
robobun marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -2268,6 +2297,14 @@ 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(&root_dir, bin.path.as_bytes()) != expected_kind {
continue;
}

match bin.ty {
BinType::File => {
pack_queue.add(PackQueueItem {
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/cli/publish_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
Outdated
if entry.kind != bun_sys::EntryKind::File {
Comment thread
robobun marked this conversation as resolved.
Outdated
continue;
}
// Entry names are UTF-8 on every platform.
Expand Down
91 changes: 89 additions & 2 deletions test/cli/install/bun-pack.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 () => {
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.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 () => {
Expand Down