diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index 6e734c9f32bc..9170c13a786d 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -188,6 +188,8 @@ ca[]="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" cafile=/path/to/ca-bundle.crt ``` +If both are set, only `cafile` is used. A `ca` or `cafile` in `bunfig.toml`, or `--ca` / `--cafile` on the command line, replaces both. + ### `omit` and `include`: Control dependency types Control which dependency types Bun installs: diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index e2d3e8d589a7..c6f7d70c5940 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -618,6 +618,8 @@ ca = "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" cafile = "path/to/cafile" ``` +Set one of the two. If both are set, only `cafile` is used. `--ca` or `--cafile` on the command line replaces both. + ### `install.cache` To configure the cache behavior: diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index 0d8afad488d1..c936aa845b6c 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -1272,7 +1272,15 @@ impl<'a> Parser<'a> { install: &mut api::BunInstall, install_obj: &Expr, ) -> crate::Result<()> { - if let Some(cafile) = install_obj.get(b"cafile") { + let cafile = install_obj.get(b"cafile"); + let ca = install_obj.get(b"ca"); + // `ca` + `cafile` are one setting: a file that sets either replaces both. + if cafile.is_some() || ca.is_some() { + install.cafile = None; + install.ca = None; + } + + if let Some(cafile) = cafile { install.cafile = match cafile.as_string(self.bump) { Some(s) => Some(s.into()), None => { @@ -1282,7 +1290,7 @@ impl<'a> Parser<'a> { }; } - if let Some(ca) = install_obj.get(b"ca") { + if let Some(ca) = ca { match &ca.data { ExprData::EArray(arr) => { let items = arr.items.slice(); diff --git a/src/ini/lib.rs b/src/ini/lib.rs index a9de2636cb6b..5ac4c4345621 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1367,7 +1367,15 @@ mod draft { } } - if let Some(query) = out.as_property(b"ca") { + let ca = out.as_property(b"ca"); + let cafile = out.as_property(b"cafile"); + // `ca` + `cafile` are one setting: a file that sets either replaces both. + if ca.is_some() || cafile.is_some() { + install.ca = None; + install.cafile = None; + } + + if let Some(query) = ca { if let Some(str_) = query.expr.as_utf8_string_literal() { install.ca = Some(bun_api::Ca::Str(Box::<[u8]>::from(str_))); } else if let ExprData::EArray(arr) = &query.expr.data { @@ -1381,7 +1389,7 @@ mod draft { } } - if let Some(query) = out.as_property(b"cafile") { + if let Some(query) = cafile { if let Some(cafile) = query.expr.as_string_cloned(bump)? { install.cafile = Some(Box::<[u8]>::from(cafile)); } diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index e2c97f58844d..b77353a96e8e 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1431,6 +1431,12 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall } } + // `ca` + `cafile` are one setting: bunfig replaces both or neither. + if ca.is_some() || cafile.is_some() { + install.ca = ca; + install.cafile = cafile; + } + macro_rules! overlay { ($($field:ident),* $(,)?) => { $( if $field.is_some() { install.$field = $field; } )* @@ -1455,9 +1461,7 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall frozen_lockfile, exact, concurrent_scripts, - cafile, save_text_lockfile, - ca, ignore_scripts, link_workspace_packages, node_linker, diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index e89a22f8b2e0..b0e591ee5f44 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -898,10 +898,9 @@ impl Options { } self.publish_config.tolerate_republish = cli.tolerate_republish; - if !cli.ca.is_empty() { + // `--ca` / `--cafile` replace the config file's `ca` + `cafile` as a whole. + if !cli.ca.is_empty() || !cli.ca_file_name.is_empty() { self.ca = cli.ca.iter().map(|s| Box::<[u8]>::from(*s)).collect(); - } - if !cli.ca_file_name.is_empty() { self.ca_file_name = cli.ca_file_name; } diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index dc76901d5a69..50682b2006b4 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -232,6 +232,111 @@ describe("certificate authority", () => { expect(err).not.toContain("error:"); expect(await exited).toBe(0); }); + test("--ca replaces a cafile from bunfig", async () => { + // The registry's certificate is only in --ca; the bunfig cafile holds an + // unrelated CA. The CLI flag replaces the config file's CA settings as a + // whole instead of being shadowed by its cafile. + using server = Bun.serve({ + port: 0, + fetch: mockRegistryFetch(), + ...tls, + }); + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.1.1", + dependencies: { + "no-deps": `https://localhost:${server.port}/no-deps-1.0.0.tgz`, + }, + }), + ), + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { + cache: false, + registry: `https://localhost:${server.port}/`, + cafile: "unrelated-ca.pem", + }, + }), + ), + write( + join(packageDir, "unrelated-ca.pem"), + file(join(import.meta.dir, "..", "..", "js", "node", "tls", "fixtures", "ca1-cert.pem")), + ), + ]); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install", "--ca", tls.cert], + cwd: packageDir, + stderr: "pipe", + stdout: "pipe", + env, + }); + const out = await stdout.text(); + const err = await stderr.text(); + expect(err).not.toContain("DEPTH_ZERO_SELF_SIGNED_CERT"); + expect(err).not.toContain("error:"); + expect(out).toContain("+ no-deps@"); + expect(await exited).toBe(0); + }); + // Each higher layer's `ca` must replace a lower layer's `cafile` (the lower + // file holds an unrelated CA, the registry's certificate is only in `ca`). + test.each(["bunfig ca over .npmrc cafile", "project .npmrc ca over user .npmrc cafile"])("%s", async layers => { + using server = Bun.serve({ + port: 0, + fetch: mockRegistryFetch(), + ...tls, + }); + const homeDir = join(packageDir, "home_dir"); + const unrelatedCa = join(packageDir, "unrelated-ca.pem"); + const npmrcCa = `ca=${JSON.stringify(tls.cert)}\n`; + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.1.1", + dependencies: { + "no-deps": `https://localhost:${server.port}/no-deps-1.0.0.tgz`, + }, + }), + ), + write(unrelatedCa, file(join(import.meta.dir, "..", "..", "js", "node", "tls", "fixtures", "ca1-cert.pem"))), + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { + cache: false, + registry: `https://localhost:${server.port}/`, + ...(layers.startsWith("bunfig") ? { ca: tls.cert } : {}), + }, + }), + ), + layers.startsWith("bunfig") + ? write(join(packageDir, ".npmrc"), `cafile=${unrelatedCa}\n`) + : Promise.all([ + write(join(homeDir, ".npmrc"), `cafile=${unrelatedCa}\n`), + write(join(packageDir, ".npmrc"), npmrcCa), + ]), + ]); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stderr: "pipe", + stdout: "pipe", + env: { ...env, XDG_CONFIG_HOME: homeDir }, + }); + const out = await stdout.text(); + const err = await stderr.text(); + expect(err).not.toContain("DEPTH_ZERO_SELF_SIGNED_CERT"); + expect(err).not.toContain("error:"); + expect(out).toContain("+ no-deps@"); + expect(await exited).toBe(0); + }); test(`non-existent --cafile`, async () => { await write(packageJson, JSON.stringify({ name: "foo", version: "1.0.0", "dependencies": { "no-deps": "1.1.1" } }));