| 5 | import { |
| 6 | NumberPrototypeToString, |
| 7 | ObjectFreeze, |
| 8 | RegExpPrototypeTest, |
| 9 | StringFromCharCode, |
| 10 | StringPrototypeCharCodeAt, |
| 11 | StringPrototypeIndexOf, |
| 12 | StringPrototypePadStart, |
| 13 | StringPrototypeSlice, |
| 14 | StringPrototypeToLowerCase, |
| 15 | } from '@socketsecurity/lib/primordials' |
| 20 | function 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 | } |
| 91 | function 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 |
| 97 | const 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 | ) |
| 104 | function 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 |
| 116 | let _localeCompare: Intl.Collator['compare'] | undefined |
| 121 | function 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 | } |
| 132 | function lowerName(purl: { name: string }): void { |
| 133 | purl.name = StringPrototypeToLowerCase(purl.name) |
| 134 | } |
| 139 | function lowerNamespace(purl: { namespace?: string | undefined }): void { |
| 140 | const { namespace } = purl |
| 141 | if (typeof namespace === 'string') { |
| 142 | purl.namespace = StringPrototypeToLowerCase(namespace) |
| 143 | } |
| 144 | } |
| 149 | function lowerVersion(purl: { version?: string | undefined }): void { |
| 150 | const { version } = purl |
| 151 | if (typeof version === 'string') { |
| 152 | purl.version = StringPrototypeToLowerCase(version) |
| 153 | } |
| 154 | } |
| 159 | function 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 | } |
| 174 | function 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 | } |
| 205 | function 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 | } |
| 318 | function 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 | } |
| 377 | function 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 | } |
| 399 | function 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 | } |
| 416 | function containsInjectionCharacters(str: string): boolean { |
| 417 | return findInjectionCharCode(str) !== -1 |
| 418 | } |
| 424 | function 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 | } |
| 435 | function trimLeadingSlashes(str: string): string { |
| 436 | let start = 0 |
| 438 | start += 1 |
| 439 | } |
| 440 | return start === 0 ? str : StringPrototypeSlice(str, start) |
| 441 | } |
| 442 | |
| 443 | export { |
| 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 | } |
| 5 | import { LOOP_SENTINEL } from './constants.js' |
| 6 | import { |
| 7 | ArrayIsArray, |
| 8 | ErrorCtor, |
| 9 | ObjectFreeze, |
| 10 | ObjectIsFrozen, |
| 11 | ReflectOwnKeys, |
| 12 | WeakSetCtor, |
| 13 | } from '@socketsecurity/lib/primordials' |
| 21 | function 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 | } |
| 83 | function isObject(value: unknown): value is { [key: PropertyKey]: unknown } { |
| 84 | return value !== null && typeof value === 'object' |
| 85 | } |
| 86 | |
| 87 | export { isObject, recursiveFreeze } |
| 14 | import { PurlError } from './error.js' |
| 15 | import { |
| 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' |
| 30 | import { isSemverString } from './strings.js' |
| 36 | type VersComparator = '=' | '!=' | '<' | '<=' | '>' | '>=' |
| 41 | type VersWildcard = '*' |
| 46 | type VersConstraint = { |
| 47 | comparator: VersComparator | VersWildcard |
| 48 | version: string |
| 49 | } |
| 54 | type SemverParts = { |
| 55 | major: number |
| 56 | minor: number |
| 57 | patch: number |
| 58 | prerelease: string[] |
| 59 | } |
| 60 | |
| 61 | // Schemes that use semver comparison |
| 62 | const 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) |
| 77 | const COMPARATORS: readonly string[] = ObjectFreeze([ |
| 78 | '!=', |
| 79 | '<=', |
| 80 | '>=', |
| 81 | '<', |
| 82 | '>', |
| 83 | '=', |
| 84 | ]) |
| 85 | |
| 86 | const DIGITS_ONLY = ObjectFreeze(/^\d+$/) |
| 87 | |
| 88 | const 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 | ) |
| 95 | function 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 | } |
| 127 | function 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 | } |
| 180 | function 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 | } |
| 204 | function 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 | } |
| 260 | class 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 | } |
| 277 | static parse(versStr: string): Vers { |
| 278 | return Vers.fromString(versStr) |
| 279 | } |
| 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 | } |
| 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 | } |
| 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 | |
| 495 | export { Vers } |
| 496 | |
| 497 | export type { VersComparator, VersConstraint, VersWildcard } |