Part 3:  Parsing & Normalization

Percent-encoding, name/namespace normalization, and the known-qualifiers catalogue.

Topics: Anatomy Building Parsing Validation URL Ecosystems Comparison Security Architecture Builders Contributing Converters Hardening Release Tour VERS
src/encode.ts
9 sections
1 2 3 4 5 6 7 8 9
Section 1 of 9
5import { isObject } from './objects.js'
6import {
7 ArrayPrototypeToSorted,
8 ObjectKeys,
9 StringPrototypeReplaceAll,
10 StringPrototypeSlice,
11 URLSearchParamsCtor,
12 encodeComponent,
13} from '@socketsecurity/lib/primordials'
14import { 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.
18const REUSED_SEARCH_PARAMS = new URLSearchParamsCtor()
19const REUSED_SEARCH_PARAMS_KEY = '_'
20// `'_='.length`
21const REUSED_SEARCH_PARAMS_OFFSET = 2
Section 2 of 9
26function encodeName(name: unknown): string {
27 return isNonEmptyString(name)
28 ? StringPrototypeReplaceAll(encodeComponent(name), '%3A', ':')
29 : ''
30}
Section 3 of 9
35function encodeNamespace(namespace: unknown): string {
36 return isNonEmptyString(namespace)
37 ? StringPrototypeReplaceAll(
38 StringPrototypeReplaceAll(encodeComponent(namespace), '%3A', ':'),
39 '%2F',
40 '/',
41 )
42 : ''
43}
Section 4 of 9
48function 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}
Section 5 of 9
69function 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}
Section 6 of 9
91function encodeSubpath(subpath: unknown): string {
92 return isNonEmptyString(subpath)
93 ? StringPrototypeReplaceAll(encodeComponent(subpath), '%2F', '/')
94 : ''
95}
Section 7 of 9
100function encodeVersion(version: unknown): string {
101 return isNonEmptyString(version)
102 ? StringPrototypeReplaceAll(encodeComponent(version), '%3A', ':')
103 : ''
104}
Section 8 of 9
109function normalizeSearchParamsEncoding(encoded: string): string {
110 return StringPrototypeReplaceAll(
111 StringPrototypeReplaceAll(encoded, '%2520', '%20'),
112 '+',
113 '%2B',
114 )
115}
Section 9 of 9
120function 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
125export {
126 encodeComponent,
127 encodeName,
128 encodeNamespace,
129 encodeVersion,
130 encodeQualifiers,
131 encodeQualifierParam,
132 encodeSubpath,
133}
src/decode.ts
2 sections
1 2
Section 1 of 2
5import { PurlError } from './error.js'
6import { decodeComponent } from '@socketsecurity/lib/primordials'
Section 2 of 2
12function 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
20export { decodePurlComponent }
src/normalize.ts
12 sections
1 2 3 4 5 6 7 8 9 10 11 12
Section 1 of 12
5import { isObject } from './objects.js'
6import {
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'
18import { isBlank } from './strings.js'
19
20const EMPTY_ENTRIES: Iterable<[string, string]> = ObjectFreeze(
21 [] as Array<[string, string]>,
22)
23
24import type { QualifiersObject } from './purl-component.js'
Section 2 of 12
29function normalizeName(rawName: unknown): string | undefined {
30 return typeof rawName === 'string' ? StringPrototypeTrim(rawName) : undefined
31}
Section 3 of 12
36function normalizeNamespace(rawNamespace: unknown): string | undefined {
37 return typeof rawNamespace === 'string'
38 ? normalizePurlPath(rawNamespace)
39 : undefined
40}
Section 4 of 12
45function 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
Section 5 of 12
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}
Section 6 of 12
92function 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}
Section 7 of 12
128function normalizeSubpath(rawSubpath: unknown): string | undefined {
129 return typeof rawSubpath === 'string'
130 ? normalizePurlPath(rawSubpath, { filter: subpathFilter })
131 : undefined
132}
Section 8 of 12
137function 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}
Section 9 of 12
148function normalizeVersion(rawVersion: unknown): string | undefined {
149 return typeof rawVersion === 'string'
150 ? StringPrototypeTrim(rawVersion)
151 : undefined
152}
Section 10 of 12
157function 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}
Section 11 of 12
180function 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
Section 12 of 12
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
198export {
199 normalizeName,
200 normalizeNamespace,
201 normalizePurlPath,
202 normalizeQualifiers,
203 normalizeSubpath,
204 normalizeType,
205 normalizeVersion,
206}
src/purl-qualifier-names.ts
1 section
1
Section 1 of 1
5// Known qualifiers:
6// https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#known-qualifiers-keyvalue-pairs
7const 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
17export { PurlQualifierNames }