@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.
@@ -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 { decode, encodeSegment } from '../http.js'
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 = createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
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
- // The one request that follows a redirect, to codeload.github.com. A full
70
- // sha only, so the bytes are that commit's, not wherever a ref points now.
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
- return await call(headers, repoApi(options.repo, ['tarball', options.sha]), { as: 'bytes', redirect: 'follow' })
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 list = await call(headers, repoApi(options.repo, ['security-advisories'], { state: 'published', per_page: PER_PAGE }))
118
- assert.ok(Array.isArray(list), `listRepoAdvisories: expected an array for ${options.repo}`)
119
- assert.ok(list.length < PER_PAGE, `listRepoAdvisories: ${options.repo} has ${PER_PAGE} or more published advisories, more than a page`)
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)
@@ -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 (err) {
70
- throw new Error(`GitHub GraphQL ${status}: malformed JSON response (${err.message}): ${printable(text.slice(0, 200))}`, { cause: err })
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
- return await createCommitOnBranch(headers, { branch: { repositoryNameWithOwner: repo, branchName: branch }, message, fileChanges: changes, expectedHeadOid })
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 (err) {
43
- throw new Error(`Malformed UTF-8 from ${from}`, { cause: err })
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 request(url, options) {
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
- const bytes = await readBody(res, LIMITS[options.as].bytes)
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 { assertArgs, assertBoolean, assertPackageName, assertRepo, isRepo, optional, show } from '../args.js'
4
- import { readCacheJSON, writeCacheJSON } from '../cache.js'
5
- import { HttpError, NPM_REGISTRY, buildUrl, request } from '../http.js'
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 function fetchRepo(method, name) {
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 without `directory` predates the field: a miss, not a package
28
- // at the repo root.
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 readCacheJSON(DIR, `${name}.json`)
32
- const age = typeof entry?.at === 'number' ? Date.now() - entry.at : Number.NaN
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
- assertRepo('writePackageRepoCache', 'github', github)
50
+ assertLookedUpRepo('writePackageRepoCache', 'github', github)
43
51
  assertRepoDirectory('writePackageRepoCache', 'directory', directory)
44
- return await writeCacheJSON(DIR, `${name}.json`, { at: Date.now(), name, github, directory })
52
+ return await writeRecord(DIR, name, { v: VERSION, github, directory })
45
53
  }
46
54
 
47
55
  export async function resolvePackageRepos(packageNames, options = {}) {
48
- assert.ok(typeof packageNames?.[Symbol.iterator] === 'function' && typeof packageNames !== 'string', 'resolvePackageRepos: packageNames must be an iterable of names')
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((err) => {
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 }) }
@@ -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, assertPackageName, assertPackageVersion, assertion, matches, show } from '../args.js'
9
- import { readCache, writeCache } from '../cache.js'
10
- import { MAX_BYTES, NPM_REGISTRY, buildUrl, request } from '../http.js'
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 expected = tarballUrl(name, version)
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 request(buildUrl(NPM_REGISTRY, [...name.split('/'), version]), { as: 'json' })
45
- assert.ok(json?.name === name && json.version === version, `${method}: the registry answered for ${show(json?.name)}@${show(json?.version)}, not ${name}@${version}`)
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
- async function readLocalCaches(name, version, integrity) {
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
- const audit = join(homedir(), '.audit/cache/tgz', `${name.replace(/^@/u, '').replace('/', ':')}-${version}.tgz`)
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. A mismatch in ours throws rather
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
- dist = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
103
- const local = await readLocalCaches(name, version, dist.integrity)
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
- const urlOf = (field) => str(field?.url ?? field)
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
- const repoIn = (regex, text) => regex.exec(text)?.groups.repo
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 github = [repoIn(bugsRegex, urlOf(bugs)), declared, homepage?.repo].find(isRepo)
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) && repoSubdirectory(repository?.directory))
47
- || (sameName(homepage?.repo, github) && repoSubdirectory(decodePath(homepage.directory)))
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
@@ -10,3 +10,5 @@ export async function pool(items, limit, fn) {
10
10
  }))
11
11
  return results
12
12
  }
13
+
14
+ export const chunks = (items, size) => Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, (i + 1) * size))
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
+ }