Part 5:  URL ↔ PURL Conversion

Convert between repository/download URLs and PURLs — ~25 ecosystem URL parsers.

Topics: Anatomy Building Parsing Validation URL Ecosystems Comparison Security Architecture Builders Contributing Converters Hardening Release Tour VERS
src/url-converter.ts
65 sections
Section 1 of 65
22import {
23 ArrayPrototypeFilter,
24 ArrayPrototypeJoin,
25 ArrayPrototypeSlice,
26 MapCtor,
27 ObjectFreeze,
28 SetCtor,
29 StringPrototypeCharCodeAt,
30 StringPrototypeEndsWith,
31 StringPrototypeIndexOf,
32 StringPrototypeLastIndexOf,
33 StringPrototypeReplace,
34 StringPrototypeSlice,
35 StringPrototypeSplit,
36 StringPrototypeStartsWith,
37 URLCtor,
38} from '@socketsecurity/lib/primordials'
39
40import type { PackageURL } from './package-url.js'
41
42// Lazy reference to `PackageURL`, set by `package-url.ts` at module load time
43// to avoid circular import issues.
44let _PackageURL: typeof PackageURL | undefined
Section 2 of 65
51export function _registerPackageURLForUrlConverter(
52 ctor: typeof PackageURL,
53): void {
54 _PackageURL = ctor
55}
56
57type UrlParser = (_url: URL) => PackageURL | undefined
Section 3 of 65
63function filterSegments(pathname: string): string[] {
64 return ArrayPrototypeFilter(
65 StringPrototypeSplit(pathname, '/' as any),
66 s => s.length > 0,
67 )
68}
Section 4 of 65
73function tryCreatePurl(
74 type: string,
75 namespace: string | undefined,
76 name: string,
77 version: string | undefined,
78): PackageURL | undefined {
Section 5 of 65
80 if (!_PackageURL) {
81 return undefined
82 }
Section 6 of 65
84 try {
85 return new _PackageURL(type, namespace, name, version, undefined, undefined)
86 } catch {
Section 7 of 65
88 return undefined
Section 8 of 65
90 }
91}
Section 9 of 65
101function parseNpmRegistry(url: URL): PackageURL | undefined {
102 const segments = filterSegments(url.pathname)
103 if (segments.length === 0) {
104 return undefined
105 }
106
107 let namespace: string | undefined
108 let name: string | undefined
109 let version: string | undefined
110
111 // Scoped package: first segment starts with `@`
112 if (segments[0] && StringPrototypeStartsWith(segments[0], '@')) {
113 namespace = segments[0]
114 name = segments[1]
115 if (!name) {
116 return undefined
117 }
118 // Download tarball: `/@scope/name/-/name-version.tgz`
119 if (segments[2] === '-' && segments[3]) {
120 const tgz = segments[3]
121 if (StringPrototypeEndsWith(tgz, '.tgz')) {
122 const withoutExt = StringPrototypeSlice(tgz, 0, -4)
123 // name-version pattern: find last hyphen after name
124 const prefix = `${name}-`
125 if (StringPrototypeStartsWith(withoutExt, prefix)) {
126 version = StringPrototypeSlice(withoutExt, prefix.length)
127 }
128 }
129 } else if (segments[2]) {
130 version = segments[2]
131 }
132 } else {
133 name = segments[0]
Section 10 of 65
135 if (!name) {
136 return undefined
137 }
Section 11 of 65
139 // Download tarball: `/name/-/name-version.tgz`
140 if (segments[1] === '-' && segments[2]) {
141 const tgz = segments[2]
142 if (StringPrototypeEndsWith(tgz, '.tgz')) {
143 const withoutExt = StringPrototypeSlice(tgz, 0, -4)
144 const prefix = `${name}-`
145 if (StringPrototypeStartsWith(withoutExt, prefix)) {
146 version = StringPrototypeSlice(withoutExt, prefix.length)
147 }
148 }
149 } else if (segments[1]) {
150 version = segments[1]
151 }
152 }
153
154 return tryCreatePurl('npm', namespace, name, version)
155}
Section 12 of 65
164function parseNpmWebsite(url: URL): PackageURL | undefined {
165 const segments = filterSegments(url.pathname)
166 if (segments.length === 0 || segments[0] !== 'package') {
167 return undefined
168 }
169
170 let namespace: string | undefined
171 let name: string | undefined
172 let version: string | undefined
173
174 if (segments[1] && StringPrototypeStartsWith(segments[1], '@')) {
175 namespace = segments[1]
176 name = segments[2]
177 if (!name) {
178 return undefined
179 }
180 if (segments[3] === 'v' && segments[4]) {
181 version = segments[4]
182 }
183 } else {
184 name = segments[1]
185 if (!name) {
186 return undefined
187 }
188 if (segments[2] === 'v' && segments[3]) {
189 version = segments[3]
190 }
191 }
192
193 return tryCreatePurl('npm', namespace, name, version)
194}
Section 13 of 65
201function parsePypi(url: URL): PackageURL | undefined {
202 const segments = filterSegments(url.pathname)
203 if (segments.length < 2 || segments[0] !== 'project') {
204 return undefined
205 }
206
207 const name = segments[1]
Section 14 of 65
209 if (!name) {
210 return undefined
211 }
Section 15 of 65
213 const version = segments[2]
214
215 return tryCreatePurl('pypi', undefined, name, version)
216}
Section 16 of 65
224function parseMaven(url: URL): PackageURL | undefined {
225 const segments = filterSegments(url.pathname)
226 // Minimum: maven2 / groupPart / artifact / version
227 if (segments.length < 4 || segments[0] !== 'maven2') {
228 return undefined
229 }
230
231 // Remove `'maven2'` prefix
232 const parts = ArrayPrototypeSlice(segments, 1)
233 // Last segment is version, second-to-last is artifact, rest is group path
234 if (parts.length < 3) {
Section 17 of 65
236 return undefined
Section 18 of 65
238 }
239 const version = parts[parts.length - 1]!
240 const name = parts[parts.length - 2]!
241 const groupParts = ArrayPrototypeSlice(parts, 0, -2)
242 const namespace = ArrayPrototypeJoin(groupParts, '.')
243
244 if (!namespace || !name) {
Section 19 of 65
246 return undefined
Section 20 of 65
248 }
249
250 return tryCreatePurl('maven', namespace, name, version)
251}
Section 21 of 65
258function parseGem(url: URL): PackageURL | undefined {
259 const segments = filterSegments(url.pathname)
260 if (segments.length < 2 || segments[0] !== 'gems') {
261 return undefined
262 }
263
264 const name = segments[1]
Section 22 of 65
266 if (!name) {
267 return undefined
268 }
Section 23 of 65
270 let version: string | undefined
271 if (segments[2] === 'versions' && segments[3]) {
272 version = segments[3]
273 }
274
275 return tryCreatePurl('gem', undefined, name, version)
276}
Section 24 of 65
286function parseCargo(url: URL): PackageURL | undefined {
287 const segments = filterSegments(url.pathname)
288 if (segments.length < 2) {
289 return undefined
290 }
291
292 // `/api/v1/crates/name/version/download`
293 if (
294 segments[0] === 'api' &&
295 segments[1] === 'v1' &&
296 segments[2] === 'crates' &&
297 segments[3]
298 ) {
299 const name = segments[3]
300 const version = segments[4]
301 return tryCreatePurl('cargo', undefined, name, version)
302 }
303
304 // `/crates/name` or `/crates/name/version`
305 if (segments[0] !== 'crates') {
306 return undefined
307 }
308
309 const name = segments[1]
Section 25 of 65
311 if (!name) {
312 return undefined
313 }
Section 26 of 65
315 const version = segments[2]
316
317 return tryCreatePurl('cargo', undefined, name, version)
318}
Section 27 of 65
327function parseNuget(url: URL): PackageURL | undefined {
328 const segments = filterSegments(url.pathname)
329 if (segments.length < 2) {
330 return undefined
331 }
332
333 // `api.nuget.org`: `/v3-flatcontainer/name/version/name.version.nupkg`
334 if (url.hostname === 'api.nuget.org') {
335 if (segments[0] !== 'v3-flatcontainer' || !segments[1]) {
336 return undefined
337 }
338 const name = segments[1]
339 const version = segments[2]
340 return tryCreatePurl('nuget', undefined, name, version)
341 }
342
343 // `www.nuget.org`: `/packages/Name` or `/packages/Name/version`
344 if (segments[0] !== 'packages') {
345 return undefined
346 }
347
348 const name = segments[1]
Section 28 of 65
350 if (!name) {
351 return undefined
352 }
Section 29 of 65
354 const version = segments[2]
355
356 return tryCreatePurl('nuget', undefined, name, version)
357}
Section 30 of 65
368function parseGitHub(url: URL): PackageURL | undefined {
369 const segments = filterSegments(url.pathname)
370 if (segments.length < 2) {
371 return undefined
372 }
373
374 const namespace = segments[0]!
375 const name = segments[1]!
376
377 let version: string | undefined
378 if (segments[2] === 'tree' && segments[3]) {
379 version = segments[3]
380 } else if (segments[2] === 'commit' && segments[3]) {
381 version = segments[3]
382 } else if (
383 segments[2] === 'releases' &&
384 segments[3] === 'tag' &&
385 segments[4]
386 ) {
387 version = segments[4]
388 }
389
390 return tryCreatePurl('github', namespace, name, version)
391}
Section 31 of 65
400function parseGolang(url: URL): PackageURL | undefined {
401 // Remove leading slash
402 let path = StringPrototypeSlice(url.pathname, 1)
403 if (!path) {
404 return undefined
405 }
406
407 let version: string | undefined
408 // Check for `@version` suffix
409 const atIndex = StringPrototypeLastIndexOf(path, '@')
410 if (atIndex !== -1) {
411 version = StringPrototypeSlice(path, atIndex + 1)
412 path = StringPrototypeSlice(path, 0, atIndex)
413 }
414
415 // The full path becomes the namespace for `golang` purls
416 // e.g. `github.com/gorilla/mux` -> `namespace=github.com/gorilla`, `name=mux`
417 const lastSlash = StringPrototypeLastIndexOf(path, '/')
418 if (lastSlash === -1) {
419 return undefined
420 }
421
422 const namespace = StringPrototypeSlice(path, 0, lastSlash)
423 const name = StringPrototypeSlice(path, lastSlash + 1)
424 if (!namespace || !name) {
Section 32 of 65
426 return undefined
Section 33 of 65
428 }
429
430 return tryCreatePurl('golang', namespace, name, version)
431}
Section 34 of 65
437function parseGitlab(url: URL): PackageURL | undefined {
438 const segments = filterSegments(url.pathname)
439 if (segments.length < 2) {
440 return undefined
441 }
442 const namespace = segments[0]!
443 const name = segments[1]!
444 let version: string | undefined
445 // GitLab uses `/-/` prefix before `tree`/`commit`/`tags`
446 if (segments[2] === '-') {
447 if (segments[3] === 'tree' && segments[4]) {
448 version = segments[4]
449 } else if (segments[3] === 'commit' && segments[4]) {
450 version = segments[4]
451 } else if (segments[3] === 'tags' && segments[4]) {
452 version = segments[4]
453 }
454 }
455 return tryCreatePurl('gitlab', namespace, name, version)
456}
Section 35 of 65
462function parseBitbucket(url: URL): PackageURL | undefined {
463 const segments = filterSegments(url.pathname)
464 if (segments.length < 2) {
465 return undefined
466 }
467 const namespace = segments[0]!
468 const name = segments[1]!
469 let version: string | undefined
470 if (segments[2] === 'commits' && segments[3]) {
471 version = segments[3]
472 } else if (segments[2] === 'src' && segments[3]) {
473 version = segments[3]
474 }
475 return tryCreatePurl('bitbucket', namespace, name, version)
476}
Section 36 of 65
482function parseComposer(url: URL): PackageURL | undefined {
483 const segments = filterSegments(url.pathname)
484 if (segments.length < 3 || segments[0] !== 'packages') {
485 return undefined
486 }
487 const namespace = segments[1]!
488 const name = segments[2]!
489 return tryCreatePurl('composer', namespace, name, undefined)
490}
Section 37 of 65
496function parseHex(url: URL): PackageURL | undefined {
497 const segments = filterSegments(url.pathname)
498 if (segments.length < 2 || segments[0] !== 'packages') {
499 return undefined
500 }
501 const name = segments[1]!
502 const version = segments[2]
503 return tryCreatePurl('hex', undefined, name, version)
504}
Section 38 of 65
510function parsePub(url: URL): PackageURL | undefined {
511 const segments = filterSegments(url.pathname)
512 if (segments.length < 2 || segments[0] !== 'packages') {
513 return undefined
514 }
515 const name = segments[1]!
516 let version: string | undefined
517 if (segments[2] === 'versions' && segments[3]) {
518 version = segments[3]
519 }
520 return tryCreatePurl('pub', undefined, name, version)
521}
Section 39 of 65
530function parseDocker(url: URL): PackageURL | undefined {
531 const segments = filterSegments(url.pathname)
532 if (segments.length < 2) {
533 return undefined
534 }
535 // Official images: `/_/name`
536 if (segments[0] === '_' && segments[1]) {
537 return tryCreatePurl('docker', 'library', segments[1], undefined)
538 }
539 // User/org images: `/r/namespace/name`
540 if (segments[0] === 'r' && segments[1] && segments[2]) {
541 return tryCreatePurl('docker', segments[1], segments[2], undefined)
542 }
543 return undefined
544}
Section 40 of 65
550function parseCocoapods(url: URL): PackageURL | undefined {
551 const segments = filterSegments(url.pathname)
552 if (segments.length < 2 || segments[0] !== 'pods') {
553 return undefined
554 }
555 return tryCreatePurl('cocoapods', undefined, segments[1]!, undefined)
556}
Section 41 of 65
562function parseHackage(url: URL): PackageURL | undefined {
563 const segments = filterSegments(url.pathname)
564 if (segments.length < 2 || segments[0] !== 'package') {
565 return undefined
566 }
567 const raw = segments[1]!
568 // Hackage uses `name-version` format in the URL
569 // Find the last hyphen followed by a digit to split name from version
570 let splitIndex = -1
571 for (let i = raw.length - 1; i >= 0; i -= 1) {
Section 42 of 65
573 const next = StringPrototypeCharCodeAt(raw, i + 1)
574 // Next char is a digit (0-9)
575 if (next >= 48 && next <= 57) {
576 splitIndex = i
577 break
578 }
579 }
580 }
581 if (splitIndex === -1) {
582 return tryCreatePurl('hackage', undefined, raw, undefined)
583 }
584 const name = StringPrototypeSlice(raw, 0, splitIndex)
585 const version = StringPrototypeSlice(raw, splitIndex + 1)
586 return tryCreatePurl('hackage', undefined, name, version)
587}
Section 43 of 65
593function parseCran(url: URL): PackageURL | undefined {
594 const segments = filterSegments(url.pathname)
595 // `/web/packages/name/index.html`
596 if (
597 segments.length >= 3 &&
598 segments[0] === 'web' &&
599 segments[1] === 'packages'
600 ) {
601 return tryCreatePurl('cran', undefined, segments[2]!, undefined)
602 }
603 return undefined
604}
Section 44 of 65
610function parseConda(url: URL): PackageURL | undefined {
611 const segments = filterSegments(url.pathname)
612 if (segments.length < 2) {
613 return undefined
614 }
615 // `segments[0]` is the channel, `segments[1]` is the package name
616 const name = segments[1]!
617 const version = segments[2]
618 return tryCreatePurl('conda', undefined, name, version)
619}
Section 45 of 65
625function parseCpan(url: URL): PackageURL | undefined {
626 const segments = filterSegments(url.pathname)
627 if (segments.length < 2) {
628 return undefined
629 }
630 if (segments[0] === 'pod' || segments[0] === 'dist') {
631 // Rejoin remaining segments for nested module names like `Foo/Bar`
632 const name = ArrayPrototypeJoin(ArrayPrototypeSlice(segments, 1), '::')
633 return tryCreatePurl('cpan', undefined, name, undefined)
634 }
635 return undefined
636}
Section 46 of 65
642const HUGGINGFACE_RESERVED = ObjectFreeze(
643 new SetCtor([
644 'docs',
645 'spaces',
646 'datasets',
647 'tasks',
648 'blog',
649 'pricing',
650 'join',
651 'login',
652 'settings',
653 'api',
654 ]),
655)
656
657function parseHuggingface(url: URL): PackageURL | undefined {
658 const segments = filterSegments(url.pathname)
659 if (segments.length < 2) {
660 return undefined
661 }
662 // Skip non-model paths (`docs`, `spaces` UI, etc.)
663 if (HUGGINGFACE_RESERVED.has(segments[0]!)) {
664 return undefined
665 }
666 const namespace = segments[0]!
667 const name = segments[1]!
668 let version: string | undefined
669 if (segments[2] === 'tree' && segments[3]) {
670 version = segments[3]
671 } else if (segments[2] === 'commit' && segments[3]) {
672 version = segments[3]
673 }
674 return tryCreatePurl('huggingface', namespace, name, version)
675}
Section 47 of 65
682function parseLuarocks(url: URL): PackageURL | undefined {
683 const segments = filterSegments(url.pathname)
684 if (segments.length < 3 || segments[0] !== 'modules') {
685 return undefined
686 }
687 const namespace = segments[1]!
688 const name = segments[2]!
689 const version = segments[3]
690 return tryCreatePurl('luarocks', namespace, name, version)
691}
Section 48 of 65
697function parseSwift(url: URL): PackageURL | undefined {
698 const segments = filterSegments(url.pathname)
699 if (segments.length < 2) {
700 return undefined
701 }
702 return tryCreatePurl('swift', segments[0]!, segments[1]!, undefined)
703}
Section 49 of 65
709function parseVscodeMarketplace(url: URL): PackageURL | undefined {
710 const segments = filterSegments(url.pathname)
711 if (segments.length < 1 || segments[0] !== 'items') {
712 return undefined
713 }
714 const itemName = url.searchParams.get('itemName')
715 if (!itemName) {
716 return undefined
717 }
718 const dotIndex = StringPrototypeIndexOf(itemName, '.')
719 if (dotIndex === -1 || dotIndex === 0 || dotIndex === itemName.length - 1) {
720 return undefined
721 }
722 const namespace = StringPrototypeSlice(itemName, 0, dotIndex)
723 const name = StringPrototypeSlice(itemName, dotIndex + 1)
724 return tryCreatePurl('vscode-extension', namespace, name, undefined)
725}
Section 50 of 65
731function parseOpenVsx(url: URL): PackageURL | undefined {
732 const segments = filterSegments(url.pathname)
733 if (segments.length < 3 || segments[0] !== 'extension') {
734 return undefined
735 }
736 const namespace = segments[1]!
737 const name = segments[2]!
738 const version = segments[3]
739 return tryCreatePurl('vscode-extension', namespace, name, version)
740}
Section 51 of 65
743const FROM_URL_PARSERS: ReadonlyMap<string, UrlParser> = ObjectFreeze(
744 new MapCtor<string, UrlParser>([
745 // Package registries
746 ['registry.npmjs.org', parseNpmRegistry],
747 ['www.npmjs.com', parseNpmWebsite],
748 ['pypi.org', parsePypi],
749 ['repo1.maven.org', parseMaven],
750 ['central.maven.org', parseMaven],
751 ['rubygems.org', parseGem],
752 ['crates.io', parseCargo],
753 ['www.nuget.org', parseNuget],
754 ['api.nuget.org', parseNuget],
755 ['pkg.go.dev', parseGolang],
756 ['hex.pm', parseHex],
757 ['pub.dev', parsePub],
758 ['packagist.org', parseComposer],
759 ['hub.docker.com', parseDocker],
760 ['cocoapods.org', parseCocoapods],
761 ['hackage.haskell.org', parseHackage],
762 ['cran.r-project.org', parseCran],
763 ['anaconda.org', parseConda],
764 ['metacpan.org', parseCpan],
765 ['luarocks.org', parseLuarocks],
766 ['swiftpackageindex.com', parseSwift],
767 ['huggingface.co', parseHuggingface],
768 // VS Code extension marketplaces
769 ['marketplace.visualstudio.com', parseVscodeMarketplace],
770 ['open-vsx.org', parseOpenVsx],
771 // VCS hosts
772 ['github.com', parseGitHub],
773 ['gitlab.com', parseGitlab],
774 ['bitbucket.org', parseBitbucket],
775 ]),
776)
Section 52 of 65
784export interface RepositoryUrl {
Section 53 of 65
786 type: 'git' | 'hg' | 'svn' | 'web'
Section 54 of 65
788 url: string
789}
Section 55 of 65
797export interface DownloadUrl {
Section 56 of 65
799 type: 'tarball' | 'zip' | 'exe' | 'wheel' | 'jar' | 'gem' | 'other'
Section 57 of 65
801 url: string
802}
Section 58 of 65
818const DOWNLOAD_URL_TYPES: ReadonlySet<string> = ObjectFreeze(
819 new SetCtor([
820 'cargo',
821 'composer',
822 'conda',
823 'gem',
824 'golang',
825 'hex',
826 'maven',
827 'npm',
828 'nuget',
829 'pub',
830 'pypi',
831 ]),
832)
833
834const REPOSITORY_URL_TYPES: ReadonlySet<string> = ObjectFreeze(
835 new SetCtor([
836 'bioconductor',
837 'bitbucket',
838 'cargo',
839 'chrome',
840 'clojars',
841 'cocoapods',
842 'composer',
843 'conan',
844 'conda',
845 'cpan',
846 'deno',
847 'docker',
848 'elm',
849 'gem',
850 'github',
851 'gitlab',
852 'golang',
853 'hackage',
854 'hex',
855 'homebrew',
856 'huggingface',
857 'luarocks',
858 'maven',
859 'npm',
860 'nuget',
861 'pub',
862 'pypi',
863 'swift',
864 'vscode',
865 ]),
866)
867
868export class UrlConverter {
Section 59 of 65
885 static fromUrl(urlStr: string): PackageURL | undefined {
886 let url: URL
887 try {
888 url = new URLCtor(urlStr)
889 } catch {
890 return undefined
891 }
892 const parser = FROM_URL_PARSERS.get(url.hostname)
893 if (!parser) {
894 return undefined
895 }
896 return parser(url)
897 }
Section 60 of 65
905 static supportsFromUrl(urlStr: string): boolean {
906 let url: URL
907 try {
908 url = new URLCtor(urlStr)
909 } catch {
910 return false
911 }
912 return FROM_URL_PARSERS.has(url.hostname)
913 }
Section 61 of 65
921 static getAllUrls(purl: PackageURL): {
922 download: DownloadUrl | undefined
923 repository: RepositoryUrl | undefined
924 } {
925 return {
926 download: UrlConverter.toDownloadUrl(purl),
927 repository: UrlConverter.toRepositoryUrl(purl),
928 }
929 }
Section 62 of 65
937 static supportsDownloadUrl(type: string): boolean {
938 return DOWNLOAD_URL_TYPES.has(type)
939 }
Section 63 of 65
947 static supportsRepositoryUrl(type: string): boolean {
948 return REPOSITORY_URL_TYPES.has(type)
949 }
Section 64 of 65
958 static toDownloadUrl(purl: PackageURL): DownloadUrl | undefined {
959 const { name, namespace, type, version } = purl
960
961 if (!version) {
962 return undefined
963 }
964
965 switch (type) {
966 case 'npm': {
967 const npmName = namespace ? `${namespace}/${name}` : name
968 return {
969 type: 'tarball',
970 url: `https://registry.npmjs.org/${npmName}/-/${name}-${version}.tgz`,
971 }
972 }
973
974 case 'pypi':
975 return {
976 type: 'wheel',
977 url: `https://pypi.org/simple/${name}/`,
978 }
979
980 case 'maven': {
981 if (!namespace) {
982 return undefined
983 }
984 const groupPath = StringPrototypeReplace(namespace, /\./g, '/' as any)
985 return {
986 type: 'jar',
987 url: `https://repo1.maven.org/maven2/${groupPath}/${name}/${version}/${name}-${version}.jar`,
988 }
989 }
990
991 case 'gem':
992 return {
993 type: 'gem',
994 url: `https://rubygems.org/downloads/${name}-${version}.gem`,
995 }
996
997 case 'cargo':
998 return {
999 type: 'tarball',
1000 url: `https://crates.io/api/v1/crates/${name}/${version}/download`,
1001 }
1002
1003 case 'nuget':
1004 return {
1005 type: 'zip',
1006 url: `https://nuget.org/packages/${name}/${version}/download`,
1007 }
1008
1009 case 'composer':
1010 if (!namespace) {
1011 return undefined
1012 }
1013 return {
1014 type: 'other',
1015 url: `https://repo.packagist.org/p2/${namespace}/${name}.json`,
1016 }
1017
1018 case 'hex':
1019 return {
1020 type: 'tarball',
1021 url: `https://repo.hex.pm/tarballs/${name}-${version}.tar`,
1022 }
1023
1024 case 'pub':
1025 return {
1026 type: 'tarball',
1027 url: `https://pub.dev/packages/${name}/versions/${version}.tar.gz`,
1028 }
1029
1030 case 'conda': {
1031 const channel = purl['qualifiers']?.['channel'] ?? 'conda-forge'
1032 return {
1033 type: 'tarball',
1034 url: `https://anaconda.org/${channel}/${name}/${version}/download`,
1035 }
1036 }
1037
1038 case 'golang':
1039 if (!namespace) {
1040 return undefined
1041 }
1042 return {
1043 type: 'zip',
1044 url: `https://proxy.golang.org/${namespace}/${name}/@v/${version}.zip`,
1045 }
1046
1047 default:
1048 return undefined
1049 }
1050 }
Section 65 of 65
1059 static toRepositoryUrl(purl: PackageURL): RepositoryUrl | undefined {
1060 const { name, namespace, type } = purl
1061
1062 const { version } = purl
1063
1064 switch (type) {
1065 case 'bioconductor':
1066 return {
1067 type: 'web',
1068 url: `https://bioconductor.org/packages/${name}`,
1069 }
1070
1071 case 'bitbucket':
1072 if (!namespace) {
1073 return undefined
1074 }
1075 return {
1076 type: 'git',
1077 url: version
1078 ? `https://bitbucket.org/${namespace}/${name}/src/${version}`
1079 : `https://bitbucket.org/${namespace}/${name}`,
1080 }
1081
1082 case 'cargo':
1083 return {
1084 type: 'web',
1085 url: `https://crates.io/crates/${name}`,
1086 }
1087
1088 case 'chrome':
1089 return {
1090 type: 'web',
1091 url: `https://chromewebstore.google.com/detail/${name}`,
1092 }
1093
1094 case 'clojars':
1095 return {
1096 type: 'web',
1097 url: `https://clojars.org/${namespace ? `${namespace}/` : ''}${name}`,
1098 }
1099
1100 case 'cocoapods':
1101 return {
1102 type: 'web',
1103 url: `https://cocoapods.org/pods/${name}`,
1104 }
1105
1106 case 'composer':
1107 return {
1108 type: 'web',
1109 url: `https://packagist.org/packages/${namespace ? `${namespace}/` : ''}${name}`,
1110 }
1111
1112 case 'conan':
1113 return {
1114 type: 'web',
1115 url: `https://conan.io/center/recipes/${name}`,
1116 }
1117
1118 case 'conda': {
1119 const channel = purl['qualifiers']?.['channel'] ?? 'conda-forge'
1120 return {
1121 type: 'web',
1122 url: `https://anaconda.org/${channel}/${name}`,
1123 }
1124 }
1125
1126 case 'cpan':
1127 return {
1128 type: 'web',
1129 url: `https://metacpan.org/${namespace ? `pod/${namespace}::` : 'pod/'}${name}`,
1130 }
1131
1132 case 'deno':
1133 return {
1134 type: 'web',
1135 url: version
1136 ? `https://deno.land/x/${name}@${version}`
1137 : `https://deno.land/x/${name}`,
1138 }
1139
1140 case 'docker': {
1141 const versionSuffix = version ? `?tab=tags&name=${version}` : ''
1142 if (!namespace || namespace === 'library') {
1143 return {
1144 type: 'web',
1145 url: `https://hub.docker.com/_/${name}${versionSuffix}`,
1146 }
1147 }
1148 return {
1149 type: 'web',
1150 url: `https://hub.docker.com/r/${namespace}/${name}${versionSuffix}`,
1151 }
1152 }
1153
1154 case 'elm':
1155 if (!namespace) {
1156 return undefined
1157 }
1158 return {
1159 type: 'web',
1160 url: version
1161 ? `https://package.elm-lang.org/packages/${namespace}/${name}/${version}`
1162 : `https://package.elm-lang.org/packages/${namespace}/${name}/latest`,
1163 }
1164
1165 case 'gem':
1166 return {
1167 type: 'web',
1168 url: `https://rubygems.org/gems/${name}`,
1169 }
1170
1171 case 'github':
1172 if (!namespace) {
1173 return undefined
1174 }
1175 return {
1176 type: 'git',
1177 url: version
1178 ? `https://github.com/${namespace}/${name}/tree/${version}`
1179 : `https://github.com/${namespace}/${name}`,
1180 }
1181
1182 case 'gitlab':
1183 if (!namespace) {
1184 return undefined
1185 }
1186 return {
1187 type: 'git',
1188 url: `https://gitlab.com/${namespace}/${name}`,
1189 }
1190
1191 case 'golang':
1192 if (!namespace) {
1193 return undefined
1194 }
1195 return {
1196 type: 'web',
1197 url: version
1198 ? `https://pkg.go.dev/${namespace}/${name}@${version}`
1199 : `https://pkg.go.dev/${namespace}/${name}`,
1200 }
1201
1202 case 'hackage':
1203 return {
1204 type: 'web',
1205 url: version
1206 ? `https://hackage.haskell.org/package/${name}-${version}`
1207 : `https://hackage.haskell.org/package/${name}`,
1208 }
1209
1210 case 'hex':
1211 return {
1212 type: 'web',
1213 url: `https://hex.pm/packages/${name}`,
1214 }
1215
1216 case 'homebrew':
1217 return {
1218 type: 'web',
1219 url: `https://formulae.brew.sh/formula/${name}`,
1220 }
1221
1222 case 'huggingface':
1223 return {
1224 type: 'web',
1225 url: `https://huggingface.co/${namespace ? `${namespace}/` : ''}${name}`,
1226 }
1227
1228 case 'luarocks':
1229 return {
1230 type: 'web',
1231 url: `https://luarocks.org/modules/${namespace ? `${namespace}/` : ''}${name}`,
1232 }
1233
1234 case 'maven': {
1235 if (!namespace) {
1236 return undefined
1237 }
1238 return {
1239 type: 'web',
1240 url: version
1241 ? `https://search.maven.org/artifact/${namespace}/${name}/${version}/jar`
1242 : `https://search.maven.org/artifact/${namespace}/${name}`,
1243 }
1244 }
1245
1246 case 'npm':
1247 return {
1248 type: 'web',
1249 url: version
1250 ? `https://www.npmjs.com/package/${namespace ? `${namespace}/` : ''}${name}/v/${version}`
1251 : `https://www.npmjs.com/package/${namespace ? `${namespace}/` : ''}${name}`,
1252 }
1253
1254 case 'nuget':
1255 return {
1256 type: 'web',
1257 url: `https://nuget.org/packages/${name}/`,
1258 }
1259
1260 case 'pub':
1261 return {
1262 type: 'web',
1263 url: `https://pub.dev/packages/${name}`,
1264 }
1265
1266 case 'pypi':
1267 return {
1268 type: 'web',
1269 url: `https://pypi.org/project/${name}/`,
1270 }
1271
1272 case 'swift':
1273 if (!namespace) {
1274 return undefined
1275 }
1276 return {
1277 type: 'git',
1278 url: `https://github.com/${namespace}/${name}`,
1279 }
1280
1281 case 'vscode':
1282 return {
1283 type: 'web',
1284 url: `https://marketplace.visualstudio.com/items?itemName=${namespace ? `${namespace}.` : ''}${name}`,
1285 }
1286
1287 default:
1288 return undefined
1289 }
1290 }
1291}