| 5 | import { isObject } from './objects.js' |
| 6 | import { |
| 7 | ArrayPrototypeToSorted, |
| 8 | ObjectKeys, |
| 9 | StringPrototypeReplaceAll, |
| 10 | StringPrototypeSlice, |
| 11 | URLSearchParamsCtor, |
| 12 | encodeComponent, |
| 13 | } from '@socketsecurity/lib/primordials' |
| 14 | import { isNonEmptyString } from './strings.js' |
| 15 | |
| 16 | // Module-private reusable `URLSearchParams` for `encodeQualifierParam`. Kept |
| 17 | // private here so mutation side-effects can't leak to other modules. |
| 18 | const REUSED_SEARCH_PARAMS = new URLSearchParamsCtor() |
| 19 | const REUSED_SEARCH_PARAMS_KEY = '_' |
| 20 | // `'_='.length` |
| 21 | const REUSED_SEARCH_PARAMS_OFFSET = 2 |
| 26 | function encodeName(name: unknown): string { |
| 27 | return isNonEmptyString(name) |
| 28 | ? StringPrototypeReplaceAll(encodeComponent(name), '%3A', ':') |
| 29 | : '' |
| 30 | } |
| 35 | function encodeNamespace(namespace: unknown): string { |
| 36 | return isNonEmptyString(namespace) |
| 37 | ? StringPrototypeReplaceAll( |
| 38 | StringPrototypeReplaceAll(encodeComponent(namespace), '%3A', ':'), |
| 39 | '%2F', |
| 40 | '/', |
| 41 | ) |
| 42 | : '' |
| 43 | } |
| 48 | function encodeQualifierParam(param: unknown): string { |
| 49 | if (isNonEmptyString(param)) { |
| 50 | const value = prepareValueForSearchParams(param) |
| 51 | // Use `URLSearchParams#set` to preserve plus signs |
| 52 | // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs |
| 53 | // Reuse shared instance — JS is single-threaded so no concurrent mutation issues |
| 54 | REUSED_SEARCH_PARAMS.set(REUSED_SEARCH_PARAMS_KEY, value) |
| 55 | // Param key and value are encoded with `percentEncodeSet` of |
| 56 | // `'application/x-www-form-urlencoded'` and `spaceAsPlus` of `true` |
| 57 | // https://url.spec.whatwg.org/#urlencoded-serializing |
| 58 | const search = REUSED_SEARCH_PARAMS.toString() |
| 59 | return normalizeSearchParamsEncoding( |
| 60 | StringPrototypeSlice(search, REUSED_SEARCH_PARAMS_OFFSET), |
| 61 | ) |
| 62 | } |
| 63 | return '' |
| 64 | } |
| 69 | function encodeQualifiers(qualifiers: unknown): string { |
| 70 | if (isObject(qualifiers)) { |
| 71 | // Sort this list of qualifier strings lexicographically |
| 72 | const qualifiersKeys = ArrayPrototypeToSorted(ObjectKeys(qualifiers)) |
| 73 | const searchParams = new URLSearchParamsCtor() |
| 74 | for (let i = 0, { length } = qualifiersKeys; i < length; i += 1) { |
| 75 | const key = qualifiersKeys[i]! |
| 76 | const value = prepareValueForSearchParams( |
| 77 | (qualifiers as Record<string, unknown>)[key], |
| 78 | ) |
| 79 | // Use `URLSearchParams#set` to preserve plus signs |
| 80 | // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs |
| 81 | searchParams.set(key!, value) |
| 82 | } |
| 83 | return normalizeSearchParamsEncoding(searchParams.toString()) |
| 84 | } |
| 85 | return '' |
| 86 | } |
| 91 | function encodeSubpath(subpath: unknown): string { |
| 92 | return isNonEmptyString(subpath) |
| 93 | ? StringPrototypeReplaceAll(encodeComponent(subpath), '%2F', '/') |
| 94 | : '' |
| 95 | } |
| 100 | function encodeVersion(version: unknown): string { |
| 101 | return isNonEmptyString(version) |
| 102 | ? StringPrototypeReplaceAll(encodeComponent(version), '%3A', ':') |
| 103 | : '' |
| 104 | } |
| 109 | function normalizeSearchParamsEncoding(encoded: string): string { |
| 110 | return StringPrototypeReplaceAll( |
| 111 | StringPrototypeReplaceAll(encoded, '%2520', '%20'), |
| 112 | '+', |
| 113 | '%2B', |
| 114 | ) |
| 115 | } |
| 120 | function prepareValueForSearchParams(value: unknown): string { |
| 121 | // Replace spaces with `%20`'s so they don't get converted to plus signs |
| 122 | return StringPrototypeReplaceAll(String(value), ' ', '%20') |
| 123 | } |
| 124 | |
| 125 | export { |
| 126 | encodeComponent, |
| 127 | encodeName, |
| 128 | encodeNamespace, |
| 129 | encodeVersion, |
| 130 | encodeQualifiers, |
| 131 | encodeQualifierParam, |
| 132 | encodeSubpath, |
| 133 | } |
| 5 | import { PurlError } from './error.js' |
| 6 | import { decodeComponent } from '@socketsecurity/lib/primordials' |
| 12 | function decodePurlComponent(comp: string, encodedComponent: string): string { |
| 13 | try { |
| 14 | return decodeComponent(encodedComponent) |
| 15 | } catch (e) { |
| 16 | throw new PurlError(`unable to decode "${comp}" component`, { cause: e }) |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | export { decodePurlComponent } |
| 5 | import { isObject } from './objects.js' |
| 6 | import { |
| 7 | ObjectCreate, |
| 8 | ObjectEntries, |
| 9 | ObjectFreeze, |
| 10 | ReflectApply, |
| 11 | StringPrototypeCharCodeAt, |
| 12 | StringPrototypeIndexOf, |
| 13 | StringPrototypeSlice, |
| 14 | StringPrototypeToLowerCase, |
| 15 | StringPrototypeTrim, |
| 16 | URLSearchParamsCtor, |
| 17 | } from '@socketsecurity/lib/primordials' |
| 18 | import { isBlank } from './strings.js' |
| 19 | |
| 20 | const EMPTY_ENTRIES: Iterable<[string, string]> = ObjectFreeze( |
| 21 | [] as Array<[string, string]>, |
| 22 | ) |
| 23 | |
| 24 | import type { QualifiersObject } from './purl-component.js' |
| 29 | function normalizeName(rawName: unknown): string | undefined { |
| 30 | return typeof rawName === 'string' ? StringPrototypeTrim(rawName) : undefined |
| 31 | } |
| 36 | function normalizeNamespace(rawNamespace: unknown): string | undefined { |
| 37 | return typeof rawNamespace === 'string' |
| 38 | ? normalizePurlPath(rawNamespace) |
| 39 | : undefined |
| 40 | } |
| 45 | function normalizePurlPath( |
| 46 | pathname: string, |
| 47 | options?: { filter?: ((_segment: string) => boolean) | undefined }, |
| 48 | ): string { |
| 49 | const { filter: callback } = options ?? {} |
| 50 | let collapsed = '' |
| 51 | let start = 0 |
| 52 | // Leading and trailing slashes, i.e. `'/'`, are not significant and should be |
| 53 | // stripped in the canonical form |
| 55 | start += 1 |
| 56 | } |
| 57 | let nextIndex = StringPrototypeIndexOf(pathname, '/', start) |
| 58 | if (nextIndex === -1) { |
| 59 | // No slashes found - return trimmed pathname |
| 60 | return StringPrototypeSlice(pathname, start) |
| 61 | } |
| 62 | // Discard any empty string segments by collapsing repeated segment |
| 63 | // separator slashes, i.e. `'/'` |
| 64 | while (nextIndex !== -1) { |
| 65 | const segment = StringPrototypeSlice(pathname, start, nextIndex) |
| 66 | if (callback === undefined || callback(segment)) { |
| 67 | // Add segment with separator if not first segment |
| 68 | collapsed = collapsed + (collapsed.length === 0 ? '' : '/') + segment |
| 69 | } |
| 70 | // Skip to next segment, consuming multiple consecutive slashes |
| 71 | start = nextIndex + 1 |
| 72 | while (StringPrototypeCharCodeAt(pathname, start) === 47) { |
| 73 | start += 1 |
| 74 | } |
| 75 | nextIndex = StringPrototypeIndexOf(pathname, '/', start) |
| 76 | } |
| 77 | // Handle last segment after final slash |
| 78 | const lastSegment = StringPrototypeSlice(pathname, start) |
| 79 | if ( |
| 80 | lastSegment.length !== 0 && |
| 81 | (callback === undefined || callback(lastSegment)) |
| 82 | ) { |
| 83 | // Add segment with separator if not first segment |
| 84 | collapsed = collapsed + (collapsed.length === 0 ? '' : '/') + lastSegment |
| 85 | } |
| 86 | return collapsed |
| 87 | } |
| 92 | function normalizeQualifiers( |
| 93 | rawQualifiers: unknown, |
| 94 | ): Record<string, string> | undefined { |
| 95 | let qualifiers: Record<string, string> | undefined |
| 96 | // Use `for-of` to work with entries iterators |
| 97 | for (const { 0: key, 1: value } of qualifiersToEntries(rawQualifiers)) { |
| 98 | // Skip non-string keys — `validateQualifiers` rejects these with `PurlError`. |
| 99 | if (typeof key !== 'string') { |
| 100 | continue |
| 101 | } |
| 102 | // Only coerce primitive types — reject objects/functions that could |
| 103 | // execute arbitrary code via `toString()` during coercion. |
| 104 | const strValue = |
| 105 | typeof value === 'string' |
| 106 | ? value |
| 107 | : typeof value === 'number' || typeof value === 'boolean' |
| 108 | ? `${value}` |
| 109 | : '' |
| 110 | const trimmed = StringPrototypeTrim(strValue) |
| 111 | // A `key=value` pair with an empty value is the same as no `key/value` |
| 112 | // at all for this key |
| 113 | if (trimmed.length === 0) { |
| 114 | continue |
| 115 | } |
| 116 | if (qualifiers === undefined) { |
| 117 | qualifiers = ObjectCreate(null) as Record<string, string> |
| 118 | } |
| 119 | // A key is case insensitive. The canonical form is lowercase |
| 120 | qualifiers[StringPrototypeToLowerCase(key)] = trimmed |
| 121 | } |
| 122 | return qualifiers |
| 123 | } |
| 128 | function normalizeSubpath(rawSubpath: unknown): string | undefined { |
| 129 | return typeof rawSubpath === 'string' |
| 130 | ? normalizePurlPath(rawSubpath, { filter: subpathFilter }) |
| 131 | : undefined |
| 132 | } |
| 137 | function normalizeType(rawType: unknown): string | undefined { |
| 138 | // The type must NOT be percent-encoded |
| 139 | // The type is case insensitive. The canonical form is lowercase |
| 140 | return typeof rawType === 'string' |
| 141 | ? StringPrototypeToLowerCase(StringPrototypeTrim(rawType)) |
| 142 | : undefined |
| 143 | } |
| 148 | function normalizeVersion(rawVersion: unknown): string | undefined { |
| 149 | return typeof rawVersion === 'string' |
| 150 | ? StringPrototypeTrim(rawVersion) |
| 151 | : undefined |
| 152 | } |
| 157 | function qualifiersToEntries( |
| 158 | rawQualifiers: unknown, |
| 159 | ): Iterable<[string, string]> { |
| 160 | if (isObject(rawQualifiers)) { |
| 161 | // `URLSearchParams` instances have an `"entries"` method that returns an iterator |
| 162 | const rawQualifiersObj = rawQualifiers as QualifiersObject | URLSearchParams |
| 163 | const entriesProperty = (rawQualifiersObj as QualifiersObject)['entries'] |
| 164 | return typeof entriesProperty === 'function' |
| 165 | ? (ReflectApply(entriesProperty, rawQualifiersObj, []) as Iterable< |
| 166 | [string, string] |
| 167 | >) |
| 168 | : (ObjectEntries(rawQualifiers as Record<string, string>) as Iterable< |
| 169 | [string, string] |
| 170 | >) |
| 171 | } |
| 172 | return typeof rawQualifiers === 'string' |
| 173 | ? new URLSearchParamsCtor(rawQualifiers).entries() |
| 174 | : EMPTY_ENTRIES |
| 175 | } |
| 180 | function subpathFilter(segment: string): boolean { |
| 181 | // When percent-decoded, a segment |
| 182 | // - must not be any of `'.'` or `'..'` |
| 183 | // - must not be empty |
| 184 | const { length } = segment |
| 186 | return false |
| 187 | } |
| 188 | if ( |
| 189 | length === 2 && |
| 190 | StringPrototypeCharCodeAt(segment, 0) === 46 && |
| 191 | StringPrototypeCharCodeAt(segment, 1) === 46 |
| 192 | ) { |
| 193 | return false |
| 194 | } |
| 195 | return !isBlank(segment) |
| 196 | } |
| 197 | |
| 198 | export { |
| 199 | normalizeName, |
| 200 | normalizeNamespace, |
| 201 | normalizePurlPath, |
| 202 | normalizeQualifiers, |
| 203 | normalizeSubpath, |
| 204 | normalizeType, |
| 205 | normalizeVersion, |
| 206 | } |
| 5 | // Known qualifiers: |
| 6 | // https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#known-qualifiers-keyvalue-pairs |
| 7 | const PurlQualifierNames = { |
| 8 | __proto__: null, |
| 9 | Checksum: 'checksum', |
| 10 | DownloadUrl: 'download_url', |
| 11 | FileName: 'file_name', |
| 12 | RepositoryUrl: 'repository_url', |
| 13 | VcsUrl: 'vcs_url', |
| 14 | Vers: 'vers', |
| 15 | } |
| 16 | |
| 17 | export { PurlQualifierNames } |