| 5 | import { |
| 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 | |
| 19 | import type { PackageURL } from './package-url.js' |
| 20 | |
| 21 | export 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. |
| 25 | let _PackageURL: typeof PackageURL | undefined |
| 29 | export function _registerPackageURL(ctor: typeof PackageURL): void { |
| 30 | _PackageURL = ctor |
| 31 | } |
| 32 | |
| 33 | function toCanonicalString(input: PurlInput): string { |
| 34 | if (typeof input === 'string') { |
| 36 | if (!_PackageURL) { |
| 37 | throw new ErrorCtor( |
| 38 | 'PackageURL not registered. Import PackageURL before using string comparison.', |
| 39 | ) |
| 40 | } |
| 42 | return _PackageURL.fromString(input).toString() |
| 43 | } |
| 44 | return input.toString() |
| 45 | } |
| 51 | const wildcardRegexCache = new MapCtor<string, RegExp>() |
| 52 | const WILDCARD_CACHE_MAX = 1024 |
| 59 | const 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). |
| 62 | const MAX_WILDCARDS_PER_PATTERN = 32 |
| 63 | |
| 64 | function 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) |
| 69 | count += 1 |
| 69 | count += 1 |
| 70 | } |
| 71 | } |
| 72 | return count |
| 73 | } |
| 74 | |
| 75 | function 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 | } |
| 122 | function 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 | } |
| 188 | export function equals(a: PurlInput, b: PurlInput): boolean { |
| 189 | return toCanonicalString(a) === toCanonicalString(b) |
| 190 | } |
| 219 | export 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 | |
| 231 | type ParsedPattern = { |
| 232 | typePattern: string |
| 233 | namespacePattern: string | undefined |
| 234 | namePattern: string |
| 235 | versionPattern: string | undefined |
| 236 | } |
| 246 | function 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 | } |
| 349 | export 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 | } |
| 381 | export 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 | } |
| 13 | export { cargoExists } from './purl-types/cargo.js' |
| 14 | export { cocoapodsExists } from './purl-types/cocoapods.js' |
| 15 | export { condaExists } from './purl-types/conda.js' |
| 16 | export { dockerExists } from './purl-types/docker.js' |
| 17 | export { packagistExists } from './purl-types/composer.js' |
| 18 | export { cpanExists } from './purl-types/cpan.js' |
| 19 | export { cranExists } from './purl-types/cran.js' |
| 20 | export { gemExists } from './purl-types/gem.js' |
| 21 | export { golangExists } from './purl-types/golang.js' |
| 22 | export { hackageExists } from './purl-types/hackage.js' |
| 23 | export { hexExists } from './purl-types/hex.js' |
| 24 | export { mavenExists } from './purl-types/maven.js' |
| 25 | export { npmExists } from './purl-types/npm.js' |
| 26 | export { nugetExists } from './purl-types/nuget.js' |
| 27 | export { pubExists } from './purl-types/pub.js' |
| 28 | export { purlExists } from './purl-exists.js' |
| 29 | export { pypiExists } from './purl-types/pypi.js' |
| 30 | export { vscodeExtensionExists } from './purl-types/vscode-extension.js' |
| 31 | export type { ExistsOptions, ExistsResult } from './purl-types/npm.js' |
| 37 | |
| 5 | import { cargoExists } from './purl-types/cargo.js' |
| 6 | import { cocoapodsExists } from './purl-types/cocoapods.js' |
| 7 | import { packagistExists } from './purl-types/composer.js' |
| 8 | import { condaExists } from './purl-types/conda.js' |
| 9 | import { cpanExists } from './purl-types/cpan.js' |
| 10 | import { cranExists } from './purl-types/cran.js' |
| 11 | import { dockerExists } from './purl-types/docker.js' |
| 12 | import { gemExists } from './purl-types/gem.js' |
| 13 | import { golangExists } from './purl-types/golang.js' |
| 14 | import { hackageExists } from './purl-types/hackage.js' |
| 15 | import { hexExists } from './purl-types/hex.js' |
| 16 | import { mavenExists } from './purl-types/maven.js' |
| 17 | import { npmExists } from './purl-types/npm.js' |
| 18 | import { nugetExists } from './purl-types/nuget.js' |
| 19 | import { pubExists } from './purl-types/pub.js' |
| 20 | import { pypiExists } from './purl-types/pypi.js' |
| 21 | |
| 22 | import type { PackageURL } from './package-url.js' |
| 23 | import type { ExistsResult, ExistsOptions } from './purl-types/npm.js' |
| 85 | export 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 | } |
| 5 | import { |
| 6 | ArrayPrototypeFlatMap, |
| 7 | ArrayPrototypeToSorted, |
| 8 | ObjectCreate, |
| 9 | ObjectKeys, |
| 10 | ObjectValues, |
| 11 | SetCtor, |
| 12 | } from '@socketsecurity/lib/primordials' |
| 17 | function 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 | |
| 62 | export { createHelpersNamespaceObject } |