Skip to content

Commit b1a2ba1

Browse files
committed
fix: sort packagist license arrays deterministically
Signed-off-by: anilb <epipav@gmail.com>
1 parent e90a2b2 commit b1a2ba1

2 files changed

Lines changed: 26 additions & 2 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,21 @@ describe('buildPackagistVersionRows', () => {
161161
expect(homepage).toBe('https://example.org')
162162
})
163163

164+
it('sorts a multi-license array deterministically, regardless of registry order', () => {
165+
// versions.licenses is documented as deterministically sorted (schema comment) —
166+
// Composer's dual-licensing means the registry can report the same license set in
167+
// different orders across fetches, which must not register as a real change.
168+
const { versionRows, licenses } = buildPackagistVersionRows([
169+
{
170+
version: '1.0.0',
171+
version_normalized: '1.0.0.0',
172+
license: ['MIT', 'Apache-2.0', 'BSD-3-Clause'],
173+
},
174+
])
175+
expect(versionRows[0].licenses).toEqual(['Apache-2.0', 'BSD-3-Clause', 'MIT'])
176+
expect(licenses).toEqual(['Apache-2.0', 'BSD-3-Clause', 'MIT'])
177+
})
178+
164179
it('returns a null homepage when the latest version omits or blanks it', () => {
165180
const absent = buildPackagistVersionRows([{ version: '1.0.0', version_normalized: '1.0.0.0' }])
166181
expect(absent.homepage).toBeNull()

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ function blankToNull(s: string | null | undefined): string | null {
1818
return t || null
1919
}
2020

21+
// versions.licenses is documented as a deterministically-sorted SPDX array (schema
22+
// comment, V1779710880__initial_schema.sql) — Composer allows dual/multi-licensed
23+
// releases, so unlike npm/pypi's near-always-single-element input, the same license
24+
// set can arrive from the registry in a different order between fetches. Sorting once
25+
// here keeps semantically identical arrays byte-identical, avoiding false audit noise.
26+
function sortLicenses(licenses: string[] | null | undefined): string[] | null {
27+
return licenses && licenses.length > 0 ? [...licenses].sort() : null
28+
}
29+
2130
export function normalizePackagistStats(pkg: PackagistPackageInfo): NormalizedPackagistStats {
2231
const description = blankToNull(pkg.description)
2332
const repositoryUrl = blankToNull(pkg.repository)
@@ -146,7 +155,7 @@ export function buildPackagistVersionRows(versions: PackagistExpandedVersion[]):
146155
publishedAt: v.time ?? null,
147156
isLatest: false, // Will be set below
148157
isPrerelease: isPackagistPrerelease(v.version_normalized ?? v.version),
149-
licenses: v.license && v.license.length > 0 ? v.license : null,
158+
licenses: sortLicenses(v.license),
150159
}))
151160

152161
// Find latest: prefer stable over prerelease; within each group use Composer ordering
@@ -176,7 +185,7 @@ export function buildPackagistVersionRows(versions: PackagistExpandedVersion[]):
176185
const latestReleaseAt = times[times.length - 1] ?? null
177186

178187
// Licenses and homepage come from the latest row
179-
const licenses = kept[latestIdx].license?.length ? (kept[latestIdx].license ?? null) : null
188+
const licenses = sortLicenses(kept[latestIdx].license)
180189
const homepage = blankToNull(kept[latestIdx].homepage)
181190

182191
return {

0 commit comments

Comments
 (0)