Skip to content

Commit 6c0c214

Browse files
committed
fix: validate packagist response shapes
Signed-off-by: anilb <epipav@gmail.com>
1 parent 4c55efe commit 6c0c214

4 files changed

Lines changed: 106 additions & 4 deletions

File tree

services/apps/packages_worker/src/packagist/__tests__/fetchPackage.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ describe('fetchPackagistStats', () => {
118118
expect(await fetchPackagistStats('monolog/monolog')).toMatchObject({ kind: 'MALFORMED' })
119119
})
120120

121+
// Regression: a technically-valid-JSON body with the wrong runtime type for a field
122+
// normalizePackagistStats consumes unconditionally must be classified MALFORMED, not
123+
// thrown past the guard (where it would be misread as a transient failure and
124+
// retried forever on the same deterministic input).
125+
it.each([
126+
['description is a number', { package: { name: 'a/b', description: 123 } }],
127+
['repository is an object', { package: { name: 'a/b', repository: {} } }],
128+
['maintainers is not an array', { package: { name: 'a/b', maintainers: {} } }],
129+
])('maps a wrong-typed field (%s) → MALFORMED, not a throw', async (_desc, body) => {
130+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeResponse(200, body)))
131+
await expect(fetchPackagistStats('monolog/monolog')).resolves.toMatchObject({
132+
kind: 'MALFORMED',
133+
})
134+
})
135+
121136
it('maps a body read aborted by the 30s timeout → TRANSIENT (not MALFORMED)', async () => {
122137
vi.useFakeTimers()
123138
vi.stubGlobal(
@@ -198,4 +213,39 @@ describe('fetchPackagistP2', () => {
198213
)
199214
expect(await fetchPackagistP2('monolog/monolog', null)).toMatchObject({ kind: 'MALFORMED' })
200215
})
216+
217+
// Regression: a malformed element in the version array must be classified
218+
// MALFORMED, not thrown past the guard — expandComposerMetadata's Object.entries()
219+
// throws on a null/non-object element, and version/version_normalized/license reach
220+
// .startsWith()/.split()/.endsWith() and the SQL text[] write unconditionally.
221+
it.each([
222+
['an element is null', [null]],
223+
['version is missing', [{ time: '2024-01-01' }]],
224+
['version is a number', [{ version: 123 }]],
225+
['version_normalized is a number', [{ version: '1.0.0', version_normalized: 42 }]],
226+
['license is a scalar string, not an array', [{ version: '1.0.0', license: 'MIT' }]],
227+
['homepage is an object', [{ version: '1.0.0', homepage: {} }]],
228+
['time is a number', [{ version: '1.0.0', time: 20240101 }]],
229+
])('maps a malformed version entry (%s) → MALFORMED, not a throw', async (_desc, versions) => {
230+
vi.stubGlobal(
231+
'fetch',
232+
vi.fn().mockResolvedValue(fakeResponse(200, { packages: { 'monolog/monolog': versions } })),
233+
)
234+
await expect(fetchPackagistP2('monolog/monolog', null)).resolves.toMatchObject({
235+
kind: 'MALFORMED',
236+
})
237+
})
238+
239+
it('accepts a version entry using the __unset diff sentinel on optional fields', async () => {
240+
const versions = [
241+
{ version: '1.0.0', license: ['MIT'] },
242+
{ version: '2.0.0', license: '__unset' },
243+
]
244+
vi.stubGlobal(
245+
'fetch',
246+
vi.fn().mockResolvedValue(fakeResponse(200, { packages: { 'monolog/monolog': versions } })),
247+
)
248+
const result = await fetchPackagistP2('monolog/monolog', null)
249+
expect(result).not.toMatchObject({ kind: 'MALFORMED' })
250+
})
201251
})

services/apps/packages_worker/src/packagist/__tests__/normalize.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ describe('normalizePackagistStats', () => {
5555
expect(normalizePackagistStats(full).status).toBe('active')
5656
})
5757

58+
it('drops a null/non-object maintainer entry instead of throwing', () => {
59+
// isPackagistStatsJson only guarantees maintainers is an array, not that every
60+
// element is a well-formed object — a rogue null must not crash `m.name` access.
61+
const stats = normalizePackagistStats({
62+
name: 'a/b',
63+
maintainers: [null, 'not-an-object', { name: 'seldaek' }] as never,
64+
})
65+
expect(stats.maintainers).toEqual([
66+
{ username: 'seldaek', displayName: null, email: null, role: 'maintainer' },
67+
])
68+
})
69+
5870
it('tolerates missing optional fields with nulls and empty lists', () => {
5971
const stats = normalizePackagistStats({ name: 'a/b' })
6072
expect(stats).toEqual({

services/apps/packages_worker/src/packagist/fetchPackage.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,47 @@ export async function fetchPackagistP2(
171171
}
172172
}
173173

174+
// Validates every field normalizePackagistStats consumes unconditionally
175+
// (blankToNull's .trim(), the maintainers .filter()) — a wrong runtime type here must
176+
// surface as MALFORMED, not throw past the fast-retry/give-up path and get treated as
177+
// a transient failure that Temporal retries forever on the same deterministic input.
174178
function isPackagistStatsJson(v: unknown): v is PackagistStatsJson {
175179
if (typeof v !== 'object' || v === null || !('package' in v)) return false
176180
const pkg = (v as { package: unknown }).package
181+
if (typeof pkg !== 'object' || pkg === null) return false
182+
const p = pkg as Record<string, unknown>
183+
if (typeof p.name !== 'string') return false
184+
if (p.description != null && typeof p.description !== 'string') return false
185+
if (p.repository != null && typeof p.repository !== 'string') return false
186+
if (p.maintainers != null && !Array.isArray(p.maintainers)) return false
187+
return true
188+
}
189+
190+
// '__unset' is the p2 minified-diff sentinel for "field removed since the previous
191+
// entry" — a legitimate value for any optional field, not just an absent key.
192+
function isValidVersionField(value: unknown, isValidType: (v: unknown) => boolean): boolean {
193+
return value === undefined || value === '__unset' || isValidType(value)
194+
}
195+
196+
// Validates every field normalize.ts consumes unconditionally on an expanded version:
197+
// `version`/`version_normalized` via .startsWith()/.split()/.endsWith()
198+
// (isPackagistDevVersion/isPackagistPrerelease), `homepage` via blankToNull's .trim(),
199+
// `license` as the text[] written to versions.licenses, `time` as the timestamptz
200+
// written to versions.published_at. `require`/`require-dev` are already guarded at
201+
// their own call site (extractVersionDependencies checks typeof before Object.entries)
202+
// so aren't re-validated here.
203+
function isValidMinifiedVersion(v: unknown): boolean {
204+
if (typeof v !== 'object' || v === null) return false
205+
const entry = v as Record<string, unknown>
177206
return (
178-
typeof pkg === 'object' && pkg !== null && typeof (pkg as { name?: unknown }).name === 'string'
207+
typeof entry.version === 'string' &&
208+
isValidVersionField(entry.version_normalized, (x) => typeof x === 'string') &&
209+
isValidVersionField(entry.homepage, (x) => typeof x === 'string') &&
210+
isValidVersionField(entry.time, (x) => typeof x === 'string') &&
211+
isValidVersionField(
212+
entry.license,
213+
(x) => Array.isArray(x) && x.every((l) => typeof l === 'string'),
214+
)
179215
)
180216
}
181217

@@ -184,5 +220,5 @@ function isP2Response(v: unknown, name: string): v is { packages: Record<string,
184220
const packages = (v as { packages: unknown }).packages
185221
if (typeof packages !== 'object' || packages === null) return false
186222
const entries = (packages as Record<string, unknown>)[name]
187-
return Array.isArray(entries)
223+
return Array.isArray(entries) && entries.every(isValidMinifiedVersion)
188224
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,13 @@ export function normalizePackagistStats(pkg: PackagistPackageInfo): NormalizedPa
3434

3535
const dependents = typeof pkg.dependents === 'number' ? pkg.dependents : null
3636

37-
// Extract maintainers with non-empty names
37+
// Extract maintainers with non-empty names. isPackagistStatsJson only guarantees
38+
// maintainers is an array — a rogue null/non-object element would throw on `m.name`.
3839
const maintainers = (pkg.maintainers ?? [])
39-
.filter((m): m is { name: string } => typeof m.name === 'string' && !!m.name)
40+
.filter(
41+
(m): m is { name: string } =>
42+
typeof m === 'object' && m !== null && typeof m.name === 'string' && !!m.name,
43+
)
4044
.map((m) => ({
4145
username: m.name,
4246
displayName: null,

0 commit comments

Comments
 (0)