Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion src/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,3 @@ workspace = true
[dependencies]
bun_alloc.workspace = true
bun_options_types.workspace = true
bun_url.workspace = true
26 changes: 5 additions & 21 deletions src/api/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
#![warn(unused_must_use)]
//! Re-exports of the install config types (`BunInstall`, `NpmRegistry`, …)
//! whose canonical definitions live in `bun_options_types::schema::api`, plus
//! the registry-URL parser shared by the bunfig and npmrc loaders.
//! the `Parser` handle through which the bunfig and npmrc loaders parse
//! registry URL strings (`NpmRegistry::from_url`).
Comment thread
robobun marked this conversation as resolved.
Outdated

// ──────────────────────────────────────────────────────────────────────────
// Re-exports — canonical definitions live in `bun_options_types::schema::api`.
Expand All @@ -19,8 +20,6 @@ pub use bun_options_types::schema::api::{
/// `Parser` lives in a sibling module of `NpmRegistry`; the canonical path
/// is `bun_api::npm_registry::Parser`.
pub mod npm_registry {
use bun_url::URL;

pub use super::NpmRegistry;

// `Parser` stays generic over `L` (Log) / `S` (Source) so this leaf
Expand All @@ -34,28 +33,13 @@ pub mod npm_registry {
}

impl<'a, L, S> Parser<'a, L, S> {
/// The bunfig / .npmrc entry point of `NpmRegistry::from_url`;
/// `--registry` and the registry env vars call that directly.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn parse_registry_url_string_impl(
&mut self,
str: &[u8],
) -> Result<NpmRegistry, bun_alloc::AllocError> {
let url = URL::parse(str);
let mut registry = NpmRegistry::default();

// Token
if url.username.is_empty() && !url.password.is_empty() {
registry.token = Box::<[u8]>::from(url.password);
registry.url = url.href_without_auth();
} else if !url.username.is_empty() && !url.password.is_empty() {
registry.username = Box::<[u8]>::from(url.username);
registry.password = Box::<[u8]>::from(url.password);

registry.url = url.href_without_auth();
} else {
// Do not include a trailing slash. There might be parameters at the end.
registry.url = Box::<[u8]>::from(url.href);
}

Ok(registry)
Ok(NpmRegistry::from_url(str))
}
}
}
8 changes: 2 additions & 6 deletions src/ini/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1303,16 +1303,12 @@ mod draft {
configs
}

fn has_credentials(registry: &NpmRegistry) -> bool {
!registry.token.is_empty() || !registry.username.is_empty() || !registry.password.is_empty()
}

pub fn apply_registry_auth(install: &mut BunInstall, auth: &[RegistryAuth]) {
if auth.is_empty() {
return;
}
if let Some(registry) = install.default_registry.as_mut() {
if !has_credentials(registry) {
if !registry.has_credentials() {
for item in auth {
let matched = item.matches(if registry.url.is_empty() {
bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL.as_bytes()
Expand All @@ -1327,7 +1323,7 @@ mod draft {
}
if let Some(scoped) = install.scoped.as_mut() {
for registry in scoped.scopes.values_mut() {
if has_credentials(registry) {
if registry.has_credentials() {
continue;
}
for item in auth {
Expand Down
68 changes: 36 additions & 32 deletions src/install/PackageManager/PackageManagerOptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,23 +607,20 @@ impl Options {
if !registry_.is_empty()
&& (registry_.starts_with(b"https://") || registry_.starts_with(b"http://"))
{
let prev_scope = self.scope.clone();
let prev_url = prev_scope.url.url();
let new_url = bun_url::URL::parse(registry_);
let token = if bun_core::without_trailing_slash(new_url.host)
== bun_core::without_trailing_slash(prev_url.host)
&& (new_url.is_https() || !prev_url.is_https())
{
prev_scope.token
} else {
Box::default()
};
// Default (empty strings) is the zero value for Api::NpmRegistry.
let api_registry = Api::NpmRegistry {
url: registry_.into(),
token,
..Default::default()
};
let mut api_registry = Api::NpmRegistry::from_url(registry_);
// Credentials written into the URL replace the configured
// ones, like a `registry=` line with userinfo does in .npmrc.
// Otherwise a token configured for the same host carries over.
Comment thread
robobun marked this conversation as resolved.
Outdated
if !api_registry.has_credentials() {
let prev_url = self.scope.url.url();
let new_url = bun_url::URL::parse(&api_registry.url);
if bun_core::without_trailing_slash(new_url.host)
== bun_core::without_trailing_slash(prev_url.host)
&& (new_url.is_https() || !prev_url.is_https())
{
api_registry.token = core::mem::take(&mut self.scope.token);
}
}
self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?;
break;
}
Expand Down Expand Up @@ -688,22 +685,29 @@ impl Options {
.set(Enable::ONLY_MISSING, cli.only_missing || cli.analyze);

if !cli.registry.is_empty() {
let new_url = bun_url::URL::parse(cli.registry);
let same_origin = {
let prev_url = self.scope.url.url();
bun_core::without_trailing_slash(new_url.host)
== bun_core::without_trailing_slash(prev_url.host)
&& (new_url.is_https() || !prev_url.is_https())
};
if !same_origin {
self.scope.token = Box::default();
self.scope.auth = Box::default();
self.scope.user = Box::default();
let api_registry = Api::NpmRegistry::from_url(cli.registry);
if api_registry.has_credentials() {
// Same rule as the env registry above: credentials in the
// URL replace whatever was configured for the registry.
Comment thread
robobun marked this conversation as resolved.
Outdated
self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?;
} else {
let new_url = bun_url::URL::parse(&api_registry.url);
let same_origin = {
let prev_url = self.scope.url.url();
bun_core::without_trailing_slash(new_url.host)
== bun_core::without_trailing_slash(prev_url.host)
&& (new_url.is_https() || !prev_url.is_https())
};
if !same_origin {
self.scope.token = Box::default();
self.scope.auth = Box::default();
self.scope.user = Box::default();
}
let href = api_registry.url;
self.scope.url_hash =
Npm::registry::Scope::hash(bun_core::without_trailing_slash(&href));
self.scope.url = bun_url::OwnedURL::from_href(href);
}
let href: Box<[u8]> = cli.registry.into();
self.scope.url_hash =
Npm::registry::Scope::hash(bun_core::without_trailing_slash(&href));
self.scope.url = bun_url::OwnedURL::from_href(href);
}

if let Some(cache_dir) = cli.cache_dir {
Expand Down
31 changes: 31 additions & 0 deletions src/options_types/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,37 @@ pub mod api {
pub email: Box<[u8]>,
}

impl NpmRegistry {
/// Parses a registry given as a bare URL string (`registry=` in
/// .npmrc, `install.registry = "..."` in bunfig, `--registry`,
/// `BUN_CONFIG_REGISTRY`). Credentials written into the URL are moved
/// out of it: `https://user:pass@host/` becomes `username`/`password`
/// and `https://:token@host/` becomes `token`, so the stored `url`
/// never carries them.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn from_url(str: &[u8]) -> NpmRegistry {
let url = bun_url::URL::parse(str);
let mut registry = NpmRegistry::default();

if url.username.is_empty() && !url.password.is_empty() {
registry.token = Box::from(url.password);
registry.url = url.href_without_auth();
} else if !url.username.is_empty() && !url.password.is_empty() {
registry.username = Box::from(url.username);
registry.password = Box::from(url.password);
registry.url = url.href_without_auth();
} else {
// Do not include a trailing slash. There might be parameters at the end.
registry.url = Box::from(url.href);
}

registry
}

pub fn has_credentials(&self) -> bool {
!self.token.is_empty() || !self.username.is_empty() || !self.password.is_empty()
}
Comment thread
claude[bot] marked this conversation as resolved.
}

/// Per-scope npm registry overrides, keyed by scope name.
#[derive(Default)]
pub struct NpmRegistryMap {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/publish_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,7 @@ impl PublishCommand {
let registry_url = registry.url.url();

if registry.token.is_empty()
&& registry.auth.is_empty()
&& (registry_url.password.is_empty() || registry_url.username.is_empty())
{
return Err(PublishError::NeedAuth);
Expand Down
89 changes: 89 additions & 0 deletions test/cli/install/bun-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,95 @@ describe("--dry-run", async () => {
});
});

describe.concurrent("credentials in the registry url", () => {
// Records every request; `bun publish` against it must send exactly one authenticated PUT.
function registryMock() {
const requests: { method: string; pathname: string; authorization: string | null }[] = [];
const server = Bun.serve({
port: 0,
fetch(req) {
requests.push({
method: req.method,
pathname: new URL(req.url).pathname,
authorization: req.headers.get("authorization"),
});
return new Response("OK", { status: 200 });
},
});
return {
requests,
port: server.port,
[Symbol.dispose]: () => server.stop(true),
};
}

async function packageDirFor(name: string) {
const packageDir = tmpdirSync();
await write(join(packageDir, "package.json"), JSON.stringify({ name, version: "1.0.0" }));
return packageDir;
}

const basicAuth = `Basic ${Buffer.from("pubuser:hunter2").toString("base64")}`;

test("--registry with user:pass@ publishes with Basic auth", async () => {
using mock = registryMock();
const packageDir = await packageDirFor("userinfo-flag-pkg");

const { out, err, exitCode } = await publish(
env,
packageDir,
"--registry",
`http://pubuser:hunter2@localhost:${mock.port}/`,
);
expect(err).not.toContain("error:");
expect(out).toContain(`Registry: http://localhost:${mock.port}/\n`);
expect(out).toContain(" + userinfo-flag-pkg@1.0.0");
expect(out).not.toContain("hunter2");
expect(err).not.toContain("hunter2");
expect(mock.requests).toEqual([{ method: "PUT", pathname: "/userinfo-flag-pkg", authorization: basicAuth }]);
expect(exitCode).toBe(0);
});

test("--registry with :token@ publishes with a Bearer token", async () => {
using mock = registryMock();
const packageDir = await packageDirFor("userinfo-token-pkg");

const { out, err, exitCode } = await publish(
env,
packageDir,
"--registry",
`http://:publish-token@localhost:${mock.port}/`,
);
expect(err).not.toContain("error:");
expect(out).toContain(" + userinfo-token-pkg@1.0.0");
expect(out).not.toContain("publish-token");
expect(mock.requests).toEqual([
{ method: "PUT", pathname: "/userinfo-token-pkg", authorization: "Bearer publish-token" },
]);
expect(exitCode).toBe(0);
});

test.each(["npm_config_registry", "NPM_CONFIG_REGISTRY", "BUN_CONFIG_REGISTRY"])(
"%s with user:pass@ publishes with Basic auth",
async key => {
using mock = registryMock();
const packageDir = await packageDirFor("userinfo-env-pkg");

const { out, err, exitCode } = await publish(
{ ...env, [key]: `http://pubuser:hunter2@localhost:${mock.port}/` },
packageDir,
);
expect(err).not.toContain("error:");
expect(out).toContain(`Registry: http://localhost:${mock.port}/\n`);
expect(out).toContain(" + userinfo-env-pkg@1.0.0");
expect(out).not.toContain("hunter2");
expect(err).not.toContain("hunter2");
expect(mock.requests).toEqual([{ method: "PUT", pathname: "/userinfo-env-pkg", authorization: basicAuth }]);
expect(exitCode).toBe(0);
},
);
});

describe("lifecycle scripts", async () => {
const script = `const fs = require("fs");
fs.writeFileSync(process.argv[2] + ".txt", \`
Expand Down
Loading
Loading