Part 6:  Ecosystems

Per-ecosystem normalize/validate/encode rules across all 41 supported package ecosystems.

Topics: Anatomy Building Parsing Validation URL Ecosystems Comparison Security Architecture Builders Contributing Converters Hardening Release Tour VERS
src/purl-types/alpm.ts
2 sections
1 2
Section 1 of 2
5import { lowerName, lowerNamespace } from '../strings.js'
6
7interface PurlObject {
8 name: string
9 namespace?: string | undefined
10 qualifiers?: Record<string, string> | undefined
11 subpath?: string | undefined
12 type?: string | undefined
13 version?: string | undefined
14}
Section 2 of 2
21export function normalize(purl: PurlObject): PurlObject {
22 lowerNamespace(purl)
23 lowerName(purl)
24 return purl
25}
src/purl-types/apk.ts
2 sections
1 2
Section 1 of 2
5import { lowerName, lowerNamespace } from '../strings.js'
6
7interface PurlObject {
8 name: string
9 namespace?: string | undefined
10 qualifiers?: Record<string, string> | undefined
11 subpath?: string | undefined
12 type?: string | undefined
13 version?: string | undefined
14}
Section 2 of 2
21export function normalize(purl: PurlObject): PurlObject {
22 lowerNamespace(purl)
23 lowerName(purl)
24 return purl
25}
src/purl-types/bazel.ts
3 sections
1 2 3
Section 1 of 3
8import { PurlError } from '../error.js'
9import { lowerName } from '../strings.js'
10import { validateNoInjectionByType } from '../validate.js'
11
12interface PurlObject {
13 name: string
14 namespace?: string | undefined
15 qualifiers?: Record<string, string> | undefined
16 subpath?: string | undefined
17 type?: string | undefined
18 version?: string | undefined
19}
Section 2 of 3
26export function normalize(purl: PurlObject): PurlObject {
27 lowerName(purl)
28 return purl
29}
Section 3 of 3
36export function validate(purl: PurlObject, throws: boolean): boolean {
37 if (!purl.version || purl.version.length === 0) {
38 if (throws) {
39 throw new PurlError('bazel requires a "version" component')
40 }
41 return false
42 }
43 if (!validateNoInjectionByType('bazel', 'name', purl.name, throws)) {
44 return false
45 }
46 return true
47}
src/purl-types/bitbucket.ts
3 sections
1 2 3
Section 1 of 3
5import { lowerName, lowerNamespace } from '../strings.js'
6import { validateNoInjectionByType } from '../validate.js'
7
8interface PurlObject {
9 name: string
10 namespace?: string | undefined
11 qualifiers?: Record<string, string> | undefined
12 subpath?: string | undefined
13 type?: string | undefined
14 version?: string | undefined
15}
Section 2 of 3
22export function normalize(purl: PurlObject): PurlObject {
23 lowerNamespace(purl)
24 lowerName(purl)
25 return purl
26}
Section 3 of 3
32export function validate(purl: PurlObject, throws: boolean): boolean {
33 if (
34 !validateNoInjectionByType('bitbucket', 'namespace', purl.namespace, throws)
35 ) {
36 return false
37 }
38 if (!validateNoInjectionByType('bitbucket', 'name', purl.name, throws)) {
39 return false
40 }
41 return true
42}
src/purl-types/bitnami.ts
2 sections
1 2
Section 1 of 2
5import { lowerName } from '../strings.js'
6
7interface PurlObject {
8 name: string
9 namespace?: string | undefined
10 qualifiers?: Record<string, string> | undefined
11 subpath?: string | undefined
12 type?: string | undefined
13 version?: string | undefined
14}
Section 2 of 2
21export function normalize(purl: PurlObject): PurlObject {
22 lowerName(purl)
23 return purl
24}
src/purl-types/cargo.ts
5 sections
1 2 3 4 5
Section 1 of 5
5import { errorMessage } from '../error.js'
6import { httpJson } from '@socketsecurity/lib/http-request'
7
8import {
9 ArrayPrototypeSome,
10 StringPrototypeIncludes,
11 encodeComponent,
12} from '@socketsecurity/lib/primordials'
13import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
14
15import type { ExistsResult, ExistsOptions } from './npm.js'
16
17interface PurlObject {
18 name: string
19 namespace?: string | undefined
20 qualifiers?: Record<string, string> | undefined
21 subpath?: string | undefined
22 type?: string | undefined
23 version?: string | undefined
24}
Section 2 of 5
64export async function cargoExists(
65 name: string,
66 version?: string,
67 options?: ExistsOptions,
68): Promise<ExistsResult> {
69 const cacheKey = version ? `cargo:${name}@${version}` : `cargo:${name}`
70
71 // Try cache first if provided
72 if (options?.cache) {
73 const cached = await options.cache.get<ExistsResult>(cacheKey)
74 if (cached !== undefined) {
75 return cached
76 }
77 }
78
79 const fetchResult = async (): Promise<ExistsResult> => {
80 try {
81 const url = `https://crates.io/api/v1/crates/${encodeComponent(name)}`
82
83 const data = await httpJson<{
84 crate?: { max_version?: string }
85 versions?: Array<{ num?: string }>
86 }>(url, {
87 headers: {
88 'User-Agent': '@socketregistry/packageurl-js',
89 },
90 })
91
92 const latestVersion =
93 data.crate?.['max_version'] || data.versions?.[0]?.['num']
94
95 // If specific version requested, validate it exists
96 if (version && data.versions) {
97 const versionExists = ArrayPrototypeSome(
98 data.versions,
99 v => v.num === version,
100 )
101 if (!versionExists) {
102 const result: ExistsResult = {
103 exists: false,
104 error: `Version ${version} not found`,
105 }
106 if (latestVersion !== undefined) {
107 result.latestVersion = latestVersion
108 }
109 return result
110 }
111 }
112
113 const result: ExistsResult = {
114 exists: true,
115 }
116 if (latestVersion !== undefined) {
117 result.latestVersion = latestVersion
118 }
119 return result
120 } catch (e) {
Section 3 of 5
122 const error = errorMessage(e)
Section 4 of 5
124 return {
125 exists: false,
126 error: StringPrototypeIncludes(error, '404')
127 ? 'Crate not found'
128 : error,
129 }
130 }
131 }
132
133 const result = await fetchResult()
134
135 // Only cache successful results to avoid negative cache poisoning
136 // from transient failures (network errors, 5xx responses)
137 if (options?.cache && result.exists) {
138 await options.cache.set(cacheKey, Object.freeze(result))
139 }
140
141 return result
142}
Section 5 of 5
148export function validate(purl: PurlObject, throws: boolean): boolean {
149 if (
150 !validateEmptyByType('cargo', 'namespace', purl.namespace, {
151 throws,
152 })
153 ) {
154 return false
155 }
156 if (!validateNoInjectionByType('cargo', 'name', purl.name, throws)) {
157 return false
158 }
159 return true
160}
src/purl-types/cocoapods.ts
6 sections
1 2 3 4 5 6
Section 1 of 6
5import { httpJson } from '@socketsecurity/lib/http-request'
6
7import { errorMessage, PurlError } from '../error.js'
8import {
9 ArrayPrototypeSome,
10 StringPrototypeCharCodeAt,
11 StringPrototypeIncludes,
12 encodeComponent,
13} from '@socketsecurity/lib/primordials'
14import { validateNoInjectionByType } from '../validate.js'
15
16import type { ExistsResult, ExistsOptions } from './npm.js'
17
18interface PurlObject {
19 name: string
20 namespace?: string | undefined
21 qualifiers?: Record<string, string> | undefined
22 subpath?: string | undefined
23 type?: string | undefined
24 version?: string | undefined
25}
Section 2 of 6
54export async function cocoapodsExists(
55 name: string,
56 version?: string,
57 options?: ExistsOptions,
58): Promise<ExistsResult> {
59 const cacheKey = version
60 ? `cocoapods:${name}@${version}`
61 : `cocoapods:${name}`
62
63 // Try cache first if provided
64 if (options?.cache) {
65 const cached = await options.cache.get<ExistsResult>(cacheKey)
66 if (cached !== undefined) {
67 return cached
68 }
69 }
70
71 const fetchResult = async (): Promise<ExistsResult> => {
72 try {
73 const url = `https://trunk.cocoapods.org/api/v1/pods/${encodeComponent(name)}`
74
75 const data = await httpJson<{
76 versions?: Array<{ name?: string }>
77 }>(url)
78
79 const versions = data.versions
80 if (!versions || versions.length === 0) {
81 return { exists: false, error: 'No versions found' }
82 }
83
84 // Latest version is first in the array
85 const latestVersion = versions[0]?.['name']
86
87 if (version) {
88 const versionExists = ArrayPrototypeSome(
89 versions,
90 v => v.name === version,
91 )
92 if (!versionExists) {
93 const result: ExistsResult = {
94 exists: false,
95 error: `Version ${version} not found`,
96 }
97 if (latestVersion !== undefined) {
98 result.latestVersion = latestVersion
99 }
100 return result
101 }
102 }
103
104 const result: ExistsResult = { exists: true }
105 if (latestVersion !== undefined) {
106 result.latestVersion = latestVersion
107 }
108 return result
109 } catch (e) {
Section 3 of 6
111 const error = errorMessage(e)
112 return {
113 exists: false,
114 error: StringPrototypeIncludes(error, '404') ? 'Pod not found' : error,
115 }
Section 4 of 6
117 }
118 }
119
120 const result = await fetchResult()
121
122 // Only cache successful results to avoid negative cache poisoning
123 // from transient failures (network errors, 5xx responses)
124 if (options?.cache && result.exists) {
125 await options.cache.set(cacheKey, Object.freeze(result))
126 }
127
128 return result
129}
Section 5 of 6
136export function validate(purl: PurlObject, throws: boolean): boolean {
137 const { name } = purl
138 // `name` must not contain injection characters
139 if (!validateNoInjectionByType('cocoapods', 'name', name, throws)) {
140 return false
141 }
142 // `name` cannot contain a plus (`+`) character
143 if (StringPrototypeIncludes(name, '+')) {
144 if (throws) {
145 throw new PurlError(
146 'cocoapods "name" component cannot contain a plus (+) character',
147 )
148 }
149 return false
150 }
151 // `name` cannot begin with a period (`.`)
Section 6 of 6
153 if (throws) {
154 throw new PurlError(
155 'cocoapods "name" component cannot begin with a period',
156 )
157 }
158 return false
159 }
160 return true
161}
src/purl-types/composer.ts
5 sections
1 2 3 4 5
Section 1 of 5
5import { errorMessage } from '../error.js'
6import { httpJson } from '@socketsecurity/lib/http-request'
7
8import {
9 ArrayPrototypeSome,
10 StringPrototypeIncludes,
11 encodeComponent,
12} from '@socketsecurity/lib/primordials'
13import { lowerName, lowerNamespace } from '../strings.js'
14
15import type { ExistsResult, ExistsOptions } from './npm.js'
16
17interface PurlObject {
18 name: string
19 namespace?: string | undefined
20 qualifiers?: Record<string, string> | undefined
21 subpath?: string | undefined
22 type?: string | undefined
23 version?: string | undefined
24}
Section 2 of 5
31export function normalize(purl: PurlObject): PurlObject {
32 lowerNamespace(purl)
33 lowerName(purl)
34 return purl
35}
Section 3 of 5
64export async function packagistExists(
65 name: string,
66 namespace?: string,
67 version?: string,
68 options?: ExistsOptions,
69): Promise<ExistsResult> {
70 if (!namespace) {
71 return { exists: false, error: 'Composer requires namespace (vendor)' }
72 }
73
74 const packageName = `${namespace}/${name}`
75 const cacheKey = version
76 ? `composer:${packageName}@${version}`
77 : `composer:${packageName}`
78
79 if (options?.cache) {
80 const cached = await options.cache.get<ExistsResult>(cacheKey)
81 if (cached !== undefined) {
82 return cached
83 }
84 }
85
86 const fetchResult = async (): Promise<ExistsResult> => {
87 try {
88 const url = `https://repo.packagist.org/p2/${encodeComponent(packageName)}.json`
89
90 const data = await httpJson<{
91 packages?: {
92 [key: string]: Array<{
93 version?: string
94 version_normalized?: string
95 }>
96 }
97 }>(url)
98
99 const packageVersions = data.packages?.[packageName]
100 if (!packageVersions || packageVersions.length === 0) {
101 return { exists: false, error: 'Package not found' }
102 }
103
104 // Find the latest stable version (highest `version_normalized` without `dev` suffix)
105 let latestVersion: string | undefined
106 for (const pkg of packageVersions) {
107 const ver = pkg.version
108 if (ver && !StringPrototypeIncludes(ver, 'dev-')) {
109 latestVersion = ver
110 break
111 }
112 }
113
114 if (version) {
115 const versionExists = ArrayPrototypeSome(
116 packageVersions,
117 pkg => pkg.version === version,
118 )
119 if (!versionExists) {
120 const result: ExistsResult = {
121 exists: false,
122 error: `Version ${version} not found`,
123 }
124 if (latestVersion !== undefined) {
125 result.latestVersion = latestVersion
126 }
127 return result
128 }
129 }
130
131 const result: ExistsResult = { exists: true }
132 if (latestVersion !== undefined) {
133 result.latestVersion = latestVersion
134 }
135 return result
136 } catch (e) {
Section 4 of 5
138 const error = errorMessage(e)
139 return {
140 exists: false,
141 error: StringPrototypeIncludes(error, '404')
142 ? 'Package not found'
143 : error,
144 }
Section 5 of 5
146 }
147 }
148
149 const result = await fetchResult()
150 // Only cache successful results to avoid negative cache poisoning
151 // from transient failures (network errors, 5xx responses)
152 if (options?.cache && result.exists) {
153 await options.cache.set(cacheKey, Object.freeze(result))
154 }
155 return result
156}
src/purl-types/conan.ts
2 sections
1 2
Section 1 of 2
5import { PurlError } from '../error.js'
6import { isNullishOrEmptyString } from '../lang.js'
7import { validateNoInjectionByType } from '../validate.js'
8
9interface PurlObject {
10 name: string
11 namespace?: string | undefined
12 qualifiers?: Record<string, string> | undefined
13 subpath?: string | undefined
14 type?: string | undefined
15 version?: string | undefined
16}
Section 2 of 2
24export function validate(purl: PurlObject, throws: boolean): boolean {
25 if (isNullishOrEmptyString(purl.namespace)) {
26 if (purl.qualifiers?.['channel']) {
27 if (throws) {
28 throw new PurlError(
29 'conan requires a "namespace" component when a "channel" qualifier is present',
30 )
31 }
32 return false
33 }
34 } else if (isNullishOrEmptyString(purl.qualifiers)) {
35 if (throws) {
36 throw new PurlError(
37 'conan requires a "qualifiers" component when a namespace is present',
38 )
39 }
40 return false
41 }
42 if (
43 !validateNoInjectionByType('conan', 'namespace', purl.namespace, throws)
44 ) {
45 return false
46 }
47 if (!validateNoInjectionByType('conan', 'name', purl.name, throws)) {
48 return false
49 }
50 return true
51}
src/purl-types/conda.ts
6 sections
1 2 3 4 5 6
Section 1 of 6
5import { errorMessage } from '../error.js'
6import { httpJson } from '@socketsecurity/lib/http-request'
7
8import {
9 ArrayPrototypeIncludes,
10 StringPrototypeIncludes,
11 encodeComponent,
12} from '@socketsecurity/lib/primordials'
13import { lowerName } from '../strings.js'
14import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
15
16import type { ExistsOptions, ExistsResult } from './npm.js'
17
18interface PurlObject {
19 name: string
20 namespace?: string | undefined
21 qualifiers?: Record<string, string> | undefined
22 subpath?: string | undefined
23 type?: string | undefined
24 version?: string | undefined
25}
Section 2 of 6
32export function normalize(purl: PurlObject): PurlObject {
33 lowerName(purl)
34 return purl
35}
Section 3 of 6
41export function validate(purl: PurlObject, throws: boolean): boolean {
42 if (
43 !validateEmptyByType('conda', 'namespace', purl.namespace, {
44 throws,
45 })
46 ) {
47 return false
48 }
49 if (!validateNoInjectionByType('conda', 'name', purl.name, throws)) {
50 return false
51 }
52 return true
53}
Section 4 of 6
98export async function condaExists(
99 name: string,
100 version?: string,
101 channel?: string,
102 options?: ExistsOptions,
103): Promise<ExistsResult> {
104 // Use provided channel or default to `conda-forge` (most popular community channel)
105 const channelName = channel || 'conda-forge'
106 const cacheKey = version
107 ? `conda:${channelName}/${name}@${version}`
108 : `conda:${channelName}/${name}`
109
110 // Try cache first if provided
111 if (options?.cache) {
112 const cached = await options.cache.get<ExistsResult>(cacheKey)
113 if (cached !== undefined) {
114 return cached
115 }
116 }
117
118 const fetchResult = async (): Promise<ExistsResult> => {
119 try {
120 const encodedChannel = encodeComponent(channelName)
121 const encodedName = encodeComponent(name)
122 const url = `https://api.anaconda.org/package/${encodedChannel}/${encodedName}`
123
124 const data = await httpJson<{
125 latest_version?: string
126 versions?: string[]
127 }>(url)
128
129 const latestVersion = data.latest_version
130
131 // If specific version requested, validate it exists
132 if (version) {
133 if (!data.versions || !ArrayPrototypeIncludes(data.versions, version)) {
134 const result: ExistsResult = {
135 exists: false,
136 error: `Version ${version} not found`,
137 }
138 if (latestVersion !== undefined) {
139 result.latestVersion = latestVersion
140 }
141 return result
142 }
143 }
144
145 const result: ExistsResult = {
146 exists: true,
147 }
148 if (latestVersion !== undefined) {
149 result.latestVersion = latestVersion
150 }
151 return result
152 } catch (e) {
Section 5 of 6
154 const error = errorMessage(e)
Section 6 of 6
156 return {
157 exists: false,
158 error: StringPrototypeIncludes(error, '404')
159 ? 'Package not found'
160 : error,
161 }
162 }
163 }
164
165 const result = await fetchResult()
166
167 // Only cache successful results to avoid negative cache poisoning
168 // from transient failures (network errors, 5xx responses)
169 if (options?.cache && result.exists) {
170 await options.cache.set(cacheKey, Object.freeze(result))
171 }
172
173 return result
174}
src/purl-types/cpan.ts
5 sections
1 2 3 4 5
Section 1 of 5
5import { httpJson } from '@socketsecurity/lib/http-request'
6
7import { errorMessage, PurlError } from '../error.js'
8import {
9 StringPrototypeIncludes,
10 StringPrototypeToUpperCase,
11 encodeComponent,
12} from '@socketsecurity/lib/primordials'
13import { validateNoInjectionByType } from '../validate.js'
14
15import type { ExistsResult, ExistsOptions } from './npm.js'
16
17interface PurlObject {
18 name: string
19 namespace?: string | undefined
20 qualifiers?: Record<string, string> | undefined
21 subpath?: string | undefined
22 type?: string | undefined
23 version?: string | undefined
24}
Section 2 of 5
53export async function cpanExists(
54 name: string,
55 version?: string,
56 options?: ExistsOptions,
57): Promise<ExistsResult> {
58 const cacheKey = version ? `cpan:${name}@${version}` : `cpan:${name}`
59
60 // Try cache first if provided
61 if (options?.cache) {
62 const cached = await options.cache.get<ExistsResult>(cacheKey)
63 if (cached !== undefined) {
64 return cached
65 }
66 }
67
68 const fetchResult = async (): Promise<ExistsResult> => {
69 try {
70 const url = `https://fastapi.metacpan.org/v1/module/${encodeComponent(name)}`
71
72 const data = await httpJson<{
73 version?: string
74 }>(url)
75
76 const latestVersion = data.version
77
78 if (version) {
79 // Check specific version
80 const versionUrl = `https://fastapi.metacpan.org/v1/module/${encodeComponent(name)}/${encodeComponent(version)}`
81 try {
82 await httpJson(versionUrl)
83 } catch {
84 const result: ExistsResult = {
85 exists: false,
86 error: `Version ${version} not found`,
87 }
88 if (latestVersion !== undefined) {
89 result.latestVersion = latestVersion
90 }
91 return result
92 }
93 }
94
95 const result: ExistsResult = { exists: true }
96 if (latestVersion !== undefined) {
97 result.latestVersion = latestVersion
98 }
99 return result
100 } catch (e) {
Section 3 of 5
102 const error = errorMessage(e)
103 return {
104 exists: false,
105 error: StringPrototypeIncludes(error, '404')
106 ? 'Module not found'
107 : error,
108 }
Section 4 of 5
110 }
111 }
112
113 const result = await fetchResult()
114
115 // Only cache successful results to avoid negative cache poisoning
116 // from transient failures (network errors, 5xx responses)
117 if (options?.cache && result.exists) {
118 await options.cache.set(cacheKey, Object.freeze(result))
119 }
120
121 return result
122}
Section 5 of 5
128export function validate(purl: PurlObject, throws: boolean): boolean {
129 const { namespace } = purl
130 if (namespace && namespace !== StringPrototypeToUpperCase(namespace)) {
131 if (throws) {
132 throw new PurlError('cpan "namespace" component must be UPPERCASE')
133 }
134 return false
135 }
136 if (!validateNoInjectionByType('cpan', 'namespace', namespace, throws)) {
137 return false
138 }
139 if (!validateNoInjectionByType('cpan', 'name', purl.name, throws)) {
140 return false
141 }
142 return true
143}
src/purl-types/cran.ts
5 sections
1 2 3 4 5
Section 1 of 5
5import { errorMessage } from '../error.js'
6import { httpJson } from '@socketsecurity/lib/http-request'
7
8import {
9 ArrayPrototypeIncludes,
10 StringPrototypeIncludes,
11 encodeComponent,
12} from '@socketsecurity/lib/primordials'
13import {
14 validateNoInjectionByType,
15 validateRequiredByType,
16} from '../validate.js'
17
18import type { ExistsResult, ExistsOptions } from './npm.js'
19
20interface PurlObject {
21 name: string
22 namespace?: string | undefined
23 qualifiers?: Record<string, string> | undefined
24 subpath?: string | undefined
25 type?: string | undefined
26 version?: string | undefined
27}
Section 2 of 5
57export async function cranExists(
58 name: string,
59 version?: string,
60 options?: ExistsOptions,
61): Promise<ExistsResult> {
62 const cacheKey = version ? `cran:${name}@${version}` : `cran:${name}`
63
64 // Try cache first if provided
65 if (options?.cache) {
66 const cached = await options.cache.get<ExistsResult>(cacheKey)
67 if (cached !== undefined) {
68 return cached
69 }
70 }
71
72 const fetchResult = async (): Promise<ExistsResult> => {
73 try {
74 // CRAN provides a JSON API via r-universe
75 const url = `https://cran.r-universe.dev/api/packages/${encodeComponent(name)}`
76
77 const data = await httpJson<{
78 Version?: string
79 versions?: string[]
80 }>(url)
81
82 const latestVersion = data.Version
83
84 if (version) {
85 const versions = data.versions || []
86 if (!ArrayPrototypeIncludes(versions, version)) {
87 const result: ExistsResult = {
88 exists: false,
89 error: `Version ${version} not found`,
90 }
91 if (latestVersion !== undefined) {
92 result.latestVersion = latestVersion
93 }
94 return result
95 }
96 }
97
98 const result: ExistsResult = { exists: true }
99 if (latestVersion !== undefined) {
100 result.latestVersion = latestVersion
101 }
102 return result
103 } catch (e) {
Section 3 of 5
105 const error = errorMessage(e)
106 return {
107 exists: false,
108 error: StringPrototypeIncludes(error, '404')
109 ? 'Package not found'
110 : error,
111 }
Section 4 of 5
113 }
114 }
115
116 const result = await fetchResult()
117
118 // Only cache successful results to avoid negative cache poisoning
119 // from transient failures (network errors, 5xx responses)
120 if (options?.cache && result.exists) {
121 await options.cache.set(cacheKey, Object.freeze(result))
122 }
123
124 return result
125}
Section 5 of 5
131export function validate(purl: PurlObject, throws: boolean): boolean {
132 if (
133 !validateRequiredByType('cran', 'version', purl.version, {
134 throws,
135 })
136 ) {
137 return false
138 }
139 if (!validateNoInjectionByType('cran', 'name', purl.name, throws)) {
140 return false
141 }
142 return true
143}
src/purl-types/deb.ts
2 sections
1 2
Section 1 of 2
5import { lowerName, lowerNamespace } from '../strings.js'
6
7interface PurlObject {
8 name: string
9 namespace?: string | undefined
10 qualifiers?: Record<string, string> | undefined
11 subpath?: string | undefined
12 type?: string | undefined
13 version?: string | undefined
14}
Section 2 of 2
21export function normalize(purl: PurlObject): PurlObject {
22 lowerNamespace(purl)
23 lowerName(purl)
24 return purl
25}
src/purl-types/docker.ts
8 sections
1 2 3 4 5 6 7 8
Section 1 of 8
5import { errorMessage } from '../error.js'
6import { httpJson } from '@socketsecurity/lib/http-request'
7
8import {
9 StringPrototypeIncludes,
10 encodeComponent,
11} from '@socketsecurity/lib/primordials'
12import { lowerName } from '../strings.js'
13import { validateNoInjectionByType } from '../validate.js'
14
15import type { ExistsOptions, ExistsResult } from './npm.js'
16
17interface PurlObject {
18 name: string
19 namespace?: string | undefined
20 qualifiers?: Record<string, string> | undefined
21 subpath?: string | undefined
22 type?: string | undefined
23 version?: string | undefined
24}
Section 2 of 8
31export function normalize(purl: PurlObject): PurlObject {
32 lowerName(purl)
33 return purl
34}
Section 3 of 8
40export function validate(purl: PurlObject, throws: boolean): boolean {
41 if (
42 !validateNoInjectionByType('docker', 'namespace', purl.namespace, throws)
43 ) {
44 return false
45 }
46 if (!validateNoInjectionByType('docker', 'name', purl.name, throws)) {
47 return false
48 }
49 return true
50}
Section 4 of 8
94export async function dockerExists(
95 name: string,
96 namespace?: string,
97 version?: string,
98 options?: ExistsOptions,
99): Promise<ExistsResult> {
100 // Default namespace to `'library'` for official images if not specified
101 const repo = namespace ? `${namespace}/${name}` : name
102 const cacheKey = version ? `docker:${repo}:${version}` : `docker:${repo}`
103
104 // Try cache first if provided
105 if (options?.cache) {
106 const cached = await options.cache.get<ExistsResult>(cacheKey)
107 if (cached !== undefined) {
108 return cached
109 }
110 }
111
112 const fetchResult = async (): Promise<ExistsResult> => {
113 try {
114 // Encode each path segment separately to preserve the `/` delimiter
115 const encodedRepo = namespace
116 ? `${encodeComponent(namespace)}/${encodeComponent(name)}`
117 : encodeComponent(name)
118 const url = `https://hub.docker.com/v2/repositories/${encodedRepo}`
119
120 const data = await httpJson<{
121 name?: string
122 }>(url)
123
124 // Docker Hub doesn't provide a simple `"latest version"` - tags need separate API call
125 // For now, we just verify the repository exists
126 if (!data.name) {
127 return {
128 exists: false,
129 error: 'Image not found',
130 }
131 }
132
133 // If specific tag requested, verify it exists
134 if (version) {
135 try {
136 const tagUrl = `https://hub.docker.com/v2/repositories/${encodedRepo}/tags/${encodeComponent(version)}`
137 await httpJson(tagUrl)
138 } catch (e) {
Section 5 of 8
140 const error = errorMessage(e)
Section 6 of 8
142 return {
143 exists: false,
144 error: StringPrototypeIncludes(error, '404')
145 ? `Tag ${version} not found`
146 : error,
147 }
148 }
149 }
150
151 return {
152 exists: true,
153 latestVersion: version || 'latest',
154 }
155 } catch (e) {
Section 7 of 8
157 const error = errorMessage(e)
Section 8 of 8
159 return {
160 exists: false,
161 error: StringPrototypeIncludes(error, '404')
162 ? 'Image not found'
163 : error,
164 }
165 }
166 }
167
168 const result = await fetchResult()
169
170 // Only cache successful results to avoid negative cache poisoning
171 // from transient failures (network errors, 5xx responses)
172 if (options?.cache && result.exists) {
173 await options.cache.set(cacheKey, Object.freeze(result))
174 }
175
176 return result
177}
src/purl-types/gem.ts
5 sections
1 2 3 4 5
Section 1 of 5
5import { httpJson } from '@socketsecurity/lib/http-request'
6
7import { errorMessage } from '../error.js'
8import {
9 ArrayIsArray,
10 ArrayPrototypeSome,
11 StringPrototypeIncludes,
12 encodeComponent,
13} from '@socketsecurity/lib/primordials'
14import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
15
16import type { ExistsResult, ExistsOptions } from './npm.js'
17
18interface PurlObject {
19 name: string
20 namespace?: string | undefined
21 qualifiers?: Record<string, string> | undefined
22 subpath?: string | undefined
23 type?: string | undefined
24 version?: string | undefined
25}
Section 2 of 5
63export async function gemExists(
64 name: string,
65 version?: string,
66 options?: ExistsOptions,
67): Promise<ExistsResult> {
68 const cacheKey = version ? `gem:${name}@${version}` : `gem:${name}`
69
70 // Try cache first if provided
71 if (options?.cache) {
72 const cached = await options.cache.get<ExistsResult>(cacheKey)
73 if (cached !== undefined) {
74 return cached
75 }
76 }
77
78 const fetchResult = async (): Promise<ExistsResult> => {
79 try {
80 const url = `https://rubygems.org/api/v1/versions/${encodeComponent(name)}.json`
81
82 const data = await httpJson<Array<{ number?: string }>>(url)
83
84 if (!ArrayIsArray(data) || data.length === 0) {
85 return {
86 exists: false,
87 error: 'No versions found',
88 }
89 }
90
91 const latestVersion = data[0]?.['number']
92
93 // If specific version requested, validate it exists
94 if (version) {
95 const versionExists = ArrayPrototypeSome(
96 data,
97 v => v.number === version,
98 )
99 if (!versionExists) {
100 const result: ExistsResult = {
101 exists: false,
102 error: `Version ${version} not found`,
103 }
104 if (latestVersion !== undefined) {
105 result.latestVersion = latestVersion
106 }
107 return result
108 }
109 }
110
111 const result: ExistsResult = {
112 exists: true,
113 }
114 if (latestVersion !== undefined) {
115 result.latestVersion = latestVersion
116 }
117 return result
118 } catch (e) {
Section 3 of 5
120 const error = errorMessage(e)
121 return {
122 exists: false,
123 error: StringPrototypeIncludes(error, '404') ? 'Gem not found' : error,
124 }
Section 4 of 5
126 }
127 }
128
129 const result = await fetchResult()
130
131 // Only cache successful results to avoid negative cache poisoning
132 // from transient failures (network errors, 5xx responses)
133 if (options?.cache && result.exists) {
134 await options.cache.set(cacheKey, Object.freeze(result))
135 }
136
137 return result
138}
Section 5 of 5
144export function validate(purl: PurlObject, throws: boolean): boolean {
145 if (
146 !validateEmptyByType('gem', 'namespace', purl.namespace, {
147 throws,
148 })
149 ) {
150 return false
151 }
152 if (!validateNoInjectionByType('gem', 'name', purl.name, throws)) {
153 return false
154 }
155 return true
156}
src/purl-types/generic.ts
2 sections
1 2
Section 1 of 2
8interface PurlObject {
9 name: string
10 namespace?: string | undefined
11 qualifiers?: Record<string, string> | undefined
12 subpath?: string | undefined
13 type?: string | undefined
14 version?: string | undefined
15}
Section 2 of 2
22export function normalize(purl: PurlObject): PurlObject {
23 return purl
24}
src/purl-types/github.ts
3 sections
1 2 3
Section 1 of 3
5import { lowerName, lowerNamespace } from '../strings.js'
6import { validateNoInjectionByType } from '../validate.js'
7
8interface PurlObject {
9 name: string
10 namespace?: string | undefined
11 qualifiers?: Record<string, string> | undefined
12 subpath?: string | undefined
13 type?: string | undefined
14 version?: string | undefined
15}
Section 2 of 3
22export function normalize(purl: PurlObject): PurlObject {
23 lowerNamespace(purl)
24 lowerName(purl)
25 return purl
26}
Section 3 of 3
32export function validate(purl: PurlObject, throws: boolean): boolean {
33 if (
34 !validateNoInjectionByType('github', 'namespace', purl.namespace, throws)
35 ) {
36 return false
37 }
38 if (!validateNoInjectionByType('github', 'name', purl.name, throws)) {
39 return false
40 }
41 return true
42}
src/purl-types/gitlab.ts
3 sections
1 2 3
Section 1 of 3
5import { lowerName, lowerNamespace } from '../strings.js'
6import { validateNoInjectionByType } from '../validate.js'
7
8interface PurlObject {
9 name: string
10 namespace?: string | undefined
11 qualifiers?: Record<string, string> | undefined
12 subpath?: string | undefined
13 type?: string | undefined
14 version?: string | undefined
15}
Section 2 of 3
22export function normalize(purl: PurlObject): PurlObject {
23 lowerNamespace(purl)
24 lowerName(purl)
25 return purl
26}
Section 3 of 3
32export function validate(purl: PurlObject, throws: boolean): boolean {
33 if (
34 !validateNoInjectionByType('gitlab', 'namespace', purl.namespace, throws)
35 ) {
36 return false
37 }
38 if (!validateNoInjectionByType('gitlab', 'name', purl.name, throws)) {
39 return false
40 }
41 return true
42}
src/purl-types/golang.ts
6 sections
1 2 3 4 5 6
Section 1 of 6
30import { httpJson } from '@socketsecurity/lib/http-request'
31
32import { errorMessage, PurlError } from '../error.js'
33import {
34 ArrayPrototypeJoin,
35 encodeComponent,
36 StringPrototypeCharCodeAt,
37 StringPrototypeIncludes,
38 StringPrototypeReplace,
39 StringPrototypeSlice,
40 StringPrototypeSplit,
41 StringPrototypeToLowerCase,
42} from '@socketsecurity/lib/primordials'
43import { isSemverString } from '../strings.js'
44import { validateNoInjectionByType } from '../validate.js'
45
46import type { ExistsResult, ExistsOptions } from './npm.js'
47
48interface PurlObject {
49 name: string
50 namespace?: string | undefined
51 qualifiers?: Record<string, string> | undefined
52 subpath?: string | undefined
53 type?: string | undefined
54 version?: string | undefined
55}
Section 2 of 6
90export async function golangExists(
91 name: string,
92 namespace?: string,
93 version?: string,
94 options?: ExistsOptions,
95): Promise<ExistsResult> {
96 const modulePath = namespace ? `${namespace}/${name}` : name
97 const cacheKey = version
98 ? `golang:${modulePath}@${version}`
99 : `golang:${modulePath}`
100
101 if (options?.cache) {
102 const cached = await options.cache.get<ExistsResult>(cacheKey)
103 if (cached !== undefined) {
104 return cached
105 }
106 }
107
108 const fetchResult = async (): Promise<ExistsResult> => {
109 try {
110 // Encode the module path for the URL
111 // Go proxy uses case-encoded paths where uppercase letters are `!lowercase`
112 const parts = StringPrototypeSplit(modulePath, '/' as any)
113 for (let i = 0; i < parts.length; i++) {
114 parts[i] = encodeComponent(
115 StringPrototypeReplace(
116 parts[i]!,
117 /[A-Z]/g,
118 letter => `!${StringPrototypeToLowerCase(letter)}`,
119 ),
120 )
121 }
122 const encodedPath = ArrayPrototypeJoin(parts, '/')
123
124 const url = `https://proxy.golang.org/${encodedPath}/@latest`
125
126 const data = await httpJson<{
127 Version?: string
128 Time?: string
129 }>(url)
130
131 const latestVersion = data.Version
132
133 if (version) {
134 const versionUrl = `https://proxy.golang.org/${encodedPath}/@v/${encodeComponent(version)}.info`
135 try {
136 await httpJson(versionUrl)
137 } catch {
138 const result: ExistsResult = {
139 exists: false,
140 error: `Version ${version} not found`,
141 }
142 if (latestVersion !== undefined) {
143 result.latestVersion = latestVersion
144 }
145 return result
146 }
147 }
148
149 const result: ExistsResult = { exists: true }
150 if (latestVersion !== undefined) {
151 result.latestVersion = latestVersion
152 }
153 return result
154 } catch (e) {
Section 3 of 6
156 const error = errorMessage(e)
157 return {
158 exists: false,
159 error:
160 StringPrototypeIncludes(error, '404') ||
161 StringPrototypeIncludes(error, '410')
162 ? 'Module not found'
163 : error,
164 }
Section 4 of 6
166 }
167 }
168
169 const result = await fetchResult()
170 // Only cache successful results to avoid negative cache poisoning
171 // from transient failures (network errors, 5xx responses)
172 if (options?.cache && result.exists) {
173 await options.cache.set(cacheKey, Object.freeze(result))
174 }
175 return result
176}
Section 5 of 6
183export function validate(purl: PurlObject, throws: boolean): boolean {
184 if (
185 !validateNoInjectionByType('golang', 'namespace', purl.namespace, throws)
186 ) {
187 return false
188 }
189 if (!validateNoInjectionByType('golang', 'name', purl.name, throws)) {
190 return false
191 }
192 // Still being lenient here since the standard changes aren't official
193 // Pending spec change: https://github.com/package-url/purl-spec/pull/196
194 const { version } = purl
195 const length = typeof version === 'string' ? version.length : 0
196 // If the version starts with a `"v"` then ensure its a valid semver version
197 // This, by semver semantics, also supports pseudo-version number
198 // https://go.dev/doc/modules/version-numbers#pseudo-version-number
199 if (
200 length &&
Section 6 of 6
202 !isSemverString(StringPrototypeSlice(version!, 1))
203 ) {
204 if (throws) {
205 throw new PurlError(
206 'golang "version" component starting with a "v" must be followed by a valid semver version',
207 )
208 }
209 return false
210 }
211 return true
212}
src/purl-types/hackage.ts
4 sections
1 2 3 4
Section 1 of 4
5import { errorMessage } from '../error.js'
6import { httpJson } from '@socketsecurity/lib/http-request'
7
8import {
9 ArrayPrototypeIncludes,
10 StringPrototypeIncludes,
11 encodeComponent,
12} from '@socketsecurity/lib/primordials'
13
14import type { ExistsResult, ExistsOptions } from './npm.js'
Section 2 of 4
43export async function hackageExists(
44 name: string,
45 version?: string,
46 options?: ExistsOptions,
47): Promise<ExistsResult> {
48 const cacheKey = version ? `hackage:${name}@${version}` : `hackage:${name}`
49
50 // Try cache first if provided
51 if (options?.cache) {
52 const cached = await options.cache.get<ExistsResult>(cacheKey)
53 if (cached !== undefined) {
54 return cached
55 }
56 }
57
58 const fetchResult = async (): Promise<ExistsResult> => {
59 try {
60 const url = `https://hackage.haskell.org/package/${encodeComponent(name)}/preferred`
61
62 const data = await httpJson<{
63 'normal-version'?: string[]
64 }>(url)
65
66 const versions = data['normal-version'] || []
67 if (versions.length === 0) {
68 return { exists: false, error: 'No versions found' }
69 }
70
71 // Latest version is typically the last in the array
72 const latestVersion = versions[versions.length - 1]
73
74 if (version) {
75 if (!ArrayPrototypeIncludes(versions, version)) {
76 const result: ExistsResult = {
77 exists: false,
78 error: `Version ${version} not found`,
79 }
80 if (latestVersion !== undefined) {
81 result.latestVersion = latestVersion
82 }
83 return result
84 }
85 }
86
87 const result: ExistsResult = { exists: true }
88 if (latestVersion !== undefined) {
89 result.latestVersion = latestVersion
90 }
91 return result
92 } catch (e) {
Section 3 of 4
94 const error = errorMessage(e)
95 return {
96 exists: false,
97 error: StringPrototypeIncludes(error, '404')
98 ? 'Package not found'
99 : error,
100 }
Section 4 of 4
102 }
103 }
104
105 const result = await fetchResult()
106
107 // Only cache successful results to avoid negative cache poisoning
108 // from transient failures (network errors, 5xx responses)
109 if (options?.cache && result.exists) {
110 await options.cache.set(cacheKey, Object.freeze(result))
111 }
112
113 return result
114}
src/purl-types/hex.ts
6 sections
1 2 3 4 5 6
Section 1 of 6
5import { errorMessage } from '../error.js'
6import { httpJson } from '@socketsecurity/lib/http-request'
7
8import {
9 ArrayPrototypeSome,
10 StringPrototypeIncludes,
11 encodeComponent,
12} from '@socketsecurity/lib/primordials'
13import { lowerName, lowerNamespace } from '../strings.js'
14import { validateNoInjectionByType } from '../validate.js'
15
16import type { ExistsResult, ExistsOptions } from './npm.js'
17
18interface PurlObject {
19 name: string
20 namespace?: string | undefined
21 qualifiers?: Record<string, string> | undefined
22 subpath?: string | undefined
23 type?: string | undefined
24 version?: string | undefined
25}
Section 2 of 6
54export async function hexExists(
55 name: string,
56 version?: string,
57 options?: ExistsOptions,
58): Promise<ExistsResult> {
59 const cacheKey = version ? `hex:${name}@${version}` : `hex:${name}`
60
61 if (options?.cache) {
62 const cached = await options.cache.get<ExistsResult>(cacheKey)
63 if (cached !== undefined) {
64 return cached
65 }
66 }
67
68 const fetchResult = async (): Promise<ExistsResult> => {
69 try {
70 const url = `https://hex.pm/api/packages/${encodeComponent(name)}`
71
72 const data = await httpJson<{
73 latest_version?: string
74 releases?: Array<{
75 version?: string
76 }>
77 }>(url)
78
79 const latestVersion = data.latest_version
80
81 if (version) {
82 const releases = data.releases || []
83 const versionExists = ArrayPrototypeSome(
84 releases,
85 r => r.version === version,
86 )
87 if (!versionExists) {
88 const result: ExistsResult = {
89 exists: false,
90 error: `Version ${version} not found`,
91 }
92 if (latestVersion !== undefined) {
93 result.latestVersion = latestVersion
94 }
95 return result
96 }
97 }
98
99 const result: ExistsResult = { exists: true }
100 if (latestVersion !== undefined) {
101 result.latestVersion = latestVersion
102 }
103 return result
104 } catch (e) {
Section 3 of 6
106 const error = errorMessage(e)
107 return {
108 exists: false,
109 error: StringPrototypeIncludes(error, '404')
110 ? 'Package not found'
111 : error,
112 }
Section 4 of 6
114 }
115 }
116
117 const result = await fetchResult()
118 // Only cache successful results to avoid negative cache poisoning
119 // from transient failures (network errors, 5xx responses)
120 if (options?.cache && result.exists) {
121 await options.cache.set(cacheKey, Object.freeze(result))
122 }
123 return result
124}
Section 5 of 6
130export function normalize(purl: PurlObject): PurlObject {
131 lowerNamespace(purl)
132 lowerName(purl)
133 return purl
134}
Section 6 of 6
140export function validate(purl: PurlObject, throws: boolean): boolean {
141 if (!validateNoInjectionByType('hex', 'namespace', purl.namespace, throws)) {
142 return false
143 }
144 if (!validateNoInjectionByType('hex', 'name', purl.name, throws)) {
145 return false
146 }
147 return true
148}
src/purl-types/huggingface.ts
2 sections
1 2
Section 1 of 2
5import { lowerVersion } from '../strings.js'
6
7interface PurlObject {
8 name: string
9 namespace?: string | undefined
10 qualifiers?: Record<string, string> | undefined
11 subpath?: string | undefined
12 type?: string | undefined
13 version?: string | undefined
14}
Section 2 of 2
21export function normalize(purl: PurlObject): PurlObject {
22 lowerVersion(purl)
23 return purl
24}
src/purl-types/julia.ts
3 sections
1 2 3
Section 1 of 3
8import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
9
10interface PurlObject {
11 name: string
12 namespace?: string | undefined
13 qualifiers?: Record<string, string> | undefined
14 subpath?: string | undefined
15 type?: string | undefined
16 version?: string | undefined
17}
Section 2 of 3
24export function normalize(purl: PurlObject): PurlObject {
25 return purl
26}
Section 3 of 3
32export function validate(purl: PurlObject, throws: boolean): boolean {
33 if (
34 !validateEmptyByType('julia', 'namespace', purl.namespace, {
35 throws,
36 })
37 ) {
38 return false
39 }
40 if (!validateNoInjectionByType('julia', 'name', purl.name, throws)) {
41 return false
42 }
43 return true
44}
src/purl-types/luarocks.ts
2 sections
1 2
Section 1 of 2
5import { lowerVersion } from '../strings.js'
6
7interface PurlObject {
8 name: string
9 namespace?: string | undefined
10 qualifiers?: Record<string, string> | undefined
11 subpath?: string | undefined
12 type?: string | undefined
13 version?: string | undefined
14}
Section 2 of 2
21export function normalize(purl: PurlObject): PurlObject {
22 lowerVersion(purl)
23 return purl
24}
src/purl-types/maven.ts
5 sections
1 2 3 4 5
Section 1 of 5
5import { httpJson } from '@socketsecurity/lib/http-request'
6
7import { errorMessage, PurlError } from '../error.js'
8import {
9 StringPrototypeIncludes,
10 encodeComponent,
11} from '@socketsecurity/lib/primordials'
12import {
13 validateNoInjectionByType,
14 validateRequiredByType,
15} from '../validate.js'
16
17import type { ExistsResult, ExistsOptions } from './npm.js'
18
19interface PurlObject {
20 name: string
21 namespace?: string | undefined
22 qualifiers?: Record<string, string> | undefined
23 subpath?: string | undefined
24 type?: string | undefined
25 version?: string | undefined
26}
Section 2 of 5
57export async function mavenExists(
58 name: string,
59 namespace?: string,
60 version?: string,
61 options?: ExistsOptions,
62): Promise<ExistsResult> {
63 if (!namespace) {
64 return { exists: false, error: 'Maven requires namespace (group ID)' }
65 }
66
67 const packageId = `${namespace}:${name}`
68 const cacheKey = version
69 ? `maven:${packageId}@${version}`
70 : `maven:${packageId}`
71
72 if (options?.cache) {
73 const cached = await options.cache.get<ExistsResult>(cacheKey)
74 if (cached !== undefined) {
75 return cached
76 }
77 }
78
79 const fetchResult = async (): Promise<ExistsResult> => {
80 try {
81 const g = encodeComponent(namespace)
82 const a = encodeComponent(name)
83 const url = `https://search.maven.org/solrsearch/select?q=g:${g}+AND+a:${a}&rows=1&wt=json`
84
85 const data = await httpJson<{
86 response?: {
87 numFound?: number
88 docs?: Array<{ latestVersion?: string; v?: string }>
89 }
90 }>(url)
91
92 const numFound = data.response?.['numFound'] || 0
93 if (numFound === 0) {
94 return { exists: false, error: 'Package not found' }
95 }
96
97 const doc = data.response?.['docs']?.[0]
98 const latestVersion = doc?.['latestVersion'] || doc?.['v']
99
100 if (version) {
101 const versionUrl = `https://search.maven.org/solrsearch/select?q=g:${g}+AND+a:${a}+AND+v:${encodeComponent(version)}&rows=1&wt=json`
102 const versionData = await httpJson<{
103 response?: { numFound?: number }
104 }>(versionUrl)
105
106 const versionFound = versionData.response?.['numFound'] || 0
107 if (versionFound === 0) {
108 const result: ExistsResult = {
109 exists: false,
110 error: `Version ${version} not found`,
111 }
112 if (latestVersion !== undefined) {
113 result.latestVersion = latestVersion
114 }
115 return result
116 }
117 }
118
119 const result: ExistsResult = { exists: true }
120 if (latestVersion !== undefined) {
121 result.latestVersion = latestVersion
122 }
123 return result
124 } catch (e) {
Section 3 of 5
126 const error = errorMessage(e)
127 return {
128 exists: false,
129 error: StringPrototypeIncludes(error, '404')
130 ? 'Package not found'
131 : error,
132 }
Section 4 of 5
134 }
135 }
136
137 const result = await fetchResult()
138 // Only cache successful results to avoid negative cache poisoning
139 // from transient failures (network errors, 5xx responses)
140 if (options?.cache && result.exists) {
141 await options.cache.set(cacheKey, Object.freeze(result))
142 }
143 return result
144}
Section 5 of 5
151export function validate(purl: PurlObject, throws: boolean): boolean {
152 if (
153 !validateRequiredByType('maven', 'namespace', purl.namespace, {
154 throws,
155 })
156 ) {
157 return false
158 }
159 if (
160 !validateNoInjectionByType('maven', 'namespace', purl.namespace, throws)
161 ) {
162 return false
163 }
164 // Maven `groupId` uses dots as separators; a literal `'/'` in the namespace is
165 // not a valid `groupId` and corrupts downstream URL construction.
166 if (
167 typeof purl.namespace === 'string' &&
168 StringPrototypeIncludes(purl.namespace, '/')
169 ) {
170 if (throws) {
171 throw new PurlError(
172 'maven "namespace" component must not contain a slash',
173 )
174 }
175 return false
176 }
177 if (!validateNoInjectionByType('maven', 'name', purl.name, throws)) {
178 return false
179 }
180 return true
181}
src/purl-types/mlflow.ts
3 sections
1 2 3
Section 1 of 3
5import { StringPrototypeIncludes } from '@socketsecurity/lib/primordials'
6import { lowerName } from '../strings.js'
7import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
8
9interface PurlObject {
10 name: string
11 namespace?: string | undefined
12 qualifiers?: Record<string, string> | undefined
13 subpath?: string | undefined
14 type?: string | undefined
15 version?: string | undefined
16}
Section 2 of 3
23export function normalize(purl: PurlObject): PurlObject {
24 const repoUrl = purl.qualifiers?.['repository_url']
25 if (repoUrl !== undefined && StringPrototypeIncludes(repoUrl, 'databricks')) {
26 lowerName(purl)
27 }
28 return purl
29}
Section 3 of 3
35export function validate(purl: PurlObject, throws: boolean): boolean {
36 if (
37 !validateEmptyByType('mlflow', 'namespace', purl.namespace, {
38 throws,
39 })
40 ) {
41 return false
42 }
43 if (!validateNoInjectionByType('mlflow', 'name', purl.name, throws)) {
44 return false
45 }
46 return true
47}
src/purl-types/npm.ts
33 sections
Section 1 of 33
5import { httpJson } from '@socketsecurity/lib/http-request'
6
7import { encodeComponent } from '../encode.js'
8import { errorMessage, PurlError } from '../error.js'
9import {
10 ErrorCtor,
11 RegExpPrototypeTest,
12 SetCtor,
13 StringPrototypeCharCodeAt,
14 StringPrototypeIncludes,
15 StringPrototypeIndexOf,
16 StringPrototypeReplace,
17 StringPrototypeSlice,
18 StringPrototypeStartsWith,
19 StringPrototypeToLowerCase,
20 StringPrototypeTrim,
21} from '@socketsecurity/lib/primordials'
22import { isBlank, lowerName, lowerNamespace } from '../strings.js'
23import { validateNoInjectionByType } from '../validate.js'
24
25import type { TtlCache } from '@socketsecurity/lib/cache-with-ttl'
26
27interface PurlObject {
28 name: string
29 namespace?: string | undefined
30 qualifiers?: Record<string, string> | undefined
31 subpath?: string | undefined
32 type?: string | undefined
33 version?: string | undefined
34}
Section 2 of 33
40export type ExistsResult = {
41 exists: boolean
42 latestVersion?: string
43 error?: string
44}
Section 3 of 33
49export type ExistsOptions = {
Section 4 of 33
63 cache?: TtlCache
64}
Section 5 of 33
70export type NpmPackageComponents = {
71 namespace: string | undefined
72 name: string
73 version: string | undefined
74}
Section 6 of 33
79const getNpmBuiltinSet = (() => {
80 let builtinSet: Set<string> | undefined
81 return () => {
82 if (builtinSet === undefined) {
83 let builtinNames: string[] | undefined
Section 7 of 33
85 try {
86 // Try to use Node.js `builtinModules` first
87 builtinNames = (module.constructor as { builtinModules?: string[] })
88 ?.builtinModules
89 } catch {}
Section 8 of 33
91 if (!builtinNames) {
92 // Fallback to hardcoded list
93 builtinNames = [
94 'assert',
95 'async_hooks',
96 'buffer',
97 'child_process',
98 'cluster',
99 'console',
100 'constants',
101 'crypto',
102 'dgram',
103 'diagnostics_channel',
104 'dns',
105 'domain',
106 'events',
107 'fs',
108 'http',
109 'http2',
110 'https',
111 'inspector',
112 'module',
113 'net',
114 'os',
115 'path',
116 'perf_hooks',
117 'process',
118 'punycode',
119 'querystring',
120 'readline',
121 'repl',
122 'stream',
123 'string_decoder',
124 'sys',
125 'timers',
126 'tls',
127 'trace_events',
128 'tty',
129 'url',
130 'util',
131 'v8',
132 'vm',
133 'wasi',
134 'worker_threads',
135 'zlib',
136 ]
137 }
138 builtinSet = new SetCtor(builtinNames)
139 }
140 return builtinSet
141 }
142})()
Section 9 of 33
147function getNpmId(purl: PurlObject): string {
148 const { name, namespace } = purl
149 return `${namespace && namespace.length > 0 ? `${namespace}/` : ''}${name}`
150}
Section 10 of 33
155const getNpmLegacySet = (() => {
156 let legacySet: Set<string> | undefined
157
158 return (): Set<string> => {
159 if (legacySet === undefined) {
160 let fullLegacyNames: string[]
Section 11 of 33
162 try {
163 // Try to load the full list from JSON file
164 fullLegacyNames = require('../../data/npm/legacy-names.json')
165 } catch {
166 // Fallback to hardcoded builtin names for simplicity
167 fullLegacyNames = [
168 'assert',
169 'buffer',
170 'crypto',
171 'events',
172 'fs',
173 'http',
174 'os',
175 'path',
176 'url',
177 'util',
178 ]
179 }
Section 12 of 33
181 legacySet = new SetCtor(fullLegacyNames)
182 }
183 return legacySet
184 }
185})()
Section 13 of 33
190const isNpmBuiltinName = (id: string): boolean =>
191 getNpmBuiltinSet().has(StringPrototypeToLowerCase(id))
Section 14 of 33
196const isNpmLegacyName = (id: string): boolean => getNpmLegacySet().has(id)
Section 15 of 33
202export function normalize(purl: PurlObject): PurlObject {
203 lowerNamespace(purl)
204 // Ignore lowercasing legacy names because they could be mixed case
205 // https://github.com/npm/validate-npm-package-name/tree/v6.0.0?tab=readme-ov-file#legacy-names
206 if (!isNpmLegacyName(getNpmId(purl))) {
207 lowerName(purl)
208 }
209 return purl
210}
Section 16 of 33
252export async function npmExists(
253 name: string,
254 namespace?: string,
255 version?: string,
256 options?: ExistsOptions,
257): Promise<ExistsResult> {
258 // Build cache key
259 const packageName = namespace ? `${namespace}/${name}` : name
260 const cacheKey = version
261 ? `npm:${packageName}@${version}`
262 : `npm:${packageName}`
263
264 // Try cache first if provided
265 if (options?.cache) {
266 const cached = await options.cache.get<ExistsResult>(cacheKey)
267 if (cached !== undefined) {
268 return cached
269 }
270 }
271
272 const fetchResult = async (): Promise<ExistsResult> => {
273 try {
274 const encodedName = encodeComponent(packageName)
275 const url = `https://registry.npmjs.org/${encodedName}`
276
277 const data = await httpJson<{
278 'dist-tags'?: { latest?: string }
279 versions?: Record<string, unknown>
280 }>(url)
281
282 const latestVersion = data['dist-tags']?.['latest']
283
284 // If specific version requested, validate it exists
285 if (version && data.versions) {
286 if (!(version in data.versions)) {
287 const result: ExistsResult = {
288 exists: false,
289 error: `Version ${version} not found`,
290 }
291 if (latestVersion !== undefined) {
292 result.latestVersion = latestVersion
293 }
294 return result
295 }
296 }
297
298 const result: ExistsResult = {
299 exists: true,
300 }
301 if (latestVersion !== undefined) {
302 result.latestVersion = latestVersion
303 }
304 return result
305 } catch (e) {
Section 17 of 33
307 // `httpJson` throws on non-2xx status codes
308 const error = errorMessage(e)
309 return {
310 exists: false,
311 error: StringPrototypeIncludes(error, '404')
312 ? 'Package not found'
313 : error,
314 }
Section 18 of 33
316 }
317 }
318
319 const result = await fetchResult()
320
321 // Only cache successful results to avoid negative cache poisoning
322 // from transient failures (network errors, 5xx responses)
323 if (options?.cache && result.exists) {
324 await options.cache.set(cacheKey, Object.freeze(result))
325 }
326
327 return result
328}
Section 19 of 33
375export function parseNpmSpecifier(specifier: unknown): NpmPackageComponents {
376 if (typeof specifier !== 'string') {
377 throw new ErrorCtor('npm package specifier string is required.')
378 }
379
380 if (isBlank(specifier)) {
381 throw new ErrorCtor('npm package specifier cannot be empty.')
382 }
383
384 // Handle scoped packages: `@scope/name@version`
385 let namespace: string | undefined
386 let name: string
387 let version: string | undefined
388
389 // Check if it's a scoped package
390 if (StringPrototypeStartsWith(specifier, '@')) {
391 // Find the second slash (after `@scope/`)
392 const slashIndex = StringPrototypeIndexOf(specifier, '/')
393 if (slashIndex === -1) {
394 throw new ErrorCtor(
395 'npm scoped specifier must contain "/" after scope (e.g. "@scope/name").',
396 )
397 }
398
399 // Find the `@` after the scope
400 const atIndex = StringPrototypeIndexOf(specifier, '@', slashIndex)
401 if (atIndex === -1) {
402 // No version specified
403 namespace = StringPrototypeSlice(specifier, 0, slashIndex)
404 name = StringPrototypeSlice(specifier, slashIndex + 1)
405 } else {
406 namespace = StringPrototypeSlice(specifier, 0, slashIndex)
407 name = StringPrototypeSlice(specifier, slashIndex + 1, atIndex)
408 version = StringPrototypeSlice(specifier, atIndex + 1)
409 }
410 } else {
411 // Non-scoped package: `name@version`
412 const atIndex = StringPrototypeIndexOf(specifier, '@')
413 if (atIndex === -1) {
414 // No version specified
415 name = specifier
416 } else {
417 name = StringPrototypeSlice(specifier, 0, atIndex)
418 version = StringPrototypeSlice(specifier, atIndex + 1)
419 }
420 }
421
422 // Clean up version - remove common npm range prefixes
423 if (version) {
424 // Remove leading `^`, `~`, `>=`, `<=`, `>`, `<`, `=`
425 version = StringPrototypeReplace(version, /^[\^~>=<]+/, '' as any)
426 // Handle version ranges like `"1.0.0 - 2.0.0"` by taking first version
427 const spaceIndex = StringPrototypeIndexOf(version, ' ')
428 if (spaceIndex !== -1) {
429 version = StringPrototypeSlice(version, 0, spaceIndex)
430 }
431 }
432
433 return { namespace, name, version }
434}
Section 20 of 33
442export function validate(purl: PurlObject, throws: boolean): boolean {
443 const { name, namespace } = purl
444 // Validate `name` and `namespace` for injection characters
445 if (!validateNoInjectionByType('npm', 'name', name, throws)) {
446 return false
447 }
448 if (!validateNoInjectionByType('npm', 'namespace', namespace, throws)) {
449 return false
450 }
451 const hasNs = namespace && namespace.length > 0
452 const id = getNpmId(purl)
453 const code0 = StringPrototypeCharCodeAt(id, 0)
454 const compName = hasNs ? 'namespace' : 'name'
Section 21 of 33
456 if (throws) {
457 throw new PurlError(
458 `npm "${compName}" component cannot start with a period`,
459 )
460 }
461 return false
462 }
Section 22 of 33
464 if (throws) {
465 throw new PurlError(
466 `npm "${compName}" component cannot start with an underscore`,
467 )
468 }
469 return false
470 }
Section 23 of 33
472 if (StringPrototypeTrim(name) !== name) {
473 if (throws) {
474 throw new PurlError(
475 'npm "name" component cannot contain leading or trailing spaces',
476 )
477 }
478 return false
479 }
Section 24 of 33
481 if (encodeComponent(name) !== name) {
482 if (throws) {
483 throw new PurlError(
484 `npm "name" component can only contain URL-friendly characters`,
485 )
486 }
487 return false
488 }
489 if (hasNs) {
Section 25 of 33
491 if (
492 (namespace !== undefined ? StringPrototypeTrim(namespace) : namespace) !==
493 namespace
494 ) {
495 if (throws) {
496 throw new PurlError(
497 'npm "namespace" component cannot contain leading or trailing spaces',
498 )
499 }
500 return false
501 }
Section 26 of 33
503 if (code0 !== 64 ) {
Section 27 of 33
504 if (throws) {
505 throw new PurlError(
506 `npm "namespace" component must start with an "@" character`,
507 )
508 }
509 return false
510 }
511 const namespaceWithoutAtSign =
512 namespace !== undefined ? StringPrototypeSlice(namespace, 1) : namespace
513 if (encodeComponent(namespaceWithoutAtSign) !== namespaceWithoutAtSign) {
514 if (throws) {
515 throw new PurlError(
516 `npm "namespace" component can only contain URL-friendly characters`,
517 )
518 }
519 return false
520 }
521 }
522 const loweredId = StringPrototypeToLowerCase(id)
523 if (loweredId === 'node_modules' || loweredId === 'favicon.ico') {
524 if (throws) {
525 throw new PurlError(
526 `npm "${compName}" component of "${loweredId}" is not allowed`,
527 )
528 }
529 return false
530 }
531 // The remaining checks are only for modern names
532 // https://github.com/npm/validate-npm-package-name/tree/v6.0.0?tab=readme-ov-file#naming-rules
533 if (!isNpmLegacyName(id)) {
534 if (id.length > 214) {
535 if (throws) {
536 // Tested: validation returns false in non-throw mode
537 // V8 coverage can't see both throw and return false paths in same test
Section 28 of 33
539 throw new PurlError(
540 `npm "namespace" and "name" components can not collectively be more than 214 characters`,
541 )
Section 29 of 33
543 }
544 return false
545 }
546 if (loweredId !== id) {
547 if (throws) {
548 throw new PurlError(
549 `npm "name" component can not contain capital letters`,
550 )
551 }
552 return false
553 }
Section 30 of 33
555 if (RegExpPrototypeTest(/[~'!()*]/, name)) {
556 if (throws) {
557 throw new PurlError(
558 `npm "name" component can not contain special characters ("~'!()*")`,
559 )
560 }
561 return false
562 }
Section 31 of 33
564 if (isNpmBuiltinName(id)) {
565 if (throws) {
566 // Tested: validation returns false in non-throw mode
567 // V8 coverage can't see both throw and return false paths in same test
Section 32 of 33
569 throw new PurlError(
570 'npm "name" component can not be a core module name',
571 )
Section 33 of 33
573 }
574 return false
575 }
576 }
577 return true
578}
src/purl-types/nuget.ts
5 sections
1 2 3 4 5
Section 1 of 5
5import { errorMessage } from '../error.js'
6import { httpJson } from '@socketsecurity/lib/http-request'
7
8import {
9 ArrayPrototypeIncludes,
10 ArrayPrototypePush,
11 StringPrototypeIncludes,
12 StringPrototypeToLowerCase,
13 encodeComponent,
14} from '@socketsecurity/lib/primordials'
15import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
16
17import type { ExistsResult, ExistsOptions } from './npm.js'
18
19interface PurlObject {
20 name: string
21 namespace?: string | undefined
22 qualifiers?: Record<string, string> | undefined
23 subpath?: string | undefined
24 type?: string | undefined
25 version?: string | undefined
26}
Section 2 of 5
55export async function nugetExists(
56 name: string,
57 version?: string,
58 options?: ExistsOptions,
59): Promise<ExistsResult> {
60 const cacheKey = version ? `nuget:${name}@${version}` : `nuget:${name}`
61
62 if (options?.cache) {
63 const cached = await options.cache.get<ExistsResult>(cacheKey)
64 if (cached !== undefined) {
65 return cached
66 }
67 }
68
69 const fetchResult = async (): Promise<ExistsResult> => {
70 try {
71 const lowerName = StringPrototypeToLowerCase(name)
72 const url = `https://api.nuget.org/v3/registration5-semver1/${encodeComponent(lowerName)}/index.json`
73
74 const data = await httpJson<{
75 items?: Array<{
76 items?: Array<{
77 catalogEntry?: {
78 version?: string
79 }
80 }>
81 upper?: string
82 }>
83 }>(url)
84
85 if (!data.items || data.items.length === 0) {
86 return { exists: false, error: 'Package not found' }
87 }
88
89 // Get all versions from all pages
90 const versions: string[] = []
91 for (const page of data.items) {
92 if (page.items) {
93 for (const item of page.items) {
94 const ver = item.catalogEntry?.['version']
95 if (ver) {
96 ArrayPrototypePush(versions, ver)
97 }
98 }
99 }
100 }
101
102 if (versions.length === 0) {
103 return { exists: false, error: 'No versions found' }
104 }
105
106 // Latest version is typically the last one
107 const latestVersion = versions[versions.length - 1]
108
109 if (version) {
110 if (!ArrayPrototypeIncludes(versions, version)) {
111 const result: ExistsResult = {
112 exists: false,
113 error: `Version ${version} not found`,
114 }
115 if (latestVersion !== undefined) {
116 result.latestVersion = latestVersion
117 }
118 return result
119 }
120 }
121
122 const result: ExistsResult = { exists: true }
123 if (latestVersion !== undefined) {
124 result.latestVersion = latestVersion
125 }
126 return result
127 } catch (e) {
Section 3 of 5
129 const error = errorMessage(e)
130 return {
131 exists: false,
132 error: StringPrototypeIncludes(error, '404')
133 ? 'Package not found'
134 : error,
135 }
Section 4 of 5
137 }
138 }
139
140 const result = await fetchResult()
141 // Only cache successful results to avoid negative cache poisoning
142 // from transient failures (network errors, 5xx responses)
143 if (options?.cache && result.exists) {
144 await options.cache.set(cacheKey, Object.freeze(result))
145 }
146 return result
147}
Section 5 of 5
153export function validate(purl: PurlObject, throws: boolean): boolean {
154 if (
155 !validateEmptyByType('nuget', 'namespace', purl.namespace, {
156 throws,
157 })
158 ) {
159 return false
160 }
161 if (!validateNoInjectionByType('nuget', 'name', purl.name, throws)) {
162 return false
163 }
164 return true
165}
src/purl-types/oci.ts
3 sections
1 2 3
Section 1 of 3
5import { lowerName, lowerVersion } from '../strings.js'
6import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
7
8interface PurlObject {
9 name: string
10 namespace?: string | undefined
11 qualifiers?: Record<string, string> | undefined
12 subpath?: string | undefined
13 type?: string | undefined
14 version?: string | undefined
15}
Section 2 of 3
22export function normalize(purl: PurlObject): PurlObject {
23 lowerName(purl)
24 lowerVersion(purl)
25 return purl
26}
Section 3 of 3
32export function validate(purl: PurlObject, throws: boolean): boolean {
33 if (
34 !validateEmptyByType('oci', 'namespace', purl.namespace, {
35 throws,
36 })
37 ) {
38 return false
39 }
40 if (!validateNoInjectionByType('oci', 'name', purl.name, throws)) {
41 return false
42 }
43 return true
44}
src/purl-types/opam.ts
2 sections
1 2
Section 1 of 2
7import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
8
9interface PurlObject {
10 name: string
11 namespace?: string | undefined
12 qualifiers?: Record<string, string> | undefined
13 subpath?: string | undefined
14 type?: string | undefined
15 version?: string | undefined
16}
Section 2 of 2
23export function validate(purl: PurlObject, throws: boolean): boolean {
24 if (
25 !validateEmptyByType('opam', 'namespace', purl.namespace, {
26 throws,
27 })
28 ) {
29 return false
30 }
31 if (!validateNoInjectionByType('opam', 'name', purl.name, throws)) {
32 return false
33 }
34 return true
35}
src/purl-types/otp.ts
3 sections
1 2 3
Section 1 of 3
8import { lowerName } from '../strings.js'
9import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
10
11interface PurlObject {
12 name: string
13 namespace?: string | undefined
14 qualifiers?: Record<string, string> | undefined
15 subpath?: string | undefined
16 type?: string | undefined
17 version?: string | undefined
18}
Section 2 of 3
25export function normalize(purl: PurlObject): PurlObject {
26 lowerName(purl)
27 return purl
28}
Section 3 of 3
34export function validate(purl: PurlObject, throws: boolean): boolean {
35 if (
36 !validateEmptyByType('otp', 'namespace', purl.namespace, {
37 throws,
38 })
39 ) {
40 return false
41 }
42 if (!validateNoInjectionByType('otp', 'name', purl.name, throws)) {
43 return false
44 }
45 return true
46}
src/purl-types/pub.ts
7 sections
1 2 3 4 5 6 7
Section 1 of 7
5import { httpJson } from '@socketsecurity/lib/http-request'
6
7import { errorMessage, PurlError } from '../error.js'
8import {
9 ArrayPrototypeSome,
10 StringPrototypeCharCodeAt,
11 StringPrototypeIncludes,
12 encodeComponent,
13} from '@socketsecurity/lib/primordials'
14import { lowerName, replaceDashesWithUnderscores } from '../strings.js'
15
16import type { ExistsResult, ExistsOptions } from './npm.js'
17
18interface PurlObject {
19 name: string
20 namespace?: string | undefined
21 qualifiers?: Record<string, string> | undefined
22 subpath?: string | undefined
23 type?: string | undefined
24 version?: string | undefined
25}
Section 2 of 7
32export function normalize(purl: PurlObject): PurlObject {
33 lowerName(purl)
34 purl.name = replaceDashesWithUnderscores(purl.name)
35 return purl
36}
Section 3 of 7
64export async function pubExists(
65 name: string,
66 version?: string,
67 options?: ExistsOptions,
68): Promise<ExistsResult> {
69 const cacheKey = version ? `pub:${name}@${version}` : `pub:${name}`
70
71 if (options?.cache) {
72 const cached = await options.cache.get<ExistsResult>(cacheKey)
73 if (cached !== undefined) {
74 return cached
75 }
76 }
77
78 const fetchResult = async (): Promise<ExistsResult> => {
79 try {
80 const url = `https://pub.dev/api/packages/${encodeComponent(name)}`
81
82 const data = await httpJson<{
83 latest?: {
84 version?: string
85 }
86 versions?: Array<{
87 version?: string
88 }>
89 }>(url)
90
91 const latestVersion = data.latest?.['version']
92
93 if (version) {
94 const versions = data.versions || []
95 const versionExists = ArrayPrototypeSome(
96 versions,
97 v => v.version === version,
98 )
99 if (!versionExists) {
100 const result: ExistsResult = {
101 exists: false,
102 error: `Version ${version} not found`,
103 }
104 if (latestVersion !== undefined) {
105 result.latestVersion = latestVersion
106 }
107 return result
108 }
109 }
110
111 const result: ExistsResult = { exists: true }
112 if (latestVersion !== undefined) {
113 result.latestVersion = latestVersion
114 }
115 return result
116 } catch (e) {
Section 4 of 7
118 const error = errorMessage(e)
119 return {
120 exists: false,
121 error: StringPrototypeIncludes(error, '404')
122 ? 'Package not found'
123 : error,
124 }
Section 5 of 7
126 }
127 }
128
129 const result = await fetchResult()
130 // Only cache successful results to avoid negative cache poisoning
131 // from transient failures (network errors, 5xx responses)
132 if (options?.cache && result.exists) {
133 await options.cache.set(cacheKey, Object.freeze(result))
134 }
135 return result
136}
Section 6 of 7
142export function validate(purl: PurlObject, throws: boolean): boolean {
143 const { name } = purl
144 for (let i = 0, { length } = name; i < length; i += 1) {
145 const code = StringPrototypeCharCodeAt(name, i)
146 // biome-ignore format: newlines
147 if (
148 !(
149 // 0-9
150 (
151 (code >= 48 && code <= 57) ||
152 // a-z
153 (code >= 97 && code <= 122) ||
154 code === 95
155 )
156 // _
157 )
158 ) {
159 if (throws) {
160 // Tested: validation returns false in non-throw mode
161 // V8 coverage can't see both throw and return false paths in same test
Section 7 of 7
163 throw new PurlError(
164 'pub "name" component may only contain [a-z0-9_] characters',
165 )
166 }
167 return false
168 }
169 }
170 return true
171}
src/purl-types/pypi.ts
6 sections
1 2 3 4 5 6
Section 1 of 6
5import { errorMessage } from '../error.js'
6import { httpJson } from '@socketsecurity/lib/http-request'
7
8import {
9 StringPrototypeIncludes,
10 encodeComponent,
11} from '@socketsecurity/lib/primordials'
12import {
13 lowerName,
14 lowerNamespace,
15 lowerVersion,
16 replaceUnderscoresWithDashes,
17} from '../strings.js'
18import { validateNoInjectionByType } from '../validate.js'
19
20import type { ExistsResult, ExistsOptions } from './npm.js'
21
22interface PurlObject {
23 name: string
24 namespace?: string | undefined
25 qualifiers?: Record<string, string> | undefined
26 subpath?: string | undefined
27 type?: string | undefined
28 version?: string | undefined
29}
Section 2 of 6
37export function normalize(purl: PurlObject): PurlObject {
38 lowerNamespace(purl)
39 lowerName(purl)
40 lowerVersion(purl)
41 purl.name = replaceUnderscoresWithDashes(purl.name)
42 return purl
43}
Section 3 of 6
49export function validate(purl: PurlObject, throws: boolean): boolean {
50 if (!validateNoInjectionByType('pypi', 'name', purl.name, throws)) {
51 return false
52 }
53 return true
54}
Section 4 of 6
91export async function pypiExists(
92 name: string,
93 version?: string,
94 options?: ExistsOptions,
95): Promise<ExistsResult> {
96 const cacheKey = version ? `pypi:${name}@${version}` : `pypi:${name}`
97
98 // Try cache first if provided
99 if (options?.cache) {
100 const cached = await options.cache.get<ExistsResult>(cacheKey)
101 if (cached !== undefined) {
102 return cached
103 }
104 }
105
106 const fetchResult = async (): Promise<ExistsResult> => {
107 try {
108 const url = `https://pypi.org/pypi/${encodeComponent(name)}/json`
109
110 const data = await httpJson<{
111 info?: { version?: string }
112 releases?: Record<string, unknown[]>
113 }>(url)
114
115 const latestVersion = data.info?.['version']
116
117 // If specific version requested, validate it exists
118 if (version && data.releases) {
119 if (!(version in data.releases)) {
120 const result: ExistsResult = {
121 exists: false,
122 error: `Version ${version} not found`,
123 }
124 if (latestVersion !== undefined) {
125 result.latestVersion = latestVersion
126 }
127 return result
128 }
129 }
130
131 const result: ExistsResult = {
132 exists: true,
133 }
134 if (latestVersion !== undefined) {
135 result.latestVersion = latestVersion
136 }
137 return result
138 } catch (e) {
Section 5 of 6
140 const error = errorMessage(e)
141 return {
142 exists: false,
143 error: StringPrototypeIncludes(error, '404')
144 ? 'Package not found'
145 : error,
146 }
Section 6 of 6
148 }
149 }
150
151 const result = await fetchResult()
152
153 // Only cache successful results to avoid negative cache poisoning
154 // from transient failures (network errors, 5xx responses)
155 if (options?.cache && result.exists) {
156 await options.cache.set(cacheKey, Object.freeze(result))
157 }
158
159 return result
160}
src/purl-types/qpkg.ts
2 sections
1 2
Section 1 of 2
5import { lowerNamespace } from '../strings.js'
6
7interface PurlObject {
8 name: string
9 namespace?: string | undefined
10 qualifiers?: Record<string, string> | undefined
11 subpath?: string | undefined
12 type?: string | undefined
13 version?: string | undefined
14}
Section 2 of 2
21export function normalize(purl: PurlObject): PurlObject {
22 lowerNamespace(purl)
23 return purl
24}
src/purl-types/rpm.ts
2 sections
1 2
Section 1 of 2
5import { lowerNamespace } from '../strings.js'
6
7interface PurlObject {
8 name: string
9 namespace?: string | undefined
10 qualifiers?: Record<string, string> | undefined
11 subpath?: string | undefined
12 type?: string | undefined
13 version?: string | undefined
14}
Section 2 of 2
21export function normalize(purl: PurlObject): PurlObject {
22 lowerNamespace(purl)
23 return purl
24}
src/purl-types/socket.ts
2 sections
1 2
Section 1 of 2
8interface PurlObject {
9 name: string
10 namespace?: string | undefined
11 qualifiers?: Record<string, string> | undefined
12 subpath?: string | undefined
13 type?: string | undefined
14 version?: string | undefined
15}
Section 2 of 2
22export function normalize(purl: PurlObject): PurlObject {
23 return purl
24}
src/purl-types/swid.ts
3 sections
1 2 3
Section 1 of 3
5import { PurlError } from '../error.js'
6import {
7 ObjectFreeze,
8 RegExpPrototypeTest,
9 StringPrototypeToLowerCase,
10 StringPrototypeTrim,
11} from '@socketsecurity/lib/primordials'
12import { validateNoInjectionByType } from '../validate.js'
13
14const GUID_PATTERN = ObjectFreeze(
15 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
16)
17
18interface PurlObject {
19 name: string
20 namespace?: string | undefined
21 qualifiers?: Record<string, string> | undefined
22 subpath?: string | undefined
23 type?: string | undefined
24 version?: string | undefined
25}
Section 2 of 3
33export function validate(purl: PurlObject, throws: boolean): boolean {
34 const { qualifiers } = purl
35 // SWID requires a `tag_id` qualifier
36 const tagId = qualifiers?.['tag_id']
37 if (!tagId) {
38 if (throws) {
39 throw new PurlError('swid requires a "tag_id" qualifier')
40 }
41 return false
42 }
43 // `tag_id` must not be empty after trimming
44 const tagIdStr = StringPrototypeTrim(String(tagId))
45 if (tagIdStr.length === 0) {
Section 3 of 3
47 if (throws) {
48 throw new PurlError('swid "tag_id" qualifier must not be empty')
49 }
50 return false
51 }
52 // If `tag_id` is a GUID, it must be lowercase
53 if (RegExpPrototypeTest(GUID_PATTERN, tagIdStr)) {
54 if (tagIdStr !== StringPrototypeToLowerCase(tagIdStr)) {
55 if (throws) {
56 throw new PurlError(
57 'swid "tag_id" qualifier must be lowercase when it is a GUID',
58 )
59 }
60 return false
61 }
62 }
63 if (!validateNoInjectionByType('swid', 'name', purl.name, throws)) {
64 return false
65 }
66 return true
67}
src/purl-types/swift.ts
2 sections
1 2
Section 1 of 2
5import {
6 validateNoInjectionByType,
7 validateRequiredByType,
8} from '../validate.js'
9
10interface PurlObject {
11 name: string
12 namespace?: string | undefined
13 qualifiers?: Record<string, string> | undefined
14 subpath?: string | undefined
15 type?: string | undefined
16 version?: string | undefined
17}
Section 2 of 2
25export function validate(purl: PurlObject, throws: boolean): boolean {
26 if (
27 !validateRequiredByType('swift', 'namespace', purl.namespace, {
28 throws,
29 })
30 ) {
31 return false
32 }
33 if (!validateRequiredByType('swift', 'version', purl.version, { throws })) {
34 return false
35 }
36 if (
37 !validateNoInjectionByType('swift', 'namespace', purl.namespace, throws)
38 ) {
39 return false
40 }
41 if (!validateNoInjectionByType('swift', 'name', purl.name, throws)) {
42 return false
43 }
44 return true
45}
src/purl-types/unknown.ts
2 sections
1 2
Section 1 of 2
7interface PurlObject {
8 name: string
9 namespace?: string | undefined
10 qualifiers?: Record<string, string> | undefined
11 subpath?: string | undefined
12 type?: string | undefined
13 version?: string | undefined
14}
Section 2 of 2
21export function normalize(purl: PurlObject): PurlObject {
22 return purl
23}
src/purl-types/vscode-extension.ts
6 sections
1 2 3 4 5 6
Section 1 of 6
8import { httpJson } from '@socketsecurity/lib/http-request'
9
10import { errorMessage, PurlError } from '../error.js'
11import {
12 ArrayPrototypeSome,
13 JSONStringify,
14 StringPrototypeIncludes,
15} from '@socketsecurity/lib/primordials'
16import {
17 isSemverString,
18 lowerName,
19 lowerNamespace,
20 lowerVersion,
21} from '../strings.js'
22import {
23 validateNoInjectionByType,
24 validateRequiredByType,
25} from '../validate.js'
26
27import type { ExistsOptions, ExistsResult } from './npm.js'
28
29interface PurlObject {
30 name: string
31 namespace?: string | undefined
32 qualifiers?: Record<string, string> | undefined
33 subpath?: string | undefined
34 type?: string | undefined
35 version?: string | undefined
36}
Section 2 of 6
44export function normalize(purl: PurlObject): PurlObject {
45 lowerNamespace(purl)
46 lowerName(purl)
47 lowerVersion(purl)
48 return purl
49}
Section 3 of 6
56export function validate(purl: PurlObject, throws: boolean): boolean {
57 const { name, namespace, version, qualifiers } = purl
58 // VSCode extensions require a `namespace` (publisher)
59 if (
60 !validateRequiredByType('vscode-extension', 'namespace', namespace, {
61 throws,
62 })
63 ) {
64 return false
65 }
66 // `namespace` must not contain injection characters
67 if (
68 !validateNoInjectionByType(
69 'vscode-extension',
70 'namespace',
71 namespace,
72 throws,
73 )
74 ) {
75 return false
76 }
77 // `name` must not contain injection characters
78 if (!validateNoInjectionByType('vscode-extension', 'name', name, throws)) {
79 return false
80 }
81 // `version` must be valid semver when present
82 if (
83 typeof version === 'string' &&
84 version.length > 0 &&
85 !isSemverString(version)
86 ) {
87 if (throws) {
88 throw new PurlError(
89 'vscode-extension "version" component must be a valid semver version',
90 )
91 }
92 return false
93 }
94 // `platform` qualifier must not contain injection characters
95 if (
96 !validateNoInjectionByType(
97 'vscode-extension',
98 'platform',
99 qualifiers?.['platform'],
100 throws,
101 )
102 ) {
103 return false
104 }
105 return true
106}
Section 4 of 6
145export async function vscodeExtensionExists(
146 name: string,
147 namespace?: string,
148 version?: string,
149 options?: ExistsOptions,
150): Promise<ExistsResult> {
151 if (!namespace) {
152 return {
153 exists: false,
154 error: 'Namespace (publisher) is required for VSCode extensions',
155 }
156 }
157
158 const extensionId = `${namespace}.${name}`
159 const cacheKey = version
160 ? `vscode-extension:${extensionId}@${version}`
161 : `vscode-extension:${extensionId}`
162
163 // Try cache first if provided
164 if (options?.cache) {
165 const cached = await options.cache.get<ExistsResult>(cacheKey)
166 if (cached !== undefined) {
167 return cached
168 }
169 }
170
171 const fetchResult = async (): Promise<ExistsResult> => {
172 try {
173 // VS Marketplace API endpoint
174 const url =
175 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'
176
177 const requestBody = {
178 filters: [
179 {
180 criteria: [
181 {
182 filterType: 7,
183 value: extensionId,
184 },
185 ],
186 },
187 ],
188 flags: 914,
189 }
190
191 const data = await httpJson<{
192 results?: Array<{
193 extensions?: Array<{
194 versions?: Array<{
195 version?: string
196 }>
197 }>
198 }>
199 }>(url, {
200 method: 'POST',
201 headers: {
202 'Content-Type': 'application/json',
203 Accept: 'application/json;api-version=7.1-preview.1',
204 },
205 body: JSONStringify(requestBody),
206 })
207
208 const extensions = data.results?.[0]?.['extensions']
209 if (!extensions || extensions.length === 0) {
210 return {
211 exists: false,
212 error: 'Extension not found',
213 }
214 }
215
216 const versions = extensions[0]?.['versions']
217 const latestVersion = versions?.[0]?.['version']
218
219 // If specific version requested, validate it exists
220 if (version && versions) {
221 const versionExists = ArrayPrototypeSome(
222 versions,
223 v => v.version === version,
224 )
225 if (!versionExists) {
226 const result: ExistsResult = {
227 exists: false,
228 error: `Version ${version} not found`,
229 }
230 if (latestVersion !== undefined) {
231 result.latestVersion = latestVersion
232 }
233 return result
234 }
235 }
236
237 const result: ExistsResult = {
238 exists: true,
239 }
240 if (latestVersion !== undefined) {
241 result.latestVersion = latestVersion
242 }
243 return result
244 } catch (e) {
Section 5 of 6
246 const error = errorMessage(e)
Section 6 of 6
248 // IMPORTANT: `httpJson()` throws on non-2xx responses, so we cannot inspect
249 // `response.status` directly. We rely on the error message containing `"404"`
250 // to distinguish "not found" from other HTTP errors.
251 // This depends on `@socketsecurity/lib`'s error message format:
252 // `"HTTP 404: Not Found"` or similar containing the status code.
253 // If upstream changes error format, this string matching may break.
254 return {
255 exists: false,
256 error: StringPrototypeIncludes(error, '404')
257 ? 'Extension not found'
258 : error,
259 }
260 }
261 }
262
263 const result = await fetchResult()
264
265 // Only cache successful results to avoid negative cache poisoning
266 // from transient failures (network errors, 5xx responses)
267 if (options?.cache && result.exists) {
268 await options.cache.set(cacheKey, Object.freeze(result))
269 }
270
271 return result
272}
src/purl-types/yocto.ts
3 sections
1 2 3
Section 1 of 3
8import { lowerName } from '../strings.js'
9import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
10
11interface PurlObject {
12 name: string
13 namespace?: string | undefined
14 qualifiers?: Record<string, string> | undefined
15 subpath?: string | undefined
16 type?: string | undefined
17 version?: string | undefined
18}
Section 2 of 3
25export function normalize(purl: PurlObject): PurlObject {
26 lowerName(purl)
27 return purl
28}
Section 3 of 3
34export function validate(purl: PurlObject, throws: boolean): boolean {
35 if (
36 !validateEmptyByType('yocto', 'namespace', purl.namespace, {
37 throws,
38 })
39 ) {
40 return false
41 }
42 if (!validateNoInjectionByType('yocto', 'name', purl.name, throws)) {
43 return false
44 }
45 return true
46}