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
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
23 changes: 2 additions & 21 deletions src/api/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#![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 used by the bunfig and npmrc loaders.

// ──────────────────────────────────────────────────────────────────────────
// Re-exports — canonical definitions live in `bun_options_types::schema::api`.
Expand All @@ -19,8 +19,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 @@ -38,24 +36,7 @@ pub mod npm_registry {
&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
74 changes: 38 additions & 36 deletions src/install/PackageManager/PackageManagerOptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,30 +607,51 @@ 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 in the URL win, as they do for `registry=` in .npmrc.
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;
}
}
}
}

if let Some(cli) = &maybe_cli {
if !cli.registry.is_empty() {
let api_registry = Api::NpmRegistry::from_url(cli.registry);
if api_registry.has_credentials() {
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);
}
}
}

{
const TOKEN_KEYS: [&[u8]; 3] = [
b"BUN_CONFIG_TOKEN",
Expand Down Expand Up @@ -687,25 +708,6 @@ impl Options {
self.enable
.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 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 {
self.cache_directory = cache_dir;
}
Expand Down
25 changes: 25 additions & 0 deletions src/options_types/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,31 @@ pub mod api {
pub email: Box<[u8]>,
}

impl NpmRegistry {
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