@preventive/upstream 1.0.0-alpha.1 → 1.0.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +202 -0
- package/advisories.d.ts +68 -16
- package/cargo.d.ts +18 -1
- package/cargo.js +2 -1
- package/git.d.ts +2 -2
- package/github.d.ts +67 -13
- package/npm.d.ts +48 -20
- package/npm.js +1 -1
- package/package.d.ts +6 -3
- package/package.json +26 -4
- package/semver.d.ts +8 -2
- package/semver.js +1 -1
- package/src/advisories/common.js +27 -5
- package/src/advisories/github.js +98 -30
- package/src/advisories/index.js +7 -7
- package/src/advisories/npm.js +19 -5
- package/src/advisories/osv.js +34 -15
- package/src/advisories/repos.js +17 -35
- package/src/args.js +36 -2
- package/src/attributes.js +81 -0
- package/src/cache.js +93 -14
- package/src/{cargo.js → cargo/crate.js} +17 -11
- package/src/cargo/repos.js +52 -0
- package/src/download.js +18 -14
- package/src/git.js +1 -1
- package/src/github/read.js +113 -14
- package/src/http.js +16 -11
- package/src/npm/registry.js +19 -0
- package/src/npm/repos.js +30 -20
- package/src/npm/tarball.js +6 -10
- package/src/package.js +26 -7
- package/src/remote.js +8 -1
- package/src/semver-peer.cjs +8 -0
- package/src/semver.js +28 -12
- package/src/soldeer.js +2 -9
- package/src/tree.js +237 -0
- package/src/weak-edges.js +87 -0
- package/weak-edges.d.ts +36 -0
- package/weak-edges.js +1 -0
- /package/{LICENSE → LICENSE-MIT} +0 -0
package/src/github/read.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { Buffer } from 'node:buffer'
|
|
3
|
-
import { createHash } from 'node:crypto'
|
|
4
3
|
|
|
5
|
-
import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, isSha, optional, sameName, show } from '../args.js'
|
|
6
|
-
import {
|
|
4
|
+
import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
|
|
5
|
+
import { verifiedDownload } from '../download.js'
|
|
6
|
+
import { decode, encodeSegment, requestWithHeaders } from '../http.js'
|
|
7
|
+
import { gitTreeOfListing, objectId } from '../tree.js'
|
|
7
8
|
import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
|
|
8
9
|
|
|
9
10
|
const PER_PAGE = 100
|
|
@@ -20,6 +21,24 @@ async function* pages(method, headers, pageUrl, maxPages = MAX_PAGES) {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
// A list GitHub pages by a cursor instead: the `after` of each page's
|
|
25
|
+
// Link header `next` link, the one thing read off the link, so the next
|
|
26
|
+
// page is asked of the list `pageUrl` names, wherever the link points. A
|
|
27
|
+
// next link proves a page more.
|
|
28
|
+
async function* cursorPages(method, headers, pageUrl, maxPages = MAX_PAGES) {
|
|
29
|
+
const paging = { per_page: PER_PAGE }
|
|
30
|
+
for (let page = 1; ; page++) {
|
|
31
|
+
const answer = await requestWithHeaders(pageUrl(paging), { as: 'json', headers })
|
|
32
|
+
assert.ok(Array.isArray(answer.body), `${method}: expected an array for page ${page}`)
|
|
33
|
+
yield answer.body
|
|
34
|
+
const next = /<([^>]*)>\s*;\s*rel="next"/u.exec(answer.headers.get('link') ?? '')?.[1]
|
|
35
|
+
if (next === undefined) return
|
|
36
|
+
paging.after = URL.parse(next)?.searchParams.get('after')
|
|
37
|
+
assert.ok(paging.after, `${method}: page ${page} links the next with no cursor`)
|
|
38
|
+
assert.ok(page < maxPages, `${method}: more than ${maxPages} pages`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
const paginate = async (...args) => (await Array.fromAsync(pages(...args))).flat()
|
|
24
43
|
const getCurrentUser = (headers) => call(headers, api(['user']))
|
|
25
44
|
|
|
@@ -46,6 +65,32 @@ export async function getRepoHead(headers, options) {
|
|
|
46
65
|
return { branch: ref, oid: data.object.sha }
|
|
47
66
|
}
|
|
48
67
|
|
|
68
|
+
async function getRepoTag(headers, options) {
|
|
69
|
+
assertArgs('getRepoTag', options, { repo: assertRepo, tag: assertTagName })
|
|
70
|
+
const { repo, tag } = options
|
|
71
|
+
const ref = await call(headers, repoApi(repo, ['git', 'ref', 'tags', encodeSegment(tag)]))
|
|
72
|
+
assert.ok(ref?.ref === `refs/tags/${tag}`, `getRepoTag: GitHub answered for ${show(ref?.ref)}, not ${show(`refs/tags/${tag}`)}`)
|
|
73
|
+
let { object } = ref
|
|
74
|
+
for (let hops = 0; object?.type === 'tag' && isSha(object.sha) && hops < 8; hops++) {
|
|
75
|
+
const annotated = await call(headers, repoApi(repo, ['git', 'tags', object.sha]))
|
|
76
|
+
assert.ok(annotated?.sha === object.sha, `getRepoTag: GitHub answered for another tag object than ${object.sha}`)
|
|
77
|
+
object = annotated.object
|
|
78
|
+
}
|
|
79
|
+
assert.ok(object?.type === 'commit' && isSha(object.sha), `getRepoTag: ${repo} has no commit for tag ${show(tag)}`)
|
|
80
|
+
return { tag, oid: object.sha }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// GitHub lists each tag with the commit it names, an annotated one's too.
|
|
84
|
+
async function listRepoTags(headers, options) {
|
|
85
|
+
assertArgs('listRepoTags', options, { repo: assertRepo, maxPages: optional(assertNumber) })
|
|
86
|
+
const { repo, maxPages } = options
|
|
87
|
+
const tags = await paginate('listRepoTags', headers, (paging) => repoApi(repo, ['tags'], paging), maxPages)
|
|
88
|
+
return tags.map((entry) => {
|
|
89
|
+
assert.ok(isTagName(entry?.name) && isSha(entry.commit?.sha), `listRepoTags: GitHub listed ${show(entry?.name)} in ${repo}, which is no tag name with a commit`)
|
|
90
|
+
return { tag: entry.name, oid: entry.commit.sha }
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
49
94
|
// GitHub's object for the path first: a directory, symlink or submodule is
|
|
50
95
|
// refused rather than read as a file's text. Up to 1 MB the object holds
|
|
51
96
|
// the content; past it, `encoding: none`, and the blob it names is read,
|
|
@@ -61,16 +106,73 @@ async function getRepoFile(headers, options) {
|
|
|
61
106
|
const bytes = file.encoding === 'none'
|
|
62
107
|
? await call({ ...headers, Accept: 'application/vnd.github.raw' }, repoApi(repo, ['git', 'blobs', file.sha]), { as: 'bytes' })
|
|
63
108
|
: Buffer.from(file.content, 'base64')
|
|
64
|
-
const blob =
|
|
109
|
+
const blob = objectId('blob', bytes).toString('hex')
|
|
65
110
|
assert.ok(bytes.length === file.size && blob === file.sha, `getRepoFile: ${show(path)} came back as blob ${blob}, not ${file.sha}`)
|
|
66
111
|
return decode(bytes, url)
|
|
67
112
|
}
|
|
68
113
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
114
|
+
// Follows the redirect to codeload.github.com. A tree id names its content,
|
|
115
|
+
// so the bytes are held to it, downloaded or cached, and cached by it alone,
|
|
116
|
+
// for good. What a tarball cannot show, a submodule's commit, a subtree
|
|
117
|
+
// with nothing in it or the LF blob of a file marked `eol=crlf`, comes
|
|
118
|
+
// from GitHub's listings of the trees, which the id checks as well: a
|
|
119
|
+
// directory at a time, as a recursive listing of a large tree is cut short.
|
|
120
|
+
async function treeTarball(method, headers, repo, tree) {
|
|
121
|
+
const listings = new Map()
|
|
122
|
+
const list = (sha) => {
|
|
123
|
+
if (!listings.has(sha)) listings.set(sha, call(headers, repoApi(repo, ['git', 'trees', sha])).then((listing) => (Array.isArray(listing?.tree) ? listing.tree : [])))
|
|
124
|
+
return listings.get(sha)
|
|
125
|
+
}
|
|
126
|
+
const locate = () => repoApi(repo, ['tarball', tree])
|
|
127
|
+
return await verifiedDownload({ method, dir: 'github/trees', what: tree, ext: 'tgz', algorithm: 'tree', expected: tree, locate, options: { headers, redirect: 'follow' }, list })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function commitTree(method, headers, repo, sha) {
|
|
131
|
+
const commit = await call(headers, repoApi(repo, ['git', 'commits', sha]))
|
|
132
|
+
assert.ok(commit?.sha === sha && isSha1(commit.tree?.sha), `${method}: GitHub names no tree for ${repo}@${sha}`)
|
|
133
|
+
return commit.tree.sha
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A full sha only, so the bytes are that commit's, not wherever a ref points
|
|
137
|
+
// now. Its tree's tarball, not its own, in which `git archive` rewrites the
|
|
138
|
+
// files marked `export-subst`.
|
|
71
139
|
async function getRepoTarball(headers, options) {
|
|
72
140
|
assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha })
|
|
73
|
-
|
|
141
|
+
const { repo, sha } = options
|
|
142
|
+
const tree = await commitTree('getRepoTarball', headers, repo, sha)
|
|
143
|
+
return await treeTarball('getRepoTarball', headers, repo, tree)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function getRepoTreeTarball(headers, options) {
|
|
147
|
+
assertArgs('getRepoTreeTarball', options, { repo: assertRepo, tree: assertTreeId })
|
|
148
|
+
return await treeTarball('getRepoTreeTarball', headers, options.repo, options.tree)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Not recursive: GitHub cuts short a recursive listing of a large tree.
|
|
152
|
+
async function listTree(method, headers, repo, tree) {
|
|
153
|
+
const entries = (await call(headers, repoApi(repo, ['git', 'trees', tree])))?.tree
|
|
154
|
+
assert.ok(Array.isArray(entries) && gitTreeOfListing(entries) === tree, `${method}: GitHub's listing of tree ${tree} in ${repo} is not that tree`)
|
|
155
|
+
return entries.map(({ path, mode, type, sha }) => ({ path, mode, type, sha }))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function treeAt(method, headers, options) {
|
|
159
|
+
assertArgs(method, options, { repo: assertRepo, sha: assertSha, directory: optional(assertPath), path: optional(assertPath) })
|
|
160
|
+
assert.ok(options.directory === undefined || options.path === undefined, `${method}: directory and path are one option, pass directory alone`)
|
|
161
|
+
const { repo, sha, directory = options.path } = options
|
|
162
|
+
let tree = await commitTree(method, headers, repo, sha)
|
|
163
|
+
for (const name of directory?.split('/') ?? []) {
|
|
164
|
+
const entry = (await listTree(method, headers, repo, tree)).find((candidate) => candidate.path === name)
|
|
165
|
+
assert.ok(entry?.type === 'tree', `${method}: ${repo}@${sha} has no directory at ${show(directory)}`)
|
|
166
|
+
tree = entry.sha
|
|
167
|
+
}
|
|
168
|
+
return tree
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const getRepoTreeId = (headers, options) => treeAt('getRepoTreeId', headers, options)
|
|
172
|
+
|
|
173
|
+
async function listRepoDir(headers, options) {
|
|
174
|
+
const tree = await treeAt('listRepoDir', headers, options)
|
|
175
|
+
return await listTree('listRepoDir', headers, options.repo, tree)
|
|
74
176
|
}
|
|
75
177
|
|
|
76
178
|
async function getPullRequest(headers, options) {
|
|
@@ -110,15 +212,12 @@ async function getAdvisory(headers, options) {
|
|
|
110
212
|
return advisory
|
|
111
213
|
}
|
|
112
214
|
|
|
113
|
-
// One page: GitHub pages this list by a cursor in the Link header, which
|
|
114
|
-
// `call` does not read, so a full page is refused rather than cut short.
|
|
115
215
|
async function listRepoAdvisories(headers, options) {
|
|
116
216
|
assertArgs('listRepoAdvisories', options, { repo: assertRepo })
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return list
|
|
217
|
+
const { repo } = options
|
|
218
|
+
const pageUrl = (paging) => repoApi(repo, ['security-advisories'], { state: 'published', ...paging })
|
|
219
|
+
return (await Array.fromAsync(cursorPages('listRepoAdvisories', headers, pageUrl))).flat()
|
|
121
220
|
}
|
|
122
221
|
|
|
123
|
-
export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
|
|
222
|
+
export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
|
|
124
223
|
export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)
|
package/src/http.js
CHANGED
|
@@ -101,22 +101,27 @@ export async function readBody(res, limit, { truncate = false } = {}) {
|
|
|
101
101
|
return new Uint8Array(await new Blob(chunks).arrayBuffer(), 0, Math.min(size, limit))
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
function parse(bytes, url, as) {
|
|
105
|
+
if (as === 'bytes') return bytes
|
|
106
|
+
const text = decode(bytes, url)
|
|
107
|
+
if (as === 'text') return text
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(text)
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
104
115
|
// Read as the caller says, not by content type, which proxies drop or
|
|
105
|
-
// rewrite.
|
|
106
|
-
export async function
|
|
116
|
+
// rewrite; answered with the response's headers.
|
|
117
|
+
export async function requestWithHeaders(url, options) {
|
|
107
118
|
const res = await send(url, options)
|
|
108
119
|
if (!res.ok) {
|
|
109
120
|
// A stream decode holds back a character cut at the limit rather than refusing it.
|
|
110
121
|
const text = await readBody(res, 4096, { truncate: true }).then((bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })).catch(() => '')
|
|
111
122
|
throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`)
|
|
112
123
|
}
|
|
113
|
-
|
|
114
|
-
if (options.as === 'bytes') return bytes
|
|
115
|
-
const text = decode(bytes, url)
|
|
116
|
-
if (options.as === 'text') return text
|
|
117
|
-
try {
|
|
118
|
-
return JSON.parse(text)
|
|
119
|
-
} catch (cause) {
|
|
120
|
-
throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause })
|
|
121
|
-
}
|
|
124
|
+
return { body: parse(await readBody(res, LIMITS[options.as].bytes), url, options.as), headers: res.headers }
|
|
122
125
|
}
|
|
126
|
+
|
|
127
|
+
export const request = async (url, options) => (await requestWithHeaders(url, options)).body
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { show } from '../args.js'
|
|
4
|
+
import { NPM_REGISTRY, buildUrl, request } from '../http.js'
|
|
5
|
+
|
|
6
|
+
// Private packages are always scoped.
|
|
7
|
+
export function withNpmToken(name, url, options = {}) {
|
|
8
|
+
const method = options.method ?? 'GET'
|
|
9
|
+
assert.ok(method === 'GET' && `${new URL(url)}` === url && url.startsWith(`${NPM_REGISTRY}/${name}/`), `Unexpected request for ${name}: ${method} ${url}`)
|
|
10
|
+
const token = process.env.NPM_TOKEN
|
|
11
|
+
return token && name.startsWith('@') ? { ...options, method, headers: { ...options.headers, Authorization: `Bearer ${token}` } } : options
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function getDocument(method, name, spec) {
|
|
15
|
+
const url = buildUrl(NPM_REGISTRY, [...name.split('/'), spec])
|
|
16
|
+
const json = await request(url, withNpmToken(name, url, { as: 'json' }))
|
|
17
|
+
assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
|
|
18
|
+
return json
|
|
19
|
+
}
|
package/src/npm/repos.js
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { assertNames, assertPackageName, assertion, isRepo, optional } from '../args.js'
|
|
4
4
|
import { readRecord, writeRecord } from '../cache.js'
|
|
5
|
-
import {
|
|
5
|
+
import { isNotFound, recover } from '../http.js'
|
|
6
6
|
import { assertRepoDirectory, getRepo, isRepoDirectory } from '../package.js'
|
|
7
7
|
import { pool } from '../pool.js'
|
|
8
|
+
import { getDocument } from './registry.js'
|
|
8
9
|
|
|
9
10
|
const DIR = 'npm/repos'
|
|
10
11
|
const CONCURRENCY = 8
|
|
12
|
+
// Stamped on each entry, and raised when getRepo would answer differently:
|
|
13
|
+
// 2 is `repository` taking precedence over `bugs`; 3 the shorthand dropping
|
|
14
|
+
// a `.git`, URLs read past whitespace, and `repository.directory`'s `\` read
|
|
15
|
+
// as `/`; 4 a declared root answered as `''`, no directory declared left
|
|
16
|
+
// out (unknown) rather than read as the root, and a declared directory's
|
|
17
|
+
// empty and `.` parts dropped.
|
|
18
|
+
const VERSION = 4
|
|
19
|
+
|
|
20
|
+
// A slug a lookup gives: getRepo answers no repo ending in `.git`, the
|
|
21
|
+
// suffix it drops (withoutDotGit; `.GIT` it keeps, and so does this).
|
|
22
|
+
const isLookedUpRepo = (github) => isRepo(github) && !github.endsWith('.git')
|
|
23
|
+
// A write takes no slug a read would discard: whatever it writes reads back.
|
|
24
|
+
const assertLookedUpRepo = assertion('"owner/name" with no trailing ".git"', isLookedUpRepo)
|
|
11
25
|
|
|
12
26
|
// `latest`, not the full packument, which is megabytes of version history.
|
|
13
|
-
async
|
|
14
|
-
const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), 'latest']), { as: 'json' })
|
|
15
|
-
assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
|
|
16
|
-
return getRepo(json)
|
|
17
|
-
}
|
|
27
|
+
const fetchRepo = async (method, name) => getRepo(await getDocument(method, name, 'latest'))
|
|
18
28
|
|
|
19
29
|
export async function getGitHub(name) {
|
|
20
30
|
assertPackageName('getGitHub', 'name', name)
|
|
@@ -23,29 +33,29 @@ export async function getGitHub(name) {
|
|
|
23
33
|
return link
|
|
24
34
|
}
|
|
25
35
|
|
|
26
|
-
// An entry
|
|
27
|
-
//
|
|
36
|
+
// An entry under another VERSION was resolved by other rules, and may
|
|
37
|
+
// name a repo getRepo no longer would (a stale tracker's, or a shorthand's
|
|
38
|
+
// with its `.git`, or the root where a package declared no directory): a
|
|
39
|
+
// miss, as is one naming a repo or a directory no lookup gives. One without
|
|
40
|
+
// `directory` is a package whose place in its repo is unknown.
|
|
28
41
|
export async function readPackageRepoCache(name) {
|
|
29
42
|
assertPackageName('readPackageRepoCache', 'name', name)
|
|
30
43
|
const entry = await readRecord(DIR, name)
|
|
31
|
-
if (!
|
|
32
|
-
return { github: entry.github, ...(entry.directory && { directory: entry.directory }) }
|
|
44
|
+
if (entry?.v !== VERSION || !isLookedUpRepo(entry.github) || (entry.directory !== undefined && !isRepoDirectory(entry.directory))) return null
|
|
45
|
+
return { github: entry.github, ...(entry.directory !== undefined && { directory: entry.directory }) }
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
// Only resolved repos are cached: a 404, a rate limit and a package with
|
|
36
49
|
// no repo link fail alike.
|
|
37
|
-
export async function writePackageRepoCache(name, github, directory
|
|
50
|
+
export async function writePackageRepoCache(name, github, directory) {
|
|
38
51
|
assertPackageName('writePackageRepoCache', 'name', name)
|
|
39
|
-
|
|
40
|
-
assertRepoDirectory('writePackageRepoCache', 'directory', directory)
|
|
41
|
-
return await writeRecord(DIR, name, { github, directory })
|
|
52
|
+
assertLookedUpRepo('writePackageRepoCache', 'github', github)
|
|
53
|
+
optional(assertRepoDirectory)('writePackageRepoCache', 'directory', directory)
|
|
54
|
+
return await writeRecord(DIR, name, { v: VERSION, github, ...(directory !== undefined && { directory }) })
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
export async function resolvePackageRepos(packageNames, options = {}) {
|
|
45
|
-
|
|
46
|
-
assertArgs('resolvePackageRepos', options, { cachedOnly: optional(assertBoolean) })
|
|
47
|
-
const names = [...new Set(packageNames)]
|
|
48
|
-
for (const name of names) assertPackageName('resolvePackageRepos', 'name', name)
|
|
58
|
+
const names = assertNames('resolvePackageRepos', 'packageNames', packageNames, options, assertPackageName)
|
|
49
59
|
const found = await pool(names, CONCURRENCY, (name) => (options.cachedOnly ? readPackageRepoCache(name) : lookUpPackageRepo(name)).catch(() => null))
|
|
50
60
|
return new Map(names.flatMap((name, i) => (found[i] ? [[name, found[i]]] : [])))
|
|
51
61
|
}
|
|
@@ -58,5 +68,5 @@ export async function lookUpPackageRepo(name) {
|
|
|
58
68
|
const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch(recover(isNotFound, {}))
|
|
59
69
|
if (!github) return null
|
|
60
70
|
await writePackageRepoCache(name, github, directory)
|
|
61
|
-
return { github, ...(directory && { directory }) }
|
|
71
|
+
return { github, ...(directory !== undefined && { directory }) }
|
|
62
72
|
}
|
package/src/npm/tarball.js
CHANGED
|
@@ -3,9 +3,10 @@ import { Buffer } from 'node:buffer'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
|
|
6
|
-
import { assertArgs,
|
|
6
|
+
import { assertArgs, assertPackage, assertion, matches, show } from '../args.js'
|
|
7
7
|
import { verifiedDownload } from '../download.js'
|
|
8
|
-
import { NPM_REGISTRY, buildUrl
|
|
8
|
+
import { NPM_REGISTRY, buildUrl } from '../http.js'
|
|
9
|
+
import { getDocument, withNpmToken } from './registry.js'
|
|
9
10
|
|
|
10
11
|
const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
|
|
11
12
|
// One sha512 and nothing else, as the registry writes it: a sha1, a second
|
|
@@ -13,11 +14,6 @@ const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twic
|
|
|
13
14
|
// last character before `==` two bits, so only A, Q, g or w is canonical.
|
|
14
15
|
const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
|
|
15
16
|
|
|
16
|
-
function assertPackage(method, name, version) {
|
|
17
|
-
assertPackageName(method, 'name', name)
|
|
18
|
-
assertPackageVersion(method, 'version', version)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
17
|
// `{ tarball, integrity }` and nothing else, the tarball exactly the
|
|
22
18
|
// registry's own URL for that version. Each field is read once, and only
|
|
23
19
|
// as the object's own, into a copy that is checked and used from then on:
|
|
@@ -34,8 +30,8 @@ function checkedDist(method, name, version, dist) {
|
|
|
34
30
|
}
|
|
35
31
|
|
|
36
32
|
async function getDist(method, name, version) {
|
|
37
|
-
const json = await
|
|
38
|
-
assert.ok(json
|
|
33
|
+
const json = await getDocument(method, name, version)
|
|
34
|
+
assert.ok(json.version === version, `${method}: the registry answered for ${name}@${show(json.version)}, not ${name}@${version}`)
|
|
39
35
|
return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
|
|
40
36
|
}
|
|
41
37
|
|
|
@@ -75,5 +71,5 @@ export async function verifyDist(name, version, dist) {
|
|
|
75
71
|
export async function getTarball(name, version, dist) {
|
|
76
72
|
assertPackage('getTarball', name, version)
|
|
77
73
|
const { tarball, integrity } = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
|
|
78
|
-
return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), locate: () => tarball })
|
|
74
|
+
return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), ours: true, locate: () => tarball, options: withNpmToken(name, tarball) })
|
|
79
75
|
}
|
package/src/package.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
3
|
import { assertion, isRepo, isRepoPath, sameName } from './args.js'
|
|
4
|
-
import { githubRepoOfUrl } from './remote.js'
|
|
4
|
+
import { githubRepoOfUrl, withoutDotGit } from './remote.js'
|
|
5
5
|
|
|
6
6
|
const bugsRegex = /^(?i:https?:\/\/github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+)\/issues\/?$/u
|
|
7
7
|
// npm's `owner/name` shorthand means GitHub. No dots in the owner, so a
|
|
@@ -11,9 +11,12 @@ const shorthandRegex = /^(?:github:)?(?<repo>[\w-]+\/[\w.-]+)$/u
|
|
|
11
11
|
// The ref is one segment: a branch with a `/` reads as part of the directory.
|
|
12
12
|
const homepageRegex = /^(?:https?:\/\/)?(?:www\.)?github\.com(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?(?:\/(?:tree\/[^/]+(?:\/(?<directory>.*))?)?)?$/iu
|
|
13
13
|
const str = (value) => (typeof value === 'string' ? value : '')
|
|
14
|
-
|
|
14
|
+
// Read past the whitespace around it, as `homepage` is.
|
|
15
|
+
const urlOf = (field) => str(field?.url ?? field).trim()
|
|
15
16
|
const homepageUrl = (homepage) => str(homepage).trim().split(/[?#]/u)[0] // npm appends `#readme`.
|
|
16
|
-
|
|
17
|
+
// Every trailing `.git` dropped, as the URLs' are: no answer ends in one
|
|
18
|
+
// (the npm repo cache refuses one).
|
|
19
|
+
const repoIn = (regex, text) => withoutDotGit(regex.exec(text)?.groups.repo)
|
|
17
20
|
// A URL's path spells a tree path percent-encoded; one that does not decode names none.
|
|
18
21
|
function decodePath(path = '') {
|
|
19
22
|
try {
|
|
@@ -33,6 +36,19 @@ function repoSubdirectory(value) {
|
|
|
33
36
|
return isRepoPath(path) ? path : undefined // No empty, `.`, `..` or `.git` part: a path inside the repo.
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
// `repository.directory` is a path its author may write with Windows' `\`.
|
|
40
|
+
// A tree path is `/`-separated: a `\` is a name's own only in a homepage's
|
|
41
|
+
// (`%5C`), which is the path itself. Its empty and `.` parts are dropped
|
|
42
|
+
// (`./a//b/.` is `a/b`), and one of nothing else (`./`, `/`, `/.`, `.`, `''`)
|
|
43
|
+
// declares the repo's root, `''`; one with a `..` part names none, whatever
|
|
44
|
+
// it would come to. A package.json that declares none says nothing of where
|
|
45
|
+
// in the repo it sits.
|
|
46
|
+
function declaredDirectory(value) {
|
|
47
|
+
if (typeof value !== 'string') return undefined
|
|
48
|
+
const parts = value.replaceAll('\\', '/').split('/').filter((part) => part !== '' && part !== '.')
|
|
49
|
+
return parts.length === 0 ? '' : repoSubdirectory(parts.join('/'))
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
export const isRepoDirectory = (value) => typeof value === 'string' && (value === '' || repoSubdirectory(value) === value)
|
|
37
53
|
export const assertRepoDirectory = assertion('a path inside the repository', isRepoDirectory)
|
|
38
54
|
|
|
@@ -41,9 +57,12 @@ export function getRepo(pkg) {
|
|
|
41
57
|
const { bugs, repository } = pkg
|
|
42
58
|
const declared = repositoryRepo(urlOf(repository))
|
|
43
59
|
const homepage = homepageRegex.exec(homepageUrl(pkg.homepage))?.groups
|
|
44
|
-
const
|
|
60
|
+
const homepageRepo = withoutDotGit(homepage?.repo)
|
|
61
|
+
// `repository` first: it is the field a publisher maintains, where a
|
|
62
|
+
// `bugs` tracker can be left pointing at a former owner or misspelt.
|
|
63
|
+
const github = [declared, repoIn(bugsRegex, urlOf(bugs)), homepageRepo].find(isRepo)
|
|
45
64
|
if (github === undefined) return {}
|
|
46
|
-
const directory = (sameName(declared, github)
|
|
47
|
-
|
|
48
|
-
return { github, ...(directory && { directory }), url: `https://github.com/${github}` }
|
|
65
|
+
const directory = (sameName(declared, github) ? declaredDirectory(repository?.directory) : undefined)
|
|
66
|
+
?? (sameName(homepageRepo, github) ? repoSubdirectory(decodePath(homepage.directory)) : undefined)
|
|
67
|
+
return { github, ...(directory !== undefined && { directory }), url: `https://github.com/${github}` }
|
|
49
68
|
}
|
package/src/remote.js
CHANGED
|
@@ -7,9 +7,16 @@ import { isRepo } from './args.js'
|
|
|
7
7
|
const gitUrlRegex = /^(?i:(?:git\+)?(?:https?|git|ssh)):\/\/(?:[\w.~%!$&'()*+,;=:-]*@)?(?i:github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?\/?$/u
|
|
8
8
|
const scpRegex = /^(?i:git\+ssh:\/\/)?git@(?i:github\.com):(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?$/u
|
|
9
9
|
|
|
10
|
+
// A repo with every trailing `.git` dropped, so none ends in one: in a loop,
|
|
11
|
+
// as a regex would backtrack over a long run of them in a crafted URL.
|
|
12
|
+
export function withoutDotGit(repo) {
|
|
13
|
+
while (repo?.endsWith('.git')) repo = repo.slice(0, -4)
|
|
14
|
+
return repo
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
export function githubRepoOfUrl(url) {
|
|
11
18
|
if (typeof url !== 'string') return undefined
|
|
12
19
|
const onGitHub = URL.parse(url.replace(/^git\+/iu, ''))?.hostname.toLowerCase() === 'github.com'
|
|
13
|
-
const repo = scpRegex.exec(url)?.groups.repo ?? (onGitHub ? gitUrlRegex.exec(url)?.groups.repo : undefined)
|
|
20
|
+
const repo = withoutDotGit(scpRegex.exec(url)?.groups.repo ?? (onGitHub ? gitUrlRegex.exec(url)?.groups.repo : undefined))
|
|
14
21
|
return isRepo(repo) ? repo : undefined
|
|
15
22
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The semver peer dependency by a plain require(), which a bundler follows
|
|
4
|
+
// and bundles: CommonJS for that alone. In an ESM bundle that leaves semver
|
|
5
|
+
// out, this require() is the bundler's stub, which throws.
|
|
6
|
+
module.exports = function requirePeer() {
|
|
7
|
+
return require('semver')
|
|
8
|
+
}
|
package/src/semver.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import { basename, dirname, resolve } from 'node:path'
|
|
4
|
+
import requirePeer from './semver-peer.cjs'
|
|
4
5
|
|
|
5
6
|
// npm's own semver, borrowed from the npm next to node rather than added
|
|
6
|
-
// as a dependency: the same implementation `npm audit` uses.
|
|
7
|
+
// as a dependency: the same implementation `npm audit` uses. Where there is
|
|
8
|
+
// no npm, as in a bundled serverless function, the `semver` package itself,
|
|
9
|
+
// an optional peer dependency.
|
|
7
10
|
|
|
8
11
|
// Plain releases that semver.valid answers unchanged, so it need not be
|
|
9
12
|
// loaded for them: no leading zeros, and at most 15 digits a part, under
|
|
@@ -11,26 +14,37 @@ import { basename, dirname, resolve } from 'node:path'
|
|
|
11
14
|
// to semver.
|
|
12
15
|
const PLAIN_RELEASE = /^(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})$/u
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
// What load() returns if it is semver, or null if it is not or throws.
|
|
18
|
+
function attempt(load) {
|
|
19
|
+
try {
|
|
20
|
+
const lib = load()
|
|
21
|
+
if (['compare', 'intersects', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
|
|
22
|
+
} catch {}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
15
25
|
|
|
16
26
|
function find() {
|
|
17
27
|
const argv0 = process.argv[0]
|
|
18
28
|
// Another host executable's `../lib` is not npm's.
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const lib =
|
|
25
|
-
if (
|
|
26
|
-
}
|
|
29
|
+
if (['node', 'node.exe'].includes(basename(argv0 ?? ''))) {
|
|
30
|
+
// POSIX keeps npm in <prefix>/lib beside <prefix>/bin/node; Windows, beside node.exe.
|
|
31
|
+
for (const prefix of ['../lib', '.']) {
|
|
32
|
+
// Required from itself, which a bundle of any format leaves alone.
|
|
33
|
+
const path = resolve(dirname(argv0), prefix, 'node_modules/npm/node_modules/semver')
|
|
34
|
+
const lib = attempt(() => createRequire(path)(path))
|
|
35
|
+
if (lib) return lib
|
|
36
|
+
}
|
|
27
37
|
}
|
|
28
|
-
|
|
38
|
+
// The peer, by the plain require() a bundler follows; or, where an ESM
|
|
39
|
+
// bundle leaves it out, by node's own require() from beside the bundle.
|
|
40
|
+
return attempt(requirePeer) ?? attempt(() => createRequire(import.meta.url)('semver'))
|
|
29
41
|
}
|
|
30
42
|
|
|
43
|
+
let found
|
|
44
|
+
|
|
31
45
|
function semver() {
|
|
32
46
|
if (found === undefined) found = find()
|
|
33
|
-
assert.ok(found, 'semver: no npm beside node to borrow it from')
|
|
47
|
+
assert.ok(found, 'semver: no npm beside node to borrow it from, and the semver peer dependency is not installed')
|
|
34
48
|
return found
|
|
35
49
|
}
|
|
36
50
|
|
|
@@ -40,3 +54,5 @@ export const intersects = (...args) => semver().intersects(...args)
|
|
|
40
54
|
export const compareVersions = (...args) => semver().compare(...args)
|
|
41
55
|
export const valid = (version, ...rest) => (rest.length === 0 && typeof version === 'string' && PLAIN_RELEASE.test(version) ? version : semver().valid(version, ...rest))
|
|
42
56
|
export const isExactVersion = (version) => typeof version === 'string' && valid(version) === version
|
|
57
|
+
export const clean = (...args) => semver().clean(...args)
|
|
58
|
+
export const major = (...args) => semver().major(...args)
|
package/src/soldeer.js
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
|
-
import { assertSha256,
|
|
3
|
+
import { assertSha256, assertSoldeerPackage, show } from './args.js'
|
|
4
4
|
import { verifiedDownload } from './download.js'
|
|
5
5
|
import { SOLDEER_API, SOLDEER_REVISIONS, buildUrl, request } from './http.js'
|
|
6
6
|
|
|
7
7
|
const DIR = 'soldeer/zips' // No expiry: the registry refuses a version it already has.
|
|
8
|
-
// Soldeer's own rule for a name: 3 to 100 of lowercase letters, digits and
|
|
9
|
-
// `-`, starting with one of them or `@`, ending with a letter or digit.
|
|
10
|
-
const assertName = assertion('a Soldeer package name', matches(/^(?=.{3,100}$)[@\da-z][\da-z-]*[\da-z]$/u))
|
|
11
|
-
// Soldeer takes any version but an empty one. Its registry has semver
|
|
12
|
-
// (`5.7.0-rc.0`, `1.0.2-solc-0.8-simulate`), bare numbers and commit hashes.
|
|
13
|
-
const assertVersion = assertion('letters, digits, `.`, `_`, `+` and `-`', matches(/^(?=.{1,128}$)[\dA-Za-z][\w.+-]*$/u))
|
|
14
8
|
|
|
15
9
|
// Where the registry keeps the version's zip; it says nothing of what the
|
|
16
10
|
// zip hashes to.
|
|
@@ -26,8 +20,7 @@ async function zipUrl(name, version) {
|
|
|
26
20
|
// The registry has no hash to check a zip against, so the caller brings
|
|
27
21
|
// one, as soldeer.lock records it.
|
|
28
22
|
export async function getZip(name, version, checksum) {
|
|
29
|
-
|
|
30
|
-
assertVersion('getZip', 'version', version)
|
|
23
|
+
assertSoldeerPackage('getZip', name, version)
|
|
31
24
|
assertSha256('getZip', 'checksum', checksum)
|
|
32
25
|
return await verifiedDownload({ method: 'getZip', dir: DIR, what: `${name}@${version}`, ext: 'zip', algorithm: 'sha256', expected: checksum, locate: () => zipUrl(name, version) })
|
|
33
26
|
}
|