@preventive/upstream 1.0.0-alpha.3 → 1.0.0-alpha.5

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.
@@ -13,6 +13,12 @@ const QUERIES_PER_REQUEST = 1000
13
13
  const RECORDS_AT_ONCE = 8
14
14
  const isOsvId = matches(/^(?=.{1,128}$)[A-Z][\dA-Z]*(?:-[\dA-Za-z]+)+$/u)
15
15
  const INFORMATIONAL = new Set(['unmaintained', 'unsound', 'notice'])
16
+ // Of the records that publish one advisory, the first answered for a
17
+ // version stands for the rest on it: RustSec's, what `cargo audit` reads
18
+ // and GitHub mirrors, then the GHSA, then any other (MAL-…), then by id.
19
+ const rank = (id) => (id.startsWith('RUSTSEC-') ? 0 : (isGhsa(id) ? 1 : 2))
20
+ const first = (a, b) => rank(a.id) - rank(b.id) || order(a.id, b.id)
21
+ const named = (a, b) => a.aliases.includes(b.id) || b.aliases.includes(a.id)
16
22
 
17
23
  export const CARGO = {
18
24
  osv: 'crates.io',
@@ -20,7 +26,6 @@ export const CARGO = {
20
26
  lookUp: crateRepos,
21
27
  assertName: assertCrateName,
22
28
  assertVersion: assertCrateVersion,
23
- keep: (id) => id.startsWith('RUSTSEC-'), // What `cargo audit` reads; the GHSA records mirror it.
24
29
  advisories: (asked, options) => osvAdvisories(CARGO, asked, options),
25
30
  }
26
31
  // TODO: packagist.org's API, what `composer audit` reads, also has the
@@ -70,11 +75,13 @@ function toAdvisory(ecosystem, name, versions, record) {
70
75
  }
71
76
 
72
77
  // OSV's batch query matches versions on its side, but answers ids only:
73
- // each record is fetched once after. A record another database also
74
- // publishes comes back under both ids, so one that aliases a GHSA keeps
75
- // only the versions that GHSA was not answered for.
78
+ // each record is fetched once after. An advisory more than one database
79
+ // publishes comes back under each one's id, the records naming each other
80
+ // as aliases, either way round, directly or through others: one group, in
81
+ // which each version is reported under the first answered for it, and
82
+ // each record has the others' ids as aliases.
76
83
  async function osvAdvisories(ecosystem, asked, options) {
77
- const { osv, github, lookUp, covers, keep = () => true } = ecosystem
84
+ const { osv, github, lookUp, covers } = ecosystem
78
85
  const list = [...asked].flatMap(([name, versions]) => versions.map((version) => ({ name, version })))
79
86
  const hits = new Map() // id → name → versions, in `list` order
80
87
  for (const chunk of chunks(list, QUERIES_PER_REQUEST)) {
@@ -85,21 +92,28 @@ async function osvAdvisories(ecosystem, asked, options) {
85
92
  const { name, version } = chunk[j]
86
93
  const vulns = result?.vulns ?? []
87
94
  assert.ok(result && result.next_page_token === undefined && Array.isArray(vulns) && vulns.every((vuln) => isOsvId(vuln?.id)), `advisories: malformed OSV result for ${name}@${version}`)
88
- for (const { id } of vulns.filter((vuln) => keep(vuln.id))) {
95
+ for (const { id } of vulns) {
89
96
  const byName = hits.get(id) ?? hits.set(id, new Map()).get(id)
90
97
  byName.set(name, (byName.get(name) ?? new Set()).add(version))
91
98
  }
92
99
  }
93
100
  }
94
101
  const records = (await pool([...hits.keys()], RECORDS_AT_ONCE, getVuln)).filter((record) => !record.withdrawn)
95
- const live = new Set(records.map((record) => record.id))
102
+ const groups = new Map()
103
+ for (const record of records) {
104
+ if (groups.has(record)) continue
105
+ const group = [record]
106
+ for (const member of group) group.push(...records.filter((other) => !group.includes(other) && named(member, other)))
107
+ for (const member of group) groups.set(member, group)
108
+ }
96
109
  const rows = []
97
110
  for (const record of records) {
111
+ const group = groups.get(record)
112
+ const above = group.filter((other) => first(other, record) < 0)
113
+ const aliases = [...new Set([...record.aliases, ...group.map((other) => other.id)])].filter((id) => id !== record.id)
98
114
  for (const [name, versions] of hits.get(record.id)) {
99
- // Only the versions a live GHSA it aliases answered for too.
100
- const shadowed = isGhsa(record.id) ? [] : record.aliases.filter((alias) => isGhsa(alias) && live.has(alias)).flatMap((alias) => [...(hits.get(alias).get(name) ?? [])])
101
- const rest = [...versions].filter((version) => !shadowed.includes(version))
102
- if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, record))
115
+ const rest = [...versions].filter((version) => !above.some((other) => hits.get(other.id).get(name)?.has(version)))
116
+ if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, { ...record, aliases }))
103
117
  }
104
118
  }
105
119
  rows.sort((a, b) => order(a.name, b.name) || order(a.id, b.id))
@@ -1,51 +1,24 @@
1
1
  import assert from 'node:assert/strict'
2
- import { setTimeout as sleep } from 'node:timers/promises'
3
2
 
4
- import { isRepo, show } from '../args.js'
5
- import { readRecord, writeRecord } from '../cache.js'
6
- import { CRATES_API, PACKAGIST_REPO, buildUrl, isNotFound, recover, request } from '../http.js'
3
+ import { show } from '../args.js'
4
+ import { addRepos, readRepos } from '../cache.js'
5
+ import { lookUpCrateRepos } from '../cargo/repos.js'
6
+ import { PACKAGIST_REPO, SOLDEER_API, buildUrl, isNotFound, recover, request } from '../http.js'
7
7
  import { lookUpPackageRepo } from '../npm/repos.js'
8
- import { chunks, pool } from '../pool.js'
8
+ import { pool } from '../pool.js'
9
9
  import { githubRepoOfUrl } from '../remote.js'
10
10
 
11
- const CRATES_PER_REQUEST = 100
12
- const CRATES_PACE_MS = 1000 // crates.io asks for a request a second at most, and a user agent.
13
- const USER_AGENT = '@preventive/upstream (https://github.com/PreventiveMeasures/libraries)'
14
11
  const PACKAGES_AT_ONCE = 8
15
12
 
16
13
  // Name → its GitHub repo, or null where the registry has no such package
17
14
  // or it names no GitHub repo; any other failure throws. Only a repo found
18
15
  // is cached, for a month.
19
16
  async function cachedRepos(dir, names, fetchMissing) {
20
- const repos = new Map()
21
- for (const name of names) {
22
- const entry = await readRecord(dir, name)
23
- if (isRepo(entry?.github)) repos.set(name, entry.github)
24
- }
25
- const missing = names.filter((name) => !repos.has(name))
26
- for (const [name, github] of await fetchMissing(missing)) {
27
- repos.set(name, github)
28
- if (github) await writeRecord(dir, name, { github })
29
- }
17
+ const repos = await readRepos(dir, names)
18
+ await addRepos(dir, repos, await fetchMissing(names.filter((name) => !repos.has(name))))
30
19
  return repos
31
20
  }
32
21
 
33
- // A crate crates.io leaves out of its answer is one it does not have.
34
- async function fetchCrates(names) {
35
- const found = new Map(names.map((name) => [name, null]))
36
- for (const [i, chunk] of chunks(names, CRATES_PER_REQUEST).entries()) {
37
- if (i > 0) await sleep(CRATES_PACE_MS)
38
- const url = buildUrl(CRATES_API, ['api', 'v1', 'crates'], { 'ids[]': chunk, per_page: CRATES_PER_REQUEST })
39
- const answer = await request(url, { as: 'json', headers: { 'User-Agent': USER_AGENT } })
40
- assert.ok(Array.isArray(answer?.crates), 'advisories: expected a list of crates from crates.io')
41
- for (const crate of answer.crates) {
42
- assert.ok(chunk.includes(crate?.id), `advisories: crates.io answered for ${show(crate?.id)}, which was not asked`)
43
- found.set(crate.id, githubRepoOfUrl(crate.repository) ?? null)
44
- }
45
- }
46
- return found
47
- }
48
-
49
22
  async function fetchEach(names, fetch) {
50
23
  const found = await pool(names, PACKAGES_AT_ONCE, fetch)
51
24
  return new Map(names.map((name, i) => [name, found[i]]))
@@ -61,6 +34,15 @@ async function fetchComposerRepo(name) {
61
34
  return githubRepoOfUrl(versions[0]?.source?.url) ?? null
62
35
  }
63
36
 
64
- export const crateRepos = (names) => cachedRepos('cargo/repos', names, fetchCrates)
37
+ // Soldeer answers a project it does not have with an empty list.
38
+ async function fetchSoldeerRepo(name) {
39
+ const answer = await request(buildUrl(SOLDEER_API, ['api', 'v1', 'project'], { project_name: name }), { as: 'json' })
40
+ assert.ok(Array.isArray(answer?.data), `advisories: expected a list of projects from Soldeer for ${name}`)
41
+ for (const project of answer.data) assert.ok(project?.name === name, `advisories: Soldeer answered for ${show(project?.name)}, not ${name}`)
42
+ return githubRepoOfUrl(answer.data[0]?.github_url) ?? null
43
+ }
44
+
45
+ export const crateRepos = (names) => lookUpCrateRepos('advisories', names)
65
46
  export const composerRepos = (names) => cachedRepos('composer/repos', names, (missing) => fetchEach(missing, fetchComposerRepo))
66
47
  export const npmRepos = (names) => fetchEach(names, async (name) => (await lookUpPackageRepo(name))?.github ?? null)
48
+ export const soldeerRepos = (names) => cachedRepos('soldeer/repos', names, (missing) => fetchEach(missing, fetchSoldeerRepo))
package/src/args.js CHANGED
@@ -15,7 +15,9 @@ const isLogin = matches(/^(?=.{1,39}$)[a-z\d](?:-?[a-z\d])*$/iu)
15
15
  const isRepoName = matches(/^(?!\.\.?$)[\w.-]{1,100}$/u)
16
16
  // `.git` is never in a repo's tree, and no commit should write into it.
17
17
  export const isRepoPath = (value) => isString(value, 4096) && value.split('/').every((part) => !['', '.', '..', '.git'].includes(part.toLowerCase()))
18
- const BAD_REF = /^$|^@$|^-|[ ~^:?*[\\]|\.\.|@\{|^\/|\/$|\/\/|(?:^|\/)\.|\.lock(?:\/|$)|\.$/u // git check-ref-format, a rule per alternative
18
+ // git check-ref-format, a rule per alternative. A tag's name may be `@` or
19
+ // start with `-`; a branch's may not.
20
+ const BAD_REF = /^$|[ ~^:?*[\\]|\.\.|@\{|^\/|\/$|\/\/|(?:^|\/)\.|\.lock(?:\/|$)|\.$/u
19
21
  const isToken = matches(/^[!-~]+$/u)
20
22
  // npm's rules for existing names: capitals allowed (JSONStream), the
21
23
  // legacy `~'!()*` not.
@@ -37,8 +39,10 @@ export function isRepo(value) {
37
39
  }
38
40
 
39
41
  export const isStrings = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string')
42
+ export const isIterable = (value) => typeof value?.[Symbol.iterator] === 'function' && typeof value !== 'string'
40
43
  export const sameName = (a, b) => typeof a === 'string' && a.toLowerCase() === b.toLowerCase()
41
- export const isRefName = (value) => isString(value, 255) && !BAD_REF.test(value)
44
+ export const isTagName = (value) => isString(value, 255) && !BAD_REF.test(value)
45
+ export const isRefName = (value) => isTagName(value) && !/^(?:@$|-)/u.test(value)
42
46
  export const isSha = matches(/^(?:[\da-f]{40}|[\da-f]{64})$/u)
43
47
  export const isGhsa = matches(/^GHSA(?:-[\da-hj-km-np-tv-z]{4}){3}$/u)
44
48
  export const assertion = (must, predicate) => (method, what, value) => assert.ok(predicate(value), `${method}: ${what} must be ${must}, got ${show(value)}`)
@@ -52,6 +56,16 @@ export function assertArgs(method, options, spec, name) {
52
56
  for (const [key, check] of Object.entries(spec)) check?.(method, label(key), options[key])
53
57
  }
54
58
 
59
+ // The names a resolve*Repos call is given, each checked and given once,
60
+ // and its options.
61
+ export function assertNames(method, what, names, options, assertName) {
62
+ assert.ok(isIterable(names), `${method}: ${what} must be an iterable of names`)
63
+ assertArgs(method, options, { cachedOnly: optional(assertBoolean) })
64
+ const unique = [...new Set(names)]
65
+ for (const name of unique) assertName(method, 'name', name)
66
+ return unique
67
+ }
68
+
55
69
  export const assertBoolean = assertion('a boolean', (value) => typeof value === 'boolean')
56
70
  export const assertNumber = assertion('a positive integer', (value) => Number.isSafeInteger(value) && value > 0)
57
71
  export const assertLine = assertion('a non-empty single line', (value) => isString(value, 1024) && value.trim() !== '')
@@ -62,6 +76,7 @@ export const assertLogin = assertion('a GitHub login', isLogin)
62
76
  export const assertRepoName = assertion('a repository name', isRepoName)
63
77
  export const assertRepo = assertion('"owner/name"', isRepo)
64
78
  export const assertRef = assertion('a branch or tag name', isRefName)
79
+ export const assertTagName = assertion('a tag name', isTagName)
65
80
  export const assertSha = assertion('a full commit sha', isSha)
66
81
  export const isSha1 = matches(/^[\da-f]{40}$/u)
67
82
  export const assertTreeId = assertion('a full tree id', isSha1)
@@ -74,5 +89,19 @@ export const assertPackageVersion = assertion('an exact semver version', isExact
74
89
  export const assertGhsa = assertion('a GHSA id', isGhsa)
75
90
  export const assertCrateName = assertion('a crate name', matches(/^[A-Za-z][\w-]{0,63}$/u))
76
91
  export const assertCrateVersion = assertion('a semver version', matches(/^(?=.{5,256}$)(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*))*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u))
92
+ // Soldeer's own rule for a name: 3 to 100 of lowercase letters, digits and
93
+ // `-`, starting with one of them or `@`, ending with a letter or digit.
94
+ export const assertSoldeerName = assertion('a Soldeer package name', matches(/^(?=.{3,100}$)[@\da-z][\da-z-]*[\da-z]$/u))
95
+ // Soldeer takes any version but an empty one. Its registry has semver
96
+ // (`5.7.0-rc.0`, `1.0.2-solc-0.8-simulate`), bare numbers and commit hashes.
97
+ export const assertSoldeerVersion = assertion('letters, digits, `.`, `_`, `+` and `-`', matches(/^(?=.{1,128}$)[\dA-Za-z][\w.+-]*$/u))
98
+ // A package's name and version, as a registry's getters take them.
99
+ const assertNameVersion = (assertName, assertVersion) => (method, name, version) => {
100
+ assertName(method, 'name', name)
101
+ assertVersion(method, 'version', version)
102
+ }
103
+ export const assertCrate = assertNameVersion(assertCrateName, assertCrateVersion)
104
+ export const assertPackage = assertNameVersion(assertPackageName, assertPackageVersion)
105
+ export const assertSoldeerPackage = assertNameVersion(assertSoldeerName, assertSoldeerVersion)
77
106
  // As Cargo.lock and soldeer.lock write a checksum.
78
107
  export const assertSha256 = assertion('a sha256 in lowercase hex', matches(/^[\da-f]{64}$/u))
package/src/cache.js CHANGED
@@ -3,10 +3,10 @@ import { constants } from 'node:fs'
3
3
  import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises'
4
4
  import { dirname, join, resolve } from 'node:path'
5
5
 
6
- import { assertDirectoryPath } from './args.js'
6
+ import { assertDirectoryPath, isRepo } from './args.js'
7
7
  import { MAX_BYTES, decode } from './http.js'
8
8
 
9
- const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/zips', 'github/trees'])
9
+ const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees'])
10
10
  const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
11
11
 
12
12
  let root
@@ -93,3 +93,21 @@ export async function readRecord(dir, name) {
93
93
  }
94
94
 
95
95
  export const writeRecord = (dir, name, value) => writeCacheJSON(dir, `${name}.json`, { at: Date.now(), name, ...value })
96
+
97
+ // Name → the GitHub repo cached for it, for those of `names` that have one.
98
+ export async function readRepos(dir, names) {
99
+ const repos = new Map()
100
+ for (const name of names) {
101
+ const entry = await readRecord(dir, name)
102
+ if (isRepo(entry?.github)) repos.set(name, entry.github)
103
+ }
104
+ return repos
105
+ }
106
+
107
+ // An answer into `repos`, and into the cache where it found a repo.
108
+ export async function addRepos(dir, repos, answer) {
109
+ for (const [name, github] of answer) {
110
+ repos.set(name, github)
111
+ if (github) await writeRecord(dir, name, { github })
112
+ }
113
+ }
@@ -3,16 +3,16 @@ import { readdir } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
5
5
 
6
- import { assertCrateName, assertCrateVersion, assertSha256, printable, show } from './args.js'
7
- import { verifiedDownload } from './download.js'
8
- import { CRATES_INDEX, CRATES_STATIC, buildUrl, encodeSegment, request } from './http.js'
6
+ import { assertCrate, assertSha256, printable, show } from '../args.js'
7
+ import { verifiedDownload } from '../download.js'
8
+ import { CRATES_INDEX, CRATES_STATIC, buildUrl, encodeSegment, request } from '../http.js'
9
9
 
10
10
  const DIR = 'cargo/crates' // No expiry: crates.io never takes a version twice.
11
11
 
12
12
  // The sparse index, what cargo reads: a file per crate, by its lowercased
13
13
  // name under 1/, 2/, 3/<first letter>/ or <1st-2nd>/<3rd-4th>/, with a
14
14
  // JSON line per version, `cksum` the .crate's sha256.
15
- async function getChecksum(name, version) {
15
+ async function getChecksum(method, name, version) {
16
16
  const lower = name.toLowerCase()
17
17
  const prefix = lower.length < 3 ? [String(lower.length)] : lower.length === 3 ? ['3', lower[0]] : [lower.slice(0, 2), lower.slice(2, 4)]
18
18
  const text = await request(buildUrl(CRATES_INDEX, [...prefix, lower]), { as: 'text' })
@@ -20,13 +20,13 @@ async function getChecksum(name, version) {
20
20
  try {
21
21
  return JSON.parse(line)
22
22
  } catch {
23
- assert.fail(`getCrate: the index for ${name} has a line that is not JSON: ${printable(line.slice(0, 200))}`)
23
+ assert.fail(`${method}: the index for ${name} has a line that is not JSON: ${printable(line.slice(0, 200))}`)
24
24
  }
25
25
  }
26
26
  const entry = text.split('\n').filter(Boolean).map(parse).find((candidate) => candidate?.vers === version)
27
- assert.ok(entry, `getCrate: the index has no ${name}@${version}`)
28
- assert.ok(entry.name === name, `getCrate: the index answered for ${show(entry.name)}, not ${name}`)
29
- assertSha256('getCrate', 'the index cksum', entry.cksum)
27
+ assert.ok(entry, `${method}: the index has no ${name}@${version}`)
28
+ assert.ok(entry.name === name, `${method}: the index answered for ${show(entry.name)}, not ${name}`)
29
+ assertSha256(method, 'the index cksum', entry.cksum)
30
30
  return entry.cksum
31
31
  }
32
32
 
@@ -37,11 +37,17 @@ async function localPaths(name, version) {
37
37
  return (await readdir(root).catch(() => [])).map((registry) => join(root, registry, `${name}-${version}.crate`))
38
38
  }
39
39
 
40
+ export async function verifyChecksum(name, version, checksum) {
41
+ assertCrate('verifyChecksum', name, version)
42
+ assertSha256('verifyChecksum', 'checksum', checksum)
43
+ const cksum = await getChecksum('verifyChecksum', name, version)
44
+ assert.ok(cksum === checksum, `verifyChecksum: ${name}@${version} is ${cksum} on crates.io, not ${checksum}`)
45
+ }
46
+
40
47
  export async function getCrate(name, version, checksum) {
41
- assertCrateName('getCrate', 'name', name)
42
- assertCrateVersion('getCrate', 'version', version)
48
+ assertCrate('getCrate', name, version)
43
49
  if (checksum !== undefined) assertSha256('getCrate', 'checksum', checksum)
44
- const expected = checksum ?? await getChecksum(name, version)
50
+ const expected = checksum ?? await getChecksum('getCrate', name, version)
45
51
  const locate = () => buildUrl(CRATES_STATIC, ['crates', name, `${name}-${encodeSegment(version)}.crate`])
46
52
  return await verifiedDownload({ method: 'getCrate', dir: DIR, what: `${name}@${version}`, ext: 'crate', algorithm: 'sha256', expected, local: await localPaths(name, version), locate })
47
53
  }
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict'
2
+ import { setTimeout as sleep } from 'node:timers/promises'
3
+
4
+ import { assertCrateName, assertNames, show } from '../args.js'
5
+ import { addRepos, readRepos } from '../cache.js'
6
+ import { CRATES_API, buildUrl, request } from '../http.js'
7
+ import { chunks } from '../pool.js'
8
+ import { githubRepoOfUrl } from '../remote.js'
9
+
10
+ const DIR = 'cargo/repos'
11
+ const PER_REQUEST = 100
12
+ const PACE_MS = 1000 // crates.io asks for a request a second at most, and a user agent.
13
+ const USER_AGENT = '@preventive/upstream (https://github.com/PreventiveMeasures/libraries)'
14
+
15
+ // One request for up to a hundred crates: each name → the GitHub repo its
16
+ // `repository` names, or null where it names none. A crate crates.io
17
+ // leaves out of its answer is one it does not have, null too.
18
+ async function askCrates(method, names) {
19
+ const url = buildUrl(CRATES_API, ['api', 'v1', 'crates'], { 'ids[]': names, per_page: PER_REQUEST })
20
+ const answer = await request(url, { as: 'json', headers: { 'User-Agent': USER_AGENT } })
21
+ assert.ok(Array.isArray(answer?.crates), `${method}: expected a list of crates from crates.io`)
22
+ const found = new Map(names.map((name) => [name, null]))
23
+ for (const crate of answer.crates) {
24
+ assert.ok(names.includes(crate?.id), `${method}: crates.io answered for ${show(crate?.id)}, which was not asked`)
25
+ found.set(crate.id, githubRepoOfUrl(crate.repository) ?? null)
26
+ }
27
+ return found
28
+ }
29
+
30
+ // Name → its GitHub repo, or null where crates.io has no such crate or it
31
+ // names no GitHub repo, through the cache: only a repo found is cached,
32
+ // for a month. A failed request throws, or with `soft` leaves its names
33
+ // out; `cachedOnly` asks nothing.
34
+ export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly = false } = {}) {
35
+ const repos = await readRepos(DIR, names)
36
+ if (cachedOnly) return repos
37
+ for (const [i, chunk] of chunks(names.filter((name) => !repos.has(name)), PER_REQUEST).entries()) {
38
+ if (i > 0) await sleep(PACE_MS)
39
+ const answer = await askCrates(method, chunk).catch((error) => {
40
+ if (soft) return new Map()
41
+ throw error
42
+ })
43
+ await addRepos(DIR, repos, answer)
44
+ }
45
+ return repos
46
+ }
47
+
48
+ export async function resolveCrateRepos(crateNames, options = {}) {
49
+ const names = assertNames('resolveCrateRepos', 'crateNames', crateNames, options, assertCrateName)
50
+ const repos = await lookUpCrateRepos('resolveCrateRepos', names, { soft: true, cachedOnly: options.cachedOnly })
51
+ return new Map(names.flatMap((name) => (repos.get(name) ? [[name, { github: repos.get(name) }]] : [])))
52
+ }
@@ -1,11 +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, assertTreeId, isSha, isSha1, optional, sameName, show } from '../args.js'
4
+ import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
6
5
  import { verifiedDownload } from '../download.js'
7
- import { decode, encodeSegment } from '../http.js'
8
- import { gitTreeOfListing } from '../tree.js'
6
+ import { decode, encodeSegment, requestWithHeaders } from '../http.js'
7
+ import { gitTreeOfListing, objectId } from '../tree.js'
9
8
  import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
10
9
 
11
10
  const PER_PAGE = 100
@@ -22,6 +21,24 @@ async function* pages(method, headers, pageUrl, maxPages = MAX_PAGES) {
22
21
  }
23
22
  }
24
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
+
25
42
  const paginate = async (...args) => (await Array.fromAsync(pages(...args))).flat()
26
43
  const getCurrentUser = (headers) => call(headers, api(['user']))
27
44
 
@@ -48,6 +65,32 @@ export async function getRepoHead(headers, options) {
48
65
  return { branch: ref, oid: data.object.sha }
49
66
  }
50
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
+
51
94
  // GitHub's object for the path first: a directory, symlink or submodule is
52
95
  // refused rather than read as a file's text. Up to 1 MB the object holds
53
96
  // the content; past it, `encoding: none`, and the blob it names is read,
@@ -63,7 +106,7 @@ async function getRepoFile(headers, options) {
63
106
  const bytes = file.encoding === 'none'
64
107
  ? await call({ ...headers, Accept: 'application/vnd.github.raw' }, repoApi(repo, ['git', 'blobs', file.sha]), { as: 'bytes' })
65
108
  : Buffer.from(file.content, 'base64')
66
- const blob = createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
109
+ const blob = objectId('blob', bytes).toString('hex')
67
110
  assert.ok(bytes.length === file.size && blob === file.sha, `getRepoFile: ${show(path)} came back as blob ${blob}, not ${file.sha}`)
68
111
  return decode(bytes, url)
69
112
  }
@@ -113,12 +156,13 @@ async function listTree(method, headers, repo, tree) {
113
156
  }
114
157
 
115
158
  async function treeAt(method, headers, options) {
116
- assertArgs(method, options, { repo: assertRepo, sha: assertSha, path: optional(assertPath) })
117
- const { repo, sha, path } = 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
118
162
  let tree = await commitTree(method, headers, repo, sha)
119
- for (const name of path?.split('/') ?? []) {
163
+ for (const name of directory?.split('/') ?? []) {
120
164
  const entry = (await listTree(method, headers, repo, tree)).find((candidate) => candidate.path === name)
121
- assert.ok(entry?.type === 'tree', `${method}: ${repo}@${sha} has no directory at ${show(path)}`)
165
+ assert.ok(entry?.type === 'tree', `${method}: ${repo}@${sha} has no directory at ${show(directory)}`)
122
166
  tree = entry.sha
123
167
  }
124
168
  return tree
@@ -168,15 +212,12 @@ async function getAdvisory(headers, options) {
168
212
  return advisory
169
213
  }
170
214
 
171
- // One page: GitHub pages this list by a cursor in the Link header, which
172
- // `call` does not read, so a full page is refused rather than cut short.
173
215
  async function listRepoAdvisories(headers, options) {
174
216
  assertArgs('listRepoAdvisories', options, { repo: assertRepo })
175
- const list = await call(headers, repoApi(options.repo, ['security-advisories'], { state: 'published', per_page: PER_PAGE }))
176
- assert.ok(Array.isArray(list), `listRepoAdvisories: expected an array for ${options.repo}`)
177
- assert.ok(list.length < PER_PAGE, `listRepoAdvisories: ${options.repo} has ${PER_PAGE} or more published advisories, more than a page`)
178
- 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()
179
220
  }
180
221
 
181
- export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
222
+ export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
182
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 request(url, options) {
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
- const bytes = await readBody(res, LIMITS[options.as].bytes)
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
package/src/npm/repos.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertArgs, assertBoolean, assertPackageName, assertRepo, isRepo, optional, show } from '../args.js'
3
+ import { assertNames, assertPackageName, assertRepo, isRepo, show } from '../args.js'
4
4
  import { readRecord, writeRecord } from '../cache.js'
5
5
  import { NPM_REGISTRY, buildUrl, isNotFound, recover, request } from '../http.js'
6
6
  import { assertRepoDirectory, getRepo, isRepoDirectory } from '../package.js'
@@ -42,10 +42,7 @@ export async function writePackageRepoCache(name, github, directory = '') {
42
42
  }
43
43
 
44
44
  export async function resolvePackageRepos(packageNames, options = {}) {
45
- assert.ok(typeof packageNames?.[Symbol.iterator] === 'function' && typeof packageNames !== 'string', 'resolvePackageRepos: packageNames must be an iterable of names')
46
- assertArgs('resolvePackageRepos', options, { cachedOnly: optional(assertBoolean) })
47
- const names = [...new Set(packageNames)]
48
- for (const name of names) assertPackageName('resolvePackageRepos', 'name', name)
45
+ const names = assertNames('resolvePackageRepos', 'packageNames', packageNames, options, assertPackageName)
49
46
  const found = await pool(names, CONCURRENCY, (name) => (options.cachedOnly ? readPackageRepoCache(name) : lookUpPackageRepo(name)).catch(() => null))
50
47
  return new Map(names.flatMap((name, i) => (found[i] ? [[name, found[i]]] : [])))
51
48
  }
@@ -3,7 +3,7 @@ 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, assertPackageName, assertPackageVersion, assertion, matches, show } from '../args.js'
6
+ import { assertArgs, assertPackage, assertion, matches, show } from '../args.js'
7
7
  import { verifiedDownload } from '../download.js'
8
8
  import { NPM_REGISTRY, buildUrl, request } from '../http.js'
9
9
 
@@ -13,11 +13,6 @@ const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twic
13
13
  // last character before `==` two bits, so only A, Q, g or w is canonical.
14
14
  const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
15
15
 
16
- function assertPackage(method, name, version) {
17
- assertPackageName(method, 'name', name)
18
- assertPackageVersion(method, 'version', version)
19
- }
20
-
21
16
  // `{ tarball, integrity }` and nothing else, the tarball exactly the
22
17
  // registry's own URL for that version. Each field is read once, and only
23
18
  // as the object's own, into a copy that is checked and used from then on:
@@ -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
+ }