@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.
- package/LICENSE-APACHE +202 -0
- package/advisories.d.ts +27 -16
- package/cargo.d.ts +18 -1
- package/cargo.js +2 -1
- package/github.d.ts +21 -11
- package/package.json +15 -3
- package/semver.d.ts +2 -1
- package/src/advisories/common.js +8 -4
- package/src/advisories/github.js +24 -8
- package/src/advisories/index.js +5 -5
- package/src/advisories/osv.js +25 -11
- package/src/advisories/repos.js +17 -35
- package/src/args.js +31 -2
- package/src/cache.js +20 -2
- package/src/{cargo.js → cargo/crate.js} +17 -11
- package/src/cargo/repos.js +52 -0
- package/src/github/read.js +57 -16
- package/src/http.js +16 -11
- package/src/npm/repos.js +2 -5
- package/src/npm/tarball.js +1 -6
- package/src/semver-peer.cjs +8 -0
- package/src/semver.js +26 -12
- package/src/soldeer.js +2 -9
- package/src/tree.js +7 -6
- /package/{LICENSE → LICENSE-MIT} +0 -0
package/src/advisories/osv.js
CHANGED
|
@@ -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.
|
|
74
|
-
// publishes comes back under
|
|
75
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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))
|
package/src/advisories/repos.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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 {
|
|
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 =
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
7
|
-
import { verifiedDownload } from '
|
|
8
|
-
import { CRATES_INDEX, CRATES_STATIC, buildUrl, encodeSegment, request } from '
|
|
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(
|
|
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,
|
|
28
|
-
assert.ok(entry.name === name,
|
|
29
|
-
assertSha256(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/github/read.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
116
|
+
// rewrite; answered with the response's headers.
|
|
117
|
+
export async function requestWithHeaders(url, options) {
|
|
107
118
|
const res = await send(url, options)
|
|
108
119
|
if (!res.ok) {
|
|
109
120
|
// A stream decode holds back a character cut at the limit rather than refusing it.
|
|
110
121
|
const text = await readBody(res, 4096, { truncate: true }).then((bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })).catch(() => '')
|
|
111
122
|
throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`)
|
|
112
123
|
}
|
|
113
|
-
|
|
114
|
-
if (options.as === 'bytes') return bytes
|
|
115
|
-
const text = decode(bytes, url)
|
|
116
|
-
if (options.as === 'text') return text
|
|
117
|
-
try {
|
|
118
|
-
return JSON.parse(text)
|
|
119
|
-
} catch (cause) {
|
|
120
|
-
throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause })
|
|
121
|
-
}
|
|
124
|
+
return { body: parse(await readBody(res, LIMITS[options.as].bytes), url, options.as), headers: res.headers }
|
|
122
125
|
}
|
|
126
|
+
|
|
127
|
+
export const request = async (url, options) => (await requestWithHeaders(url, options)).body
|
package/src/npm/repos.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
package/src/npm/tarball.js
CHANGED
|
@@ -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,
|
|
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
|
+
}
|