Skip to content

Commit de1fce6

Browse files
committed
fix: complete the ecosystem-case split (URL titlecase, storage lowercase)
The previous "lowercase Maven" commit (1d77fd3) was incomplete: versionCompare.compareVersion still keyed on 'Maven' (titlecase) so deriveCriticalFlag's lowercase 'maven' fell through to the null branch, isInRange treated every Maven range as "no match," and Maven packages could not have has_critical_vulnerability flipped TRUE. The same commit also lowercased OSV_ECOSYSTEMS in the env, which broke the OSV bucket URL (Maven/all.zip is case-sensitive on the GCS side — maven/all.zip 404s). Cursor and Copilot both caught this on the latest push; 8 bot threads were filed against the partial fix. This commit closes all of them. Changes: - versionCompare.ts: compareVersion now matches lowercase 'maven' per ADR-0001 §OSV. compareMaven returns null for empty / punctuation- only input (Cursor 3312194073) so isInRange treats unparseable versions as "no match" rather than silently ordering them as 0. - versionCompare.ts callers were already lowercase (deriveCriticalFlag via the packages-db column, parseOsvRecord via the .toLowerCase() at the OSV boundary). No change needed there. - activities.ts: requirePositiveInt env validator replaces parseInt, fails fast on non-numeric or <= 0 input so an upstream typo can't silently turn into NaN flush-thresholds or LIMIT NaN SQL. - activities.ts: the allowlist is built from allowedEcosystems.map(toLowerCase) so the activity layer keeps the caller-supplied ecosystem (used as a case-sensitive bucket URL segment) separate from the lowercase matching set used by parseOsvRecord. - .env.dist.local: OSV_ECOSYSTEMS back to npm,Maven (OSV's canonical bucket case). Comment clarifies that the URL is case-sensitive while the allowlist + DB storage normalize to lowercase internally. - workflows.ts: comment now states the dual handling explicitly so the next reader doesn't trip on the same casing seam. - versionCompare.test.ts: 19 Maven test cases moved to 'maven' matching production; +3 cases pinning the new compareMaven null contract (empty, swap empty, '...'); +1 regression guard rejecting titlecase 'Maven' through compareVersion so a future "forgot to lowercase" caller fails loudly instead of silently missing vulnerabilities. - deriveCriticalFlag.integration.test.ts: finalQuirk fixture moved to 'maven' to match what production stores. All 70 unit tests pass. Signed-off-by: Joan Reyero <joan@reyero.io>
1 parent 1d77fd3 commit de1fce6

6 files changed

Lines changed: 63 additions & 13 deletions

File tree

backend/.env.dist.local

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,12 @@ ENRICHER_REPO_UPDATE_INTERVAL_HOURS=24
185185
ENRICHER_IDLE_SLEEP_SEC=60
186186

187187
# osv-sync (Temporal-scheduled; see services/apps/packages_worker/src/osv/schedule.ts)
188+
# OSV_ECOSYSTEMS uses OSV's canonical bucket case (npm lowercase, Maven titlecase) because
189+
# the bucket URL <BASE>/<ecosystem>/all.zip is case-sensitive (Maven/all.zip exists,
190+
# maven/all.zip 404s). The allowlist check and DB storage normalize to lowercase
191+
# internally per ADR-0001 §OSV "Ecosystem normalization", so downstream stays lowercase.
188192
OSV_BULK_BASE_URL=https://osv-vulnerabilities.storage.googleapis.com
189-
OSV_ECOSYSTEMS=npm,maven
193+
OSV_ECOSYSTEMS=npm,Maven
190194
OSV_TMP_DIR=/tmp/osv
191195
OSV_BATCH_SIZE=500
192196
OSV_DERIVE_BATCH_SIZE=1000

services/apps/packages_worker/src/osv/__tests__/deriveCriticalFlag.integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const FIXTURES: Record<string, FixturePackage> = {
5050
// Regression guard for the implicit-empty-qualifier Maven bug. There's no
5151
// critical advisory for fixture.test:final-quirk, so the flag must be FALSE.
5252
finalQuirk: {
53-
ecosystem: 'Maven',
53+
ecosystem: 'maven',
5454
namespace: 'fixture.test',
5555
name: 'final-quirk',
5656
latest_version: '1.0-final',

services/apps/packages_worker/src/osv/__tests__/versionCompare.test.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('compareVersion — npm (semver)', () => {
3030
})
3131
})
3232

33-
describe('compareVersion — Maven (ComparableVersion-style)', () => {
33+
describe('compareVersion — maven (ComparableVersion-style)', () => {
3434
it.each([
3535
// Basic numeric ordering
3636
['1.0', '1.1', -1],
@@ -56,8 +56,16 @@ describe('compareVersion — Maven (ComparableVersion-style)', () => {
5656
['1.0-m1', '1.0-milestone1', 0],
5757
// Numeric beats alpha at same depth
5858
['1.0-1', '1.0-alpha', 1],
59-
])('compareVersion("Maven", %s, %s) sign = %s', (a, b, expected) => {
60-
expect(sign(compareVersion('Maven', a, b))).toBe(expected)
59+
])('compareVersion("maven", %s, %s) sign = %s', (a, b, expected) => {
60+
expect(sign(compareVersion('maven', a, b))).toBe(expected)
61+
})
62+
63+
it('returns null for unparseable maven versions', () => {
64+
// Empty / punctuation-only strings tokenize to [] and used to silently
65+
// compare as version 0. Per the comparator contract, return null instead.
66+
expect(compareVersion('maven', '', '1.0')).toBeNull()
67+
expect(compareVersion('maven', '1.0', '')).toBeNull()
68+
expect(compareVersion('maven', '...', '1.0')).toBeNull()
6169
})
6270
})
6371

@@ -66,4 +74,13 @@ describe('compareVersion — unsupported ecosystems', () => {
6674
expect(compareVersion('PyPI', '1.0.0', '2.0.0')).toBeNull()
6775
expect(compareVersion('crates.io', '0.1', '0.2')).toBeNull()
6876
})
77+
78+
it('rejects titlecase "Maven" — production storage is always lowercase', () => {
79+
// Regression guard for the casing bug Fix 1 missed: deriveCriticalFlag
80+
// reads `ecosystem` from packages-db where it's lowercase. The comparator
81+
// is keyed on the same lowercase form per ADR-0001 §OSV "Ecosystem
82+
// normalization". A titlecase 'Maven' call indicates the caller forgot
83+
// to normalize.
84+
expect(compareVersion('Maven', '1.0', '2.0')).toBeNull()
85+
})
6986
})

services/apps/packages_worker/src/osv/activities.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ function getActivityConfig() {
2222
return {
2323
bulkBaseUrl: required('OSV_BULK_BASE_URL'),
2424
tmpDir: required('OSV_TMP_DIR'),
25-
upsertBatchSize: parseInt(required('OSV_BATCH_SIZE'), 10),
26-
deriveBatchSize: parseInt(required('OSV_DERIVE_BATCH_SIZE'), 10),
25+
upsertBatchSize: requirePositiveInt('OSV_BATCH_SIZE'),
26+
deriveBatchSize: requirePositiveInt('OSV_DERIVE_BATCH_SIZE'),
2727
}
2828
}
2929

@@ -33,6 +33,19 @@ function required(name: string): string {
3333
return value
3434
}
3535

36+
// requirePositiveInt fails fast on non-numeric or zero/negative values so an
37+
// upstream env-config typo can't turn into NaN propagating through the upsert
38+
// buffer flush threshold (`buffer.length >= NaN` is always false → unbounded
39+
// growth) or SQL `LIMIT NaN` errors in the derive loop.
40+
function requirePositiveInt(name: string): number {
41+
const raw = required(name)
42+
const parsed = parseInt(raw, 10)
43+
if (!Number.isFinite(parsed) || parsed <= 0) {
44+
throw new Error(`Env ${name} must be a positive integer, got: ${raw}`)
45+
}
46+
return parsed
47+
}
48+
3649
export interface OsvSyncEcosystemInput {
3750
ecosystem: string
3851
allowedEcosystems: string[]
@@ -66,7 +79,11 @@ export async function osvSyncEcosystem(
6679
): Promise<OsvSyncEcosystemResult> {
6780
const { ecosystem, allowedEcosystems } = input
6881
const config = getActivityConfig()
69-
const allowed = new Set(allowedEcosystems)
82+
// OSV's bucket uses case-sensitive paths (Maven/all.zip, not maven/all.zip),
83+
// so `ecosystem` (used for the URL) keeps OSV's canonical case. The allowlist
84+
// is matched in parseOsvRecord after lowercasing each record's ecosystem per
85+
// ADR-0001 §OSV "Ecosystem normalization", so the set is built lowercase here.
86+
const allowed = new Set(allowedEcosystems.map((e) => e.toLowerCase()))
7087
const start = Date.now()
7188

7289
const ecoDir = path.join(config.tmpDir, ecosystem)

services/apps/packages_worker/src/osv/versionCompare.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,15 @@ function padFor(other: Token): Token {
9494
return other.kind === 'num' ? { kind: 'num', value: 0 } : { kind: 'str', value: '' }
9595
}
9696

97-
function compareMaven(a: string, b: string): number {
97+
function compareMaven(a: string, b: string): number | null {
9898
const ta = tokenizeMaven(a)
9999
const tb = tokenizeMaven(b)
100+
// Empty or punctuation-only inputs (e.g. '', '...', '---') tokenize to []
101+
// and would otherwise be treated as version 0. Per the compareVersion
102+
// contract ("returns null when either operand cannot be parsed"), reject
103+
// them here so isInRange treats them as "no match" — safer than silently
104+
// ordering garbage as 0.
105+
if (ta.length === 0 || tb.length === 0) return null
100106
const max = Math.max(ta.length, tb.length)
101107
for (let i = 0; i < max; i++) {
102108
if (i >= ta.length) {
@@ -115,8 +121,11 @@ function compareMaven(a: string, b: string): number {
115121
return 0
116122
}
117123

124+
// Ecosystem names are stored lowercase in packages-db per ADR-0001 §OSV
125+
// "Ecosystem normalization" — 'npm' and 'maven'. Callers (deriveCriticalFlag)
126+
// pull the value straight from the DB so the literals here must match.
118127
export function compareVersion(ecosystem: string, a: string, b: string): number | null {
119128
if (ecosystem === 'npm') return compareNpm(a, b)
120-
if (ecosystem === 'Maven') return compareMaven(a, b)
129+
if (ecosystem === 'maven') return compareMaven(a, b)
121130
return null
122131
}

services/apps/packages_worker/src/osv/workflows.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ const { osvSyncEcosystem, osvDeriveCriticalFlag } = proxyActivities<typeof activ
2828
})
2929

3030
export interface OsvSyncWorkflowInput {
31-
// OSV ecosystem labels (case-sensitive: 'npm', 'Maven', 'PyPI', …). The same
32-
// list is used both for the per-ecosystem download loop and as the
33-
// post-parse allowlist filter inside parseOsvRecord.
31+
// OSV ecosystem labels in OSV's canonical case ('npm', 'Maven', 'PyPI', …).
32+
// Same list is used by the per-ecosystem download loop, where OSV's bucket
33+
// is case-sensitive (Maven/all.zip), and as the allowlist source for
34+
// parseOsvRecord. The activity lowercases the allowlist internally before
35+
// matching parsed records, since storage normalizes to lowercase per
36+
// ADR-0001 §OSV "Ecosystem normalization".
3437
ecosystems: string[]
3538
}
3639

0 commit comments

Comments
 (0)