fix(config): drop unresolved ${VAR} placeholders from .npmrc auth values (#11526)

Closes #11513.

`actions/setup-node` writes `_authToken=${NODE_AUTH_TOKEN}` to `.npmrc`. When the user relies on OIDC trusted publishing without setting `NODE_AUTH_TOKEN`, pnpm previously passed the literal placeholder through verbatim — so any time OIDC fallback failed, pnpm sent `Authorization: Bearer ${NODE_AUTH_TOKEN}` to the registry and the publish came back as a 404.

This worked in v10 because `pnpm publish` shelled out to `npm publish`, whose own OIDC flow handled the case.

The fix lives in `@pnpm/config.env-replace@4.1.0`, which adds an `envReplaceLossy` variant that returns `{ value, unresolved }` instead of throwing. Unresolved `${VAR}` placeholders become `''` and are reported back as a list — leaving OIDC trusted publishing as the sole auth source. Resolvable placeholders and `${VAR-default}` / `${VAR:-default}` fallbacks elsewhere in the same string still expand normally, so a value like `pre-${SET}-mid-${UNSET}-${OTHER-default}-post` now produces `pre-AAA-mid--default-post` rather than dropping every placeholder.

Also treats `{ KEY: undefined }` in the env object the same as a missing key (the `Record<string, string | undefined>` contract), so a `${KEY-default}` reaches the fallback in that case.

### Changes

- `@pnpm/config.env-replace` catalog bumped from `^3.0.2` → `^4.1.0` (`pnpm-workspace.yaml`, `pnpm-lock.yaml`)
- `config/reader/src/loadNpmrcFiles.ts` — `substituteEnv` now calls `envReplaceLossy` and pushes one warning per unresolved placeholder
- `config/reader/test/index.ts` + `parseCreds.test.ts` — regression tests covering the OIDC case, mixed resolvable/unresolved placeholders, explicit-undefined env values, and `parseCreds({ authToken: '' })`
- `.changeset/oidc-unresolved-env-placeholder.md` — patch bump for `@pnpm/config.reader` and `pnpm`
- `pacquet/crates/config/{env_replace.rs, npmrc_auth.rs, npmrc_auth/tests.rs}` — mirrors the lossy semantics in pacquet's local `env_replace_lossy`, with matching test coverage
This commit is contained in:
Zoltan Kochan
2026-05-15 16:25:28 +02:00
committed by GitHub
parent 4ababc0fc6
commit a62f959242
9 changed files with 273 additions and 95 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Fixed `pnpm publish` failing with a 404 when authentication relied on OIDC trusted publishing alongside an `.npmrc` written by `actions/setup-node` (`_authToken=${NODE_AUTH_TOKEN}`) without `NODE_AUTH_TOKEN` being set. Unresolved `${VAR}` placeholders in auth values are now treated as empty rather than passed through verbatim, so the literal placeholder no longer surfaces as a bearer token when OIDC fallback is the intended auth source [#11513](https://github.com/pnpm/pnpm/issues/11513).

View File

@@ -2,7 +2,7 @@ import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { envReplace } from '@pnpm/config.env-replace'
import { envReplaceLossy } from '@pnpm/config.env-replace'
import { readIniFileSync } from 'read-ini-file'
import { isNpmrcReadableKey } from './localConfig.js'
@@ -153,13 +153,18 @@ function readAndFilterNpmrc (
return result
}
// Use the lossy variant so unresolved `${VAR}` placeholders become '' (each
// recorded as a warning) instead of throwing. Critical for the OIDC case in
// https://github.com/pnpm/pnpm/issues/11513 — leaving the literal `${VAR}` in
// an auth value would be sent verbatim as a bearer token. Resolvable
// placeholders and `${VAR-default}` / `${VAR:-default}` fallbacks elsewhere
// in the same string still expand normally.
function substituteEnv (value: string, env: Record<string, string | undefined>, warnings: string[]): string {
try {
return envReplace(value, env)
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err))
return value
const { value: substituted, unresolved } = envReplaceLossy(value, env)
for (const placeholder of unresolved) {
warnings.push(`Failed to replace env in config: ${placeholder}`)
}
return substituted
}
function normalizePath (p: string | undefined): string | undefined {

View File

@@ -661,6 +661,120 @@ test('workspace .npmrc overrides pnpm auth file', async () => {
}
})
describe('unresolved ${VAR} placeholders in .npmrc auth values', () => {
// Regression suite for https://github.com/pnpm/pnpm/issues/11513: actions/setup-node
// writes `_authToken=${NODE_AUTH_TOKEN}` to .npmrc, and when the user relies on OIDC
// trusted publishing without setting NODE_AUTH_TOKEN, the literal placeholder must not
// surface as a bearer token — otherwise the registry sees `Bearer ${NODE_AUTH_TOKEN}`
// and rejects the publish.
let originalXdg: string | undefined
let configHome: string
beforeEach(() => {
prepareEmpty()
fs.writeFileSync('.npmrc', '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n', 'utf8')
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), '', 'utf8')
// Isolate from the developer's real ~/.config/pnpm/auth.ini, which on a maintainer's
// machine often contains a working npm token that would otherwise satisfy the assertion.
configHome = path.resolve('xdg-config')
fs.mkdirSync(path.join(configHome, 'pnpm'), { recursive: true })
originalXdg = process.env.XDG_CONFIG_HOME
process.env.XDG_CONFIG_HOME = configHome
})
afterEach(() => {
if (originalXdg != null) {
process.env.XDG_CONFIG_HOME = originalXdg
} else {
delete process.env.XDG_CONFIG_HOME
}
})
test('drops the placeholder when the env var is unset', async () => {
const { config } = await getConfig({
cliOptions: {
userconfig: path.resolve('user-home', '.npmrc'),
},
env: { ...env, XDG_CONFIG_HOME: configHome }, // NODE_AUTH_TOKEN intentionally unset
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
workspaceDir: process.cwd(),
})
expect(config.authConfig['//registry.npmjs.org/:_authToken']).toBe('')
})
test('substitutes normally when the env var is set', async () => {
const { config } = await getConfig({
cliOptions: {
userconfig: path.resolve('user-home', '.npmrc'),
},
env: { ...env, XDG_CONFIG_HOME: configHome, NODE_AUTH_TOKEN: 'real-token' },
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
workspaceDir: process.cwd(),
})
expect(config.authConfig['//registry.npmjs.org/:_authToken']).toBe('real-token')
})
test('only drops the unresolved placeholder, preserving resolved ones and defaults', async () => {
// Same value contains one resolvable placeholder, one unresolved bare placeholder,
// and one placeholder with a `-default` fallback. The unresolved one becomes ''
// but the other two must still expand. Guards against the original implementation
// that stripped every `${...}` on any substitution failure.
fs.writeFileSync(
'.npmrc',
'//registry.test/:_authToken=${SET}-${UNSET}-${DEFAULTED-fallback}\n',
'utf8'
)
const { config } = await getConfig({
cliOptions: {
userconfig: path.resolve('user-home', '.npmrc'),
},
env: { ...env, XDG_CONFIG_HOME: configHome, SET: 'AAA' }, // UNSET, DEFAULTED unset
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
workspaceDir: process.cwd(),
})
expect(config.authConfig['//registry.test/:_authToken']).toBe('AAA--fallback')
})
test('explicit `undefined` value in env is treated as unset for `${VAR-default}` fallbacks', async () => {
// Callers that construct the env object directly (notably tests) commonly use
// `{ KEY: undefined }` to model an unset variable. `${VAR-default}` must then
// resolve to `default`, matching the `Record<string, string | undefined>` contract.
fs.writeFileSync(
'.npmrc',
'//registry.test/:_authToken=${EXPLICIT_UNDEF-fallback}\n',
'utf8'
)
const { config } = await getConfig({
cliOptions: {
userconfig: path.resolve('user-home', '.npmrc'),
},
env: { ...env, XDG_CONFIG_HOME: configHome, EXPLICIT_UNDEF: undefined },
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
workspaceDir: process.cwd(),
})
expect(config.authConfig['//registry.test/:_authToken']).toBe('fallback')
})
})
test('throw error if --save-prod is used with --save-peer', async () => {
await expect(getConfig({
cliOptions: {

View File

@@ -20,6 +20,12 @@ describe('parseCreds', () => {
} as Creds)
})
test('empty authToken is treated as absent', () => {
// Reachable path via loadNpmrcFiles when an unresolved `${VAR}` placeholder
// is dropped to empty. Must not surface as a usable token.
expect(parseCreds({ authToken: '' })).toBeUndefined()
})
test('authPairBase64', () => {
expect(parseCreds({
authPairBase64: btoa('foo:bar'),

View File

@@ -13,10 +13,11 @@
//! half of the backslashes are kept (one literal `\\` per pair).
//! * odd-number-of-backslashes prefix: the placeholder is left literal
//! and one backslash is consumed.
//! * unset variable + no default: the call returns an
//! [`EnvReplaceError`] rather than substituting an empty string.
//! Pacquet surfaces the same condition as a warning, matching
//! `loadNpmrcFiles.ts`'s `substituteEnv`.
//! * unset variable + no default: the placeholder is substituted with `""`
//! and recorded in the returned `Vec` so the caller can surface it as a
//! warning, matching `loadNpmrcFiles.ts`'s `substituteEnv` lossy fallback
//! (critical for OIDC trusted publishing — see
//! <https://github.com/pnpm/pnpm/issues/11513>).
//! * empty variable + default present: the default wins; this is
//! pnpm's behaviour even though plain shell `${VAR:-default}` would
//! also use the default for the empty case.
@@ -26,39 +27,27 @@
//! own per-test unit struct, per the DI pattern from
//! [pnpm/pacquet#339](https://github.com/pnpm/pacquet/issues/339).
use std::fmt;
use crate::api::EnvVar;
/// A single missing variable surfaced from [`env_replace`].
/// Replace every `${VAR}` (or `${VAR:-default}`) placeholder in `text` with
/// the value [`Api::var`] returns. Placeholders that have no value and no
/// default become `""` (the literal `${...}` never reaches the caller) and
/// are recorded in the returned `Vec` so the caller can surface each one as
/// a warning.
///
/// Mirrors the `Failed to replace env in config: ${...}` message pnpm
/// produces in `loadNpmrcFiles.ts`'s `substituteEnv`. Callers typically
/// downgrade this to a warning and keep the original value verbatim.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EnvReplaceError {
/// The placeholder that could not be resolved, including its
/// surrounding `${...}` so the message lines up with pnpm's.
pub placeholder: String,
}
impl fmt::Display for EnvReplaceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Failed to replace env in config: {}", self.placeholder)
}
}
impl std::error::Error for EnvReplaceError {}
/// Replace every `${VAR}` (or `${VAR:-default}`) placeholder in `text`
/// with the value [`Api::var`] returns. Returns an error on the first
/// unresolvable placeholder so the caller can warn and skip the line,
/// matching pnpm's `substituteEnv`.
/// Mirrors pnpm's `substituteEnv` fallback in
/// `config/reader/src/loadNpmrcFiles.ts`: leaving an unresolved `${VAR}` in
/// an auth value would later be sent as a literal bearer token, notably
/// under OIDC trusted publishing (<https://github.com/pnpm/pnpm/issues/11513>).
/// Resolvable placeholders and `${VAR:-default}` fallbacks elsewhere in the
/// same string still expand normally — only the unresolved bare ones are
/// dropped to `""`.
///
/// [`Api::var`]: EnvVar::var
pub(crate) fn env_replace<Api: EnvVar>(text: &str) -> Result<String, EnvReplaceError> {
pub(crate) fn env_replace_lossy<Api: EnvVar>(text: &str) -> (String, Vec<String>) {
let bytes = text.as_bytes();
let mut output = String::with_capacity(text.len());
let mut unresolved = Vec::new();
let mut index = 0;
while index < bytes.len() {
let char = bytes[index];
@@ -107,14 +96,12 @@ pub(crate) fn env_replace<Api: EnvVar>(text: &str) -> Result<String, EnvReplaceE
match (value, default) {
(Some(value), _) => output.push_str(&value),
(None, Some(default)) => output.push_str(default),
(None, None) => {
return Err(EnvReplaceError { placeholder: placeholder.to_owned() });
}
(None, None) => unresolved.push(placeholder.to_owned()),
}
}
index = end + 1;
}
Ok(output)
(output, unresolved)
}
/// Return the index of the closing `}` for a `${...}` starting at `start`.
@@ -139,7 +126,7 @@ fn find_placeholder_end(bytes: &[u8], start: usize) -> Option<usize> {
#[cfg(test)]
mod tests {
use super::{EnvVar, env_replace};
use super::{EnvVar, env_replace_lossy};
use pretty_assertions::assert_eq;
/// Empty env: no variable is ever set. Used by tests that only
@@ -151,6 +138,14 @@ mod tests {
}
}
/// Run [`env_replace_lossy`] and assert no placeholder went unresolved.
/// Used by tests that exercise paths where every placeholder must expand.
fn replace_clean<Api: EnvVar>(text: &str) -> String {
let (value, unresolved) = env_replace_lossy::<Api>(text);
assert_eq!(unresolved, Vec::<String>::new(), "unexpected unresolved placeholders");
value
}
#[test]
fn substitutes_simple_placeholder() {
struct EnvWithToken;
@@ -159,19 +154,19 @@ mod tests {
(name == "TOKEN").then(|| "abc123".to_owned())
}
}
assert_eq!(env_replace::<EnvWithToken>("Bearer ${TOKEN}").unwrap(), "Bearer abc123");
assert_eq!(replace_clean::<EnvWithToken>("Bearer ${TOKEN}"), "Bearer abc123");
}
#[test]
fn returns_error_on_missing_variable() {
let result = env_replace::<NoEnv>("${MISSING}").unwrap_err();
assert_eq!(result.placeholder, "${MISSING}");
assert_eq!(result.to_string(), "Failed to replace env in config: ${MISSING}");
fn unresolved_drops_to_empty_and_collects_placeholder() {
let (value, unresolved) = env_replace_lossy::<NoEnv>("//reg/:_authToken=${MISSING}");
assert_eq!(value, "//reg/:_authToken=");
assert_eq!(unresolved, vec!["${MISSING}".to_owned()]);
}
#[test]
fn uses_default_when_variable_unset() {
assert_eq!(env_replace::<NoEnv>("${MISSING:-fallback}").unwrap(), "fallback");
assert_eq!(replace_clean::<NoEnv>("${MISSING:-fallback}"), "fallback");
}
#[test]
@@ -182,7 +177,7 @@ mod tests {
(name == "EMPTY").then(String::new)
}
}
assert_eq!(env_replace::<EmptyEnv>("${EMPTY:-fallback}").unwrap(), "fallback");
assert_eq!(replace_clean::<EmptyEnv>("${EMPTY:-fallback}"), "fallback");
}
#[test]
@@ -193,17 +188,17 @@ mod tests {
(name == "PORT").then(|| "8080".to_owned())
}
}
assert_eq!(env_replace::<EnvWithPort>("${PORT:-3000}").unwrap(), "8080");
assert_eq!(replace_clean::<EnvWithPort>("${PORT:-3000}"), "8080");
}
#[test]
fn passthrough_when_no_placeholder() {
assert_eq!(env_replace::<NoEnv>("plain string").unwrap(), "plain string");
assert_eq!(replace_clean::<NoEnv>("plain string"), "plain string");
}
#[test]
fn lone_dollar_is_left_alone() {
assert_eq!(env_replace::<NoEnv>("$ price").unwrap(), "$ price");
assert_eq!(replace_clean::<NoEnv>("$ price"), "$ price");
}
/// Hits the early-`None` branch of `bytes.get(start + 1)?` inside
@@ -211,8 +206,8 @@ mod tests {
/// to peek at.
#[test]
fn trailing_dollar_with_no_byte_after_is_passthrough() {
assert_eq!(env_replace::<NoEnv>("$").unwrap(), "$");
assert_eq!(env_replace::<NoEnv>("foo$").unwrap(), "foo$");
assert_eq!(replace_clean::<NoEnv>("$"), "$");
assert_eq!(replace_clean::<NoEnv>("foo$"), "foo$");
}
/// A nested `$` or `{` inside a placeholder body, or an unclosed
@@ -222,8 +217,8 @@ mod tests {
/// be a no-op.
#[test]
fn malformed_placeholder_is_left_alone() {
assert_eq!(env_replace::<NoEnv>("${OPEN").unwrap(), "${OPEN");
assert_eq!(env_replace::<NoEnv>("${A$B}").unwrap(), "${A$B}");
assert_eq!(replace_clean::<NoEnv>("${OPEN"), "${OPEN");
assert_eq!(replace_clean::<NoEnv>("${A$B}"), "${A$B}");
}
/// One literal backslash escapes the placeholder; the parser
@@ -231,7 +226,7 @@ mod tests {
/// because the lookup never runs in this branch.
#[test]
fn odd_backslash_count_escapes_placeholder() {
assert_eq!(env_replace::<NoEnv>(r"\${X}").unwrap(), "${X}");
assert_eq!(replace_clean::<NoEnv>(r"\${X}"), "${X}");
}
#[test]
@@ -243,7 +238,7 @@ mod tests {
(name == "X").then(|| "y".to_owned())
}
}
assert_eq!(env_replace::<EnvWithX>(r"\\${X}").unwrap(), r"\y");
assert_eq!(replace_clean::<EnvWithX>(r"\\${X}"), r"\y");
}
#[test]
@@ -260,7 +255,7 @@ mod tests {
ENV.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned())
}
}
assert_eq!(env_replace::<StaticEnv>("${A}-${B}-${A}").unwrap(), "1-2-1");
assert_eq!(replace_clean::<StaticEnv>("${A}-${B}-${A}"), "1-2-1");
}
/// The source-backslash count must come from the original input,
@@ -286,7 +281,7 @@ mod tests {
}
}
// Single literal `\` between `${A}` and `${B}`. Must escape `${B}`.
assert_eq!(env_replace::<Env>(r"${A}\${B}").unwrap(), r"x\${B}");
assert_eq!(replace_clean::<Env>(r"${A}\${B}"), r"x\${B}");
}
#[test]
@@ -299,8 +294,31 @@ mod tests {
}
}
assert_eq!(
env_replace::<EnvWithToken>("//registry.npmjs.org/:_authToken=${NPM_TOKEN}").unwrap(),
replace_clean::<EnvWithToken>("//registry.npmjs.org/:_authToken=${NPM_TOKEN}"),
"//registry.npmjs.org/:_authToken=secret",
);
}
#[test]
fn preserves_resolved_and_default_placeholders_alongside_unresolved() {
// One resolvable, one unresolved bare, one with a `:-default` fallback.
// Only the bare unresolved one becomes ""; the other two still expand.
struct EnvWithSet;
impl EnvVar for EnvWithSet {
fn var(name: &str) -> Option<String> {
(name == "SET").then(|| "AAA".to_owned())
}
}
let (value, unresolved) =
env_replace_lossy::<EnvWithSet>("${SET}-${UNSET}-${DEFAULTED:-fallback}");
assert_eq!(value, "AAA--fallback");
assert_eq!(unresolved, vec!["${UNSET}".to_owned()]);
}
#[test]
fn collects_every_unresolved_placeholder_occurrence() {
let (value, unresolved) = env_replace_lossy::<NoEnv>("${A}-${B}-${A}");
assert_eq!(value, "--");
assert_eq!(unresolved, vec!["${A}".to_owned(), "${B}".to_owned(), "${A}".to_owned()],);
}
}

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use pacquet_network::{AuthHeaders, NoProxySetting, PerRegistryTls, RegistryTls, base64_encode};
use crate::{Config, api::EnvVar, env_replace::env_replace};
use crate::{Config, api::EnvVar, env_replace::env_replace_lossy};
/// Subset of `.npmrc` keys pacquet honours for registry / auth setup.
///
@@ -26,9 +26,10 @@ use crate::{Config, api::EnvVar, env_replace::env_replace};
/// Applied via [`NpmrcAuth::apply_tls_and_local_address`].
///
/// Values pass through `${VAR}` substitution before being stored,
/// matching pnpm's `loadNpmrcFiles.ts` flow. Substitution failures are
/// recorded as warnings and the offending value is left verbatim, again
/// matching pnpm.
/// matching pnpm's `loadNpmrcFiles.ts` flow. Unresolved placeholders are
/// substituted with `""` and recorded as warnings so the literal `${VAR}`
/// never reaches downstream auth code (critical for OIDC trusted publishing
/// — see <https://github.com/pnpm/pnpm/issues/11513>), again matching pnpm.
///
/// Other `.npmrc` knobs (scoped `@scope:registry`, per-registry TLS
/// like `//host:cafile=`, etc.) remain unparsed for now. See the
@@ -129,10 +130,14 @@ impl RawCreds {
impl NpmrcAuth {
/// Parse an `.npmrc` file's contents and pick out the auth/network keys.
/// Unknown keys are silently dropped. `${VAR}` placeholders inside
/// values are resolved via the [`EnvVar`] capability; placeholders
/// that cannot be resolved leave the value verbatim and emit a
/// warning.
/// Unknown keys are silently dropped. `${VAR}` placeholders inside keys
/// and values are resolved via the [`EnvVar`] capability; unresolved
/// placeholders (no env value and no `${VAR:-default}` fallback) are
/// substituted with `""` and surfaced as warnings, matching pnpm's
/// `substituteEnv` in `loadNpmrcFiles.ts`. Leaving the literal `${VAR}`
/// in an auth value would otherwise be sent verbatim — most damagingly
/// as a bearer auth token under OIDC trusted publishing
/// (<https://github.com/pnpm/pnpm/issues/11513>).
///
/// The `.npmrc` format is a tiny ini dialect: one `key=value` per line,
/// plus comments starting with `;` or `#`. We hand-parse rather than
@@ -153,20 +158,12 @@ impl NpmrcAuth {
// Apply ${VAR} substitution to both the key and the value,
// matching `readAndFilterNpmrc` in pnpm's `loadNpmrcFiles.ts`.
let key = match env_replace::<Api>(raw_key) {
Ok(value) => value,
Err(error) => {
auth.warnings.push(error.to_string());
raw_key.to_owned()
}
};
let value = match env_replace::<Api>(raw_value) {
Ok(value) => value,
Err(error) => {
auth.warnings.push(error.to_string());
raw_value.to_owned()
}
};
// Unresolved placeholders become "" and are recorded as warnings.
let (key, key_unresolved) = env_replace_lossy::<Api>(raw_key);
let (value, value_unresolved) = env_replace_lossy::<Api>(raw_value);
for placeholder in key_unresolved.into_iter().chain(value_unresolved) {
auth.warnings.push(format!("Failed to replace env in config: {placeholder}"));
}
if key == "registry" {
auth.registry = Some(value);

View File

@@ -141,17 +141,42 @@ fn env_replace_substitutes_token() {
}
#[test]
fn env_replace_failure_warns_and_keeps_raw_value() {
fn env_replace_failure_warns_and_drops_unresolved_to_empty() {
// Mirrors pnpm's `substituteEnv` lossy fallback: unresolved `${VAR}` becomes
// "" 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);
assert_eq!(
auth.creds_by_uri.get("//reg.com/").map(|creds| creds.auth_token.as_deref()),
Some(Some("${MISSING}")),
Some(Some("")),
);
assert_eq!(auth.warnings.len(), 1);
assert!(auth.warnings[0].contains("${MISSING}"));
}
#[test]
fn env_replace_failure_preserves_resolved_and_default_placeholders() {
// Mixed value with one resolvable placeholder, one unresolved bare placeholder,
// and one with a `:-default` fallback. Only the bare unresolved one becomes "";
// the others must still expand. Guards against an earlier implementation that
// stripped every `${...}` on any substitution failure.
struct EnvWithSet;
impl EnvVar for EnvWithSet {
fn var(name: &str) -> Option<String> {
(name == "SET").then(|| "AAA".to_owned())
}
}
let ini = "//reg.com/:_authToken=${SET}-${UNSET}-${DEFAULTED:-fallback}\n";
let auth = NpmrcAuth::from_ini::<EnvWithSet>(ini);
assert_eq!(
auth.creds_by_uri.get("//reg.com/").map(|creds| creds.auth_token.as_deref()),
Some(Some("AAA--fallback")),
);
assert_eq!(auth.warnings.len(), 1);
assert!(auth.warnings[0].contains("${UNSET}"));
}
#[test]
fn basic_auth_built_from_username_and_password() {
// Pnpm's `_password` is base64(raw_password). Header should
@@ -193,16 +218,17 @@ fn ini_section_headers_are_dropped_silently() {
}
/// When a `${VAR}` placeholder appears in the *key* and cannot be
/// resolved, the parser keeps the raw key verbatim and pushes a
/// warning. Mirrors `substituteEnv` in pnpm's `loadNpmrcFiles.ts`.
/// resolved, the parser substitutes it with "" and pushes a warning.
/// Mirrors `substituteEnv` in pnpm's `loadNpmrcFiles.ts`.
#[test]
fn env_replace_failure_on_key_warns_and_keeps_raw_key() {
// `${MISSING}_authToken` resolves to a literal key, so it lands
// in `default_creds` rather than being recognised as the typed
// `_authToken` field. The point of this test is to exercise the
// warning + raw-key branch at the top of `from_ini`.
fn env_replace_failure_on_key_warns_and_drops_unresolved_to_empty() {
// `${MISSING}_authToken` resolves to the literal key `_authToken` (the
// unresolved placeholder becomes ""), so it lands on `default_creds` as
// 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);
assert_eq!(auth.default_creds.auth_token.as_deref(), Some("abc"));
assert!(auth.warnings.iter().any(|warning| warning.contains("${MISSING}")));
}

12
pnpm-lock.yaml generated
View File

@@ -242,8 +242,8 @@ catalogs:
specifier: ^2.0.0
version: 2.0.0
'@pnpm/config.env-replace':
specifier: ^3.0.2
version: 3.0.2
specifier: ^4.1.0
version: 4.1.0
'@pnpm/config.nerf-dart':
specifier: ^1.0.0
version: 1.0.1
@@ -2605,7 +2605,7 @@ importers:
version: link:../../catalogs/types
'@pnpm/config.env-replace':
specifier: 'catalog:'
version: 3.0.2
version: 4.1.0
'@pnpm/config.matcher':
specifier: workspace:*
version: link:../matcher
@@ -11066,6 +11066,10 @@ packages:
resolution: {integrity: sha512-GD6nKLyKF+ev15Tj3pS8y6cTVPIuAqTyhPrUFMfmodFvhEDdYKN/gdGimkc9GJLfHVC/SuCVFg49YNJyoW7niA==}
engines: {node: '>=18.12'}
'@pnpm/config.env-replace@4.1.0':
resolution: {integrity: sha512-5sirJgdwgE8YU8RMSOZVkluCxCdVRV5a9vRbiLMJ7HBlEluG7jYRvLfLtZ/K3RCI8dckX7Lfpkz0VKpCLp9nug==}
engines: {node: '>=22.13'}
'@pnpm/config.nerf-dart@1.0.1':
resolution: {integrity: sha512-03d2l21gAyzGVr9SR6rS5pvCTnZ4HaNdi8jB2Y/UGvszzrNbA+AJVObVw6SulNQ1Eah3SHB9wCezJwtP+jYIcA==}
engines: {node: '>=18.12'}
@@ -18957,6 +18961,8 @@ snapshots:
'@pnpm/config.env-replace@3.0.2': {}
'@pnpm/config.env-replace@4.1.0': {}
'@pnpm/config.nerf-dart@1.0.1': {}
'@pnpm/config@1004.11.0(@pnpm/logger@1001.0.1)':

View File

@@ -83,7 +83,7 @@ catalog:
'@npm/types': ^2.1.0
'@pnpm/byline': ^1.0.0
'@pnpm/colorize-semver-diff': ^2.0.0
'@pnpm/config.env-replace': ^3.0.2
'@pnpm/config.env-replace': ^4.1.0
'@pnpm/config.nerf-dart': ^1.0.0
'@pnpm/exec': ^4.0.0
'@pnpm/log.group': 3.0.2