Skip to content
Closed
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
13 changes: 13 additions & 0 deletions docs/pm/npmrc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ The equivalent `bunfig.toml` option is to add a key in [`install.scopes`](/runti
myorg = { url = "http://localhost:4873/", username = "myusername", password = "$NPM_PASSWORD" }
```

#### Tarballs served from a different host than the registry

Bun only forwards a registry's `Authorization` header to tarball downloads from the same origin as that registry. If your registry serves package manifests from one host and tarballs from a separate authenticated host (for example, Artifactory or Nexus with a CDN), add an auth entry for the tarball host too:

```ini .npmrc icon="npm"
@myorg:registry=https://packages.example.com/npm/
//packages.example.com/npm/:_authToken=${NPM_TOKEN}
# dist.tarball points at a separate authenticated host:
//cdn.example.com/:_authToken=${NPM_TOKEN}
```

Bun looks up tarball auth by the host and path prefix of the download URL, the same way npm does.

### `link-workspace-packages`: Control workspace package installation

Controls how workspace packages are installed when available locally:
Expand Down
120 changes: 68 additions & 52 deletions src/ini/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1681,14 +1681,22 @@ mod draft {
}
}

// `configs` accumulates across .npmrc files and is re-iterated in
// full each call, so rebuild tarball_url_auth from scratch: an
// entry that was unmatched in an earlier file may now match a
// scoped registry introduced by this file.
install.tarball_url_auth.clear();

for conf_item in configs.iter() {
let conf_item_url = URL::parse(&conf_item.registry_url);
let mut matched_any_registry = false;

if bun_core::without_trailing_slash(&default_registry_host)
== bun_core::without_trailing_slash(conf_item_url.host)
&& bun_core::without_trailing_slash(&default_registry_pathname)
== bun_core::without_trailing_slash(conf_item_url.pathname)
{
matched_any_registry = true;
// Apply config to default registry
let v: &mut NpmRegistry = 'brk: {
if let Some(r) = install.default_registry.as_mut() {
Expand All @@ -1707,32 +1715,7 @@ mod draft {
install.default_registry.as_mut().unwrap()
};

match conf_item.optname {
ConfigOpt::_AuthToken => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.token = x;
}
}
ConfigOpt::Username => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.username = x;
}
}
ConfigOpt::_Password => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.password = x;
}
}
ConfigOpt::_Auth => {
handle_auth(v, conf_item, log, source)?;
}
ConfigOpt::Email => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.email = x;
}
}
ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(),
}
apply_config_opt(v, conf_item, log, source)?;
}

// `keys()`/`values_mut()` on the same map alias; since
Expand All @@ -1758,37 +1741,35 @@ mod draft {
continue;
}
}
matched_any_registry = true;
// Apply config to scoped registry
match conf_item.optname {
ConfigOpt::_AuthToken => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.token = x;
}
}
ConfigOpt::Username => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.username = x;
}
}
ConfigOpt::_Password => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.password = x;
}
}
ConfigOpt::_Auth => {
handle_auth(v, conf_item, log, source)?;
}
ConfigOpt::Email => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.email = x;
}
}
ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(),
}
apply_config_opt(v, conf_item, log, source)?;
// We have to keep going as it could match multiple scopes
continue;
}
}

if !matched_any_registry {
// `//host/path/:*=` entry that matches neither the default
// nor any scoped registry. npm looks up auth by the URL
// being fetched, so such an entry can still apply to a
// tarball download whose `dist.tarball` points at this
// origin. Group by `.npmrc` URL so multiple options for the
// same host accumulate into one `NpmRegistry`.
let v: &mut NpmRegistry = 'brk: {
for entry in install.tarball_url_auth.iter_mut() {
if *entry.url == *conf_item.registry_url {
break 'brk entry;
}
}
install.tarball_url_auth.push(NpmRegistry {
url: Box::<[u8]>::from(&*conf_item.registry_url),
..Default::default()
});
install.tarball_url_auth.last_mut().unwrap()
};
apply_config_opt(v, conf_item, log, source)?;
}
Comment thread
robobun marked this conversation as resolved.
}

drop(url_map);
Expand Down Expand Up @@ -1923,6 +1904,41 @@ mod draft {
})
}

fn apply_config_opt(
v: &mut NpmRegistry,
conf_item: &ConfigItem,
log: &mut Log,
source: &Source,
) -> OOM<()> {
match conf_item.optname {
ConfigOpt::_AuthToken => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.token = x;
}
}
ConfigOpt::Username => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.username = x;
}
}
ConfigOpt::_Password => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.password = x;
}
}
ConfigOpt::_Auth => {
handle_auth(v, conf_item, log, source)?;
}
ConfigOpt::Email => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.email = x;
}
}
ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(),
}
Ok(())
}

fn handle_auth(
v: &mut NpmRegistry,
conf_item: &ConfigItem,
Expand Down
85 changes: 74 additions & 11 deletions src/install/NetworkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,8 @@ impl NetworkTask {

#[derive(Clone, Copy)]
pub enum Authorization {
/// Do not attach the package scope's registry credential. `.npmrc`
/// `//host/:*=` entries that match the tarball URL are still honored.
NoAuthorization,
AllowAuthorization,
}
Expand Down Expand Up @@ -375,6 +377,52 @@ fn append_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope)
header_builder.append("npm-auth-type", "legacy");
}

/// Look up a `.npmrc` `//host/path/:*=` auth entry for a tarball URL. The
/// entry's host (including port) must match exactly; its pathname must be a
/// path prefix of the tarball pathname (npm walks the target path upward until
/// a matching key is found). Returns the entry with the longest matching
/// pathname so more specific entries win.
fn tarball_url_auth_for<'a>(
entries: &'a [npm::registry::Scope],
tarball: &URL<'_>,
) -> Option<&'a npm::registry::Scope> {
let tarball_path = tarball.pathname;
// `.npmrc` keys have no scheme, so a portless key can only mean "default
// port for the tarball's scheme" (npm builds the key from
// `new URL(uri).host`, which strips default ports). Normalize on the
// tarball side so `//cdn/:_authToken=` matches `https://cdn:443/...`.
let tarball_port_is_default = tarball.get_port() == Some(tarball.get_default_port());
let mut best: Option<(&'a npm::registry::Scope, usize)> = None;
for entry in entries {
let entry_url = entry.url.url();
let host_matches = if entry_url.port.is_empty() && tarball_port_is_default {
entry_url.hostname == tarball.hostname
} else {
strings::without_trailing_slash(entry_url.host)
== strings::without_trailing_slash(tarball.host)
};
if !host_matches {
continue;
}
Comment thread
robobun marked this conversation as resolved.
let entry_path = strings::without_trailing_slash(entry_url.pathname);
// `without_trailing_slash` keeps a lone `/`; treat it as the root
// prefix that matches every path.
let is_prefix = entry_path.is_empty()
|| entry_path == b"/"
|| (tarball_path.len() >= entry_path.len()
&& tarball_path[..entry_path.len()] == *entry_path
&& (tarball_path.len() == entry_path.len()
|| tarball_path[entry_path.len()] == b'/'));
if !is_prefix {
continue;
}
if best.is_none_or(|(_, len)| entry_path.len() > len) {
best = Some((entry, entry_path.len()));
}
}
best.map(|(s, _)| s)
}

fn count_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope) {
if !scope.token.is_empty() {
header_builder.count("Authorization", "");
Expand Down Expand Up @@ -802,28 +850,43 @@ impl NetworkTask {
// registries emit `dist.tarball` URLs with the default port spelled
// out; without normalization those installs lose the `Authorization`
// header and fail with 401.
let send_auth = matches!(authorization, Authorization::AllowAuthorization) && {
let tarball = URL::parse(&self.url_buf);
let registry = scope.url.url();
tarball.protocol == registry.protocol
&& tarball.hostname == registry.hostname
&& tarball.get_port_auto() == registry.get_port_auto()
};
//
// When the origin does NOT match, fall back to `.npmrc` `//host/:*=`
// entries the user configured explicitly for the tarball host
// (`options.tarball_url_auth`), so registries that serve tarballs from
// a separate authenticated origin have a config escape hatch. This is
// how `npm-registry-fetch` resolves auth: by the URL being fetched,
// not by the package scope.
let auth_scope: Option<&npm::registry::Scope> =
if matches!(authorization, Authorization::AllowAuthorization) {
let tarball = URL::parse(&self.url_buf);
let registry = scope.url.url();
if tarball.protocol == registry.protocol
&& tarball.hostname == registry.hostname
&& tarball.get_port_auto() == registry.get_port_auto()
{
Some(scope)
} else {
tarball_url_auth_for(&pm.options.tarball_url_auth, &tarball)
}
} else {
Comment thread
robobun marked this conversation as resolved.
tarball_url_auth_for(&pm.options.tarball_url_auth, &URL::parse(&self.url_buf))
};

self.response_buffer = MutableString::init_empty();

let mut header_builder = HeaderBuilder::default();
let mut header_buf: &'static [u8] = b"";

if send_auth {
count_auth(&mut header_builder, scope);
if let Some(auth_scope) = auth_scope {
count_auth(&mut header_builder, auth_scope);
}

if header_builder.header_count > 0 {
header_builder.allocate()?;

if send_auth {
append_auth(&mut header_builder, scope);
if let Some(auth_scope) = auth_scope {
append_auth(&mut header_builder, auth_scope);
}

// SAFETY: `written_slice()` is the safe (ptr,len) accessor; only the
Expand Down
13 changes: 13 additions & 0 deletions src/install/PackageManager/PackageManagerOptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ pub struct Options {
pub scope: Npm::registry::Scope,

pub registries: Npm::registry::Map,
/// `.npmrc` `//host/path/:*=` auth entries that do not match any
/// configured registry. Consulted by tarball downloads when
/// `dist.tarball` points at a different origin than the registry.
pub tarball_url_auth: Vec<Npm::registry::Scope>,
pub cache_directory: &'static [u8],
pub enable: Enable,
pub do_: Do,
Expand Down Expand Up @@ -104,6 +108,7 @@ impl Default for Options {
// Always assigned in `load()` before read.
scope: Npm::registry::Scope::default(),
registries: Npm::registry::Map::default(),
tarball_url_auth: Vec::new(),
cache_directory: b"",
enable: Enable::default(),
do_: Do::default(),
Expand Down Expand Up @@ -435,6 +440,14 @@ impl Options {
}
}

for registry_ in &config.tarball_url_auth {
self.tarball_url_auth.push(Npm::registry::Scope::from_api(
b"",
registry_.clone(),
env,
)?);
}

if let Some(ca) = &config.ca {
match ca {
Api::Ca::List(ca_list) => {
Expand Down
7 changes: 7 additions & 0 deletions src/options_types/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ pub mod api {
pub default_registry: Option<NpmRegistry>,
/// scoped
pub scoped: Option<NpmRegistryMap>,
/// `.npmrc` `//host/path/:_authToken=`-style entries whose URL does not
/// match the default registry or any scoped registry. Consulted by
/// tarball downloads when `dist.tarball` points at a host other than
/// the configured registry origin (Artifactory/Nexus with a separate
/// tarball/CDN host). `NpmRegistry::url` holds the raw `.npmrc` URL
/// part (no protocol, e.g. `cdn.example.com/`).
pub tarball_url_auth: Vec<NpmRegistry>,
/// lockfile_path
pub lockfile_path: Option<Box<[u8]>>,
/// save_lockfile_path
Expand Down
Loading
Loading