Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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/cli/add.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ bun add zod@https://registry.npmjs.org/zod/-/zod-3.21.4.tgz
}
```

A tarball URL can carry credentials, such as `https://user:password@example.com/zod-3.21.4.tgz`. Bun sends them as an `Authorization: Basic` header and requests the URL without them, like npm. The URL, credentials included, is written to `package.json` and to the lockfile.

---

<Add />
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
65 changes: 64 additions & 1 deletion src/install/NetworkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,40 @@ fn count_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope)
header_builder.count("npm-auth-type", "legacy");
}

/// Splits `http://user:pass@host/pkg.tgz` into `user:pass` and
/// `http://host/pkg.tgz`. `None` when the authority has no `@`; the `@` of a
/// scoped package in the path (`/@scope/pkg/-/pkg.tgz`) is not one.
Comment thread
robobun marked this conversation as resolved.
fn split_url_userinfo(url: &[u8]) -> Option<(&[u8], Box<[u8]>)> {
let authority_start = strings::index_of(url, b"://")? + b"://".len();
let rest = &url[authority_start..];
let authority = &rest[..strings::index_of_any(rest, b"/?#").unwrap_or(rest.len())];
let at = strings::last_index_of_char(authority, b'@')?;

let mut without_userinfo = Vec::with_capacity(url.len() - (at + 1));
without_userinfo.extend_from_slice(&url[..authority_start]);
without_userinfo.extend_from_slice(&rest[at + 1..]);
Some((&rest[..at], without_userinfo.into_boxed_slice()))
}

/// `Basic base64(userinfo)`, the header npm sends for credentials embedded in a
/// tarball URL: minipass-fetch (`getNodeRequestOptions` in `lib/request.js`)
/// hands the URL's `username:password` to node's `auth` option as is, so
/// nothing is percent-decoded here either, and a userinfo without a `:` is a
/// username with an empty password.
Comment thread
robobun marked this conversation as resolved.
fn basic_authorization_from_userinfo(userinfo: &[u8]) -> Vec<u8> {
const SCHEME: &[u8] = b"Basic ";
let mut user_pass = Vec::with_capacity(userinfo.len() + 1);
user_pass.extend_from_slice(userinfo);
if !strings::contains_char(userinfo, b':') {
user_pass.push(b':');
}
let mut value = vec![0u8; SCHEME.len() + bun_core::base64::encode_len(&user_pass)];
value[..SCHEME.len()].copy_from_slice(SCHEME);
let encoded_len = bun_core::base64::encode(&mut value[SCHEME.len()..], &user_pass);
value.truncate(SCHEME.len() + encoded_len);
value
}

#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
pub enum ForManifestError {
#[error("OutOfMemory")]
Expand Down Expand Up @@ -784,6 +818,21 @@ impl NetworkTask {
return Err(ForTarballError::InvalidURL);
}

// `"dep": "https://user:pass@host/dep.tgz"`: the credentials become a
// header, as npm sends them, and the URL is requested without them.
// They cannot stay in the URL: `bun_url` keeps the userinfo in `origin`,
// and the HTTP client compares origins to decide whether `Authorization`
// follows a redirect, so a redirect to the same host would lose it.
Comment thread
robobun marked this conversation as resolved.
let url_authorization: Option<Vec<u8>> = match split_url_userinfo(&self.url_buf) {
Some((userinfo, url_without_userinfo)) => {
let value =
(!userinfo.is_empty()).then(|| basic_authorization_from_userinfo(userinfo));
self.url_buf = url_without_userinfo;
value
}
None => None,
};

// Only attach the registry `Authorization` header when the tarball URL
// origin matches the configured registry scope origin. The npm manifest
// is registry-controlled, so a malicious registry could otherwise point
Expand Down Expand Up @@ -815,9 +864,23 @@ impl NetworkTask {
count_auth(&mut header_builder, scope);
}

// Same precedence as npm, where node derives `Authorization` from the
// URL only when the request does not carry one already: credentials
// configured for the registry win over the ones embedded in the URL.
Comment thread
robobun marked this conversation as resolved.
let url_authorization = match url_authorization {
Some(value) if header_builder.header_count == 0 => {
header_builder.count("Authorization", &value);
Some(value)
}
_ => None,
};

let header_buf: &'static [u8] = if header_builder.header_count > 0 {
header_builder.allocate()?;
append_auth(&mut header_builder, scope);
match &url_authorization {
Some(value) => header_builder.append("Authorization", value),
None => append_auth(&mut header_builder, scope),
}
debug_assert_eq!(header_builder.content.len, header_builder.content.cap);
self.header_buf = header_builder.content.move_to_slice();
// SAFETY: `self.header_buf` outlives the request; it is freed when the slot returns to the pool.
Expand Down
64 changes: 39 additions & 25 deletions src/install/PackageManager/PackageManagerEnqueue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1903,31 +1903,18 @@ fn enqueue_local_tarball(
// other dependencies (e.g. `appendPackage` / `StringBuilder.allocate`
// in `Package.fromNPM`).
let mut abs_buf = PathBuffer::uninit();
let (tarball_path, normalize): (&[u8], bool) = 'tarball_path: {
let workspace_pkg_id = this
.lockfile
.get_workspace_pkg_if_workspace_dep(dependency_id);
if workspace_pkg_id == invalid_package_id {
break 'tarball_path (path, true);
}

let workspace_res = this.lockfile.packages.items_resolution()[workspace_pkg_id as usize];
if workspace_res.tag != ResolutionTag::Workspace {
break 'tarball_path (path, true);
}

// Construct an absolute path to the tarball.
// Normally tarball paths are always relative to the root directory, but if a
// workspace depends on a tarball path, it should be relative to the workspace.
let workspace_str = *workspace_res.workspace();
let workspace_path = workspace_str.slice(this.lockfile.buffers.string_bytes.as_slice());
let joined = Path::resolve_path::join_abs_string_buf::<Path::platform::Auto>(
FileSystem::instance().top_level_dir(),
&mut abs_buf,
&[workspace_path, path],
);
break 'tarball_path (joined, false);
};
let (tarball_path, normalize): (&[u8], bool) =
match local_tarball_base_dir(&this.lockfile, dependency_id, path) {
None => (path, true),
Some(base_dir) => (
Path::resolve_path::join_abs_string_buf::<Path::platform::Auto>(
FileSystem::instance().top_level_dir(),
&mut abs_buf,
&[base_dir, path],
),
false,
),
};

// Build the `Task` value *before* claiming a hive slot — the `.expect()`s
// below can unwind, and `Task` carries drop glue. See `enqueue_git_clone`.
Expand Down Expand Up @@ -1978,6 +1965,33 @@ fn enqueue_local_tarball(
unsafe { &raw mut (*task).threadpool_task }
}

/// The workspace or `file:` folder directory that `path` is relative to; `None` is the top-level dir.
fn local_tarball_base_dir<'a>(
lockfile: &'a Lockfile::Lockfile,
dependency_id: DependencyID,
path: &[u8],
) -> Option<&'a [u8]> {
let declared = &lockfile.buffers.dependencies[dependency_id as usize].version;
let declared_by_parent = declared.tag == dependency::version::Tag::Tarball
&& matches!(
&declared.tarball().uri,
dependency::tarball::Uri::Local(declared_path) if lockfile.str(declared_path) == path
);
if !declared_by_parent {
// Overrides, resolutions and catalogs are all written in the root package.json.
return None;
}

let declarer = lockfile.get_parent_pkg_of_dependency(dependency_id)?;
let declarer_res = &lockfile.packages.items_resolution()[declarer as usize];
let base_dir = match declarer_res.tag {
ResolutionTag::Workspace => declarer_res.workspace(),
ResolutionTag::Folder => declarer_res.folder(),
_ => return None,
};
Some(lockfile.str(base_dir))
}

fn update_name_and_name_hash_from_version_replacement(
lockfile: &Lockfile::Lockfile,
original_name: SemverString,
Expand Down
7 changes: 1 addition & 6 deletions src/install/PackageManagerTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,12 +699,7 @@ pub struct GitCheckoutRequest {

pub struct LocalTarballRequest {
pub(crate) tarball: ExtractTarball,
/// Path to read the tarball from. May be the same as `tarball.url` (when
/// `normalize` is true) or an absolute path joined with a workspace
/// directory. Computed on the main thread in `enqueueLocalTarball` because
/// resolving it requires reading `lockfile.packages` / `string_bytes`,
/// which can be reallocated concurrently by the main thread while this
/// task runs on a ThreadPool worker.
/// Resolved by `enqueue_local_tarball` on the main thread; the worker must not read the lockfile.
pub(crate) tarball_path: StringOrTinyString,
/// When true, `tarball_path` is a user-provided path resolved relative to
/// cwd. When false, it is already an absolute path.
Expand Down
67 changes: 57 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,58 @@ pub mod entry {
}
}

/// Max bytes of resolution (the text after `name@`; a folder path or git/tarball
/// URL otherwise makes it arbitrarily long) in an entry name. The package directory
/// under the entry is the cwd of its lifecycle scripts, which Windows' `CreateProcess`
/// rejects past MAX_PATH (ENOENT) although bun's own file I/O accepts such paths.
/// 80 keeps versions and `github+owner+repo+<sha>` verbatim.
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
const MAX_RESOLUTION_LEN: usize = 80;
/// Longer resolutions become `<leading bytes>+<16 hex wyhash of the whole text>`,
/// `MAX_RESOLUTION_LEN` bytes at most.
Comment thread
robobun marked this conversation as resolved.
const CUT_RESOLUTION_LEN: usize = MAX_RESOLUTION_LEN - "+".len() - 16;

/// The first `MAX_RESOLUTION_LEN` bytes written, plus the length and hash of all of them.
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 +432,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
10 changes: 10 additions & 0 deletions src/install/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,16 @@ impl Lockfile {
self.get_workspace_pkg_if_workspace_dep(id) != invalid_package_id
}

/// `None` for the edges `enqueue_dependency_to_root` appends outside of any package.
pub(crate) fn get_parent_pkg_of_dependency(&self, id: DependencyID) -> Option<PackageID> {
for (pkg_id, dependencies) in self.packages.items_dependencies().iter().enumerate() {
if dependencies.contains(id) {
return Some(PackageID::try_from(pkg_id).expect("int cast"));
}
}
None
}

pub(crate) fn get_workspace_pkg_if_workspace_dep(&self, id: DependencyID) -> PackageID {
let packages = self.packages.slice();
let resolutions = packages.items_resolution();
Expand Down
Loading
Loading