Skip to content

Commit c8d2fe3

Browse files
committed
fix(ci): honour parent selectors, numeric child names, and resync the header
Three more from review. A version-scoped parent key was reduced to its bare name, so `foo@1>child` was credited for a path through foo@2.0.0. pnpm applies such a key only when the parent's own version satisfies the selector, so a patched pin could suppress a copy the override never rewrites. The path check now tests the parent's version too, and passes when the path carries no version to compare. The parent separator was told apart from the range operator by the character AFTER the '>', which rejected any child whose name starts with a digit. `2fa` is a real package name, and `foo>2fa` was indexed under the literal string rather than the child, skipping every advisory for it. The operator is identified by the character before the '>' instead, which is always one of @ < > or =. The header still described the hand-rolled comparator, the dependency-free constraint and compound and x-ranges being unrecognised. The previous commit replaced all of that with semver, so the documented contract was wrong about the check's own false-negative policy. Rewritten to match. 93 tests here, 123 across test:scripts. Each fix verified to fail the suite when reverted.
1 parent 2e2e6f3 commit c8d2fe3

2 files changed

Lines changed: 61 additions & 26 deletions

File tree

scripts/ci/check-override-advisories.mjs

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,17 @@
4646
// any pin stale" misses that entirely; the second question is
4747
// "does any pin fail to COVER a vulnerable copy".
4848
//
49-
// Deciding the second question needs the override key compared as a semver
50-
// range, so this file carries a small comparator rather than taking a
51-
// dependency. It handles the selector shapes the override block uses today plus
52-
// the two commonest operators it does not: no selector, a bare major, a
53-
// major.minor, an exact version, the < <= > >= = comparators, and ^ / ~ ranges.
54-
// An unrecognised selector (a compound range, an x-range, a workspace protocol)
55-
// is treated as covering the version, which errs towards silence rather than a
56-
// false alarm; the stale-pin check still watches that key on its own.
49+
// Deciding the second question means comparing the override key as a semver
50+
// range, which is delegated to `semver` - the same implementation pnpm resolves
51+
// with. An earlier revision hand-rolled it to keep this file dependency-free
52+
// and review found eight defects in that comparator, every one a silent false
53+
// negative, so the constraint was dropped; other scripts here already take
54+
// dependencies. Every range form semver understands is therefore evaluated,
55+
// including compound ranges, x-ranges, caret and tilde, and prereleases.
56+
//
57+
// A selector semver cannot parse as a range at all - a workspace protocol, an
58+
// npm alias - is treated as covering the version, which errs towards silence
59+
// rather than a false alarm; the stale-pin check still watches that key.
5760
//
5861
// Known limitation: an override pinning a package that nothing actually resolves
5962
// to is invisible here, because it never appears in the audited tree. Such an
@@ -102,23 +105,22 @@ function fail(message) {
102105
export function splitOverrideKey(key) {
103106
const trimmed = key.trim();
104107

105-
// Scan right to left for the parent>child separator. A '>' can also be a range
106-
// operator, and telling them apart needs more than the next character: the
107-
// supported spellings include '@>=5.0.0', '@>1.2.3', '@>v1.2.3' and '@> 1.2.3'.
108-
// A '>' preceded by '@' is always an operator, never a separator - reading
109-
// 'pkg@>v1.2.3' as parent>child would index the entry under 'v1.2.3' and skip
110-
// every advisory for pkg.
108+
// Scan right to left for the parent>child separator. A '>' can also be a
109+
// range operator, and the two are told apart by the character BEFORE it, not
110+
// after: an operator '>' always follows '@', '<', '>' or '='. Keying off the
111+
// character after instead would reject a perfectly valid child whose name
112+
// starts with a digit, such as 'foo>2fa', and index the whole string as a
113+
// package name so every advisory for that child is skipped.
111114
let child = trimmed;
112115
let parent = null;
113116
for (let i = trimmed.length - 1; i >= 0; i -= 1) {
114117
if (trimmed[i] !== '>') continue;
115-
if (trimmed[i - 1] === '@' || trimmed[i - 1] === '<' || trimmed[i - 1] === '>') continue;
116-
const next = trimmed[i + 1];
117-
if (next && !/[=\d]/.test(next)) {
118-
child = trimmed.slice(i + 1);
119-
parent = trimmed.slice(0, i);
120-
break;
121-
}
118+
const prev = trimmed[i - 1];
119+
if (prev === '@' || prev === '<' || prev === '>' || prev === '=') continue;
120+
if (i + 1 >= trimmed.length) continue;
121+
child = trimmed.slice(i + 1);
122+
parent = trimmed.slice(0, i);
123+
break;
122124
}
123125

124126
// On a scoped name the leading '@' is part of the name, so the selector
@@ -208,15 +210,25 @@ export function overrideKeyCoversPath(key, version, path) {
208210
//
209211
// Segments are compared as package identities rather than substrings, so
210212
// `foo` does not match a `foobar` segment.
211-
const parentName = splitOverrideKey(parent).name;
213+
const { name: parentName, selector: parentSelector } = splitOverrideKey(parent);
212214
const segments = String(path)
213215
.split('>')
214216
.map((segment) => segment.trim());
215-
const isPkg = (segment, pkg) => segment === pkg || segment.startsWith(`${pkg}@`);
216217

217-
return segments.some(
218-
(segment, i) => isPkg(segment, parentName) && isPkg(segments[i + 1] ?? '', name)
219-
);
218+
// A version-scoped parent key only applies when the parent's own version
219+
// satisfies that selector, so `foo@1>child` must not be credited for a path
220+
// through foo@2.0.0. Where the path carries no version for the segment there
221+
// is nothing to disprove, so the selector passes.
222+
const isParent = (segment) => {
223+
if (segment !== parentName && !segment.startsWith(`${parentName}@`)) return false;
224+
if (!parentSelector) return true;
225+
const at = segment.indexOf('@', segment.startsWith('@') ? 1 : 0);
226+
if (at === -1) return true;
227+
return selectorCovers(parentSelector, segment.slice(at + 1));
228+
};
229+
const isChild = (segment) => segment === name || segment.startsWith(`${name}@`);
230+
231+
return segments.some((segment, i) => isParent(segment) && isChild(segments[i + 1] ?? ''));
220232
}
221233

222234
// package name -> [{ key, pinned }], because a single package is routinely

scripts/ci/check-override-advisories.test.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ describe('parseOverrideKey', () => {
112112
// A '>' straight after '@' is always an operator. Reading 'pkg@>v1.2.3' as
113113
// parent>child would index the entry under 'v1.2.3' and skip every advisory
114114
// for pkg, which is silent and total.
115+
// A child package name may start with a digit ('2fa' is a real package).
116+
// Rejecting the separator on that basis indexed the whole key as one name and
117+
// skipped every advisory for the child.
118+
it('accepts a child package name that starts with a digit', () => {
119+
assert.equal(parseOverrideKey('foo>2fa'), '2fa');
120+
assert.deepEqual(splitOverrideKey('foo>2fa'), { name: '2fa', selector: null, parent: 'foo' });
121+
});
122+
115123
it('does not mistake >v or "> " spellings for a parent separator', () => {
116124
assert.equal(parseOverrideKey('pkg@>v1.2.3'), 'pkg');
117125
assert.equal(parseOverrideKey('pkg@> 1.2.3'), 'pkg');
@@ -429,6 +437,21 @@ describe('overrideKeyCovers with a parent-scoped key', () => {
429437
);
430438
});
431439

440+
// pnpm applies a version-scoped parent key only when the parent's own version
441+
// satisfies the selector, so foo@1>child must not be credited for foo@2.0.0.
442+
it("honours the parent selector's version", () => {
443+
assert.equal(
444+
overrideKeyCoversPath('foo@1>child', '1.0.0', 'app > foo@2.0.0 > child@1.0.0'),
445+
false
446+
);
447+
assert.equal(
448+
overrideKeyCoversPath('foo@1>child', '1.0.0', 'app > foo@1.5.0 > child@1.0.0'),
449+
true
450+
);
451+
// No version in the path segment means there is nothing to disprove.
452+
assert.equal(overrideKeyCoversPath('foo@1>child', '1.0.0', 'app > foo > child@1.0.0'), true);
453+
});
454+
432455
it('matches a parent segment by identity, not by substring', () => {
433456
assert.equal(overrideKeyCoversPath('foo>child', '1.0.0', 'app > foobar > child@1.0.0'), false);
434457
assert.equal(overrideKeyCoversPath('foo>child', '1.0.0', 'app > foo > child@1.0.0'), true);

0 commit comments

Comments
 (0)