fix(config/reader): resolve relative cafile path against the .npmrc directory (#11726)

* fix(config/reader): resolve relative cafile path against the .npmrc directory

`cafile=<relative-path>` in `.npmrc` was being read via `fs.readFileSync`,
which resolves relative paths against `process.cwd()`. When pnpm is invoked
from a different cwd than the project (e.g. `pnpm --dir <project> install`
in CI wrappers and monorepo scripts), the CA file silently failed to load:
the `try/catch` in the loader dropped the CA list, the install proceeded
without the configured CA, and the user only saw TLS errors against a
private registry — with no log line tying back to the wrong path.

Resolve relative `cafile` values in `readAndFilterNpmrc` against
`path.dirname(filePath)` of the .npmrc that declared the key, before
`loadCAFile` reads the file. Absolute paths (the dominant CI shape) and
CLI `--cafile` are unchanged.

Ref: #11624

* refactor(config/reader): tighten cafile-fix comments

The test name and the linked issue already describe the failure mode,
so the 4-line preamble on the test and the 5-line in-line comment on
the implementation were re-narrating what the tests document.

---
Written by an agent (Claude Code, claude-opus-4-7).

* fix(pacquet/config): resolve relative cafile path against the .npmrc directory

Pacquet's `NpmrcAuth::from_ini` used to store the `cafile=` value
verbatim and pass it to `std::fs::read_to_string` at apply time. A
relative path therefore resolved against the process cwd, so a
project `.npmrc` containing `cafile=certs/ca.pem` reached via
`pacquet --dir <proj>` from a different cwd silently failed to load
the CA — same failure mode as pnpm/pnpm#11624 on the TypeScript side,
which the parent commit fixed by resolving against `path.dirname` of
the `.npmrc`.

Mirrors the parent commit on the pacquet side:

- `NpmrcAuth::from_ini` now takes the directory the `.npmrc` was
  loaded from. A relative non-empty `cafile=` value is resolved
  against that directory via `npmrc_dir.join(...)`; empty and
  absolute values pass through unchanged.

- `Config::current` tracks which of `start_dir` / home dir actually
  provided the `.npmrc` text and passes that path through.

- The `load_cafile` doc comment that documented "matches pnpm's
  surprising cwd-resolution behavior" is gone; that caveat was
  current only as long as pnpm itself had the bug.

- Existing tests updated mechanically to pass `Path::new("")` for
  the new parameter; four new tests cover the resolution branches
  (relative resolves, absolute passes through, empty passes through,
  end-to-end load via `apply_to` with a real tempdir-based fixture).

---
Written by an agent (Claude Code, claude-opus-4-7).

* fix(pacquet/config): add trailing commas inside multi-line assert_eq! macros

`perfectionist::macro-trailing-comma` is enforced via dylint at CI and
ran clean before the cafile port. Rustfmt reflowed two `assert_eq!`
calls in `parses_strict_ssl_true_and_false` onto multiple lines when
the `Path::new("")` argument made the line too long, but did not add
the trailing comma the dylint rule wants on the last macro argument.

---
Written by an agent (Claude Code, claude-opus-4-7).

---------

Co-authored-by: shiminshen <16914659+shiminshen@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
shiminshen
2026-05-20 06:32:18 +08:00
committed by GitHub
parent a62055786b
commit 3687b0e180
6 changed files with 181 additions and 69 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Fix `cafile=<relative-path>` in `.npmrc` being read from the wrong directory when pnpm is invoked from a different cwd (e.g. `pnpm --dir <project> install` from a CI wrapper or monorepo script). The path is now resolved against the directory of the `.npmrc` that declared it, not `process.cwd()`. Before this fix the CA file silently failed to load — the install proceeded without the configured CA and the user only saw TLS errors against a private registry, with no log line tying back to the wrongly resolved path [#11624](https://github.com/pnpm/pnpm/issues/11624).

View File

@@ -137,16 +137,23 @@ function readAndFilterNpmrc (
return {}
}
const npmrcDir = path.dirname(filePath)
const result: Record<string, unknown> = {}
for (const [rawKey, rawValue] of Object.entries(raw)) {
// Apply ${VAR} substitution to both keys and values
const key = substituteEnv(rawKey, env, warnings)
const value = typeof rawValue === 'string'
let value: unknown = typeof rawValue === 'string'
? substituteEnv(rawValue, env, warnings)
: rawValue
// Only keep auth/registry related keys
if (isNpmrcReadableKey(key)) {
// A relative `cafile=` resolves against the .npmrc's directory rather
// than process.cwd(), so `pnpm --dir <project>` from a different cwd
// still finds it. See https://github.com/pnpm/pnpm/issues/11624.
if (key === 'cafile' && typeof value === 'string' && value !== '' && !path.isAbsolute(value)) {
value = path.resolve(npmrcDir, value)
}
result[key] = value
}
}

View File

@@ -1525,6 +1525,27 @@ test('getConfig() should read cafile', async () => {
-----END CERTIFICATE-----`])
})
// Regression for https://github.com/pnpm/pnpm/issues/11624.
test('getConfig() resolves a relative cafile= from .npmrc against the npmrc directory, not process.cwd()', async () => {
prepareEmpty()
const projectDir = path.resolve('project')
fs.mkdirSync(path.join(projectDir, 'certs'), { recursive: true })
fs.writeFileSync(
path.join(projectDir, 'certs', 'ca.pem'),
'relative-ca\n-----END CERTIFICATE-----'
)
fs.writeFileSync(path.join(projectDir, '.npmrc'), 'cafile=certs/ca.pem\n')
// process.cwd() is the prepareEmpty() root, *not* projectDir — i.e. the same
// shape as `pnpm --dir <projectDir> install` invoked from a sibling cwd.
const { config } = await getConfig({
cliOptions: { dir: projectDir },
packageManager: { name: 'pnpm', version: '1.0.0' },
})
expect(config.ca).toStrictEqual(['relative-ca\n-----END CERTIFICATE-----'])
})
test('getConfig() should read inline SSL certificates from .npmrc', async () => {
prepareEmpty()

View File

@@ -931,10 +931,11 @@ impl Config {
// had a chance to override `registry`. Pnpm keys default-registry
// creds at the final resolved URL, not the `.npmrc` literal — see
// [`getAuthHeadersFromConfig`](https://github.com/pnpm/pnpm/blob/601317e7a3/network/auth-header/src/getAuthHeadersFromConfig.ts).
let auth_source =
read_npmrc(start_dir).or_else(|| Sys::home_dir().and_then(|dir| read_npmrc(&dir)));
let auth_source = read_npmrc(start_dir)
.map(|text| (text, start_dir.to_path_buf()))
.or_else(|| Sys::home_dir().and_then(|dir| read_npmrc(&dir).map(|text| (text, dir))));
let mut npmrc_auth = auth_source
.map(|text| crate::npmrc_auth::NpmrcAuth::from_ini::<Sys>(&text))
.map(|(text, dir)| crate::npmrc_auth::NpmrcAuth::from_ini::<Sys>(&text, &dir))
.unwrap_or_default();
npmrc_auth.apply_registry_and_warn(&mut self);
// Proxy cascade fires unconditionally — even when no `.npmrc`

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use pacquet_network::{AuthHeaders, NoProxySetting, PerRegistryTls, RegistryTls, base64_encode};
@@ -77,7 +77,10 @@ pub(crate) struct NpmrcAuth {
/// `-----END CERTIFICATE-----` to produce one PEM per cert
/// (mirroring pnpm's
/// [`loadCAFile`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/loadNpmrcFiles.ts#L249-L255)).
/// `cafile`-not-found is silently treated as unset.
/// `cafile`-not-found is silently treated as unset. A relative
/// path is resolved against the directory of the `.npmrc` that
/// declared it (matching pnpm/pnpm#11726), so `pnpm --dir <proj>`
/// from a different cwd still finds it.
pub cafile: Option<String>,
/// `cert=…` client certificate PEM from .npmrc.
pub cert: Option<String>,
@@ -143,7 +146,12 @@ impl NpmrcAuth {
/// plus comments starting with `;` or `#`. We hand-parse rather than
/// use a strongly-typed deserializer so unknown / malformed keys don't
/// blow up parsing.
pub fn from_ini<Sys: EnvVar>(text: &str) -> Self {
///
/// `npmrc_dir` is the directory of the `.npmrc` file the `text`
/// came from. A relative `cafile=` resolves against it so a
/// project `.npmrc` reachable via `pacquet --dir <proj>` from a
/// different cwd still finds its CA bundle (pnpm/pnpm#11726).
pub fn from_ini<Sys: EnvVar>(text: &str, npmrc_dir: &Path) -> Self {
let mut auth = NpmrcAuth::default();
for line in text.lines() {
let line = line.trim();
@@ -195,7 +203,7 @@ impl NpmrcAuth {
continue;
}
"cafile" => {
auth.cafile = Some(value);
auth.cafile = Some(resolve_cafile(value, npmrc_dir));
continue;
}
"cert" => {
@@ -272,9 +280,9 @@ impl NpmrcAuth {
///
/// `strict_ssl`, `cert`, `key` are pass-through (no transformation).
///
/// `cafile` reads relative paths against the process cwd, matching
/// pnpm's `path.resolve(cafile)` in
/// [`loadCAFile`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/loadNpmrcFiles.ts#L241-L243).
/// `cafile` paths arrive here already absolute — relative values
/// were resolved against the `.npmrc`'s directory in
/// [`NpmrcAuth::from_ini`] (pnpm/pnpm#11726).
pub fn apply_tls_and_local_address(&mut self, config: &mut Config) {
// Inline CA first, then file-loaded CA, so a user that
// duplicates a cert across both ends up with it added twice
@@ -399,6 +407,18 @@ impl NpmrcAuth {
}
}
/// Resolve a top-level `cafile=` value against the directory of the
/// `.npmrc` that declared it. Empty and absolute values pass through
/// unchanged; relative values are joined onto `npmrc_dir`. Mirrors
/// pnpm/pnpm#11726.
fn resolve_cafile(value: String, npmrc_dir: &Path) -> String {
if value.is_empty() || Path::new(&value).is_absolute() {
return value;
}
let resolved: PathBuf = npmrc_dir.join(&value);
resolved.into_os_string().into_string().unwrap_or(value)
}
/// Parse a `strict-ssl=…` value. pnpm/nopt accepts only the literal
/// `true` and `false` tokens; anything else is dropped silently so the
/// dispatcher's per-emit `strictSsl ?? true` default kicks in.
@@ -416,20 +436,6 @@ fn parse_bool(value: &str) -> Option<bool> {
/// [`loadCAFile`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/loadNpmrcFiles.ts#L238-L265):
/// re-append the delimiter to each split, trim, drop empties, and
/// silently treat any read error as an empty list.
///
/// **Relative-path resolution caveat.** Pnpm's `loadCAFile` passes
/// `cafile` straight to `fs.readFileSync` without `path.resolve` —
/// relative paths therefore resolve against Node's `process.cwd()`,
/// *not* against the directory the `.npmrc` was read from. Pnpm
/// doesn't `process.chdir(opts.dir)` on `--dir`, so a project
/// `.npmrc` containing `cafile=certs/ca.pem` invoked as
/// `pnpm --dir /project install` from `/home/user` reads
/// `/home/user/certs/ca.pem` and silently drops the CA list when it
/// isn't found. Pacquet matches that exact behavior (cardinal rule:
/// match pnpm even when the upstream behavior is surprising). Users
/// who hit this should either use an absolute path in `cafile=`,
/// `cd` into the project directory before running pacquet, or set
/// the `ca=` inline form which doesn't read from disk.
fn load_cafile(path: &Path) -> Vec<String> {
let Ok(contents) = std::fs::read_to_string(path) else {
return Vec::new();

View File

@@ -1,3 +1,5 @@
use std::path::Path;
use super::{EnvVar, NpmrcAuth, RawCreds, base64_decode, base64_encode};
use crate::Config;
use pacquet_network::NoProxySetting;
@@ -35,7 +37,7 @@ impl EnvVar for NoEnv {
#[test]
fn picks_up_registry_and_normalises_trailing_slash() {
let ini = "registry=https://r.example\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(auth.registry.as_deref(), Some("https://r.example"));
let mut config = Config::new();
@@ -46,7 +48,8 @@ fn picks_up_registry_and_normalises_trailing_slash() {
#[test]
fn preserves_existing_trailing_slash() {
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>("registry=https://r.example/\n").apply_to::<NoEnv>(&mut config);
NpmrcAuth::from_ini::<NoEnv>("registry=https://r.example/\n", Path::new(""))
.apply_to::<NoEnv>(&mut config);
assert_eq!(config.registry, "https://r.example/");
}
@@ -76,7 +79,7 @@ node-linker=hoisted
";
let config_before = Config::new();
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>(ini).apply_to::<NoEnv>(&mut config);
NpmrcAuth::from_ini::<NoEnv>(ini, Path::new("")).apply_to::<NoEnv>(&mut config);
assert_eq!(config.store_dir, config_before.store_dir);
assert_eq!(config.lockfile, config_before.lockfile);
assert_eq!(config.hoist, config_before.hoist);
@@ -92,21 +95,21 @@ fn ignores_comments_and_empty_lines() {
registry=https://r.example
# trailing comment
";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(auth.registry.as_deref(), Some("https://r.example"));
}
#[test]
fn ignores_malformed_lines() {
let ini = "not_a_key_value\nregistry=https://r.example\n=orphan_equals\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(auth.registry.as_deref(), Some("https://r.example"));
}
#[test]
fn parses_per_registry_auth_token() {
let ini = "//npm.pkg.github.com/pnpm/:_authToken=ghp_xxx\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(
auth.creds_by_uri
.get("//npm.pkg.github.com/pnpm/")
@@ -118,7 +121,7 @@ fn parses_per_registry_auth_token() {
#[test]
fn parses_default_auth_token_and_keys_to_registry() {
let ini = "_authToken=top-secret\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(auth.default_creds.auth_token.as_deref(), Some("top-secret"));
let mut config = Config::new();
@@ -138,7 +141,7 @@ fn env_replace_substitutes_token() {
}
}
let ini = "//reg.com/:_authToken=${TOKEN}\n";
let auth = NpmrcAuth::from_ini::<EnvWithToken>(ini);
let auth = NpmrcAuth::from_ini::<EnvWithToken>(ini, Path::new(""));
assert_eq!(
auth.creds_by_uri.get("//reg.com/").map(|creds| creds.auth_token.as_deref()),
Some(Some("abc123")),
@@ -151,7 +154,7 @@ fn env_replace_failure_warns_and_drops_unresolved_to_empty() {
// "" so a downstream `Authorization: Bearer ...` header is never sent with a
// literal placeholder. See https://github.com/pnpm/pnpm/issues/11513.
let ini = "//reg.com/:_authToken=${MISSING}\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(
auth.creds_by_uri.get("//reg.com/").map(|creds| creds.auth_token.as_deref()),
Some(Some("")),
@@ -173,7 +176,7 @@ fn env_replace_failure_preserves_resolved_and_default_placeholders() {
}
}
let ini = "//reg.com/:_authToken=${SET}-${UNSET}-${DEFAULTED:-fallback}\n";
let auth = NpmrcAuth::from_ini::<EnvWithSet>(ini);
let auth = NpmrcAuth::from_ini::<EnvWithSet>(ini, Path::new(""));
assert_eq!(
auth.creds_by_uri.get("//reg.com/").map(|creds| creds.auth_token.as_deref()),
Some(Some("AAA--fallback")),
@@ -190,7 +193,7 @@ fn basic_auth_built_from_username_and_password() {
let password_b64 = base64_encode(raw_password);
let ini = format!("//reg.com/:username=alice\n//reg.com/:_password={password_b64}\n");
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>(&ini).apply_to::<NoEnv>(&mut config);
NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new("")).apply_to::<NoEnv>(&mut config);
assert_eq!(
config.auth_headers.for_url("https://reg.com/").as_deref(),
Some(format!("Basic {}", base64_encode("alice:p@ss")).as_str()),
@@ -202,7 +205,7 @@ fn auth_pair_base64_passes_through_to_basic_header() {
let pair = base64_encode("alice:p@ss");
let ini = format!("//reg.com/:_auth={pair}\n");
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>(&ini).apply_to::<NoEnv>(&mut config);
NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new("")).apply_to::<NoEnv>(&mut config);
assert_eq!(
config.auth_headers.for_url("https://reg.com/").as_deref(),
Some(format!("Basic {pair}").as_str()),
@@ -217,7 +220,7 @@ fn auth_pair_base64_passes_through_to_basic_header() {
#[test]
fn ini_section_headers_are_dropped_silently() {
let ini = "[default]\nregistry=https://r.example\n[other]\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(auth.registry.as_deref(), Some("https://r.example"));
assert_eq!(auth.warnings, Vec::<String>::new());
}
@@ -232,7 +235,7 @@ fn env_replace_failure_on_key_warns_and_drops_unresolved_to_empty() {
// the typed `_authToken` field. The point of this test is to exercise
// the warning + lossy-substitution branch at the top of `from_ini`.
let ini = "${MISSING}_authToken=abc\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(auth.default_creds.auth_token.as_deref(), Some("abc"));
assert!(auth.warnings.iter().any(|warning| warning.contains("${MISSING}")));
}
@@ -245,7 +248,7 @@ fn top_level_auth_pair_keys_to_default_registry_basic_header() {
let pair = base64_encode("bob:hunter2");
let ini = format!("_auth={pair}\n");
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>(&ini).apply_to::<NoEnv>(&mut config);
NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new("")).apply_to::<NoEnv>(&mut config);
assert_eq!(
config.auth_headers.for_url("https://registry.npmjs.org/").as_deref(),
Some(format!("Basic {pair}").as_str()),
@@ -258,7 +261,7 @@ fn top_level_username_password_keys_to_default_registry_basic_header() {
let password_b64 = base64_encode(raw_password);
let ini = format!("username=bob\n_password={password_b64}\n");
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>(&ini).apply_to::<NoEnv>(&mut config);
NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new("")).apply_to::<NoEnv>(&mut config);
assert_eq!(
config.auth_headers.for_url("https://registry.npmjs.org/").as_deref(),
Some(format!("Basic {}", base64_encode("bob:hunter2")).as_str()),
@@ -272,7 +275,7 @@ fn top_level_username_password_keys_to_default_registry_basic_header() {
fn lone_per_registry_password_produces_no_header() {
let ini = format!("//reg.com/:_password={}\n", base64_encode("solo"));
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>(&ini).apply_to::<NoEnv>(&mut config);
NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new("")).apply_to::<NoEnv>(&mut config);
assert_eq!(config.auth_headers.for_url("https://reg.com/"), None);
}
@@ -286,7 +289,7 @@ fn per_registry_username_password_apply_through_build_auth_headers() {
let password_b64 = base64_encode(raw_password);
let ini = format!("//reg.example/:username=alice\n//reg.example/:_password={password_b64}\n");
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>(&ini).apply_to::<NoEnv>(&mut config);
NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new("")).apply_to::<NoEnv>(&mut config);
assert_eq!(
config.auth_headers.for_url("https://reg.example/foo").as_deref(),
Some(format!("Basic {}", base64_encode("alice:hunter2")).as_str()),
@@ -301,7 +304,7 @@ fn per_registry_username_password_apply_through_build_auth_headers() {
#[test]
fn unknown_per_registry_suffix_is_silently_dropped() {
let ini = "//reg.example/:registry=https://other.example/\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert!(auth.creds_by_uri.is_empty());
assert_eq!(auth.default_creds, RawCreds::default());
assert_eq!(auth.warnings, Vec::<String>::new());
@@ -313,7 +316,7 @@ fn unknown_per_registry_suffix_is_silently_dropped() {
#[test]
fn apply_registry_and_warn_drains_warnings() {
let ini = "//reg.com/:_authToken=${MISSING}\n";
let mut auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let mut auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(auth.warnings.len(), 1);
let mut config = Config::new();
auth.apply_registry_and_warn(&mut config);
@@ -332,7 +335,7 @@ fn invalid_base64_password_falls_back_to_raw_value() {
// returns `None` and the raw string is used as the password.
let ini = "//reg.com/:username=alice\n//reg.com/:_password=raw*pw\n";
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>(ini).apply_to::<NoEnv>(&mut config);
NpmrcAuth::from_ini::<NoEnv>(ini, Path::new("")).apply_to::<NoEnv>(&mut config);
assert_eq!(
config.auth_headers.for_url("https://reg.com/").as_deref(),
Some(format!("Basic {}", base64_encode("alice:raw*pw")).as_str()),
@@ -366,19 +369,21 @@ fn base64_decode_covers_every_alphabet_branch() {
#[test]
fn parses_https_proxy_from_ini() {
let auth = NpmrcAuth::from_ini::<NoEnv>("https-proxy=http://proxy.example:8080\n");
let auth =
NpmrcAuth::from_ini::<NoEnv>("https-proxy=http://proxy.example:8080\n", Path::new(""));
assert_eq!(auth.https_proxy.as_deref(), Some("http://proxy.example:8080"));
}
#[test]
fn parses_http_proxy_from_ini() {
let auth = NpmrcAuth::from_ini::<NoEnv>("http-proxy=http://proxy.example:3128\n");
let auth =
NpmrcAuth::from_ini::<NoEnv>("http-proxy=http://proxy.example:3128\n", Path::new(""));
assert_eq!(auth.http_proxy.as_deref(), Some("http://proxy.example:3128"));
}
#[test]
fn parses_legacy_proxy_key_from_ini() {
let auth = NpmrcAuth::from_ini::<NoEnv>("proxy=http://legacy.example:8080\n");
let auth = NpmrcAuth::from_ini::<NoEnv>("proxy=http://legacy.example:8080\n", Path::new(""));
assert_eq!(auth.legacy_proxy.as_deref(), Some("http://legacy.example:8080"));
assert_eq!(auth.https_proxy, None, "legacy `proxy` is its own slot");
}
@@ -387,17 +392,23 @@ fn parses_legacy_proxy_key_from_ini() {
fn no_proxy_and_noproxy_aliases_last_wins() {
// pnpm pipes both spellings into a single `noProxy` slot — the last
// assignment in `.npmrc` order wins, same as upstream's single field.
let auth = NpmrcAuth::from_ini::<NoEnv>("no-proxy=first.example\nnoproxy=second.example\n");
let auth = NpmrcAuth::from_ini::<NoEnv>(
"no-proxy=first.example\nnoproxy=second.example\n",
Path::new(""),
);
assert_eq!(auth.no_proxy.as_deref(), Some("second.example"));
let auth = NpmrcAuth::from_ini::<NoEnv>("noproxy=second.example\nno-proxy=first.example\n");
let auth = NpmrcAuth::from_ini::<NoEnv>(
"noproxy=second.example\nno-proxy=first.example\n",
Path::new(""),
);
assert_eq!(auth.no_proxy.as_deref(), Some("first.example"));
}
#[test]
fn cascade_https_proxy_uses_legacy_proxy_when_unset() {
// Mirrors upstream: `httpsProxy ?? proxy ?? env`.
let auth = NpmrcAuth::from_ini::<NoEnv>("proxy=http://legacy.example:8080\n");
let auth = NpmrcAuth::from_ini::<NoEnv>("proxy=http://legacy.example:8080\n", Path::new(""));
let mut config = Config::new();
auth.apply_to::<NoEnv>(&mut config);
assert_eq!(config.proxy.https_proxy.as_deref(), Some("http://legacy.example:8080"));
@@ -407,6 +418,7 @@ fn cascade_https_proxy_uses_legacy_proxy_when_unset() {
fn cascade_explicit_https_proxy_wins_over_legacy_key() {
let auth = NpmrcAuth::from_ini::<NoEnv>(
"https-proxy=http://https.example:8080\nproxy=http://legacy.example:8080\n",
Path::new(""),
);
let mut config = Config::new();
auth.apply_to::<NoEnv>(&mut config);
@@ -422,7 +434,8 @@ fn cascade_http_proxy_uses_resolved_https_proxy() {
EnvHttpButOverridden,
&[("HTTP_PROXY", "http://env.example:80"), ("PROXY", "http://envproxy.example:80")]
);
let auth = NpmrcAuth::from_ini::<NoEnv>("https-proxy=http://https.example:8080\n");
let auth =
NpmrcAuth::from_ini::<NoEnv>("https-proxy=http://https.example:8080\n", Path::new(""));
let mut config = Config::new();
auth.apply_to::<EnvHttpButOverridden>(&mut config);
assert_eq!(config.proxy.http_proxy.as_deref(), Some("http://https.example:8080"));
@@ -430,7 +443,7 @@ fn cascade_http_proxy_uses_resolved_https_proxy() {
#[test]
fn cascade_no_proxy_true_literal_becomes_bypass_variant() {
let auth = NpmrcAuth::from_ini::<NoEnv>("no-proxy=true\n");
let auth = NpmrcAuth::from_ini::<NoEnv>("no-proxy=true\n", Path::new(""));
let mut config = Config::new();
auth.apply_to::<NoEnv>(&mut config);
assert_eq!(config.proxy.no_proxy, Some(NoProxySetting::Bypass));
@@ -438,7 +451,8 @@ fn cascade_no_proxy_true_literal_becomes_bypass_variant() {
#[test]
fn cascade_no_proxy_comma_list_trimmed() {
let auth = NpmrcAuth::from_ini::<NoEnv>("no-proxy= foo.example , , bar.example ,\n");
let auth =
NpmrcAuth::from_ini::<NoEnv>("no-proxy= foo.example , , bar.example ,\n", Path::new(""));
let mut config = Config::new();
auth.apply_to::<NoEnv>(&mut config);
assert_eq!(
@@ -473,6 +487,7 @@ fn cascade_npmrc_value_wins_over_env() {
);
let auth = NpmrcAuth::from_ini::<NoEnv>(
"https-proxy=http://npmrc.example:8080\nno-proxy=npmrc.example\n",
Path::new(""),
);
let mut config = Config::new();
auth.apply_to::<ConflictingEnv>(&mut config);
@@ -519,35 +534,85 @@ fn parses_inline_ca_from_ini() {
// INI doesn't allow real newlines in values, but for round-trip
// through this test we still parse `value` as a single line. The
// important assertion is that the value lands on `auth.ca`.
let auth = NpmrcAuth::from_ini::<NoEnv>(&ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new(""));
assert_eq!(auth.ca.len(), 1, "auth.ca={:?}", auth.ca);
}
#[test]
fn parses_cafile_path_from_ini() {
let auth = NpmrcAuth::from_ini::<NoEnv>("cafile=/etc/pacquet/ca.pem\n");
let auth = NpmrcAuth::from_ini::<NoEnv>("cafile=/etc/pacquet/ca.pem\n", Path::new(""));
assert_eq!(auth.cafile.as_deref(), Some("/etc/pacquet/ca.pem"));
}
// Regression for https://github.com/pnpm/pnpm/issues/11624.
#[test]
fn cafile_relative_path_resolves_against_npmrc_dir() {
let npmrc_dir = tempfile::tempdir().expect("tempdir");
let auth = NpmrcAuth::from_ini::<NoEnv>("cafile=certs/ca.pem\n", npmrc_dir.path());
let expected = npmrc_dir.path().join("certs/ca.pem").to_string_lossy().into_owned();
assert_eq!(auth.cafile.as_deref(), Some(expected.as_str()));
}
#[test]
fn cafile_absolute_path_passes_through_unchanged() {
let npmrc_dir = tempfile::tempdir().expect("tempdir");
let abs_cafile = tempfile::NamedTempFile::new().expect("tempfile");
let abs_path = abs_cafile.path().to_string_lossy().into_owned();
let auth = NpmrcAuth::from_ini::<NoEnv>(&format!("cafile={abs_path}\n"), npmrc_dir.path());
assert_eq!(auth.cafile.as_deref(), Some(abs_path.as_str()));
}
#[test]
fn cafile_empty_value_passes_through_unchanged() {
// An explicit `cafile=` (empty) means "no cafile". Joining the
// npmrc dir onto an empty path would incorrectly load the dir
// itself, so empty must short-circuit.
let npmrc_dir = tempfile::tempdir().expect("tempdir");
let auth = NpmrcAuth::from_ini::<NoEnv>("cafile=\n", npmrc_dir.path());
assert_eq!(auth.cafile.as_deref(), Some(""));
}
// End-to-end regression for https://github.com/pnpm/pnpm/issues/11624.
#[test]
fn cafile_relative_path_loads_ca_from_disk_via_apply() {
use std::io::Write;
let npmrc_dir = tempfile::tempdir().expect("tempdir");
let certs_dir = npmrc_dir.path().join("certs");
std::fs::create_dir_all(&certs_dir).expect("certs dir");
let mut ca_file = std::fs::File::create(certs_dir.join("ca.pem")).expect("create ca.pem");
ca_file.write_all(TEST_CA_PEM.as_bytes()).expect("write");
let auth = NpmrcAuth::from_ini::<NoEnv>("cafile=certs/ca.pem\n", npmrc_dir.path());
let mut config = Config::new();
auth.apply_to::<NoEnv>(&mut config);
assert_eq!(config.tls.ca.len(), 1, "tls.ca={:?}", config.tls.ca);
assert!(config.tls.ca[0].contains("BEGIN CERTIFICATE"));
}
#[test]
fn parses_cert_and_key_from_ini() {
let ini = "cert=cert-pem\nkey=key-pem\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(auth.cert.as_deref(), Some("cert-pem"));
assert_eq!(auth.key.as_deref(), Some("key-pem"));
}
#[test]
fn parses_strict_ssl_true_and_false() {
assert_eq!(NpmrcAuth::from_ini::<NoEnv>("strict-ssl=true\n").strict_ssl, Some(true));
assert_eq!(NpmrcAuth::from_ini::<NoEnv>("strict-ssl=false\n").strict_ssl, Some(false));
assert_eq!(
NpmrcAuth::from_ini::<NoEnv>("strict-ssl=true\n", Path::new("")).strict_ssl,
Some(true),
);
assert_eq!(
NpmrcAuth::from_ini::<NoEnv>("strict-ssl=false\n", Path::new("")).strict_ssl,
Some(false),
);
}
#[test]
fn strict_ssl_invalid_value_silently_drops() {
// pnpm/nopt drops non-boolean values. Pacquet does the same so
// the per-emit-site `?? true` default kicks in.
let auth = NpmrcAuth::from_ini::<NoEnv>("strict-ssl=maybe\n");
let auth = NpmrcAuth::from_ini::<NoEnv>("strict-ssl=maybe\n", Path::new(""));
assert_eq!(auth.strict_ssl, None);
}
@@ -558,13 +623,13 @@ fn strict_ssl_invalid_value_resets_prior_value() {
// parser silently kept the earlier `false`, a typo on a later
// line would leave TLS verification disabled — silently — until
// the user noticed.
let auth = NpmrcAuth::from_ini::<NoEnv>("strict-ssl=false\nstrict-ssl=oops\n");
let auth = NpmrcAuth::from_ini::<NoEnv>("strict-ssl=false\nstrict-ssl=oops\n", Path::new(""));
assert_eq!(auth.strict_ssl, None);
}
#[test]
fn parses_local_address_from_ini() {
let auth = NpmrcAuth::from_ini::<NoEnv>("local-address=10.0.0.5\n");
let auth = NpmrcAuth::from_ini::<NoEnv>("local-address=10.0.0.5\n", Path::new(""));
assert_eq!(auth.local_address.as_deref(), Some("10.0.0.5"));
}
@@ -713,6 +778,7 @@ fn defaults_leave_tls_config_empty() {
fn parses_scoped_inline_ca() {
let auth = NpmrcAuth::from_ini::<NoEnv>(
"//reg.example.com/:ca=-----BEGIN CERTIFICATE-----\\nMIIB-----END CERTIFICATE-----\n",
Path::new(""),
);
let entry = auth.tls_by_uri.get("//reg.example.com/").expect("entry present");
let ca = entry.ca.as_deref().expect("ca set");
@@ -726,7 +792,7 @@ fn parses_scoped_cafile_reads_from_disk() {
let tmp = tempfile::NamedTempFile::new().expect("create tempfile");
tmp.as_file().write_all(TEST_CA_PEM.as_bytes()).expect("write");
let ini = format!("//reg.example.com/:cafile={}\n", tmp.path().display());
let auth = NpmrcAuth::from_ini::<NoEnv>(&ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new(""));
let entry = auth.tls_by_uri.get("//reg.example.com/").expect("entry present");
let ca = entry.ca.as_deref().expect("ca set");
assert!(ca.contains("BEGIN CERTIFICATE"), "expected PEM contents from cafile: {ca:?}");
@@ -734,7 +800,10 @@ fn parses_scoped_cafile_reads_from_disk() {
#[test]
fn parses_scoped_cafile_missing_silently_dropped() {
let auth = NpmrcAuth::from_ini::<NoEnv>("//reg.example.com/:cafile=/nonexistent/path/ca.pem\n");
let auth = NpmrcAuth::from_ini::<NoEnv>(
"//reg.example.com/:cafile=/nonexistent/path/ca.pem\n",
Path::new(""),
);
// Either the entry doesn't exist, or it exists with `ca = None`.
// `PerRegistryTls::from_map` filters all-`None` entries later;
// here the parse-time behavior is "no entry written".
@@ -749,6 +818,7 @@ fn parses_scoped_cafile_missing_silently_dropped() {
fn parses_scoped_cert_and_key() {
let auth = NpmrcAuth::from_ini::<NoEnv>(
"//reg.example.com/:cert=cert-pem\n//reg.example.com/:key=key-pem\n",
Path::new(""),
);
let entry = auth.tls_by_uri.get("//reg.example.com/").expect("entry present");
assert_eq!(entry.cert.as_deref(), Some("cert-pem"));
@@ -767,7 +837,7 @@ fn parses_scoped_certfile_and_keyfile() {
tmp_cert.path().display(),
tmp_key.path().display(),
);
let auth = NpmrcAuth::from_ini::<NoEnv>(&ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new(""));
let entry = auth.tls_by_uri.get("//reg.example.com/").expect("entry present");
assert_eq!(entry.cert.as_deref(), Some("CERT-CONTENTS"));
assert_eq!(entry.key.as_deref(), Some("KEY-CONTENTS"));
@@ -784,7 +854,7 @@ fn scoped_inline_and_file_share_same_slot_last_wins() {
"//reg.example.com/:cert=inline\n//reg.example.com/:certfile={}\n",
tmp.path().display(),
);
let auth = NpmrcAuth::from_ini::<NoEnv>(&ini);
let auth = NpmrcAuth::from_ini::<NoEnv>(&ini, Path::new(""));
let entry = auth.tls_by_uri.get("//reg.example.com/").expect("entry present");
assert_eq!(entry.cert.as_deref(), Some("FROM-FILE"));
}
@@ -794,7 +864,7 @@ fn scoped_n_escape_expansion_only_on_inline() {
// pnpm's `:ca=...` value goes through `.replace(/\\n/g, '\n')`.
// The `:cafile` variant reads from disk and doesn't apply the
// replacement (the file already has real newlines).
let auth = NpmrcAuth::from_ini::<NoEnv>("//reg.example.com/:ca=line1\\nline2\n");
let auth = NpmrcAuth::from_ini::<NoEnv>("//reg.example.com/:ca=line1\\nline2\n", Path::new(""));
let entry = auth.tls_by_uri.get("//reg.example.com/").expect("entry present");
assert_eq!(entry.ca.as_deref(), Some("line1\nline2"));
}
@@ -803,6 +873,7 @@ fn scoped_n_escape_expansion_only_on_inline() {
fn applies_tls_by_uri_to_config_drops_empty() {
let auth = NpmrcAuth::from_ini::<NoEnv>(
"//keep.example.com/:ca=ca-pem\n//drop.example.com/:registry=https://drop.example/\n",
Path::new(""),
);
// `//drop.example.com/:registry=` doesn't match any TLS suffix
// so no `RegistryTls` entry is ever created for that prefix.
@@ -817,7 +888,7 @@ fn scoped_tls_keys_dont_collide_with_top_level() {
// Top-level `ca=`, `cert=`, `key=`, `cafile=` arms run *before*
// the SSL-suffix matcher. A bare `ca=` line should land on
// `auth.ca`, not in `tls_by_uri` as registry=`""`.
let auth = NpmrcAuth::from_ini::<NoEnv>("ca=top-level\n");
let auth = NpmrcAuth::from_ini::<NoEnv>("ca=top-level\n", Path::new(""));
assert_eq!(auth.ca, vec!["top-level".to_string()]);
assert!(auth.tls_by_uri.is_empty(), "top-level `ca=` must not pollute tls_by_uri");
}