@preventive/upstream 1.0.0-alpha.9 → 1.0.1
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/advisories.d.ts +21 -10
- package/cargo.d.ts +0 -2
- package/git.d.ts +2 -4
- package/github/write.d.ts +1 -2
- package/github.d.ts +63 -9
- package/npm.d.ts +120 -25
- package/npm.js +1 -1
- package/package.d.ts +5 -4
- package/package.json +4 -1
- package/semver.d.ts +0 -2
- package/soldeer.d.ts +0 -3
- package/src/advisories/github.js +87 -26
- package/src/advisories/index.js +4 -3
- package/src/advisories/osv.js +3 -3
- package/src/advisories/repos.js +21 -10
- package/src/args.js +1 -1
- package/src/cache.js +77 -24
- package/src/cargo/repos.js +8 -8
- package/src/download.js +8 -7
- package/src/git.js +1 -1
- package/src/github/read.js +91 -6
- package/src/http.js +4 -2
- package/src/lockfiles.js +113 -0
- package/src/npm/cacache.js +44 -0
- package/src/npm/registry.js +10 -4
- package/src/npm/repos.js +25 -13
- package/src/npm/tarball.js +99 -37
- package/src/npm/versions.js +97 -0
- package/src/package.js +24 -7
- package/src/remote.js +10 -3
package/src/advisories/github.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
3
|
import { assertRepo, assertSoldeerName, assertSoldeerVersion, assertion, isGhsa, isRefName, isStrings } from '../args.js'
|
|
4
|
+
import { readRecord, writeRecord } from '../cache.js'
|
|
4
5
|
import { isGone } from '../github/client.js'
|
|
5
6
|
import { recover } from '../http.js'
|
|
6
7
|
import { pool } from '../pool.js'
|
|
8
|
+
import { isExactVersion } from '../semver.js'
|
|
7
9
|
import { advisoryUrl, covered, detailsOf, isText, mayBeInRange, metrics } from './common.js'
|
|
8
10
|
import { soldeerRepos } from './repos.js'
|
|
9
11
|
|
|
10
12
|
const REPOS_AT_ONCE = 4
|
|
13
|
+
const DIR = 'github/advisories'
|
|
14
|
+
// A maintainer publishes one whenever they are ready, and seeing it before
|
|
15
|
+
// GitHub reviews it is what a repository is asked for: 90 minutes, not a month.
|
|
16
|
+
const LISTING_TTL_MS = 90 * 60 * 1000
|
|
17
|
+
// Stamped on each entry, and raised when a listing is kept differently.
|
|
18
|
+
const VERSION = 2
|
|
11
19
|
|
|
12
20
|
const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' && isGhsa(advisory.ghsa_id)
|
|
13
21
|
&& advisory.state === 'published' && isText(advisory.summary)
|
|
@@ -15,7 +23,63 @@ const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' &&
|
|
|
15
23
|
|
|
16
24
|
export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function')
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
// A maintainer may give a vulnerable range only its lower bound, and the
|
|
27
|
+
// version that fixed it apart (`>= 5.0.0-beta.1`, patched in `5.0.0-rc.2`),
|
|
28
|
+
// as GitHub's page shows them side by side: such a range ends below its one
|
|
29
|
+
// patched version. A range bounded above, or patched in more than one
|
|
30
|
+
// version, is as given.
|
|
31
|
+
function boundedRange(range, patched) {
|
|
32
|
+
const fixed = typeof patched === 'string' ? patched.trim() : null
|
|
33
|
+
if (!isExactVersion(fixed) || range.includes('<')) return range
|
|
34
|
+
return range.trim() === '' ? `< ${fixed}` : `${range.trim()}, < ${fixed}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// What rows are made from, and all that is kept of a listing: each advisory
|
|
38
|
+
// not withdrawn, with its CVSS vector GitHub prefers, and each vulnerable
|
|
39
|
+
// range with the package it names, ended below its patched version where it
|
|
40
|
+
// has no end of its own (boundedRange). A listing that is malformed is
|
|
41
|
+
// refused whole, and never kept; one whose only fault is a description
|
|
42
|
+
// (isDigest) is answered, but not kept either.
|
|
43
|
+
function digest(repo, list) {
|
|
44
|
+
return list.flatMap((advisory) => {
|
|
45
|
+
assert.ok(isRepoAdvisory(advisory), `advisories: malformed advisory from ${repo}`)
|
|
46
|
+
if (advisory.withdrawn_at) return []
|
|
47
|
+
const cvss = [advisory.cvss_severities?.cvss_v3, advisory.cvss_severities?.cvss_v4, advisory.cvss].find((entry) => typeof entry?.vector_string === 'string')
|
|
48
|
+
const ranges = (advisory.vulnerabilities ?? []).map((vulnerability) => {
|
|
49
|
+
const range = vulnerability?.vulnerable_version_range ?? ''
|
|
50
|
+
assert.ok(typeof range === 'string', `advisories: malformed range in ${advisory.ghsa_id}`)
|
|
51
|
+
const { ecosystem, name } = vulnerability?.package ?? {}
|
|
52
|
+
return { range: boundedRange(range, vulnerability?.patched_versions), ...(typeof ecosystem === 'string' && { ecosystem }), ...(typeof name === 'string' && { name }) }
|
|
53
|
+
})
|
|
54
|
+
return [{
|
|
55
|
+
ghsa: advisory.ghsa_id,
|
|
56
|
+
title: advisory.summary,
|
|
57
|
+
...(advisory.description != null && advisory.description !== '' && { description: advisory.description }),
|
|
58
|
+
...metrics({ severity: advisory.severity, score: cvss?.score, vector: cvss?.vector_string, cwe: advisory.cwe_ids }),
|
|
59
|
+
ranges,
|
|
60
|
+
}]
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A description is checked only for `details` (detailsOf), so a malformed
|
|
65
|
+
// one leaves the listing usable, but not kept.
|
|
66
|
+
const isDigest = (advisory) => advisory && typeof advisory === 'object' && isGhsa(advisory.ghsa) && isText(advisory.title)
|
|
67
|
+
&& (advisory.description === undefined || isText(advisory.description))
|
|
68
|
+
&& Array.isArray(advisory.ranges) && advisory.ranges.every((entry) => typeof entry?.range === 'string')
|
|
69
|
+
|
|
70
|
+
// A repository's listing, digested, through the cache: GitHub's names are
|
|
71
|
+
// case-insensitive, so one entry answers every spelling. A repository gone,
|
|
72
|
+
// renamed or blocked has none, and that is not kept.
|
|
73
|
+
async function listAdvisories(github, repo, cache) {
|
|
74
|
+
const name = repo.toLowerCase()
|
|
75
|
+
const entry = await readRecord(DIR, name, cache, LISTING_TTL_MS)
|
|
76
|
+
if (entry?.v === VERSION && Array.isArray(entry.advisories) && entry.advisories.every(isDigest)) return entry.advisories
|
|
77
|
+
const list = await github.listRepoAdvisories({ repo }).catch(recover(isGone, null))
|
|
78
|
+
if (list === null) return []
|
|
79
|
+
const advisories = digest(repo, list)
|
|
80
|
+
if (advisories.every(isDigest)) await writeRecord(DIR, name, { v: VERSION, advisories }, cache)
|
|
81
|
+
return advisories
|
|
82
|
+
}
|
|
19
83
|
|
|
20
84
|
// GitHub's `>= 1.0.0, < 1.2.6` is npm's with the commas dropped.
|
|
21
85
|
// Maintainers write these unreviewed: one without a range, or with one
|
|
@@ -23,17 +87,16 @@ const listAdvisories = (github, repo) => github.listRepoAdvisories({ repo }).cat
|
|
|
23
87
|
// may publish it long before the advisory database has one, and its
|
|
24
88
|
// `description` is the text `details` asks for: the listing has it all.
|
|
25
89
|
function fromRepository(repo, name, advisory, range, asked, { covers, details }) {
|
|
26
|
-
const cvss = [advisory.cvss_severities?.cvss_v3, advisory.cvss_severities?.cvss_v4, advisory.cvss].find((entry) => typeof entry?.vector_string === 'string')
|
|
27
90
|
return {
|
|
28
91
|
name,
|
|
29
92
|
source: 'repository',
|
|
30
|
-
id: advisory.
|
|
31
|
-
ghsa: advisory.
|
|
32
|
-
url: advisoryUrl(advisory.
|
|
93
|
+
id: advisory.ghsa,
|
|
94
|
+
ghsa: advisory.ghsa,
|
|
95
|
+
url: advisoryUrl(advisory.ghsa, repo),
|
|
33
96
|
aliases: [],
|
|
34
|
-
title: advisory.
|
|
35
|
-
...(details && detailsOf(advisory.description, advisory.
|
|
36
|
-
...metrics({ severity: advisory.severity, score: cvss
|
|
97
|
+
title: advisory.title,
|
|
98
|
+
...(details && detailsOf(advisory.description, advisory.ghsa)),
|
|
99
|
+
...metrics({ severity: advisory.severity, score: advisory.cvss, vector: advisory.cvssVector, cwe: advisory.cwe }),
|
|
37
100
|
range,
|
|
38
101
|
versions: covered(asked, range.replaceAll(',', ' '), covers),
|
|
39
102
|
}
|
|
@@ -41,21 +104,18 @@ function fromRepository(repo, name, advisory, range, asked, { covers, details })
|
|
|
41
104
|
|
|
42
105
|
// Each asked name's repository, `repoOf` it, is asked once, and its
|
|
43
106
|
// advisories' vulnerable ranges become rows for the names that `takes` a
|
|
44
|
-
//
|
|
107
|
+
// range's package, one per name, advisory and range, holding the asked
|
|
45
108
|
// versions it `covers`, and with `details`, its text.
|
|
46
|
-
async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers, details }) {
|
|
109
|
+
async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers, details, cache }) {
|
|
47
110
|
// GitHub's names are case-insensitive: one spelling asks for all.
|
|
48
111
|
const namesOf = Map.groupBy([...asked.keys()].filter(repoOf), (name) => repoOf(name).toLowerCase())
|
|
49
|
-
const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ repo: repoOf(names[0]), names, list: await listAdvisories(github, repoOf(names[0])) }))
|
|
112
|
+
const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ repo: repoOf(names[0]), names, list: await listAdvisories(github, repoOf(names[0]), cache) }))
|
|
50
113
|
const rows = new Map()
|
|
51
114
|
for (const { repo, names, list } of listed) {
|
|
52
115
|
for (const advisory of list) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
assert.ok(typeof range === 'string', `advisories: malformed range in ${advisory.ghsa_id}`)
|
|
57
|
-
for (const name of names.filter((candidate) => takes(candidate, vulnerability?.package))) {
|
|
58
|
-
rows.set(`${name} ${advisory.ghsa_id} ${range}`, fromRepository(repo, name, advisory, range, asked.get(name), { covers, details }))
|
|
116
|
+
for (const { range, ...pkg } of advisory.ranges) {
|
|
117
|
+
for (const name of names.filter((candidate) => takes(candidate, pkg))) {
|
|
118
|
+
rows.set(`${name} ${advisory.ghsa} ${range}`, fromRepository(repo, name, advisory, range, asked.get(name), { covers, details }))
|
|
59
119
|
}
|
|
60
120
|
}
|
|
61
121
|
}
|
|
@@ -63,9 +123,10 @@ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true,
|
|
|
63
123
|
return [...rows.values()]
|
|
64
124
|
}
|
|
65
125
|
|
|
66
|
-
// Each asked name's repository: as given, `known`, or else looked up
|
|
67
|
-
|
|
68
|
-
|
|
126
|
+
// Each asked name's repository: as given, `known`, or else looked up,
|
|
127
|
+
// through `cache`.
|
|
128
|
+
async function reposOf(asked, known, lookUp, cache) {
|
|
129
|
+
const found = await lookUp([...asked.keys()].filter((name) => !known.has(name)), { asked, cache })
|
|
69
130
|
return (name) => known.get(name) ?? found.get(name)
|
|
70
131
|
}
|
|
71
132
|
|
|
@@ -74,12 +135,12 @@ async function reposOf(asked, known, lookUp) {
|
|
|
74
135
|
// each package's repository, `known` or else looked up, adds what it
|
|
75
136
|
// publishes for GitHub's `ecosystem` entries naming the package, for the
|
|
76
137
|
// versions `rows` do not already report under that GHSA.
|
|
77
|
-
export async function withRepositories(rows, asked, { github, repoAdvisories, known, details }, { ecosystem, lookUp, covers }) {
|
|
138
|
+
export async function withRepositories(rows, asked, { github, repoAdvisories, known, details, cache }, { ecosystem, lookUp, covers }) {
|
|
78
139
|
if (!repoAdvisories) return rows
|
|
79
140
|
const reported = new Set(rows.flatMap((row) => [row.id, row.ghsa, ...row.aliases].filter(isGhsa).flatMap((id) => row.versions.map((version) => `${row.name} ${id} ${version}`))))
|
|
80
|
-
const repoOf = await reposOf(asked, known, lookUp)
|
|
81
|
-
const takes = (name, pkg) => pkg
|
|
82
|
-
const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers, details })
|
|
141
|
+
const repoOf = await reposOf(asked, known, lookUp, cache)
|
|
142
|
+
const takes = (name, pkg) => pkg.ecosystem === ecosystem && pkg.name === name
|
|
143
|
+
const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers, details, cache })
|
|
83
144
|
return [...rows, ...added.map((row) => ({ ...row, versions: row.versions.filter((version) => !reported.has(`${row.name} ${row.id} ${version}`)) }))]
|
|
84
145
|
}
|
|
85
146
|
|
|
@@ -93,7 +154,7 @@ export const GITHUB = {
|
|
|
93
154
|
repositoryOnly: true,
|
|
94
155
|
assertName: assertRepo,
|
|
95
156
|
assertVersion: assertion('a version or a branch name', isRefName),
|
|
96
|
-
advisories: (asked, { github, details }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder, details }),
|
|
157
|
+
advisories: (asked, { github, details, cache }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder, details, cache }),
|
|
97
158
|
}
|
|
98
159
|
|
|
99
160
|
// Soldeer packages, which no advisory database has: the repository each
|
|
@@ -104,5 +165,5 @@ export const SOLDEER = {
|
|
|
104
165
|
repositoryOnly: true,
|
|
105
166
|
assertName: assertSoldeerName,
|
|
106
167
|
assertVersion: assertSoldeerVersion,
|
|
107
|
-
advisories: async (asked, { github, known, details }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos), details }),
|
|
168
|
+
advisories: async (asked, { github, known, details, cache }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos, cache), details, cache }),
|
|
108
169
|
}
|
package/src/advisories/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
3
|
import { assertArgs, assertBoolean, assertRepo, assertion, isIterable, optional, sameName } from '../args.js'
|
|
4
|
+
import { assertCache } from '../cache.js'
|
|
4
5
|
import { order } from './common.js'
|
|
5
6
|
import { GITHUB, SOLDEER, assertClient } from './github.js'
|
|
6
7
|
import { NPM } from './npm.js'
|
|
@@ -11,7 +12,7 @@ const byNumbers = new Intl.Collator('en', { numeric: true }).compare
|
|
|
11
12
|
const assertEcosystem = assertion(`one of ${Object.keys(ECOSYSTEMS).join(', ')}`, (value) => typeof value === 'string' && Object.hasOwn(ECOSYSTEMS, value))
|
|
12
13
|
const assertVersions = assertion('a non-empty array', (value) => Array.isArray(value) && value.length > 0)
|
|
13
14
|
|
|
14
|
-
// Ecosystem
|
|
15
|
+
// Ecosystem -> name -> { versions, github? }, a name given twice merged.
|
|
15
16
|
// Every package is checked before any is used.
|
|
16
17
|
function collect(packages) {
|
|
17
18
|
assert.ok(isIterable(packages), 'advisories: packages must be an iterable of { ecosystem, name, github?, versions }')
|
|
@@ -34,7 +35,7 @@ function collect(packages) {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export async function advisories(packages, options = {}) {
|
|
37
|
-
assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean) })
|
|
38
|
+
assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean), cache: optional(assertCache) })
|
|
38
39
|
assert.ok(!options.repoAdvisories || options.github, 'advisories: repoAdvisories needs a github client')
|
|
39
40
|
const byEcosystem = collect(packages)
|
|
40
41
|
for (const ecosystem of byEcosystem.keys()) assert.ok(!ECOSYSTEMS[ecosystem].repositoryOnly || options.github, `advisories: ${ecosystem} packages need a github client`)
|
|
@@ -43,7 +44,7 @@ export async function advisories(packages, options = {}) {
|
|
|
43
44
|
const names = [...named.keys()].toSorted()
|
|
44
45
|
const asked = new Map(names.map((name) => [name, [...named.get(name).versions].toSorted(compare)]))
|
|
45
46
|
const known = new Map(names.filter((name) => named.get(name).github).map((name) => [name, named.get(name).github]))
|
|
46
|
-
const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, details: options.details === true, known })
|
|
47
|
+
const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, details: options.details === true, known, cache: options.cache })
|
|
47
48
|
return rows.map((row) => ({ ecosystem, ...row }))
|
|
48
49
|
}))
|
|
49
50
|
return found.flat().filter((row) => row.versions.length > 0).toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name))
|
package/src/advisories/osv.js
CHANGED
|
@@ -13,12 +13,12 @@ const QUERIES_PER_REQUEST = 1000
|
|
|
13
13
|
export const RECORDS_AT_ONCE = 8
|
|
14
14
|
const isOsvId = matches(/^(?=.{1,128}$)[A-Z][\dA-Z]*(?:-[\dA-Za-z]+)+$/u)
|
|
15
15
|
// osv.dev's page for one of its records. An id that is only another's
|
|
16
|
-
// alias (PKSA
|
|
16
|
+
// alias (PKSA-...) has none, and redirects to a search.
|
|
17
17
|
const osvPage = (id) => `https://osv.dev/vulnerability/${id}`
|
|
18
18
|
const INFORMATIONAL = new Set(['unmaintained', 'unsound', 'notice'])
|
|
19
19
|
// Of the records that publish one advisory, the first answered for a
|
|
20
20
|
// version stands for the rest on it: RustSec's, what `cargo audit` reads
|
|
21
|
-
// and GitHub mirrors, then the GHSA, then any other (MAL
|
|
21
|
+
// and GitHub mirrors, then the GHSA, then any other (MAL-...), then by id.
|
|
22
22
|
const rank = (id) => (id.startsWith('RUSTSEC-') ? 0 : (isGhsa(id) ? 1 : 2))
|
|
23
23
|
const first = (a, b) => rank(a.id) - rank(b.id) || order(a.id, b.id)
|
|
24
24
|
const named = (a, b) => a.aliases.includes(b.id) || b.aliases.includes(a.id)
|
|
@@ -88,7 +88,7 @@ function toAdvisory(ecosystem, name, versions, record, details) {
|
|
|
88
88
|
async function osvAdvisories(ecosystem, asked, options) {
|
|
89
89
|
const { osv, github, lookUp, covers } = ecosystem
|
|
90
90
|
const list = [...asked].flatMap(([name, versions]) => versions.map((version) => ({ name, version })))
|
|
91
|
-
const hits = new Map() // id
|
|
91
|
+
const hits = new Map() // id -> name -> versions, in `list` order
|
|
92
92
|
for (const chunk of chunks(list, QUERIES_PER_REQUEST)) {
|
|
93
93
|
const body = { queries: chunk.map(({ name, version }) => ({ package: { name, ecosystem: osv }, version })) }
|
|
94
94
|
const answer = await request(QUERY_URL, { method: 'POST', body, as: 'json' })
|
package/src/advisories/repos.js
CHANGED
|
@@ -4,18 +4,19 @@ import { show } from '../args.js'
|
|
|
4
4
|
import { addRepos, readRepos } from '../cache.js'
|
|
5
5
|
import { lookUpCrateRepos } from '../cargo/repos.js'
|
|
6
6
|
import { PACKAGIST_REPO, SOLDEER_API, buildUrl, isNotFound, recover, request } from '../http.js'
|
|
7
|
-
import {
|
|
7
|
+
import { getVersionDocument } from '../npm/versions.js'
|
|
8
|
+
import { getRepo } from '../package.js'
|
|
8
9
|
import { pool } from '../pool.js'
|
|
9
10
|
import { githubRepoOfUrl } from '../remote.js'
|
|
10
11
|
|
|
11
12
|
const PACKAGES_AT_ONCE = 8
|
|
12
13
|
|
|
13
|
-
// Name
|
|
14
|
+
// Name -> its GitHub repo, or null where the registry has no such package
|
|
14
15
|
// or it names no GitHub repo; any other failure throws. Only a repo found
|
|
15
|
-
// is cached, for a month.
|
|
16
|
-
async function cachedRepos(dir, names, fetchMissing) {
|
|
17
|
-
const repos = await readRepos(dir, names)
|
|
18
|
-
await addRepos(dir, repos, await fetchMissing(names.filter((name) => !repos.has(name))))
|
|
16
|
+
// is cached, for a month, in the cache set or `cache` (readRecord).
|
|
17
|
+
async function cachedRepos(dir, names, fetchMissing, cache) {
|
|
18
|
+
const repos = await readRepos(dir, names, cache)
|
|
19
|
+
await addRepos(dir, repos, await fetchMissing(names.filter((name) => !repos.has(name))), cache)
|
|
19
20
|
return repos
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -42,7 +43,17 @@ async function fetchSoldeerRepo(name) {
|
|
|
42
43
|
return githubRepoOfUrl(answer.data[0]?.github_url) ?? null
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// The repo the newest version asked of each package names, from that
|
|
47
|
+
// version's document, kept for good (getVersionDocument): null where the
|
|
48
|
+
// registry does not have that version, or it names no GitHub repo.
|
|
49
|
+
async function npmRepo(name, version, cache) {
|
|
50
|
+
const json = await getVersionDocument('advisories', name, version, cache)
|
|
51
|
+
return (json && getRepo(json).github) ?? null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Each takes the names to look up, the versions `asked` of each, and the
|
|
55
|
+
// call's `cache`.
|
|
56
|
+
export const crateRepos = (names, { cache }) => lookUpCrateRepos('advisories', names, { cache })
|
|
57
|
+
export const composerRepos = (names, { cache }) => cachedRepos('composer/repos', names, (missing) => fetchEach(missing, fetchComposerRepo), cache)
|
|
58
|
+
export const npmRepos = (names, { asked, cache }) => fetchEach(names, (name) => npmRepo(name, asked.get(name).at(-1), cache))
|
|
59
|
+
export const soldeerRepos = (names, { cache }) => cachedRepos('soldeer/repos', names, (missing) => fetchEach(missing, fetchSoldeerRepo), cache)
|
package/src/args.js
CHANGED
|
@@ -7,7 +7,7 @@ export const matches = (regex) => (value) => typeof value === 'string' && regex.
|
|
|
7
7
|
// Bidi controls can make a log line read as something it doesn't say.
|
|
8
8
|
const isBidi = (char) => (char >= '\u202A' && char <= '\u202E') || (char >= '\u2066' && char <= '\u2069')
|
|
9
9
|
// A prototype's properties would be read as options without being checked.
|
|
10
|
-
const isPlainObject = (value) => value != null && [Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
10
|
+
export const isPlainObject = (value) => value != null && [Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
11
11
|
// Lone surrogates make encodeURIComponent throw.
|
|
12
12
|
const isString = (value, max, allowed = '') => typeof value === 'string' && value.length <= max && value.isWellFormed()
|
|
13
13
|
&& ![...value].some((char) => isControl(char) && !allowed.includes(char))
|
package/src/cache.js
CHANGED
|
@@ -3,12 +3,22 @@ import { constants } from 'node:fs'
|
|
|
3
3
|
import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { gunzip, gzip } from 'node:zlib'
|
|
6
8
|
|
|
7
|
-
import { assertCacheName, assertDirectoryPath, isRepo } from './args.js'
|
|
9
|
+
import { assertCacheName, assertDirectoryPath, assertion, isRepo } from './args.js'
|
|
8
10
|
import { MAX_BYTES, decode } from './http.js'
|
|
11
|
+
import { pool } from './pool.js'
|
|
9
12
|
|
|
10
|
-
const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees'])
|
|
13
|
+
const DIRS = new Set(['npm/repos', 'npm/tarballs', 'npm/versions', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees', 'github/advisories'])
|
|
11
14
|
const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
|
|
15
|
+
const RECORDS_AT_ONCE = 8
|
|
16
|
+
// JSON filed gzipped, as `<name>.json.gz`: a listing carries each
|
|
17
|
+
// advisory's full text, Markdown that compresses well, and a version
|
|
18
|
+
// document all npm has of that version.
|
|
19
|
+
const GZIP_DIRS = new Set(['github/advisories', 'npm/versions'])
|
|
20
|
+
const compress = promisify(gzip)
|
|
21
|
+
const decompress = promisify(gunzip)
|
|
12
22
|
|
|
13
23
|
// An environment variable may hold anything: only an absolute path is one.
|
|
14
24
|
const absolute = (path) => (typeof path === 'string' && isAbsolute(path) ? path : undefined)
|
|
@@ -95,10 +105,12 @@ export async function readCache(dir, key) {
|
|
|
95
105
|
return path && await readRegularFile(path)
|
|
96
106
|
}
|
|
97
107
|
|
|
98
|
-
|
|
108
|
+
// With `compressed`, the file is decompressed first, to no more than a file
|
|
109
|
+
// read; one that is not gzip, fails its CRC, or runs past that, is a miss.
|
|
110
|
+
export async function readCacheJSON(dir, key, { compressed = false } = {}) {
|
|
99
111
|
const bytes = await readCache(dir, key)
|
|
100
112
|
try {
|
|
101
|
-
return bytes && JSON.parse(decode(bytes, key))
|
|
113
|
+
return bytes && JSON.parse(decode(compressed ? await decompress(bytes, { maxOutputLength: MAX_BYTES }) : bytes, key))
|
|
102
114
|
} catch {
|
|
103
115
|
return null
|
|
104
116
|
}
|
|
@@ -119,33 +131,74 @@ export async function writeCache(dir, key, data) {
|
|
|
119
131
|
}
|
|
120
132
|
}
|
|
121
133
|
|
|
122
|
-
export async function writeCacheJSON(dir, key, value) {
|
|
123
|
-
|
|
134
|
+
export async function writeCacheJSON(dir, key, value, { compressed = false } = {}) {
|
|
135
|
+
const json = JSON.stringify(value)
|
|
136
|
+
return await writeCache(dir, key, compressed ? await compress(json) : json)
|
|
124
137
|
}
|
|
125
138
|
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
// A call's `cache` option: a store of the caller's, with read and write,
|
|
140
|
+
// to keep what the call caches in place of the cache set; or false, for
|
|
141
|
+
// the cache set to be read and nothing written. `cache` below is that.
|
|
142
|
+
export const assertCache = assertion('false, or a store with read and write', (value) => value === false || (typeof value?.read === 'function' && typeof value?.write === 'function'))
|
|
143
|
+
|
|
144
|
+
// What is kept as `dir` and `name`: in a `cache` store, else in the cache
|
|
145
|
+
// set, as `fromDisk` and `toDisk` read and write it.
|
|
146
|
+
async function read(dir, name, cache, fromDisk) {
|
|
147
|
+
assert.ok(DIRS.has(dir), `Unexpected cache entry: ${dir}`)
|
|
148
|
+
return cache ? await cache.read(dir, name) : await fromDisk()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function write(dir, name, value, cache, toDisk) {
|
|
152
|
+
assert.ok(DIRS.has(dir), `Unexpected cache entry: ${dir}`)
|
|
153
|
+
if (cache === false) return false
|
|
154
|
+
if (!cache) return await toDisk()
|
|
155
|
+
await cache.write(dir, name, value)
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// JSON, as it is in a store, and in the cache set gzipped where `dir` is.
|
|
160
|
+
const jsonFile = (dir, name) => (GZIP_DIRS.has(dir) ? { file: `${name}.json.gz`, compressed: true } : { file: `${name}.json` })
|
|
161
|
+
|
|
162
|
+
export function readJSON(dir, name, cache) {
|
|
163
|
+
const { file, compressed } = jsonFile(dir, name)
|
|
164
|
+
return read(dir, name, cache, () => readCacheJSON(dir, file, { compressed }))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function writeJSON(dir, name, value, cache) {
|
|
168
|
+
const { file, compressed } = jsonFile(dir, name)
|
|
169
|
+
return write(dir, name, value, cache, () => writeCacheJSON(dir, file, value, { compressed }))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Bytes, in the cache set as `file`. A store may keep what it is given and
|
|
173
|
+
// answer what it keeps, so bytes cross it as a copy each way; what it
|
|
174
|
+
// answers that is not bytes is none.
|
|
175
|
+
export async function readBytes(dir, name, file, cache) {
|
|
176
|
+
const bytes = await read(dir, name, cache, () => readCache(dir, file))
|
|
177
|
+
if (!(bytes instanceof Uint8Array)) return null
|
|
178
|
+
return cache ? new Uint8Array(bytes) : bytes
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const writeBytes = (dir, name, file, bytes, cache) => write(dir, name, cache ? new Uint8Array(bytes) : bytes, cache, () => writeCache(dir, file, bytes))
|
|
182
|
+
|
|
183
|
+
// A record, kept as readJSON and writeJSON keep it, for `ttl`.
|
|
184
|
+
export async function readRecord(dir, name, cache, ttl = RECORD_TTL_MS) {
|
|
185
|
+
const record = await readJSON(dir, name, cache)
|
|
128
186
|
const age = typeof record?.at === 'number' ? Date.now() - record.at : Number.NaN
|
|
129
|
-
const fresh = age >= 0 && age <=
|
|
187
|
+
const fresh = age >= 0 && age <= ttl // An entry from the future is not fresh forever.
|
|
130
188
|
return fresh && record.name === name ? record : null
|
|
131
189
|
}
|
|
132
190
|
|
|
133
|
-
export const writeRecord = (dir, name, value) =>
|
|
191
|
+
export const writeRecord = (dir, name, value, cache) => writeJSON(dir, name, { at: Date.now(), name, ...value }, cache)
|
|
134
192
|
|
|
135
|
-
// Name
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (isRepo(entry?.github)) repos.set(name, entry.github)
|
|
141
|
-
}
|
|
142
|
-
return repos
|
|
193
|
+
// Name -> the GitHub repo kept for it (readRecord), for those of `names`
|
|
194
|
+
// that have one.
|
|
195
|
+
export async function readRepos(dir, names, cache) {
|
|
196
|
+
const entries = await pool(names, RECORDS_AT_ONCE, (name) => readRecord(dir, name, cache))
|
|
197
|
+
return new Map(names.flatMap((name, i) => (isRepo(entries[i]?.github) ? [[name, entries[i].github]] : [])))
|
|
143
198
|
}
|
|
144
199
|
|
|
145
|
-
// An answer into `repos`, and
|
|
146
|
-
export async function addRepos(dir, repos, answer) {
|
|
147
|
-
for (const [name, github] of answer)
|
|
148
|
-
|
|
149
|
-
if (github) await writeRecord(dir, name, { github })
|
|
150
|
-
}
|
|
200
|
+
// An answer into `repos`, and kept (writeRecord) where it found a repo.
|
|
201
|
+
export async function addRepos(dir, repos, answer, cache) {
|
|
202
|
+
for (const [name, github] of answer) repos.set(name, github)
|
|
203
|
+
await pool([...answer].filter(([, github]) => github), RECORDS_AT_ONCE, ([name, github]) => writeRecord(dir, name, { github }, cache))
|
|
151
204
|
}
|
package/src/cargo/repos.js
CHANGED
|
@@ -12,7 +12,7 @@ const PER_REQUEST = 100
|
|
|
12
12
|
const PACE_MS = 1000 // crates.io asks for a request a second at most, and a user agent.
|
|
13
13
|
const USER_AGENT = '@preventive/upstream (https://github.com/PreventiveMeasures/libraries)'
|
|
14
14
|
|
|
15
|
-
// One request for up to a hundred crates: each name
|
|
15
|
+
// One request for up to a hundred crates: each name -> the GitHub repo its
|
|
16
16
|
// `repository` names, or null where it names none. A crate crates.io
|
|
17
17
|
// leaves out of its answer is one it does not have, null too.
|
|
18
18
|
async function askCrates(method, names) {
|
|
@@ -27,12 +27,12 @@ async function askCrates(method, names) {
|
|
|
27
27
|
return found
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
// Name
|
|
31
|
-
// names no GitHub repo, through the cache
|
|
32
|
-
// for a month. A failed request throws, or with
|
|
33
|
-
// out; `cachedOnly` asks nothing.
|
|
34
|
-
export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly = false } = {}) {
|
|
35
|
-
const repos = await readRepos(DIR, names)
|
|
30
|
+
// Name -> its GitHub repo, or null where crates.io has no such crate or it
|
|
31
|
+
// names no GitHub repo, through the cache, or a `cache` store (readRecord):
|
|
32
|
+
// only a repo found is kept, for a month. A failed request throws, or with
|
|
33
|
+
// `soft` leaves its names out; `cachedOnly` asks nothing.
|
|
34
|
+
export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly = false, cache } = {}) {
|
|
35
|
+
const repos = await readRepos(DIR, names, cache)
|
|
36
36
|
if (cachedOnly) return repos
|
|
37
37
|
for (const [i, chunk] of chunks(names.filter((name) => !repos.has(name)), PER_REQUEST).entries()) {
|
|
38
38
|
if (i > 0) await sleep(PACE_MS)
|
|
@@ -40,7 +40,7 @@ export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly
|
|
|
40
40
|
if (soft) return new Map()
|
|
41
41
|
throw error
|
|
42
42
|
})
|
|
43
|
-
await addRepos(DIR, repos, answer)
|
|
43
|
+
await addRepos(DIR, repos, answer, cache)
|
|
44
44
|
}
|
|
45
45
|
return repos
|
|
46
46
|
}
|
package/src/download.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
|
|
4
|
-
import { ourCachePaths,
|
|
4
|
+
import { ourCachePaths, readBytes, readRegularFile, writeBytes } from './cache.js'
|
|
5
5
|
import { request } from './http.js'
|
|
6
6
|
import { gitTreeOfTarball } from './tree.js'
|
|
7
7
|
|
|
@@ -18,9 +18,10 @@ const DIGESTS = {
|
|
|
18
18
|
// caller gives, never a disk; without it nothing is read. From `local`,
|
|
19
19
|
// other tools' caches, and with `ours` our default cache and stasis's,
|
|
20
20
|
// whether set or not (read, never written; a mismatch is passed over),
|
|
21
|
-
// else the cache set
|
|
22
|
-
// checked and then
|
|
23
|
-
|
|
21
|
+
// else the cache set, or a `cache` store in its place (readBytes; a
|
|
22
|
+
// mismatch throws), else what `locate` answers, checked and then kept as
|
|
23
|
+
// writeBytes keeps it.
|
|
24
|
+
export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], ours = false, cache, locate, options = {}, list }) {
|
|
24
25
|
assert.ok(Object.hasOwn(DIGESTS, algorithm) && typeof expected === 'string' && expected !== '', `${method}: nothing to check ${what} against`)
|
|
25
26
|
const digest = (bytes) => DIGESTS[algorithm](bytes, { expected, list })
|
|
26
27
|
const key = `${what}.${ext}`
|
|
@@ -33,10 +34,10 @@ export async function verifiedDownload({ method, dir, what, ext, algorithm, expe
|
|
|
33
34
|
assert.ok(actual === expected, `${method}: integrity mismatch for ${what} from ${from}: expected ${expected}, got ${actual}`)
|
|
34
35
|
return bytes
|
|
35
36
|
}
|
|
36
|
-
const cached = await
|
|
37
|
-
if (cached) return await check(cached, 'the cache')
|
|
37
|
+
const cached = await readBytes(dir, what, key, cache)
|
|
38
|
+
if (cached) return await check(cached, cache ? 'the store' : 'the cache')
|
|
38
39
|
const url = await locate()
|
|
39
40
|
const bytes = await check(await request(url, { ...options, as: 'bytes' }), url)
|
|
40
|
-
await
|
|
41
|
+
await writeBytes(dir, what, key, bytes, cache)
|
|
41
42
|
return bytes
|
|
42
43
|
}
|
package/src/git.js
CHANGED
|
@@ -54,5 +54,5 @@ export async function findGitCheckout(dir) {
|
|
|
54
54
|
const directory = relative(dirs.root, start).split(sep).join('/')
|
|
55
55
|
const github = await originGitHub(dirs.commonDir)
|
|
56
56
|
const commit = await headCommit(dirs.gitDir, dirs.commonDir)
|
|
57
|
-
return { ...(github && { github }),
|
|
57
|
+
return { ...(github && { github }), directory, ...(github && { url: `https://github.com/${github}` }), ...(commit && { commit }) }
|
|
58
58
|
}
|