| 22 | import { |
| 23 | _registerPackageURL, |
| 24 | compare as comparePurls, |
| 25 | equals as equalsPurls, |
| 26 | } from './compare.js' |
| 27 | import { decodePurlComponent } from './decode.js' |
| 28 | import { PurlError } from './error.js' |
| 29 | import { |
| 30 | normalizeName, |
| 31 | normalizeNamespace, |
| 32 | normalizeQualifiers, |
| 33 | normalizeSubpath, |
| 34 | normalizeType, |
| 35 | normalizeVersion, |
| 36 | } from './normalize.js' |
| 37 | import { isObject, recursiveFreeze } from './objects.js' |
| 38 | import { |
| 39 | ArrayIsArray, |
| 40 | BufferByteLength, |
| 41 | ErrorCtor, |
| 42 | JSONParse, |
| 43 | JSONStringify, |
| 44 | MapCtor, |
| 45 | ObjectCreate, |
| 46 | ObjectFreeze, |
| 47 | ObjectKeys, |
| 48 | ReflectDefineProperty, |
| 49 | ReflectGetOwnPropertyDescriptor, |
| 50 | ReflectSetPrototypeOf, |
| 51 | RegExpPrototypeTest, |
| 52 | StringPrototypeCharCodeAt, |
| 53 | StringPrototypeIncludes, |
| 54 | StringPrototypeIndexOf, |
| 55 | StringPrototypeLastIndexOf, |
| 56 | StringPrototypeSlice, |
| 57 | StringPrototypeSplit, |
| 58 | StringPrototypeStartsWith, |
| 59 | SyntaxErrorCtor, |
| 60 | URLCtor, |
| 61 | URLSearchParamsCtor, |
| 62 | } from '@socketsecurity/lib/primordials' |
| 63 | import { PurlComponent } from './purl-component.js' |
| 64 | import { PurlQualifierNames } from './purl-qualifier-names.js' |
| 65 | import { PurlType } from './purl-type.js' |
| 66 | import { parseNpmSpecifier } from './purl-types/npm.js' |
| 67 | import { Err, Ok, ResultUtils, err, ok } from './result.js' |
| 68 | import { stringify, stringifySpec } from './stringify.js' |
| 69 | import { isBlank, isNonEmptyString, trimLeadingSlashes } from './strings.js' |
| 70 | import { |
| 71 | UrlConverter, |
| 72 | _registerPackageURLForUrlConverter, |
| 73 | } from './url-converter.js' |
| 74 | import { |
| 75 | validateName, |
| 76 | validateNamespace, |
| 77 | validateQualifiers, |
| 78 | validateSubpath, |
| 79 | validateType, |
| 80 | validateVersion, |
| 81 | } from './validate.js' |
| 82 | |
| 83 | import type { QualifiersObject } from './purl-component.js' |
| 84 | import type { Result } from './result.js' |
| 85 | import type { DownloadUrl, RepositoryUrl } from './url-converter.js' |
| 101 | export type PackageURLComponentValue = string | QualifiersObject | undefined |
| 107 | export type PackageURLObject = { |
| 108 | type?: string | undefined |
| 109 | namespace?: string | undefined |
| 110 | name?: string | undefined |
| 111 | version?: string | undefined |
| 112 | qualifiers?: QualifiersObject | undefined |
| 113 | subpath?: string | undefined |
| 114 | } |
| 121 | export type ParsedPurlComponents = [ |
| 122 | type: string | undefined, |
| 123 | namespace: string | undefined, |
| 124 | name: string | undefined, |
| 125 | version: string | undefined, |
| 126 | qualifiers: URLSearchParams | undefined, |
| 127 | subpath: string | undefined, |
| 128 | ] |
| 129 | |
| 130 | // LRU flyweight cache for `fromString` — avoids re-parsing identical PURL strings. |
| 131 | // Bounded to prevent memory leaks. Uses a `Map` for O(1) lookup with LRU eviction. |
| 132 | const FLYWEIGHT_CACHE_MAX = 1024 |
| 133 | const flyweightCache = new MapCtor<string, PackageURL>() |
| 134 | |
| 135 | // Pattern to match URLs with schemes other than "pkg" |
| 136 | // Limited to 256 chars for scheme to prevent ReDoS |
| 137 | const OTHER_SCHEME_PATTERN = ObjectFreeze(/^[a-zA-Z][a-zA-Z0-9+.-]{0,255}:\/\//) |
| 138 | |
| 139 | // Pattern to match purl-like strings with type/name format |
| 140 | // Limited to 256 chars for type to prevent ReDoS |
| 141 | const PURL_LIKE_PATTERN = ObjectFreeze(/^[a-zA-Z0-9+.-]{1,256}\//) |
| 147 | class PackageURL { |
| 148 | static Component = recursiveFreeze(PurlComponent) |
| 149 | static KnownQualifierNames = recursiveFreeze(PurlQualifierNames) |
| 150 | static Type = recursiveFreeze(PurlType) |
| 153 | _cachedString?: string | undefined |
| 154 | |
| 155 | name?: string | undefined |
| 156 | namespace?: string | undefined |
| 157 | qualifiers?: QualifiersObject | undefined |
| 158 | subpath?: string | undefined |
| 159 | type?: string | undefined |
| 160 | version?: string | undefined |
| 161 | |
| 162 | constructor( |
| 163 | rawType: unknown, |
| 164 | rawNamespace: unknown, |
| 165 | rawName: unknown, |
| 166 | rawVersion: unknown, |
| 167 | rawQualifiers: unknown, |
| 168 | rawSubpath: unknown, |
| 169 | ) { |
| 170 | const type = isNonEmptyString(rawType) ? normalizeType(rawType) : rawType |
| 171 | validateType(type, true) |
| 172 | |
| 173 | const namespace = isNonEmptyString(rawNamespace) |
| 174 | ? normalizeNamespace(rawNamespace) |
| 175 | : rawNamespace |
| 176 | validateNamespace(namespace, true) |
| 177 | |
| 178 | const name = isNonEmptyString(rawName) ? normalizeName(rawName) : rawName |
| 179 | validateName(name, true) |
| 180 | |
| 181 | const version = isNonEmptyString(rawVersion) |
| 182 | ? normalizeVersion(rawVersion) |
| 183 | : rawVersion |
| 184 | validateVersion(version, true) |
| 185 | |
| 186 | const qualifiers = |
| 187 | typeof rawQualifiers === 'string' || isObject(rawQualifiers) |
| 188 | ? normalizeQualifiers(rawQualifiers as string | QualifiersObject) |
| 189 | : rawQualifiers |
| 190 | validateQualifiers(qualifiers, true) |
| 191 | |
| 192 | const subpath = isNonEmptyString(rawSubpath) |
| 193 | ? normalizeSubpath(rawSubpath) |
| 194 | : rawSubpath |
| 195 | validateSubpath(subpath, true) |
| 196 | |
| 197 | this.type = type as string |
| 198 | this.name = name as string |
| 199 | if (namespace !== undefined) { |
| 200 | this.namespace = namespace as string |
| 201 | } |
| 202 | if (version !== undefined) { |
| 203 | this.version = version as string |
| 204 | } |
| 205 | this.qualifiers = (qualifiers as QualifiersObject) ?? undefined |
| 206 | if (subpath !== undefined) { |
| 207 | this.subpath = subpath as string |
| 208 | } |
| 209 | |
| 210 | const typeHelpers = PurlType[type as string] |
| 211 | if (typeHelpers) { |
| 212 | ;(typeHelpers?.['normalize'] as (_purl: PackageURL) => void)?.(this) |
| 213 | ;( |
| 214 | typeHelpers?.['validate'] as ( |
| 215 | _purl: PackageURL, |
| 216 | _throws: boolean, |
| 217 | ) => boolean |
| 218 | )?.(this, true) |
| 219 | } |
| 220 | } |
| 225 | toJSON(): PackageURLObject { |
| 226 | return this.toObject() |
| 227 | } |
| 232 | toJSONString(): string { |
| 233 | return JSONStringify(this.toObject()) |
| 234 | } |
| 239 | toObject(): PackageURLObject { |
| 240 | const result: PackageURLObject = { __proto__: null } as PackageURLObject |
| 241 | if (this.type !== undefined) { |
| 242 | result.type = this.type |
| 243 | } |
| 244 | if (this.namespace !== undefined) { |
| 245 | result.namespace = this.namespace |
| 246 | } |
| 247 | if (this.name !== undefined) { |
| 248 | result.name = this.name |
| 249 | } |
| 250 | if (this.version !== undefined) { |
| 251 | result.version = this.version |
| 252 | } |
| 253 | if (this.qualifiers !== undefined) { |
| 254 | const qualifiersCopy = ObjectCreate(null) as QualifiersObject |
| 255 | for (const key of ObjectKeys(this.qualifiers)) { |
| 256 | qualifiersCopy[key] = this.qualifiers[key]! |
| 257 | } |
| 258 | result.qualifiers = qualifiersCopy |
| 259 | } |
| 260 | if (this.subpath !== undefined) { |
| 261 | result.subpath = this.subpath |
| 262 | } |
| 263 | return result |
| 264 | } |
| 274 | toSpec() { |
| 275 | return stringifySpec(this) |
| 276 | } |
| 277 | |
| 278 | toString() { |
| 279 | let cached = this._cachedString |
| 280 | if (cached === undefined) { |
| 281 | cached = stringify(this) |
| 282 | this._cachedString = cached |
| 283 | } |
| 284 | return cached |
| 285 | } |
| 294 | withVersion(version: string | undefined): PackageURL { |
| 295 | return new PackageURL( |
| 296 | this.type, |
| 297 | this.namespace, |
| 298 | this.name, |
| 299 | version, |
| 300 | this.qualifiers, |
| 301 | this.subpath, |
| 302 | ) |
| 303 | } |
| 312 | withNamespace(namespace: string | undefined): PackageURL { |
| 313 | return new PackageURL( |
| 314 | this.type, |
| 315 | namespace, |
| 316 | this.name, |
| 317 | this.version, |
| 318 | this.qualifiers, |
| 319 | this.subpath, |
| 320 | ) |
| 321 | } |
| 334 | withQualifier(key: string, value: string): PackageURL { |
| 335 | return new PackageURL( |
| 336 | this.type, |
| 337 | this.namespace, |
| 338 | this.name, |
| 339 | this.version, |
| 340 | { |
| 341 | __proto__: null, |
| 342 | ...this.qualifiers, |
| 343 | [key]: value, |
| 344 | } as unknown as Record<string, string>, |
| 345 | this.subpath, |
| 346 | ) |
| 347 | } |
| 356 | withQualifiers(qualifiers: Record<string, string> | undefined): PackageURL { |
| 357 | return new PackageURL( |
| 358 | this.type, |
| 359 | this.namespace, |
| 360 | this.name, |
| 361 | this.version, |
| 362 | qualifiers, |
| 363 | this.subpath, |
| 364 | ) |
| 365 | } |
| 374 | withSubpath(subpath: string | undefined): PackageURL { |
| 375 | return new PackageURL( |
| 376 | this.type, |
| 377 | this.namespace, |
| 378 | this.name, |
| 379 | this.version, |
| 380 | this.qualifiers, |
| 381 | subpath, |
| 382 | ) |
| 383 | } |
| 394 | equals(other: PackageURL): boolean { |
| 395 | return equalsPurls(this, other) |
| 396 | } |
| 407 | static equals(a: PackageURL, b: PackageURL): boolean { |
| 408 | return equalsPurls(a, b) |
| 409 | } |
| 422 | compare(other: PackageURL): -1 | 0 | 1 { |
| 423 | return comparePurls(this, other) |
| 424 | } |
| 439 | static compare(a: PackageURL, b: PackageURL): -1 | 0 | 1 { |
| 440 | return comparePurls(a, b) |
| 441 | } |
| 446 | static fromJSON(json: unknown): PackageURL { |
| 447 | if (typeof json !== 'string') { |
| 448 | throw new ErrorCtor('JSON string argument is required.') |
| 449 | } |
| 450 | |
| 451 | // Size limit: 1MB to prevent memory exhaustion |
| 452 | // Check actual byte size, not character length |
| 453 | const MAX_JSON_SIZE = 1024 * 1024 |
| 454 | const byteSize = BufferByteLength!(json, 'utf8') |
| 455 | if (byteSize > MAX_JSON_SIZE) { |
| 456 | throw new ErrorCtor( |
| 457 | `JSON string exceeds maximum size limit of ${MAX_JSON_SIZE} bytes`, |
| 458 | ) |
| 459 | } |
| 460 | |
| 461 | let parsed: unknown |
| 462 | try { |
| 463 | parsed = JSONParse(json) |
| 464 | } catch (e) { |
| 465 | // For JSON parsing errors, throw a SyntaxError with the expected message |
| 466 | throw new SyntaxErrorCtor('Failed to parse PackageURL from JSON', { |
| 467 | cause: e, |
| 468 | }) |
| 469 | } |
| 470 | |
| 471 | // Validate parsed result is an object |
| 472 | if (!parsed || typeof parsed !== 'object' || ArrayIsArray(parsed)) { |
| 473 | throw new ErrorCtor('JSON must parse to an object.') |
| 474 | } |
| 475 | |
| 476 | // Cast to record type for safe property access |
| 477 | const parsedRecord = parsed as Record<string, unknown> |
| 478 | |
| 479 | // Create a safe object without prototype chain to prevent prototype pollution |
| 480 | const safeObject: PackageURLObject = { |
| 481 | __proto__: null, |
| 482 | type: parsedRecord['type'] as string | undefined, |
| 483 | namespace: parsedRecord['namespace'] as string | undefined, |
| 484 | name: parsedRecord['name'] as string | undefined, |
| 485 | version: parsedRecord['version'] as string | undefined, |
| 486 | qualifiers: parsedRecord['qualifiers'] as |
| 487 | | Record<string, string> |
| 488 | | undefined, |
| 489 | subpath: parsedRecord['subpath'] as string | undefined, |
| 490 | } as PackageURLObject |
| 491 | |
| 492 | return PackageURL.fromObject(safeObject) |
| 493 | } |
| 498 | static fromObject(obj: unknown): PackageURL { |
| 499 | if (!isObject(obj)) { |
| 500 | throw new ErrorCtor('Object argument is required.') |
| 501 | } |
| 502 | const typedObj = obj as Record<string, unknown> |
| 503 | return new PackageURL( |
| 504 | typedObj['type'], |
| 505 | typedObj['namespace'], |
| 506 | typedObj['name'], |
| 507 | typedObj['version'], |
| 508 | typedObj['qualifiers'], |
| 509 | typedObj['subpath'], |
| 510 | ) |
| 511 | } |
| 512 | |
| 513 | static fromString(purlStr: unknown): PackageURL { |
| 514 | // Flyweight: return cached instance for identical strings |
| 515 | if (typeof purlStr === 'string') { |
| 516 | const cached = flyweightCache.get(purlStr) |
| 517 | if (cached !== undefined) { |
| 518 | // Promote to most-recently-used by re-inserting. |
| 519 | flyweightCache.delete(purlStr) |
| 520 | flyweightCache.set(purlStr, cached) |
| 521 | return cached |
| 522 | } |
| 523 | } |
| 524 | const purl = new PackageURL(...PackageURL.parseString(purlStr)) |
| 525 | // Eagerly populate the `toString` cache before freezing |
| 526 | purl.toString() |
| 527 | // Deep freeze the instance and its nested qualifiers object to prevent |
| 528 | // cache poisoning via mutation of shared cached instances. |
| 529 | recursiveFreeze(purl) |
| 530 | // Cache the frozen result for future lookups — freezing prevents |
| 531 | // cache poisoning via property mutation on shared instances. |
| 532 | if (typeof purlStr === 'string') { |
| 533 | if (flyweightCache.size >= FLYWEIGHT_CACHE_MAX) { |
| 534 | // Evict oldest entry (first key in `Map` iteration order) |
| 535 | const oldest = flyweightCache.keys().next().value |
| 536 | if (oldest !== undefined) { |
| 537 | flyweightCache.delete(oldest) |
| 538 | } |
| 539 | } |
| 540 | flyweightCache.set(purlStr, purl) |
| 541 | } |
| 542 | return purl |
| 543 | } |
| 590 | static fromNpm(specifier: unknown): PackageURL { |
| 591 | const { name, namespace, version } = parseNpmSpecifier(specifier) |
| 592 | return new PackageURL('npm', namespace, name, version, undefined, undefined) |
| 593 | } |
| 619 | static fromSpec(type: string, specifier: unknown): PackageURL { |
| 620 | switch (type) { |
| 621 | case 'npm': { |
| 622 | const { name, namespace, version } = parseNpmSpecifier(specifier) |
| 623 | return new PackageURL( |
| 624 | 'npm', |
| 625 | namespace, |
| 626 | name, |
| 627 | version, |
| 628 | undefined, |
| 629 | undefined, |
| 630 | ) |
| 631 | } |
| 632 | default: |
| 633 | throw new ErrorCtor( |
| 634 | `Unsupported package type: ${type}. Currently supported: npm`, |
| 635 | ) |
| 636 | } |
| 637 | } |
| 638 | |
| 639 | static parseString(purlStr: unknown): ParsedPurlComponents { |
| 640 | // https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#how-to-parse-a-purl-string-in-its-components |
| 641 | if (typeof purlStr !== 'string') { |
| 642 | throw new ErrorCtor('A purl string argument is required.') |
| 643 | } |
| 644 | if (isBlank(purlStr)) { |
| 645 | return [undefined, undefined, undefined, undefined, undefined, undefined] |
| 646 | } |
| 647 | |
| 648 | // Input length validation to prevent DoS |
| 649 | // Reasonable limit for a package URL |
| 650 | const MAX_PURL_LENGTH = 4096 |
| 651 | if (purlStr.length > MAX_PURL_LENGTH) { |
| 652 | throw new ErrorCtor( |
| 653 | `Package URL exceeds maximum length of ${MAX_PURL_LENGTH} characters.`, |
| 654 | ) |
| 655 | } |
| 656 | |
| 657 | // If the string doesn't start with "pkg:" but looks like a purl format, prepend "pkg:" and try parsing |
| 658 | if (!StringPrototypeStartsWith(purlStr, 'pkg:')) { |
| 659 | // Only auto-prepend "pkg:" if the string looks like a purl (contains a type/name pattern) |
| 660 | // and doesn't look like a URL with a different scheme |
| 661 | const hasOtherScheme = RegExpPrototypeTest(OTHER_SCHEME_PATTERN, purlStr) |
| 662 | const looksLikePurl = RegExpPrototypeTest(PURL_LIKE_PATTERN, purlStr) |
| 663 | |
| 664 | if (!hasOtherScheme && looksLikePurl) { |
| 665 | return PackageURL.parseString(`pkg:${purlStr}`) |
| 666 | } |
| 667 | } |
| 668 | |
| 669 | // Split the remainder once from left on ':' |
| 670 | const colonIndex = StringPrototypeIndexOf(purlStr, ':') |
| 671 | // Use WHATWG URL to split up the purl string |
| 673 | // - Split the purl string once from right on '#' |
| 674 | // - Split the remainder once from right on '?' |
| 675 | // - Split the remainder once from left on ':' |
| 676 | let url: URL | undefined |
| 677 | let hasAuth = false |
| 678 | if (colonIndex !== -1) { |
| 679 | try { |
| 680 | // Since a purl never contains a URL Authority, its scheme |
| 681 | // must not be suffixed with double slash as in 'pkg://' |
| 682 | // and should use instead 'pkg:'. Purl parsers must accept |
| 683 | // URLs such as 'pkg://' and must ignore the '//' |
| 684 | const beforeColon = StringPrototypeSlice(purlStr, 0, colonIndex) |
| 685 | const afterColon = StringPrototypeSlice(purlStr, colonIndex + 1) |
| 686 | const trimmedAfterColon = trimLeadingSlashes(afterColon) |
| 687 | url = new URLCtor(`${beforeColon}:${trimmedAfterColon}`) |
| 688 | // Check for auth (user:pass@host) without creating a second URL. |
| 689 | // When leading slashes were trimmed, the original string had an authority |
| 690 | // section (e.g., pkg://user:pass@host/...). Detect `@` in the authority |
| 691 | // by checking between the `//` and the next `/`. |
| 693 | if (afterColon.length !== trimmedAfterColon.length) { |
| 694 | // afterColon starts with slashes — find the authority section |
| 695 | const authorityStart = StringPrototypeIndexOf(afterColon, '//') + 2 |
| 696 | const authorityEnd = StringPrototypeIndexOf( |
| 697 | afterColon, |
| 698 | '/', |
| 699 | authorityStart, |
| 700 | ) |
| 701 | const authority = |
| 702 | authorityEnd === -1 |
| 703 | ? StringPrototypeSlice(afterColon, authorityStart) |
| 704 | : StringPrototypeSlice(afterColon, authorityStart, authorityEnd) |
| 705 | hasAuth = StringPrototypeIncludes(authority, '@') |
| 706 | } |
| 707 | } catch (e) { |
| 708 | throw new PurlError('failed to parse as URL', { |
| 709 | cause: e, |
| 710 | }) |
| 711 | } |
| 712 | } |
| 713 | // The scheme is a constant with the value "pkg" |
| 715 | url?.protocol !== 'pkg:' |
| 716 | ) { |
| 717 | throw new PurlError('missing required "pkg" scheme component') |
| 719 | } |
| 720 | // A purl must NOT contain a URL Authority i.e. there is no support for |
| 721 | // username, password, host and port components |
| 722 | if (hasAuth) { |
| 723 | throw new PurlError('cannot contain a "user:pass@host:port"') |
| 724 | } |
| 725 | |
| 726 | const { pathname } = url |
| 727 | const firstSlashIndex = StringPrototypeIndexOf(pathname, '/') |
| 728 | const rawType = decodePurlComponent( |
| 729 | 'type', |
| 730 | firstSlashIndex === -1 |
| 731 | ? pathname |
| 732 | : StringPrototypeSlice(pathname, 0, firstSlashIndex), |
| 733 | ) |
| 734 | if (firstSlashIndex < 1) { |
| 735 | return [rawType, undefined, undefined, undefined, undefined, undefined] |
| 736 | } |
| 737 | |
| 738 | let rawVersion: string | undefined |
| 739 | // Both branches of this ternary are tested, but V8 reports phantom branch combinations |
| 741 | // Deviate from the specification to handle a special npm purl type case for |
| 742 | // pnpm ids such as 'pkg:npm/next@14.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)' |
| 743 | let atSignIndex = |
| 744 | rawType === 'npm' |
| 745 | ? StringPrototypeIndexOf(pathname, '@', firstSlashIndex + 2) |
| 746 | : StringPrototypeLastIndexOf(pathname, '@') |
| 748 | // When a forward slash ('/') is directly preceding an '@' symbol, |
| 749 | // then the '@' symbol is NOT considered a version separator |
| 750 | if ( |
| 751 | atSignIndex > 0 && |
| 753 | ) { |
| 754 | atSignIndex = -1 |
| 755 | } |
| 756 | const beforeVersion = StringPrototypeSlice( |
| 757 | pathname, |
| 758 | rawType.length + 1, |
| 759 | atSignIndex === -1 ? pathname.length : atSignIndex, |
| 760 | ) |
| 761 | if (atSignIndex !== -1) { |
| 762 | // Split the remainder once from right on '@' |
| 763 | rawVersion = decodePurlComponent( |
| 764 | 'version', |
| 765 | StringPrototypeSlice(pathname, atSignIndex + 1), |
| 766 | ) |
| 767 | } |
| 768 | |
| 769 | let rawNamespace: string | undefined |
| 770 | let rawName: string |
| 771 | const lastSlashIndex = StringPrototypeLastIndexOf(beforeVersion, '/') |
| 772 | if (lastSlashIndex === -1) { |
| 773 | // Split the remainder once from right on '/' |
| 774 | rawName = decodePurlComponent('name', beforeVersion) |
| 775 | } else { |
| 776 | // Split the remainder once from right on '/' |
| 777 | rawName = decodePurlComponent( |
| 778 | 'name', |
| 779 | StringPrototypeSlice(beforeVersion, lastSlashIndex + 1), |
| 780 | ) |
| 781 | // Split the remainder on '/' |
| 782 | rawNamespace = decodePurlComponent( |
| 783 | 'namespace', |
| 784 | StringPrototypeSlice(beforeVersion, 0, lastSlashIndex), |
| 785 | ) |
| 786 | } |
| 787 | |
| 788 | let rawQualifiers: URLSearchParams | undefined |
| 789 | if (url.searchParams.size !== 0) { |
| 790 | const search = StringPrototypeSlice(url.search, 1) |
| 791 | const searchParams = new URLSearchParamsCtor() |
| 792 | const entries = StringPrototypeSplit(search, '&' as any) |
| 793 | for (let i = 0, { length } = entries; i < length; i += 1) { |
| 794 | const entry = entries[i]! |
| 795 | // Slice on the FIRST '=' so values containing '=' (e.g. |
| 796 | // download_url=https://example.com/x?a=1, base64 padding `==`) |
| 797 | // round-trip intact. Splitting on '=' and indexing [1] silently |
| 798 | // truncates everything after the second '='. |
| 799 | const eqIndex = StringPrototypeIndexOf(entry, '=') |
| 800 | const key = |
| 801 | eqIndex === -1 ? entry : StringPrototypeSlice(entry, 0, eqIndex) |
| 802 | // Validate qualifier key is not empty (reject malformed PURLs like ?&key=val or ?key=val&) |
| 803 | if (key.length === 0) { |
| 804 | throw new PurlError('qualifier key must not be empty') |
| 805 | } |
| 806 | const value = decodePurlComponent( |
| 807 | 'qualifiers', |
| 808 | eqIndex === -1 ? '' : StringPrototypeSlice(entry, eqIndex + 1), |
| 809 | ) |
| 810 | // Use `URLSearchParams#append` to preserve plus signs |
| 811 | // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs |
| 813 | key, |
| 814 | value, |
| 815 | ) |
| 816 | } |
| 817 | // Split the remainder once from right on '?' |
| 818 | rawQualifiers = searchParams |
| 819 | } |
| 820 | |
| 821 | let rawSubpath: string | undefined |
| 822 | const { hash } = url |
| 823 | if (hash.length !== 0) { |
| 824 | // Split the purl string once from right on '#' |
| 825 | rawSubpath = decodePurlComponent('subpath', StringPrototypeSlice(hash, 1)) |
| 826 | } |
| 827 | |
| 828 | return [ |
| 829 | rawType, |
| 830 | rawNamespace, |
| 831 | rawName, |
| 832 | rawVersion, |
| 833 | rawQualifiers, |
| 834 | rawSubpath, |
| 835 | ] |
| 836 | } |
| 850 | static isValid(purlStr: unknown): boolean { |
| 851 | return PackageURL.tryFromString(purlStr).isOk() |
| 852 | } |
| 872 | static fromUrl(urlStr: string): PackageURL | undefined { |
| 873 | return UrlConverter.fromUrl(urlStr) |
| 874 | } |
| 875 | |
| 876 | static tryFromJSON(json: unknown): Result<PackageURL, Error> { |
| 877 | return ResultUtils.from(() => PackageURL.fromJSON(json)) |
| 878 | } |
| 879 | |
| 880 | static tryFromObject(obj: unknown): Result<PackageURL, Error> { |
| 881 | return ResultUtils.from(() => PackageURL.fromObject(obj)) |
| 882 | } |
| 883 | |
| 884 | static tryFromString(purlStr: unknown): Result<PackageURL, Error> { |
| 885 | return ResultUtils.from(() => PackageURL.fromString(purlStr)) |
| 886 | } |
| 887 | |
| 888 | static tryParseString(purlStr: unknown): Result<unknown[], Error> { |
| 889 | return ResultUtils.from(() => PackageURL.parseString(purlStr)) |
| 890 | } |
| 891 | } |
| 892 | |
| 893 | for (const staticProp of ['Component', 'KnownQualifierNames', 'Type']) { |
| 894 | ReflectDefineProperty(PackageURL, staticProp, { |
| 895 | ...ReflectGetOwnPropertyDescriptor(PackageURL, staticProp), |
| 896 | writable: false, |
| 897 | }) |
| 898 | } |
| 899 | |
| 900 | ReflectSetPrototypeOf(PackageURL.prototype, null) |
| 901 | |
| 902 | // Register PackageURL with compare module for string-based comparison support. |
| 903 | _registerPackageURL(PackageURL) |
| 904 | |
| 905 | // Register PackageURL with url-converter module for fromUrl construction. |
| 906 | _registerPackageURLForUrlConverter(PackageURL) |
| 907 | |
| 908 | export { |
| 909 | Err, |
| 910 | Ok, |
| 911 | PackageURL, |
| 912 | PurlComponent, |
| 913 | PurlQualifierNames, |
| 914 | PurlType, |
| 915 | ResultUtils, |
| 916 | UrlConverter, |
| 917 | err, |
| 918 | ok, |
| 919 | } |
| 920 | export type { DownloadUrl, RepositoryUrl, Result } |
| 5 | import { |
| 6 | encodeComponent, |
| 7 | encodeName, |
| 8 | encodeNamespace, |
| 9 | encodeQualifierParam, |
| 10 | encodeQualifiers, |
| 11 | encodeSubpath, |
| 12 | encodeVersion, |
| 13 | } from './encode.js' |
| 14 | import { createHelpersNamespaceObject } from './helpers.js' |
| 15 | import { |
| 16 | normalizeName, |
| 17 | normalizeNamespace, |
| 18 | normalizeQualifiers, |
| 19 | normalizeSubpath, |
| 20 | normalizeType, |
| 21 | normalizeVersion, |
| 22 | } from './normalize.js' |
| 23 | import { isNonEmptyString } from './strings.js' |
| 24 | import { |
| 25 | validateName, |
| 26 | validateNamespace, |
| 27 | validateQualifierKey, |
| 28 | validateQualifiers, |
| 29 | validateSubpath, |
| 30 | validateType, |
| 31 | validateVersion, |
| 32 | } from './validate.js' |
| 37 | export type ComponentEncoder = (_value: unknown) => string |
| 38 | export type ComponentNormalizer = (_value: string) => string | undefined |
| 39 | export type ComponentValidator = (_value: unknown, _throws: boolean) => boolean |
| 40 | export type QualifiersValue = string | number | boolean | null | undefined |
| 41 | export type QualifiersObject = Record<string, QualifiersValue> |
| 42 | |
| 43 | const componentSortOrderLookup = { |
| 44 | __proto__: null, |
| 45 | type: 0, |
| 46 | namespace: 1, |
| 47 | name: 2, |
| 48 | version: 3, |
| 49 | qualifiers: 4, |
| 50 | qualifierKey: 5, |
| 51 | qualifierValue: 6, |
| 52 | subpath: 7, |
| 53 | } |
| 58 | function componentComparator(compA: string, compB: string): number { |
| 59 | return componentSortOrder(compA) - componentSortOrder(compB) |
| 60 | } |
| 65 | function componentSortOrder(comp: string): number { |
| 66 | return ( |
| 67 | (componentSortOrderLookup as unknown as Record<string, number>)[comp] ?? |
| 68 | // Unknown components sort after all known ones |
| 69 | 8 |
| 70 | ) |
| 71 | } |
| 76 | function PurlComponentEncoder(comp: unknown): string { |
| 77 | return isNonEmptyString(comp) ? encodeComponent(comp) : '' |
| 78 | } |
| 83 | function PurlComponentStringNormalizer(comp: unknown): string | undefined { |
| 84 | return typeof comp === 'string' ? comp : undefined |
| 85 | } |
| 90 | function PurlComponentValidator(_comp: unknown, _throws: boolean): boolean { |
| 91 | return true |
| 92 | } |
| 93 | |
| 94 | // Rules for each purl component: |
| 95 | // https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#rules-for-each-purl-component |
| 96 | const PurlComponent = createHelpersNamespaceObject( |
| 97 | { |
| 98 | encode: { |
| 99 | name: encodeName, |
| 100 | namespace: encodeNamespace, |
| 101 | version: encodeVersion, |
| 102 | qualifiers: encodeQualifiers, |
| 103 | qualifierKey: encodeQualifierParam, |
| 104 | qualifierValue: encodeQualifierParam, |
| 105 | subpath: encodeSubpath, |
| 106 | }, |
| 107 | normalize: { |
| 108 | type: normalizeType, |
| 109 | namespace: normalizeNamespace, |
| 110 | name: normalizeName, |
| 111 | version: normalizeVersion, |
| 112 | qualifiers: normalizeQualifiers, |
| 113 | subpath: normalizeSubpath, |
| 114 | }, |
| 115 | validate: { |
| 116 | type: validateType, |
| 117 | namespace: validateNamespace, |
| 118 | name: validateName, |
| 119 | version: validateVersion, |
| 120 | qualifierKey: validateQualifierKey, |
| 121 | qualifiers: validateQualifiers, |
| 122 | subpath: validateSubpath, |
| 123 | }, |
| 124 | }, |
| 125 | { |
| 126 | comparator: componentComparator, |
| 127 | encode: PurlComponentEncoder, |
| 128 | normalize: PurlComponentStringNormalizer, |
| 129 | validate: PurlComponentValidator, |
| 130 | }, |
| 131 | ) |
| 132 | |
| 133 | export { |
| 134 | PurlComponent, |
| 135 | PurlComponentEncoder, |
| 136 | PurlComponentStringNormalizer, |
| 137 | PurlComponentValidator, |
| 138 | componentComparator, |
| 139 | componentSortOrder, |
| 140 | } |
| 4 | const LOOP_SENTINEL = 1_000_000 |
| 5 | |
| 6 | export { LOOP_SENTINEL } |