@preventive/upstream 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +47 -16
- package/cargo.d.ts +36 -0
- package/cargo.js +3 -0
- package/github.d.ts +67 -13
- package/npm.d.ts +40 -13
- package/npm.js +1 -1
- package/package.d.ts +3 -3
- package/package.json +41 -4
- package/semver.d.ts +12 -2
- package/semver.js +1 -1
- package/soldeer.d.ts +17 -0
- package/soldeer.js +2 -0
- package/src/advisories/common.js +27 -5
- package/src/advisories/github.js +38 -20
- package/src/advisories/index.js +7 -7
- package/src/advisories/npm.js +20 -6
- package/src/advisories/osv.js +39 -21
- package/src/advisories/repos.js +18 -42
- package/src/args.js +40 -2
- package/src/attributes.js +81 -0
- package/src/cache.js +103 -9
- package/src/cargo/crate.js +53 -0
- package/src/cargo/repos.js +52 -0
- package/src/download.js +42 -0
- package/src/github/read.js +113 -14
- package/src/github/write.js +6 -9
- package/src/http.js +29 -14
- package/src/npm/registry.js +19 -0
- package/src/npm/repos.js +27 -25
- package/src/npm/tarball.js +13 -53
- package/src/package.js +17 -6
- package/src/pool.js +2 -0
- package/src/remote.js +8 -1
- package/src/semver-peer.cjs +8 -0
- package/src/semver.js +29 -12
- package/src/soldeer.js +26 -0
- 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/github/write.js
CHANGED
|
@@ -66,19 +66,13 @@ export function parseGraphQLResponse(status, text) {
|
|
|
66
66
|
throw new HttpError(status, `GitHub GraphQL ${status}: ${printable(text.slice(0, 4096)) || '(empty body)'}`)
|
|
67
67
|
}
|
|
68
68
|
let json
|
|
69
|
-
try { json = JSON.parse(text) } catch (
|
|
70
|
-
throw new Error(`GitHub GraphQL ${status}: malformed JSON response (${
|
|
69
|
+
try { json = JSON.parse(text) } catch (cause) {
|
|
70
|
+
throw new Error(`GitHub GraphQL ${status}: malformed JSON response (${cause.message}): ${printable(text.slice(0, 200))}`, { cause })
|
|
71
71
|
}
|
|
72
72
|
if (json.errors) throw new Error(`GitHub GraphQL: ${printable(JSON.stringify(json.errors))}`)
|
|
73
73
|
return json.data
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// A signed commit; the branch must already exist.
|
|
77
|
-
async function createCommitOnBranch(headers, input) {
|
|
78
|
-
const res = await send(api(['graphql']), { method: 'POST', headers, body: { query: CREATE_COMMIT_MUTATION, variables: { input } }, as: 'json' })
|
|
79
|
-
return parseGraphQLResponse(res.status, decode(await readBody(res, 1024 * 1024), 'GitHub GraphQL')).createCommitOnBranch.commit
|
|
80
|
-
}
|
|
81
|
-
|
|
82
76
|
async function forkRepo(headers, options) {
|
|
83
77
|
assertArgs('forkRepo', options, { repo: assertRepo, name: optional(assertRepoName), organization: optional(assertLogin), defaultBranchOnly: optional(assertBoolean) })
|
|
84
78
|
const { repo, name, organization, defaultBranchOnly } = options
|
|
@@ -93,13 +87,16 @@ async function createBranch(headers, options) {
|
|
|
93
87
|
return await call(headers, repoApi(repo, ['git', 'refs']), { method: 'POST', body: { ref: `refs/heads/${branch}`, sha } })
|
|
94
88
|
}
|
|
95
89
|
|
|
90
|
+
// A signed commit; the branch must already exist.
|
|
96
91
|
async function createCommit(headers, options) {
|
|
97
92
|
assertArgs('createCommit', options, { repo: assertRepo, branch: assertRef, message: null, additions: null, deletions: null, expectedHeadOid: optional(assertSha) })
|
|
98
93
|
const { repo, branch } = options
|
|
99
94
|
const changes = fileChanges(options.additions, options.deletions)
|
|
100
95
|
const message = commitMessage(options.message)
|
|
101
96
|
const expectedHeadOid = options.expectedHeadOid ?? (await getRepoHead(headers, { repo, branch })).oid
|
|
102
|
-
|
|
97
|
+
const input = { branch: { repositoryNameWithOwner: repo, branchName: branch }, message, fileChanges: changes, expectedHeadOid }
|
|
98
|
+
const res = await send(api(['graphql']), { method: 'POST', headers, body: { query: CREATE_COMMIT_MUTATION, variables: { input } }, as: 'json' })
|
|
99
|
+
return parseGraphQLResponse(res.status, decode(await readBody(res, 1024 * 1024), 'GitHub GraphQL')).createCommitOnBranch.commit
|
|
103
100
|
}
|
|
104
101
|
|
|
105
102
|
async function createPR(headers, options) {
|
package/src/http.js
CHANGED
|
@@ -11,9 +11,13 @@ export const GITHUB_API = 'https://api.github.com'
|
|
|
11
11
|
export const OSV_API = 'https://api.osv.dev'
|
|
12
12
|
export const CRATES_API = 'https://crates.io'
|
|
13
13
|
export const PACKAGIST_REPO = 'https://repo.packagist.org'
|
|
14
|
+
export const CRATES_INDEX = 'https://index.crates.io'
|
|
15
|
+
export const CRATES_STATIC = 'https://static.crates.io'
|
|
16
|
+
export const SOLDEER_API = 'https://api.soldeer.xyz'
|
|
17
|
+
export const SOLDEER_REVISIONS = 'https://soldeer-revisions.s3.amazonaws.com'
|
|
14
18
|
export const MAX_BYTES = 512 * 1024 * 1024
|
|
15
19
|
|
|
16
|
-
const ORIGINS = new Set([NPM_REGISTRY, GITHUB_API, OSV_API, CRATES_API, PACKAGIST_REPO])
|
|
20
|
+
const ORIGINS = new Set([NPM_REGISTRY, GITHUB_API, OSV_API, CRATES_API, PACKAGIST_REPO, CRATES_INDEX, CRATES_STATIC, SOLDEER_API, SOLDEER_REVISIONS])
|
|
17
21
|
const isSegment = matches(/^(?!\.\.?$)(?:[\w.~@-]|%[\dA-F]{2})+$/u)
|
|
18
22
|
const isQueryKey = matches(/^[a-z_]+(?:\[\])?$/u)
|
|
19
23
|
const isQueryValue = (value) => (typeof value === 'string' && value !== '') || (Number.isSafeInteger(value) && value >= 0)
|
|
@@ -36,11 +40,17 @@ export class HttpError extends Error {
|
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
export const isNotFound = (err) => err instanceof HttpError && err.status === 404
|
|
44
|
+
export const recover = (expected, value) => (err) => {
|
|
45
|
+
if (!expected(err)) throw err
|
|
46
|
+
return value
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
export function decode(bytes, from) {
|
|
40
50
|
try {
|
|
41
51
|
return decoder.decode(bytes)
|
|
42
|
-
} catch (
|
|
43
|
-
throw new Error(`Malformed UTF-8 from ${from}`, { cause
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
throw new Error(`Malformed UTF-8 from ${from}`, { cause })
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
|
|
@@ -91,22 +101,27 @@ export async function readBody(res, limit, { truncate = false } = {}) {
|
|
|
91
101
|
return new Uint8Array(await new Blob(chunks).arrayBuffer(), 0, Math.min(size, limit))
|
|
92
102
|
}
|
|
93
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
|
+
|
|
94
115
|
// Read as the caller says, not by content type, which proxies drop or
|
|
95
|
-
// rewrite.
|
|
96
|
-
export async function
|
|
116
|
+
// rewrite; answered with the response's headers.
|
|
117
|
+
export async function requestWithHeaders(url, options) {
|
|
97
118
|
const res = await send(url, options)
|
|
98
119
|
if (!res.ok) {
|
|
99
120
|
// A stream decode holds back a character cut at the limit rather than refusing it.
|
|
100
121
|
const text = await readBody(res, 4096, { truncate: true }).then((bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })).catch(() => '')
|
|
101
122
|
throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`)
|
|
102
123
|
}
|
|
103
|
-
|
|
104
|
-
if (options.as === 'bytes') return bytes
|
|
105
|
-
const text = decode(bytes, url)
|
|
106
|
-
if (options.as === 'text') return text
|
|
107
|
-
try {
|
|
108
|
-
return JSON.parse(text)
|
|
109
|
-
} catch (err) {
|
|
110
|
-
throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause: err })
|
|
111
|
-
}
|
|
124
|
+
return { body: parse(await readBody(res, LIMITS[options.as].bytes), url, options.as), headers: res.headers }
|
|
112
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,21 +1,28 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { assertNames, assertPackageName, assertion, isRepo } from '../args.js'
|
|
4
|
+
import { readRecord, writeRecord } from '../cache.js'
|
|
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
|
-
const TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
|
|
11
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 `/`.
|
|
16
|
+
const VERSION = 3
|
|
17
|
+
|
|
18
|
+
// A slug a lookup gives: getRepo answers no repo ending in `.git`, the
|
|
19
|
+
// suffix it drops (withoutDotGit; `.GIT` it keeps, and so does this).
|
|
20
|
+
const isLookedUpRepo = (github) => isRepo(github) && !github.endsWith('.git')
|
|
21
|
+
// A write takes no slug a read would discard: whatever it writes reads back.
|
|
22
|
+
const assertLookedUpRepo = assertion('"owner/name" with no trailing ".git"', isLookedUpRepo)
|
|
12
23
|
|
|
13
24
|
// `latest`, not the full packument, which is megabytes of version history.
|
|
14
|
-
async
|
|
15
|
-
const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), 'latest']), { as: 'json' })
|
|
16
|
-
assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
|
|
17
|
-
return getRepo(json)
|
|
18
|
-
}
|
|
25
|
+
const fetchRepo = async (method, name) => getRepo(await getDocument(method, name, 'latest'))
|
|
19
26
|
|
|
20
27
|
export async function getGitHub(name) {
|
|
21
28
|
assertPackageName('getGitHub', 'name', name)
|
|
@@ -24,14 +31,15 @@ export async function getGitHub(name) {
|
|
|
24
31
|
return link
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
// An entry
|
|
28
|
-
//
|
|
34
|
+
// An entry under another VERSION was resolved by other rules, and may
|
|
35
|
+
// name a repo getRepo no longer would (a stale tracker's, or a shorthand's
|
|
36
|
+
// with its `.git`): a miss, as is one naming a repo or a directory no
|
|
37
|
+
// lookup gives, or without `directory`, which would read as a package at
|
|
38
|
+
// the repo root.
|
|
29
39
|
export async function readPackageRepoCache(name) {
|
|
30
40
|
assertPackageName('readPackageRepoCache', 'name', name)
|
|
31
|
-
const entry = await
|
|
32
|
-
|
|
33
|
-
const fresh = age >= 0 && age <= TTL_MS // An entry from the future is not fresh forever.
|
|
34
|
-
if (!fresh || entry.name !== name || !isRepo(entry.github) || !isRepoDirectory(entry.directory)) return null
|
|
41
|
+
const entry = await readRecord(DIR, name)
|
|
42
|
+
if (entry?.v !== VERSION || !isLookedUpRepo(entry.github) || !isRepoDirectory(entry.directory)) return null
|
|
35
43
|
return { github: entry.github, ...(entry.directory && { directory: entry.directory }) }
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -39,16 +47,13 @@ export async function readPackageRepoCache(name) {
|
|
|
39
47
|
// no repo link fail alike.
|
|
40
48
|
export async function writePackageRepoCache(name, github, directory = '') {
|
|
41
49
|
assertPackageName('writePackageRepoCache', 'name', name)
|
|
42
|
-
|
|
50
|
+
assertLookedUpRepo('writePackageRepoCache', 'github', github)
|
|
43
51
|
assertRepoDirectory('writePackageRepoCache', 'directory', directory)
|
|
44
|
-
return await
|
|
52
|
+
return await writeRecord(DIR, name, { v: VERSION, github, directory })
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export async function resolvePackageRepos(packageNames, options = {}) {
|
|
48
|
-
|
|
49
|
-
assertArgs('resolvePackageRepos', options, { cachedOnly: optional(assertBoolean) })
|
|
50
|
-
const names = [...new Set(packageNames)]
|
|
51
|
-
for (const name of names) assertPackageName('resolvePackageRepos', 'name', name)
|
|
56
|
+
const names = assertNames('resolvePackageRepos', 'packageNames', packageNames, options, assertPackageName)
|
|
52
57
|
const found = await pool(names, CONCURRENCY, (name) => (options.cachedOnly ? readPackageRepoCache(name) : lookUpPackageRepo(name)).catch(() => null))
|
|
53
58
|
return new Map(names.flatMap((name, i) => (found[i] ? [[name, found[i]]] : [])))
|
|
54
59
|
}
|
|
@@ -58,10 +63,7 @@ export async function resolvePackageRepos(packageNames, options = {}) {
|
|
|
58
63
|
export async function lookUpPackageRepo(name) {
|
|
59
64
|
const stored = await readPackageRepoCache(name)
|
|
60
65
|
if (stored) return stored
|
|
61
|
-
const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch((
|
|
62
|
-
if (err instanceof HttpError && err.status === 404) return {}
|
|
63
|
-
throw err
|
|
64
|
-
})
|
|
66
|
+
const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch(recover(isNotFound, {}))
|
|
65
67
|
if (!github) return null
|
|
66
68
|
await writePackageRepoCache(name, github, directory)
|
|
67
69
|
return { github, ...(directory && { directory }) }
|
package/src/npm/tarball.js
CHANGED
|
@@ -1,30 +1,18 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { Buffer } from 'node:buffer'
|
|
3
|
-
import { createHash } from 'node:crypto'
|
|
4
|
-
import { readFile, stat } from 'node:fs/promises'
|
|
5
3
|
import { homedir } from 'node:os'
|
|
6
4
|
import { join, resolve } from 'node:path'
|
|
7
5
|
|
|
8
|
-
import { assertArgs,
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
6
|
+
import { assertArgs, assertPackage, assertion, matches, show } from '../args.js'
|
|
7
|
+
import { verifiedDownload } from '../download.js'
|
|
8
|
+
import { NPM_REGISTRY, buildUrl } from '../http.js'
|
|
9
|
+
import { getDocument, withNpmToken } from './registry.js'
|
|
11
10
|
|
|
12
11
|
const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
|
|
13
12
|
// One sha512 and nothing else, as the registry writes it: a sha1, a second
|
|
14
13
|
// hash or an option is refused rather than trusted. 64 bytes leave the
|
|
15
14
|
// last character before `==` two bits, so only A, Q, g or w is canonical.
|
|
16
15
|
const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
|
|
17
|
-
const sha512 = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`
|
|
18
|
-
|
|
19
|
-
function assertPackage(method, name, version) {
|
|
20
|
-
assertPackageName(method, 'name', name)
|
|
21
|
-
assertPackageVersion(method, 'version', version)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function tarballUrl(name, version) {
|
|
25
|
-
const segments = name.split('/')
|
|
26
|
-
return buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
|
|
27
|
-
}
|
|
28
16
|
|
|
29
17
|
// `{ tarball, integrity }` and nothing else, the tarball exactly the
|
|
30
18
|
// registry's own URL for that version. Each field is read once, and only
|
|
@@ -35,22 +23,18 @@ function checkedDist(method, name, version, dist) {
|
|
|
35
23
|
assertArgs(method, dist, { tarball: null, integrity: null }, 'dist')
|
|
36
24
|
const [tarball, integrity] = ['tarball', 'integrity'].map((key) => (Object.hasOwn(dist, key) ? dist[key] : undefined))
|
|
37
25
|
assertIntegrity(method, 'dist.integrity', integrity)
|
|
38
|
-
const
|
|
26
|
+
const segments = name.split('/')
|
|
27
|
+
const expected = buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
|
|
39
28
|
assert.ok(tarball === expected, `${method}: dist.tarball must be ${expected}, got ${show(tarball)}`)
|
|
40
29
|
return { tarball, integrity }
|
|
41
30
|
}
|
|
42
31
|
|
|
43
32
|
async function getDist(method, name, version) {
|
|
44
|
-
const json = await
|
|
45
|
-
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}`)
|
|
46
35
|
return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
|
|
47
36
|
}
|
|
48
37
|
|
|
49
|
-
function assertBytes(bytes, integrity, what) {
|
|
50
|
-
const actual = sha512(bytes)
|
|
51
|
-
assert.ok(actual === integrity, `getTarball: integrity mismatch for ${what}: expected ${integrity}, got ${actual}`)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
38
|
// Where npm keeps its cache, short of an .npmrc moving it: npm_config_cache,
|
|
55
39
|
// as npm sets it for what it runs, else npm's default. On Windows, npm 4
|
|
56
40
|
// and before defaulted to %APPDATA%, later ones to %LOCALAPPDATA%.
|
|
@@ -61,24 +45,12 @@ function npmCacheDirs() {
|
|
|
61
45
|
return [process.env.LOCALAPPDATA, process.env.APPDATA].map((dir) => join(dir || homedir(), 'npm-cache'))
|
|
62
46
|
}
|
|
63
47
|
|
|
64
|
-
// Only a regular file: a FIFO or a device would block or never end.
|
|
65
|
-
async function readRegularFile(path) {
|
|
66
|
-
const stats = await stat(path).catch(() => null)
|
|
67
|
-
if (!stats?.isFile() || stats.size > MAX_BYTES) return null
|
|
68
|
-
return await readFile(path).then((bytes) => new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), () => null)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
48
|
// npm 5+ (cacache) files a tarball by its sha512, npm 4 and before as
|
|
72
49
|
// <name>/<version>/package.tgz; ~/.audit as <org>:<name>-<version>.tgz.
|
|
73
|
-
|
|
50
|
+
function localPaths(name, version, integrity) {
|
|
74
51
|
const hex = Buffer.from(integrity.slice('sha512-'.length), 'base64').toString('hex')
|
|
75
52
|
const npm = npmCacheDirs().flatMap((root) => [join(root, '_cacache/content-v2/sha512', hex.slice(0, 2), hex.slice(2, 4), hex.slice(4)), join(root, name, version, 'package.tgz')])
|
|
76
|
-
|
|
77
|
-
for (const path of [...npm, audit]) {
|
|
78
|
-
const bytes = await readRegularFile(path)
|
|
79
|
-
if (bytes && sha512(bytes) === integrity) return bytes
|
|
80
|
-
}
|
|
81
|
-
return null
|
|
53
|
+
return [...npm, join(homedir(), '.audit/cache/tgz', `${name.replace(/^@/u, '').replace('/', ':')}-${version}.tgz`)]
|
|
82
54
|
}
|
|
83
55
|
|
|
84
56
|
export async function getMeta(name, version) {
|
|
@@ -95,21 +67,9 @@ export async function verifyDist(name, version, dist) {
|
|
|
95
67
|
|
|
96
68
|
// Without `dist`, the version document is read every time, cache or not:
|
|
97
69
|
// bytes from a cache are checked against the registry's integrity, never
|
|
98
|
-
// against anything a cache itself holds.
|
|
99
|
-
// than fetching over it.
|
|
70
|
+
// against anything a cache itself holds.
|
|
100
71
|
export async function getTarball(name, version, dist) {
|
|
101
72
|
assertPackage('getTarball', name, version)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (local) return local
|
|
105
|
-
const key = `${name}@${version}.tgz`
|
|
106
|
-
const cached = await readCache(DIR, key)
|
|
107
|
-
if (cached) {
|
|
108
|
-
assertBytes(cached, dist.integrity, `${name}@${version} from the cache`)
|
|
109
|
-
return cached
|
|
110
|
-
}
|
|
111
|
-
const bytes = await request(dist.tarball, { as: 'bytes' })
|
|
112
|
-
assertBytes(bytes, dist.integrity, `${name}@${version} from ${dist.tarball}`)
|
|
113
|
-
await writeCache(DIR, key, bytes)
|
|
114
|
-
return bytes
|
|
73
|
+
const { tarball, integrity } = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
|
|
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) })
|
|
115
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,11 @@ 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.
|
|
42
|
+
const declaredDirectory = (value) => repoSubdirectory(str(value).replaceAll('\\', '/'))
|
|
43
|
+
|
|
36
44
|
export const isRepoDirectory = (value) => typeof value === 'string' && (value === '' || repoSubdirectory(value) === value)
|
|
37
45
|
export const assertRepoDirectory = assertion('a path inside the repository', isRepoDirectory)
|
|
38
46
|
|
|
@@ -41,9 +49,12 @@ export function getRepo(pkg) {
|
|
|
41
49
|
const { bugs, repository } = pkg
|
|
42
50
|
const declared = repositoryRepo(urlOf(repository))
|
|
43
51
|
const homepage = homepageRegex.exec(homepageUrl(pkg.homepage))?.groups
|
|
44
|
-
const
|
|
52
|
+
const homepageRepo = withoutDotGit(homepage?.repo)
|
|
53
|
+
// `repository` first: it is the field a publisher maintains, where a
|
|
54
|
+
// `bugs` tracker can be left pointing at a former owner or misspelt.
|
|
55
|
+
const github = [declared, repoIn(bugsRegex, urlOf(bugs)), homepageRepo].find(isRepo)
|
|
45
56
|
if (github === undefined) return {}
|
|
46
|
-
const directory = (sameName(declared, github) &&
|
|
47
|
-
|| (sameName(
|
|
57
|
+
const directory = (sameName(declared, github) && declaredDirectory(repository?.directory))
|
|
58
|
+
|| (sameName(homepageRepo, github) && repoSubdirectory(decodePath(homepage.directory)))
|
|
48
59
|
return { github, ...(directory && { directory }), url: `https://github.com/${github}` }
|
|
49
60
|
}
|
package/src/pool.js
CHANGED
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
|
+
}
|