Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
719bc52
install: bound the resolution part of isolated store entry names
robobun Aug 15, 2026
cd82495
test: cover a cut that lands inside a multi-byte character
robobun Aug 15, 2026
c915d80
docs: describe the store name bound as what it adds to the path, not …
robobun Aug 15, 2026
e1ac7d0
install: shorten the store name bound comments
robobun Aug 15, 2026
3affb5c
install: tighten the store name bound comments
robobun Aug 15, 2026
39bc380
test: isolate bun's git clone from the runner's git config in the lon…
robobun Aug 15, 2026
a70ce0c
install: read file: tarballs relative to the file: folder package tha…
robobun Aug 15, 2026
2ed666b
test: cover a root catalog entry pointing a workspace dependency at a…
robobun Aug 15, 2026
25f7523
install: send credentials embedded in a tarball URL as Basic authoriz…
robobun Aug 15, 2026
ef99947
install: shorten the local tarball base dir doc comments
robobun Aug 15, 2026
223e0a4
install: name the declared-by-parent check instead of documenting it
robobun Aug 15, 2026
5ce5696
install: one-line doc for LocalTarballRequest.tarball_path
robobun Aug 15, 2026
474da9d
test: cover a local tarball and a folder whose paths are longer than …
robobun Aug 15, 2026
951b5c1
Merge origin/main
Jarred-Sumner Aug 16, 2026
105e509
Merge remote-tracking branch 'origin/farm/a6acfa69/tarball-url-creden…
Jarred-Sumner Aug 16, 2026
d415c43
Merge remote-tracking branch 'origin/farm/1d4e54b0/folder-dep-local-t…
Jarred-Sumner Aug 16, 2026
5fa0679
isolated-install test: expected git store entry names go through the …
Jarred-Sumner Aug 16, 2026
9df4f83
install: one-line comments in NetworkTask and Store
Jarred-Sumner Aug 17, 2026
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
2 changes: 2 additions & 0 deletions docs/pm/isolated-installs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ node_modules/
└── package-name -> .bun/package@1.0.0/node_modules/package # Symlinks
```

The part of a store directory name after `@` is the package's resolution. For a registry package, that is its version. For a folder, tarball, git, or GitHub dependency, Bun derives it from the path or URL (plus the commit), so it can be long. Bun writes at most 80 bytes of it: it cuts a longer resolution to at most 63 bytes and appends `+` and 16 hex digits derived from the full resolution. The path of a package inside the store is therefore at most `34 + 2 * <name length> + 80` characters longer than your project directory (17 more when the package has peer dependencies). This matters on Windows, where a path longer than 260 characters still works for Bun itself, but not as the working directory of the package's lifecycle scripts.

### Resolution algorithm

1. **Central store** — Bun installs all packages in `node_modules/.bun/package@version/` directories
Expand Down
70 changes: 60 additions & 10 deletions src/install/isolated_install/Store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use bstr::BStr;
use bun_alloc::AllocError;
use bun_collections::{ArrayHashMap, MultiArrayList};
use bun_semver::String as SemverString;
use bun_wyhash::Wyhash;

use crate::lockfile::{Lockfile, package};
use crate::{Dependency, DependencyID, INVALID_DEPENDENCY_ID, PackageID, Resolution};
Expand Down Expand Up @@ -351,7 +352,61 @@ pub mod entry {
}
}

/// Bound on the resolution text (everything after `name@`), which for folder,
/// tarball and git dependencies is otherwise as long as the user's spec. The
/// package directory is the cwd of its lifecycle scripts, and on Windows
/// `CreateProcess` rejects a cwd beyond MAX_PATH (ENOENT) even though bun's
/// own file operations accept such paths. 80 leaves versions and
/// `github+owner+repo+<sha>` verbatim.
Comment thread
robobun marked this conversation as resolved.
Outdated
const MAX_RESOLUTION_LEN: usize = 80;
/// A longer resolution becomes its leading bytes, cut at a character boundary,
/// plus `+<16 hex wyhash (seed 0) of the whole text>`: at most
/// `MAX_RESOLUTION_LEN` bytes in total.
Comment thread
robobun marked this conversation as resolved.
Outdated
const CUT_RESOLUTION_LEN: usize = MAX_RESOLUTION_LEN - "+".len() - 16;

/// Keeps the first `MAX_RESOLUTION_LEN` bytes written plus the length and
/// hash of everything written.
Comment thread
robobun marked this conversation as resolved.
Outdated
struct ResolutionSink {
buf: [u8; MAX_RESOLUTION_LEN],
len: usize,
hasher: Wyhash,
}

impl fmt::Write for ResolutionSink {
fn write_str(&mut self, s: &str) -> fmt::Result {
let bytes = s.as_bytes();
if let Some(room) = self.buf.get_mut(self.len..) {
let n = bytes.len().min(room.len());
room[..n].copy_from_slice(&bytes[..n]);
}
self.len += bytes.len();
self.hasher.update(bytes);
Ok(())
}
}

fn write_resolution(f: &mut fmt::Formatter<'_>, resolution: fmt::Arguments<'_>) -> fmt::Result {
let mut sink = ResolutionSink {
buf: [0; MAX_RESOLUTION_LEN],
len: 0,
hasher: Wyhash::init(0),
};
fmt::write(&mut sink, resolution)?;

if sink.len <= MAX_RESOLUTION_LEN {
return f.write_str(bun_core::str_utf8(&sink.buf[..sink.len]).ok_or(fmt::Error)?);
}

let mut cut = CUT_RESOLUTION_LEN;
while !bun_core::strings::is_on_char_boundary(&sink.buf, cut) {
cut -= 1;
}
f.write_str(bun_core::str_utf8(&sink.buf[..cut]).ok_or(fmt::Error)?)?;
write!(f, "+{:016x}", sink.hasher.final_())
}

/// `name@version` (or `name@file+path` / `name@root`) without the `+peerhash` suffix.
/// The resolution part is bounded by [`MAX_RESOLUTION_LEN`].
pub struct StoreKeyFormatter<'a> {
name: SemverString,
resolution: &'a Resolution,
Expand Down Expand Up @@ -380,20 +435,15 @@ pub mod entry {
}
crate::resolution::Tag::Folder => {
let folder = *pkg_res.folder();
write!(
write!(f, "{}@", pkg_name.fmt_store_path(string_buf))?;
write_resolution(
f,
"{}@file+{}",
pkg_name.fmt_store_path(string_buf),
folder.fmt_store_path(string_buf),
format_args!("file+{}", folder.fmt_store_path(string_buf)),
)
}
_ => {
write!(
f,
"{}@{}",
pkg_name.fmt_store_path(string_buf),
pkg_res.fmt_store_path(string_buf),
)
write!(f, "{}@", pkg_name.fmt_store_path(string_buf))?;
write_resolution(f, format_args!("{}", pkg_res.fmt_store_path(string_buf)))
}
}
}
Expand Down
259 changes: 259 additions & 0 deletions test/cli/install/isolated-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mkdir, readlink, rm, symlink } from "fs/promises";
import { VerdaccioRegistry, bunEnv, bunExe, readdirSorted, runBunInstall, tempDir } from "harness";
import { createRequire } from "module";
import { dirname, join } from "path";
import { pathToFileURL } from "url";

const registry = new VerdaccioRegistry();

Expand Down Expand Up @@ -2087,6 +2088,264 @@ test("tarball URL with query string resolves at runtime", async () => {
expect(exitCode).toBe(0);
});

// The resolution part of a store entry name (`<name>@<resolution>`) embeds
// folder paths, tarball URLs and git URLs verbatim. `write_resolution` in
// src/install/isolated_install/Store.rs bounds it: a resolution longer than
// MAX_RESOLUTION_LEN bytes is cut and suffixed with `+` and the wyhash of the
// full text. Unbounded, the entry's absolute path passes MAX_PATH on Windows,
// where the package's lifecycle scripts then fail to spawn with ENOENT, and a
// resolution longer than NAME_MAX cannot be created at all.
describe("long store entry names", () => {
const MAX_RESOLUTION_LEN = 80;
const CUT_RESOLUTION_LEN = MAX_RESOLUTION_LEN - "+".length - 16;

function storeEntryName(name: string, resolution: string): string {
if (resolution.length <= MAX_RESOLUTION_LEN) return `${name}@${resolution}`;
const hash = Bun.hash(resolution).toString(16).padStart(16, "0");
return `${name}@${resolution.slice(0, CUT_RESOLUTION_LEN)}+${hash}`;
}

async function storeEntries(packageDir: string): Promise<string[]> {
return (await readdirSorted(join(packageDir, "node_modules", ".bun"))).filter(entry => entry !== "node_modules");
}

test("a resolution at the limit is kept verbatim, a longer one is cut and hashed", async () => {
// `file+` plus this folder name is exactly MAX_RESOLUTION_LEN bytes; the
// other three folders are one byte longer.
const atLimit = Buffer.alloc(MAX_RESOLUTION_LEN - "file+".length, "a").toString();
const pastLimit = `${atLimit}b`;
// Two resolutions of the same package that only differ after the cut
// point: the hash is what keeps their entries apart.
const sharedPrefix1 = `${atLimit}1`;
const sharedPrefix2 = `${atLimit}2`;

const { packageJson, packageDir } = await registry.createTestDir({
bunfigOpts: { linker: "isolated" },
files: {
[`${atLimit}/package.json`]: JSON.stringify({ name: "at-limit", version: "1.0.0" }),
[`${pastLimit}/package.json`]: JSON.stringify({ name: "past-limit", version: "1.0.0" }),
[`${sharedPrefix1}/package.json`]: JSON.stringify({ name: "shared-prefix", version: "1.0.0" }),
[`${sharedPrefix2}/package.json`]: JSON.stringify({ name: "shared-prefix", version: "2.0.0" }),
},
});
await write(
packageJson,
JSON.stringify({
name: "test-long-store-entry-names",
dependencies: {
"at-limit": `file:./${atLimit}`,
"past-limit": `file:./${pastLimit}`,
"shared-prefix-1": `file:./${sharedPrefix1}`,
"shared-prefix-2": `file:./${sharedPrefix2}`,
},
}),
);

await runBunInstall(bunEnv, packageDir);

const pastLimitEntry = storeEntryName("past-limit", `file+${pastLimit}`);
expect(pastLimitEntry).toMatch(/^past-limit@file\+a{58}\+[0-9a-f]{16}$/);
const expectedEntries = [
`at-limit@file+${atLimit}`,
pastLimitEntry,
storeEntryName("shared-prefix", `file+${sharedPrefix1}`),
storeEntryName("shared-prefix", `file+${sharedPrefix2}`),
].sort();
expect(await storeEntries(packageDir)).toEqual(expectedEntries);

expect(readlinkSync(join(packageDir, "node_modules", "past-limit"))).toBe(
join(".bun", pastLimitEntry, "node_modules", "past-limit"),
);
expect(
await Promise.all(
["at-limit", "past-limit", "shared-prefix-1", "shared-prefix-2"].map(alias =>
file(join(packageDir, "node_modules", alias, "package.json")).json(),
),
),
).toEqual([
{ name: "at-limit", version: "1.0.0" },
{ name: "past-limit", version: "1.0.0" },
{ name: "shared-prefix", version: "1.0.0" },
{ name: "shared-prefix", version: "2.0.0" },
]);

// The cut name is a pure function of the resolution, so the next install
// finds the same entries again.
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
expect(await storeEntries(packageDir)).toEqual(expectedEntries);
});

test("the peer hash is appended after the cut resolution", async () => {
const folder = Buffer.alloc(MAX_RESOLUTION_LEN, "p").toString();
const { packageJson, packageDir } = await registry.createTestDir({
bunfigOpts: { linker: "isolated" },
files: {
[`${folder}/package.json`]: JSON.stringify({
name: "has-peer",
version: "1.0.0",
peerDependencies: { "no-deps": "1.0.0" },
}),
},
});
await write(
packageJson,
JSON.stringify({
name: "test-long-store-entry-name-with-peer",
dependencies: { "has-peer": `file:./${folder}`, "no-deps": "1.0.0" },
}),
);

await runBunInstall(bunEnv, packageDir);

// `+7347ae2d86f1441a` is the hash of the peer set `no-deps@1.0.0`.
const entry = `${storeEntryName("has-peer", `file+${folder}`)}+7347ae2d86f1441a`;
expect(await storeEntries(packageDir)).toEqual([entry, "no-deps@1.0.0"]);
expect(
await file(join(packageDir, "node_modules", ".bun", entry, "node_modules", "no-deps", "package.json")).json(),
).toEqual({ name: "no-deps", version: "1.0.0" });
});

test("a cut that would split a multi-byte character backs up to the character boundary", async () => {
// `file+x` is 6 bytes and every character after it is 2 bytes wide, so
// byte 63 of the resolution falls inside a character and the cut ends at
// byte 62. Only the shape is asserted: how non-ASCII bytes are spelled in
// the name is a separate matter (#32304), the boundary handling is not.
const folder = `x${Buffer.alloc(40 * 2, "\u00e9").toString()}`;
const { packageJson, packageDir } = await registry.createTestDir({
bunfigOpts: { linker: "isolated" },
files: { [`${folder}/package.json`]: JSON.stringify({ name: "non-ascii", version: "1.0.0" }) },
});
await write(
packageJson,
JSON.stringify({ name: "test-non-ascii-store-entry-name", dependencies: { "non-ascii": `file:./${folder}` } }),
);

await runBunInstall(bunEnv, packageDir);

const entries = await storeEntries(packageDir);
expect(entries).toEqual([expect.stringMatching(/^non-ascii@file\+x.*\+[0-9a-f]{16}$/)]);
const [entry] = entries;
const cutResolution = entry.slice("non-ascii@".length, -"+0123456789abcdef".length);
expect(Buffer.byteLength(cutResolution)).toBe(CUT_RESOLUTION_LEN - 1);
expect(readlinkSync(join(packageDir, "node_modules", "non-ascii"))).toBe(
join(".bun", entry, "node_modules", "non-ascii"),
);
expect(await file(join(packageDir, "node_modules", "non-ascii", "package.json")).json()).toEqual({
name: "non-ascii",
version: "1.0.0",
});
});

test("a tarball URL longer than NAME_MAX installs, also into the global store", async () => {
const tarball = file(join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"));
const segment = Buffer.alloc(255, "t").toString();

using server = Bun.serve({
port: 0,
fetch(req) {
if (new URL(req.url).pathname !== `/${segment}/no-deps.tgz`) {
return new Response("Not found", { status: 404 });
}
return new Response(tarball, { headers: { "Content-Type": "application/octet-stream" } });
},
});
const url = `http://localhost:${server.port}/${segment}/no-deps.tgz`;

const { packageJson, packageDir } = await registry.createTestDir({
bunfigOpts: { linker: "isolated", globalStore: true },
files: { "index.mjs": `import pkg from "no-deps";\nconsole.log(pkg.version);` },
});
await write(packageJson, JSON.stringify({ name: "test-long-tarball-url", dependencies: { "no-deps": url } }));

await runBunInstall(bunEnv, packageDir);

const entry = storeEntryName("no-deps", url.replaceAll(/[/:]/g, "+"));
expect(entry).toMatch(/^no-deps@http\+\+\+localhost\+\d+\+t+\+[0-9a-f]{16}$/);
expect(await storeEntries(packageDir)).toEqual([entry]);

const localEntry = join(packageDir, "node_modules", ".bun", entry);
expect(lstatSync(localEntry).isSymbolicLink()).toBe(true);
expect(entryStoreName(readlinkSync(localEntry))).toMatch(
new RegExp(`^${entry.replaceAll("+", "\\+")}-[0-9a-f]{16}$`),
);

await using proc = spawn({
cmd: [bunExe(), "index.mjs"],
env: bunEnv,
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("1.0.0\n");
expect(exitCode).toBe(0);
});

// The reported case: a git dependency's resolution is its repository URL
// (here: a path inside the temp directory) plus the commit, and the entry is
// the cwd its lifecycle scripts are spawned with.
test.skipIf(!gitExecutable)("a trusted git dependency with a long URL runs its lifecycle scripts", async () => {
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
const repoDir = join(packageDir, Buffer.alloc(60, "r").toString());
const gitEnv = {
...bunEnv,
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: join(packageDir, "gitconfig"),
GIT_AUTHOR_NAME: "bun-test",
GIT_AUTHOR_EMAIL: "test@bun.sh",
GIT_COMMITTER_NAME: "bun-test",
GIT_COMMITTER_EMAIL: "test@bun.sh",
};
async function git(...args: string[]): Promise<string> {
await using proc = spawn({
cmd: [gitExecutable!, ...args],
cwd: repoDir,
env: gitEnv,
stdout: "pipe",
stderr: "pipe",
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(err).not.toContain("fatal:");
expect(exitCode).toBe(0);
return out;
}

await Promise.all([
write(join(packageDir, "gitconfig"), ""),
write(
join(repoDir, "package.json"),
JSON.stringify({
name: "git-dep",
version: "1.0.0",
scripts: { postinstall: `${bunExe()} -e "require('fs').writeFileSync('postinstall-ran.txt', 'ran')"` },
}),
),
]);
await git("init", "-q");
await git("add", "package.json");
await git("commit", "-qm", "init", "--no-gpg-sign");
const sha = (await git("rev-parse", "HEAD")).trim();

const repoUrl = pathToFileURL(repoDir).href;
await write(
packageJson,
JSON.stringify({
name: "test-long-git-url",
dependencies: { "git-dep": `git+${repoUrl}` },
trustedDependencies: ["git-dep"],
}),
);

await runBunInstall(bunEnv, packageDir);
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

const entry = storeEntryName("git-dep", `git+${repoUrl.replaceAll(/[/:]/g, "+")}+${sha}`);
expect(entry).toMatch(/^git-dep@git\+file\+\+\+\+.*\+[0-9a-f]{16}$/);
expect(await storeEntries(packageDir)).toEqual([entry]);
expect(await file(join(packageDir, "node_modules", "git-dep", "postinstall-ran.txt")).text()).toBe("ran");
});
});

describe("global virtual store", () => {
// The global virtual store is off by default; tests that exercise it opt
// in via bunfig `install.globalStore = true`.
Expand Down
Loading