Skip to content

Commit 571a3bb

Browse files
authored
Merge branch 'main' into fix/revert-segment-middleware-leaf-expansion
2 parents 74e61b2 + a9fa630 commit 571a3bb

14 files changed

Lines changed: 118 additions & 34 deletions

File tree

backend/.env.dist.local

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ OSSPCKGS_GCP_CREDENTIALS_B64=e30=
195195
# maven/all.zip 404s). The allowlist check and DB storage normalize to lowercase
196196
# internally per ADR-0001 §OSV "Ecosystem normalization", so downstream stays lowercase.
197197
OSV_BULK_BASE_URL=https://osv-vulnerabilities.storage.googleapis.com
198-
OSV_ECOSYSTEMS=npm,Maven
198+
OSV_ECOSYSTEMS=npm,Maven,cargo
199199
OSV_TMP_DIR=/tmp/osv
200200
OSV_BATCH_SIZE=500
201201
OSV_DERIVE_BATCH_SIZE=1000

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,23 @@ async function ingestOne(qx: QueryExecutor, purl: string, dispatcher?: Dispatche
124124
}
125125

126126
// 429 / 5xx / network → bubble up so Temporal retries the activity with exponential backoff.
127-
if (!isClientError(packumentResult.statusCode, packumentResult.kind)) {
127+
// MALFORMED is permanent (a 200 body that isn't a packument — retrying won't change it),
128+
// so it takes the quick-retry-then-skip path below instead of poisoning the lane forever.
129+
if (
130+
!isClientError(packumentResult.statusCode, packumentResult.kind) &&
131+
packumentResult.kind !== 'MALFORMED'
132+
) {
128133
throw new Error(`Failed to fetch packument for ${name}: ${packumentResult.message}`)
129134
}
130135

131-
// 4xx → quick retry a few times (1s, 2s); give up and skip after the last attempt.
136+
// 4xx/MALFORMED → quick retry a few times (1s, 2s); give up and skip after the last attempt.
132137
if (attempt < INGEST_4XX_ATTEMPTS) {
133138
await sleep(attempt * INGEST_4XX_BACKOFF_MS)
134139
continue
135140
}
136141
log.warn(
137142
{ purl, statusCode: packumentResult.statusCode, kind: packumentResult.kind },
138-
'npm packument 4xx after fast retries — marking scanned and skipping',
143+
'npm packument 4xx/malformed after fast retries — marking scanned and skipping',
139144
)
140145
await markNpmPackageScanned(qx, purl, {
141146
status: 'error',

services/apps/packages_worker/src/npm/fetchPackument.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,34 @@ export async function fetchPackument(
4848
return { kind: 'MALFORMED', message: 'invalid JSON' }
4949
}
5050

51-
if (!isPackument(json)) return { kind: 'MALFORMED', message: 'unexpected shape' }
51+
if (!isPackument(json)) {
52+
const stub = asUnpublishedStub(json)
53+
if (stub) return stub
54+
return { kind: 'MALFORMED', message: 'unexpected shape' }
55+
}
5256
delete (json as unknown as Record<string, unknown>).readme
5357
return json
5458
}
5559

5660
function isPackument(v: unknown): v is Packument {
5761
return typeof v === 'object' && v !== null && 'name' in v && 'versions' in v && 'dist-tags' in v
5862
}
63+
64+
// A fully unpublished package returns HTTP 200 with a stub document — just name + time,
65+
// where time.unpublished records the unpublish event; there are no versions/dist-tags keys,
66+
// so isPackument rejects it. Normalize the stub into an empty packument with `unpublished`
67+
// set, so ingest stores status='unpublished' instead of erroring on shape.
68+
function asUnpublishedStub(v: unknown): Packument | null {
69+
if (typeof v !== 'object' || v === null) return null
70+
const o = v as Record<string, unknown>
71+
if (typeof o.name !== 'string' || typeof o.time !== 'object' || o.time === null) return null
72+
const t = o.time as Record<string, unknown>
73+
const unpublished = t.unpublished
74+
if (typeof unpublished !== 'object' || unpublished === null) return null
75+
if (typeof (unpublished as Record<string, unknown>).time !== 'string') return null
76+
const time: Record<string, string> = {}
77+
for (const [key, value] of Object.entries(t)) {
78+
if (typeof value === 'string') time[key] = value
79+
}
80+
return { name: o.name, 'dist-tags': {}, versions: {}, time, unpublished }
81+
}

services/apps/packages_worker/src/npm/normalize.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,33 @@ export function normalizeLicenses(packument: Packument): string[] {
5656
)
5757
}
5858

59+
// A version's `license` field comes in several shapes: a plain SPDX string ("MIT"),
60+
// an object ({ type, url }), or the legacy array form ([{ type, file }, ...]). Passing those
61+
// raw into a text column would persist objects/arrays, so collapse every shape to a single
62+
// string (OR-joined for the array form) or null. Non-string or blank `type` values are dropped.
63+
export function versionLicense(raw: unknown): string | null {
64+
if (raw == null) return null
65+
if (typeof raw === 'string') return blankToNull(raw)
66+
if (Array.isArray(raw)) {
67+
const types = raw
68+
.map((l) => (typeof l === 'string' ? blankToNull(l) : licenseType(l)))
69+
.filter((t): t is string => t !== null)
70+
return types.length ? types.join(' OR ') : null
71+
}
72+
return licenseType(raw)
73+
}
74+
75+
function licenseType(v: unknown): string | null {
76+
if (typeof v !== 'object' || v === null) return null
77+
const type = (v as { type?: unknown }).type
78+
return typeof type === 'string' ? blankToNull(type) : null
79+
}
80+
81+
function blankToNull(s: string): string | null {
82+
const trimmed = s.trim()
83+
return trimmed || null
84+
}
85+
5986
function clean(s: string): string {
6087
return s.replace(/[()]/g, '').trim()
6188
}

services/apps/packages_worker/src/npm/upsertPackage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
normalizeLicenses,
1616
parseNpmName,
1717
stripNullBytesDeep,
18+
versionLicense,
1819
} from './normalize'
1920
import type { FundingEntry, Packument } from './types'
2021

@@ -98,7 +99,7 @@ export async function upsertPackage(
9899
publishedAt: time[number] ?? null,
99100
isLatest: number === latestVersion,
100101
isPrerelease: isPrerelease(number),
101-
license: v.license ?? licenses[0] ?? null,
102+
license: versionLicense(v.license) ?? licenses[0] ?? null,
102103
})),
103104
)
104105
verChanged.forEach((f) => changed.add(f))

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const heartbeatingActs = proxyActivities<typeof activities>({
2727

2828
// Per lane, per round. Total purls fetched per round = lanes × INGEST_PER_LANE.
2929
const INGEST_PER_LANE = 50
30-
const INGEST_ROUNDS_PER_RUN = 25
30+
const INGEST_ROUNDS_PER_RUN = 5
3131

3232
interface IngestState {
3333
unscannedCursor: string

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { compareVersion } from '../versionCompare'
66
// the contract is -1/0/1, but we only care about the sign.
77
const sign = (n: number | null) => (n === null ? null : Math.sign(n))
88

9-
describe('compareVersion — npm (semver)', () => {
9+
describe('compareVersion — npm/cargo (semver)', () => {
1010
it.each([
1111
['1.2.3', '1.2.4', -1],
1212
['1.2.4', '1.2.3', 1],
@@ -20,11 +20,21 @@ describe('compareVersion — npm (semver)', () => {
2020
expect(sign(compareVersion('npm', a, b))).toBe(expected)
2121
})
2222

23-
it('returns null for unparseable npm versions', () => {
23+
it.each([
24+
['0.1.0', '0.2.0', -1],
25+
['1.0.0', '1.0.0', 0],
26+
['2.0.0', '1.9.9', 1],
27+
['0.3.0-alpha.1', '0.3.0', -1],
28+
])('compareVersion("cargo", %s, %s) sign = %s', (a, b, expected) => {
29+
expect(sign(compareVersion('cargo', a, b))).toBe(expected)
30+
})
31+
32+
it('returns null for unparseable semver versions', () => {
2433
expect(compareVersion('npm', 'not-a-version-at-all', '1.0.0')).toBeNull()
34+
expect(compareVersion('cargo', 'not-a-version-at-all', '1.0.0')).toBeNull()
2535
})
2636

27-
it('returns null for short / lossy npm versions instead of coercing', () => {
37+
it('returns null for short / lossy versions instead of coercing', () => {
2838
// Under-flag over mis-flag: semver.coerce would map "1.2" → "1.2.0" and
2939
// "1.2-junk-3" → "1.2.3", which can flip has_critical_vulnerability on
3040
// a malformed introduced/fixed boundary. We prefer null (no match).
@@ -76,7 +86,6 @@ describe('compareVersion — maven (ComparableVersion-style)', () => {
7686
describe('compareVersion — unsupported ecosystems', () => {
7787
it('returns null for ecosystems we have no comparator for', () => {
7888
expect(compareVersion('PyPI', '1.0.0', '2.0.0')).toBeNull()
79-
expect(compareVersion('crates.io', '0.1', '0.2')).toBeNull()
8089
})
8190

8291
it('rejects titlecase "Maven" — production storage is always lowercase', () => {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ export async function* fetchEcosystemZip(
7070
await mkdir(ecoDir, { recursive: true })
7171
const zipPath = path.join(ecoDir, 'all.zip')
7272

73-
const url = `${baseUrl.replace(/\/$/, '')}/${ecosystem}/all.zip`
73+
const bucketEcosystem = ecosystem === 'cargo' ? 'crates.io' : ecosystem
74+
const url = `${baseUrl.replace(/\/$/, '')}/${bucketEcosystem}/all.zip`
7475

7576
try {
7677
await downloadZip(url, zipPath)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ export function parseOsvRecord(
111111
for (const affected of record.affected ?? []) {
112112
const pkg = affected.package
113113
if (!pkg) continue
114-
const ecosystem = pkg.ecosystem.toLowerCase()
114+
const rawEcosystem = pkg.ecosystem.toLowerCase()
115+
const ecosystem = rawEcosystem === 'crates.io' ? 'cargo' : rawEcosystem
115116
if (!allowedEcosystems.has(ecosystem)) continue
116117

117118
const ranges = flattenRanges(affected)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const SCHEDULE_ID = 'osv-advisories-sync'
1111
// validate the env input against this list and refuse to register the
1212
// schedule on a mismatch — better a loud startup error than a silent miss.
1313
// Add new entries here when v1 expands beyond npm + Maven.
14-
const VALID_ECOSYSTEMS = ['npm', 'Maven'] as const
14+
const VALID_ECOSYSTEMS = ['npm', 'Maven', 'cargo'] as const
1515

1616
function getEcosystems(): string[] {
1717
const raw = process.env.OSV_ECOSYSTEMS

0 commit comments

Comments
 (0)