mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-27 17:35:30 -04:00
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:
6
.changeset/minimum-release-age-exclude-multi-entry.md
Normal file
6
.changeset/minimum-release-age-exclude-multi-entry.md
Normal 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).
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user