@preventive/upstream 1.0.0-alpha.11 → 1.0.0-alpha.13
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 +14 -21
- package/npm.d.ts +107 -16
- package/npm.js +1 -1
- package/package.json +3 -1
- package/src/advisories/github.js +9 -9
- package/src/advisories/index.js +3 -2
- package/src/advisories/repos.js +20 -9
- package/src/args.js +1 -1
- package/src/cache.js +72 -42
- package/src/cargo/repos.js +6 -6
- package/src/download.js +8 -7
- package/src/npm/cacache.js +44 -0
- package/src/npm/registry.js +10 -4
- package/src/npm/repos.js +1 -1
- package/src/npm/tarball.js +99 -37
- package/src/npm/versions.js +97 -0
package/advisories.d.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// other.
|
|
3
3
|
|
|
4
4
|
import type { Client, RepoName } from './github.js'
|
|
5
|
+
import type { CacheStore } from './npm.js'
|
|
5
6
|
|
|
6
7
|
export { HttpError } from './npm.js'
|
|
8
|
+
export type { CacheStore } from './npm.js'
|
|
7
9
|
|
|
8
10
|
// As purl and stasis name them.
|
|
9
11
|
export type Ecosystem = 'npm' | 'cargo' | 'composer' | 'soldeer' | 'github'
|
|
@@ -27,19 +29,6 @@ export interface Package {
|
|
|
27
29
|
versions: string[]
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
// A store of the caller's, a database's say, for what would otherwise be
|
|
31
|
-
// kept in setCacheDir's cache (npm.js): `read` answers the value last
|
|
32
|
-
// written under `key`, or null or undefined for none. Keys are
|
|
33
|
-
// `<kind>/<name>`, `github/advisories/owner/name` for a repository's
|
|
34
|
-
// listing, and values plain JSON data, which `read` may answer as written
|
|
35
|
-
// or as a copy. Whatever it answers is checked as a cached file is: one
|
|
36
|
-
// malformed, stale or kept differently is a miss. A rejection from
|
|
37
|
-
// either is the call's. It is trusted as setCacheDir's directory is.
|
|
38
|
-
export interface CacheStore {
|
|
39
|
-
read(key: string): Promise<unknown>
|
|
40
|
-
write(key: string, value: unknown): Promise<void>
|
|
41
|
-
}
|
|
42
|
-
|
|
43
32
|
export interface AdvisoryOptions {
|
|
44
33
|
// The client every GitHub request goes through. Required for `soldeer`
|
|
45
34
|
// and `github` packages, whose repository's published advisories are
|
|
@@ -49,12 +38,16 @@ export interface AdvisoryOptions {
|
|
|
49
38
|
// Also asks each npm, cargo and composer package's GitHub repository
|
|
50
39
|
// for its published advisories, which it has before GitHub reviews them
|
|
51
40
|
// into the databases above: the package's `github` where given, else
|
|
52
|
-
// the one npm's
|
|
53
|
-
//
|
|
41
|
+
// the one looked up. An npm package's is the one the document of its
|
|
42
|
+
// newest version asked names, kept for good as getMeta keeps it (none
|
|
43
|
+
// where the registry does not have that version); a crate's and a
|
|
44
|
+
// composer package's, the one crates.io or Packagist names, kept a
|
|
45
|
+
// month. Needs `github`.
|
|
54
46
|
repoAdvisories?: boolean
|
|
55
|
-
// Where
|
|
56
|
-
//
|
|
57
|
-
|
|
47
|
+
// Where what this call caches is kept, as CacheOptions (npm.js) has it:
|
|
48
|
+
// a repository's listing, and each package's repository looked up (an
|
|
49
|
+
// npm package's as its version's document). See `advisories`.
|
|
50
|
+
cache?: CacheStore | false
|
|
58
51
|
// Also returns each advisory's full text, as `details`. OSV's records
|
|
59
52
|
// and a repository's listing already carry it; npm's registry does not,
|
|
60
53
|
// and its rows take OSV's record of their GHSA, one more request each
|
|
@@ -126,9 +119,9 @@ export interface Advisory {
|
|
|
126
119
|
// nothing is left out quietly.
|
|
127
120
|
// A repository's listing is kept for 90 minutes, as one entry for every
|
|
128
121
|
// spelling of its name, holding only what rows are made from, `details`
|
|
129
|
-
// included: in setCacheDir's cache
|
|
130
|
-
// `cache`
|
|
131
|
-
// publishes can be seen. One gone or malformed is not kept.
|
|
122
|
+
// included: in setCacheDir's cache gzipped, in a `cache` store as it is,
|
|
123
|
+
// or with `cache` false, nowhere. 90 minutes is how late an advisory its
|
|
124
|
+
// maintainer publishes can be seen. One gone or malformed is not kept.
|
|
132
125
|
// Sorted by ecosystem and name. Versions are matched by npm's semver, from
|
|
133
126
|
// the npm beside node, or by the semver peer where there is no npm.
|
|
134
127
|
export function advisories(packages: Iterable<Package>, options?: AdvisoryOptions): Promise<Advisory[]>
|
package/npm.d.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
// a timeout, as github.d.ts describes, and lookups for many names go to
|
|
8
8
|
// the registry eight at a time.
|
|
9
9
|
//
|
|
10
|
-
// Where
|
|
11
|
-
//
|
|
10
|
+
// Where PREVENTIVE_MEASURES_NPM_TOKEN, STASIS_NPM_TOKEN or NPM_TOKEN is
|
|
11
|
+
// set, the first of them not empty, in that order, is sent with the
|
|
12
|
+
// requests for a scoped package, so a private one can be read.
|
|
12
13
|
|
|
13
14
|
// Where a program called `name` keeps its cache for this user, as each
|
|
14
15
|
// platform has it, read from the environment when called. macOS:
|
|
@@ -32,15 +33,45 @@ export const defaultCacheDir: string | undefined
|
|
|
32
33
|
// cache: every read of it misses and every write is skipped. getTarball
|
|
33
34
|
// reads ours all the same, set or not.
|
|
34
35
|
//
|
|
35
|
-
// The directory is trusted: a package's repo,
|
|
36
|
-
//
|
|
37
|
-
//
|
|
36
|
+
// The directory is trusted: a package's repo, as recorded or as a version
|
|
37
|
+
// document kept here names it, and for 90 minutes a repository's published
|
|
38
|
+
// advisories (advisories.js), are answered from it as they were written,
|
|
39
|
+
// with no request. Point it only at storage that
|
|
38
40
|
// nothing less trusted than the caller can write, never a cache shared
|
|
39
41
|
// with or restored from lower-trust jobs. Tarballs, crates (cargo.js), zips
|
|
40
42
|
// (soldeer.js) and tree tarballs (github.js) don't rely on this, since
|
|
41
|
-
// they are checked against their integrity on every call
|
|
43
|
+
// they are checked against their integrity on every call; and the version
|
|
44
|
+
// documents kept here (getMeta) never supply an integrity, only confirm
|
|
45
|
+
// one a caller gives (verifyDist, and getMeta with a `dist`). A call's `cache` option, where it has
|
|
46
|
+
// one, keeps what it caches elsewhere, or writes none of it here.
|
|
42
47
|
export function setCacheDir(dir?: string | false): void
|
|
43
48
|
|
|
49
|
+
// A store of the caller's, a database's say, for what would otherwise be
|
|
50
|
+
// kept in setCacheDir's cache: `read` answers the value last written
|
|
51
|
+
// under that `type` and `key`, or null or undefined for none. A `type` is
|
|
52
|
+
// what is kept and a `key` which one: `npm/versions` and `npm/tarballs`
|
|
53
|
+
// with `<name>@<version>` for a version's document and tarball; and for
|
|
54
|
+
// advisories.js, `github/advisories` with the repository's `owner/name` in
|
|
55
|
+
// lowercase for its listing, and `cargo/repos`, `composer/repos` and
|
|
56
|
+
// `soldeer/repos` with a package's name for the repository it names.
|
|
57
|
+
// Values are plain JSON data, which `read` may answer as written or as a
|
|
58
|
+
// copy, but a tarball's are its bytes, a Uint8Array. Whatever it answers
|
|
59
|
+
// is checked as a cached file is: one malformed, stale or kept differently
|
|
60
|
+
// is a miss, and a tarball's bytes that do not match throw. A rejection
|
|
61
|
+
// from either is the call's. It is trusted as setCacheDir's directory is.
|
|
62
|
+
export interface CacheStore {
|
|
63
|
+
read(type: string, key: string): Promise<unknown>
|
|
64
|
+
write(type: string, key: string, value: unknown): Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Where a call keeps what it caches: in setCacheDir's cache where `cache`
|
|
68
|
+
// is left out; in a store of the caller's in place of it; or with `false`,
|
|
69
|
+
// nowhere, though setCacheDir's cache is read all the same. Other tools'
|
|
70
|
+
// caches and ours are read for tarballs whatever it is (getTarball).
|
|
71
|
+
export interface CacheOptions {
|
|
72
|
+
cache?: CacheStore | false
|
|
73
|
+
}
|
|
74
|
+
|
|
44
75
|
// A failed request: `status` is the HTTP status the registry answered
|
|
45
76
|
// with (404 for a package or version it does not have).
|
|
46
77
|
export class HttpError extends Error {
|
|
@@ -88,25 +119,83 @@ export interface Dist {
|
|
|
88
119
|
integrity: string
|
|
89
120
|
}
|
|
90
121
|
|
|
122
|
+
// `gitHead` is the commit the document names, as npm reads it off the git
|
|
123
|
+
// checkout it publishes from, where it is a full commit id: 40 lowercase
|
|
124
|
+
// hex digits, or 64. `repository`, `homepage` and `bugs` are the
|
|
125
|
+
// document's, as the package.json published had them, or npm filled them
|
|
126
|
+
// in from one another: each a string as it is, or for `repository` and
|
|
127
|
+
// `bugs` an object, of which only the strings named below are read. Each is
|
|
128
|
+
// absent where the document has none, or has another shape, or an object
|
|
129
|
+
// with none of those strings. All of it is the publisher's word, held to no
|
|
130
|
+
// repository.
|
|
91
131
|
export interface PackageMeta {
|
|
92
132
|
name: string
|
|
93
133
|
version: string
|
|
94
134
|
dist: Dist
|
|
135
|
+
gitHead?: string
|
|
136
|
+
repository?: string | PackageRepository
|
|
137
|
+
homepage?: string
|
|
138
|
+
bugs?: string | PackageBugs
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PackageRepository {
|
|
142
|
+
type: string | undefined
|
|
143
|
+
url: string | undefined
|
|
144
|
+
directory: string | undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PackageBugs {
|
|
148
|
+
url: string | undefined
|
|
149
|
+
email: string | undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// getMeta's options: `cache`, and the `dist` a caller already has of that
|
|
153
|
+
// version, as a lockfile gives it, held to the rules above.
|
|
154
|
+
export interface MetaOptions extends CacheOptions {
|
|
155
|
+
dist?: Dist
|
|
95
156
|
}
|
|
96
157
|
|
|
97
158
|
// The registry's version document, refused unless it is for that name and
|
|
98
159
|
// version and its dist is held to the rules above.
|
|
99
|
-
|
|
160
|
+
//
|
|
161
|
+
// With `dist`, npm's own cache comes first, where getTarball looks for
|
|
162
|
+
// npm's tarballs: where it keeps the full packument of that name, as npm 10
|
|
163
|
+
// and later fetch it, and that version's entry there has the given dist's
|
|
164
|
+
// tarball and integrity, that entry answers, with no request, and neither
|
|
165
|
+
// `cache` nor setCacheDir's cache is read or written. An abbreviated
|
|
166
|
+
// packument, as earlier npm fetches, is passed over, as is one whose
|
|
167
|
+
// content does not match the sha512 npm filed it by. What it answers
|
|
168
|
+
// beside the dist is taken from npm's cache as it is from setCacheDir's.
|
|
169
|
+
// Else the document `cache` keeps answers, with no request, where its
|
|
170
|
+
// tarball and integrity are the given dist's; else the registry's is asked
|
|
171
|
+
// for, and throws where they are not.
|
|
172
|
+
//
|
|
173
|
+
// Without `dist`, the registry is asked on every call: a cache never
|
|
174
|
+
// supplies a dist. Kept whole, as `cache` says, and for good once it
|
|
175
|
+
// passes, as the registry never takes a version twice: in setCacheDir's
|
|
176
|
+
// cache, compressed, where `cache` is left out. One refused is not kept.
|
|
177
|
+
export function getMeta(name: string, version: string, options?: MetaOptions): Promise<PackageMeta>
|
|
178
|
+
|
|
179
|
+
// When the registry says each version of a package it lists was published,
|
|
180
|
+
// by version, as Date#toISOString writes it: from the whole package's
|
|
181
|
+
// document, the only one with `time`, megabytes for some. A version whose
|
|
182
|
+
// time is missing, not an ISO 8601 UTC time, or not one there is, as
|
|
183
|
+
// 2018-02-29, is left out. Refused unless the document is for that name.
|
|
184
|
+
export function getPublishTimes(name: string): Promise<Map<string, string>>
|
|
100
185
|
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
186
|
+
// Throws unless the integrity of that version is the one given, as for a
|
|
187
|
+
// dist read off a lockfile: by the version document `cache` keeps, with no
|
|
188
|
+
// request, else by the registry's, kept then as getMeta keeps it. One kept
|
|
189
|
+
// with another integrity, refused, or for another name or version, is
|
|
190
|
+
// passed over for the registry's, which throws where it has another.
|
|
191
|
+
export function verifyDist(name: string, version: string, dist: Dist, options?: CacheOptions): Promise<void>
|
|
104
192
|
|
|
105
193
|
// A published version's gzipped tarball, whole, in memory. Without `dist`,
|
|
106
|
-
// the version document is fetched on every call, cached tarball or not
|
|
107
|
-
// with one, it is trusted as given and
|
|
108
|
-
// for. Either way the bytes are checked
|
|
109
|
-
// they were downloaded (before they are
|
|
194
|
+
// the version document is fetched on every call, cached tarball or not,
|
|
195
|
+
// and kept as getMeta keeps it; with one, it is trusted as given and
|
|
196
|
+
// nothing but the tarball is asked for. Either way the bytes are checked
|
|
197
|
+
// against `dist.integrity`, whether they were downloaded (before they are
|
|
198
|
+
// cached) or read from a cache.
|
|
110
199
|
//
|
|
111
200
|
// Other caches are read first, and never written: npm's (cacache under
|
|
112
201
|
// npm_config_cache, else npm's default, `~/.npm` outside Windows; and npm
|
|
@@ -114,5 +203,7 @@ export function verifyDist(name: string, version: string, dist: Dist): Promise<v
|
|
|
114
203
|
// `~/.audit/cache/tgz/<org>:<name>-<version>.tgz`, then defaultCacheDir
|
|
115
204
|
// and cacheDirFor('stasis'), as setCacheDir files a tarball in either,
|
|
116
205
|
// whether set or not. A file there that does not match is passed over.
|
|
117
|
-
// Then setCacheDir's cache,
|
|
118
|
-
|
|
206
|
+
// Then setCacheDir's cache, or a `cache` store in its place, where bytes
|
|
207
|
+
// that do not match throw. A tarball downloaded is kept there, unless
|
|
208
|
+
// `cache` is false.
|
|
209
|
+
export function getTarball(name: string, version: string, dist?: Dist, options?: CacheOptions): Promise<Uint8Array>
|
package/npm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { cacheDirFor, defaultCacheDir, setCacheDir } from './src/cache.js'
|
|
2
2
|
export { HttpError } from './src/http.js'
|
|
3
3
|
export { getGitHub, readPackageRepoCache, resolvePackageRepos, writePackageRepoCache } from './src/npm/repos.js'
|
|
4
|
-
export { getMeta, getTarball, verifyDist } from './src/npm/tarball.js'
|
|
4
|
+
export { getMeta, getPublishTimes, getTarball, verifyDist } from './src/npm/tarball.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/upstream",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.13",
|
|
4
4
|
"description": "Minimal npm, crates.io and Soldeer registry clients, a GitHub client, and a git checkout reader: the calls we use, nothing more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -85,9 +85,11 @@
|
|
|
85
85
|
"src/github/read.js",
|
|
86
86
|
"src/github/write.js",
|
|
87
87
|
"src/http.js",
|
|
88
|
+
"src/npm/cacache.js",
|
|
88
89
|
"src/npm/registry.js",
|
|
89
90
|
"src/npm/repos.js",
|
|
90
91
|
"src/npm/tarball.js",
|
|
92
|
+
"src/npm/versions.js",
|
|
91
93
|
"src/package.js",
|
|
92
94
|
"src/pool.js",
|
|
93
95
|
"src/remote.js",
|
package/src/advisories/github.js
CHANGED
|
@@ -21,7 +21,6 @@ const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' &&
|
|
|
21
21
|
&& (advisory.vulnerabilities == null || Array.isArray(advisory.vulnerabilities)) && (advisory.cwe_ids == null || isStrings(advisory.cwe_ids))
|
|
22
22
|
|
|
23
23
|
export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function')
|
|
24
|
-
export const assertCacheStore = assertion('a store with read and write', (value) => typeof value?.read === 'function' && typeof value?.write === 'function')
|
|
25
24
|
|
|
26
25
|
// What rows are made from, and all that is kept of a listing: each advisory
|
|
27
26
|
// not withdrawn, with its CVSS vector GitHub prefers, and each vulnerable
|
|
@@ -58,14 +57,14 @@ const isDigest = (advisory) => advisory && typeof advisory === 'object' && isGhs
|
|
|
58
57
|
// A repository's listing, digested, through the cache: GitHub's names are
|
|
59
58
|
// case-insensitive, so one entry answers every spelling. A repository gone,
|
|
60
59
|
// renamed or blocked has none, and that is not kept.
|
|
61
|
-
async function listAdvisories(github, repo,
|
|
60
|
+
async function listAdvisories(github, repo, cache) {
|
|
62
61
|
const name = repo.toLowerCase()
|
|
63
|
-
const entry = await readRecord(DIR, name,
|
|
62
|
+
const entry = await readRecord(DIR, name, cache, LISTING_TTL_MS)
|
|
64
63
|
if (entry?.v === VERSION && Array.isArray(entry.advisories) && entry.advisories.every(isDigest)) return entry.advisories
|
|
65
64
|
const list = await github.listRepoAdvisories({ repo }).catch(recover(isGone, null))
|
|
66
65
|
if (list === null) return []
|
|
67
66
|
const advisories = digest(repo, list)
|
|
68
|
-
if (advisories.every(isDigest)) await writeRecord(DIR, name, { v: VERSION, advisories },
|
|
67
|
+
if (advisories.every(isDigest)) await writeRecord(DIR, name, { v: VERSION, advisories }, cache)
|
|
69
68
|
return advisories
|
|
70
69
|
}
|
|
71
70
|
|
|
@@ -111,9 +110,10 @@ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true,
|
|
|
111
110
|
return [...rows.values()]
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
// Each asked name's repository: as given, `known`, or else looked up
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
// Each asked name's repository: as given, `known`, or else looked up,
|
|
114
|
+
// through `cache`.
|
|
115
|
+
async function reposOf(asked, known, lookUp, cache) {
|
|
116
|
+
const found = await lookUp([...asked.keys()].filter((name) => !known.has(name)), { asked, cache })
|
|
117
117
|
return (name) => known.get(name) ?? found.get(name)
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -125,7 +125,7 @@ async function reposOf(asked, known, lookUp) {
|
|
|
125
125
|
export async function withRepositories(rows, asked, { github, repoAdvisories, known, details, cache }, { ecosystem, lookUp, covers }) {
|
|
126
126
|
if (!repoAdvisories) return rows
|
|
127
127
|
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}`))))
|
|
128
|
-
const repoOf = await reposOf(asked, known, lookUp)
|
|
128
|
+
const repoOf = await reposOf(asked, known, lookUp, cache)
|
|
129
129
|
const takes = (name, pkg) => pkg.ecosystem === ecosystem && pkg.name === name
|
|
130
130
|
const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers, details, cache })
|
|
131
131
|
return [...rows, ...added.map((row) => ({ ...row, versions: row.versions.filter((version) => !reported.has(`${row.name} ${row.id} ${version}`)) }))]
|
|
@@ -152,5 +152,5 @@ export const SOLDEER = {
|
|
|
152
152
|
repositoryOnly: true,
|
|
153
153
|
assertName: assertSoldeerName,
|
|
154
154
|
assertVersion: assertSoldeerVersion,
|
|
155
|
-
advisories: async (asked, { github, known, details, cache }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos), details, cache }),
|
|
155
|
+
advisories: async (asked, { github, known, details, cache }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos, cache), details, cache }),
|
|
156
156
|
}
|
package/src/advisories/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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
|
-
import { GITHUB, SOLDEER,
|
|
6
|
+
import { GITHUB, SOLDEER, assertClient } from './github.js'
|
|
6
7
|
import { NPM } from './npm.js'
|
|
7
8
|
import { CARGO, COMPOSER } from './osv.js'
|
|
8
9
|
|
|
@@ -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), cache: optional(
|
|
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`)
|
package/src/advisories/repos.js
CHANGED
|
@@ -4,7 +4,8 @@ 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
|
|
|
@@ -12,10 +13,10 @@ const PACKAGES_AT_ONCE = 8
|
|
|
12
13
|
|
|
13
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
|
@@ -4,19 +4,21 @@ 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
6
|
import { promisify } from 'node:util'
|
|
7
|
-
import {
|
|
7
|
+
import { gunzip, gzip } from 'node:zlib'
|
|
8
8
|
|
|
9
|
-
import { assertCacheName, assertDirectoryPath, isRepo } from './args.js'
|
|
9
|
+
import { assertCacheName, assertDirectoryPath, assertion, isRepo } from './args.js'
|
|
10
10
|
import { MAX_BYTES, decode } from './http.js'
|
|
11
|
+
import { pool } from './pool.js'
|
|
11
12
|
|
|
12
|
-
const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees', 'github/advisories'])
|
|
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'])
|
|
13
14
|
const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
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)
|
|
20
22
|
|
|
21
23
|
// An environment variable may hold anything: only an absolute path is one.
|
|
22
24
|
const absolute = (path) => (typeof path === 'string' && isAbsolute(path) ? path : undefined)
|
|
@@ -103,12 +105,12 @@ export async function readCache(dir, key) {
|
|
|
103
105
|
return path && await readRegularFile(path)
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
// With `
|
|
107
|
-
// read; one that is not
|
|
108
|
-
export async function readCacheJSON(dir, key, {
|
|
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 } = {}) {
|
|
109
111
|
const bytes = await readCache(dir, key)
|
|
110
112
|
try {
|
|
111
|
-
return bytes && JSON.parse(decode(
|
|
113
|
+
return bytes && JSON.parse(decode(compressed ? await decompress(bytes, { maxOutputLength: MAX_BYTES }) : bytes, key))
|
|
112
114
|
} catch {
|
|
113
115
|
return null
|
|
114
116
|
}
|
|
@@ -129,46 +131,74 @@ export async function writeCache(dir, key, data) {
|
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
export async function writeCacheJSON(dir, key, value, {
|
|
134
|
+
export async function writeCacheJSON(dir, key, value, { compressed = false } = {}) {
|
|
133
135
|
const json = JSON.stringify(value)
|
|
134
|
-
return await writeCache(dir, key,
|
|
136
|
+
return await writeCache(dir, key, compressed ? await compress(json) : json)
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
// A
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
const
|
|
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'))
|
|
141
143
|
|
|
142
|
-
|
|
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) {
|
|
143
147
|
assert.ok(DIRS.has(dir), `Unexpected cache entry: ${dir}`)
|
|
144
|
-
|
|
145
|
-
const age = typeof record?.at === 'number' ? Date.now() - record.at : Number.NaN
|
|
146
|
-
const fresh = age >= 0 && age <= ttl // An entry from the future is not fresh forever.
|
|
147
|
-
return fresh && record.name === name ? record : null
|
|
148
|
+
return cache ? await cache.read(dir, name) : await fromDisk()
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
async function write(dir, name, value, cache, toDisk) {
|
|
151
152
|
assert.ok(DIRS.has(dir), `Unexpected cache entry: ${dir}`)
|
|
152
|
-
|
|
153
|
-
if (!
|
|
154
|
-
await
|
|
153
|
+
if (cache === false) return false
|
|
154
|
+
if (!cache) return await toDisk()
|
|
155
|
+
await cache.write(dir, name, value)
|
|
155
156
|
return true
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
return repos
|
|
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 }))
|
|
166
165
|
}
|
|
167
166
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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)
|
|
186
|
+
const age = typeof record?.at === 'number' ? Date.now() - record.at : Number.NaN
|
|
187
|
+
const fresh = age >= 0 && age <= ttl // An entry from the future is not fresh forever.
|
|
188
|
+
return fresh && record.name === name ? record : null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const writeRecord = (dir, name, value, cache) => writeJSON(dir, name, { at: Date.now(), name, ...value }, cache)
|
|
192
|
+
|
|
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]] : [])))
|
|
198
|
+
}
|
|
199
|
+
|
|
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))
|
|
174
204
|
}
|
package/src/cargo/repos.js
CHANGED
|
@@ -28,11 +28,11 @@ async function askCrates(method, names) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// Name → its GitHub repo, or null where crates.io has no such crate or it
|
|
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)
|
|
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
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { readRegularFile } from '../cache.js'
|
|
6
|
+
|
|
7
|
+
// npm's cache, cacache as npm 5 and later keep it: a response by the URL
|
|
8
|
+
// it answered, under the key `make-fetch-happen:request-cache:<url>`. The
|
|
9
|
+
// index has a file for each key's sha256, of lines `<sha1>\t<entry>`, each
|
|
10
|
+
// entry's sha1 of its own JSON: the last whole one for the key counts, and
|
|
11
|
+
// one with no integrity is one deleted. The content is filed by its
|
|
12
|
+
// sha512, and checked against it.
|
|
13
|
+
const KEY = 'make-fetch-happen:request-cache:'
|
|
14
|
+
const digest = (algorithm, data, encoding) => createHash(algorithm).update(data).digest(encoding)
|
|
15
|
+
const shard = (hex) => [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4)]
|
|
16
|
+
const decoder = new TextDecoder('utf-8')
|
|
17
|
+
|
|
18
|
+
async function lastEntry(root, key) {
|
|
19
|
+
const bytes = await readRegularFile(join(root, 'index-v5', ...shard(digest('sha256', key, 'hex'))))
|
|
20
|
+
let entry = null
|
|
21
|
+
for (const line of bytes ? decoder.decode(bytes).split('\n') : []) {
|
|
22
|
+
const tab = line.indexOf('\t')
|
|
23
|
+
const json = line.slice(tab + 1)
|
|
24
|
+
if (tab < 0 || digest('sha1', json, 'hex') !== line.slice(0, tab)) continue
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(json)
|
|
27
|
+
if (parsed?.key === key) entry = parsed
|
|
28
|
+
} catch {
|
|
29
|
+
// A line cut short, or written over: passed over, as npm passes it.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return entry
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The body npm keeps for `url`, beside what it kept of the response, or
|
|
36
|
+
// null: none kept, one deleted, or content gone or not its sha512.
|
|
37
|
+
export async function readNpmCached(root, url) {
|
|
38
|
+
const entry = await lastEntry(root, `${KEY}${url}`)
|
|
39
|
+
const integrity = typeof entry?.integrity === 'string' ? entry.integrity : ''
|
|
40
|
+
const sha512 = /(?:^|\s)sha512-([\d+/A-Za-z]+={0,2})(?=[\s?]|$)/u.exec(integrity)?.[1]
|
|
41
|
+
if (sha512 === undefined) return null
|
|
42
|
+
const body = await readRegularFile(join(root, 'content-v2', 'sha512', ...shard(Buffer.from(sha512, 'base64').toString('hex'))))
|
|
43
|
+
return body && digest('sha512', body, 'base64') === sha512 ? { body, metadata: entry.metadata } : null
|
|
44
|
+
}
|
package/src/npm/registry.js
CHANGED
|
@@ -3,16 +3,22 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { show } from '../args.js'
|
|
4
4
|
import { NPM_REGISTRY, buildUrl, request } from '../http.js'
|
|
5
5
|
|
|
6
|
+
// The first of these that is set and not empty.
|
|
7
|
+
const npmToken = () => process.env.PREVENTIVE_MEASURES_NPM_TOKEN || process.env.STASIS_NPM_TOKEN || process.env.NPM_TOKEN
|
|
8
|
+
|
|
6
9
|
// Private packages are always scoped.
|
|
7
10
|
export function withNpmToken(name, url, options = {}) {
|
|
8
11
|
const method = options.method ?? 'GET'
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
const own = url === `${NPM_REGISTRY}/${name}` || url.startsWith(`${NPM_REGISTRY}/${name}/`)
|
|
13
|
+
assert.ok(method === 'GET' && `${new URL(url)}` === url && own, `Unexpected request for ${name}: ${method} ${url}`)
|
|
14
|
+
const token = npmToken()
|
|
11
15
|
return token && name.startsWith('@') ? { ...options, method, headers: { ...options.headers, Authorization: `Bearer ${token}` } } : options
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
// A version's document, or with no `spec` the whole package's, every
|
|
19
|
+
// version's and `time`.
|
|
20
|
+
export async function getDocument(method, name, ...spec) {
|
|
21
|
+
const url = buildUrl(NPM_REGISTRY, [...name.split('/'), ...spec])
|
|
16
22
|
const json = await request(url, withNpmToken(name, url, { as: 'json' }))
|
|
17
23
|
assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
|
|
18
24
|
return json
|
package/src/npm/repos.js
CHANGED
|
@@ -62,7 +62,7 @@ export async function resolvePackageRepos(packageNames, options = {}) {
|
|
|
62
62
|
|
|
63
63
|
// Through the cache. Null for a package the registry does not have, or
|
|
64
64
|
// one naming no GitHub repo; any other failure throws.
|
|
65
|
-
|
|
65
|
+
async function lookUpPackageRepo(name) {
|
|
66
66
|
const stored = await readPackageRepoCache(name)
|
|
67
67
|
if (stored) return stored
|
|
68
68
|
const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch(recover(isNotFound, {}))
|
package/src/npm/tarball.js
CHANGED
|
@@ -1,39 +1,16 @@
|
|
|
1
|
-
import assert from 'node:assert/strict'
|
|
2
1
|
import { Buffer } from 'node:buffer'
|
|
3
2
|
import { homedir } from 'node:os'
|
|
4
3
|
import { join, resolve } from 'node:path'
|
|
5
4
|
|
|
6
|
-
import { assertArgs, assertPackage,
|
|
5
|
+
import { assertArgs, assertPackage, assertPackageName, isPlainObject, isSha, optional } from '../args.js'
|
|
6
|
+
import { assertCache } from '../cache.js'
|
|
7
7
|
import { verifiedDownload } from '../download.js'
|
|
8
|
-
import { NPM_REGISTRY,
|
|
8
|
+
import { NPM_REGISTRY, decode } from '../http.js'
|
|
9
|
+
import { readNpmCached } from './cacache.js'
|
|
9
10
|
import { getDocument, withNpmToken } from './registry.js'
|
|
11
|
+
import { checkedDist, getVersion, matchingDist } from './versions.js'
|
|
10
12
|
|
|
11
13
|
const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
|
|
12
|
-
// One sha512 and nothing else, as the registry writes it: a sha1, a second
|
|
13
|
-
// hash or an option is refused rather than trusted. 64 bytes leave the
|
|
14
|
-
// last character before `==` two bits, so only A, Q, g or w is canonical.
|
|
15
|
-
const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
|
|
16
|
-
|
|
17
|
-
// `{ tarball, integrity }` and nothing else, the tarball exactly the
|
|
18
|
-
// registry's own URL for that version. Each field is read once, and only
|
|
19
|
-
// as the object's own, into a copy that is checked and used from then on:
|
|
20
|
-
// the caller's object can change while a request is out, and a field on
|
|
21
|
-
// its prototype is not one it has.
|
|
22
|
-
function checkedDist(method, name, version, dist) {
|
|
23
|
-
assertArgs(method, dist, { tarball: null, integrity: null }, 'dist')
|
|
24
|
-
const [tarball, integrity] = ['tarball', 'integrity'].map((key) => (Object.hasOwn(dist, key) ? dist[key] : undefined))
|
|
25
|
-
assertIntegrity(method, 'dist.integrity', integrity)
|
|
26
|
-
const segments = name.split('/')
|
|
27
|
-
const expected = buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
|
|
28
|
-
assert.ok(tarball === expected, `${method}: dist.tarball must be ${expected}, got ${show(tarball)}`)
|
|
29
|
-
return { tarball, integrity }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function getDist(method, name, version) {
|
|
33
|
-
const json = await getDocument(method, name, version)
|
|
34
|
-
assert.ok(json.version === version, `${method}: the registry answered for ${name}@${show(json.version)}, not ${name}@${version}`)
|
|
35
|
-
return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
|
|
36
|
-
}
|
|
37
14
|
|
|
38
15
|
// Where npm keeps its cache, short of an .npmrc moving it: npm_config_cache,
|
|
39
16
|
// as npm sets it for what it runs, else npm's default. On Windows, npm 4
|
|
@@ -53,23 +30,108 @@ function localPaths(name, version, integrity) {
|
|
|
53
30
|
return [...npm, join(homedir(), '.audit/cache/tgz', `${name.replace(/^@/u, '').replace('/', ':')}-${version}.tgz`)]
|
|
54
31
|
}
|
|
55
32
|
|
|
56
|
-
|
|
33
|
+
// verifyDist's and getTarball's options: `cache` alone.
|
|
34
|
+
const assertOptions = (method, options) => assertArgs(method, options, { cache: optional(assertCache) })
|
|
35
|
+
|
|
36
|
+
const own = (object, key) => (Object.hasOwn(object, key) ? object[key] : undefined)
|
|
37
|
+
const stringOr = (value) => (typeof value === 'string' ? value : undefined)
|
|
38
|
+
|
|
39
|
+
// A string as it is, or an object's strings under `keys`, the rest undefined:
|
|
40
|
+
// none where it is neither, or names none of them.
|
|
41
|
+
function linkOf(value, keys) {
|
|
42
|
+
if (typeof value === 'string') return value
|
|
43
|
+
if (!isPlainObject(value)) return undefined
|
|
44
|
+
const link = Object.fromEntries(keys.map((key) => [key, stringOr(own(value, key))]))
|
|
45
|
+
return keys.some((key) => link[key] !== undefined) ? link : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const mediaType = (value) => (typeof value === 'string' ? value.split(';')[0].trim().toLowerCase() : undefined)
|
|
49
|
+
|
|
50
|
+
function jsonOf(bytes, from) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(decode(bytes, from))
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A version as npm's own cache keeps it, where it keeps its name's full
|
|
59
|
+
// packument, as npm 10 and later fetch it, whose entry for the version
|
|
60
|
+
// matches `given` (matchingDist): that entry is the version's document. An
|
|
61
|
+
// abbreviated packument, as earlier npm fetches, has none of the rest
|
|
62
|
+
// getMeta answers, and is passed over.
|
|
63
|
+
async function npmCachedVersion(name, version, given) {
|
|
64
|
+
const url = `${NPM_REGISTRY}/${name.replace('/', '%2f')}`
|
|
65
|
+
for (const root of npmCacheDirs()) {
|
|
66
|
+
const cached = await readNpmCached(join(root, '_cacache'), url)
|
|
67
|
+
if (!cached || mediaType(cached.metadata?.resHeaders?.['content-type']) !== 'application/json') continue
|
|
68
|
+
const packument = jsonOf(cached.body, url)
|
|
69
|
+
const json = packument?.name === name && isPlainObject(packument.versions) ? own(packument.versions, version) : undefined
|
|
70
|
+
const dist = matchingDist('getMeta', name, version, json, given)
|
|
71
|
+
if (dist) return { dist, json }
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The version's document: with the `dist` the caller has, npm's own cache's
|
|
77
|
+
// where it matches (npmCachedVersion), and neither `cache` nor the cache
|
|
78
|
+
// set is read or written; else as getVersion has it. Beside its dist, the
|
|
79
|
+
// document's gitHead where that is a full commit id, and its repository,
|
|
80
|
+
// homepage and bugs, in the shapes a package.json gives them. Each but
|
|
81
|
+
// dist is left out where there is none.
|
|
82
|
+
export async function getMeta(name, version, options = {}) {
|
|
57
83
|
assertPackage('getMeta', name, version)
|
|
58
|
-
|
|
84
|
+
assertArgs('getMeta', options, { dist: null, cache: optional(assertCache) })
|
|
85
|
+
const given = options.dist === undefined ? undefined : checkedDist('getMeta', name, version, options.dist)
|
|
86
|
+
const { dist, json } = (given && await npmCachedVersion(name, version, given)) ?? await getVersion('getMeta', name, version, given, options.cache)
|
|
87
|
+
const about = {
|
|
88
|
+
gitHead: isSha(own(json, 'gitHead')) ? json.gitHead : undefined,
|
|
89
|
+
repository: linkOf(own(json, 'repository'), ['type', 'url', 'directory']),
|
|
90
|
+
homepage: stringOr(own(json, 'homepage')),
|
|
91
|
+
bugs: linkOf(own(json, 'bugs'), ['url', 'email']),
|
|
92
|
+
}
|
|
93
|
+
return { name, version, dist, ...Object.fromEntries(Object.entries(about).filter(([, value]) => value !== undefined)) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Only the whole package's document has `time`: megabytes for some.
|
|
97
|
+
const PUBLISHED = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.(\d{1,3}))?Z$/u
|
|
98
|
+
const record = (value) => (isPlainObject(value) ? value : {})
|
|
99
|
+
|
|
100
|
+
// A time toISOString writes back as given, its milliseconds padded: none
|
|
101
|
+
// Date.parse moves, as it moves 2018-02-29 to March.
|
|
102
|
+
function readTime(value) {
|
|
103
|
+
const match = typeof value === 'string' ? PUBLISHED.exec(value) : null
|
|
104
|
+
if (match === null) return undefined
|
|
105
|
+
const iso = `${match[1]}.${(match[2] ?? '').padEnd(3, '0')}Z`
|
|
106
|
+
const parsed = Date.parse(iso)
|
|
107
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === iso ? iso : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function getPublishTimes(name) {
|
|
111
|
+
assertPackageName('getPublishTimes', 'name', name)
|
|
112
|
+
const json = await getDocument('getPublishTimes', name)
|
|
113
|
+
const time = record(json.time)
|
|
114
|
+
const times = new Map()
|
|
115
|
+
for (const version of Object.keys(record(json.versions))) {
|
|
116
|
+
const at = readTime(Object.hasOwn(time, version) ? time[version] : undefined)
|
|
117
|
+
if (at !== undefined) times.set(version, at)
|
|
118
|
+
}
|
|
119
|
+
return times
|
|
59
120
|
}
|
|
60
121
|
|
|
61
|
-
export async function verifyDist(name, version, dist) {
|
|
122
|
+
export async function verifyDist(name, version, dist, options = {}) {
|
|
62
123
|
assertPackage('verifyDist', name, version)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
assert.ok(given.integrity === integrity, `verifyDist: ${name}@${version} is ${integrity} on the registry, not ${given.integrity}`)
|
|
124
|
+
assertOptions('verifyDist', options)
|
|
125
|
+
await getVersion('verifyDist', name, version, checkedDist('verifyDist', name, version, dist), options.cache)
|
|
66
126
|
}
|
|
67
127
|
|
|
68
128
|
// Without `dist`, the version document is read every time, cache or not:
|
|
69
129
|
// bytes from a cache are checked against the registry's integrity, never
|
|
70
130
|
// against anything a cache itself holds.
|
|
71
|
-
export async function getTarball(name, version, dist) {
|
|
131
|
+
export async function getTarball(name, version, dist, options = {}) {
|
|
72
132
|
assertPackage('getTarball', name, version)
|
|
73
|
-
|
|
74
|
-
|
|
133
|
+
assertOptions('getTarball', options)
|
|
134
|
+
const { cache } = options
|
|
135
|
+
const { tarball, integrity } = dist === undefined ? (await getVersion('getTarball', name, version, undefined, cache)).dist : checkedDist('getTarball', name, version, dist)
|
|
136
|
+
return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), ours: true, cache, locate: () => tarball, options: withNpmToken(name, tarball) })
|
|
75
137
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { assertArgs, assertion, matches, show } from '../args.js'
|
|
4
|
+
import { readJSON, writeJSON } from '../cache.js'
|
|
5
|
+
import { NPM_REGISTRY, buildUrl, isNotFound, recover } from '../http.js'
|
|
6
|
+
import { getDocument } from './registry.js'
|
|
7
|
+
|
|
8
|
+
const DIR = 'npm/versions' // No expiry: the registry never takes a version twice, nor changes its dist.
|
|
9
|
+
// One sha512 and nothing else, as the registry writes it: a sha1, a second
|
|
10
|
+
// hash or an option is refused rather than trusted. 64 bytes leave the
|
|
11
|
+
// last character before `==` two bits, so only A, Q, g or w is canonical.
|
|
12
|
+
const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
|
|
13
|
+
|
|
14
|
+
// `{ tarball, integrity }` and nothing else, the tarball exactly the
|
|
15
|
+
// registry's own URL for that version. Each field is read once, and only
|
|
16
|
+
// as the object's own, into a copy that is checked and used from then on:
|
|
17
|
+
// the caller's object can change while a request is out, and a field on
|
|
18
|
+
// its prototype is not one it has.
|
|
19
|
+
export function checkedDist(method, name, version, dist) {
|
|
20
|
+
assertArgs(method, dist, { tarball: null, integrity: null }, 'dist')
|
|
21
|
+
const [tarball, integrity] = ['tarball', 'integrity'].map((key) => (Object.hasOwn(dist, key) ? dist[key] : undefined))
|
|
22
|
+
assertIntegrity(method, 'dist.integrity', integrity)
|
|
23
|
+
const segments = name.split('/')
|
|
24
|
+
const expected = buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
|
|
25
|
+
assert.ok(tarball === expected, `${method}: dist.tarball must be ${expected}, got ${show(tarball)}`)
|
|
26
|
+
return { tarball, integrity }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const distOf = (method, name, version, json) => checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
|
|
30
|
+
|
|
31
|
+
// Whether a version document is one to keep, or to take as kept: for that
|
|
32
|
+
// name and version, with a dist that passes.
|
|
33
|
+
function isKeepable(method, name, version, json) {
|
|
34
|
+
if (json?.name !== name || json.version !== version) return false
|
|
35
|
+
try {
|
|
36
|
+
distOf(method, name, version, json)
|
|
37
|
+
return true
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A version's document as `cache` keeps it (readJSON), or none.
|
|
44
|
+
async function keptDocument(method, name, version, cache) {
|
|
45
|
+
const json = await readJSON(DIR, `${name}@${version}`, cache)
|
|
46
|
+
return isKeepable(method, name, version, json) ? json : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The registry's document, kept whole in `cache` once it is keepable; one
|
|
50
|
+
// whose dist is refused is answered all the same.
|
|
51
|
+
async function keepDocument(method, name, version, json, cache) {
|
|
52
|
+
assert.ok(json.version === version, `${method}: the registry answered for ${name}@${show(json.version)}, not ${name}@${version}`)
|
|
53
|
+
if (isKeepable(method, name, version, json)) await writeJSON(DIR, `${name}@${version}`, json, cache)
|
|
54
|
+
return json
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fetchDocument = async (method, name, version, cache) => await keepDocument(method, name, version, await getDocument(method, name, version), cache)
|
|
58
|
+
|
|
59
|
+
// A version's document, kept or else fetched, for what is not an
|
|
60
|
+
// integrity: a package's repo (advisories.js), as trusted as setCacheDir's
|
|
61
|
+
// repo records are. Null where the registry does not have that version:
|
|
62
|
+
// its 404 alone, never a failure of the cache's.
|
|
63
|
+
export async function getVersionDocument(method, name, version, cache) {
|
|
64
|
+
const kept = await keptDocument(method, name, version, cache)
|
|
65
|
+
if (kept) return kept
|
|
66
|
+
const json = await getDocument(method, name, version).catch(recover(isNotFound, null))
|
|
67
|
+
return json && await keepDocument(method, name, version, json, cache)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The dist of a version document a cache answers, where it is that
|
|
71
|
+
// version's, its dist passes, and its tarball and integrity are `given`'s
|
|
72
|
+
// (checkedDist): a cache never supplies a dist, only confirms one.
|
|
73
|
+
export function matchingDist(method, name, version, json, given) {
|
|
74
|
+
if (!isKeepable(method, name, version, json)) return undefined
|
|
75
|
+
const dist = distOf(method, name, version, json)
|
|
76
|
+
return dist.integrity === given.integrity && dist.tarball === given.tarball ? dist : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A version's document, and its dist checked. A kept document is read only
|
|
80
|
+
// for a caller that already has a dist, `given`, and answers with no
|
|
81
|
+
// request where it matches (matchingDist). Else, or without one, the
|
|
82
|
+
// registry is asked, and its document, kept in place of any other, throws
|
|
83
|
+
// where it does not.
|
|
84
|
+
export async function getVersion(method, name, version, given, cache) {
|
|
85
|
+
if (given !== undefined) {
|
|
86
|
+
const kept = await readJSON(DIR, `${name}@${version}`, cache)
|
|
87
|
+
const dist = matchingDist(method, name, version, kept, given)
|
|
88
|
+
if (dist) return { dist, json: kept }
|
|
89
|
+
}
|
|
90
|
+
const json = await fetchDocument(method, name, version, cache)
|
|
91
|
+
const dist = distOf(method, name, version, json)
|
|
92
|
+
if (given !== undefined) {
|
|
93
|
+
assert.ok(dist.integrity === given.integrity, `${method}: ${name}@${version} is ${dist.integrity} on the registry, not ${given.integrity}`)
|
|
94
|
+
assert.ok(dist.tarball === given.tarball, `${method}: ${name}@${version} is at ${dist.tarball} on the registry, not ${given.tarball}`)
|
|
95
|
+
}
|
|
96
|
+
return { dist, json }
|
|
97
|
+
}
|