From 744ff92ab39e86f5251fad9afbb871aee50ceb1f Mon Sep 17 00:00:00 2001 From: Facundo Rodriguez Date: Wed, 24 Jun 2026 18:13:04 -0300 Subject: [PATCH] feat(purl): add canonicalizePurl with alias and ecosystem case normalization Add canonicalizePurl to @herodevs/eol-shared: parses with packageurl-js, applies go->golang / rubygems->gem type aliases and lowercases namespace/name for case-insensitive ecosystems (nuget, composer, cargo, npm), then re-serializes. Exposes an optional onUncanonicalized hook so callers can observe serialize-failure passthroughs without breaking the never-throw contract. Includes the authoritative unit test suite and widens the test glob to run nested specs. --- package.json | 2 +- src/eol/utils.test.ts | 337 +++++++++++++++++++++++++++++++++++++++++- src/eol/utils.ts | 76 ++++++++++ src/index.ts | 6 +- 4 files changed, 418 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3768aa4..2c9b316 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "prepare": "npm run build", "clean": "rm -rf dist", "build": "npm run clean && tsc -p tsconfig.build.json", - "test": "node --test --experimental-strip-types --experimental-test-coverage src/*.test.*", + "test": "node --test --experimental-strip-types --experimental-test-coverage 'src/**/*.test.*'", "lint": "oxlint", "lint:fix": "oxlint --fix --fix-suggestions", "format:check": "prettier --check .", diff --git a/src/eol/utils.test.ts b/src/eol/utils.test.ts index d94c939..0539c16 100644 --- a/src/eol/utils.test.ts +++ b/src/eol/utils.test.ts @@ -1,6 +1,6 @@ import { test, describe } from 'node:test'; import { strict as assert } from 'node:assert'; -import { deriveComponentStatus } from './utils.ts'; +import { deriveComponentStatus, canonicalizePurl } from './utils.ts'; import type { EolScanComponentMetadata } from '../types/eol-scan.ts'; // These are required for the object but not used to derive the status @@ -80,3 +80,338 @@ describe('deriveComponentStatus', () => { assert.equal(result, 'OK'); }); }); + +describe('canonicalizePurl', () => { + // --- Canonical output matrix: exact expected strings --- + // Each assertion pins the literal output produced by the library-based implementation. + // Each assertion pins the exact output; a change in alias, case, or encoding fails a concrete string comparison. + + describe('type alias translation', () => { + test('go maps to golang — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:go/github.com/foo/bar@v1.0.0'), + 'pkg:golang/github.com/foo/bar@v1.0.0', + ); + }); + + test('rubygems maps to gem — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:rubygems/rails@7.0.0'), + 'pkg:gem/rails@7.0.0', + ); + }); + + test('golang is not re-aliased — idempotent', () => { + assert.equal( + canonicalizePurl('pkg:golang/github.com/foo/bar@v1.0.0'), + 'pkg:golang/github.com/foo/bar@v1.0.0', + ); + }); + + test('gem is not re-aliased — idempotent', () => { + assert.equal( + canonicalizePurl('pkg:gem/rails@7.0.0'), + 'pkg:gem/rails@7.0.0', + ); + }); + + test('npm type is not aliased — passes through unchanged', () => { + assert.equal( + canonicalizePurl('pkg:npm/lodash@4.17.21'), + 'pkg:npm/lodash@4.17.21', + ); + }); + }); + + // --- Case matrix: lowercased types --- + + describe('ecosystem case normalization — lowercased allowlist', () => { + test('nuget name is lowercased — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:nuget/Serilog.Sinks.Console@2.1.0'), + 'pkg:nuget/serilog.sinks.console@2.1.0', + ); + }); + + test('composer namespace and name are lowercased — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:composer/Foo/Bar@1.0.0'), + 'pkg:composer/foo/bar@1.0.0', + ); + }); + + test('cargo name is lowercased — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:cargo/MyLib@0.1.0'), + 'pkg:cargo/mylib@0.1.0', + ); + }); + + test('npm percent-encoded scoped name is lowercased — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:npm/%40Angular/Core@15.0.0'), + 'pkg:npm/%40angular/core@15.0.0', + ); + }); + + test('npm raw scoped name is percent-encoded and lowercased — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:npm/@angular/core@15.0.0'), + 'pkg:npm/%40angular/core@15.0.0', + ); + }); + }); + + // --- Case matrix: case-preserved types --- + + describe('ecosystem case normalization — case-sensitive types preserved', () => { + test('maven groupId and artifactId are byte-unchanged — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:maven/org.Apache.Commons/commons-Lang3@3.12.0'), + 'pkg:maven/org.Apache.Commons/commons-Lang3@3.12.0', + ); + }); + + test('golang namespace path is byte-unchanged — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:golang/github.com/BurntSushi/toml@v0.4.1'), + 'pkg:golang/github.com/BurntSushi/toml@v0.4.1', + ); + }); + + test('multi-segment golang path is byte-unchanged — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:golang/github.com/Foo/Bar/Baz@v1.0.0'), + 'pkg:golang/github.com/Foo/Bar/Baz@v1.0.0', + ); + }); + + test('rubygems alias applies but gem name is byte-unchanged — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:rubygems/ActiveSupport@7.0.0'), + 'pkg:gem/ActiveSupport@7.0.0', + ); + }); + }); + + // --- Version byte-preservation --- + + describe('version byte-preservation', () => { + test('version with mixed case is not modified — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:nuget/serilog@2.0.0-Beta3'), + 'pkg:nuget/serilog@2.0.0-Beta3', + ); + }); + + test('version with +build metadata is not modified — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:maven/g/a@1.0.0+build5'), + 'pkg:maven/g/a@1.0.0+build5', + ); + }); + + test('versionless PURL is accepted — exact canonical output', () => { + assert.equal(canonicalizePurl('pkg:nuget/Serilog'), 'pkg:nuget/serilog'); + }); + }); + + // --- Qualifier and subpath parsing edges --- + + describe('qualifier and subpath parsing', () => { + test('@ inside a qualifier value is not treated as the version separator — version is 1.0.0, qualifier canonically re-encoded', () => { + // The version is isolated as "1.0.0"; the @ in git@github.com belongs to the + // qualifier value, which the serializer re-encodes: git@github.com → git%40github.com%2Fx%2Fy.git + assert.equal( + canonicalizePurl('pkg:npm/foo@1.0.0?vcs_url=git@github.com/x/y.git'), + 'pkg:npm/foo@1.0.0?vcs_url=git%40github.com%2Fx%2Fy.git', + ); + }); + + test('@ inside qualifier value is idempotent — canonicalize twice returns equal', () => { + const input = 'pkg:npm/foo@1.0.0?vcs_url=git@github.com/x/y.git'; + const once = canonicalizePurl(input); + assert.equal(canonicalizePurl(once), once); + }); + + test('# in subpath is parsed correctly — exact canonical output', () => { + // The subpath @ is re-encoded by the serializer: path@x → path%40x + assert.equal( + canonicalizePurl('pkg:npm/foo@1.0.0#path@x'), + 'pkg:npm/foo@1.0.0#path%40x', + ); + }); + + test('# in subpath is idempotent — canonicalize twice returns equal', () => { + const input = 'pkg:npm/foo@1.0.0#path@x'; + const once = canonicalizePurl(input); + assert.equal(canonicalizePurl(once), once); + }); + }); + + // --- pypi: not double-applied --- + + describe('pypi passes through case normalization unchanged', () => { + test('pypi name is byte-identical to input — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:pypi/requests@2.28.0'), + 'pkg:pypi/requests@2.28.0', + ); + }); + }); + + // --- Idempotency --- + + describe('idempotency — canonicalize(canonicalize(x)) === canonicalize(x)', () => { + const idempotentCases: Array<[string, string]> = [ + ['pkg:nuget/Serilog.Sinks.Console@2.1.0', 'nuget mixed case'], + ['pkg:composer/Foo/Bar@1.0.0', 'composer mixed case'], + ['pkg:cargo/MyLib@0.1.0', 'cargo mixed case'], + ['pkg:npm/@angular/core@15.0.0', 'npm raw @'], + ['pkg:go/github.com/foo/bar@v1.0.0', 'go→golang alias'], + ['pkg:rubygems/rails@7.0.0', 'rubygems→gem alias'], + ['pkg:golang/github.com/BurntSushi/toml@v0.4.1', 'golang preserved'], + ['pkg:gem/rails@7.0.0', 'gem direct'], + ['pkg:maven/org.Apache.Commons/commons-Lang3@3.12.0', 'maven preserved'], + ['pkg:pypi/requests@2.28.0', 'pypi'], + ['pkg:npm/foo@1.0.0?vcs_url=git@github.com/x/y.git', 'qualifier with @'], + ['pkg:npm/foo@1.0.0#path@x', 'subpath with #@'], + ]; + + for (const [input, label] of idempotentCases) { + test(`idempotent for ${label}`, () => { + const once = canonicalizePurl(input); + assert.equal(canonicalizePurl(once), once); + }); + } + }); + + // --- Unparseable passthrough --- + + describe('unparseable PURL passthrough — returns input unchanged, never throws', () => { + test('string not starting with pkg: is returned unchanged', () => { + assert.equal(canonicalizePurl('not-a-purl'), 'not-a-purl'); + }); + + test('malformed pkg: input is returned unchanged', () => { + assert.equal(canonicalizePurl('pkg:'), 'pkg:'); + }); + + test('no exception for non-PURL input', () => { + assert.doesNotThrow(() => canonicalizePurl('not-a-purl')); + }); + + test('no exception for malformed pkg: input', () => { + assert.doesNotThrow(() => canonicalizePurl('pkg:')); + }); + + // A bare-major Go version (@v1, not full semver) is not serializable by the + // pinned packageurl-js, so the input passes through unchanged and is not aliased. + test('go-typed PURL with a bare-major version (@v1) does not throw', () => { + assert.doesNotThrow(() => + canonicalizePurl('pkg:go/github.com/foo/bar@v1'), + ); + }); + + test('go-typed PURL with a bare-major version (@v1) returns input unchanged', () => { + assert.equal( + canonicalizePurl('pkg:go/github.com/foo/bar@v1'), + 'pkg:go/github.com/foo/bar@v1', + ); + }); + }); + + // --- Encoded separator inside a segment is preserved --- + + describe('percent-encoded slash inside a segment is preserved', () => { + test('nuget %2F inside name stays encoded and uppercase-hex — exact canonical output', () => { + assert.equal( + canonicalizePurl('pkg:nuget/Foo%2FBar@1.0.0'), + 'pkg:nuget/foo%2Fbar@1.0.0', + ); + }); + }); + + // --- onUncanonicalized observability hook --- + // The hook fires ONLY in the serialize-failure branch (parsed OK, reconstruct threw). + // It MUST NOT fire on parse failures or on success. The function never throws even + // when the callback itself throws. + + describe('onUncanonicalized observability hook', () => { + // A golang bare-major version (@v1) parses OK but fails to serialize under + // the packageurl-js golang validator — the canonical serialize-failure scenario. + const serializeFailurePurl = 'pkg:go/github.com/foo/bar@v1'; + + test('hook fires exactly once on serialize failure, receives input purl and an error', () => { + let callCount = 0; + let receivedPurl: string | undefined; + let receivedError: unknown; + + canonicalizePurl(serializeFailurePurl, (purl, error) => { + callCount++; + receivedPurl = purl; + receivedError = error; + }); + + assert.equal(callCount, 1); + assert.equal(receivedPurl, serializeFailurePurl); + assert.ok(receivedError instanceof Error); + }); + + test('return value on serialize failure is the input unchanged', () => { + const result = canonicalizePurl(serializeFailurePurl, () => {}); + assert.equal(result, serializeFailurePurl); + }); + + test('hook does NOT fire on parse failure — non-PURL input', () => { + let callCount = 0; + canonicalizePurl('not-a-purl', () => { + callCount++; + }); + assert.equal(callCount, 0); + }); + + test('hook does NOT fire on parse failure — malformed pkg: input', () => { + let callCount = 0; + canonicalizePurl('pkg:', () => { + callCount++; + }); + assert.equal(callCount, 0); + }); + + test('hook does NOT fire on success — nuget canonical output', () => { + let callCount = 0; + const result = canonicalizePurl('pkg:nuget/Serilog@1.0.0', () => { + callCount++; + }); + assert.equal(callCount, 0); + assert.equal(result, 'pkg:nuget/serilog@1.0.0'); + }); + + test('backward compatible — no callback, serialize failure, does not throw', () => { + assert.doesNotThrow(() => canonicalizePurl(serializeFailurePurl)); + }); + + test('backward compatible — no callback, serialize failure, returns input unchanged', () => { + assert.equal( + canonicalizePurl(serializeFailurePurl), + serializeFailurePurl, + ); + }); + + test('a callback that itself throws does not propagate — function does not throw', () => { + assert.doesNotThrow(() => { + canonicalizePurl(serializeFailurePurl, () => { + throw new Error('boom'); + }); + }); + }); + + test('a callback that itself throws does not break identity — return value is input unchanged', () => { + const result = canonicalizePurl(serializeFailurePurl, () => { + throw new Error('boom'); + }); + assert.equal(result, serializeFailurePurl); + }); + }); +}); diff --git a/src/eol/utils.ts b/src/eol/utils.ts index d16f43e..5d7a0ad 100644 --- a/src/eol/utils.ts +++ b/src/eol/utils.ts @@ -5,6 +5,82 @@ import type { EolScanComponentMetadata, } from '../types/eol-scan.js'; +// Maps non-canonical PURL type tokens to their canonical equivalents. +// This is a PURL type→type alias map — not the engine's type→Ecosystem map. +const PURL_TYPE_ALIASES: Record = { + go: 'golang', + rubygems: 'gem', +}; + +// Types whose namespace and name tokens are case-insensitive (registry-lowercased). +// golang, maven, gem, pypi, pub, and all others are EXCLUDED — their paths are case-sensitive. +const CASE_INSENSITIVE_TYPES = new Set(['nuget', 'composer', 'cargo', 'npm']); + +/** + * Normalizes a PURL string to its canonical identity form. + * + * Applies, in order: + * 1. Parse with PackageURL.fromString — the library handles version isolation, + * qualifier/subpath separation, and percent-encoding correctly. + * 2. Type-alias translation (go → golang, rubygems → gem). + * 3. Ecosystem-aware case normalization (namespace + name lowercased for + * nuget, composer, cargo, npm only). + * 4. Reconstruct with new PackageURL(...).toString() — the library serializes + * qualifiers and subpath with canonical percent-encoding. + * + * Version is byte-preserved. Qualifiers and subpath are canonically re-encoded + * by the serializer (deterministic and idempotent). + * + * Returns the input unchanged for unparseable strings; never throws. + * + * The optional `onUncanonicalized` callback is invoked when the PURL parses + * successfully but cannot be re-serialized into canonical form (e.g. the + * packageurl-js golang validator limitation on bare-major versions like `@v1`). + * It is NOT invoked on parse failures (non-PURL / malformed input — those + * are silent passthroughs) and NOT invoked on success. A callback that + * itself throws is silently ignored so it cannot break the identity path. + */ +export function canonicalizePurl( + purl: string, + onUncanonicalized?: (purl: string, error: unknown) => void, +): string { + let parsed: PackageURL; + try { + parsed = PackageURL.fromString(purl); + } catch { + // Input is not a parseable PURL — return it unchanged. This is expected for + // non-PURL input and is intentionally not reported via onUncanonicalized. + return purl; + } + + try { + const type = PURL_TYPE_ALIASES[parsed.type] ?? parsed.type; + let namespace = parsed.namespace; + let name = parsed.name; + if (CASE_INSENSITIVE_TYPES.has(type)) { + namespace = namespace ? namespace.toLowerCase() : namespace; + name = name.toLowerCase(); + } + return new PackageURL( + type, + namespace ?? undefined, + name, + parsed.version ?? undefined, + parsed.qualifiers ?? undefined, + parsed.subpath ?? undefined, + ).toString(); + } catch (error) { + // The PURL parsed but could not be re-serialized into canonical form. + // Return it unchanged and notify the caller so the anomaly is observable. + try { + onUncanonicalized?.(purl, error); + } catch { + // A misbehaving callback must not break the identity path. + } + return purl; + } +} + export function deriveComponentStatus( metadata: EolScanComponentMetadata | null, ): ComponentStatus { diff --git a/src/index.ts b/src/index.ts index 2cd88c1..f609715 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,11 @@ export { xmlStringToJSON } from './cdx-xml-to-json.js'; export { trimCdxBom } from './trim-cdx-bom.js'; export { spdxToCdxBom } from './spdx-to-cdx.js'; -export { deriveComponentStatus, extractPurlsFromCdxBom } from './eol/utils.js'; +export { + deriveComponentStatus, + extractPurlsFromCdxBom, + canonicalizePurl, +} from './eol/utils.js'; export type * from './types/eol-scan.js'; export type * from './types/index.js';