Part 7:  Comparison, Matching & Existence

Compare + equal + match PURLs with wildcard patterns *(ReDoS-protected)*; registry existence checks.

Topics: Anatomy Building Parsing Validation URL Ecosystems Comparison Security Architecture Builders Contributing Converters Hardening Release Tour VERS
src/compare.ts
14 sections
1 2 3 4 5 6 7 8 9 10 11 12 13 14
Section 1 of 14
5import {
6 ErrorCtor,
7 MapCtor,
8 RegExpCtor,
9 RegExpPrototypeTest,
10 StringPrototypeCharCodeAt,
11 StringPrototypeIncludes,
12 StringPrototypeIndexOf,
13 StringPrototypeReplace,
14 StringPrototypeSlice,
15 StringPrototypeStartsWith,
16 StringPrototypeToLowerCase,
17} from '@socketsecurity/lib/primordials'
18
19import type { PackageURL } from './package-url.js'
20
21export type PurlInput = PackageURL | string
22
23// Lazy reference to `PackageURL`, set by `package-url.ts` at module load time
24// to avoid circular import issues.
25let _PackageURL: typeof PackageURL | undefined
Section 2 of 14
29export function _registerPackageURL(ctor: typeof PackageURL): void {
30 _PackageURL = ctor
31}
32
33function toCanonicalString(input: PurlInput): string {
34 if (typeof input === 'string') {
Section 3 of 14
36 if (!_PackageURL) {
37 throw new ErrorCtor(
38 'PackageURL not registered. Import PackageURL before using string comparison.',
39 )
40 }
Section 4 of 14
42 return _PackageURL.fromString(input).toString()
43 }
44 return input.toString()
45}
Section 5 of 14
51const wildcardRegexCache = new MapCtor<string, RegExp>()
52const WILDCARD_CACHE_MAX = 1024
Section 6 of 14
59const MAX_PATTERN_LENGTH = 4096
60// Cap wildcards to prevent backtracking DoS via many alternating `*` even
61// under the overall length limit (e.g., `a*b*c*…*z` on a long non-match).
62const MAX_WILDCARDS_PER_PATTERN = 32
63
64function countWildcards(pattern: string): number {
65 let count = 0
66 for (let i = 0, { length } = pattern; i < length; i += 1) {
67 const code = StringPrototypeCharCodeAt(pattern, i)
Section 7 of 14
69 count += 1
Section 7 of 14
69 count += 1
70 }
71 }
72 return count
73}
74
75function matchWildcard(pattern: string, value: string): boolean {
76 // Reject excessively long patterns to prevent regex compilation DoS
77 if (pattern.length > MAX_PATTERN_LENGTH) {
78 return false
79 }
80 // Reject patterns with too many wildcards to prevent polynomial backtracking.
81 if (countWildcards(pattern) > MAX_WILDCARDS_PER_PATTERN) {
82 return false
83 }
84 let regex = wildcardRegexCache.get(pattern)
85 if (regex === undefined) {
86 // Convert glob pattern to regex
87 // Escape regex special chars except `*` and `?`
88 const regexPattern = StringPrototypeReplace(
89 StringPrototypeReplace(
90 StringPrototypeReplace(pattern, /[.+^${}()|[\]\\]/g, '\\$&' as any),
91 /\*/g,
92 '.*' as any,
93 ),
94 /\?/g,
95 '.' as any,
96 )
97
98 // Collapse consecutive .* groups to prevent polynomial backtracking (ReDoS)
99 regex = new RegExpCtor(
100 `^${StringPrototypeReplace(regexPattern, /(\.\*)+/g, '.*' as any)}$`,
101 )
102 if (wildcardRegexCache.size >= WILDCARD_CACHE_MAX) {
103 // Evict oldest entry (`Map` iteration order is insertion order)
104 const oldest = wildcardRegexCache.keys().next().value
105 if (oldest !== undefined) {
106 wildcardRegexCache.delete(oldest)
107 }
108 }
109 wildcardRegexCache.set(pattern, regex)
110 } else {
111 // Promote to most-recently-used by re-inserting.
112 wildcardRegexCache.delete(pattern)
113 wildcardRegexCache.set(pattern, regex)
114 }
115 return RegExpPrototypeTest(regex, value)
116}
Section 9 of 14
122function matchComponent(
123 patternValue: string | null | undefined,
124 actualValue: string | null | undefined,
125 matcher?: (_value: string) => boolean,
126): boolean {
127 // Handle `**` (match any value including empty)
128 if (patternValue === '**') {
129 return true
130 }
131
132 // If pattern has no value, actual must also have no value
133 if (
134 patternValue === null ||
135 patternValue === undefined ||
136 patternValue === ''
137 ) {
138 return (
139 actualValue === null || actualValue === undefined || actualValue === ''
140 )
141 }
142
143 // If actual has no value but pattern expects one, no match
144 if (actualValue === null || actualValue === undefined || actualValue === '') {
145 return false
146 }
147
148 // Use pre-compiled matcher if provided
149 if (matcher) {
150 return matcher(actualValue)
151 }
152
153 // Check if pattern contains wildcards (when no pre-compiled matcher)
154 if (
155 StringPrototypeIncludes(patternValue, '*') ||
156 StringPrototypeIncludes(patternValue, '?')
157 ) {
158 return matchWildcard(patternValue, actualValue)
159 }
160
161 // Literal match
162 return patternValue === actualValue
163}
Section 10 of 14
188export function equals(a: PurlInput, b: PurlInput): boolean {
189 return toCanonicalString(a) === toCanonicalString(b)
190}
Section 11 of 14
219export function compare(a: PurlInput, b: PurlInput): -1 | 0 | 1 {
220 const aStr = toCanonicalString(a)
221 const bStr = toCanonicalString(b)
222 if (aStr < bStr) {
223 return -1
224 }
225 if (aStr > bStr) {
226 return 1
227 }
228 return 0
229}
230
231type ParsedPattern = {
232 typePattern: string
233 namespacePattern: string | undefined
234 namePattern: string
235 versionPattern: string | undefined
236}
Section 12 of 14
246function parsePattern(pattern: string): ParsedPattern | undefined {
247 if (!StringPrototypeStartsWith(pattern, 'pkg:')) {
248 return undefined
249 }
250
251 // Remove `'pkg:'` prefix
252 const patternWithoutScheme = StringPrototypeSlice(pattern, 4)
253
254 // Extract type
255 const typeEndIndex = StringPrototypeIndexOf(patternWithoutScheme, '/')
256 if (typeEndIndex === -1) {
257 return undefined
258 }
259 let typePattern = StringPrototypeSlice(patternWithoutScheme, 0, typeEndIndex)
260
261 // Extract remaining parts
262 const remaining = StringPrototypeSlice(patternWithoutScheme, typeEndIndex + 1)
263
264 // Parse namespace and name
265 // Format: `[namespace/]name[@version][?qualifiers][#subpath]`
266 // Namespace is optional and ends at the first `'/'`
267 let namespacePattern: string | undefined
268 let namePattern: string
269 let versionPattern: string | undefined
270
271 // Check if there's a namespace (indicated by presence of `'/'`)
272 const firstSlashIndex = StringPrototypeIndexOf(remaining, '/')
273 let nameAndVersion: string
274
275 if (firstSlashIndex !== -1) {
276 // Has namespace
277 namespacePattern = StringPrototypeSlice(remaining, 0, firstSlashIndex)
278 nameAndVersion = StringPrototypeSlice(remaining, firstSlashIndex + 1)
279 } else {
280 // No namespace
281 nameAndVersion = remaining
282 }
283
284 // Extract version from name (version starts with `'@'`)
285 // For scoped packages without namespace (e.g., `'@foo'` as name), skip first `'@'`
286 const versionSeparatorIndex = StringPrototypeStartsWith(nameAndVersion, '@')
287 ? StringPrototypeIndexOf(nameAndVersion, '@', 1)
288 : StringPrototypeIndexOf(nameAndVersion, '@')
289
290 if (versionSeparatorIndex !== -1) {
291 namePattern = StringPrototypeSlice(nameAndVersion, 0, versionSeparatorIndex)
292 // Version is everything after `@` (qualifiers/subpath not supported in patterns v1)
293 versionPattern = StringPrototypeSlice(
294 nameAndVersion,
295 versionSeparatorIndex + 1,
296 )
297 } else {
298 namePattern = nameAndVersion
299 }
300
301 // Apply type-specific normalization to pattern components
302 // Types are case-insensitive, so normalize to lowercase
303 typePattern = StringPrototypeToLowerCase(typePattern)
304
305 // For `npm`: lowercase namespace and name (ignoring legacy names for simplicity)
306 if (typePattern === 'npm') {
307 if (namespacePattern) {
308 namespacePattern = StringPrototypeToLowerCase(namespacePattern)
309 }
310 namePattern = StringPrototypeToLowerCase(namePattern)
311 }
312
313 // For `pypi`: lowercase name and replace underscores with hyphens
314 if (typePattern === 'pypi') {
315 namePattern = StringPrototypeReplace(
316 StringPrototypeToLowerCase(namePattern),
317 /_/g,
318 '-' as any,
319 )
320 }
321
322 return { typePattern, namespacePattern, namePattern, versionPattern }
323}
Section 13 of 14
349export function matches(pattern: string, purl: PackageURL): boolean {
350 const parsed = parsePattern(pattern)
351 if (!parsed) {
352 return false
353 }
354 const { typePattern, namespacePattern, namePattern, versionPattern } = parsed
355
356 // Match each component (always use component matching to properly ignore qualifiers/subpath)
357 return (
358 matchComponent(typePattern, purl.type) &&
359 matchComponent(namespacePattern, purl.namespace) &&
360 matchComponent(namePattern, purl.name) &&
361 matchComponent(versionPattern, purl.version)
362 )
363}
Section 14 of 14
381export function createMatcher(pattern: string): (_purl: PackageURL) => boolean {
382 const parsed = parsePattern(pattern)
383 if (!parsed) {
384 return () => false
385 }
386 const { typePattern, namespacePattern, namePattern, versionPattern } = parsed
387
388 // Pre-compile wildcard matchers for components with wildcards
389 const typeHasWildcard =
390 typePattern &&
391 (StringPrototypeIncludes(typePattern, '*') ||
392 StringPrototypeIncludes(typePattern, '?'))
393 const typeMatcher = typeHasWildcard
394 ? (value: string) => matchWildcard(typePattern, value)
395 : undefined
396
397 const namespaceHasWildcard =
398 namespacePattern &&
399 (StringPrototypeIncludes(namespacePattern, '*') ||
400 StringPrototypeIncludes(namespacePattern, '?'))
401 const namespaceMatcher =
402 namespaceHasWildcard && namespacePattern
403 ? (value: string) => matchWildcard(namespacePattern, value)
404 : undefined
405
406 const nameHasWildcard =
407 namePattern &&
408 (StringPrototypeIncludes(namePattern, '*') ||
409 StringPrototypeIncludes(namePattern, '?'))
410 const nameMatcher = nameHasWildcard
411 ? (value: string) => matchWildcard(namePattern, value)
412 : undefined
413
414 const versionHasWildcard =
415 versionPattern &&
416 (StringPrototypeIncludes(versionPattern, '*') ||
417 StringPrototypeIncludes(versionPattern, '?'))
418 const versionMatcher =
419 versionHasWildcard && versionPattern
420 ? (value: string) => matchWildcard(versionPattern, value)
421 : undefined
422
423 // Return optimized matcher function with pre-compiled matchers
424 return (_purl: PackageURL): boolean => {
425 return (
426 matchComponent(typePattern, _purl.type, typeMatcher) &&
427 matchComponent(namespacePattern, _purl.namespace, namespaceMatcher) &&
428 matchComponent(namePattern, _purl.name, nameMatcher) &&
429 matchComponent(versionPattern, _purl.version, versionMatcher)
430 )
431 }
432}
src/exists.ts
2 sections
1 2
Section 1 of 2
13export { cargoExists } from './purl-types/cargo.js'
14export { cocoapodsExists } from './purl-types/cocoapods.js'
15export { condaExists } from './purl-types/conda.js'
16export { dockerExists } from './purl-types/docker.js'
17export { packagistExists } from './purl-types/composer.js'
18export { cpanExists } from './purl-types/cpan.js'
19export { cranExists } from './purl-types/cran.js'
20export { gemExists } from './purl-types/gem.js'
21export { golangExists } from './purl-types/golang.js'
22export { hackageExists } from './purl-types/hackage.js'
23export { hexExists } from './purl-types/hex.js'
24export { mavenExists } from './purl-types/maven.js'
25export { npmExists } from './purl-types/npm.js'
26export { nugetExists } from './purl-types/nuget.js'
27export { pubExists } from './purl-types/pub.js'
28export { purlExists } from './purl-exists.js'
29export { pypiExists } from './purl-types/pypi.js'
30export { vscodeExtensionExists } from './purl-types/vscode-extension.js'
31export type { ExistsOptions, ExistsResult } from './purl-types/npm.js'
Section 2 of 2
37
src/purl-exists.ts
2 sections
1 2
Section 1 of 2
5import { cargoExists } from './purl-types/cargo.js'
6import { cocoapodsExists } from './purl-types/cocoapods.js'
7import { packagistExists } from './purl-types/composer.js'
8import { condaExists } from './purl-types/conda.js'
9import { cpanExists } from './purl-types/cpan.js'
10import { cranExists } from './purl-types/cran.js'
11import { dockerExists } from './purl-types/docker.js'
12import { gemExists } from './purl-types/gem.js'
13import { golangExists } from './purl-types/golang.js'
14import { hackageExists } from './purl-types/hackage.js'
15import { hexExists } from './purl-types/hex.js'
16import { mavenExists } from './purl-types/maven.js'
17import { npmExists } from './purl-types/npm.js'
18import { nugetExists } from './purl-types/nuget.js'
19import { pubExists } from './purl-types/pub.js'
20import { pypiExists } from './purl-types/pypi.js'
21
22import type { PackageURL } from './package-url.js'
23import type { ExistsResult, ExistsOptions } from './purl-types/npm.js'
Section 2 of 2
85export async function purlExists(
86 purl: PackageURL,
87 options?: ExistsOptions,
88): Promise<ExistsResult> {
89 const { name, namespace, type, version } = purl
90
91 if (!type) {
92 return {
93 exists: false,
94 error: 'Package type is required',
95 }
96 }
97
98 if (!name) {
99 return {
100 exists: false,
101 error: 'Package name is required',
102 }
103 }
104
105 switch (type) {
106 case 'npm':
107 return npmExists(name, namespace, version, options)
108 case 'pypi':
109 return pypiExists(name, version, options)
110 case 'cargo':
111 return cargoExists(name, version, options)
112 case 'gem':
113 return gemExists(name, version, options)
114 case 'maven':
115 return mavenExists(name, namespace, version, options)
116 case 'nuget':
117 return nugetExists(name, version, options)
118 case 'golang':
119 return golangExists(name, namespace, version, options)
120 case 'composer':
121 return packagistExists(name, namespace, version, options)
122 case 'cocoapods':
123 return cocoapodsExists(name, version, options)
124 case 'conda':
125 return condaExists(name, version, namespace, options)
126 case 'docker':
127 return dockerExists(name, namespace, version, options)
128 case 'pub':
129 return pubExists(name, version, options)
130 case 'hex':
131 return hexExists(name, version, options)
132 case 'cpan':
133 return cpanExists(name, version, options)
134 case 'cran':
135 return cranExists(name, version, options)
136 case 'hackage':
137 return hackageExists(name, version, options)
138 default:
139 return {
140 exists: false,
141 error: `Unsupported type: ${type}`,
142 }
143 }
144}
src/helpers.ts
2 sections
1 2
Section 1 of 2
5import {
6 ArrayPrototypeFlatMap,
7 ArrayPrototypeToSorted,
8 ObjectCreate,
9 ObjectKeys,
10 ObjectValues,
11 SetCtor,
12} from '@socketsecurity/lib/primordials'
Section 2 of 2
17function createHelpersNamespaceObject(
18 helpers: Record<string, Record<string, unknown>>,
19 options_: Record<string, unknown> = {},
20): Record<string, Record<string, unknown>> {
21 const { comparator, ...defaults } = {
22 __proto__: null,
23 ...options_,
24 } as Record<string, unknown> & {
25 comparator?: ((_a: string, _b: string) => number) | undefined
26 }
27 const helperNames = ArrayPrototypeToSorted(ObjectKeys(helpers))
28 // Collect all unique property names from all helper objects
29 const propNames = ArrayPrototypeToSorted(
30 [
31 ...new SetCtor(
32 ArrayPrototypeFlatMap(
33 ObjectValues(helpers),
34 (helper: Record<string, unknown>) => ObjectKeys(helper),
35 ),
36 ),
37 ],
38 comparator,
39 )
40 const nsObject: Record<string, Record<string, unknown>> = ObjectCreate(null)
41 // Build inverted structure: property -> {helper1: value1, helper2: value2}
42 for (let i = 0, { length } = propNames; i < length; i += 1) {
43 const propName = propNames[i]!
44 const helpersForProp: Record<string, unknown> = ObjectCreate(null)
45 for (
46 let j = 0, { length: helperNamesLength } = helperNames;
47 j < helperNamesLength;
48 j += 1
49 ) {
50 const helperName = helperNames[j]!
51 const helperValue =
52 helpers[helperName]?.[propName] ?? defaults[helperName]
53 if (helperValue !== undefined) {
54 helpersForProp[helperName] = helperValue
55 }
56 }
57 nsObject[propName] = helpersForProp
58 }
59 return nsObject
60}
61
62export { createHelpersNamespaceObject }