diff --git a/.config/esbuild.config.mjs b/.config/esbuild.config.mjs index 421fea7..d496b49 100644 --- a/.config/esbuild.config.mjs +++ b/.config/esbuild.config.mjs @@ -216,7 +216,7 @@ function createPathShorteningPlugin() { // Build configuration for CommonJS output export const buildConfig = { - entryPoints: [`${srcPath}/index.ts`], + entryPoints: [`${srcPath}/index.ts`, `${srcPath}/exists.ts`], outdir: distPath, outbase: srcPath, bundle: true, diff --git a/CHANGELOG.md b/CHANGELOG.md index 66bde6e..a04d50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.4.0](https://github.com/SocketDev/socket-packageurl-js/releases/tag/v1.4.0) - 2026-03-28 + +### Added + +- **VERS parser**: First JavaScript implementation of the VERS (VErsion Range Specifier) companion spec to PURL. Supports parsing, serialization, and containment checking for semver-based schemes (npm, cargo, golang, gem, hex, pub, cran, swift) +- **URL-to-PURL conversion**: `UrlConverter.fromUrl()` converts registry URLs to PackageURLs across 27 hostnames and 17 purl types (npm, pypi, maven, cargo, nuget, github, gitlab, bitbucket, docker, hex, pub, cocoapods, hackage, conda, cpan, luarocks, huggingface, swift, cran, vscode) +- **`toSpec()` method**: Returns the package identity without the `pkg:type/` prefix (the npm "spec" equivalent) +- **`isValid()` static method**: Quick validation without throwing +- **`fromUrl()` static method**: Convenience wrapper for `UrlConverter.fromUrl()` +- **Immutable copy methods**: `withVersion()`, `withNamespace()`, `withQualifier()`, `withQualifiers()`, `withSubpath()` return new instances +- **PurlBuilder factories**: Added 18 new type factories (bitbucket, cocoapods, conan, conda, cran, deb, docker, github, gitlab, hackage, hex, huggingface, luarocks, oci, pub, rpm, swift, vscode-extension) +- **Injection character detection**: `containsInjectionCharacters()` utility for shell metacharacter detection +- **`vers` qualifier**: Added 6th standard qualifier per purl spec +- **`./exists` entry point**: Registry existence checks available via `@socketregistry/packageurl-js/exists` + +### Changed + +- **Bundle size reduced 95%**: Core bundle is 178 KB (was 3.3 MB). Exists functions moved to separate entry point to avoid bundling HTTP dependencies +- **Primordials module**: All 43 built-in references captured at module load time via `uncurryThis` pattern (mirrors Node.js internals). Zero raw prototype method calls remain +- **Frozen constants**: Module-level Maps, Sets, regex patterns, and arrays are frozen +- **Null prototype objects**: All user-facing object literals use `__proto__: null` +- **Flyweight cache**: `fromString()` caches up to 1024 instances; `toString()` memoized +- **Version lowercasing**: Added for oci, pypi, and vscode-extension per upstream spec + +### Fixed + +- **ReDoS prevention**: Consecutive `.*` groups collapsed in wildcard regex +- **Null byte rejection**: All string components reject `\x00` to prevent truncation in C-based consumers +- **VERS resource limits**: 1000 constraint maximum, MAX_SAFE_INTEGER validation +- **vscode-extension validation**: Rejects illegal characters in namespace, name, version, and platform qualifier + +### Security + +- Prototype pollution resilience via primordials (captured String, Array, RegExp, Object, Reflect methods) +- Global tampering protection verified (replacing `global.URL` after import has no effect) +- Inline regex patterns hoisted to frozen module-scope constants + ## [1.3.5](https://github.com/SocketDev/socket-packageurl-js/releases/tag/v1.3.5) - 2025-11-02 ### Changed diff --git a/package.json b/package.json index ee9bb29..6192491 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@socketregistry/packageurl-js", - "version": "1.3.5", + "version": "1.4.0", "packageManager": "pnpm@10.33.0", "license": "MIT", "description": "Socket.dev optimized package override for packageurl-js", @@ -20,6 +20,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./exists": { + "types": "./dist/exists.d.ts", + "default": "./dist/exists.js" + }, "./data/npm/builtin-names.json": "./data/npm/builtin-names.json", "./data/npm/legacy-names.json": "./data/npm/legacy-names.json", "./package.json": "./package.json" @@ -58,7 +62,7 @@ "@babel/parser": "7.29.0", "@dotenvx/dotenvx": "1.52.0", "@oxlint/migrate": "1.51.0", - "@socketsecurity/lib": "5.11.3", + "@socketsecurity/lib": "5.11.4", "@socketsecurity/registry": "2.0.2", "@types/node": "24.9.2", "@types/picomatch": "4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f8be27..029bd95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.51.0 version: 1.51.0 '@socketsecurity/lib': - specifier: 5.11.3 - version: 5.11.3(typescript@5.9.2) + specifier: 5.11.4 + version: 5.11.4(typescript@5.9.2) '@socketsecurity/registry': specifier: 2.0.2 version: 2.0.2(typescript@5.9.2) @@ -963,8 +963,8 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} - '@socketsecurity/lib@5.11.3': - resolution: {integrity: sha512-Jp6TSn8ATHrTtw/kTFXmVr+cZwZVuX9wqym/FQ3n8GSAgByUPJJdzWMLzHVFK1HaQRK4kVdB5Db3IKXdFkpQ2Q==} + '@socketsecurity/lib@5.11.4': + resolution: {integrity: sha512-3/stIFexg45DNhGvpw/U49UFuHYe0ZLS45scuYw83HfWrHmL7h9Mi4m27C3/ihAF6O9uOJSKBsWBIHlTM8Wikg==} engines: {node: '>=22', pnpm: '>=10.25.0'} peerDependencies: typescript: '>=5.0.0' @@ -2831,7 +2831,7 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} - '@socketsecurity/lib@5.11.3(typescript@5.9.2)': + '@socketsecurity/lib@5.11.4(typescript@5.9.2)': optionalDependencies: typescript: 5.9.2 diff --git a/src/compare.ts b/src/compare.ts index f366e0d..7764eed 100644 --- a/src/compare.ts +++ b/src/compare.ts @@ -3,6 +3,17 @@ * Functions for comparing PackageURL instances or PURL strings. */ +import { + MapCtor, + RegExpPrototypeTest, + StringPrototypeIncludes, + StringPrototypeIndexOf, + StringPrototypeReplace, + StringPrototypeSlice, + StringPrototypeStartsWith, + StringPrototypeToLowerCase, +} from './primordials.js' + import type { PackageURL } from './package-url.js' export type PurlInput = PackageURL | string @@ -18,6 +29,7 @@ export function _registerPackageURL(ctor: typeof PackageURL): void { function toCanonicalString(input: PurlInput): string { if (typeof input === 'string') { + /* c8 ignore next 5 -- PackageURL is always registered at module load time. */ if (!_PackageURL) { throw new Error( 'PackageURL not registered. Import PackageURL before using string comparison.', @@ -31,7 +43,7 @@ function toCanonicalString(input: PurlInput): string { /** * Cache for compiled wildcard regexes to avoid recompilation on repeated calls. */ -const wildcardRegexCache = new Map() +const wildcardRegexCache = new MapCtor() /** * Simple wildcard matcher for PURL components. @@ -43,15 +55,23 @@ function matchWildcard(pattern: string, value: string): boolean { if (regex === undefined) { // Convert glob pattern to regex // Escape regex special chars except * and ? - const regexPattern = pattern - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*/g, '.*') - .replace(/\?/g, '.') + const regexPattern = StringPrototypeReplace( + StringPrototypeReplace( + StringPrototypeReplace(pattern, /[.+^${}()|[\]\\]/g, '\\$&' as any), + /\*/g, + '.*' as any, + ), + /\?/g, + '.' as any, + ) - regex = new RegExp(`^${regexPattern}$`) + // Collapse consecutive .* groups to prevent polynomial backtracking (ReDoS) + regex = new RegExp( + `^${StringPrototypeReplace(regexPattern, /(\.\*)+/g, '.*' as any)}$`, + ) wildcardRegexCache.set(pattern, regex) } - return regex.test(value) + return RegExpPrototypeTest(regex, value) } /** @@ -90,7 +110,10 @@ function matchComponent( } // Check if pattern contains wildcards (when no pre-compiled matcher) - if (patternValue.includes('*') || patternValue.includes('?')) { + if ( + StringPrototypeIncludes(patternValue, '*') || + StringPrototypeIncludes(patternValue, '?') + ) { return matchWildcard(patternValue, actualValue) } @@ -164,65 +187,54 @@ export function compare(a: PurlInput, b: PurlInput): -1 | 0 | 1 { return 0 } +type ParsedPattern = { + typePattern: string + namespacePattern: string | undefined + namePattern: string + versionPattern: string | undefined +} + /** - * Check if a PackageURL matches a pattern with wildcards. - * - * Supports glob-style wildcards: - * - asterisk matches any sequence of characters within a component - * - double asterisk matches any value including empty (for optional components) - * - question mark matches single character - * - * Pattern matching is performed on normalized PURLs (after type-specific - * normalization). Each component is matched independently. - * - * @param pattern - PURL string with wildcards - * @param purl - PackageURL instance to test - * @returns true if purl matches the pattern - * - * @example - * Wildcard in name: matches('pkg:npm/lodash-star', purl) - * Wildcard in namespace: matches('pkg:npm/@babel/star', purl) - * Wildcard in version: matches('pkg:npm/react@18.star', purl) - * Match any type: matches('pkg:star/lodash', purl) - * Optional version: matches('pkg:npm/lodash@star-star', purl) + * Parse a PURL pattern string into its individual components. + * Strips the `pkg:` prefix, extracts type/namespace/name/version, + * handles scoped `@` prefixes, and applies type-specific normalization + * (npm lowercase, pypi underscore-to-hyphen). * - * See test/pattern-matching.test.mts for comprehensive examples. + * Returns undefined if the pattern is not a valid PURL pattern shape. */ -export function matches(pattern: string, purl: PackageURL): boolean { - // Parse pattern string manually to extract components (without validation) - // Pattern format: pkg:type/namespace/name@version?qualifiers#subpath - if (!pattern.startsWith('pkg:')) { - return false +function parsePattern(pattern: string): ParsedPattern | undefined { + if (!StringPrototypeStartsWith(pattern, 'pkg:')) { + return undefined } // Remove 'pkg:' prefix - const patternWithoutScheme = pattern.slice(4) + const patternWithoutScheme = StringPrototypeSlice(pattern, 4) // Extract type - const typeEndIndex = patternWithoutScheme.indexOf('/') + const typeEndIndex = StringPrototypeIndexOf(patternWithoutScheme, '/') if (typeEndIndex === -1) { - return false + return undefined } - let patternType = patternWithoutScheme.slice(0, typeEndIndex) + let typePattern = StringPrototypeSlice(patternWithoutScheme, 0, typeEndIndex) // Extract remaining parts - const remaining = patternWithoutScheme.slice(typeEndIndex + 1) + const remaining = StringPrototypeSlice(patternWithoutScheme, typeEndIndex + 1) // Parse namespace and name // Format: [namespace/]name[@version][?qualifiers][#subpath] // Namespace is optional and ends at the first '/' - let patternNamespace: string | undefined - let patternName: string - let patternVersion: string | undefined + let namespacePattern: string | undefined + let namePattern: string + let versionPattern: string | undefined // Check if there's a namespace (indicated by presence of '/') - const firstSlashIndex = remaining.indexOf('/') + const firstSlashIndex = StringPrototypeIndexOf(remaining, '/') let nameAndVersion: string if (firstSlashIndex !== -1) { // Has namespace - patternNamespace = remaining.slice(0, firstSlashIndex) - nameAndVersion = remaining.slice(firstSlashIndex + 1) + namespacePattern = StringPrototypeSlice(remaining, 0, firstSlashIndex) + nameAndVersion = StringPrototypeSlice(remaining, firstSlashIndex + 1) } else { // No namespace nameAndVersion = remaining @@ -230,41 +242,82 @@ export function matches(pattern: string, purl: PackageURL): boolean { // Extract version from name (version starts with '@') // For scoped packages without namespace (e.g., '@foo' as name), skip first '@' - const versionSeparatorIndex = nameAndVersion.startsWith('@') - ? nameAndVersion.indexOf('@', 1) - : nameAndVersion.indexOf('@') + const versionSeparatorIndex = StringPrototypeStartsWith(nameAndVersion, '@') + ? StringPrototypeIndexOf(nameAndVersion, '@', 1) + : StringPrototypeIndexOf(nameAndVersion, '@') if (versionSeparatorIndex !== -1) { - patternName = nameAndVersion.slice(0, versionSeparatorIndex) + namePattern = StringPrototypeSlice(nameAndVersion, 0, versionSeparatorIndex) // Version is everything after @ (qualifiers/subpath not supported in patterns v1) - patternVersion = nameAndVersion.slice(versionSeparatorIndex + 1) + versionPattern = StringPrototypeSlice( + nameAndVersion, + versionSeparatorIndex + 1, + ) } else { - patternName = nameAndVersion + namePattern = nameAndVersion } // Apply type-specific normalization to pattern components // Types are case-insensitive, so normalize to lowercase - patternType = patternType.toLowerCase() + typePattern = StringPrototypeToLowerCase(typePattern) // For npm: lowercase namespace and name (ignoring legacy names for simplicity) - if (patternType === 'npm') { - if (patternNamespace) { - patternNamespace = patternNamespace.toLowerCase() + if (typePattern === 'npm') { + if (namespacePattern) { + namespacePattern = StringPrototypeToLowerCase(namespacePattern) } - patternName = patternName.toLowerCase() + namePattern = StringPrototypeToLowerCase(namePattern) } // For pypi: lowercase name and replace underscores with hyphens - if (patternType === 'pypi') { - patternName = patternName.toLowerCase().replace(/_/g, '-') + if (typePattern === 'pypi') { + namePattern = StringPrototypeReplace( + StringPrototypeToLowerCase(namePattern), + /_/g, + '-' as any, + ) + } + + return { typePattern, namespacePattern, namePattern, versionPattern } +} + +/** + * Check if a PackageURL matches a pattern with wildcards. + * + * Supports glob-style wildcards: + * - asterisk matches any sequence of characters within a component + * - double asterisk matches any value including empty (for optional components) + * - question mark matches single character + * + * Pattern matching is performed on normalized PURLs (after type-specific + * normalization). Each component is matched independently. + * + * @param pattern - PURL string with wildcards + * @param purl - PackageURL instance to test + * @returns true if purl matches the pattern + * + * @example + * Wildcard in name: matches('pkg:npm/lodash-star', purl) + * Wildcard in namespace: matches('pkg:npm/@babel/star', purl) + * Wildcard in version: matches('pkg:npm/react@18.star', purl) + * Match any type: matches('pkg:star/lodash', purl) + * Optional version: matches('pkg:npm/lodash@star-star', purl) + * + * See test/pattern-matching.test.mts for comprehensive examples. + */ +export function matches(pattern: string, purl: PackageURL): boolean { + const parsed = parsePattern(pattern) + if (!parsed) { + return false } + const { typePattern, namespacePattern, namePattern, versionPattern } = parsed // Match each component (always use component matching to properly ignore qualifiers/subpath) return ( - matchComponent(patternType, purl.type) && - matchComponent(patternNamespace, purl.namespace) && - matchComponent(patternName, purl.name) && - matchComponent(patternVersion, purl.version) + matchComponent(typePattern, purl.type) && + matchComponent(namespacePattern, purl.namespace) && + matchComponent(namePattern, purl.name) && + matchComponent(versionPattern, purl.version) ) } @@ -285,107 +338,54 @@ export function matches(pattern: string, purl: PackageURL): boolean { * See test/pattern-matching.test.mts for comprehensive examples. */ export function createMatcher(pattern: string): (_purl: PackageURL) => boolean { - // Parse pattern string manually (without validation) - if (!pattern.startsWith('pkg:')) { + const parsed = parsePattern(pattern) + if (!parsed) { return () => false } - - const patternWithoutScheme = pattern.slice(4) - const typeEndIndex = patternWithoutScheme.indexOf('/') - if (typeEndIndex === -1) { - return () => false - } - - let patternType = patternWithoutScheme.slice(0, typeEndIndex) - const remaining = patternWithoutScheme.slice(typeEndIndex + 1) - - // Parse namespace and name - // Format: [namespace/]name[@version][?qualifiers][#subpath] - // Namespace is optional and ends at the first '/' - let patternNamespace: string | undefined - let patternName: string - let patternVersion: string | undefined - - // Check if there's a namespace (indicated by presence of '/') - const firstSlashIndex = remaining.indexOf('/') - let nameAndVersion: string - - if (firstSlashIndex !== -1) { - // Has namespace - patternNamespace = remaining.slice(0, firstSlashIndex) - nameAndVersion = remaining.slice(firstSlashIndex + 1) - } else { - // No namespace - nameAndVersion = remaining - } - - // Extract version from name (version starts with '@') - // For scoped packages without namespace (e.g., '@foo' as name), skip first '@' - const versionSeparatorIndex = nameAndVersion.startsWith('@') - ? nameAndVersion.indexOf('@', 1) - : nameAndVersion.indexOf('@') - - if (versionSeparatorIndex !== -1) { - patternName = nameAndVersion.slice(0, versionSeparatorIndex) - // Version is everything after @ (qualifiers/subpath not supported in patterns v1) - patternVersion = nameAndVersion.slice(versionSeparatorIndex + 1) - } else { - patternName = nameAndVersion - } - - // Apply type-specific normalization to pattern components - // Types are case-insensitive, so normalize to lowercase - patternType = patternType.toLowerCase() - - // For npm: lowercase namespace and name (ignoring legacy names for simplicity) - if (patternType === 'npm') { - if (patternNamespace) { - patternNamespace = patternNamespace.toLowerCase() - } - patternName = patternName.toLowerCase() - } - - // For pypi: lowercase name and replace underscores with hyphens - if (patternType === 'pypi') { - patternName = patternName.toLowerCase().replace(/_/g, '-') - } + const { typePattern, namespacePattern, namePattern, versionPattern } = parsed // Pre-compile wildcard matchers for components with wildcards const typeHasWildcard = - patternType && (patternType.includes('*') || patternType.includes('?')) + typePattern && + (StringPrototypeIncludes(typePattern, '*') || + StringPrototypeIncludes(typePattern, '?')) const typeMatcher = typeHasWildcard - ? (value: string) => matchWildcard(patternType, value) + ? (value: string) => matchWildcard(typePattern, value) : undefined const namespaceHasWildcard = - patternNamespace && - (patternNamespace.includes('*') || patternNamespace.includes('?')) + namespacePattern && + (StringPrototypeIncludes(namespacePattern, '*') || + StringPrototypeIncludes(namespacePattern, '?')) const namespaceMatcher = - namespaceHasWildcard && patternNamespace - ? (value: string) => matchWildcard(patternNamespace, value) + namespaceHasWildcard && namespacePattern + ? (value: string) => matchWildcard(namespacePattern, value) : undefined const nameHasWildcard = - patternName && (patternName.includes('*') || patternName.includes('?')) + namePattern && + (StringPrototypeIncludes(namePattern, '*') || + StringPrototypeIncludes(namePattern, '?')) const nameMatcher = nameHasWildcard - ? (value: string) => matchWildcard(patternName, value) + ? (value: string) => matchWildcard(namePattern, value) : undefined const versionHasWildcard = - patternVersion && - (patternVersion.includes('*') || patternVersion.includes('?')) + versionPattern && + (StringPrototypeIncludes(versionPattern, '*') || + StringPrototypeIncludes(versionPattern, '?')) const versionMatcher = - versionHasWildcard && patternVersion - ? (value: string) => matchWildcard(patternVersion, value) + versionHasWildcard && versionPattern + ? (value: string) => matchWildcard(versionPattern, value) : undefined // Return optimized matcher function with pre-compiled matchers return (_purl: PackageURL): boolean => { return ( - matchComponent(patternType, _purl.type, typeMatcher) && - matchComponent(patternNamespace, _purl.namespace, namespaceMatcher) && - matchComponent(patternName, _purl.name, nameMatcher) && - matchComponent(patternVersion, _purl.version, versionMatcher) + matchComponent(typePattern, _purl.type, typeMatcher) && + matchComponent(namespacePattern, _purl.namespace, namespaceMatcher) && + matchComponent(namePattern, _purl.name, nameMatcher) && + matchComponent(versionPattern, _purl.version, versionMatcher) ) } } diff --git a/src/constants.ts b/src/constants.ts index b09b8aa..f516344 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,9 +3,11 @@ * Includes loop sentinels and reusable URL search parameter utilities. */ +import { URLSearchParamsCtor } from './primordials.js' + const LOOP_SENTINEL = 1_000_000 -const REUSED_SEARCH_PARAMS = new URLSearchParams() +const REUSED_SEARCH_PARAMS = new URLSearchParamsCtor() const REUSED_SEARCH_PARAMS_KEY = '_' diff --git a/src/decode.ts b/src/decode.ts index c25da79..2f26ab5 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -3,12 +3,7 @@ * Provides proper error handling for invalid encoded strings. */ import { PurlError } from './error.js' - -// IMPORTANT: Do not use destructuring here - use direct assignment instead -// tsgo has a bug that incorrectly transpiles destructured exports, resulting in -// `exports.decodeComponent = void 0;` which causes runtime errors -// See: https://github.com/SocketDev/socket-packageurl-js/issues/3 -const decodeComponent = globalThis.decodeURIComponent +import { decodeComponent } from './primordials.js' /** * Decode PURL component value from URL encoding. diff --git a/src/encode.ts b/src/encode.ts index 0fa8eb5..cfa5c0d 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -8,20 +8,22 @@ import { REUSED_SEARCH_PARAMS_OFFSET, } from './constants.js' import { isObject } from './objects.js' +import { + ArrayPrototypeToSorted, + ObjectKeys, + StringPrototypeReplaceAll, + StringPrototypeSlice, + URLSearchParamsCtor, + encodeComponent, +} from './primordials.js' import { isNonEmptyString } from './strings.js' -// IMPORTANT: Do not use destructuring here (e.g., const { encodeURIComponent } = globalThis) -// tsgo has a bug that incorrectly transpiles destructured exports, resulting in -// `exports.encodeComponent = void 0;` which causes runtime errors -// See: https://github.com/SocketDev/socket-packageurl-js/issues/3 -const encodeComponent = globalThis.encodeURIComponent - /** * Encode package name component for URL. */ function encodeName(name: unknown): string { return isNonEmptyString(name) - ? encodeComponent(name).replaceAll('%3A', ':') + ? StringPrototypeReplaceAll(encodeComponent(name), '%3A', ':') : '' } @@ -30,7 +32,11 @@ function encodeName(name: unknown): string { */ function encodeNamespace(namespace: unknown): string { return isNonEmptyString(namespace) - ? encodeComponent(namespace).replaceAll('%3A', ':').replaceAll('%2F', '/') + ? StringPrototypeReplaceAll( + StringPrototypeReplaceAll(encodeComponent(namespace), '%3A', ':'), + '%2F', + '/', + ) : '' } @@ -49,7 +55,7 @@ function encodeQualifierParam(param: unknown): string { // https://url.spec.whatwg.org/#urlencoded-serializing const search = REUSED_SEARCH_PARAMS.toString() return normalizeSearchParamsEncoding( - search.slice(REUSED_SEARCH_PARAMS_OFFSET), + StringPrototypeSlice(search, REUSED_SEARCH_PARAMS_OFFSET), ) } return '' @@ -61,8 +67,8 @@ function encodeQualifierParam(param: unknown): string { function encodeQualifiers(qualifiers: unknown): string { if (isObject(qualifiers)) { // Sort this list of qualifier strings lexicographically - const qualifiersKeys = Object.keys(qualifiers).toSorted() - const searchParams = new URLSearchParams() + const qualifiersKeys = ArrayPrototypeToSorted(ObjectKeys(qualifiers)) + const searchParams = new URLSearchParamsCtor() for (let i = 0, { length } = qualifiersKeys; i < length; i += 1) { const key = qualifiersKeys[i]! const value = prepareValueForSearchParams( @@ -82,7 +88,7 @@ function encodeQualifiers(qualifiers: unknown): string { */ function encodeSubpath(subpath: unknown): string { return isNonEmptyString(subpath) - ? encodeComponent(subpath).replaceAll('%2F', '/') + ? StringPrototypeReplaceAll(encodeComponent(subpath), '%2F', '/') : '' } @@ -91,7 +97,7 @@ function encodeSubpath(subpath: unknown): string { */ function encodeVersion(version: unknown): string { return isNonEmptyString(version) - ? encodeComponent(version).replaceAll('%3A', ':') + ? StringPrototypeReplaceAll(encodeComponent(version), '%3A', ':') : '' } @@ -99,7 +105,11 @@ function encodeVersion(version: unknown): string { * Normalize URLSearchParams output for qualifier encoding. */ function normalizeSearchParamsEncoding(encoded: string): string { - return encoded.replaceAll('%2520', '%20').replaceAll('+', '%2B') + return StringPrototypeReplaceAll( + StringPrototypeReplaceAll(encoded, '%2520', '%20'), + '+', + '%2B', + ) } /** @@ -107,7 +117,7 @@ function normalizeSearchParamsEncoding(encoded: string): string { */ function prepareValueForSearchParams(value: unknown): string { // Replace spaces with %20's so they don't get converted to plus signs - return String(value).replaceAll(' ', '%20') + return StringPrototypeReplaceAll(String(value), ' ', '%20') } export { diff --git a/src/error.ts b/src/error.ts index 5366caf..8f9f582 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,3 +1,9 @@ +import { + StringPrototypeCharCodeAt, + StringPrototypeSlice, + StringPrototypeToLowerCase, +} from './primordials.js' + /** * @fileoverview Custom PurlError class for Package URL parsing and validation errors. * Provides consistent error message formatting for PURL-related exceptions. @@ -11,18 +17,18 @@ function formatPurlErrorMessage(message = ''): string { let formatted = '' if (length) { // Lower case start of message - const code0 = message.charCodeAt(0) + const code0 = StringPrototypeCharCodeAt(message, 0) formatted = code0 >= 65 /*'A'*/ && code0 <= 90 /*'Z'*/ - ? `${message[0]?.toLowerCase()}${message.slice(1)}` + ? `${StringPrototypeToLowerCase(message[0]!)}${StringPrototypeSlice(message, 1)}` : message // Remove period from end of message if ( length > 1 && - message.charCodeAt(length - 1) === 46 /*'.'*/ && - message.charCodeAt(length - 2) !== 46 + StringPrototypeCharCodeAt(message, length - 1) === 46 /*'.'*/ && + StringPrototypeCharCodeAt(message, length - 2) !== 46 ) { - formatted = formatted.slice(0, -1) + formatted = StringPrototypeSlice(formatted, 0, -1) } } return `Invalid purl: ${formatted}` diff --git a/src/exists.ts b/src/exists.ts new file mode 100644 index 0000000..8fc7305 --- /dev/null +++ b/src/exists.ts @@ -0,0 +1,36 @@ +/** + * @fileoverview Registry existence check functions. + * + * This module provides functions to check if packages exist in their + * respective registries. Separated from the core module to allow + * consumers to import the parser without pulling in HTTP dependencies. + * + * @example + * ```typescript + * import { npmExists, purlExists } from '@socketregistry/packageurl-js/exists' + * ``` + */ + +/* c8 ignore start - Re-export only file, no logic to test */ + +export { cargoExists } from './purl-types/cargo.js' +export { cocoapodsExists } from './purl-types/cocoapods.js' +export { condaExists } from './purl-types/conda.js' +export { dockerExists } from './purl-types/docker.js' +export { packagistExists } from './purl-types/composer.js' +export { cpanExists } from './purl-types/cpan.js' +export { cranExists } from './purl-types/cran.js' +export { gemExists } from './purl-types/gem.js' +export { golangExists } from './purl-types/golang.js' +export { hackageExists } from './purl-types/hackage.js' +export { hexExists } from './purl-types/hex.js' +export { mavenExists } from './purl-types/maven.js' +export { npmExists } from './purl-types/npm.js' +export { nugetExists } from './purl-types/nuget.js' +export { pubExists } from './purl-types/pub.js' +export { purlExists } from './purl-exists.js' +export { pypiExists } from './purl-types/pypi.js' +export { vscodeExtensionExists } from './purl-types/vscode-extension.js' +export type { ExistsOptions, ExistsResult } from './purl-types/npm.js' + +/* c8 ignore stop */ diff --git a/src/helpers.ts b/src/helpers.ts index 14a9627..0bb71d9 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -2,6 +2,14 @@ * @fileoverview Helper function for creating namespace objects. * Organizes helper functions by property names with configurable defaults and sorting. */ +import { + ArrayPrototypeFlatMap, + ArrayPrototypeToSorted, + ObjectCreate, + ObjectKeys, + ObjectValues, + SetCtor, +} from './primordials.js' /** * Create namespace object organizing helpers by property names. @@ -16,20 +24,24 @@ function createHelpersNamespaceObject( } as Record & { comparator?: ((_a: string, _b: string) => number) | undefined } - const helperNames = Object.keys(helpers).toSorted() + const helperNames = ArrayPrototypeToSorted(ObjectKeys(helpers)) // Collect all unique property names from all helper objects - const propNames = [ - ...new Set( - Object.values(helpers).flatMap((helper: Record) => - Object.keys(helper), + const propNames = ArrayPrototypeToSorted( + [ + ...new SetCtor( + ArrayPrototypeFlatMap( + ObjectValues(helpers), + (helper: Record) => ObjectKeys(helper), + ), ), - ), - ].toSorted(comparator) - const nsObject: Record> = Object.create(null) + ], + comparator, + ) + const nsObject: Record> = ObjectCreate(null) // Build inverted structure: property -> {helper1: value1, helper2: value2} for (let i = 0, { length } = propNames; i < length; i += 1) { const propName = propNames[i]! - const helpersForProp: Record = Object.create(null) + const helpersForProp: Record = ObjectCreate(null) for ( let j = 0, { length: helperNamesLength } = helperNames; j < helperNamesLength; diff --git a/src/index.ts b/src/index.ts index 29e0b10..7674b64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,25 +92,13 @@ export { PurlError } from './error.js' // ============================================================================ export { compare, createMatcher, equals, matches } from './compare.js' export { parseNpmSpecifier } from './purl-types/npm.js' -export { cargoExists } from './purl-types/cargo.js' -export { cocoapodsExists } from './purl-types/cocoapods.js' -export { condaExists } from './purl-types/conda.js' -export { dockerExists } from './purl-types/docker.js' -export { packagistExists } from './purl-types/composer.js' -export { cpanExists } from './purl-types/cpan.js' -export { cranExists } from './purl-types/cran.js' -export { gemExists } from './purl-types/gem.js' -export { golangExists } from './purl-types/golang.js' -export { hackageExists } from './purl-types/hackage.js' -export { hexExists } from './purl-types/hex.js' -export { mavenExists } from './purl-types/maven.js' -export { npmExists } from './purl-types/npm.js' -export { nugetExists } from './purl-types/nuget.js' -export { pubExists } from './purl-types/pub.js' -export { purlExists } from './purl-exists.js' -export { pypiExists } from './purl-types/pypi.js' -export { vscodeExtensionExists } from './purl-types/vscode-extension.js' +// Registry existence checks (*Exists functions) are available from the +// separate entry point: import { npmExists } from '@socketregistry/packageurl-js/exists' +// This keeps the core bundle lean (~200 KB vs 3.3 MB with HTTP deps). export type { ExistsOptions, ExistsResult } from './purl-types/npm.js' -export { stringify } from './stringify.js' +export { containsInjectionCharacters } from './strings.js' +export { stringify, stringifySpec } from './stringify.js' +export { Vers } from './vers.js' +export type { VersComparator, VersConstraint, VersWildcard } from './vers.js' /* c8 ignore stop */ diff --git a/src/normalize.ts b/src/normalize.ts index 861e317..0aa5a7b 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -3,9 +3,21 @@ * Handles path normalization, qualifier processing, and canonical form conversion. */ import { isObject } from './objects.js' +import { + ObjectCreate, + ObjectFreeze, + ReflectApply, + StringPrototypeCharCodeAt, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeToLowerCase, + StringPrototypeTrim, +} from './primordials.js' import { isBlank } from './strings.js' -const EMPTY_ENTRIES: Iterable<[string, string]> = [] +const EMPTY_ENTRIES: Iterable<[string, string]> = ObjectFreeze( + [] as Array<[string, string]>, +) import type { QualifiersObject } from './purl-component.js' @@ -13,7 +25,7 @@ import type { QualifiersObject } from './purl-component.js' * Normalize package name by trimming whitespace. */ function normalizeName(rawName: unknown): string | undefined { - return typeof rawName === 'string' ? rawName.trim() : undefined + return typeof rawName === 'string' ? StringPrototypeTrim(rawName) : undefined } /** @@ -37,31 +49,31 @@ function normalizePurlPath( let start = 0 // Leading and trailing slashes, i.e. '/', are not significant and should be // stripped in the canonical form - while (pathname.charCodeAt(start) === 47 /*'/'*/) { + while (StringPrototypeCharCodeAt(pathname, start) === 47 /*'/'*/) { start += 1 } - let nextIndex = pathname.indexOf('/', start) + let nextIndex = StringPrototypeIndexOf(pathname, '/', start) if (nextIndex === -1) { // No slashes found - return trimmed pathname - return pathname.slice(start) + return StringPrototypeSlice(pathname, start) } // Discard any empty string segments by collapsing repeated segment // separator slashes, i.e. '/' while (nextIndex !== -1) { - const segment = pathname.slice(start, nextIndex) + const segment = StringPrototypeSlice(pathname, start, nextIndex) if (callback === undefined || callback(segment)) { // Add segment with separator if not first segment collapsed = collapsed + (collapsed.length === 0 ? '' : '/') + segment } // Skip to next segment, consuming multiple consecutive slashes start = nextIndex + 1 - while (pathname.charCodeAt(start) === 47) { + while (StringPrototypeCharCodeAt(pathname, start) === 47) { start += 1 } - nextIndex = pathname.indexOf('/', start) + nextIndex = StringPrototypeIndexOf(pathname, '/', start) } // Handle last segment after final slash - const lastSegment = pathname.slice(start) + const lastSegment = StringPrototypeSlice(pathname, start) if ( lastSegment.length !== 0 && (callback === undefined || callback(lastSegment)) @@ -82,17 +94,17 @@ function normalizeQualifiers( // Use for-of to work with entries iterators for (const { 0: key, 1: value } of qualifiersToEntries(rawQualifiers)) { const strValue = typeof value === 'string' ? value : String(value) - const trimmed = strValue.trim() + const trimmed = StringPrototypeTrim(strValue) // A key=value pair with an empty value is the same as no key/value // at all for this key if (trimmed.length === 0) { continue } if (qualifiers === undefined) { - qualifiers = Object.create(null) as Record + qualifiers = ObjectCreate(null) as Record } // A key is case insensitive. The canonical form is lowercase - qualifiers[key.toLowerCase()] = trimmed + qualifiers[StringPrototypeToLowerCase(key)] = trimmed } return qualifiers } @@ -112,22 +124,20 @@ function normalizeSubpath(rawSubpath: unknown): string | undefined { function normalizeType(rawType: unknown): string | undefined { // The type must NOT be percent-encoded // The type is case insensitive. The canonical form is lowercase - return typeof rawType === 'string' ? rawType.trim().toLowerCase() : undefined + return typeof rawType === 'string' + ? StringPrototypeToLowerCase(StringPrototypeTrim(rawType)) + : undefined } /** * Normalize package version by trimming whitespace. */ function normalizeVersion(rawVersion: unknown): string | undefined { - return typeof rawVersion === 'string' ? rawVersion.trim() : undefined + return typeof rawVersion === 'string' + ? StringPrototypeTrim(rawVersion) + : undefined } -// IMPORTANT: Do not use destructuring here - use direct assignment instead -// tsgo has a bug that incorrectly transpiles destructured exports, resulting in -// `exports.ReflectApply = void 0;` which causes runtime errors -// See: https://github.com/SocketDev/socket-packageurl-js/issues/3 -const ReflectApply = Reflect.apply - /** * Convert qualifiers to iterable entries. */ @@ -159,13 +169,13 @@ function subpathFilter(segment: string): boolean { // - must not be any of '.' or '..' // - must not be empty const { length } = segment - if (length === 1 && segment.charCodeAt(0) === 46 /*'.'*/) { + if (length === 1 && StringPrototypeCharCodeAt(segment, 0) === 46 /*'.'*/) { return false } if ( length === 2 && - segment.charCodeAt(0) === 46 && - segment.charCodeAt(1) === 46 + StringPrototypeCharCodeAt(segment, 0) === 46 && + StringPrototypeCharCodeAt(segment, 1) === 46 ) { return false } diff --git a/src/objects.ts b/src/objects.ts index 4b670f7..f35f2b3 100644 --- a/src/objects.ts +++ b/src/objects.ts @@ -3,9 +3,14 @@ * Provides object validation and recursive freezing utilities. */ -import { isObject } from '@socketsecurity/lib/objects' - import { LOOP_SENTINEL } from './constants.js' +import { + ArrayIsArray, + ObjectFreeze, + ObjectIsFrozen, + ReflectOwnKeys, + WeakSetCtor, +} from './primordials.js' /** * Recursively freeze an object and all nested objects. @@ -16,13 +21,13 @@ function recursiveFreeze(value_: T): T { if ( value_ === null || !(typeof value_ === 'object' || typeof value_ === 'function') || - Object.isFrozen(value_) + ObjectIsFrozen(value_) ) { return value_ } // Use breadth-first traversal to avoid stack overflow on deep objects const queue = [value_ as T & object] - const visited = new WeakSet() + const visited = new WeakSetCtor() visited.add(value_ as T & object) let { length: queueLength } = queue let pos = 0 @@ -32,15 +37,15 @@ function recursiveFreeze(value_: T): T { throw new Error('Object graph too large (exceeds 1,000,000 items).') } const obj = queue[pos++]! - Object.freeze(obj) - if (Array.isArray(obj)) { + ObjectFreeze(obj) + if (ArrayIsArray(obj)) { // Queue unfrozen array items for processing for (let i = 0, { length } = obj; i < length; i += 1) { const item: unknown = obj[i] if ( item !== null && (typeof item === 'object' || typeof item === 'function') && - !Object.isFrozen(item) && + !ObjectIsFrozen(item) && !visited.has(item as object) ) { visited.add(item as object) @@ -49,7 +54,7 @@ function recursiveFreeze(value_: T): T { } } else { // Queue unfrozen object properties for processing - const keys = Reflect.ownKeys(obj) + const keys = ReflectOwnKeys(obj) for (let i = 0, { length } = keys; i < length; i += 1) { const propValue: unknown = (obj as Record)[ keys[i]! @@ -57,7 +62,7 @@ function recursiveFreeze(value_: T): T { if ( propValue !== null && (typeof propValue === 'object' || typeof propValue === 'function') && - !Object.isFrozen(propValue) && + !ObjectIsFrozen(propValue) && !visited.has(propValue as object) ) { visited.add(propValue as object) @@ -69,4 +74,13 @@ function recursiveFreeze(value_: T): T { return value_ } +/** + * Check if value is a non-null object. + * Inlined to avoid importing @socketsecurity/lib/objects which transitively + * pulls in sorts → semver → npm-pack (2.5 MB). + */ +function isObject(value: unknown): value is { [key: PropertyKey]: unknown } { + return value !== null && typeof value === 'object' +} + export { isObject, recursiveFreeze } diff --git a/src/package-url-builder.ts b/src/package-url-builder.ts index b200b50..19d2a96 100644 --- a/src/package-url-builder.ts +++ b/src/package-url-builder.ts @@ -24,6 +24,7 @@ SOFTWARE. * @fileoverview Builder pattern implementation for PackageURL construction with fluent API. */ import { PackageURL } from './package-url.js' +import { ArrayPrototypeMap, ObjectEntries } from './primordials.js' import type { QualifiersObject } from './purl-component.js' @@ -138,7 +139,10 @@ export class PurlBuilder { */ qualifier(key: string, value: string): this { if (!this._qualifiers) { - this._qualifiers = {} + this._qualifiers = { __proto__: null } as unknown as Record< + string, + string + > } this._qualifiers[key] = value return this @@ -153,7 +157,10 @@ export class PurlBuilder { * - classifier: additional classifier for the package */ qualifiers(qualifiers: Record): this { - this._qualifiers = { ...qualifiers } + this._qualifiers = { __proto__: null, ...qualifiers } as unknown as Record< + string, + string + > return this } @@ -188,26 +195,69 @@ export class PurlBuilder { return this } + /** + * Create a builder with the bitbucket package type preset. + * + * @example `PurlBuilder.bitbucket().namespace('owner').name('repo').build()` + */ + static bitbucket(): PurlBuilder { + return new PurlBuilder().type('bitbucket') + } + /** * Create a builder with the cargo package type preset. * - * This convenience method creates a new builder instance with the type - * already set to 'cargo', ready for building Rust crate URLs. + * @example `PurlBuilder.cargo().name('serde').version('1.0.0').build()` */ static cargo(): PurlBuilder { return new PurlBuilder().type('cargo') } + /** + * Create a builder with the cocoapods package type preset. + * + * @example `PurlBuilder.cocoapods().name('Alamofire').version('5.9.1').build()` + */ + static cocoapods(): PurlBuilder { + return new PurlBuilder().type('cocoapods') + } + /** * Create a builder with the composer package type preset. * - * This convenience method creates a new builder instance with the type - * already set to 'composer', ready for building Composer package URLs. + * @example `PurlBuilder.composer().namespace('laravel').name('framework').build()` */ static composer(): PurlBuilder { return new PurlBuilder().type('composer') } + /** + * Create a builder with the conan package type preset. + * + * @example `PurlBuilder.conan().name('zlib').version('1.3.1').build()` + */ + static conan(): PurlBuilder { + return new PurlBuilder().type('conan') + } + + /** + * Create a builder with the conda package type preset. + * + * @example `PurlBuilder.conda().name('numpy').version('1.26.4').build()` + */ + static conda(): PurlBuilder { + return new PurlBuilder().type('conda') + } + + /** + * Create a builder with the cran package type preset. + * + * @example `PurlBuilder.cran().name('ggplot2').version('3.5.0').build()` + */ + static cran(): PurlBuilder { + return new PurlBuilder().type('cran') + } + /** * Create a new empty builder instance. * @@ -218,6 +268,24 @@ export class PurlBuilder { return new PurlBuilder() } + /** + * Create a builder with the deb package type preset. + * + * @example `PurlBuilder.deb().namespace('debian').name('curl').version('8.5.0').build()` + */ + static deb(): PurlBuilder { + return new PurlBuilder().type('deb') + } + + /** + * Create a builder with the docker package type preset. + * + * @example `PurlBuilder.docker().namespace('library').name('nginx').version('latest').build()` + */ + static docker(): PurlBuilder { + return new PurlBuilder().type('docker') + } + /** * Create a builder from an existing PackageURL instance. * @@ -241,11 +309,11 @@ export class PurlBuilder { if (purl.qualifiers !== undefined) { const qualifiersObj = purl.qualifiers as QualifiersObject builder._qualifiers = Object.fromEntries( - Object.entries(qualifiersObj).map(([key, value]) => [ + ArrayPrototypeMap(ObjectEntries(qualifiersObj), ([key, value]) => [ key, String(value), ]), - ) + ) as Record } if (purl.subpath !== undefined) { builder._subpath = purl.subpath @@ -256,37 +324,79 @@ export class PurlBuilder { /** * Create a builder with the gem package type preset. * - * This convenience method creates a new builder instance with the type - * already set to 'gem', ready for building Ruby gem URLs. - * - * @example - * ```typescript - * PurlBuilder.gem() - * .name('rails') - * .version('7.0.0') - * .build() - * // -> pkg:gem/rails@7.0.0 - * ``` + * @example `PurlBuilder.gem().name('rails').version('7.0.0').build()` */ static gem(): PurlBuilder { return new PurlBuilder().type('gem') } + /** + * Create a builder with the github package type preset. + * + * @example `PurlBuilder.github().namespace('socketdev').name('socket-cli').build()` + */ + static github(): PurlBuilder { + return new PurlBuilder().type('github') + } + + /** + * Create a builder with the gitlab package type preset. + * + * @example `PurlBuilder.gitlab().namespace('owner').name('project').build()` + */ + static gitlab(): PurlBuilder { + return new PurlBuilder().type('gitlab') + } + /** * Create a builder with the golang package type preset. * - * This convenience method creates a new builder instance with the type - * already set to 'golang', ready for building Go package URLs. + * @example `PurlBuilder.golang().namespace('github.com/go').name('text').build()` */ static golang(): PurlBuilder { return new PurlBuilder().type('golang') } + /** + * Create a builder with the hackage package type preset. + * + * @example `PurlBuilder.hackage().name('aeson').version('2.2.1.0').build()` + */ + static hackage(): PurlBuilder { + return new PurlBuilder().type('hackage') + } + + /** + * Create a builder with the hex package type preset. + * + * @example `PurlBuilder.hex().name('phoenix').version('1.7.12').build()` + */ + static hex(): PurlBuilder { + return new PurlBuilder().type('hex') + } + + /** + * Create a builder with the huggingface package type preset. + * + * @example `PurlBuilder.huggingface().name('bert-base-uncased').build()` + */ + static huggingface(): PurlBuilder { + return new PurlBuilder().type('huggingface') + } + + /** + * Create a builder with the luarocks package type preset. + * + * @example `PurlBuilder.luarocks().name('luasocket').version('3.1.0').build()` + */ + static luarocks(): PurlBuilder { + return new PurlBuilder().type('luarocks') + } + /** * Create a builder with the maven package type preset. * - * This convenience method creates a new builder instance with the type - * already set to 'maven', ready for building Maven package URLs. + * @example `PurlBuilder.maven().namespace('org.apache').name('commons-lang3').build()` */ static maven(): PurlBuilder { return new PurlBuilder().type('maven') @@ -295,8 +405,7 @@ export class PurlBuilder { /** * Create a builder with the npm package type preset. * - * This convenience method creates a new builder instance with the type - * already set to 'npm', ready for building npm package URLs. + * @example `PurlBuilder.npm().name('lodash').version('4.17.21').build()` */ static npm(): PurlBuilder { return new PurlBuilder().type('npm') @@ -305,20 +414,54 @@ export class PurlBuilder { /** * Create a builder with the nuget package type preset. * - * This convenience method creates a new builder instance with the type - * already set to 'nuget', ready for building NuGet package URLs. + * @example `PurlBuilder.nuget().name('Newtonsoft.Json').version('13.0.3').build()` */ static nuget(): PurlBuilder { return new PurlBuilder().type('nuget') } + /** + * Create a builder with the oci package type preset. + * + * @example `PurlBuilder.oci().name('nginx').version('sha256:abc123').build()` + */ + static oci(): PurlBuilder { + return new PurlBuilder().type('oci') + } + + /** + * Create a builder with the pub package type preset. + * + * @example `PurlBuilder.pub().name('flutter').version('3.19.0').build()` + */ + static pub(): PurlBuilder { + return new PurlBuilder().type('pub') + } + /** * Create a builder with the pypi package type preset. * - * This convenience method creates a new builder instance with the type - * already set to 'pypi', ready for building Python package URLs. + * @example `PurlBuilder.pypi().name('requests').version('2.31.0').build()` */ static pypi(): PurlBuilder { return new PurlBuilder().type('pypi') } + + /** + * Create a builder with the rpm package type preset. + * + * @example `PurlBuilder.rpm().namespace('fedora').name('curl').version('8.5.0').build()` + */ + static rpm(): PurlBuilder { + return new PurlBuilder().type('rpm') + } + + /** + * Create a builder with the swift package type preset. + * + * @example `PurlBuilder.swift().namespace('apple').name('swift-nio').version('2.64.0').build()` + */ + static swift(): PurlBuilder { + return new PurlBuilder().type('swift') + } } diff --git a/src/package-url.ts b/src/package-url.ts index db44a47..97435ef 100644 --- a/src/package-url.ts +++ b/src/package-url.ts @@ -45,14 +45,38 @@ import { normalizeVersion, } from './normalize.js' import { isObject, recursiveFreeze } from './objects.js' +import { + ArrayIsArray, + ArrayPrototypeAt, + JSONParse, + MapCtor, + ObjectFreeze, + JSONStringify, + ReflectDefineProperty, + ReflectGetOwnPropertyDescriptor, + ReflectSetPrototypeOf, + RegExpPrototypeTest, + StringPrototypeCharCodeAt, + StringPrototypeIncludes, + StringPrototypeIndexOf, + StringPrototypeLastIndexOf, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeStartsWith, + URLCtor, + URLSearchParamsCtor, +} from './primordials.js' import { PurlComponent } from './purl-component.js' import { PurlQualifierNames } from './purl-qualifier-names.js' import { PurlType } from './purl-type.js' import { parseNpmSpecifier } from './purl-types/npm.js' import { Err, Ok, ResultUtils, err, ok } from './result.js' -import { stringify } from './stringify.js' +import { stringify, stringifySpec } from './stringify.js' import { isBlank, isNonEmptyString, trimLeadingSlashes } from './strings.js' -import { UrlConverter } from './url-converter.js' +import { + UrlConverter, + _registerPackageURLForUrlConverter, +} from './url-converter.js' import { validateName, validateNamespace, @@ -99,13 +123,18 @@ export type ParsedPurlComponents = [ subpath: string | undefined, ] +// LRU flyweight cache for fromString — avoids re-parsing identical PURL strings. +// Bounded to prevent memory leaks. Uses a Map for O(1) lookup with LRU eviction. +const FLYWEIGHT_CACHE_MAX = 1024 +const flyweightCache = new MapCtor() + // Pattern to match URLs with schemes other than "pkg" // Limited to 256 chars for scheme to prevent ReDoS -const OTHER_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]{0,255}:\/\// +const OTHER_SCHEME_PATTERN = ObjectFreeze(/^[a-zA-Z][a-zA-Z0-9+.-]{0,255}:\/\//) // Pattern to match purl-like strings with type/name format // Limited to 256 chars for type to prevent ReDoS -const PURL_LIKE_PATTERN = /^[a-zA-Z0-9+.-]{1,256}\// +const PURL_LIKE_PATTERN = ObjectFreeze(/^[a-zA-Z0-9+.-]{1,256}\//) /** * Package URL parser and constructor implementing the PURL specification. @@ -116,6 +145,9 @@ class PackageURL { static KnownQualifierNames = recursiveFreeze(PurlQualifierNames) static Type = recursiveFreeze(PurlType) + /** @internal Cached canonical string representation. */ + _cachedString?: string | undefined + name?: string | undefined namespace?: string | undefined qualifiers?: QualifiersObject | undefined @@ -184,7 +216,7 @@ class PackageURL { } /** - * Convert PackageURL to object for JSON.stringify compatibility. + * Convert PackageURL to object for JSONStringify compatibility. */ toJSON(): PackageURLObject { return this.toObject() @@ -194,14 +226,14 @@ class PackageURL { * Convert PackageURL to JSON string representation. */ toJSONString(): string { - return JSON.stringify(this.toObject()) + return JSONStringify(this.toObject()) } /** * Convert PackageURL to a plain object representation. */ toObject(): PackageURLObject { - const result: PackageURLObject = {} + const result: PackageURLObject = { __proto__: null } as PackageURLObject if (this.type !== undefined) { result.type = this.type } @@ -223,8 +255,120 @@ class PackageURL { return result } + /** + * Get the package specifier string without the scheme and type prefix. + * + * Returns `namespace/name@version?qualifiers#subpath` — the package identity + * without the `pkg:type/` prefix. + * + * @returns Spec string (e.g., '@babel/core@7.0.0' for pkg:npm/%40babel/core@7.0.0) + */ + toSpec() { + return stringifySpec(this) + } + toString() { - return stringify(this) + let cached = this._cachedString + if (cached === undefined) { + cached = stringify(this) + this._cachedString = cached + } + return cached + } + + /** + * Create a new PackageURL with a different version. + * Returns a new instance — the original is unchanged. + * + * @param version - New version string + * @returns New PackageURL with the updated version + */ + withVersion(version: string | undefined): PackageURL { + return new PackageURL( + this.type, + this.namespace, + this.name, + version, + this.qualifiers, + this.subpath, + ) + } + + /** + * Create a new PackageURL with a different namespace. + * Returns a new instance — the original is unchanged. + * + * @param namespace - New namespace string + * @returns New PackageURL with the updated namespace + */ + withNamespace(namespace: string | undefined): PackageURL { + return new PackageURL( + this.type, + namespace, + this.name, + this.version, + this.qualifiers, + this.subpath, + ) + } + + /** + * Create a new PackageURL with a single qualifier added or updated. + * Returns a new instance — the original is unchanged. + * + * @param key - Qualifier key + * @param value - Qualifier value + * @returns New PackageURL with the qualifier set + */ + withQualifier(key: string, value: string): PackageURL { + return new PackageURL( + this.type, + this.namespace, + this.name, + this.version, + { + __proto__: null, + ...this.qualifiers, + [key]: value, + } as unknown as Record, + this.subpath, + ) + } + + /** + * Create a new PackageURL with all qualifiers replaced. + * Returns a new instance — the original is unchanged. + * + * @param qualifiers - New qualifiers object (or undefined to remove all) + * @returns New PackageURL with the updated qualifiers + */ + withQualifiers(qualifiers: Record | undefined): PackageURL { + return new PackageURL( + this.type, + this.namespace, + this.name, + this.version, + qualifiers, + this.subpath, + ) + } + + /** + * Create a new PackageURL with a different subpath. + * Returns a new instance — the original is unchanged. + * + * @param subpath - New subpath string + * @returns New PackageURL with the updated subpath + */ + withSubpath(subpath: string | undefined): PackageURL { + return new PackageURL( + this.type, + this.namespace, + this.name, + this.version, + this.qualifiers, + subpath, + ) } /** @@ -305,7 +449,7 @@ class PackageURL { let parsed: unknown try { - parsed = JSON.parse(json) + parsed = JSONParse(json) } catch (e) { // For JSON parsing errors, throw a SyntaxError with the expected message throw new SyntaxError('Failed to parse PackageURL from JSON', { @@ -314,7 +458,7 @@ class PackageURL { } // Validate parsed result is an object - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + if (!parsed || typeof parsed !== 'object' || ArrayIsArray(parsed)) { throw new Error('JSON must parse to an object.') } @@ -356,7 +500,26 @@ class PackageURL { } static fromString(purlStr: unknown): PackageURL { - return new PackageURL(...PackageURL.parseString(purlStr)) + // Flyweight: return cached instance for identical strings + if (typeof purlStr === 'string') { + const cached = flyweightCache.get(purlStr) + if (cached !== undefined) { + return cached + } + } + const purl = new PackageURL(...PackageURL.parseString(purlStr)) + // Cache the result for future lookups + if (typeof purlStr === 'string') { + if (flyweightCache.size >= FLYWEIGHT_CACHE_MAX) { + // Evict oldest entry (first key in Map iteration order) + const oldest = flyweightCache.keys().next().value + if (oldest !== undefined) { + flyweightCache.delete(oldest) + } + } + flyweightCache.set(purlStr, purl) + } + return purl } /** @@ -472,11 +635,11 @@ class PackageURL { } // If the string doesn't start with "pkg:" but looks like a purl format, prepend "pkg:" and try parsing - if (!purlStr.startsWith('pkg:')) { + if (!StringPrototypeStartsWith(purlStr, 'pkg:')) { // Only auto-prepend "pkg:" if the string looks like a purl (contains a type/name pattern) // and doesn't look like a URL with a different scheme - const hasOtherScheme = OTHER_SCHEME_PATTERN.test(purlStr) - const looksLikePurl = PURL_LIKE_PATTERN.test(purlStr) + const hasOtherScheme = RegExpPrototypeTest(OTHER_SCHEME_PATTERN, purlStr) + const looksLikePurl = RegExpPrototypeTest(PURL_LIKE_PATTERN, purlStr) if (!hasOtherScheme && looksLikePurl) { return PackageURL.parseString(`pkg:${purlStr}`) @@ -484,7 +647,7 @@ class PackageURL { } // Split the remainder once from left on ':' - const colonIndex = purlStr.indexOf(':') + const colonIndex = StringPrototypeIndexOf(purlStr, ':') // Use WHATWG URL to split up the purl string /* c8 ignore next 3 -- Comment lines don't need coverage. */ // - Split the purl string once from right on '#' @@ -498,10 +661,10 @@ class PackageURL { // must not be suffixed with double slash as in 'pkg://' // and should use instead 'pkg:'. Purl parsers must accept // URLs such as 'pkg://' and must ignore the '//' - const beforeColon = purlStr.slice(0, colonIndex) - const afterColon = purlStr.slice(colonIndex + 1) + const beforeColon = StringPrototypeSlice(purlStr, 0, colonIndex) + const afterColon = StringPrototypeSlice(purlStr, colonIndex + 1) const trimmedAfterColon = trimLeadingSlashes(afterColon) - url = new URL(`${beforeColon}:${trimmedAfterColon}`) + url = new URLCtor(`${beforeColon}:${trimmedAfterColon}`) // Check for auth (user:pass@host) without creating a second URL. // When leading slashes were trimmed, the original string had an authority // section (e.g., pkg://user:pass@host/...). Detect `@` in the authority @@ -509,13 +672,17 @@ class PackageURL { /* c8 ignore next 8 -- V8 coverage sees multiple branch paths that can't all be tested. */ if (afterColon.length !== trimmedAfterColon.length) { // afterColon starts with slashes — find the authority section - const authorityStart = afterColon.indexOf('//') + 2 - const authorityEnd = afterColon.indexOf('/', authorityStart) + const authorityStart = StringPrototypeIndexOf(afterColon, '//') + 2 + const authorityEnd = StringPrototypeIndexOf( + afterColon, + '/', + authorityStart, + ) const authority = authorityEnd === -1 - ? afterColon.slice(authorityStart) - : afterColon.slice(authorityStart, authorityEnd) - hasAuth = authority.includes('@') + ? StringPrototypeSlice(afterColon, authorityStart) + : StringPrototypeSlice(afterColon, authorityStart, authorityEnd) + hasAuth = StringPrototypeIncludes(authority, '@') } } catch (e) { throw new PurlError('failed to parse as URL', { @@ -537,10 +704,12 @@ class PackageURL { } const { pathname } = url - const firstSlashIndex = pathname.indexOf('/') + const firstSlashIndex = StringPrototypeIndexOf(pathname, '/') const rawType = decodePurlComponent( 'type', - firstSlashIndex === -1 ? pathname : pathname.slice(0, firstSlashIndex), + firstSlashIndex === -1 + ? pathname + : StringPrototypeSlice(pathname, 0, firstSlashIndex), ) if (firstSlashIndex < 1) { return [rawType, undefined, undefined, undefined, undefined, undefined] @@ -553,18 +722,19 @@ class PackageURL { // pnpm ids such as 'pkg:npm/next@14.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)' let atSignIndex = rawType === 'npm' - ? pathname.indexOf('@', firstSlashIndex + 2) - : pathname.lastIndexOf('@') + ? StringPrototypeIndexOf(pathname, '@', firstSlashIndex + 2) + : StringPrototypeLastIndexOf(pathname, '@') /* c8 ignore stop */ // When a forward slash ('/') is directly preceding an '@' symbol, // then the '@' symbol is NOT considered a version separator if ( atSignIndex > 0 && - pathname.charCodeAt(atSignIndex - 1) === 47 /*'/'*/ + StringPrototypeCharCodeAt(pathname, atSignIndex - 1) === 47 /*'/'*/ ) { atSignIndex = -1 } - const beforeVersion = pathname.slice( + const beforeVersion = StringPrototypeSlice( + pathname, rawType.length + 1, atSignIndex === -1 ? pathname.length : atSignIndex, ) @@ -572,13 +742,13 @@ class PackageURL { // Split the remainder once from right on '@' rawVersion = decodePurlComponent( 'version', - pathname.slice(atSignIndex + 1), + StringPrototypeSlice(pathname, atSignIndex + 1), ) } let rawNamespace: string | undefined let rawName: string - const lastSlashIndex = beforeVersion.lastIndexOf('/') + const lastSlashIndex = StringPrototypeLastIndexOf(beforeVersion, '/') if (lastSlashIndex === -1) { // Split the remainder once from right on '/' rawName = decodePurlComponent('name', beforeVersion) @@ -586,29 +756,32 @@ class PackageURL { // Split the remainder once from right on '/' rawName = decodePurlComponent( 'name', - beforeVersion.slice(lastSlashIndex + 1), + StringPrototypeSlice(beforeVersion, lastSlashIndex + 1), ) // Split the remainder on '/' rawNamespace = decodePurlComponent( 'namespace', - beforeVersion.slice(0, lastSlashIndex), + StringPrototypeSlice(beforeVersion, 0, lastSlashIndex), ) } let rawQualifiers: URLSearchParams | undefined if (url.searchParams.size !== 0) { - const search = url.search.slice(1) - const searchParams = new URLSearchParams() - const entries = search.split('&') + const search = StringPrototypeSlice(url.search, 1) + const searchParams = new URLSearchParamsCtor() + const entries = StringPrototypeSplit(search, '&' as any) for (let i = 0, { length } = entries; i < length; i += 1) { - const pairs = entries[i]?.split('=') + const pairs = StringPrototypeSplit(entries[i]!, '=' as any) if (pairs) { const key = pairs[0]! // Validate qualifier key is not empty (reject malformed PURLs like ?&key=val or ?key=val&) if (key.length === 0) { throw new PurlError('qualifier key must not be empty') } - const value = decodePurlComponent('qualifiers', pairs.at(1) ?? '') + const value = decodePurlComponent( + 'qualifiers', + ArrayPrototypeAt(pairs, 1) ?? '', + ) // Use URLSearchParams#append to preserve plus signs // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs /* c8 ignore next -- URLSearchParams.append has internal V8 branches we can't control. */ searchParams.append( @@ -625,7 +798,7 @@ class PackageURL { const { hash } = url if (hash.length !== 0) { // Split the purl string once from right on '#' - rawSubpath = decodePurlComponent('subpath', hash.slice(1)) + rawSubpath = decodePurlComponent('subpath', StringPrototypeSlice(hash, 1)) } return [ @@ -638,6 +811,44 @@ class PackageURL { ] } + /** + * Check if a string is a valid PURL without throwing. + * + * @param purlStr - String to validate + * @returns true if the string is a valid PURL + * + * @example + * ```typescript + * PackageURL.isValid('pkg:npm/lodash@4.17.21') // true + * PackageURL.isValid('not a purl') // false + * ``` + */ + static isValid(purlStr: unknown): boolean { + return PackageURL.tryFromString(purlStr).isOk() + } + + /** + * Create PackageURL from a registry or repository URL. + * + * Convenience wrapper for UrlConverter.fromUrl(). Supports 27 hostnames + * across 17 package types including npm, pypi, maven, github, and more. + * + * @param urlStr - Registry or repository URL + * @returns PackageURL instance or undefined if URL is not recognized + * + * @example + * ```typescript + * PackageURL.fromUrl('https://www.npmjs.com/package/lodash') + * // -> pkg:npm/lodash + * + * PackageURL.fromUrl('https://github.com/lodash/lodash') + * // -> pkg:github/lodash/lodash + * ``` + */ + static fromUrl(urlStr: string): PackageURL | undefined { + return UrlConverter.fromUrl(urlStr) + } + static tryFromJSON(json: unknown): Result { return ResultUtils.from(() => PackageURL.fromJSON(json)) } @@ -656,17 +867,20 @@ class PackageURL { } for (const staticProp of ['Component', 'KnownQualifierNames', 'Type']) { - Reflect.defineProperty(PackageURL, staticProp, { - ...Reflect.getOwnPropertyDescriptor(PackageURL, staticProp), + ReflectDefineProperty(PackageURL, staticProp, { + ...ReflectGetOwnPropertyDescriptor(PackageURL, staticProp), writable: false, }) } -Reflect.setPrototypeOf(PackageURL.prototype, null) +ReflectSetPrototypeOf(PackageURL.prototype, null) // Register PackageURL with compare module for string-based comparison support. _registerPackageURL(PackageURL) +// Register PackageURL with url-converter module for fromUrl construction. +_registerPackageURLForUrlConverter(PackageURL) + export { Err, Ok, diff --git a/src/primordials.ts b/src/primordials.ts new file mode 100644 index 0000000..43b7d4c --- /dev/null +++ b/src/primordials.ts @@ -0,0 +1,157 @@ +/** + * @fileoverview Safe references to built-in functions and constructors. + * + * Captures references to JavaScript built-ins at module load time, before + * user code can tamper with prototypes or globals. All consumers should + * import from this module instead of using globals directly. + * + * Follows Node.js internal primordials conventions: + * - Static methods: ObjectKeys, ArrayIsArray, JSONParse, etc. + * - Prototype methods: StringPrototypeSlice, ArrayPrototypePush, etc. + * - Constructors: MapCtor, SetCtor, URLCtor, etc. + * + * IMPORTANT: Do not use destructuring on globalThis or Reflect here. + * tsgo has a bug that incorrectly transpiles destructured exports. + * See: https://github.com/SocketDev/socket-packageurl-js/issues/3 + * + * @see https://github.com/nicolo-ribaudo/tc39-proposal-primordials + * @see https://github.com/nicolo-ribaudo/tc39-proposal-primordials/blob/main/polyfill.mjs + * @see https://github.com/nicolo-ribaudo/tc39-proposal-primordials/blob/main/polyfill.js + * @see https://github.com/nicolo-ribaudo/tc39-proposal-primordials/blob/main/README.md + * @see https://github.com/nicolo-ribaudo/tc39-proposal-primordials/blob/main/playground.mjs + * @see https://github.com/nicolo-ribaudo/tc39-proposal-primordials/blob/main/tests.mjs + */ + +// ─── uncurryThis ─────────────────────────────────────────────────────── +// Mirrors Node.js internal/per_context/primordials.js: +// const { apply, bind, call } = Function.prototype +// const uncurryThis = bind.bind(call) +const { apply, bind, call } = Function.prototype +const uncurryThis = bind.bind(call) as ( + fn: (this: T, ...args: A) => R, +) => (self: T, ...args: A) => R +const applyBind = bind.bind(apply) as ( + fn: (this: T, ...args: A) => R, +) => (self: T, args: A) => R + +// ─── Constructors ────────────────────────────────────────────────────── +const MapCtor: MapConstructor = Map +const SetCtor: SetConstructor = Set +const URLCtor: typeof URL = URL +const URLSearchParamsCtor: typeof URLSearchParams = URLSearchParams +const WeakSetCtor: WeakSetConstructor = WeakSet + +// ─── Global functions ────────────────────────────────────────────────── +const encodeComponent = globalThis.encodeURIComponent +const decodeComponent = globalThis.decodeURIComponent + +// ─── JSON ────────────────────────────────────────────────────────────── +const JSONParse = JSON.parse +const JSONStringify = JSON.stringify + +// ─── Object ──────────────────────────────────────────────────────────── +const ObjectCreate = Object.create +const ObjectEntries = Object.entries +const ObjectFreeze = Object.freeze +const ObjectIsFrozen = Object.isFrozen +const ObjectKeys = Object.keys +const ObjectValues = Object.values + +// ─── Array ───────────────────────────────────────────────────────────── +const ArrayIsArray = Array.isArray +const ArrayPrototypeAt = uncurryThis(Array.prototype.at) +const ArrayPrototypeFilter = uncurryThis(Array.prototype.filter) +const ArrayPrototypeFlatMap = uncurryThis(Array.prototype.flatMap) +const ArrayPrototypeIncludes = uncurryThis(Array.prototype.includes) +const ArrayPrototypeJoin = uncurryThis(Array.prototype.join) +const ArrayPrototypeMap = uncurryThis(Array.prototype.map) +const ArrayPrototypePush = uncurryThis(Array.prototype.push) as ( + self: T[], + ...items: T[] +) => number +const ArrayPrototypeSlice = uncurryThis(Array.prototype.slice) +const ArrayPrototypeSome = uncurryThis(Array.prototype.some) +const ArrayPrototypeToSorted = uncurryThis(Array.prototype.toSorted) + +// ─── Reflect ─────────────────────────────────────────────────────────── +const ReflectApply = Reflect.apply +const ReflectDefineProperty = Reflect.defineProperty +const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor +const ReflectOwnKeys = Reflect.ownKeys +const ReflectSetPrototypeOf = Reflect.setPrototypeOf + +// ─── RegExp ──────────────────────────────────────────────────────────── +const RegExpPrototypeExec = uncurryThis(RegExp.prototype.exec) +const RegExpPrototypeTest = uncurryThis(RegExp.prototype.test) + +// ─── String ──────────────────────────────────────────────────────────── +const StringPrototypeCharCodeAt = uncurryThis(String.prototype.charCodeAt) +const StringPrototypeEndsWith = uncurryThis(String.prototype.endsWith) +const StringPrototypeIncludes = uncurryThis(String.prototype.includes) +const StringPrototypeIndexOf = uncurryThis(String.prototype.indexOf) +const StringPrototypeLastIndexOf = uncurryThis(String.prototype.lastIndexOf) +const StringPrototypeReplace = uncurryThis(String.prototype.replace) +const StringPrototypeReplaceAll = uncurryThis( + String.prototype.replaceAll as ( + this: string, + searchValue: string, + replaceValue: string, + ) => string, +) +const StringPrototypeSlice = uncurryThis(String.prototype.slice) +const StringPrototypeSplit = uncurryThis(String.prototype.split) +const StringPrototypeStartsWith = uncurryThis(String.prototype.startsWith) +const StringPrototypeToLowerCase = uncurryThis(String.prototype.toLowerCase) +const StringPrototypeToUpperCase = uncurryThis(String.prototype.toUpperCase) +const StringPrototypeTrim = uncurryThis(String.prototype.trim) + +export { + applyBind, + ArrayIsArray, + ArrayPrototypeAt, + ArrayPrototypeFilter, + ArrayPrototypeFlatMap, + ArrayPrototypeIncludes, + ArrayPrototypeJoin, + ArrayPrototypeMap, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSome, + ArrayPrototypeToSorted, + decodeComponent, + encodeComponent, + JSONParse, + JSONStringify, + MapCtor, + ObjectCreate, + ObjectEntries, + ObjectFreeze, + ObjectIsFrozen, + ObjectKeys, + ObjectValues, + ReflectApply, + ReflectDefineProperty, + ReflectGetOwnPropertyDescriptor, + ReflectOwnKeys, + ReflectSetPrototypeOf, + RegExpPrototypeExec, + RegExpPrototypeTest, + SetCtor, + StringPrototypeCharCodeAt, + StringPrototypeEndsWith, + StringPrototypeIncludes, + StringPrototypeIndexOf, + StringPrototypeLastIndexOf, + StringPrototypeReplace, + StringPrototypeReplaceAll, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeStartsWith, + StringPrototypeToLowerCase, + StringPrototypeToUpperCase, + StringPrototypeTrim, + uncurryThis, + URLCtor, + URLSearchParamsCtor, + WeakSetCtor, +} diff --git a/src/purl-qualifier-names.ts b/src/purl-qualifier-names.ts index 862b6fc..760555a 100644 --- a/src/purl-qualifier-names.ts +++ b/src/purl-qualifier-names.ts @@ -7,11 +7,12 @@ // https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#known-qualifiers-keyvalue-pairs const PurlQualifierNames = { __proto__: null, - RepositoryUrl: 'repository_url', + Checksum: 'checksum', DownloadUrl: 'download_url', - VcsUrl: 'vcs_url', FileName: 'file_name', - Checksum: 'checksum', + RepositoryUrl: 'repository_url', + VcsUrl: 'vcs_url', + Vers: 'vers', } export { PurlQualifierNames } diff --git a/src/purl-type.ts b/src/purl-type.ts index 134d451..c1730e0 100644 --- a/src/purl-type.ts +++ b/src/purl-type.ts @@ -69,7 +69,10 @@ import { normalize as socketNormalize } from './purl-types/socket.js' import { validate as swidValidate } from './purl-types/swid.js' import { validate as swiftValidate } from './purl-types/swift.js' import { normalize as unknownNormalize } from './purl-types/unknown.js' -import { normalize as vscodeExtensionNormalize } from './purl-types/vscode-extension.js' +import { + normalize as vscodeExtensionNormalize, + validate as vscodeExtensionValidate, +} from './purl-types/vscode-extension.js' import { normalize as yoctoNormalize, validate as yoctoValidate, @@ -149,6 +152,7 @@ const PurlType = createHelpersNamespaceObject( pub: pubValidate, swift: swiftValidate, swid: swidValidate, + 'vscode-extension': vscodeExtensionValidate, yocto: yoctoValidate, }, }, diff --git a/src/purl-types/cargo.ts b/src/purl-types/cargo.ts index 5875910..5089858 100644 --- a/src/purl-types/cargo.ts +++ b/src/purl-types/cargo.ts @@ -5,6 +5,7 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' import { validateEmptyByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -88,7 +89,10 @@ export async function cargoExists( // If specific version requested, validate it exists if (version && data.versions) { - const versionExists = data.versions.some(v => v.num === version) + const versionExists = ArrayPrototypeSome( + data.versions, + v => v.num === version, + ) if (!versionExists) { const result: ExistsResult = { exists: false, @@ -114,7 +118,9 @@ export async function cargoExists( /* c8 ignore stop */ return { exists: false, - error: error.includes('404') ? 'Crate not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Crate not found' + : error, } } } diff --git a/src/purl-types/cocoapods.ts b/src/purl-types/cocoapods.ts index 617a575..3615042 100644 --- a/src/purl-types/cocoapods.ts +++ b/src/purl-types/cocoapods.ts @@ -6,6 +6,12 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { PurlError } from '../error.js' +import { + RegExpPrototypeTest, + StringPrototypeCharCodeAt, + StringPrototypeIncludes, + ArrayPrototypeSome, +} from '../primordials.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -76,7 +82,10 @@ export async function cocoapodsExists( const latestVersion = versions[0]?.['name'] if (version) { - const versionExists = versions.some(v => v.name === version) + const versionExists = ArrayPrototypeSome( + versions, + v => v.name === version, + ) if (!versionExists) { const result: ExistsResult = { exists: false, @@ -99,7 +108,7 @@ export async function cocoapodsExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Pod not found' : error, + error: StringPrototypeIncludes(error, '404') ? 'Pod not found' : error, } } } @@ -121,7 +130,7 @@ export async function cocoapodsExists( export function validate(purl: PurlObject, throws: boolean): boolean { const { name } = purl // Name cannot contain whitespace - if (/\s/.test(name)) { + if (RegExpPrototypeTest(/\s/, name)) { if (throws) { throw new PurlError( 'cocoapods "name" component cannot contain whitespace', @@ -130,7 +139,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } // Name cannot contain a plus (+) character - if (name.includes('+')) { + if (StringPrototypeIncludes(name, '+')) { if (throws) { throw new PurlError( 'cocoapods "name" component cannot contain a plus (+) character', @@ -139,7 +148,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } // Name cannot begin with a period (.) - if (name.charCodeAt(0) === 46 /*'.'*/) { + if (StringPrototypeCharCodeAt(name, 0) === 46 /*'.'*/) { if (throws) { throw new PurlError( 'cocoapods "name" component cannot begin with a period', diff --git a/src/purl-types/composer.ts b/src/purl-types/composer.ts index 365e3d8..f8ae049 100644 --- a/src/purl-types/composer.ts +++ b/src/purl-types/composer.ts @@ -5,6 +5,7 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' import { lowerName, lowerNamespace } from '../strings.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -97,14 +98,15 @@ export async function packagistExists( let latestVersion: string | undefined for (const pkg of packageVersions) { const ver = pkg.version - if (ver && !ver.includes('dev-')) { + if (ver && !StringPrototypeIncludes(ver, 'dev-')) { latestVersion = ver break } } if (version) { - const versionExists = packageVersions.some( + const versionExists = ArrayPrototypeSome( + packageVersions, pkg => pkg.version === version, ) if (!versionExists) { @@ -129,7 +131,9 @@ export async function packagistExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } diff --git a/src/purl-types/conda.ts b/src/purl-types/conda.ts index 88b8aff..2cd73b6 100644 --- a/src/purl-types/conda.ts +++ b/src/purl-types/conda.ts @@ -5,6 +5,10 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { + ArrayPrototypeIncludes, + StringPrototypeIncludes, +} from '../primordials.js' import { lowerName } from '../strings.js' import { validateEmptyByType } from '../validate.js' @@ -115,7 +119,7 @@ export async function condaExists( // If specific version requested, validate it exists if (version) { - if (!data.versions || !data.versions.includes(version)) { + if (!data.versions || !ArrayPrototypeIncludes(data.versions, version)) { const result: ExistsResult = { exists: false, error: `Version ${version} not found`, @@ -140,7 +144,9 @@ export async function condaExists( /* c8 ignore stop */ return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } diff --git a/src/purl-types/cpan.ts b/src/purl-types/cpan.ts index 1582109..a8ce33f 100644 --- a/src/purl-types/cpan.ts +++ b/src/purl-types/cpan.ts @@ -6,6 +6,10 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { PurlError } from '../error.js' +import { + StringPrototypeIncludes, + StringPrototypeToUpperCase, +} from '../primordials.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -96,7 +100,9 @@ export async function cpanExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Module not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Module not found' + : error, } } } @@ -117,7 +123,7 @@ export async function cpanExists( */ export function validate(purl: PurlObject, throws: boolean): boolean { const { namespace } = purl - if (namespace && namespace !== namespace.toUpperCase()) { + if (namespace && namespace !== StringPrototypeToUpperCase(namespace)) { if (throws) { throw new PurlError('cpan "namespace" component must be UPPERCASE') } diff --git a/src/purl-types/cran.ts b/src/purl-types/cran.ts index a0f4802..ea39c3d 100644 --- a/src/purl-types/cran.ts +++ b/src/purl-types/cran.ts @@ -5,6 +5,10 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { + ArrayPrototypeIncludes, + StringPrototypeIncludes, +} from '../primordials.js' import { validateRequiredByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -74,7 +78,7 @@ export async function cranExists( if (version) { const versions = data.versions || [] - if (!versions.includes(version)) { + if (!ArrayPrototypeIncludes(versions, version)) { const result: ExistsResult = { exists: false, error: `Version ${version} not found`, @@ -96,7 +100,9 @@ export async function cranExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } diff --git a/src/purl-types/docker.ts b/src/purl-types/docker.ts index cd6bb50..aa7317c 100644 --- a/src/purl-types/docker.ts +++ b/src/purl-types/docker.ts @@ -5,6 +5,7 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { StringPrototypeIncludes } from '../primordials.js' import { lowerName } from '../strings.js' import type { ExistsOptions, ExistsResult } from './npm.js' @@ -116,7 +117,9 @@ export async function dockerExists( /* c8 ignore stop */ return { exists: false, - error: error.includes('404') ? `Tag ${version} not found` : error, + error: StringPrototypeIncludes(error, '404') + ? `Tag ${version} not found` + : error, } } } @@ -131,7 +134,9 @@ export async function dockerExists( /* c8 ignore stop */ return { exists: false, - error: error.includes('404') ? 'Image not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Image not found' + : error, } } } diff --git a/src/purl-types/gem.ts b/src/purl-types/gem.ts index bde4c55..28002eb 100644 --- a/src/purl-types/gem.ts +++ b/src/purl-types/gem.ts @@ -5,6 +5,7 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' import { validateEmptyByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -85,7 +86,10 @@ export async function gemExists( // If specific version requested, validate it exists if (version) { - const versionExists = data.some(v => v.number === version) + const versionExists = ArrayPrototypeSome( + data, + v => v.number === version, + ) if (!versionExists) { const result: ExistsResult = { exists: false, @@ -110,7 +114,7 @@ export async function gemExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Gem not found' : error, + error: StringPrototypeIncludes(error, '404') ? 'Gem not found' : error, } } } diff --git a/src/purl-types/golang.ts b/src/purl-types/golang.ts index 8e399e0..0b40490 100644 --- a/src/purl-types/golang.ts +++ b/src/purl-types/golang.ts @@ -31,6 +31,15 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { PurlError } from '../error.js' +import { + ArrayPrototypeJoin, + StringPrototypeCharCodeAt, + StringPrototypeIncludes, + StringPrototypeReplace, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeToLowerCase, +} from '../primordials.js' import { isSemverString } from '../strings.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -96,12 +105,15 @@ export async function golangExists( try { // Encode the module path for the URL // Go proxy uses case-encoded paths where uppercase letters are !lowercase - const encodedPath = modulePath - .split('/') - .map(part => { - return part.replace(/[A-Z]/g, letter => `!${letter.toLowerCase()}`) - }) - .join('/') + const parts = StringPrototypeSplit(modulePath, '/' as any) + for (let i = 0; i < parts.length; i++) { + parts[i] = StringPrototypeReplace( + parts[i]!, + /[A-Z]/g, + letter => `!${StringPrototypeToLowerCase(letter)}`, + ) + } + const encodedPath = ArrayPrototypeJoin(parts, '/') const url = `https://proxy.golang.org/${encodedPath}/@latest` @@ -139,7 +151,8 @@ export async function golangExists( return { exists: false, error: - error.includes('404') || error.includes('410') + StringPrototypeIncludes(error, '404') || + StringPrototypeIncludes(error, '410') ? 'Module not found' : error, } @@ -167,8 +180,8 @@ export function validate(purl: PurlObject, throws: boolean): boolean { // https://go.dev/doc/modules/version-numbers#pseudo-version-number if ( length && - version?.charCodeAt(0) === 118 /*'v'*/ && - !isSemverString(version?.slice(1)) + StringPrototypeCharCodeAt(version!, 0) === 118 /*'v'*/ && + !isSemverString(StringPrototypeSlice(version!, 1)) ) { if (throws) { throw new PurlError( diff --git a/src/purl-types/hackage.ts b/src/purl-types/hackage.ts index bf0330a..4afedb8 100644 --- a/src/purl-types/hackage.ts +++ b/src/purl-types/hackage.ts @@ -5,6 +5,11 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { + ArrayPrototypeIncludes, + StringPrototypeIncludes, +} from '../primordials.js' + import type { ExistsResult, ExistsOptions } from './npm.js' /** @@ -65,7 +70,7 @@ export async function hackageExists( const latestVersion = versions[versions.length - 1] if (version) { - if (!versions.includes(version)) { + if (!ArrayPrototypeIncludes(versions, version)) { const result: ExistsResult = { exists: false, error: `Version ${version} not found`, @@ -87,7 +92,9 @@ export async function hackageExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } diff --git a/src/purl-types/hex.ts b/src/purl-types/hex.ts index 3a5bdda..ee1dbcd 100644 --- a/src/purl-types/hex.ts +++ b/src/purl-types/hex.ts @@ -5,6 +5,7 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' import { lowerName, lowerNamespace } from '../strings.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -73,7 +74,10 @@ export async function hexExists( if (version) { const releases = data.releases || [] - const versionExists = releases.some(r => r.version === version) + const versionExists = ArrayPrototypeSome( + releases, + r => r.version === version, + ) if (!versionExists) { const result: ExistsResult = { exists: false, @@ -96,7 +100,9 @@ export async function hexExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } diff --git a/src/purl-types/maven.ts b/src/purl-types/maven.ts index ea66a2b..e4c5067 100644 --- a/src/purl-types/maven.ts +++ b/src/purl-types/maven.ts @@ -5,6 +5,7 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { StringPrototypeIncludes } from '../primordials.js' import { validateRequiredByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -116,7 +117,9 @@ export async function mavenExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } diff --git a/src/purl-types/mlflow.ts b/src/purl-types/mlflow.ts index 5f5fb48..78a11f8 100644 --- a/src/purl-types/mlflow.ts +++ b/src/purl-types/mlflow.ts @@ -3,6 +3,7 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow */ +import { StringPrototypeIncludes } from '../primordials.js' import { lowerName } from '../strings.js' import { validateEmptyByType } from '../validate.js' @@ -20,7 +21,8 @@ interface PurlObject { * Lowercases name only if repository_url qualifier contains 'databricks'. */ export function normalize(purl: PurlObject): PurlObject { - if (purl.qualifiers?.['repository_url']?.includes('databricks')) { + const repoUrl = purl.qualifiers?.['repository_url'] + if (repoUrl !== undefined && StringPrototypeIncludes(repoUrl, 'databricks')) { lowerName(purl) } return purl diff --git a/src/purl-types/npm.ts b/src/purl-types/npm.ts index 7ec7e44..33c2bef 100644 --- a/src/purl-types/npm.ts +++ b/src/purl-types/npm.ts @@ -7,6 +7,17 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { encodeComponent } from '../encode.js' import { PurlError } from '../error.js' +import { + RegExpPrototypeTest, + StringPrototypeCharCodeAt, + StringPrototypeIncludes, + StringPrototypeIndexOf, + StringPrototypeReplace, + StringPrototypeSlice, + StringPrototypeStartsWith, + StringPrototypeToLowerCase, + StringPrototypeTrim, +} from '../primordials.js' import { isBlank, lowerName, lowerNamespace } from '../strings.js' import type { TtlCache } from '@socketsecurity/lib/cache-with-ttl' @@ -174,7 +185,7 @@ const getNpmLegacySet = (() => { * Check if npm identifier is a Node.js built-in module name. */ const isNpmBuiltinName = (id: string): boolean => - getNpmBuiltinSet().has(id.toLowerCase()) + getNpmBuiltinSet().has(StringPrototypeToLowerCase(id)) /** * Check if npm identifier is a legacy package name. @@ -292,7 +303,9 @@ export async function npmExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } @@ -367,44 +380,44 @@ export function parseNpmSpecifier(specifier: unknown): NpmPackageComponents { let version: string | undefined // Check if it's a scoped package - if (specifier.startsWith('@')) { + if (StringPrototypeStartsWith(specifier, '@')) { // Find the second slash (after @scope/) - const slashIndex = specifier.indexOf('/') + const slashIndex = StringPrototypeIndexOf(specifier, '/') if (slashIndex === -1) { throw new Error('Invalid scoped package specifier.') } // Find the @ after the scope - const atIndex = specifier.indexOf('@', slashIndex) + const atIndex = StringPrototypeIndexOf(specifier, '@', slashIndex) if (atIndex === -1) { // No version specified - namespace = specifier.slice(0, slashIndex) - name = specifier.slice(slashIndex + 1) + namespace = StringPrototypeSlice(specifier, 0, slashIndex) + name = StringPrototypeSlice(specifier, slashIndex + 1) } else { - namespace = specifier.slice(0, slashIndex) - name = specifier.slice(slashIndex + 1, atIndex) - version = specifier.slice(atIndex + 1) + namespace = StringPrototypeSlice(specifier, 0, slashIndex) + name = StringPrototypeSlice(specifier, slashIndex + 1, atIndex) + version = StringPrototypeSlice(specifier, atIndex + 1) } } else { // Non-scoped package: name@version - const atIndex = specifier.indexOf('@') + const atIndex = StringPrototypeIndexOf(specifier, '@') if (atIndex === -1) { // No version specified name = specifier } else { - name = specifier.slice(0, atIndex) - version = specifier.slice(atIndex + 1) + name = StringPrototypeSlice(specifier, 0, atIndex) + version = StringPrototypeSlice(specifier, atIndex + 1) } } // Clean up version - remove common npm range prefixes if (version) { // Remove leading ^, ~, >=, <=, >, <, = - version = version.replace(/^[\^~>=<]+/, '') + version = StringPrototypeReplace(version, /^[\^~>=<]+/, '' as any) // Handle version ranges like "1.0.0 - 2.0.0" by taking first version - const spaceIndex = version.indexOf(' ') + const spaceIndex = StringPrototypeIndexOf(version, ' ') if (spaceIndex !== -1) { - version = version.slice(0, spaceIndex) + version = StringPrototypeSlice(version, 0, spaceIndex) } } @@ -421,7 +434,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { const { name, namespace } = purl const hasNs = namespace && namespace.length > 0 const id = getNpmId(purl) - const code0 = id.charCodeAt(0) + const code0 = StringPrototypeCharCodeAt(id, 0) const compName = hasNs ? 'namespace' : 'name' if (code0 === 46 /*'.'*/) { if (throws) { @@ -439,7 +452,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } - if (name.trim() !== name) { + if (StringPrototypeTrim(name) !== name) { if (throws) { throw new PurlError( 'npm "name" component cannot contain leading or trailing spaces', @@ -456,7 +469,10 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } if (hasNs) { - if (namespace?.trim() !== namespace) { + if ( + (namespace !== undefined ? StringPrototypeTrim(namespace) : namespace) !== + namespace + ) { if (throws) { throw new PurlError( 'npm "namespace" component cannot contain leading or trailing spaces', @@ -472,7 +488,8 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } - const namespaceWithoutAtSign = namespace?.slice(1) + const namespaceWithoutAtSign = + namespace !== undefined ? StringPrototypeSlice(namespace, 1) : namespace if (encodeComponent(namespaceWithoutAtSign) !== namespaceWithoutAtSign) { if (throws) { throw new PurlError( @@ -482,7 +499,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } } - const loweredId = id.toLowerCase() + const loweredId = StringPrototypeToLowerCase(id) if (loweredId === 'node_modules' || loweredId === 'favicon.ico') { if (throws) { throw new PurlError( @@ -513,7 +530,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } - if (/[~'!()*]/.test(name)) { + if (RegExpPrototypeTest(/[~'!()*]/, name)) { if (throws) { throw new PurlError( `npm "name" component can not contain special characters ("~'!()*")`, diff --git a/src/purl-types/nuget.ts b/src/purl-types/nuget.ts index b07ad1d..9c75f47 100644 --- a/src/purl-types/nuget.ts +++ b/src/purl-types/nuget.ts @@ -5,6 +5,12 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { + ArrayPrototypeIncludes, + ArrayPrototypePush, + StringPrototypeIncludes, + StringPrototypeToLowerCase, +} from '../primordials.js' import { validateEmptyByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -60,7 +66,7 @@ export async function nugetExists( const fetchResult = async (): Promise => { try { - const lowerName = name.toLowerCase() + const lowerName = StringPrototypeToLowerCase(name) const url = `https://api.nuget.org/v3/registration5-semver1/${encodeURIComponent(lowerName)}/index.json` const data = await httpJson<{ @@ -85,7 +91,7 @@ export async function nugetExists( for (const item of page.items) { const ver = item.catalogEntry?.['version'] if (ver) { - versions.push(ver) + ArrayPrototypePush(versions, ver) } } } @@ -99,7 +105,7 @@ export async function nugetExists( const latestVersion = versions[versions.length - 1] if (version) { - if (!versions.includes(version)) { + if (!ArrayPrototypeIncludes(versions, version)) { const result: ExistsResult = { exists: false, error: `Version ${version} not found`, @@ -121,7 +127,9 @@ export async function nugetExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } diff --git a/src/purl-types/oci.ts b/src/purl-types/oci.ts index cd8c70a..1092ae5 100644 --- a/src/purl-types/oci.ts +++ b/src/purl-types/oci.ts @@ -3,7 +3,7 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci */ -import { lowerName } from '../strings.js' +import { lowerName, lowerVersion } from '../strings.js' import { validateEmptyByType } from '../validate.js' interface PurlObject { @@ -17,10 +17,11 @@ interface PurlObject { /** * Normalize OCI package URL. - * Lowercases name only. + * Lowercases name and version per spec. */ export function normalize(purl: PurlObject): PurlObject { lowerName(purl) + lowerVersion(purl) return purl } diff --git a/src/purl-types/pub.ts b/src/purl-types/pub.ts index 1564f7c..93bb230 100644 --- a/src/purl-types/pub.ts +++ b/src/purl-types/pub.ts @@ -6,6 +6,11 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { PurlError } from '../error.js' +import { + StringPrototypeCharCodeAt, + StringPrototypeIncludes, + ArrayPrototypeSome, +} from '../primordials.js' import { lowerName, replaceDashesWithUnderscores } from '../strings.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -86,7 +91,10 @@ export async function pubExists( if (version) { const versions = data.versions || [] - const versionExists = versions.some(v => v.version === version) + const versionExists = ArrayPrototypeSome( + versions, + v => v.version === version, + ) if (!versionExists) { const result: ExistsResult = { exists: false, @@ -109,7 +117,9 @@ export async function pubExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } @@ -128,7 +138,7 @@ export async function pubExists( export function validate(purl: PurlObject, throws: boolean): boolean { const { name } = purl for (let i = 0, { length } = name; i < length; i += 1) { - const code = name.charCodeAt(i) + const code = StringPrototypeCharCodeAt(name, i) // biome-ignore format: newlines if ( !( diff --git a/src/purl-types/pypi.ts b/src/purl-types/pypi.ts index c7a7d2d..3a58011 100644 --- a/src/purl-types/pypi.ts +++ b/src/purl-types/pypi.ts @@ -5,9 +5,11 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { StringPrototypeIncludes } from '../primordials.js' import { lowerName, lowerNamespace, + lowerVersion, replaceUnderscoresWithDashes, } from '../strings.js' @@ -24,11 +26,13 @@ interface PurlObject { /** * Normalize PyPI package URL. - * Lowercases namespace and name, replaces underscores with dashes in name. + * Lowercases namespace, name, and version, replaces underscores with dashes in name. + * Spec: namespace, name, and version are all case-insensitive. */ export function normalize(purl: PurlObject): PurlObject { lowerNamespace(purl) lowerName(purl) + lowerVersion(purl) purl.name = replaceUnderscoresWithDashes(purl.name) return purl } @@ -120,7 +124,9 @@ export async function pypiExists( const error = e instanceof Error ? e.message : String(e) return { exists: false, - error: error.includes('404') ? 'Package not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Package not found' + : error, } } } diff --git a/src/purl-types/swid.ts b/src/purl-types/swid.ts index f401a33..b2d1429 100644 --- a/src/purl-types/swid.ts +++ b/src/purl-types/swid.ts @@ -4,6 +4,16 @@ */ import { PurlError } from '../error.js' +import { + ObjectFreeze, + RegExpPrototypeTest, + StringPrototypeToLowerCase, + StringPrototypeTrim, +} from '../primordials.js' + +const GUID_PATTERN = ObjectFreeze( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, +) interface PurlObject { name: string @@ -30,7 +40,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } // tag_id must not be empty after trimming - const tagIdStr = String(tagId).trim() + const tagIdStr = StringPrototypeTrim(String(tagId)) if (tagIdStr.length === 0) { /* c8 ignore next 3 -- Throw path tested separately from return false path. */ if (throws) { @@ -39,10 +49,8 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } // If tag_id is a GUID, it must be lowercase - const guidPattern = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - if (guidPattern.test(tagIdStr)) { - if (tagIdStr !== tagIdStr.toLowerCase()) { + if (RegExpPrototypeTest(GUID_PATTERN, tagIdStr)) { + if (tagIdStr !== StringPrototypeToLowerCase(tagIdStr)) { if (throws) { throw new PurlError( 'swid "tag_id" qualifier must be lowercase when it is a GUID', diff --git a/src/purl-types/vscode-extension.ts b/src/purl-types/vscode-extension.ts index 1421175..531d7e0 100644 --- a/src/purl-types/vscode-extension.ts +++ b/src/purl-types/vscode-extension.ts @@ -8,7 +8,16 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { lowerName, lowerNamespace } from '../strings.js' +import { PurlError } from '../error.js' +import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' +import { + containsInjectionCharacters, + isSemverString, + lowerName, + lowerNamespace, + lowerVersion, +} from '../strings.js' +import { validateRequiredByType } from '../validate.js' import type { ExistsOptions, ExistsResult } from './npm.js' @@ -23,14 +32,75 @@ interface PurlObject { /** * Normalize VSCode extension package URL. - * Lowercases both namespace (publisher) and name (extension). + * Lowercases namespace (publisher), name (extension), and version per spec. + * Spec: namespace, name, and version are all case-insensitive. */ export function normalize(purl: PurlObject): PurlObject { lowerNamespace(purl) lowerName(purl) + lowerVersion(purl) return purl } +/** + * Validate VSCode extension package URL. + * Checks namespace (publisher) and name (extension) for injection characters, + * and validates version as semver when present. + */ +export function validate(purl: PurlObject, throws: boolean): boolean { + const { name, namespace, version, qualifiers } = purl + // VSCode extensions require a namespace (publisher) + if ( + !validateRequiredByType('vscode-extension', 'namespace', namespace, { + throws, + }) + ) { + return false + } + // Namespace must not contain injection characters + if (typeof namespace === 'string' && containsInjectionCharacters(namespace)) { + if (throws) { + throw new PurlError( + 'vscode-extension "namespace" component contains illegal characters', + ) + } + return false + } + // Name must not contain injection characters + if (containsInjectionCharacters(name)) { + if (throws) { + throw new PurlError( + 'vscode-extension "name" component contains illegal characters', + ) + } + return false + } + // Version must be valid semver when present + if ( + typeof version === 'string' && + version.length > 0 && + !isSemverString(version) + ) { + if (throws) { + throw new PurlError( + 'vscode-extension "version" component must be a valid semver version', + ) + } + return false + } + // Platform qualifier must not contain injection characters + const platform = qualifiers?.['platform'] + if (typeof platform === 'string' && containsInjectionCharacters(platform)) { + if (throws) { + throw new PurlError( + 'vscode-extension qualifier "platform" contains illegal characters', + ) + } + return false + } + return true +} + /** * Check if a VSCode extension exists in the Visual Studio Marketplace. * @@ -142,7 +212,10 @@ export async function vscodeExtensionExists( // If specific version requested, validate it exists if (version && versions) { - const versionExists = versions.some(v => v.version === version) + const versionExists = ArrayPrototypeSome( + versions, + v => v.version === version, + ) if (!versionExists) { const result: ExistsResult = { exists: false, @@ -174,7 +247,9 @@ export async function vscodeExtensionExists( // If upstream changes error format, this string matching may break. return { exists: false, - error: error.includes('404') ? 'Extension not found' : error, + error: StringPrototypeIncludes(error, '404') + ? 'Extension not found' + : error, } } } diff --git a/src/result.ts b/src/result.ts index e399f03..040658f 100644 --- a/src/result.ts +++ b/src/result.ts @@ -20,6 +20,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { ArrayPrototypePush } from './primordials.js' + /** * @fileoverview Result type for functional error handling without exceptions. */ @@ -220,7 +222,7 @@ export const ResultUtils = { if (result.isErr()) { return result as unknown as Result } - values.push((result as Ok).value) + ArrayPrototypePush(values, (result as Ok).value) } return ok(values as ExtractedValues) }, diff --git a/src/stringify.ts b/src/stringify.ts index f51c25c..6bc1f19 100644 --- a/src/stringify.ts +++ b/src/stringify.ts @@ -17,50 +17,70 @@ import type { PackageURL } from './package-url.js' import type { QualifiersObject } from './purl-component.js' /** - * Convert PackageURL instance to canonical PURL string. + * Convert PackageURL instance to spec string (without scheme and type). * - * Serializes a PackageURL object into its canonical string representation - * according to the PURL specification. + * Returns the package identity portion: namespace/name@version?qualifiers#subpath + * This is the purl equivalent of an npm "spec" — the package identity without + * the ecosystem prefix. * * @param purl - PackageURL instance to stringify - * @returns Canonical PURL string (e.g., 'pkg:npm/lodash@4.17.21') + * @returns Spec string (e.g., '%40babel/core@7.0.0' for pkg:npm/%40babel/core@7.0.0) * * @example * ```typescript - * const purl = new PackageURL('npm', undefined, 'lodash', '4.17.21') - * stringify(purl) - * // -> 'pkg:npm/lodash@4.17.21' + * const purl = new PackageURL('npm', '@babel', 'core', '7.0.0') + * stringifySpec(purl) + * // -> '%40babel/core@7.0.0' * ``` */ -export function stringify(purl: PackageURL): string { +export function stringifySpec(purl: PackageURL): string { const { name, namespace, qualifiers, subpath, - type, version, }: { name?: string | undefined namespace?: string | undefined qualifiers?: QualifiersObject | undefined subpath?: string | undefined - type?: string | undefined version?: string | undefined } = purl - let purlStr = `pkg:${isNonEmptyString(type) ? encodeComponent(type) : ''}/` + let specStr = '' if (namespace) { - purlStr = `${purlStr}${encodeNamespace(namespace)}/` + specStr = `${encodeNamespace(namespace)}/` } - purlStr = `${purlStr}${encodeName(name)}` + specStr = `${specStr}${encodeName(name)}` if (version) { - purlStr = `${purlStr}@${encodeVersion(version)}` + specStr = `${specStr}@${encodeVersion(version)}` } if (qualifiers) { - purlStr = `${purlStr}?${encodeQualifiers(qualifiers)}` + specStr = `${specStr}?${encodeQualifiers(qualifiers)}` } if (subpath) { - purlStr = `${purlStr}#${encodeSubpath(subpath)}` + specStr = `${specStr}#${encodeSubpath(subpath)}` } - return purlStr + return specStr +} + +/** + * Convert PackageURL instance to canonical PURL string. + * + * Serializes a PackageURL object into its canonical string representation + * according to the PURL specification. + * + * @param purl - PackageURL instance to stringify + * @returns Canonical PURL string (e.g., 'pkg:npm/lodash@4.17.21') + * + * @example + * ```typescript + * const purl = new PackageURL('npm', undefined, 'lodash', '4.17.21') + * stringify(purl) + * // -> 'pkg:npm/lodash@4.17.21' + * ``` + */ +export function stringify(purl: PackageURL): string { + const type = isNonEmptyString(purl.type) ? encodeComponent(purl.type) : '' + return `pkg:${type}/${stringifySpec(purl)}` } diff --git a/src/strings.ts b/src/strings.ts index a158e0a..b3abf23 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -2,13 +2,21 @@ * @fileoverview String utility functions for PURL processing. * Includes whitespace detection, semver validation, locale comparison, and character replacement. */ +import { + ObjectFreeze, + RegExpPrototypeTest, + StringPrototypeCharCodeAt, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeToLowerCase, +} from './primordials.js' /** * Check if string contains only whitespace characters. */ function isBlank(str: string): boolean { for (let i = 0, { length } = str; i < length; i += 1) { - const code = str.charCodeAt(i) + const code = StringPrototypeCharCodeAt(str, i) // biome-ignore format: newlines if ( !( @@ -83,14 +91,18 @@ function isNonEmptyString(value: unknown): value is string { // This regexp is valid as of 2024-08-01 // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string -const regexSemverNumberedGroups = - /^(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-]+)*))?$/ +const regexSemverNumberedGroups = ObjectFreeze( + /^(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-]+)*))?$/, +) /** * Check if value is a valid semantic version string. */ function isSemverString(value: unknown): value is string { - return typeof value === 'string' && regexSemverNumberedGroups.test(value) + return ( + typeof value === 'string' && + RegExpPrototypeTest(regexSemverNumberedGroups, value) + ) } // Intl.Collator is faster than String#localeCompare @@ -115,7 +127,7 @@ function localeCompare(x: string, y: string): number { * Convert package name to lowercase. */ function lowerName(purl: { name: string }): void { - purl.name = purl.name.toLowerCase() + purl.name = StringPrototypeToLowerCase(purl.name) } /** @@ -124,7 +136,7 @@ function lowerName(purl: { name: string }): void { function lowerNamespace(purl: { namespace?: string | undefined }): void { const { namespace } = purl if (typeof namespace === 'string') { - purl.namespace = namespace.toLowerCase() + purl.namespace = StringPrototypeToLowerCase(namespace) } } @@ -134,7 +146,7 @@ function lowerNamespace(purl: { namespace?: string | undefined }): void { function lowerVersion(purl: { version?: string | undefined }): void { const { version } = purl if (typeof version === 'string') { - purl.version = version.toLowerCase() + purl.version = StringPrototypeToLowerCase(version) } } @@ -146,11 +158,11 @@ function replaceDashesWithUnderscores(str: string): string { let result = '' let fromIndex = 0 let index = 0 - while ((index = str.indexOf('-', fromIndex)) !== -1) { - result = `${result + str.slice(fromIndex, index)}_` + while ((index = StringPrototypeIndexOf(str, '-', fromIndex)) !== -1) { + result = `${result + StringPrototypeSlice(str, fromIndex, index)}_` fromIndex = index + 1 } - return fromIndex ? result + str.slice(fromIndex) : str + return fromIndex ? result + StringPrototypeSlice(str, fromIndex) : str } /** @@ -161,11 +173,63 @@ function replaceUnderscoresWithDashes(str: string): string { let result = '' let fromIndex = 0 let index = 0 - while ((index = str.indexOf('_', fromIndex)) !== -1) { - result = `${result + str.slice(fromIndex, index)}-` + while ((index = StringPrototypeIndexOf(str, '_', fromIndex)) !== -1) { + result = `${result + StringPrototypeSlice(str, fromIndex, index)}-` fromIndex = index + 1 } - return fromIndex ? result + str.slice(fromIndex) : str + return fromIndex ? result + StringPrototypeSlice(str, fromIndex) : str +} + +/** + * Check if string contains characters commonly used in shell/URL injection attacks. + * Detects shell metacharacters (|, &, ;, `, $, <, >, {, }, #, \, newlines) + * and whitespace that could be used to break out of command or URL contexts. + * Uses charCode scanning for performance in hot paths. + */ +function containsInjectionCharacters(str: string): boolean { + for (let i = 0, { length } = str; i < length; i += 1) { + const code = StringPrototypeCharCodeAt(str, i) + // biome-ignore format: newlines + if ( + // | + code === 0x7c || + // & + code === 0x26 || + // ; + code === 0x3b || + // ` + code === 0x60 || + // $ + code === 0x24 || + // < + code === 0x3c || + // > + code === 0x3e || + // ( + code === 0x28 || + // ) + code === 0x29 || + // { + code === 0x7b || + // } + code === 0x7d || + // # + code === 0x23 || + // \ + code === 0x5c || + // space + code === 0x20 || + // tab + code === 0x09 || + // newline + code === 0x0a || + // carriage return + code === 0x0d + ) { + return true + } + } + return false } /** @@ -173,13 +237,14 @@ function replaceUnderscoresWithDashes(str: string): string { */ function trimLeadingSlashes(str: string): string { let start = 0 - while (str.charCodeAt(start) === 47 /*'/'*/) { + while (StringPrototypeCharCodeAt(str, start) === 47 /*'/'*/) { start += 1 } - return start === 0 ? str : str.slice(start) + return start === 0 ? str : StringPrototypeSlice(str, start) } export { + containsInjectionCharacters, isBlank, isNonEmptyString, isSemverString, diff --git a/src/url-converter.ts b/src/url-converter.ts index 190e423..1b4c3ad 100644 --- a/src/url-converter.ts +++ b/src/url-converter.ts @@ -23,8 +23,748 @@ SOFTWARE. /** * @fileoverview URL conversion utilities for converting Package URLs to repository and download URLs. */ +import { + ArrayPrototypeFilter, + ArrayPrototypeJoin, + ArrayPrototypeSlice, + MapCtor, + ObjectFreeze, + SetCtor, + StringPrototypeCharCodeAt, + StringPrototypeEndsWith, + StringPrototypeIndexOf, + StringPrototypeLastIndexOf, + StringPrototypeReplace, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeStartsWith, + URLCtor, +} from './primordials.js' + import type { PackageURL } from './package-url.js' +// Lazy reference to PackageURL, set by package-url.ts at module load time +// to avoid circular import issues. +let _PackageURL: typeof PackageURL | undefined + +/** @internal Register the PackageURL class for fromUrl construction. */ +export function _registerPackageURLForUrlConverter( + ctor: typeof PackageURL, +): void { + _PackageURL = ctor +} + +type UrlParser = (_url: URL) => PackageURL | undefined + +/** + * Filter empty segments from a URL pathname split. + * Trailing slashes create empty segments that must be removed. + */ +function filterSegments(pathname: string): string[] { + return ArrayPrototypeFilter( + StringPrototypeSplit(pathname, '/' as any), + s => s.length > 0, + ) +} + +/** + * Safely construct a PackageURL, returning undefined if construction fails. + */ +function tryCreatePurl( + type: string, + namespace: string | undefined, + name: string, + version: string | undefined, +): PackageURL | undefined { + /* c8 ignore next 3 -- PackageURL is always registered at module load time. */ + if (!_PackageURL) { + return undefined + } + try { + return new _PackageURL(type, namespace, name, version, undefined, undefined) + } catch { + /* c8 ignore next -- Defensive: validation error in PackageURL constructor. */ + return undefined + } +} + +/** + * Parse npm registry URLs (registry.npmjs.org). + * + * Handles: + * - Registry metadata: /\@scope/name or /name + * - Registry metadata with version: /\@scope/name/version or /name/version + * - Download tarballs: /\@scope/name/-/name-version.tgz or /name/-/name-version.tgz + */ +function parseNpmRegistry(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length === 0) { + return undefined + } + + let namespace: string | undefined + let name: string | undefined + let version: string | undefined + + // Scoped package: first segment starts with @ + if (segments[0] && StringPrototypeStartsWith(segments[0], '@')) { + namespace = segments[0] + name = segments[1] + if (!name) { + return undefined + } + // Download tarball: /@scope/name/-/name-version.tgz + if (segments[2] === '-' && segments[3]) { + const tgz = segments[3] + if (StringPrototypeEndsWith(tgz, '.tgz')) { + const withoutExt = StringPrototypeSlice(tgz, 0, -4) + // name-version pattern: find last hyphen after name + const prefix = `${name}-` + if (StringPrototypeStartsWith(withoutExt, prefix)) { + version = StringPrototypeSlice(withoutExt, prefix.length) + } + } + } else if (segments[2]) { + version = segments[2] + } + } else { + name = segments[0] + /* c8 ignore next 3 -- Defensive: filterSegments ensures non-empty. */ + if (!name) { + return undefined + } + // Download tarball: /name/-/name-version.tgz + if (segments[1] === '-' && segments[2]) { + const tgz = segments[2] + if (StringPrototypeEndsWith(tgz, '.tgz')) { + const withoutExt = StringPrototypeSlice(tgz, 0, -4) + const prefix = `${name}-` + if (StringPrototypeStartsWith(withoutExt, prefix)) { + version = StringPrototypeSlice(withoutExt, prefix.length) + } + } + } else if (segments[1]) { + version = segments[1] + } + } + + return tryCreatePurl('npm', namespace, name, version) +} + +/** + * Parse npm website URLs (www.npmjs.com). + * + * Handles: + * - /package/\@scope/name, /package/\@scope/name/v/version + * - /package/name, /package/name/v/version + */ +function parseNpmWebsite(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length === 0 || segments[0] !== 'package') { + return undefined + } + + let namespace: string | undefined + let name: string | undefined + let version: string | undefined + + if (segments[1] && StringPrototypeStartsWith(segments[1], '@')) { + namespace = segments[1] + name = segments[2] + if (!name) { + return undefined + } + if (segments[3] === 'v' && segments[4]) { + version = segments[4] + } + } else { + name = segments[1] + if (!name) { + return undefined + } + if (segments[2] === 'v' && segments[3]) { + version = segments[3] + } + } + + return tryCreatePurl('npm', namespace, name, version) +} + +/** + * Parse PyPI URLs (pypi.org). + * + * Handles: /project/name/, /project/name/version/ + */ +function parsePypi(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2 || segments[0] !== 'project') { + return undefined + } + + const name = segments[1] + /* c8 ignore next 3 -- Defensive: filterSegments ensures non-empty. */ + if (!name) { + return undefined + } + const version = segments[2] + + return tryCreatePurl('pypi', undefined, name, version) +} + +/** + * Parse Maven Central URLs (repo1.maven.org). + * + * Handles: /maven2/{group-as-path}/{artifact}/{version}/ + * Group path segments are joined with '.'. + */ +function parseMaven(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + // Minimum: maven2 / groupPart / artifact / version + if (segments.length < 4 || segments[0] !== 'maven2') { + return undefined + } + + // Remove 'maven2' prefix + const parts = ArrayPrototypeSlice(segments, 1) + // Last segment is version, second-to-last is artifact, rest is group path + if (parts.length < 3) { + /* c8 ignore next -- Defensive: filterSegments ensures non-empty. */ + return undefined + } + const version = parts[parts.length - 1]! + const name = parts[parts.length - 2]! + const groupParts = ArrayPrototypeSlice(parts, 0, -2) + const namespace = ArrayPrototypeJoin(groupParts, '.') + + if (!namespace || !name) { + /* c8 ignore next -- Defensive: filterSegments ensures non-empty. */ + return undefined + } + + return tryCreatePurl('maven', namespace, name, version) +} + +/** + * Parse RubyGems URLs (rubygems.org). + * + * Handles: /gems/name, /gems/name/versions/version + */ +function parseGem(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2 || segments[0] !== 'gems') { + return undefined + } + + const name = segments[1] + /* c8 ignore next 3 -- Defensive: filterSegments ensures non-empty. */ + if (!name) { + return undefined + } + + let version: string | undefined + if (segments[2] === 'versions' && segments[3]) { + version = segments[3] + } + + return tryCreatePurl('gem', undefined, name, version) +} + +/** + * Parse crates.io URLs. + * + * Handles: + * - /crates/name, /crates/name/version + * - /api/v1/crates/name/version/download + */ +function parseCargo(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + + // /api/v1/crates/name/version/download + if ( + segments[0] === 'api' && + segments[1] === 'v1' && + segments[2] === 'crates' && + segments[3] + ) { + const name = segments[3] + const version = segments[4] + return tryCreatePurl('cargo', undefined, name, version) + } + + // /crates/name or /crates/name/version + if (segments[0] !== 'crates') { + return undefined + } + + const name = segments[1] + /* c8 ignore next 3 -- Defensive: filterSegments ensures non-empty. */ + if (!name) { + return undefined + } + const version = segments[2] + + return tryCreatePurl('cargo', undefined, name, version) +} + +/** + * Parse NuGet URLs (www.nuget.org and api.nuget.org). + * + * Handles: + * - www.nuget.org: /packages/Name, /packages/Name/version + * - api.nuget.org: /v3-flatcontainer/name/version/name.version.nupkg + */ +function parseNuget(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + + // api.nuget.org: /v3-flatcontainer/name/version/name.version.nupkg + if (url.hostname === 'api.nuget.org') { + if (segments[0] !== 'v3-flatcontainer' || !segments[1]) { + return undefined + } + const name = segments[1] + const version = segments[2] + return tryCreatePurl('nuget', undefined, name, version) + } + + // www.nuget.org: /packages/Name or /packages/Name/version + if (segments[0] !== 'packages') { + return undefined + } + + const name = segments[1] + /* c8 ignore next 3 -- Defensive: filterSegments ensures non-empty. */ + if (!name) { + return undefined + } + const version = segments[2] + + return tryCreatePurl('nuget', undefined, name, version) +} + +/** + * Parse GitHub URLs (github.com). + * + * Handles: + * - /owner/repo + * - /owner/repo/tree/ref + * - /owner/repo/commit/sha + * - /owner/repo/releases/tag/tagname + */ +function parseGitHub(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + + const namespace = segments[0]! + const name = segments[1]! + + let version: string | undefined + if (segments[2] === 'tree' && segments[3]) { + version = segments[3] + } else if (segments[2] === 'commit' && segments[3]) { + version = segments[3] + } else if ( + segments[2] === 'releases' && + segments[3] === 'tag' && + segments[4] + ) { + version = segments[4] + } + + return tryCreatePurl('github', namespace, name, version) +} + +/** + * Parse Go package URLs (pkg.go.dev). + * + * Handles: + * - /module/path (e.g. /github.com/gorilla/mux) + * - /module/path\@version (e.g. /github.com/gorilla/mux\@v1.8.0) + */ +function parseGolang(url: URL): PackageURL | undefined { + // Remove leading slash + let path = StringPrototypeSlice(url.pathname, 1) + if (!path) { + return undefined + } + + let version: string | undefined + // Check for @version suffix + const atIndex = StringPrototypeLastIndexOf(path, '@') + if (atIndex !== -1) { + version = StringPrototypeSlice(path, atIndex + 1) + path = StringPrototypeSlice(path, 0, atIndex) + } + + // The full path becomes the namespace for golang purls + // e.g. github.com/gorilla/mux -> namespace=github.com/gorilla, name=mux + const lastSlash = StringPrototypeLastIndexOf(path, '/') + if (lastSlash === -1) { + return undefined + } + + const namespace = StringPrototypeSlice(path, 0, lastSlash) + const name = StringPrototypeSlice(path, lastSlash + 1) + if (!namespace || !name) { + /* c8 ignore next -- Defensive: filterSegments ensures non-empty. */ + return undefined + } + + return tryCreatePurl('golang', namespace, name, version) +} + +/** + * Parse GitLab URLs (gitlab.com). + * Same pattern as GitHub: /owner/repo, /owner/repo/-/tree/ref, /owner/repo/-/commit/sha + */ +function parseGitlab(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + const namespace = segments[0]! + const name = segments[1]! + let version: string | undefined + // GitLab uses /-/ prefix before tree/commit/tags + if (segments[2] === '-') { + if (segments[3] === 'tree' && segments[4]) { + version = segments[4] + } else if (segments[3] === 'commit' && segments[4]) { + version = segments[4] + } else if (segments[3] === 'tags' && segments[4]) { + version = segments[4] + } + } + return tryCreatePurl('gitlab', namespace, name, version) +} + +/** + * Parse Bitbucket URLs (bitbucket.org). + * Pattern: /owner/repo, /owner/repo/commits/sha, /owner/repo/src/ref + */ +function parseBitbucket(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + const namespace = segments[0]! + const name = segments[1]! + let version: string | undefined + if (segments[2] === 'commits' && segments[3]) { + version = segments[3] + } else if (segments[2] === 'src' && segments[3]) { + version = segments[3] + } + return tryCreatePurl('bitbucket', namespace, name, version) +} + +/** + * Parse Packagist/Composer URLs (packagist.org). + * Pattern: /packages/namespace/name + */ +function parseComposer(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 3 || segments[0] !== 'packages') { + return undefined + } + const namespace = segments[1]! + const name = segments[2]! + return tryCreatePurl('composer', namespace, name, undefined) +} + +/** + * Parse Hex.pm URLs (hex.pm). + * Pattern: /packages/name, /packages/name/version + */ +function parseHex(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2 || segments[0] !== 'packages') { + return undefined + } + const name = segments[1]! + const version = segments[2] + return tryCreatePurl('hex', undefined, name, version) +} + +/** + * Parse pub.dev URLs (pub.dev). + * Pattern: /packages/name, /packages/name/versions/version + */ +function parsePub(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2 || segments[0] !== 'packages') { + return undefined + } + const name = segments[1]! + let version: string | undefined + if (segments[2] === 'versions' && segments[3]) { + version = segments[3] + } + return tryCreatePurl('pub', undefined, name, version) +} + +/** + * Parse Docker Hub URLs (hub.docker.com). + * Patterns: + * - Official images: /\_/name + * - User images: /r/namespace/name + * - Library alias: /r/library/name + */ +function parseDocker(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + // Official images: /_/name + if (segments[0] === '_' && segments[1]) { + return tryCreatePurl('docker', 'library', segments[1], undefined) + } + // User/org images: /r/namespace/name + if (segments[0] === 'r' && segments[1] && segments[2]) { + return tryCreatePurl('docker', segments[1], segments[2], undefined) + } + return undefined +} + +/** + * Parse CocoaPods URLs (cocoapods.org). + * Pattern: /pods/name + */ +function parseCocoapods(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2 || segments[0] !== 'pods') { + return undefined + } + return tryCreatePurl('cocoapods', undefined, segments[1]!, undefined) +} + +/** + * Parse Hackage URLs (hackage.haskell.org). + * Pattern: /package/name, /package/name-version + */ +function parseHackage(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2 || segments[0] !== 'package') { + return undefined + } + const raw = segments[1]! + // Hackage uses name-version format in the URL + // Find the last hyphen followed by a digit to split name from version + let splitIndex = -1 + for (let i = raw.length - 1; i >= 0; i -= 1) { + if (StringPrototypeCharCodeAt(raw, i) === 45 /*'-'*/) { + const next = StringPrototypeCharCodeAt(raw, i + 1) + // Next char is a digit (0-9) + if (next >= 48 && next <= 57) { + splitIndex = i + break + } + } + } + if (splitIndex === -1) { + return tryCreatePurl('hackage', undefined, raw, undefined) + } + const name = StringPrototypeSlice(raw, 0, splitIndex) + const version = StringPrototypeSlice(raw, splitIndex + 1) + return tryCreatePurl('hackage', undefined, name, version) +} + +/** + * Parse CRAN URLs (cran.r-project.org). + * Pattern: /web/packages/name, /package=name (query param) + */ +function parseCran(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + // /web/packages/name/index.html + if ( + segments.length >= 3 && + segments[0] === 'web' && + segments[1] === 'packages' + ) { + return tryCreatePurl('cran', undefined, segments[2]!, undefined) + } + return undefined +} + +/** + * Parse Anaconda/Conda URLs (anaconda.org). + * Pattern: /channel/name, /channel/name/version + */ +function parseConda(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + // segments[0] is the channel, segments[1] is the package name + const name = segments[1]! + const version = segments[2] + return tryCreatePurl('conda', undefined, name, version) +} + +/** + * Parse MetaCPAN URLs (metacpan.org). + * Patterns: /pod/Name, /pod/Name::Sub, /dist/Name + */ +function parseCpan(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + if (segments[0] === 'pod' || segments[0] === 'dist') { + // Rejoin remaining segments for nested module names like Foo/Bar + const name = ArrayPrototypeJoin(ArrayPrototypeSlice(segments, 1), '::') + return tryCreatePurl('cpan', undefined, name, undefined) + } + return undefined +} + +/** + * Parse Hugging Face URLs (huggingface.co). + * Pattern: /namespace/name, /namespace/name/tree/ref + */ +/** Reserved Hugging Face paths that are not model pages. */ +const HUGGINGFACE_RESERVED = ObjectFreeze( + new SetCtor([ + 'docs', + 'spaces', + 'datasets', + 'tasks', + 'blog', + 'pricing', + 'join', + 'login', + 'settings', + 'api', + ]), +) + +function parseHuggingface(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + // Skip non-model paths (docs, spaces UI, etc.) + if (HUGGINGFACE_RESERVED.has(segments[0]!)) { + return undefined + } + const namespace = segments[0]! + const name = segments[1]! + let version: string | undefined + if (segments[2] === 'tree' && segments[3]) { + version = segments[3] + } else if (segments[2] === 'commit' && segments[3]) { + version = segments[3] + } + return tryCreatePurl('huggingface', namespace, name, version) +} + +/** + * Parse LuaRocks URLs (luarocks.org). + * Pattern: /modules/namespace/name, /modules/namespace/name/version + */ +function parseLuarocks(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 3 || segments[0] !== 'modules') { + return undefined + } + const namespace = segments[1]! + const name = segments[2]! + const version = segments[3] + return tryCreatePurl('luarocks', namespace, name, version) +} + +/** + * Parse Swift Package Index URLs (swiftpackageindex.com). + * Pattern: /owner/repo + */ +function parseSwift(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 2) { + return undefined + } + return tryCreatePurl('swift', segments[0]!, segments[1]!, undefined) +} + +/** + * Parse VS Code Marketplace URLs (marketplace.visualstudio.com). + * Pattern: /items?itemName=publisher.extension + */ +function parseVscodeMarketplace(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 1 || segments[0] !== 'items') { + return undefined + } + const itemName = url.searchParams.get('itemName') + if (!itemName) { + return undefined + } + const dotIndex = StringPrototypeIndexOf(itemName, '.') + if (dotIndex === -1 || dotIndex === 0 || dotIndex === itemName.length - 1) { + return undefined + } + const namespace = StringPrototypeSlice(itemName, 0, dotIndex) + const name = StringPrototypeSlice(itemName, dotIndex + 1) + return tryCreatePurl('vscode-extension', namespace, name, undefined) +} + +/** + * Parse Open VSX URLs (open-vsx.org). + * Pattern: /extension/namespace/name, /extension/namespace/name/version + */ +function parseOpenVsx(url: URL): PackageURL | undefined { + const segments = filterSegments(url.pathname) + if (segments.length < 3 || segments[0] !== 'extension') { + return undefined + } + const namespace = segments[1]! + const name = segments[2]! + const version = segments[3] + return tryCreatePurl('vscode-extension', namespace, name, version) +} + +/** Hostname-based dispatch map for URL-to-PURL parsing. */ +const FROM_URL_PARSERS: ReadonlyMap = ObjectFreeze( + new MapCtor([ + // Package registries + ['registry.npmjs.org', parseNpmRegistry], + ['www.npmjs.com', parseNpmWebsite], + ['pypi.org', parsePypi], + ['repo1.maven.org', parseMaven], + ['central.maven.org', parseMaven], + ['rubygems.org', parseGem], + ['crates.io', parseCargo], + ['www.nuget.org', parseNuget], + ['api.nuget.org', parseNuget], + ['pkg.go.dev', parseGolang], + ['hex.pm', parseHex], + ['pub.dev', parsePub], + ['packagist.org', parseComposer], + ['hub.docker.com', parseDocker], + ['cocoapods.org', parseCocoapods], + ['hackage.haskell.org', parseHackage], + ['cran.r-project.org', parseCran], + ['anaconda.org', parseConda], + ['metacpan.org', parseCpan], + ['luarocks.org', parseLuarocks], + ['swiftpackageindex.com', parseSwift], + ['huggingface.co', parseHuggingface], + // VS Code extension marketplaces + ['marketplace.visualstudio.com', parseVscodeMarketplace], + ['open-vsx.org', parseOpenVsx], + // VCS hosts + ['github.com', parseGitHub], + ['gitlab.com', parseGitlab], + ['bitbucket.org', parseBitbucket], + ]), +) + /** * Repository URL conversion results. * @@ -65,53 +805,103 @@ export interface DownloadUrl { * const downloadUrl = UrlConverter.toDownloadUrl(purl) * ``` */ -const DOWNLOAD_URL_TYPES = new Set([ - 'cargo', - 'composer', - 'conda', - 'gem', - 'golang', - 'hex', - 'maven', - 'npm', - 'nuget', - 'pub', - 'pypi', -]) - -const REPOSITORY_URL_TYPES = new Set([ - 'bioconductor', - 'bitbucket', - 'cargo', - 'chrome', - 'clojars', - 'cocoapods', - 'composer', - 'conan', - 'conda', - 'cpan', - 'deno', - 'docker', - 'elm', - 'gem', - 'github', - 'gitlab', - 'golang', - 'hackage', - 'hex', - 'homebrew', - 'huggingface', - 'luarocks', - 'maven', - 'npm', - 'nuget', - 'pub', - 'pypi', - 'swift', - 'vscode', -]) +const DOWNLOAD_URL_TYPES: ReadonlySet = ObjectFreeze( + new SetCtor([ + 'cargo', + 'composer', + 'conda', + 'gem', + 'golang', + 'hex', + 'maven', + 'npm', + 'nuget', + 'pub', + 'pypi', + ]), +) + +const REPOSITORY_URL_TYPES: ReadonlySet = ObjectFreeze( + new SetCtor([ + 'bioconductor', + 'bitbucket', + 'cargo', + 'chrome', + 'clojars', + 'cocoapods', + 'composer', + 'conan', + 'conda', + 'cpan', + 'deno', + 'docker', + 'elm', + 'gem', + 'github', + 'gitlab', + 'golang', + 'hackage', + 'hex', + 'homebrew', + 'huggingface', + 'luarocks', + 'maven', + 'npm', + 'nuget', + 'pub', + 'pypi', + 'swift', + 'vscode', + ]), +) export class UrlConverter { + /** + * Convert a URL string to a PackageURL if the URL is recognized. + * + * Dispatches to type-specific parsers based on the URL hostname. + * Returns undefined for unrecognized hosts, invalid URLs, or URLs + * without enough path information to construct a valid PackageURL. + * + * @example + * ```typescript + * UrlConverter.fromUrl('https://www.npmjs.com/package/lodash') + * // -> PackageURL for pkg:npm/lodash + * + * UrlConverter.fromUrl('https://github.com/lodash/lodash') + * // -> PackageURL for pkg:github/lodash/lodash + * ``` + */ + static fromUrl(urlStr: string): PackageURL | undefined { + let url: URL + try { + url = new URLCtor(urlStr) + } catch { + return undefined + } + const parser = FROM_URL_PARSERS.get(url.hostname) + if (!parser) { + return undefined + } + return parser(url) + } + + /** + * Check if a URL string is recognized for conversion to a PackageURL. + * + * Returns true if the URL's hostname has a registered parser, + * false for invalid URLs or unrecognized hosts. + */ + static supportsFromUrl(urlStr: string): boolean { + let url: URL + try { + url = new URLCtor(urlStr) + } catch { + return false + } + return FROM_URL_PARSERS.has(url.hostname) + } + /** * Get all available URLs for a PackageURL. * @@ -181,7 +971,7 @@ export class UrlConverter { if (!namespace) { return undefined } - const groupPath = namespace.replace(/\./g, '/') + const groupPath = StringPrototypeReplace(namespace, /\./g, '/' as any) return { type: 'jar', url: `https://repo1.maven.org/maven2/${groupPath}/${name}/${version}/${name}-${version}.jar`, diff --git a/src/validate.ts b/src/validate.ts index 6bc733d..58b7d5f 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -4,16 +4,15 @@ */ import { PurlError } from './error.js' import { isNullishOrEmptyString } from './lang.js' +import { + ReflectApply, + StringPrototypeCharCodeAt, + StringPrototypeIncludes, +} from './primordials.js' import { isNonEmptyString } from './strings.js' import type { QualifiersObject } from './purl-component.js' -// IMPORTANT: Do not use destructuring here - use direct assignment instead -// tsgo has a bug that incorrectly transpiles destructured exports, resulting in -// `exports.ReflectApply = void 0;` which causes runtime errors -// See: https://github.com/SocketDev/socket-packageurl-js/issues/3 -const ReflectApply = Reflect.apply - /** * Validate that component is empty for specific package type. */ @@ -127,7 +126,7 @@ function validateQualifierKey( // The key must be composed only of ASCII letters and numbers, // '.', '-' and '_' (period, dash and underscore) for (let i = 0, { length } = key as string; i < length; i += 1) { - const code = (key as string).charCodeAt(i) + const code = StringPrototypeCharCodeAt(key as string, i) // biome-ignore format: newlines if ( !( @@ -252,7 +251,7 @@ function validateStartsWithoutNumber( const { throws = false } = typeof options === 'boolean' ? { throws: options } : (options ?? {}) if (isNonEmptyString(value)) { - const code = value.charCodeAt(0) + const code = StringPrototypeCharCodeAt(value, 0) if (code >= 48 /*'0'*/ && code <= 57 /*'9'*/) { if (throws) { throw new PurlError(`${name} "${value}" cannot start with a number`) @@ -275,13 +274,23 @@ function validateStrings( // Support both legacy boolean parameter and new options object for backward compatibility const { throws = false } = typeof options === 'boolean' ? { throws: options } : (options ?? {}) - if (value === null || value === undefined || typeof value === 'string') { + if (value === null || value === undefined) { return true } - if (throws) { - throw new PurlError(`"${name}" must be a string`) + if (typeof value !== 'string') { + if (throws) { + throw new PurlError(`"${name}" must be a string`) + } + return false } - return false + // Reject null bytes which cause truncation in C-based consumers + if (StringPrototypeIncludes(value, '\x00')) { + if (throws) { + throw new PurlError(`"${name}" must not contain null bytes`) + } + return false + } + return true } /** @@ -319,7 +328,7 @@ function validateType( // The package type is composed only of ASCII letters and numbers, // '.' (period), and '-' (dash) for (let i = 0, { length } = type as string; i < length; i += 1) { - const code = (type as string).charCodeAt(i) + const code = StringPrototypeCharCodeAt(type as string, i) // biome-ignore format: newlines if ( !( diff --git a/src/vers.ts b/src/vers.ts new file mode 100644 index 0000000..5eed70b --- /dev/null +++ b/src/vers.ts @@ -0,0 +1,433 @@ +/** + * @fileoverview VERS (VErsion Range Specifier) implementation. + * + * Implements the VERS specification for version range matching. + * VERS is a companion standard to PURL, currently in pre-standard draft + * with Ecma submission planned for late 2026. + * + * **Early adoption warning:** The VERS spec is not yet finalized. This + * implementation covers the semver scheme and common aliases (npm, cargo, + * golang, etc.). Additional version schemes may be added as the spec matures. + * + * @see https://github.com/package-url/vers-spec + */ + +import { PurlError } from './error.js' +import { + ArrayPrototypeJoin, + ArrayPrototypePush, + ObjectFreeze, + RegExpPrototypeExec, + RegExpPrototypeTest, + SetCtor, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeStartsWith, + StringPrototypeToLowerCase, + StringPrototypeTrim, +} from './primordials.js' +import { isSemverString } from './strings.js' + +/** + * Valid VERS comparator operators. + */ +type VersComparator = '=' | '!=' | '<' | '<=' | '>' | '>=' + +/** + * Special wildcard comparator matching all versions. + */ +type VersWildcard = '*' + +/** + * A single version constraint within a VERS range. + */ +type VersConstraint = { + comparator: VersComparator | VersWildcard + version: string +} + +/** + * Parsed semver components for comparison. + */ +type SemverParts = { + major: number + minor: number + patch: number + prerelease: string[] +} + +// Schemes that use semver comparison +const SEMVER_SCHEMES: ReadonlySet = ObjectFreeze( + new SetCtor([ + 'semver', + 'npm', + 'cargo', + 'golang', + 'hex', + 'pub', + 'cran', + 'gem', + 'swift', + ]), +) + +// Valid comparator prefixes sorted by length (longest first for greedy matching) +const COMPARATORS: readonly string[] = ObjectFreeze([ + '!=', + '<=', + '>=', + '<', + '>', + '=', +]) + +const DIGITS_ONLY = ObjectFreeze(/^\d+$/) + +const regexSemverNumberedGroups = ObjectFreeze( + /^(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-]+)*))?$/, +) + +/** + * Parse a semver string into comparable components. + */ +function parseSemver(version: string): SemverParts { + const match = RegExpPrototypeExec(regexSemverNumberedGroups, version) + if (!match) { + throw new PurlError(`invalid semver version "${version}"`) + } + const major = Number(match[1]) + const minor = Number(match[2]) + const patch = Number(match[3]) + // Guard against precision loss with numbers above MAX_SAFE_INTEGER + if ( + major > Number.MAX_SAFE_INTEGER || + minor > Number.MAX_SAFE_INTEGER || + patch > Number.MAX_SAFE_INTEGER + ) { + throw new PurlError( + `version component exceeds maximum safe integer in "${version}"`, + ) + } + return { + major, + minor, + patch, + prerelease: match[4] ? StringPrototypeSplit(match[4], '.' as any) : [], + } +} + +/** + * Compare two prerelease identifier arrays per semver spec. + * Returns -1, 0, or 1. + */ +function comparePrereleases(a: string[], b: string[]): number { + // No prerelease has higher precedence than any prerelease + if (a.length === 0 && b.length === 0) return 0 + if (a.length === 0) return 1 + if (b.length === 0) return -1 + + const len = Math.min(a.length, b.length) + for (let i = 0; i < len; i += 1) { + const ai = a[i]! + const bi = b[i]! + if (ai === bi) continue + const aNum = RegExpPrototypeTest(DIGITS_ONLY, ai) + const bNum = RegExpPrototypeTest(DIGITS_ONLY, bi) + // Numeric identifiers always have lower precedence than alphanumeric + if (aNum && bNum) { + const diff = Number(ai) - Number(bi) + if (diff !== 0) return diff < 0 ? -1 : 1 + } else if (aNum) { + return -1 + } else if (bNum) { + return 1 + } else { + // Alphanumeric: lexicographic comparison + if (ai < bi) return -1 + if (ai > bi) return 1 + } + } + // Larger set of pre-release fields has higher precedence + if (a.length !== b.length) { + return a.length < b.length ? -1 : 1 + } + return 0 +} + +/** + * Compare two semver version strings. + * Returns -1 if a < b, 0 if a === b, 1 if a > b. + * Build metadata is ignored per semver spec. + */ +function compareSemver(a: string, b: string): -1 | 0 | 1 { + const pa = parseSemver(a) + const pb = parseSemver(b) + // Compare major.minor.patch + if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1 + if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1 + if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1 + // Compare prerelease + const pre = comparePrereleases(pa.prerelease, pb.prerelease) + if (pre !== 0) return pre < 0 ? -1 : 1 + return 0 +} + +/** + * Parse a single constraint string into comparator and version. + */ +function parseConstraint(raw: string): VersConstraint { + const trimmed = StringPrototypeTrim(raw) + if (trimmed === '*') { + return ObjectFreeze({ + __proto__: null, + comparator: '*', + version: '*', + } as VersConstraint) + } + for (let i = 0, { length } = COMPARATORS; i < length; i += 1) { + const op = COMPARATORS[i]! + if (StringPrototypeStartsWith(trimmed, op)) { + const version = StringPrototypeTrim( + StringPrototypeSlice(trimmed, op.length), + ) + if (version.length === 0) { + throw new PurlError(`empty version after comparator "${op}"`) + } + return ObjectFreeze({ + __proto__: null, + comparator: op as VersComparator, + version, + } as VersConstraint) + } + } + // Bare version implies equality + if (trimmed.length === 0) { + throw new PurlError('empty constraint') + } + return ObjectFreeze({ + __proto__: null, + comparator: '=', + version: trimmed, + } as VersConstraint) +} + +/** + * VERS (VErsion Range Specifier) parser and evaluator. + * + * **Early adoption:** The VERS spec is pre-standard draft. This implementation + * supports semver-based schemes (npm, cargo, golang, gem, etc.). Additional + * version schemes may be added as the spec matures. + * + * @example + * ```typescript + * const range = Vers.parse('vers:npm/>=1.0.0|<2.0.0') + * range.contains('1.5.0') // true + * range.contains('2.0.0') // false + * range.toString() // 'vers:npm/>=1.0.0|<2.0.0' + * + * // Wildcard matches all versions + * Vers.parse('vers:semver/*').contains('999.0.0') // true + * ``` + */ +class Vers { + readonly scheme: string + readonly constraints: readonly VersConstraint[] + + private constructor(scheme: string, constraints: VersConstraint[]) { + this.scheme = scheme + this.constraints = ObjectFreeze(constraints) + ObjectFreeze(this) + } + + /** + * Parse a VERS string. + * + * @param versStr - VERS string (e.g., 'vers:npm/>=1.0.0|<2.0.0') + * @returns Vers instance + * @throws {PurlError} If the string is not a valid VERS + */ + static parse(versStr: string): Vers { + return Vers.fromString(versStr) + } + + /** + * Parse a VERS string. + * + * @param versStr - VERS string (e.g., 'vers:npm/>=1.0.0|<2.0.0') + * @returns Vers instance + * @throws {PurlError} If the string is not a valid VERS + */ + static fromString(versStr: string): Vers { + if (typeof versStr !== 'string' || versStr.length === 0) { + throw new PurlError('VERS string is required') + } + + // Must start with 'vers:' + if (!StringPrototypeStartsWith(versStr, 'vers:')) { + throw new PurlError('VERS must start with "vers:" scheme') + } + + const remainder = StringPrototypeSlice(versStr, 5) // after 'vers:' + const slashIndex = StringPrototypeIndexOf(remainder, '/') + if (slashIndex === -1 || slashIndex === 0) { + throw new PurlError('VERS must contain a version scheme before "/"') + } + + const scheme = StringPrototypeToLowerCase( + StringPrototypeSlice(remainder, 0, slashIndex), + ) + const constraintsStr = StringPrototypeSlice(remainder, slashIndex + 1) + + if (constraintsStr.length === 0) { + throw new PurlError('VERS must contain at least one constraint') + } + + // Parse constraints + const rawConstraints = StringPrototypeSplit(constraintsStr, '|' as any) + + // Limit constraint count to prevent resource exhaustion + const MAX_CONSTRAINTS = 1000 + if (rawConstraints.length > MAX_CONSTRAINTS) { + throw new PurlError( + `VERS exceeds maximum of ${MAX_CONSTRAINTS} constraints`, + ) + } + + const constraints: VersConstraint[] = [] + + for (let i = 0, { length } = rawConstraints; i < length; i += 1) { + const constraint = parseConstraint(rawConstraints[i]!) + ArrayPrototypePush(constraints, constraint) + } + + // Validate: wildcard must be alone + if (constraints.length > 1) { + for (let i = 0, { length } = constraints; i < length; i += 1) { + if (constraints[i]!.comparator === '*') { + throw new PurlError('wildcard "*" must be the only constraint') + } + } + } + + // Validate versions for semver schemes + if (SEMVER_SCHEMES.has(scheme)) { + for (let i = 0, { length } = constraints; i < length; i += 1) { + const c = constraints[i]! + if (c.comparator !== '*' && !isSemverString(c.version)) { + throw new PurlError( + `invalid semver version "${c.version}" in VERS constraint`, + ) + } + } + } + + return new Vers(scheme, constraints) + } + + /** + * Check if a version is contained within this VERS range. + * + * Implements the VERS containment algorithm for semver-based schemes. + * + * @param version - Version string to check + * @returns true if the version matches the range + * @throws {PurlError} If the scheme is not supported + */ + contains(version: string): boolean { + if (!SEMVER_SCHEMES.has(this.scheme)) { + throw new PurlError( + `unsupported VERS scheme "${this.scheme}" for containment check`, + ) + } + + const { constraints } = this + + // Wildcard matches everything + if (constraints.length === 1 && constraints[0]!.comparator === '*') { + return true + } + + // Check equals and not-equals first + for (let i = 0, { length } = constraints; i < length; i += 1) { + const c = constraints[i]! + const cmp = compareSemver(version, c.version) + if (c.comparator === '!=') { + if (cmp === 0) return false + } else if (c.comparator === '=') { + if (cmp === 0) return true + } + } + + // Filter to range constraints (not = or !=) + const ranges: VersConstraint[] = [] + for (let i = 0, { length } = constraints; i < length; i += 1) { + const c = constraints[i]! + if (c.comparator !== '=' && c.comparator !== '!=') { + ArrayPrototypePush(ranges, c) + } + } + + if (ranges.length === 0) { + return false + } + + // Evaluate range constraints + // Per the VERS spec, constraints are sorted and form alternating intervals + for (let i = 0, { length } = ranges; i < length; i += 1) { + const c = ranges[i]! + const cmp = compareSemver(version, c.version) + + if (c.comparator === '>=') { + if (cmp < 0) return false + // Check if next constraint bounds the range + const next = ranges[i + 1] + if (!next) return true + const cmpNext = compareSemver(version, next.version) + if (next.comparator === '<') return cmpNext < 0 + if (next.comparator === '<=') return cmpNext <= 0 + /* c8 ignore next -- Defensive: next is not < or <=, skip it. */ + i += 1 + } else if (c.comparator === '>') { + if (cmp <= 0) return false + const next = ranges[i + 1] + if (!next) return true + const cmpNext = compareSemver(version, next.version) + if (next.comparator === '<') return cmpNext < 0 + if (next.comparator === '<=') return cmpNext <= 0 + /* c8 ignore next -- Defensive: next is not < or <=, skip it. */ + i += 1 + } else if (c.comparator === '<' || c.comparator === '<=') { + // Leading less-than: version must be below this bound + const cmpVal = compareSemver(version, c.version) + if (c.comparator === '<' && cmpVal < 0) return true + if (c.comparator === '<=' && cmpVal <= 0) return true + return false + } + } + /* c8 ignore next -- Defensive: all constraint paths return above. */ + return false + } + + /** + * Serialize to canonical VERS string. + */ + toString(): string { + const parts: string[] = [] + for (let i = 0, { length } = this.constraints; i < length; i += 1) { + const c = this.constraints[i]! + if (c.comparator === '*') { + ArrayPrototypePush(parts, '*') + } else if (c.comparator === '=') { + ArrayPrototypePush(parts, c.version) + } else { + ArrayPrototypePush(parts, `${c.comparator}${c.version}`) + } + } + return `vers:${this.scheme}/${ArrayPrototypeJoin(parts, '|')}` + } +} + +export { Vers } + +export type { VersComparator, VersConstraint, VersWildcard } diff --git a/test/package-url.test.mts b/test/package-url.test.mts index 4d32c8b..f7fd017 100644 --- a/test/package-url.test.mts +++ b/test/package-url.test.mts @@ -40,11 +40,12 @@ import { createTestPurl } from './utils/test-helpers.mjs' describe('PackageURL', () => { describe('KnownQualifierNames', () => { it.each([ - ['RepositoryUrl', 'repository_url'], + ['Checksum', 'checksum'], ['DownloadUrl', 'download_url'], - ['VcsUrl', 'vcs_url'], ['FileName', 'file_name'], - ['Checksum', 'checksum'], + ['RepositoryUrl', 'repository_url'], + ['VcsUrl', 'vcs_url'], + ['Vers', 'vers'], ])('maps: %s => %s', (name, expectedValue) => { expect( PackageURL.KnownQualifierNames[ @@ -73,6 +74,135 @@ describe('PackageURL', () => { }) }) + describe('isValid', () => { + it('should return true for valid PURLs', () => { + expect(PackageURL.isValid('pkg:npm/lodash@4.17.21')).toBe(true) + expect(PackageURL.isValid('pkg:maven/org.apache/commons@1.0')).toBe(true) + }) + + it('should return false for invalid PURLs', () => { + expect(PackageURL.isValid('not a purl')).toBe(false) + expect(PackageURL.isValid('')).toBe(false) + expect(PackageURL.isValid(null)).toBe(false) + expect(PackageURL.isValid(123)).toBe(false) + }) + }) + + describe('fromUrl', () => { + it('should convert registry URLs to PackageURLs', () => { + const purl = PackageURL.fromUrl('https://www.npmjs.com/package/lodash') + expect(purl?.type).toBe('npm') + expect(purl?.name).toBe('lodash') + }) + + it('should return undefined for unrecognized URLs', () => { + expect(PackageURL.fromUrl('https://example.com/foo')).toBeUndefined() + }) + }) + + describe('with* immutable copy methods', () => { + const purl = PackageURL.fromString('pkg:npm/%40babel/core@7.0.0') + + it('withVersion should return new instance with different version', () => { + const updated = purl.withVersion('8.0.0') + expect(updated.version).toBe('8.0.0') + expect(purl.version).toBe('7.0.0') // original unchanged + expect(updated.name).toBe('core') + expect(updated.namespace).toBe('@babel') + }) + + it('withVersion(undefined) should remove version', () => { + const updated = purl.withVersion(undefined) + expect(updated.version).toBeUndefined() + }) + + it('withNamespace should return new instance', () => { + const updated = purl.withNamespace('@scope') + expect(updated.namespace).toBe('@scope') + expect(purl.namespace).toBe('@babel') + }) + + it('withQualifier should add a qualifier', () => { + const updated = purl.withQualifier('arch', 'x86_64') + expect(updated.qualifiers).toEqual({ arch: 'x86_64' }) + expect(purl.qualifiers).toBeUndefined() + }) + + it('withQualifier should preserve existing qualifiers', () => { + const p = PackageURL.fromString('pkg:npm/foo@1.0?a=1') + const updated = p.withQualifier('b', '2') + expect(updated.qualifiers).toEqual({ a: '1', b: '2' }) + }) + + it('withQualifiers should replace all qualifiers', () => { + const updated = purl.withQualifiers({ platform: 'linux-x64' }) + expect(updated.qualifiers).toEqual({ platform: 'linux-x64' }) + }) + + it('withQualifiers(undefined) should remove all qualifiers', () => { + const p = PackageURL.fromString('pkg:npm/foo@1.0?a=1') + const updated = p.withQualifiers(undefined) + expect(updated.qualifiers).toBeUndefined() + }) + + it('withSubpath should set subpath', () => { + const updated = purl.withSubpath('dist/index.js') + expect(updated.subpath).toBe('dist/index.js') + expect(purl.subpath).toBeUndefined() + }) + + it('withSubpath(undefined) should remove subpath', () => { + const p = PackageURL.fromString('pkg:npm/foo@1.0#dist') + const updated = p.withSubpath(undefined) + expect(updated.subpath).toBeUndefined() + }) + }) + + describe('toSpec', () => { + it('should return name only for simple packages', () => { + const purl = PackageURL.fromString('pkg:npm/express') + expect(purl.toSpec()).toBe('express') + }) + + it('should return name@version', () => { + const purl = PackageURL.fromString('pkg:npm/lodash@4.17.21') + expect(purl.toSpec()).toBe('lodash@4.17.21') + }) + + it('should include namespace with slash separator', () => { + const purl = PackageURL.fromString('pkg:npm/%40babel/core@7.0.0') + expect(purl.toSpec()).toBe('%40babel/core@7.0.0') + }) + + it('should include qualifiers', () => { + const purl = PackageURL.fromString( + 'pkg:npm/lodash@4.17.21?repository_url=https://example.com', + ) + expect(purl.toSpec()).toBe( + 'lodash@4.17.21?repository_url=https%3A%2F%2Fexample.com', + ) + }) + + it('should include subpath', () => { + const purl = PackageURL.fromString( + 'pkg:npm/lodash@4.17.21#dist/lodash.js', + ) + expect(purl.toSpec()).toBe('lodash@4.17.21#dist/lodash.js') + }) + + it('should handle maven namespace', () => { + const purl = PackageURL.fromString( + 'pkg:maven/org.apache.commons/commons-lang3@3.12.0', + ) + expect(purl.toSpec()).toBe('org.apache.commons/commons-lang3@3.12.0') + }) + + it('should handle package with no version', () => { + const purl = PackageURL.fromString('pkg:github/lodash/lodash') + expect(purl.toSpec()).toBe('lodash/lodash') + }) + }) + describe('constructor', () => { const paramMap = { type: 0, diff --git a/test/purl-edge-cases.test.mts b/test/purl-edge-cases.test.mts index ea04d89..bb9d2fe 100644 --- a/test/purl-edge-cases.test.mts +++ b/test/purl-edge-cases.test.mts @@ -32,6 +32,7 @@ import { describe, expect, it } from 'vitest' import { encodeComponent, encodeNamespace, + encodeQualifierParam, encodeSubpath, encodeVersion, } from '../src/encode.js' @@ -603,6 +604,78 @@ describe('Edge cases and additional coverage', () => { expect(str1).toContain('key-with-plus=value%2Bplus') }) + it('should reject null bytes in name', () => { + expect( + () => + new PackageURL( + 'npm', + undefined, + 'foo\x00bar', + '1.0.0', + undefined, + undefined, + ), + ).toThrow('must not contain null bytes') + }) + + it('should reject null bytes in namespace', () => { + expect( + () => + new PackageURL( + 'npm', + '@scope\x00evil', + 'name', + '1.0.0', + undefined, + undefined, + ), + ).toThrow('must not contain null bytes') + }) + + it('should reject null bytes in version', () => { + expect( + () => + new PackageURL( + 'npm', + undefined, + 'name', + '1.0.0\x00evil', + undefined, + undefined, + ), + ).toThrow('must not contain null bytes') + }) + + it('should reject null bytes in subpath', () => { + expect( + () => + new PackageURL( + 'npm', + undefined, + 'name', + '1.0.0', + undefined, + 'path\x00evil', + ), + ).toThrow('must not contain null bytes') + }) + + it('should test encodeQualifierParam directly', () => { + // Test with non-empty string value + expect(encodeQualifierParam('hello world')).toBe('hello%20world') + expect(encodeQualifierParam('value+plus')).toBe('value%2Bplus') + // Test with empty/non-string returns empty + expect(encodeQualifierParam('')).toBe('') + expect(encodeQualifierParam(null)).toBe('') + expect(encodeQualifierParam(undefined)).toBe('') + }) + + it('should test encodeSubpath with empty values', () => { + expect(encodeSubpath('')).toBe('') + expect(encodeSubpath(null)).toBe('') + expect(encodeSubpath(undefined)).toBe('') + }) + // Test objects module - recursiveFreeze edge cases it.each([ ['already frozen objects', { key: 'value' }], diff --git a/test/purl-global-mocking.isolated.test.mts b/test/purl-global-mocking.isolated.test.mts index 946cd08..e822794 100644 --- a/test/purl-global-mocking.isolated.test.mts +++ b/test/purl-global-mocking.isolated.test.mts @@ -38,30 +38,26 @@ import { describe, expect, it } from 'vitest' import { PackageURL } from '../src/package-url.js' describe('Global object mocking tests', () => { - describe('URL constructor error handling', () => { - it('should handle URL parsing error when URL constructor throws', () => { - // Test package-url.js lines 144-148 - URL parsing failure path - // We need to mock URL constructor to throw an error + describe('Primordials protect against global tampering', () => { + it('should use captured URL constructor even when global.URL is replaced', () => { + // Primordials capture built-in references at module load time. + // Replacing global.URL after import should NOT affect PackageURL. const originalURL = global.URL - let callCount = 0 - // Mock URL to throw error global.URL = class MockURL { constructor(_url: string) { - callCount++ - // Always throw to trigger the catch block - throw new Error('Mocked URL error') + throw new Error('Mocked URL error - should not be called') } } as any try { - expect(() => PackageURL.fromString('pkg:type/name')).toThrow( - 'failed to parse as URL', - ) - // Make sure our mock was actually called - expect(callCount).toBeGreaterThan(0) + // PackageURL uses the captured URL constructor from primordials, + // so it should still work correctly despite global.URL being tampered. + const purl = PackageURL.fromString('pkg:npm/lodash@4.17.21') + expect(purl.type).toBe('npm') + expect(purl.name).toBe('lodash') + expect(purl.version).toBe('4.17.21') } finally { - // Critical: restore original URL in finally block global.URL = originalURL } }) diff --git a/test/purl-types.test.mts b/test/purl-types.test.mts index 3ad87d0..d95d16b 100644 --- a/test/purl-types.test.mts +++ b/test/purl-types.test.mts @@ -29,8 +29,10 @@ import { describe, expect, it } from 'vitest' import npmBuiltinNames from '../data/npm/builtin-names.json' import npmLegacyNames from '../data/npm/legacy-names.json' +import { PurlError } from '../src/error.js' import { PackageURL } from '../src/package-url.js' import { validate as validateBazel } from '../src/purl-types/bazel.js' +import { validate as validateVscodeExtension } from '../src/purl-types/vscode-extension.js' function getNpmId(purl: any) { const { name, namespace } = purl @@ -603,4 +605,302 @@ describe('PackageURL type-specific tests', () => { expect(purl.name).toBe('MyPackage') }) }) + + describe('oci', () => { + it('should lowercase version per spec', () => { + const purl = PackageURL.fromString( + 'pkg:oci/myimage@SHA256:ABCDEF1234567890', + ) + expect(purl.name).toBe('myimage') + expect(purl.version).toBe('sha256:abcdef1234567890') + }) + }) + + describe('pypi', () => { + it('should lowercase version per spec', () => { + const purl = PackageURL.fromString('pkg:pypi/Django@3.0.0RC1') + expect(purl.name).toBe('django') + expect(purl.version).toBe('3.0.0rc1') + }) + }) + + describe('vscode-extension', () => { + describe('validate', () => { + // Spec examples from purl-spec PR #673 + it('should accept spec-compliant PURLs', () => { + expect( + validateVscodeExtension( + { + name: 'python', + namespace: 'ms-python', + version: '2023.25.10292213', + }, + false, + ), + ).toBe(true) + expect( + validateVscodeExtension( + { name: 'java', namespace: 'redhat', version: '1.46.2025091308' }, + false, + ), + ).toBe(true) + expect( + validateVscodeExtension( + { name: 'go', namespace: 'golang', version: '0.39.1' }, + false, + ), + ).toBe(true) + }) + + it('should accept PURLs without version', () => { + expect( + validateVscodeExtension({ name: 'java', namespace: 'redhat' }, false), + ).toBe(true) + }) + + it('should accept valid platform qualifiers', () => { + const platforms = [ + 'universal', + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', + 'win32-arm64', + ] + for (const platform of platforms) { + expect( + validateVscodeExtension( + { + name: 'python', + namespace: 'ms-python', + qualifiers: { platform }, + }, + false, + ), + ).toBe(true) + } + }) + + it('should accept valid semver versions', () => { + expect( + validateVscodeExtension( + { name: 'python', namespace: 'ms-python', version: '1.0.0' }, + false, + ), + ).toBe(true) + expect( + validateVscodeExtension( + { name: 'python', namespace: 'ms-python', version: '0.3.0-beta.1' }, + false, + ), + ).toBe(true) + expect( + validateVscodeExtension( + { + name: 'python', + namespace: 'ms-python', + version: '1.0.0+build123', + }, + false, + ), + ).toBe(true) + }) + + it('should require namespace (publisher)', () => { + expect(validateVscodeExtension({ name: 'python' }, false)).toBe(false) + expect(() => validateVscodeExtension({ name: 'python' }, true)).toThrow( + PurlError, + ) + }) + + it('should reject illegal characters in namespace', () => { + const illegal = ['ns|x', 'ns&x', 'ns;x', 'ns`x`', 'ns$(x)', 'ns x'] + for (const namespace of illegal) { + expect( + validateVscodeExtension({ name: 'ext', namespace }, false), + ).toBe(false) + } + expect(() => + validateVscodeExtension({ name: 'ext', namespace: 'ns|x' }, true), + ).toThrow(PurlError) + }) + + it('should reject illegal characters in name', () => { + const illegal = ['ext|x', 'ext&x', 'ext;x', 'ext', 'ext{x}'] + for (const name of illegal) { + expect( + validateVscodeExtension({ name, namespace: 'ms-python' }, false), + ).toBe(false) + } + expect(() => + validateVscodeExtension( + { name: 'ext|x', namespace: 'ms-python' }, + true, + ), + ).toThrow(PurlError) + }) + + it('should reject non-semver version strings', () => { + const invalid = ['not-semver', 'latest', '1.0', '1'] + for (const version of invalid) { + expect( + validateVscodeExtension( + { name: 'python', namespace: 'ms-python', version }, + false, + ), + ).toBe(false) + } + expect(() => + validateVscodeExtension( + { name: 'python', namespace: 'ms-python', version: 'latest' }, + true, + ), + ).toThrow(PurlError) + }) + + it('should reject illegal characters in platform qualifier', () => { + const illegal = ['linux x64', 'linux|x64', 'linux&x64', 'linux;x64'] + for (const platform of illegal) { + expect( + validateVscodeExtension( + { + name: 'python', + namespace: 'ms-python', + qualifiers: { platform }, + }, + false, + ), + ).toBe(false) + } + expect(() => + validateVscodeExtension( + { + name: 'python', + namespace: 'ms-python', + qualifiers: { platform: 'linux|x64' }, + }, + true, + ), + ).toThrow(PurlError) + }) + }) + + describe('normalize', () => { + it('should lowercase namespace, name, and version per spec', () => { + const purl = PackageURL.fromString( + 'pkg:vscode-extension/MS-Python/Python@1.0.0-BETA', + ) + expect(purl.namespace).toBe('ms-python') + expect(purl.name).toBe('python') + expect(purl.version).toBe('1.0.0-beta') + }) + }) + + describe('PackageURL construction', () => { + it('should construct valid vscode-extension PURLs', () => { + const purl = new PackageURL( + 'vscode-extension', + 'ms-python', + 'python', + '1.0.0', + undefined, + undefined, + ) + expect(purl.type).toBe('vscode-extension') + expect(purl.namespace).toBe('ms-python') + expect(purl.name).toBe('python') + expect(purl.version).toBe('1.0.0') + }) + + it('should reject illegal characters in namespace during construction', () => { + expect( + () => + new PackageURL( + 'vscode-extension', + 'pub|x', + 'ext', + '1.0.0', + undefined, + undefined, + ), + ).toThrow(PurlError) + }) + + it('should reject illegal characters in name during construction', () => { + expect( + () => + new PackageURL( + 'vscode-extension', + 'publisher', + 'ext&x', + '1.0.0', + undefined, + undefined, + ), + ).toThrow(PurlError) + }) + + it('should reject non-semver version during construction', () => { + expect( + () => + new PackageURL( + 'vscode-extension', + 'publisher', + 'ext', + 'not-a-version', + undefined, + undefined, + ), + ).toThrow(PurlError) + }) + + it('should reject illegal characters in platform qualifier during construction', () => { + expect( + () => + new PackageURL( + 'vscode-extension', + 'publisher', + 'ext', + '1.0.0', + { platform: 'linux|x64' }, + undefined, + ), + ).toThrow(PurlError) + }) + }) + + describe('PackageURL.fromString', () => { + it('should parse spec-compliant PURL strings', () => { + const purl = PackageURL.fromString( + 'pkg:vscode-extension/ms-python/python@2023.25.10292213', + ) + expect(purl.type).toBe('vscode-extension') + expect(purl.namespace).toBe('ms-python') + expect(purl.name).toBe('python') + expect(purl.version).toBe('2023.25.10292213') + }) + + it('should parse PURL with platform qualifier', () => { + const purl = PackageURL.fromString( + 'pkg:vscode-extension/golang/go@0.39.1?platform=win32-x64', + ) + expect(purl.qualifiers).toEqual({ platform: 'win32-x64' }) + }) + + it('should reject encoded illegal characters in version', () => { + // %26 decodes to &, which is not valid in a semver version + expect(() => + PackageURL.fromString('pkg:vscode-extension/publisher/ext@1.0.0%26x'), + ).toThrow(PurlError) + }) + + it('should reject encoded illegal characters in namespace', () => { + // %7C decodes to |, which is not a valid publisher character + expect(() => + PackageURL.fromString('pkg:vscode-extension/%7Cx/ext@1.0.0'), + ).toThrow(PurlError) + }) + }) + }) }) diff --git a/test/strings.test.mts b/test/strings.test.mts index b405871..fd08312 100644 --- a/test/strings.test.mts +++ b/test/strings.test.mts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest' import { + containsInjectionCharacters, isBlank, isNonEmptyString, isSemverString, @@ -19,6 +20,44 @@ import { } from '../src/strings.js' describe('String utilities', () => { + describe('containsInjectionCharacters', () => { + it('should return false for valid package identifier characters', () => { + expect(containsInjectionCharacters('ms-python')).toBe(false) + expect(containsInjectionCharacters('python')).toBe(false) + expect(containsInjectionCharacters('1.0.0')).toBe(false) + expect(containsInjectionCharacters('1.0.0-alpha.1+build.2')).toBe(false) + expect(containsInjectionCharacters('linux-x64')).toBe(false) + expect(containsInjectionCharacters('@scope/name')).toBe(false) + expect(containsInjectionCharacters('my_package')).toBe(false) + expect(containsInjectionCharacters('')).toBe(false) + }) + + it('should detect illegal special characters', () => { + // Pipe, ampersand, semicolon + expect(containsInjectionCharacters('a|b')).toBe(true) + expect(containsInjectionCharacters('a&b')).toBe(true) + expect(containsInjectionCharacters('a;b')).toBe(true) + // Backtick, dollar + expect(containsInjectionCharacters('a`b`')).toBe(true) + expect(containsInjectionCharacters('a$(b)')).toBe(true) + // Angle brackets, braces + expect(containsInjectionCharacters('ab')).toBe(true) + expect(containsInjectionCharacters('a{b}')).toBe(true) + // Hash, backslash, parentheses + expect(containsInjectionCharacters('a#b')).toBe(true) + expect(containsInjectionCharacters('a\\b')).toBe(true) + expect(containsInjectionCharacters('a(b)')).toBe(true) + }) + + it('should detect embedded whitespace', () => { + expect(containsInjectionCharacters('a b')).toBe(true) + expect(containsInjectionCharacters('a\tb')).toBe(true) + expect(containsInjectionCharacters('a\nb')).toBe(true) + expect(containsInjectionCharacters('a\rb')).toBe(true) + }) + }) + describe('isBlank', () => { it('should return true for whitespace-only strings', () => { expect(isBlank('')).toBe(true) diff --git a/test/url-to-purl.test.mts b/test/url-to-purl.test.mts new file mode 100644 index 0000000..b77db25 --- /dev/null +++ b/test/url-to-purl.test.mts @@ -0,0 +1,976 @@ +/*! +Copyright (c) the purl authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/** + * @fileoverview Unit tests for URL-to-PURL conversion functionality. + */ +import { describe, expect, it } from 'vitest' + +import { UrlConverter } from '../src/url-converter.js' + +// Import PackageURL to trigger registration +import '../src/package-url.js' + +describe('UrlConverter.fromUrl', () => { + describe('npm — registry.npmjs.org', () => { + it.each([ + [ + 'https://registry.npmjs.org/@babel/core', + 'npm', + '@babel', + 'core', + undefined, + ], + [ + 'https://registry.npmjs.org/@babel/core/7.0.0', + 'npm', + '@babel', + 'core', + '7.0.0', + ], + [ + 'https://registry.npmjs.org/lodash', + 'npm', + undefined, + 'lodash', + undefined, + ], + [ + 'https://registry.npmjs.org/lodash/4.17.21', + 'npm', + undefined, + 'lodash', + '4.17.21', + ], + [ + 'https://registry.npmjs.org/@babel/core/-/core-7.0.0.tgz', + 'npm', + '@babel', + 'core', + '7.0.0', + ], + [ + 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz', + 'npm', + undefined, + 'lodash', + '4.17.21', + ], + ])( + 'should parse %s', + (url, expectedType, expectedNamespace, expectedName, expectedVersion) => { + const result = UrlConverter.fromUrl(url) + expect(result).toBeDefined() + expect(result!.type).toBe(expectedType) + expect(result!.namespace).toBe(expectedNamespace) + expect(result!.name).toBe(expectedName) + expect(result!.version).toBe(expectedVersion) + }, + ) + }) + + describe('npm — www.npmjs.com', () => { + it.each([ + [ + 'https://www.npmjs.com/package/@babel/core', + 'npm', + '@babel', + 'core', + undefined, + ], + [ + 'https://www.npmjs.com/package/@babel/core/v/7.0.0', + 'npm', + '@babel', + 'core', + '7.0.0', + ], + [ + 'https://www.npmjs.com/package/lodash', + 'npm', + undefined, + 'lodash', + undefined, + ], + [ + 'https://www.npmjs.com/package/lodash/v/4.17.21', + 'npm', + undefined, + 'lodash', + '4.17.21', + ], + ])( + 'should parse %s', + (url, expectedType, expectedNamespace, expectedName, expectedVersion) => { + const result = UrlConverter.fromUrl(url) + expect(result).toBeDefined() + expect(result!.type).toBe(expectedType) + expect(result!.namespace).toBe(expectedNamespace) + expect(result!.name).toBe(expectedName) + expect(result!.version).toBe(expectedVersion) + }, + ) + }) + + describe('pypi — pypi.org', () => { + it.each([ + ['https://pypi.org/project/requests/', 'pypi', 'requests', undefined], + [ + 'https://pypi.org/project/requests/2.28.0/', + 'pypi', + 'requests', + '2.28.0', + ], + ['https://pypi.org/project/Django/', 'pypi', 'django', undefined], + ])( + 'should parse %s', + (url, expectedType, expectedName, expectedVersion) => { + const result = UrlConverter.fromUrl(url) + expect(result).toBeDefined() + expect(result!.type).toBe(expectedType) + expect(result!.name).toBe(expectedName) + expect(result!.version).toBe(expectedVersion) + }, + ) + }) + + describe('maven — repo1.maven.org', () => { + it('should parse https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/3.12.0/', () => { + const result = UrlConverter.fromUrl( + 'https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/3.12.0/', + ) + expect(result).toBeDefined() + expect(result!.type).toBe('maven') + expect(result!.namespace).toBe('org.apache.commons') + expect(result!.name).toBe('commons-lang3') + expect(result!.version).toBe('3.12.0') + }) + }) + + describe('gem — rubygems.org', () => { + it.each([ + ['https://rubygems.org/gems/rails', 'gem', 'rails', undefined], + [ + 'https://rubygems.org/gems/rails/versions/7.0.0', + 'gem', + 'rails', + '7.0.0', + ], + ])( + 'should parse %s', + (url, expectedType, expectedName, expectedVersion) => { + const result = UrlConverter.fromUrl(url) + expect(result).toBeDefined() + expect(result!.type).toBe(expectedType) + expect(result!.name).toBe(expectedName) + expect(result!.version).toBe(expectedVersion) + }, + ) + }) + + describe('cargo — crates.io', () => { + it.each([ + ['https://crates.io/crates/serde', 'cargo', 'serde', undefined], + ['https://crates.io/crates/serde/1.0.0', 'cargo', 'serde', '1.0.0'], + [ + 'https://crates.io/api/v1/crates/serde/1.0.0/download', + 'cargo', + 'serde', + '1.0.0', + ], + ])( + 'should parse %s', + (url, expectedType, expectedName, expectedVersion) => { + const result = UrlConverter.fromUrl(url) + expect(result).toBeDefined() + expect(result!.type).toBe(expectedType) + expect(result!.name).toBe(expectedName) + expect(result!.version).toBe(expectedVersion) + }, + ) + }) + + describe('nuget — www.nuget.org and api.nuget.org', () => { + it.each([ + [ + 'https://www.nuget.org/packages/Newtonsoft.Json', + 'nuget', + 'Newtonsoft.Json', + undefined, + ], + [ + 'https://www.nuget.org/packages/Newtonsoft.Json/13.0.1', + 'nuget', + 'Newtonsoft.Json', + '13.0.1', + ], + [ + 'https://api.nuget.org/v3-flatcontainer/newtonsoft.json/13.0.1/newtonsoft.json.13.0.1.nupkg', + 'nuget', + 'newtonsoft.json', + '13.0.1', + ], + ])( + 'should parse %s', + (url, expectedType, expectedName, expectedVersion) => { + const result = UrlConverter.fromUrl(url) + expect(result).toBeDefined() + expect(result!.type).toBe(expectedType) + expect(result!.name).toBe(expectedName) + expect(result!.version).toBe(expectedVersion) + }, + ) + }) + + describe('github — github.com', () => { + it.each([ + [ + 'https://github.com/lodash/lodash', + 'github', + 'lodash', + 'lodash', + undefined, + ], + [ + 'https://github.com/lodash/lodash/tree/v4.17.21', + 'github', + 'lodash', + 'lodash', + 'v4.17.21', + ], + [ + 'https://github.com/lodash/lodash/commit/abc1234', + 'github', + 'lodash', + 'lodash', + 'abc1234', + ], + [ + 'https://github.com/lodash/lodash/releases/tag/v4.17.21', + 'github', + 'lodash', + 'lodash', + 'v4.17.21', + ], + ])( + 'should parse %s', + (url, expectedType, expectedNamespace, expectedName, expectedVersion) => { + const result = UrlConverter.fromUrl(url) + expect(result).toBeDefined() + expect(result!.type).toBe(expectedType) + expect(result!.namespace).toBe(expectedNamespace) + expect(result!.name).toBe(expectedName) + expect(result!.version).toBe(expectedVersion) + }, + ) + }) + + describe('golang — pkg.go.dev', () => { + it.each([ + [ + 'https://pkg.go.dev/github.com/gorilla/mux', + 'golang', + 'github.com/gorilla', + 'mux', + undefined, + ], + [ + 'https://pkg.go.dev/github.com/gorilla/mux@v1.8.0', + 'golang', + 'github.com/gorilla', + 'mux', + 'v1.8.0', + ], + ])( + 'should parse %s', + (url, expectedType, expectedNamespace, expectedName, expectedVersion) => { + const result = UrlConverter.fromUrl(url) + expect(result).toBeDefined() + expect(result!.type).toBe(expectedType) + expect(result!.namespace).toBe(expectedNamespace) + expect(result!.name).toBe(expectedName) + expect(result!.version).toBe(expectedVersion) + }, + ) + }) + + describe('gitlab — gitlab.com', () => { + it('should parse repo URL', () => { + const p = UrlConverter.fromUrl('https://gitlab.com/inkscape/inkscape') + expect(p?.type).toBe('gitlab') + expect(p?.namespace).toBe('inkscape') + expect(p?.name).toBe('inkscape') + }) + + it('should parse tree URL with version', () => { + const p = UrlConverter.fromUrl( + 'https://gitlab.com/inkscape/inkscape/-/tree/v1.3', + ) + expect(p?.version).toBe('v1.3') + }) + + it('should parse commit URL', () => { + const p = UrlConverter.fromUrl( + 'https://gitlab.com/inkscape/inkscape/-/commit/abc1234', + ) + expect(p?.version).toBe('abc1234') + }) + + it('should parse tags URL', () => { + const p = UrlConverter.fromUrl( + 'https://gitlab.com/inkscape/inkscape/-/tags/v1.3', + ) + expect(p?.version).toBe('v1.3') + }) + + it('should return undefined for root', () => { + expect(UrlConverter.fromUrl('https://gitlab.com/')).toBeUndefined() + }) + }) + + describe('bitbucket — bitbucket.org', () => { + it('should parse repo URL', () => { + const p = UrlConverter.fromUrl( + 'https://bitbucket.org/atlassian/python-bitbucket', + ) + expect(p?.type).toBe('bitbucket') + expect(p?.namespace).toBe('atlassian') + expect(p?.name).toBe('python-bitbucket') + }) + + it('should parse commits URL', () => { + const p = UrlConverter.fromUrl( + 'https://bitbucket.org/atlassian/python-bitbucket/commits/abc1234', + ) + expect(p?.version).toBe('abc1234') + }) + + it('should parse src URL', () => { + const p = UrlConverter.fromUrl( + 'https://bitbucket.org/atlassian/python-bitbucket/src/v1.0', + ) + expect(p?.version).toBe('v1.0') + }) + + it('should return undefined for root', () => { + expect(UrlConverter.fromUrl('https://bitbucket.org/')).toBeUndefined() + }) + }) + + describe('composer — packagist.org', () => { + it('should parse package URL', () => { + const p = UrlConverter.fromUrl( + 'https://packagist.org/packages/symfony/console', + ) + expect(p?.type).toBe('composer') + expect(p?.namespace).toBe('symfony') + expect(p?.name).toBe('console') + }) + + it('should return undefined for non-packages path', () => { + expect( + UrlConverter.fromUrl('https://packagist.org/explore'), + ).toBeUndefined() + }) + + it('should return undefined without namespace and name', () => { + expect( + UrlConverter.fromUrl('https://packagist.org/packages'), + ).toBeUndefined() + }) + }) + + describe('hex — hex.pm', () => { + it('should parse package URL', () => { + const p = UrlConverter.fromUrl('https://hex.pm/packages/phoenix') + expect(p?.type).toBe('hex') + expect(p?.name).toBe('phoenix') + }) + + it('should parse package with version', () => { + const p = UrlConverter.fromUrl('https://hex.pm/packages/phoenix/1.7.0') + expect(p?.version).toBe('1.7.0') + }) + + it('should return undefined for non-packages path', () => { + expect(UrlConverter.fromUrl('https://hex.pm/docs')).toBeUndefined() + }) + }) + + describe('pub — pub.dev', () => { + it('should parse package URL', () => { + const p = UrlConverter.fromUrl('https://pub.dev/packages/flutter_bloc') + expect(p?.type).toBe('pub') + expect(p?.name).toBe('flutter_bloc') + }) + + it('should parse package with version', () => { + const p = UrlConverter.fromUrl( + 'https://pub.dev/packages/flutter_bloc/versions/8.1.0', + ) + expect(p?.version).toBe('8.1.0') + }) + + it('should return undefined for non-packages path', () => { + expect(UrlConverter.fromUrl('https://pub.dev/topics')).toBeUndefined() + }) + }) + + describe('docker — hub.docker.com', () => { + it('should parse official image URL', () => { + const p = UrlConverter.fromUrl('https://hub.docker.com/_/nginx') + expect(p?.type).toBe('docker') + expect(p?.namespace).toBe('library') + expect(p?.name).toBe('nginx') + }) + + it('should parse user image URL', () => { + const p = UrlConverter.fromUrl( + 'https://hub.docker.com/r/bitnami/postgresql', + ) + expect(p?.type).toBe('docker') + expect(p?.namespace).toBe('bitnami') + expect(p?.name).toBe('postgresql') + }) + + it('should return undefined for root', () => { + expect(UrlConverter.fromUrl('https://hub.docker.com/')).toBeUndefined() + }) + + it('should return undefined for unrecognized path', () => { + expect( + UrlConverter.fromUrl('https://hub.docker.com/search'), + ).toBeUndefined() + }) + }) + + describe('cocoapods — cocoapods.org', () => { + it('should parse pod URL', () => { + const p = UrlConverter.fromUrl('https://cocoapods.org/pods/Alamofire') + expect(p?.type).toBe('cocoapods') + expect(p?.name).toBe('Alamofire') + }) + + it('should return undefined for non-pods path', () => { + expect( + UrlConverter.fromUrl('https://cocoapods.org/about'), + ).toBeUndefined() + }) + }) + + describe('hackage — hackage.haskell.org', () => { + it('should parse package with version', () => { + const p = UrlConverter.fromUrl( + 'https://hackage.haskell.org/package/aeson-2.1.0.0', + ) + expect(p?.type).toBe('hackage') + expect(p?.name).toBe('aeson') + expect(p?.version).toBe('2.1.0.0') + }) + + it('should parse package without version', () => { + const p = UrlConverter.fromUrl( + 'https://hackage.haskell.org/package/aeson', + ) + expect(p?.name).toBe('aeson') + expect(p?.version).toBeUndefined() + }) + + it('should return undefined for non-package path', () => { + expect( + UrlConverter.fromUrl('https://hackage.haskell.org/browse'), + ).toBeUndefined() + }) + }) + + describe('cran — cran.r-project.org', () => { + it('should return undefined for web/packages URL without version (cran requires version)', () => { + // CRAN type requires a version component, and web URLs don't include one + expect( + UrlConverter.fromUrl( + 'https://cran.r-project.org/web/packages/ggplot2/index.html', + ), + ).toBeUndefined() + }) + + it('should return undefined for non-package path', () => { + expect( + UrlConverter.fromUrl('https://cran.r-project.org/mirrors.html'), + ).toBeUndefined() + }) + }) + + describe('conda — anaconda.org', () => { + it('should parse package URL', () => { + const p = UrlConverter.fromUrl('https://anaconda.org/conda-forge/numpy') + expect(p?.type).toBe('conda') + expect(p?.name).toBe('numpy') + }) + + it('should parse package with version', () => { + const p = UrlConverter.fromUrl( + 'https://anaconda.org/conda-forge/numpy/1.24.0', + ) + expect(p?.version).toBe('1.24.0') + }) + + it('should return undefined for root', () => { + expect(UrlConverter.fromUrl('https://anaconda.org/')).toBeUndefined() + }) + }) + + describe('cpan — metacpan.org', () => { + it('should parse pod URL', () => { + const p = UrlConverter.fromUrl('https://metacpan.org/pod/Moose') + expect(p?.type).toBe('cpan') + expect(p?.name).toBe('Moose') + }) + + it('should parse nested module URL', () => { + const p = UrlConverter.fromUrl( + 'https://metacpan.org/pod/Moose/Meta/Class', + ) + expect(p?.name).toBe('Moose::Meta::Class') + }) + + it('should parse dist URL', () => { + const p = UrlConverter.fromUrl('https://metacpan.org/dist/Moose') + expect(p?.name).toBe('Moose') + }) + + it('should return undefined for non-pod/dist path', () => { + expect(UrlConverter.fromUrl('https://metacpan.org/about')).toBeUndefined() + }) + }) + + describe('huggingface — huggingface.co', () => { + it('should parse model URL', () => { + const p = UrlConverter.fromUrl('https://huggingface.co/microsoft/phi-2') + expect(p?.type).toBe('huggingface') + expect(p?.namespace).toBe('microsoft') + expect(p?.name).toBe('phi-2') + }) + + it('should parse model with tree ref', () => { + const p = UrlConverter.fromUrl( + 'https://huggingface.co/microsoft/phi-2/tree/main', + ) + expect(p?.version).toBe('main') + }) + + it('should parse model with commit ref', () => { + const p = UrlConverter.fromUrl( + 'https://huggingface.co/microsoft/phi-2/commit/abc1234', + ) + expect(p?.version).toBe('abc1234') + }) + + it('should return undefined for reserved paths', () => { + expect( + UrlConverter.fromUrl('https://huggingface.co/docs/transformers'), + ).toBeUndefined() + expect( + UrlConverter.fromUrl('https://huggingface.co/spaces/foo'), + ).toBeUndefined() + }) + + it('should return undefined for root', () => { + expect(UrlConverter.fromUrl('https://huggingface.co/')).toBeUndefined() + }) + }) + + describe('luarocks — luarocks.org', () => { + it('should parse module URL', () => { + const p = UrlConverter.fromUrl( + 'https://luarocks.org/modules/luarocks/luasocket', + ) + expect(p?.type).toBe('luarocks') + expect(p?.namespace).toBe('luarocks') + expect(p?.name).toBe('luasocket') + }) + + it('should parse module with version', () => { + const p = UrlConverter.fromUrl( + 'https://luarocks.org/modules/luarocks/luasocket/3.1.0', + ) + expect(p?.version).toBe('3.1.0') + }) + + it('should return undefined for non-modules path', () => { + expect( + UrlConverter.fromUrl('https://luarocks.org/search'), + ).toBeUndefined() + }) + }) + + describe('swift — swiftpackageindex.com', () => { + it('should return undefined without version (swift requires version)', () => { + // Swift type requires a version component + expect( + UrlConverter.fromUrl('https://swiftpackageindex.com/apple/swift-nio'), + ).toBeUndefined() + }) + + it('should return undefined for root', () => { + expect( + UrlConverter.fromUrl('https://swiftpackageindex.com/'), + ).toBeUndefined() + }) + }) + + describe('vscode — marketplace.visualstudio.com', () => { + it('should parse marketplace URL', () => { + const p = UrlConverter.fromUrl( + 'https://marketplace.visualstudio.com/items?itemName=ms-python.python', + ) + expect(p?.type).toBe('vscode-extension') + expect(p?.namespace).toBe('ms-python') + expect(p?.name).toBe('python') + }) + + it('should return undefined without itemName', () => { + expect( + UrlConverter.fromUrl('https://marketplace.visualstudio.com/items'), + ).toBeUndefined() + }) + + it('should return undefined for invalid itemName format', () => { + expect( + UrlConverter.fromUrl( + 'https://marketplace.visualstudio.com/items?itemName=noDot', + ), + ).toBeUndefined() + }) + + it('should return undefined for non-items path', () => { + expect( + UrlConverter.fromUrl('https://marketplace.visualstudio.com/manage'), + ).toBeUndefined() + }) + }) + + describe('vscode — open-vsx.org', () => { + it('should parse extension URL', () => { + const p = UrlConverter.fromUrl( + 'https://open-vsx.org/extension/redhat/java', + ) + expect(p?.type).toBe('vscode-extension') + expect(p?.namespace).toBe('redhat') + expect(p?.name).toBe('java') + }) + + it('should parse extension with version', () => { + const p = UrlConverter.fromUrl( + 'https://open-vsx.org/extension/redhat/java/1.0.0', + ) + expect(p?.version).toBe('1.0.0') + }) + + it('should return undefined for non-extension path', () => { + expect(UrlConverter.fromUrl('https://open-vsx.org/about')).toBeUndefined() + }) + }) + + describe('unrecognized and invalid URLs', () => { + it('should return undefined for unknown hosts', () => { + expect( + UrlConverter.fromUrl('https://example.com/foo/bar'), + ).toBeUndefined() + }) + + it('should return undefined for empty string', () => { + expect(UrlConverter.fromUrl('')).toBeUndefined() + }) + + it('should return undefined for garbage input', () => { + expect(UrlConverter.fromUrl('not a url at all')).toBeUndefined() + }) + + it('should return undefined for URLs without enough path info', () => { + expect( + UrlConverter.fromUrl('https://registry.npmjs.org/'), + ).toBeUndefined() + expect(UrlConverter.fromUrl('https://pypi.org/')).toBeUndefined() + expect(UrlConverter.fromUrl('https://github.com/')).toBeUndefined() + expect(UrlConverter.fromUrl('https://github.com/lodash')).toBeUndefined() + expect( + UrlConverter.fromUrl('https://www.npmjs.com/package'), + ).toBeUndefined() + }) + }) + + describe('edge cases', () => { + it('should return undefined for npm registry root', () => { + expect( + UrlConverter.fromUrl('https://registry.npmjs.org/'), + ).toBeUndefined() + }) + + it('should return undefined for npm scoped package without name', () => { + expect( + UrlConverter.fromUrl('https://registry.npmjs.org/@scope'), + ).toBeUndefined() + }) + + it('should return undefined for npmjs.com without package path', () => { + expect(UrlConverter.fromUrl('https://www.npmjs.com/')).toBeUndefined() + }) + + it('should return undefined for npmjs.com /package without name', () => { + expect( + UrlConverter.fromUrl('https://www.npmjs.com/package'), + ).toBeUndefined() + }) + + it('should return undefined for npmjs.com scoped without name', () => { + expect( + UrlConverter.fromUrl('https://www.npmjs.com/package/@scope'), + ).toBeUndefined() + }) + + it('should handle npm registry unscoped tarball', () => { + const purl = UrlConverter.fromUrl( + 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz', + ) + expect(purl?.toString()).toBe('pkg:npm/lodash@4.17.21') + }) + + it('should handle npm registry scoped tarball', () => { + const purl = UrlConverter.fromUrl( + 'https://registry.npmjs.org/@babel/core/-/core-7.0.0.tgz', + ) + expect(purl?.toString()).toBe('pkg:npm/%40babel/core@7.0.0') + }) + + it('should return undefined for pypi non-project path', () => { + expect( + UrlConverter.fromUrl('https://pypi.org/simple/requests'), + ).toBeUndefined() + }) + + it('should return undefined for pypi with only /project/', () => { + expect(UrlConverter.fromUrl('https://pypi.org/project/')).toBeUndefined() + }) + + it('should return undefined for maven with too few segments', () => { + expect( + UrlConverter.fromUrl('https://repo1.maven.org/maven2/org/apache'), + ).toBeUndefined() + }) + + it('should return undefined for maven non-maven2 path', () => { + expect( + UrlConverter.fromUrl('https://repo1.maven.org/other/path'), + ).toBeUndefined() + }) + + it('should return undefined for rubygems non-gems path', () => { + expect( + UrlConverter.fromUrl('https://rubygems.org/api/v1/gems'), + ).toBeUndefined() + }) + + it('should return undefined for rubygems /gems/ without name', () => { + expect(UrlConverter.fromUrl('https://rubygems.org/gems/')).toBeUndefined() + }) + + it('should return undefined for crates.io root', () => { + expect(UrlConverter.fromUrl('https://crates.io/')).toBeUndefined() + }) + + it('should return undefined for crates.io non-crates path', () => { + expect(UrlConverter.fromUrl('https://crates.io/teams')).toBeUndefined() + }) + + it('should handle cargo API download URL', () => { + const purl = UrlConverter.fromUrl( + 'https://crates.io/api/v1/crates/serde/1.0.0/download', + ) + expect(purl?.toString()).toBe('pkg:cargo/serde@1.0.0') + }) + + it('should return undefined for nuget api.nuget.org non-flatcontainer', () => { + expect( + UrlConverter.fromUrl('https://api.nuget.org/v3/catalog/'), + ).toBeUndefined() + }) + + it('should handle nuget API flatcontainer URL', () => { + const purl = UrlConverter.fromUrl( + 'https://api.nuget.org/v3-flatcontainer/newtonsoft.json/13.0.1/newtonsoft.json.13.0.1.nupkg', + ) + expect(purl?.toString()).toBe('pkg:nuget/newtonsoft.json@13.0.1') + }) + + it('should return undefined for nuget non-packages path', () => { + expect( + UrlConverter.fromUrl('https://www.nuget.org/profiles/user'), + ).toBeUndefined() + }) + + it('should return undefined for github with only owner', () => { + expect(UrlConverter.fromUrl('https://github.com/lodash')).toBeUndefined() + }) + + it('should handle github commit URL', () => { + const purl = UrlConverter.fromUrl( + 'https://github.com/lodash/lodash/commit/abc1234', + ) + expect(purl?.toString()).toBe('pkg:github/lodash/lodash@abc1234') + }) + + it('should handle github releases/tag URL', () => { + const purl = UrlConverter.fromUrl( + 'https://github.com/lodash/lodash/releases/tag/v4.17.21', + ) + expect(purl?.toString()).toBe('pkg:github/lodash/lodash@v4.17.21') + }) + + it('should return undefined for golang empty path', () => { + expect(UrlConverter.fromUrl('https://pkg.go.dev/')).toBeUndefined() + }) + + it('should return undefined for golang single segment', () => { + expect(UrlConverter.fromUrl('https://pkg.go.dev/fmt')).toBeUndefined() + }) + + it('should handle npm registry unscoped without version', () => { + const purl = UrlConverter.fromUrl('https://registry.npmjs.org/lodash') + expect(purl?.toString()).toBe('pkg:npm/lodash') + }) + + it('should handle npm website scoped with version', () => { + const purl = UrlConverter.fromUrl( + 'https://www.npmjs.com/package/@babel/core/v/7.0.0', + ) + expect(purl?.toString()).toBe('pkg:npm/%40babel/core@7.0.0') + }) + + it('should handle npm website unscoped without version', () => { + const purl = UrlConverter.fromUrl('https://www.npmjs.com/package/lodash') + expect(purl?.toString()).toBe('pkg:npm/lodash') + }) + + it('should handle pypi without version', () => { + const purl = UrlConverter.fromUrl('https://pypi.org/project/requests/') + expect(purl?.toString()).toBe('pkg:pypi/requests') + }) + + it('should handle gems with version', () => { + const purl = UrlConverter.fromUrl( + 'https://rubygems.org/gems/rails/versions/7.0.0', + ) + expect(purl?.toString()).toBe('pkg:gem/rails@7.0.0') + }) + + it('should handle cargo without version', () => { + const purl = UrlConverter.fromUrl('https://crates.io/crates/serde') + expect(purl?.toString()).toBe('pkg:cargo/serde') + }) + + it('should handle nuget without version', () => { + const purl = UrlConverter.fromUrl( + 'https://www.nuget.org/packages/Newtonsoft.Json', + ) + expect(purl?.toString()).toBe('pkg:nuget/Newtonsoft.Json') + }) + + it('should handle golang with version', () => { + const purl = UrlConverter.fromUrl( + 'https://pkg.go.dev/github.com/gorilla/mux@v1.8.0', + ) + expect(purl?.toString()).toBe('pkg:golang/github.com/gorilla/mux@v1.8.0') + }) + + it('should handle golang without version', () => { + const purl = UrlConverter.fromUrl( + 'https://pkg.go.dev/github.com/gorilla/mux', + ) + expect(purl?.toString()).toBe('pkg:golang/github.com/gorilla/mux') + }) + + it('should return undefined for cargo non-crates non-api path', () => { + expect( + UrlConverter.fromUrl('https://crates.io/teams/foo'), + ).toBeUndefined() + }) + + it('should return undefined for nuget root path', () => { + expect(UrlConverter.fromUrl('https://www.nuget.org/')).toBeUndefined() + }) + + it('should return undefined for golang trailing slash (empty name)', () => { + expect( + UrlConverter.fromUrl('https://pkg.go.dev/github.com/'), + ).toBeUndefined() + }) + + it('should return undefined for vscode marketplace non-items path', () => { + expect( + UrlConverter.fromUrl('https://marketplace.visualstudio.com/publishers'), + ).toBeUndefined() + }) + + it('should return undefined for npmjs.com /package/@scope only', () => { + expect( + UrlConverter.fromUrl('https://www.npmjs.com/package/@scope'), + ).toBeUndefined() + }) + }) +}) + +describe('UrlConverter.supportsFromUrl', () => { + it.each([ + 'https://registry.npmjs.org/lodash', + 'https://www.npmjs.com/package/lodash', + 'https://pypi.org/project/requests', + 'https://github.com/lodash/lodash', + 'https://gitlab.com/inkscape/inkscape', + 'https://bitbucket.org/atlassian/repo', + 'https://pkg.go.dev/github.com/gorilla/mux', + 'https://hex.pm/packages/phoenix', + 'https://pub.dev/packages/flutter_bloc', + 'https://packagist.org/packages/symfony/console', + 'https://hub.docker.com/r/bitnami/postgresql', + 'https://cocoapods.org/pods/Alamofire', + 'https://hackage.haskell.org/package/aeson', + 'https://cran.r-project.org/web/packages/ggplot2', + 'https://anaconda.org/conda-forge/numpy', + 'https://metacpan.org/pod/Moose', + 'https://luarocks.org/modules/luarocks/luasocket', + 'https://swiftpackageindex.com/apple/swift-nio', + 'https://huggingface.co/microsoft/phi-2', + 'https://marketplace.visualstudio.com/items?itemName=ms-python.python', + 'https://open-vsx.org/extension/redhat/java', + ])('should return true for %s', url => { + expect(UrlConverter.supportsFromUrl(url)).toBe(true) + }) + + it('should return false for unknown hosts', () => { + expect(UrlConverter.supportsFromUrl('https://example.com/foo')).toBe(false) + }) + + it('should return false for invalid URLs', () => { + expect(UrlConverter.supportsFromUrl('')).toBe(false) + expect(UrlConverter.supportsFromUrl('not a url')).toBe(false) + }) +}) diff --git a/test/vers.test.mts b/test/vers.test.mts new file mode 100644 index 0000000..0cad48a --- /dev/null +++ b/test/vers.test.mts @@ -0,0 +1,361 @@ +/** + * @fileoverview Tests for VERS (VErsion Range Specifier) implementation. + * Tests parsing, serialization, containment, and validation. + */ +import { describe, expect, it } from 'vitest' + +import { PurlError } from '../src/error.js' +import { Vers } from '../src/vers.js' + +describe('Vers', () => { + describe('parse', () => { + it('should parse basic range', () => { + const v = Vers.parse('vers:semver/>=1.0.0|<2.0.0') + expect(v.scheme).toBe('semver') + expect(v.constraints).toHaveLength(2) + expect(v.constraints[0]).toEqual({ + comparator: '>=', + version: '1.0.0', + }) + expect(v.constraints[1]).toEqual({ + comparator: '<', + version: '2.0.0', + }) + }) + + it('should parse wildcard', () => { + const v = Vers.parse('vers:semver/*') + expect(v.constraints).toHaveLength(1) + expect(v.constraints[0]!.comparator).toBe('*') + }) + + it('should parse bare version as equality', () => { + const v = Vers.parse('vers:npm/1.0.0') + expect(v.constraints).toHaveLength(1) + expect(v.constraints[0]).toEqual({ + comparator: '=', + version: '1.0.0', + }) + }) + + it('should parse multiple equality constraints', () => { + const v = Vers.parse('vers:npm/1.0.0|2.0.0|3.0.0') + expect(v.constraints).toHaveLength(3) + for (const c of v.constraints) { + expect(c.comparator).toBe('=') + } + }) + + it('should parse exclusion constraints', () => { + const v = Vers.parse('vers:semver/>=1.0.0|!=1.5.0|<2.0.0') + expect(v.constraints).toHaveLength(3) + expect(v.constraints[1]).toEqual({ + comparator: '!=', + version: '1.5.0', + }) + }) + + it('should parse scheme aliases', () => { + for (const scheme of ['npm', 'cargo', 'golang', 'gem', 'hex', 'pub']) { + const v = Vers.parse(`vers:${scheme}/>=1.0.0`) + expect(v.scheme).toBe(scheme) + } + }) + + it('should lowercase the scheme', () => { + const v = Vers.parse('vers:NPM/>=1.0.0') + expect(v.scheme).toBe('npm') + }) + + it('should parse prerelease versions', () => { + const v = Vers.parse('vers:semver/>=1.0.0-alpha|<2.0.0') + expect(v.constraints[0]!.version).toBe('1.0.0-alpha') + }) + }) + + describe('parse errors', () => { + it('should reject empty string', () => { + expect(() => Vers.parse('')).toThrow(PurlError) + }) + + it('should reject missing vers: prefix', () => { + expect(() => Vers.parse('npm/>=1.0.0')).toThrow(PurlError) + }) + + it('should reject missing scheme', () => { + expect(() => Vers.parse('vers:/>=1.0.0')).toThrow(PurlError) + }) + + it('should reject missing constraints', () => { + expect(() => Vers.parse('vers:npm/')).toThrow(PurlError) + }) + + it('should reject missing slash', () => { + expect(() => Vers.parse('vers:npm')).toThrow(PurlError) + }) + + it('should reject wildcard with other constraints', () => { + expect(() => Vers.parse('vers:semver/*|>=1.0.0')).toThrow(PurlError) + }) + + it('should reject invalid semver in semver scheme', () => { + expect(() => Vers.parse('vers:semver/>=not-semver')).toThrow(PurlError) + }) + + it('should reject empty comparator version', () => { + expect(() => Vers.parse('vers:semver/>=')).toThrow(PurlError) + }) + + it('should reject empty constraint in pipe-separated list', () => { + expect(() => Vers.parse('vers:semver/>=1.0.0|')).toThrow(PurlError) + }) + + it('should reject non-semver version passed to parseSemver via contains', () => { + const v = Vers.parse('vers:semver/>=1.0.0') + expect(() => v.contains('not-semver')).toThrow(PurlError) + }) + + it('should reject version components exceeding MAX_SAFE_INTEGER', () => { + const v = Vers.parse('vers:semver/>=99999999999999999.0.0') + expect(() => v.contains('1.0.0')).toThrow(PurlError) + }) + + it('should reject too many constraints', () => { + const many = Array(1001).fill('>=1.0.0').join('|') + expect(() => Vers.parse(`vers:semver/${many}`)).toThrow(PurlError) + }) + }) + + describe('toString', () => { + it('should roundtrip basic range', () => { + const input = 'vers:npm/>=1.0.0|<2.0.0' + expect(Vers.parse(input).toString()).toBe(input) + }) + + it('should roundtrip wildcard', () => { + expect(Vers.parse('vers:semver/*').toString()).toBe('vers:semver/*') + }) + + it('should serialize bare versions without = prefix', () => { + expect(Vers.parse('vers:npm/1.0.0').toString()).toBe('vers:npm/1.0.0') + }) + + it('should roundtrip exclusion', () => { + const input = 'vers:npm/>=1.0.0|!=1.5.0|<2.0.0' + expect(Vers.parse(input).toString()).toBe(input) + }) + + it('should roundtrip multiple equalities', () => { + const input = 'vers:npm/1.0.0|2.0.0|3.0.0' + expect(Vers.parse(input).toString()).toBe(input) + }) + }) + + describe('contains', () => { + describe('wildcard', () => { + it('should match any version', () => { + const v = Vers.parse('vers:semver/*') + expect(v.contains('0.0.1')).toBe(true) + expect(v.contains('999.999.999')).toBe(true) + }) + }) + + describe('equality', () => { + it('should match exact version', () => { + const v = Vers.parse('vers:npm/1.0.0') + expect(v.contains('1.0.0')).toBe(true) + expect(v.contains('1.0.1')).toBe(false) + expect(v.contains('0.9.9')).toBe(false) + }) + + it('should match any listed version', () => { + const v = Vers.parse('vers:npm/1.0.0|2.0.0|3.0.0') + expect(v.contains('1.0.0')).toBe(true) + expect(v.contains('2.0.0')).toBe(true) + expect(v.contains('3.0.0')).toBe(true) + expect(v.contains('1.5.0')).toBe(false) + }) + }) + + describe('exclusion', () => { + it('should exclude specific version from range', () => { + const v = Vers.parse('vers:npm/>=1.0.0|!=1.5.0|<2.0.0') + expect(v.contains('1.0.0')).toBe(true) + expect(v.contains('1.4.9')).toBe(true) + expect(v.contains('1.5.0')).toBe(false) + expect(v.contains('1.5.1')).toBe(true) + expect(v.contains('2.0.0')).toBe(false) + }) + }) + + describe('ranges', () => { + it('should handle >=X| { + const v = Vers.parse('vers:npm/>=1.0.0|<2.0.0') + expect(v.contains('0.9.9')).toBe(false) + expect(v.contains('1.0.0')).toBe(true) + expect(v.contains('1.5.0')).toBe(true) + expect(v.contains('1.9.9')).toBe(true) + expect(v.contains('2.0.0')).toBe(false) + }) + + it('should handle >=X|<=Y range', () => { + const v = Vers.parse('vers:npm/>=1.0.0|<=2.0.0') + expect(v.contains('1.0.0')).toBe(true) + expect(v.contains('2.0.0')).toBe(true) + expect(v.contains('2.0.1')).toBe(false) + }) + + it('should handle >X| { + const v = Vers.parse('vers:npm/>1.0.0|<2.0.0') + expect(v.contains('1.0.0')).toBe(false) + expect(v.contains('1.0.1')).toBe(true) + expect(v.contains('1.9.9')).toBe(true) + expect(v.contains('2.0.0')).toBe(false) + }) + + it('should handle >X|<=Y range', () => { + const v = Vers.parse('vers:npm/>1.0.0|<=2.0.0') + expect(v.contains('1.0.0')).toBe(false) + expect(v.contains('1.0.1')).toBe(true) + expect(v.contains('2.0.0')).toBe(true) + }) + + it('should handle unbounded >=X', () => { + const v = Vers.parse('vers:semver/>=1.0.0') + expect(v.contains('0.9.9')).toBe(false) + expect(v.contains('1.0.0')).toBe(true) + expect(v.contains('999.0.0')).toBe(true) + }) + + it('should handle unbounded { + const v = Vers.parse('vers:semver/<2.0.0') + expect(v.contains('0.0.1')).toBe(true) + expect(v.contains('1.9.9')).toBe(true) + expect(v.contains('2.0.0')).toBe(false) + }) + + it('should handle unbounded <=X', () => { + const v = Vers.parse('vers:semver/<=2.0.0') + expect(v.contains('2.0.0')).toBe(true) + expect(v.contains('2.0.1')).toBe(false) + }) + }) + + describe('prerelease', () => { + it('should order prereleases before release', () => { + const v = Vers.parse('vers:semver/>=1.0.0-alpha|<1.0.0') + expect(v.contains('1.0.0-alpha')).toBe(true) + expect(v.contains('1.0.0-beta')).toBe(true) + expect(v.contains('1.0.0')).toBe(false) + }) + + it('should compare prerelease identifiers correctly', () => { + const v = Vers.parse('vers:semver/>=1.0.0-alpha.1|<1.0.0-beta') + expect(v.contains('1.0.0-alpha.1')).toBe(true) + expect(v.contains('1.0.0-alpha.2')).toBe(true) + expect(v.contains('1.0.0-beta')).toBe(false) + }) + }) + + describe('scheme aliases', () => { + it('should support npm scheme', () => { + const v = Vers.parse('vers:npm/>=1.0.0|<2.0.0') + expect(v.contains('1.5.0')).toBe(true) + }) + + it('should support cargo scheme', () => { + const v = Vers.parse('vers:cargo/>=1.0.0|<2.0.0') + expect(v.contains('1.5.0')).toBe(true) + }) + + it('should support golang scheme', () => { + const v = Vers.parse('vers:golang/>=1.0.0|<2.0.0') + expect(v.contains('1.5.0')).toBe(true) + }) + }) + + describe('unsupported scheme', () => { + it('should throw for unsupported scheme on contains()', () => { + const v = Vers.parse('vers:deb/>=1.0.0') + expect(() => v.contains('1.0.0')).toThrow(PurlError) + }) + }) + + describe('prerelease comparison edge cases', () => { + it('should handle numeric vs alphanumeric prerelease identifiers', () => { + // Numeric identifiers have lower precedence than alphanumeric + const v = Vers.parse('vers:semver/>=1.0.0-1|<1.0.0-alpha') + expect(v.contains('1.0.0-1')).toBe(true) + expect(v.contains('1.0.0-2')).toBe(true) + // numeric < alphanumeric per semver spec + expect(v.contains('1.0.0-alpha')).toBe(false) + }) + + it('should handle alphanumeric vs numeric prerelease identifiers', () => { + const v = Vers.parse('vers:semver/>=1.0.0-alpha|<=1.0.0-beta') + expect(v.contains('1.0.0-alpha')).toBe(true) + expect(v.contains('1.0.0-beta')).toBe(true) + expect(v.contains('1.0.0-gamma')).toBe(false) + }) + + it('should handle prerelease with different identifier counts', () => { + // More prerelease identifiers = higher precedence when all prior equal + const v = Vers.parse('vers:semver/>=1.0.0-alpha|<1.0.0-alpha.1') + expect(v.contains('1.0.0-alpha')).toBe(true) + expect(v.contains('1.0.0-alpha.1')).toBe(false) + }) + }) + + describe('>X|<=Y range', () => { + it('should handle >X|<=Y correctly', () => { + const v = Vers.parse('vers:npm/>1.0.0|<=2.0.0') + expect(v.contains('1.0.0')).toBe(false) + expect(v.contains('1.0.1')).toBe(true) + expect(v.contains('2.0.0')).toBe(true) + expect(v.contains('2.0.1')).toBe(false) + }) + }) + + describe('>X| { + it('should handle >X| { + const v = Vers.parse('vers:npm/>1.0.0|<2.0.0') + expect(v.contains('1.0.0')).toBe(false) + expect(v.contains('1.0.1')).toBe(true) + expect(v.contains('1.9.9')).toBe(true) + expect(v.contains('2.0.0')).toBe(false) + }) + }) + + describe('unbounded >X', () => { + it('should match all versions above bound', () => { + const v = Vers.parse('vers:semver/>1.0.0') + expect(v.contains('1.0.0')).toBe(false) + expect(v.contains('1.0.1')).toBe(true) + expect(v.contains('999.0.0')).toBe(true) + }) + }) + + describe('empty range results', () => { + it('should return false when no range constraints match', () => { + // Only != constraints, version doesn't match any + const v = Vers.parse('vers:npm/!=1.0.0|!=2.0.0') + // 3.0.0 is not excluded but there's no inclusive range + expect(v.contains('3.0.0')).toBe(false) + }) + + it('should return false at end of range loop', () => { + // Version below the lower bound of a >= range + const v = Vers.parse('vers:npm/>=2.0.0|<3.0.0') + expect(v.contains('1.0.0')).toBe(false) + }) + }) + }) + + describe('immutability', () => { + it('should freeze constraints array', () => { + const v = Vers.parse('vers:npm/>=1.0.0|<2.0.0') + expect(Object.isFrozen(v)).toBe(true) + expect(Object.isFrozen(v.constraints)).toBe(true) + }) + }) +})