Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion docs/guides/install/add-tarball.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sidebarTitle: Add a tarball dependency
mode: center
---

Bun's package manager can install any publicly available tarball URL as a dependency of your project.
Bun's package manager can install a tarball URL as a dependency of your project. If the URL needs credentials, see [Tarball dependencies](/pm/cli/add#tarball-dependencies).

```sh terminal icon="terminal"
bun add zod@https://registry.npmjs.org/zod/-/zod-3.21.4.tgz
Expand Down
6 changes: 4 additions & 2 deletions docs/pm/cli/add.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ The package's `prepare` script, which often builds `dist/`, runs only when the p

## Tarball dependencies

A package name can correspond to a publicly hosted `.tgz` file. Bun downloads and installs the package from that tarball URL rather than from the package registry.
A package name can correspond to a `.tgz` file at a URL. Bun downloads and installs the package from that tarball URL rather than from the package registry.

```sh terminal icon="terminal"
bun add zod@https://registry.npmjs.org/zod/-/zod-3.21.4.tgz
Expand All @@ -252,7 +252,9 @@ 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. Percent-encode a character that cannot be written in a URL: the password `p@ss` is written `p%40ss`. Bun decodes the credentials before it sends them.
Bun sends your configured credentials to a tarball URL that your project names. [`.npmrc`](/pm/npmrc) describes which ones apply. A tarball URL that only another package names gets no configured credentials.

A tarball URL can also 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. Percent-encode a character that cannot be written in a URL: the password `p@ss` is written `p%40ss`. Bun decodes the credentials before it sends them. When configured credentials also apply, Bun sends those instead, as npm does.

## Local paths

Expand Down
3 changes: 3 additions & 0 deletions docs/pm/npmrc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ Bun looks credentials up the way npm does. It takes the URL of the registry, wit
- `:443` on an `https` URL and `:80` on an `http` URL are the same as no port.
- A credential you set next to the registry URL wins over these lines: `token`, `username` and `password` in `bunfig.toml`, `user:password@` in a URL, and `BUN_CONFIG_TOKEN` / `NPM_CONFIG_TOKEN`. The one exception is a credential inside a `registry=` line of `.npmrc`, which a matching `//host/` line replaces, as in npm.
- A tarball on the same origin as its registry gets the registry's credentials. A tarball anywhere else gets a credential only if `.npmrc` has a line for that URL and the URL is `https`. Bun never sends a registry's credentials to another host.
- A dependency written as a tarball URL follows the tarball rule when your project names the URL. The default registry stands in for its registry. Your project names a URL when the root, a workspace, or a `file:` folder that one of them depends on lists it as a dependency. A URL that is a value in `overrides`, a catalog or `packageExtensions` counts too.
- A tarball URL that only another package names gets no configured credentials. npm is less strict: it sends them whoever names the URL.
- Unlike npm, Bun never picks a scoped registry from the name a tarball URL is listed under. A scoped registry's credentials reach a tarball URL only through a `.npmrc` line for that URL, over `https`.
- `publishConfig.registry` in `package.json` follows the tarball rule: it gets a `.npmrc` line only over `https`, and environment variables are not expanded in it.
- Bun sends credentials to `http` and `https` URLs only. A registry URL with another scheme, or without one, fails before any request.
- A credential that contains a newline or a NUL byte is an error, wherever it comes from.
Expand Down
28 changes: 13 additions & 15 deletions src/install/PackageManager/PackageManagerEnqueue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use crate::dependency::{DependencyExt as _, TagExt as _, VersionExt as _};
use crate::lockfile::package::Package;
use crate::lockfile::reachable;
use crate::lockfile_real as Lockfile;
use crate::network_task::{Authorization, ForTarballError, add_error_or_warning, stored_string};
use crate::network_task::{ForTarballError, add_error_or_warning, stored_string};
use crate::package_manager_real::directories::{
try_get_cache_directory, try_get_temporary_directory, try_global_link_dir,
};
Expand Down Expand Up @@ -280,7 +280,6 @@ pub fn enqueue_tarball_for_download(
package_id,
url,
task_context,
Authorization::NoAuthorization,
)
}

Expand All @@ -301,7 +300,6 @@ pub fn enqueue_package_for_download(
package_id,
url,
task_context,
Authorization::AllowAuthorization,
)
}

Expand All @@ -313,7 +311,6 @@ fn enqueue_download(
package_id: PackageID,
url: &[u8],
task_context: TaskCallbackContext,
authorization: Authorization,
) -> Result<(), ForTarballError> {
if has_already_failed(this, task_id, dependency_id, package_id, &task_context) {
return Err(ForTarballError::AlreadyFailed);
Expand All @@ -337,7 +334,6 @@ fn enqueue_download(
is_required,
dependency_id,
&package,
authorization,
)? {
// reshaped for borrowck — `task: &mut NetworkTask` borrows
// `*this` (pool slot); reborrow as raw so `this.network_tarball_batch`
Expand Down Expand Up @@ -1344,18 +1340,22 @@ fn request_remote_package_ahead(
install_peer: bool,
) {
let resolution = this.lockfile.buffers.resolutions[id as usize];
let is_local_tarball = asked.version.tag == dependency::version::Tag::Tarball
&& matches!(
asked.version.tarball().uri,
dependency::tarball::Uri::Local(_)
);
if !this.lockfile.awaits_resolve(id)
|| this.options.runtime_auto_install
// A deferred peer doesn't request anything, because the tree may provide it.
|| (dependency.behavior.is_peer() && !install_peer)
// Whether another package may refer to a tarball in the project is decided and reported
// when the dependency is resolved.
|| (is_local_tarball && !this.lockfile.is_dependency_of_local_package(id))
|| (asked.version.tag == dependency::version::Tag::Tarball
&& match &asked.version.tarball().uri {
// Whether another package may name a tarball in the project is decided at its turn.
dependency::tarball::Uri::Local(_) => {
!this.lockfile.is_dependency_of_local_package(id)
}
// A local package that is resolved before this dependency may still name the URL.
dependency::tarball::Uri::Remote(url) => {
this.options.has_tarball_url_credentials()
&& !(this.lockfile).is_tarball_url_of_project(this.lockfile.str(url))
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
}
})
{
return;
}
Expand Down Expand Up @@ -2508,7 +2508,6 @@ fn bind_row_without_manifest(
resolution: res,
..Package::default()
},
Authorization::NoAuthorization,
) {
// An `--offline` miss. It was already reported if required, or skipped if
// optional.
Expand Down Expand Up @@ -3270,7 +3269,6 @@ fn tarball_task(
behavior.is_required(),
dependency_id,
package,
Authorization::AllowAuthorization,
)?;
Ok(Some(ResolvedPackageTask::NetworkTask(
task.expect("unreachable"),
Expand Down
7 changes: 6 additions & 1 deletion src/install/PackageManager/PackageManagerOptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ impl Options {
}
}

/// False if `tarball_credentials` finds nothing for a tarball URL, whatever the URL is.
pub(crate) fn has_tarball_url_credentials(&self) -> bool {
self.scope.has_credentials() || !self.url_auth.is_empty()
}

/// Follows the order of npm's `getAuth`: the `.npmrc` line for the tarball's own URL, or else
/// the registry's credentials if the tarball is on the registry's origin.
pub fn tarball_credentials<'a>(
Expand All @@ -309,7 +314,7 @@ impl Options {
// A URL that one parse could not settle gets no line.
normalized: bool,
) -> Option<&'a Npm::registry::Scope> {
// This URL comes from package data, and an `.npmrc` key has no scheme, so credentials are
// No configuration names this URL, and an `.npmrc` key has no scheme, so credentials are
// never sent over plain http.
let own = (normalized && tarball.is_https())
.then(|| Npm::registry::UrlAuth::find_entry(&self.url_auth, tarball))
Expand Down
18 changes: 16 additions & 2 deletions src/install/PackageManager/runTasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2083,7 +2083,6 @@ pub fn generate_network_task_for_tarball<'a>(
is_required: bool,
dependency_id: DependencyID,
package: &Package,
authorization: Authorization,
) -> Result<Option<&'a mut NetworkTask>, ForTarballError> {
// Only reached when the tarball is not already extracted in the cache.
if this.options.offline == Options::OfflineMode::Offline {
Expand Down Expand Up @@ -2137,7 +2136,22 @@ pub fn generate_network_task_for_tarball<'a>(
let network_task = unsafe { &mut *net_ptr };

let pkg_name = this.lockfile.str(&package.name);
let scope = this.scope_for_package_name(pkg_name);
let (scope, authorization) = match package.resolution.tag {
bun_install::ResolutionTag::Npm => (
this.scope_for_package_name(pkg_name),
Authorization::AllowAuthorization,
),
// One download serves every name that the URL is listed under, so the name picks no scope.
bun_install::ResolutionTag::RemoteTarball
if this.lockfile.is_tarball_url_of_project(url) =>
{
(&this.options.scope, Authorization::AllowAuthorization)
}
_ => (
this.scope_for_package_name(pkg_name),
Authorization::NoAuthorization,
),
};

let extract_tarball = ExtractTarball {
// BACKREF — `this_backref` is non-null (just derived from `&mut *this`)
Expand Down
30 changes: 30 additions & 0 deletions src/install/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,36 @@ impl Lockfile {
.is_some_and(|parent_id| self.is_local_declarer(parent_id))
}

/// Whether the project, and not only a package that it installs, names the tarball URL `url`.
pub(crate) fn is_tarball_url_of_project(&self, url: &[u8]) -> bool {
let buf = self.buffers.string_bytes.as_slice();
let dependencies = self.buffers.dependencies.as_slice();
let packages = self.packages.slice();
let names_url = |declared: &Dependency| {
declared.version.tag == dependency::version::Tag::Tarball
&& matches!(
&declared.version.tarball().uri,
dependency::tarball::Uri::Remote(remote) if remote.slice(buf) == url
)
};
(self.overrides.values().chain(self.catalogs.values())).any(names_url)
|| self.package_extensions.has_specifier(url)
|| (packages.items_resolution().iter())
.zip(packages.items_dependencies())
.enumerate()
.any(|(id, (resolution, declared))| {
declared.get(dependencies).iter().any(names_url)
&& match resolution.tag {
ResolutionTag::Root | ResolutionTag::Workspace => true,
// One that only an override selects may be resolved too late.
ResolutionTag::Folder => {
self.is_workspace_declared_package(id as PackageID)
}
_ => false,
}
})
}

/// See `is_dependency_of_local_package`.
pub(crate) fn is_local_declarer(&self, id: PackageID) -> bool {
match self.packages.items_resolution()[id as usize].tag {
Expand Down
7 changes: 7 additions & 0 deletions src/install/lockfile/CatalogMap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ impl CatalogMap {
self.default.count() > 0 || self.groups.count() > 0
}

/// The entries of every catalog.
pub(crate) fn values(&self) -> impl Iterator<Item = &Dependency> {
core::iter::once(&self.default)
.chain(self.groups.values())
.flat_map(|catalog| catalog.values())
}

/// `catalog:` and `catalog:default` name the same catalog.
pub(crate) fn same_name(a: &[u8], b: &[u8]) -> bool {
let is_default = |name: &[u8]| name.is_empty() || name == b"default";
Expand Down
8 changes: 6 additions & 2 deletions src/install/lockfile/OverrideMap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,11 @@ impl OverrideMap {
.is_some_and(|dep| dep.name.slice(buf) == name)
}

/// What every rule replaces a dependency with.
pub(crate) fn values(&self) -> impl Iterator<Item = &Dependency> {
(self.map.values().iter()).chain(self.scoped.iter().map(|rule| &rule.dep))
}

#[inline]
pub(crate) fn is_empty(&self) -> bool {
self.map.count() == 0 && self.scoped.is_empty()
Expand Down Expand Up @@ -477,8 +482,7 @@ impl OverrideMap {
root: &Expr,
has_workspaces: bool,
) {
let rules = (self.map.values().iter()).chain(self.scoped.iter().map(|rule| &rule.dep));
for dep in rules.filter(|dep| dep.version.tag == VersionTag::Catalog) {
for dep in (self.values()).filter(|dep| dep.version.tag == VersionTag::Catalog) {
if (catalogs.get_ref(buf, *dep.version.catalog(), dep.name)).is_some() {
continue;
}
Expand Down
6 changes: 6 additions & 0 deletions src/install/lockfile/package_extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ impl PackageExtensions {
self.rules.is_empty() && self.applied.is_empty()
}

/// Returns true if a rule adds a dependency that is written as `specifier`.
pub(crate) fn has_specifier(&self, specifier: &[u8]) -> bool {
(self.rules.iter().flat_map(|rule| &rule.edges))
.any(|edge| dependency::trim_literal(&edge.literal) == specifier)
}

pub(crate) fn parse(
&mut self,
log: &mut bun_ast::Log,
Expand Down
8 changes: 0 additions & 8 deletions src/install/patch_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,6 @@ impl PatchTask {
// after `determine_preinstall_state` consumes it.
let pkg_meta_id = pkg.meta.id;
let pkg_name = pkg.name;
let pkg_resolution_tag = pkg.resolution.tag;
let name_and_version_hash = calc_hash.name_and_version_hash;
let dep_behavior = manager.lockfile.buffers.dependencies[dep_id as usize].behavior;

Expand Down Expand Up @@ -408,12 +407,6 @@ impl PatchTask {
is_required,
dep_id,
&pkg_again,
match pkg_resolution_tag {
crate::resolution_real::Tag::Npm => {
Authorization::AllowAuthorization
}
_ => Authorization::NoAuthorization,
},
) {
// --offline and not cached: already reported / skipped; nothing to patch
Err(crate::network_task::ForTarballError::Offline) => return Ok(()),
Expand Down Expand Up @@ -817,7 +810,6 @@ impl PatchTask {
}

use crate::PreinstallState;
use crate::network_task::Authorization;
use crate::package_install::{InstallResult, Method as InstallMethod};
use crate::package_manager::Options::LogLevel;
use crate::package_manager_task::Id as TaskId;
Loading
Loading