From c1d8ead9a24aa442ded9b213be78c7e25384caa9 Mon Sep 17 00:00:00 2001 From: Ori <18102267+oritwoen@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:07:10 +0200 Subject: [PATCH] fix(purl): canonicalize qualifier keys --- src/core/purl.ts | 18 ++++++++++++++++-- test/unit/purl.test.ts | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/core/purl.ts b/src/core/purl.ts index 8087371..f485117 100644 --- a/src/core/purl.ts +++ b/src/core/purl.ts @@ -3,6 +3,8 @@ import type { Client } from "./client.ts"; import { create, type Registry } from "./registry.ts"; import { InvalidPURLError } from "./errors.ts"; +const QUALIFIER_KEY_PATTERN = /^[a-z][a-z0-9._-]*$/; + /** Decode a percent-encoded PURL component, throwing InvalidPURLError on malformed sequences. */ function decodePURLComponent(purlStr: string, value: string): string { try { @@ -147,8 +149,20 @@ export function buildPURL(parts: { purl += `@${encodeURIComponent(parts.version)}`; } if (parts.qualifiers && Object.keys(parts.qualifiers).length > 0) { - const qs = Object.entries(parts.qualifiers) - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + const qualifiers = new Map(); + for (const [key, value] of Object.entries(parts.qualifiers)) { + const canonicalKey = key.toLowerCase(); + if (!QUALIFIER_KEY_PATTERN.test(canonicalKey)) { + throw new InvalidPURLError(purl, `invalid qualifier key "${key}"`); + } + if (qualifiers.has(canonicalKey)) { + throw new InvalidPURLError(purl, `duplicate qualifier key "${canonicalKey}"`); + } + qualifiers.set(canonicalKey, value); + } + const qs = [...qualifiers] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join("&"); purl += `?${qs}`; } diff --git a/test/unit/purl.test.ts b/test/unit/purl.test.ts index 39609d2..f4b5f3e 100644 --- a/test/unit/purl.test.ts +++ b/test/unit/purl.test.ts @@ -265,6 +265,25 @@ describe("purl", () => { ).toBe("pkg:npm/lodash?arch=x86_64&os=linux"); }); + it("canonicalizes qualifier keys and ordering", () => { + expect( + buildPURL({ + type: "npm", + name: "lodash", + qualifiers: { Zeta: "1", arch: "x86_64", Distro: "debian" }, + }), + ).toBe("pkg:npm/lodash?arch=x86_64&distro=debian&zeta=1"); + }); + + it("rejects invalid or colliding qualifier keys", () => { + expect(() => + buildPURL({ type: "npm", name: "lodash", qualifiers: { "bad key": "value" } }), + ).toThrow('invalid qualifier key "bad key"'); + expect(() => + buildPURL({ type: "npm", name: "lodash", qualifiers: { Arch: "arm64", arch: "x86_64" } }), + ).toThrow('duplicate qualifier key "arch"'); + }); + it("builds PURL with subpath", () => { expect(buildPURL({ type: "npm", name: "lodash", subpath: "lib/index.js" })).toBe( "pkg:npm/lodash#lib/index.js",