mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-30 19:46:44 -04:00
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:
6
.changeset/oidc-unresolved-env-placeholder.md
Normal file
6
.changeset/oidc-unresolved-env-placeholder.md
Normal 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).
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()],);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
12
pnpm-lock.yaml
generated
@@ -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)':
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user