Part 8:  Security Primitives & VERS

Injection-character detection for safe PURL handling, safe object freezing, and VERS — the pre-standard version-range specifier slated for [ECMA submission](https://github.com/package-url/vers-spec) in late 2026.

Topics: Anatomy Building Parsing Validation URL Ecosystems Comparison Security Architecture Builders Contributing Converters Hardening Release Tour VERS
src/strings.ts
18 sections
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
Section 1 of 18
5import {
6 NumberPrototypeToString,
7 ObjectFreeze,
8 RegExpPrototypeTest,
9 StringFromCharCode,
10 StringPrototypeCharCodeAt,
11 StringPrototypeIndexOf,
12 StringPrototypePadStart,
13 StringPrototypeSlice,
14 StringPrototypeToLowerCase,
15} from '@socketsecurity/lib/primordials'
Section 2 of 18
20function isBlank(str: string): boolean {
21 for (let i = 0, { length } = str; i < length; i += 1) {
22 const code = StringPrototypeCharCodeAt(str, i)
23 // biome-ignore format: newlines
24 if (
25 !(
26 // Whitespace characters according to ECMAScript spec:
27 // https://tc39.es/ecma262/#sec-white-space
28 // Space
29 (
30 code === 0x00_20 ||
31 // Tab
32 code === 0x00_09 ||
33 // Line Feed
34 code === 0x00_0a ||
35 // Vertical Tab
36 code === 0x00_0b ||
37 // Form Feed
38 code === 0x00_0c ||
39 // Carriage Return
40 code === 0x00_0d ||
41 // No-Break Space
42 code === 0x00_a0 ||
43 // Ogham Space Mark
44 code === 0x16_80 ||
45 // En Quad
46 code === 0x20_00 ||
47 // Em Quad
48 code === 0x20_01 ||
49 // En Space
50 code === 0x20_02 ||
51 // Em Space
52 code === 0x20_03 ||
53 // Three-Per-Em Space
54 code === 0x20_04 ||
55 // Four-Per-Em Space
56 code === 0x20_05 ||
57 // Six-Per-Em Space
58 code === 0x20_06 ||
59 // Figure Space
60 code === 0x20_07 ||
61 // Punctuation Space
62 code === 0x20_08 ||
63 // Thin Space
64 code === 0x20_09 ||
65 // Hair Space
66 code === 0x20_0a ||
67 // Line Separator
68 code === 0x20_28 ||
69 // Paragraph Separator
70 code === 0x20_29 ||
71 // Narrow No-Break Space
72 code === 0x20_2f ||
73 // Medium Mathematical Space
74 code === 0x20_5f ||
75 // Ideographic Space
76 code === 0x30_00 ||
77 code === 0xfe_ff
78 )
79 // Byte Order Mark
80 )
81 ) {
82 return false
83 }
84 }
85 return true
86}
Section 3 of 18
91function isNonEmptyString(value: unknown): value is string {
92 return typeof value === 'string' && value.length > 0
93}
94
95// This regexp is valid as of 2024-08-01
96// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
97const regexSemverNumberedGroups = ObjectFreeze(
98 /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
99)
Section 4 of 18
104function isSemverString(value: unknown): value is string {
105 return (
106 typeof value === 'string' &&
107 RegExpPrototypeTest(regexSemverNumberedGroups, value)
108 )
109}
110
111// `Intl.Collator` is faster than `String#localeCompare`
112// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare:
113// > When comparing large numbers of strings, such as in sorting large arrays,
114// > it is better to create an `Intl.Collator` object and use the function provided
115// > by its `compare()` method
116let _localeCompare: Intl.Collator['compare'] | undefined
Section 5 of 18
121function localeCompare(x: string, y: string): number {
122 if (_localeCompare === undefined) {
123 // Lazily call `new Intl.Collator()` because in Node it can take 10-14ms
124 _localeCompare = new Intl.Collator().compare
125 }
126 return _localeCompare(x, y)
127}
Section 6 of 18
132function lowerName(purl: { name: string }): void {
133 purl.name = StringPrototypeToLowerCase(purl.name)
134}
Section 7 of 18
139function lowerNamespace(purl: { namespace?: string | undefined }): void {
140 const { namespace } = purl
141 if (typeof namespace === 'string') {
142 purl.namespace = StringPrototypeToLowerCase(namespace)
143 }
144}
Section 8 of 18
149function lowerVersion(purl: { version?: string | undefined }): void {
150 const { version } = purl
151 if (typeof version === 'string') {
152 purl.version = StringPrototypeToLowerCase(version)
153 }
154}
Section 9 of 18
159function replaceDashesWithUnderscores(str: string): string {
160 // Replace all `"-"` with `"_"`
161 let result = ''
162 let fromIndex = 0
163 let index = 0
164 while ((index = StringPrototypeIndexOf(str, '-', fromIndex)) !== -1) {
165 result = `${result + StringPrototypeSlice(str, fromIndex, index)}_`
166 fromIndex = index + 1
167 }
168 return fromIndex ? result + StringPrototypeSlice(str, fromIndex) : str
169}
Section 10 of 18
174function replaceUnderscoresWithDashes(str: string): string {
175 // Replace all `"_"` with `"-"`
176 let result = ''
177 let fromIndex = 0
178 let index = 0
179 while ((index = StringPrototypeIndexOf(str, '_', fromIndex)) !== -1) {
180 result = `${result + StringPrototypeSlice(str, fromIndex, index)}-`
181 fromIndex = index + 1
182 }
183 return fromIndex ? result + StringPrototypeSlice(str, fromIndex) : str
184}
Section 11 of 18
205function isInjectionCharCode(code: number): boolean {
206 // C0 control characters (0x00-0x1f)
207 if (code <= 0x1f) {
208 return true
209 }
210 // biome-ignore format: newlines
211 if (
212 // space
213 code === 0x20 ||
214 // !
215 code === 0x21 ||
216 // "
217 code === 0x22 ||
218 // #
219 code === 0x23 ||
220 // $
221 code === 0x24 ||
222 // %
223 code === 0x25 ||
224 // &
225 code === 0x26 ||
226 // '
227 code === 0x27 ||
228 // (
229 code === 0x28 ||
230 // )
231 code === 0x29 ||
232 // *
233 code === 0x2a ||
234 // ;
235 code === 0x3b ||
236 // <
237 code === 0x3c ||
238 // =
239 code === 0x3d ||
240 // >
241 code === 0x3e ||
242 // ?
243 code === 0x3f ||
244 // [
245 code === 0x5b ||
246 // \
247 code === 0x5c ||
248 // ]
249 code === 0x5d ||
250 // `
251 code === 0x60 ||
252 // {
253 code === 0x7b ||
254 // |
255 code === 0x7c ||
256 // }
257 code === 0x7d ||
258 // ~
259 code === 0x7e ||
260 // DEL
261 code === 0x7f
262 ) {
263 return true
264 }
265 // C1 control characters (0x80-0x9f)
266 if (code >= 0x80 && code <= 0x9f) {
267 return true
268 }
269 // Unicode dangerous characters
270 // biome-ignore format: newlines
271 if (
272 // Zero-width space
273 code === 0x200b ||
274 // Zero-width non-joiner
275 code === 0x200c ||
276 // Zero-width joiner
277 code === 0x200d ||
278 // Left-to-right mark
279 code === 0x200e ||
280 // Right-to-left mark
281 code === 0x200f ||
282 // Left-to-right embedding
283 code === 0x202a ||
284 // Right-to-left embedding
285 code === 0x202b ||
286 // Pop directional formatting
287 code === 0x202c ||
288 // Left-to-right override
289 code === 0x202d ||
290 // Right-to-left override
291 code === 0x202e ||
292 // Word joiner
293 code === 0x2060 ||
294 // BOM / zero-width no-break space
295 code === 0xfeff ||
296 // Object replacement character
297 code === 0xfffc ||
298 // Replacement character
299 code === 0xfffd
300 ) {
301 return true
302 }
303 return false
304}
Section 12 of 18
318function isCommandInjectionCharCode(code: number): boolean {
319 // C0 control characters except tab (`0x09`) — tab is used in some
320 // version metadata but other controls are never legitimate
321 if (code <= 0x1f && code !== 0x09) {
322 return true
323 }
324 // biome-ignore format: newlines
325 if (
326 // `$` — command substitution `$()`
327 code === 0x24 ||
328 // `;` — command separator
329 code === 0x3b ||
330 // `<` — input redirection
331 code === 0x3c ||
332 // `>` — output redirection
333 code === 0x3e ||
334 // `\` — escape character
335 code === 0x5c ||
336 // `` ` `` — command substitution (backtick form)
337 code === 0x60 ||
338 // `|` — pipe
339 code === 0x7c ||
340 // DEL
341 code === 0x7f
342 ) {
343 return true
344 }
345 // C1 control characters
346 if (code >= 0x80 && code <= 0x9f) {
347 return true
348 }
349 // Unicode dangerous characters (same set as `isInjectionCharCode`)
350 // biome-ignore format: newlines
351 if (
352 code === 0x200b ||
353 code === 0x200c ||
354 code === 0x200d ||
355 code === 0x200e ||
356 code === 0x200f ||
357 code === 0x202a ||
358 code === 0x202b ||
359 code === 0x202c ||
360 code === 0x202d ||
361 code === 0x202e ||
362 code === 0x2060 ||
363 code === 0xfeff ||
364 code === 0xfffc ||
365 code === 0xfffd
366 ) {
367 return true
368 }
369 return false
370}
Section 13 of 18
377function findCommandInjectionCharCode(str: string): number {
378 for (let i = 0, { length } = str; i < length; i += 1) {
379 const code = StringPrototypeCharCodeAt(str, i)
380 if (isCommandInjectionCharCode(code)) {
381 return code
382 }
383 }
384 return -1
385}
Section 14 of 18
399function findInjectionCharCode(str: string): number {
400 for (let i = 0, { length } = str; i < length; i += 1) {
401 const code = StringPrototypeCharCodeAt(str, i)
402 if (isInjectionCharCode(code)) {
403 return code
404 }
405 }
406 return -1
407}
Section 15 of 18
416function containsInjectionCharacters(str: string): boolean {
417 return findInjectionCharCode(str) !== -1
418}
Section 16 of 18
424function formatInjectionChar(code: number): string {
425 const hex = NumberPrototypeToString(code, 16)
426 if (code >= 0x20 && code <= 0x7e) {
427 return `"${StringFromCharCode(code)}" (0x${hex})`
428 }
429 return `0x${StringPrototypePadStart(hex, 2, '0')}`
430}
Section 17 of 18
435function trimLeadingSlashes(str: string): string {
436 let start = 0
Section 18 of 18
438 start += 1
439 }
440 return start === 0 ? str : StringPrototypeSlice(str, start)
441}
442
443export {
444 containsInjectionCharacters,
445 findCommandInjectionCharCode,
446 findInjectionCharCode,
447 formatInjectionChar,
448 isBlank,
449 isNonEmptyString,
450 isSemverString,
451 localeCompare,
452 lowerName,
453 lowerNamespace,
454 lowerVersion,
455 replaceDashesWithUnderscores,
456 replaceUnderscoresWithDashes,
457 trimLeadingSlashes,
458}
src/objects.ts
3 sections
1 2 3
Section 1 of 3
5import { LOOP_SENTINEL } from './constants.js'
6import {
7 ArrayIsArray,
8 ErrorCtor,
9 ObjectFreeze,
10 ObjectIsFrozen,
11 ReflectOwnKeys,
12 WeakSetCtor,
13} from '@socketsecurity/lib/primordials'
Section 2 of 3
21function recursiveFreeze<T>(value_: T): T {
22 if (
23 value_ === null ||
24 !(typeof value_ === 'object' || typeof value_ === 'function') ||
25 ObjectIsFrozen(value_)
26 ) {
27 return value_
28 }
29 // Use breadth-first traversal to avoid stack overflow on deep objects
30 const queue = [value_ as T & object]
31 const visited = new WeakSetCtor<object>()
32 visited.add(value_ as T & object)
33 let { length: queueLength } = queue
34 let pos = 0
35 while (pos < queueLength) {
36 // Safety check to prevent processing excessively large object graphs
37 if (pos === LOOP_SENTINEL) {
38 throw new ErrorCtor('Object graph too large (exceeds 1,000,000 items).')
39 }
40 const obj = queue[pos++]!
41 ObjectFreeze(obj)
42 if (ArrayIsArray(obj)) {
43 // Queue unfrozen array items for processing
44 for (let i = 0, { length } = obj; i < length; i += 1) {
45 const item: unknown = obj[i]
46 if (
47 item !== null &&
48 (typeof item === 'object' || typeof item === 'function') &&
49 !ObjectIsFrozen(item) &&
50 !visited.has(item as object)
51 ) {
52 visited.add(item as object)
53 queue[queueLength++] = item as T & object
54 }
55 }
56 } else {
57 // Queue unfrozen object properties for processing
58 const keys = ReflectOwnKeys(obj)
59 for (let i = 0, { length } = keys; i < length; i += 1) {
60 const propValue: unknown = (obj as Record<PropertyKey, unknown>)[
61 keys[i]!
62 ]
63 if (
64 propValue !== null &&
65 (typeof propValue === 'object' || typeof propValue === 'function') &&
66 !ObjectIsFrozen(propValue) &&
67 !visited.has(propValue as object)
68 ) {
69 visited.add(propValue as object)
70 queue[queueLength++] = propValue as T & object
71 }
72 }
73 }
74 }
75 return value_
76}
Section 3 of 3
83function isObject(value: unknown): value is { [key: PropertyKey]: unknown } {
84 return value !== null && typeof value === 'object'
85}
86
87export { isObject, recursiveFreeze }
src/vers.ts
14 sections
1 2 3 4 5 6 7 8 9 10 11 12 13 14
Section 1 of 14
14import { PurlError } from './error.js'
15import {
16 ArrayPrototypeJoin,
17 ArrayPrototypePush,
18 MathMin,
19 ObjectFreeze,
20 RegExpPrototypeExec,
21 RegExpPrototypeTest,
22 SetCtor,
23 StringPrototypeIndexOf,
24 StringPrototypeSlice,
25 StringPrototypeSplit,
26 StringPrototypeStartsWith,
27 StringPrototypeToLowerCase,
28 StringPrototypeTrim,
29} from '@socketsecurity/lib/primordials'
30import { isSemverString } from './strings.js'
Section 2 of 14
36type VersComparator = '=' | '!=' | '<' | '<=' | '>' | '>='
Section 3 of 14
41type VersWildcard = '*'
Section 4 of 14
46type VersConstraint = {
47 comparator: VersComparator | VersWildcard
48 version: string
49}
Section 5 of 14
54type SemverParts = {
55 major: number
56 minor: number
57 patch: number
58 prerelease: string[]
59}
60
61// Schemes that use semver comparison
62const SEMVER_SCHEMES: ReadonlySet<string> = ObjectFreeze(
63 new SetCtor([
64 'semver',
65 'npm',
66 'cargo',
67 'golang',
68 'hex',
69 'pub',
70 'cran',
71 'gem',
72 'swift',
73 ]),
74)
75
76// Valid comparator prefixes sorted by length (longest first for greedy matching)
77const COMPARATORS: readonly string[] = ObjectFreeze([
78 '!=',
79 '<=',
80 '>=',
81 '<',
82 '>',
83 '=',
84])
85
86const DIGITS_ONLY = ObjectFreeze(/^\d+$/)
87
88const regexSemverNumberedGroups = ObjectFreeze(
89 /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
90)
Section 6 of 14
95function parseSemver(version: string): SemverParts {
96 const match = RegExpPrototypeExec(regexSemverNumberedGroups, version)
97 if (!match) {
98 throw new PurlError(
99 `semver version "${version}" must match MAJOR.MINOR.PATCH (e.g. "1.2.3")`,
100 )
101 }
102 const major = Number(match[1])
103 const minor = Number(match[2])
104 const patch = Number(match[3])
105 // Guard against precision loss with numbers above `MAX_SAFE_INTEGER`
106 if (
107 major > Number.MAX_SAFE_INTEGER ||
108 minor > Number.MAX_SAFE_INTEGER ||
109 patch > Number.MAX_SAFE_INTEGER
110 ) {
111 throw new PurlError(
112 `version component exceeds maximum safe integer in "${version}"`,
113 )
114 }
115 return {
116 major,
117 minor,
118 patch,
119 prerelease: match[4] ? StringPrototypeSplit(match[4], '.' as any) : [],
120 }
121}
Section 7 of 14
127function comparePrereleases(a: string[], b: string[]): number {
128 // No prerelease has higher precedence than any prerelease
129 if (a.length === 0 && b.length === 0) {
130 return 0
131 }
132 if (a.length === 0) {
133 return 1
134 }
135 if (b.length === 0) {
136 return -1
137 }
138
139 const len = MathMin(a.length, b.length)
140 for (let i = 0; i < len; i += 1) {
141 const ai = a[i]!
142 const bi = b[i]!
143 if (ai === bi) {
144 continue
145 }
146 const aNum = RegExpPrototypeTest(DIGITS_ONLY, ai)
147 const bNum = RegExpPrototypeTest(DIGITS_ONLY, bi)
148 // Numeric identifiers always have lower precedence than alphanumeric
149 if (aNum && bNum) {
150 const diff = Number(ai) - Number(bi)
151 if (diff !== 0) {
152 return diff < 0 ? -1 : 1
153 }
154 } else if (aNum) {
155 return -1
156 } else if (bNum) {
157 return 1
158 } else {
159 // Alphanumeric: lexicographic comparison
160 if (ai < bi) {
161 return -1
162 }
163 if (ai > bi) {
164 return 1
165 }
166 }
167 }
168 // Larger set of pre-release fields has higher precedence
169 if (a.length !== b.length) {
170 return a.length < b.length ? -1 : 1
171 }
172 return 0
173}
Section 8 of 14
180function compareSemver(a: string, b: string): -1 | 0 | 1 {
181 const pa = parseSemver(a)
182 const pb = parseSemver(b)
183 // Compare major.minor.patch
184 if (pa.major !== pb.major) {
185 return pa.major < pb.major ? -1 : 1
186 }
187 if (pa.minor !== pb.minor) {
188 return pa.minor < pb.minor ? -1 : 1
189 }
190 if (pa.patch !== pb.patch) {
191 return pa.patch < pb.patch ? -1 : 1
192 }
193 // Compare prerelease
194 const pre = comparePrereleases(pa.prerelease, pb.prerelease)
195 if (pre !== 0) {
196 return pre < 0 ? -1 : 1
197 }
198 return 0
199}
Section 9 of 14
204function parseConstraint(raw: string): VersConstraint {
205 const trimmed = StringPrototypeTrim(raw)
206 if (trimmed === '*') {
207 return ObjectFreeze({
208 __proto__: null,
209 comparator: '*',
210 version: '*',
211 } as VersConstraint)
212 }
213 for (let i = 0, { length } = COMPARATORS; i < length; i += 1) {
214 const op = COMPARATORS[i]!
215 if (StringPrototypeStartsWith(trimmed, op)) {
216 const version = StringPrototypeTrim(
217 StringPrototypeSlice(trimmed, op.length),
218 )
219 if (version.length === 0) {
220 throw new PurlError(`empty version after comparator "${op}"`)
221 }
222 return ObjectFreeze({
223 __proto__: null,
224 comparator: op as VersComparator,
225 version,
226 } as VersConstraint)
227 }
228 }
229 // Bare version implies equality
230 if (trimmed.length === 0) {
231 throw new PurlError(
232 'VERS constraint must not be empty (use "*" for the wildcard)',
233 )
234 }
235 return ObjectFreeze({
236 __proto__: null,
237 comparator: '=',
238 version: trimmed,
239 } as VersConstraint)
240}
Section 10 of 14
260class Vers {
261 readonly scheme: string
262 readonly constraints: readonly VersConstraint[]
263
264 private constructor(scheme: string, constraints: VersConstraint[]) {
265 this.scheme = scheme
266 this.constraints = ObjectFreeze(constraints)
267 ObjectFreeze(this)
268 }
Section 11 of 14
277 static parse(versStr: string): Vers {
278 return Vers.fromString(versStr)
279 }
Section 12 of 14
288 static fromString(versStr: string): Vers {
289 if (typeof versStr !== 'string' || versStr.length === 0) {
290 throw new PurlError('VERS string is required')
291 }
292
293 // Must start with `'vers:'`
294 if (!StringPrototypeStartsWith(versStr, 'vers:')) {
295 throw new PurlError('VERS must start with "vers:" scheme')
296 }
297
298 const remainder = StringPrototypeSlice(versStr, 5) // after `'vers:'`
299 const slashIndex = StringPrototypeIndexOf(remainder, '/')
300 if (slashIndex === -1 || slashIndex === 0) {
301 throw new PurlError('VERS must contain a version scheme before "/"')
302 }
303
304 const scheme = StringPrototypeToLowerCase(
305 StringPrototypeSlice(remainder, 0, slashIndex),
306 )
307 const constraintsStr = StringPrototypeSlice(remainder, slashIndex + 1)
308
309 if (constraintsStr.length === 0) {
310 throw new PurlError('VERS must contain at least one constraint')
311 }
312
313 // Parse constraints
314 const rawConstraints = StringPrototypeSplit(constraintsStr, '|' as any)
315
316 // Limit constraint count to prevent resource exhaustion
317 const MAX_CONSTRAINTS = 1000
318 if (rawConstraints.length > MAX_CONSTRAINTS) {
319 throw new PurlError(
320 `VERS exceeds maximum of ${MAX_CONSTRAINTS} constraints`,
321 )
322 }
323
324 const constraints: VersConstraint[] = []
325
326 for (let i = 0, { length } = rawConstraints; i < length; i += 1) {
327 const constraint = parseConstraint(rawConstraints[i]!)
328 ArrayPrototypePush(constraints, constraint)
329 }
330
331 // Validate: wildcard must be alone
332 if (constraints.length > 1) {
333 for (let i = 0, { length } = constraints; i < length; i += 1) {
334 if (constraints[i]!.comparator === '*') {
335 throw new PurlError('wildcard "*" must be the only constraint')
336 }
337 }
338 }
339
340 // Validate versions for semver schemes
341 if (SEMVER_SCHEMES.has(scheme)) {
342 for (let i = 0, { length } = constraints; i < length; i += 1) {
343 const c = constraints[i]!
344 if (c.comparator !== '*' && !isSemverString(c.version)) {
345 throw new PurlError(
346 `invalid semver version "${c.version}" in VERS constraint`,
347 )
348 }
349 }
350 }
351
352 return new Vers(scheme, constraints)
353 }
Section 13 of 14
364 contains(version: string): boolean {
365 if (!SEMVER_SCHEMES.has(this.scheme)) {
366 throw new PurlError(
367 `unsupported VERS scheme "${this.scheme}" for containment check`,
368 )
369 }
370
371 const { constraints } = this
372
373 // Wildcard matches everything
374 if (constraints.length === 1 && constraints[0]!.comparator === '*') {
375 return true
376 }
377
378 // Check not-equals first — a `!=` exclusion takes priority over `=` inclusion
379 // to prevent short-circuiting on `=` before a conflicting `!=` is evaluated.
380 for (let i = 0, { length } = constraints; i < length; i += 1) {
381 const c = constraints[i]!
382 if (c.comparator === '!=' && compareSemver(version, c.version) === 0) {
383 return false
384 }
385 }
386 // Then check equals
387 for (let i = 0, { length } = constraints; i < length; i += 1) {
388 const c = constraints[i]!
389 if (c.comparator === '=' && compareSemver(version, c.version) === 0) {
390 return true
391 }
392 }
393
394 // Filter to range constraints (not `=` or `!=`)
395 const ranges: VersConstraint[] = []
396 for (let i = 0, { length } = constraints; i < length; i += 1) {
397 const c = constraints[i]!
398 if (c.comparator !== '=' && c.comparator !== '!=') {
399 ArrayPrototypePush(ranges, c)
400 }
401 }
402
403 if (ranges.length === 0) {
404 return false
405 }
406
407 // Evaluate range constraints
408 // Per the VERS spec, constraints are sorted and form alternating intervals.
409 // Multiple disjoint ranges are possible (e.g., `>=1.0.0|<2.0.0|>=3.0.0|<4.0.0`),
410 // so we must check ALL range pairs — not return on the first mismatch.
411 for (let i = 0, { length } = ranges; i < length; i += 1) {
412 const c = ranges[i]!
413 const cmp = compareSemver(version, c.version)
414
415 if (c.comparator === '>=') {
416 if (cmp < 0) {
417 // Below this lower bound — skip to next range pair
418 const next = ranges[i + 1]
419 if (next && (next.comparator === '<' || next.comparator === '<=')) {
420 i += 1
421 }
422 continue
423 }
424 // Version >= lower bound — check upper bound
425 const next = ranges[i + 1]
426 if (!next) {
427 return true
428 }
429 const cmpNext = compareSemver(version, next.version)
430 if (next.comparator === '<' && cmpNext < 0) {
431 return true
432 }
433 if (next.comparator === '<=' && cmpNext <= 0) {
434 return true
435 }
436 // Outside this range's upper bound — advance past it and try next range
437 i += 1
438 } else if (c.comparator === '>') {
439 if (cmp <= 0) {
440 // At or below this lower bound — skip to next range pair
441 const next = ranges[i + 1]
442 if (next && (next.comparator === '<' || next.comparator === '<=')) {
443 i += 1
444 }
445 continue
446 }
447 // Version > lower bound — check upper bound
448 const next = ranges[i + 1]
449 if (!next) {
450 return true
451 }
452 const cmpNext = compareSemver(version, next.version)
453 if (next.comparator === '<' && cmpNext < 0) {
454 return true
455 }
456 if (next.comparator === '<=' && cmpNext <= 0) {
457 return true
458 }
459 // Outside this range's upper bound — advance past it and try next range
460 i += 1
461 } else if (c.comparator === '<' || c.comparator === '<=') {
462 // Leading less-than without a preceding lower bound
463 const cmpVal = compareSemver(version, c.version)
464 if (c.comparator === '<' && cmpVal < 0) {
465 return true
466 }
467 if (c.comparator === '<=' && cmpVal <= 0) {
468 return true
469 }
470 // Not in this range — continue to next
471 }
472 }
473 return false
474 }
Section 14 of 14
479 toString(): string {
480 const parts: string[] = []
481 for (let i = 0, { length } = this.constraints; i < length; i += 1) {
482 const c = this.constraints[i]!
483 if (c.comparator === '*') {
484 ArrayPrototypePush(parts, '*')
485 } else if (c.comparator === '=') {
486 ArrayPrototypePush(parts, c.version)
487 } else {
488 ArrayPrototypePush(parts, `${c.comparator}${c.version}`)
489 }
490 }
491 return `vers:${this.scheme}/${ArrayPrototypeJoin(parts, '|')}`
492 }
493}
494
495export { Vers }
496
497export type { VersComparator, VersConstraint, VersWildcard }