fix(version-policy): treat multiple exact-version excludes as equivalent to a single disjunction (#12516)

`minimumReleaseAgeExclude` (and `trustPolicyExclude`) ignored every
rule after the first match for a given package, so two separate
exact-version entries like `[form-data@4.0.6, form-data@2.5.6]` could
still trip the policy for the second version while
`[form-data@4.0.6 || 2.5.6]` (a single disjunction entry) worked.
That made list semantics depend on whether the user happened to merge
versions into one `||` selector, which is surprising and unsafe for
supply-chain exclusion lists.

Walk every matching rule and merge consecutive `name@version[...]`
matches in source order with duplicates removed. A bare-name or
wildcard match still terminates the walk, with first-match precedence
between bare and exact rules: a wildcard listed after an
exact-version rule no longer silently widens the exclusion to every
version of the package, while a bare-name rule listed first keeps
its existing `AnyVersion` semantics. Apply the same change to the
pacquet port so both stacks stay in sync.

Closes pnpm/pnpm#12463

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
shiro
2026-06-19 20:20:08 +09:00
committed by GitHub
parent ee9fab5476
commit fbdc0ebaf9
5 changed files with 130 additions and 17 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.version-policy": patch
"pnpm": patch
---
Fixed `minimumReleaseAgeExclude` and `trustPolicyExclude` so multiple exact-version entries for the same package behave the same as a single `||` disjunction entry. Previously only the first matching rule's versions were honored, so a config like `[form-data@4.0.6, form-data@2.5.6]` could still flag `form-data@2.5.6` as violating `minimumReleaseAge`, while `[form-data@4.0.6 || 2.5.6]` worked as expected [#12463](https://github.com/pnpm/pnpm/issues/12463).

View File

@@ -72,16 +72,27 @@ export function expandPackageVersionSpecs (specs: string[]): Set<string> {
}
function evaluateVersionPolicy (rules: VersionPolicyRule[], pkgName: string): boolean | string[] {
let matchedVersions: string[] | undefined
let seen: Set<string> | undefined
for (const { nameMatcher, exactVersions } of rules) {
if (!nameMatcher(pkgName)) {
continue
}
if (exactVersions.length === 0) {
return true
return matchedVersions ?? true
}
if (matchedVersions == null) {
matchedVersions = []
seen = new Set()
}
for (const version of exactVersions) {
if (!seen!.has(version)) {
seen!.add(version)
matchedVersions.push(version)
}
}
return exactVersions
}
return false
return matchedVersions ?? false
}
interface VersionPolicyRule {

View File

@@ -52,6 +52,34 @@ test('createPackageVersionPolicy()', () => {
const match = createPackageVersionPolicy(['pkg@1.0.0||1.0.1 || 1.0.2'])
expect(match('pkg')).toStrictEqual(['1.0.0', '1.0.1', '1.0.2'])
}
{
const match = createPackageVersionPolicy(['form-data@4.0.6', 'form-data@2.5.6'])
expect(match('form-data')).toStrictEqual(['4.0.6', '2.5.6'])
}
{
const match = createPackageVersionPolicy(['form-data@4.0.6', 'form-data@2.5.6 || 2.5.7'])
expect(match('form-data')).toStrictEqual(['4.0.6', '2.5.6', '2.5.7'])
}
{
const match = createPackageVersionPolicy(['form-data@4.0.6', 'form-data@4.0.6'])
expect(match('form-data')).toStrictEqual(['4.0.6'])
}
{
const match = createPackageVersionPolicy(['axios@1.12.2', 'axios'])
expect(match('axios')).toStrictEqual(['1.12.2'])
}
{
const match = createPackageVersionPolicy(['axios', 'axios@1.12.2'])
expect(match('axios')).toBe(true)
}
{
const match = createPackageVersionPolicy(['axios@1.12.2', 'ax*'])
expect(match('axios')).toStrictEqual(['1.12.2'])
}
{
const match = createPackageVersionPolicy(['ax*', 'axios@1.12.2'])
expect(match('axios')).toBe(true)
}
})
test('createPackageVersionPolicyOrThrow() rewraps parser errors with INVALID_<KEY>', () => {

View File

@@ -111,9 +111,10 @@ pub enum PolicyMatch {
}
/// Matcher-based version policy built from a list of
/// `<name-pattern>[@<version>||<version>...]` rules. Rules are walked
/// in order; the first whose name matcher accepts the input package
/// name wins. Mirrors upstream's [`createPackageVersionPolicy`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L6-L13).
/// `<name-pattern>[@<version>||<version>...]` rules. See
/// [`PackageVersionPolicy::matches`] for the evaluation semantics.
/// Mirrors upstream's
/// [`createPackageVersionPolicy`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L6-L13).
///
/// Used by `minimumReleaseAgeExclude` and `trustPolicyExclude`, both
/// of which need wildcard name patterns (`is-*`, `@scope/*`) AND
@@ -143,23 +144,37 @@ struct VersionPolicyRule {
}
impl PackageVersionPolicy {
/// Evaluate the policy against a package name. Returns the
/// matching rule's payload (`AnyVersion` for a bare-name rule,
/// `ExactVersions` for `name@versions...`), or `PolicyMatch::No`
/// when no rule matched.
/// Evaluate the policy against a package name, merging the exact
/// versions of all matching `name@version[...]` rules.
///
/// A bare-name or wildcard rule matches every version, but never
/// widens exact versions already accumulated from earlier rules: a
/// wildcard listed after an exact-version rule does not silently
/// turn the exclusion into every version of the package.
#[must_use]
pub fn matches(&self, pkg_name: &str) -> PolicyMatch {
let mut merged: Option<(Vec<String>, HashSet<String>)> = None;
for rule in &self.rules {
if !rule.name_matcher.matches(pkg_name) {
continue;
}
return if rule.exact_versions.is_empty() {
PolicyMatch::AnyVersion
} else {
PolicyMatch::ExactVersions(rule.exact_versions.clone())
};
if rule.exact_versions.is_empty() {
return match merged {
Some((versions, _)) => PolicyMatch::ExactVersions(versions),
None => PolicyMatch::AnyVersion,
};
}
let (acc, seen) = merged.get_or_insert_with(|| (Vec::new(), HashSet::new()));
for version in &rule.exact_versions {
if seen.insert(version.clone()) {
acc.push(version.clone());
}
}
}
match merged {
Some((versions, _)) => PolicyMatch::ExactVersions(versions),
None => PolicyMatch::No,
}
PolicyMatch::No
}
}

View File

@@ -116,13 +116,66 @@ fn create_policy_scoped_bare_name_returns_any_version() {
}
#[test]
fn create_policy_first_matching_rule_wins() {
fn create_policy_distinct_name_rules() {
let policy = create_package_version_policy(["axios@1.12.2", "lodash@4.17.21", "is-*"]).unwrap();
assert_eq!(policy.matches("axios"), PolicyMatch::ExactVersions(vec!["1.12.2".to_string()]));
assert_eq!(policy.matches("lodash"), PolicyMatch::ExactVersions(vec!["4.17.21".to_string()]));
assert_eq!(policy.matches("is-odd"), PolicyMatch::AnyVersion);
}
#[test]
fn create_policy_multiple_exact_version_rules_for_same_name_merge() {
let policy = create_package_version_policy(["form-data@4.0.6", "form-data@2.5.6"]).unwrap();
assert_eq!(
policy.matches("form-data"),
PolicyMatch::ExactVersions(vec!["4.0.6".to_string(), "2.5.6".to_string()]),
);
}
#[test]
fn create_policy_merges_exact_versions_and_unions_for_same_name() {
let policy =
create_package_version_policy(["form-data@4.0.6", "form-data@2.5.6 || 2.5.7"]).unwrap();
assert_eq!(
policy.matches("form-data"),
PolicyMatch::ExactVersions(vec![
"4.0.6".to_string(),
"2.5.6".to_string(),
"2.5.7".to_string(),
]),
);
}
#[test]
fn create_policy_deduplicates_repeated_versions_across_rules() {
let policy = create_package_version_policy(["form-data@4.0.6", "form-data@4.0.6"]).unwrap();
assert_eq!(policy.matches("form-data"), PolicyMatch::ExactVersions(vec!["4.0.6".to_string()]));
}
#[test]
fn create_policy_bare_rule_after_exact_keeps_exact_versions() {
let policy = create_package_version_policy(["axios@1.12.2", "axios"]).unwrap();
assert_eq!(policy.matches("axios"), PolicyMatch::ExactVersions(vec!["1.12.2".to_string()]));
}
#[test]
fn create_policy_bare_rule_listed_first_wins_over_later_exact() {
let policy = create_package_version_policy(["axios", "axios@1.12.2"]).unwrap();
assert_eq!(policy.matches("axios"), PolicyMatch::AnyVersion);
}
#[test]
fn create_policy_wildcard_after_exact_keeps_exact_versions() {
let policy = create_package_version_policy(["axios@1.12.2", "ax*"]).unwrap();
assert_eq!(policy.matches("axios"), PolicyMatch::ExactVersions(vec!["1.12.2".to_string()]));
}
#[test]
fn create_policy_wildcard_listed_first_wins_over_later_exact() {
let policy = create_package_version_policy(["ax*", "axios@1.12.2"]).unwrap();
assert_eq!(policy.matches("axios"), PolicyMatch::AnyVersion);
}
#[test]
fn create_policy_range_specifier_in_version_errors() {
let err = create_package_version_policy(["lodash@^4.17.0"]).expect_err("must reject");