@preventive/upstream 1.0.0-alpha.10 → 1.0.0-alpha.12

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 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'
@@ -36,9 +38,16 @@ export interface AdvisoryOptions {
36
38
  // Also asks each npm, cargo and composer package's GitHub repository
37
39
  // for its published advisories, which it has before GitHub reviews them
38
40
  // into the databases above: the package's `github` where given, else
39
- // the one npm's metadata, crates.io or Packagist names, looked up
40
- // through the cache. Needs `github`.
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`.
41
46
  repoAdvisories?: boolean
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
42
51
  // Also returns each advisory's full text, as `details`. OSV's records
43
52
  // and a repository's listing already carry it; npm's registry does not,
44
53
  // and its rows take OSV's record of their GHSA, one more request each
@@ -108,6 +117,11 @@ export interface Advisory {
108
117
  // nothing, and a GHSA OSV does not have adds no `details`. Any other
109
118
  // failure, a malformed answer, or one about something not asked, throws:
110
119
  // nothing is left out quietly.
120
+ // A repository's listing is kept for 90 minutes, as one entry for every
121
+ // spelling of its name, holding only what rows are made from, `details`
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.
111
125
  // Sorted by ecosystem and name. Versions are matched by npm's semver, from
112
126
  // the npm beside node, or by the semver peer where there is no npm.
113
127
  export function advisories(packages: Iterable<Package>, options?: AdvisoryOptions): Promise<Advisory[]>
package/git.d.ts CHANGED
@@ -6,8 +6,8 @@ export interface GitCheckout {
6
6
  // `owner/name`, when the origin remote is a GitHub one. Never the
7
7
  // remote's URL, which can name a private host or carry a token.
8
8
  github?: string
9
- // Where the directory sits in its checkout, `/`-separated; absent at
10
- // the root.
9
+ // Where the directory sits in its checkout, `/`-separated; `''` at the
10
+ // root.
11
11
  directory?: string
12
12
  // `https://github.com/<github>`, whenever there is a `github`.
13
13
  url?: string
package/npm.d.ts CHANGED
@@ -32,14 +32,45 @@ export const defaultCacheDir: string | undefined
32
32
  // cache: every read of it misses and every write is skipped. getTarball
33
33
  // reads ours all the same, set or not.
34
34
  //
35
- // The directory is trusted: a package's repo is answered from it as it
36
- // was written, with no request. Point it only at storage that nothing
37
- // less trusted than the caller can write, never a cache shared with or
38
- // restored from lower-trust jobs. Tarballs, crates (cargo.js), zips
35
+ // The directory is trusted: a package's repo, as recorded or as a version
36
+ // document kept here names it, and for 90 minutes a repository's published
37
+ // advisories (advisories.js), are answered from it as they were written,
38
+ // with no request. Point it only at storage that
39
+ // nothing less trusted than the caller can write, never a cache shared
40
+ // with or restored from lower-trust jobs. Tarballs, crates (cargo.js), zips
39
41
  // (soldeer.js) and tree tarballs (github.js) don't rely on this, since
40
- // they are checked against their integrity on every call.
42
+ // they are checked against their integrity on every call; and the version
43
+ // documents kept here (getMeta) never supply an integrity, only confirm
44
+ // one a caller gives (verifyDist, and getMeta with a `dist`). A call's `cache` option, where it has
45
+ // one, keeps what it caches elsewhere, or writes none of it here.
41
46
  export function setCacheDir(dir?: string | false): void
42
47
 
48
+ // A store of the caller's, a database's say, for what would otherwise be
49
+ // kept in setCacheDir's cache: `read` answers the value last written
50
+ // under that `type` and `key`, or null or undefined for none. A `type` is
51
+ // what is kept and a `key` which one: `npm/versions` and `npm/tarballs`
52
+ // with `<name>@<version>` for a version's document and tarball; and for
53
+ // advisories.js, `github/advisories` with the repository's `owner/name` in
54
+ // lowercase for its listing, and `cargo/repos`, `composer/repos` and
55
+ // `soldeer/repos` with a package's name for the repository it names.
56
+ // Values are plain JSON data, which `read` may answer as written or as a
57
+ // copy, but a tarball's are its bytes, a Uint8Array. Whatever it answers
58
+ // is checked as a cached file is: one malformed, stale or kept differently
59
+ // is a miss, and a tarball's bytes that do not match throw. A rejection
60
+ // from either is the call's. It is trusted as setCacheDir's directory is.
61
+ export interface CacheStore {
62
+ read(type: string, key: string): Promise<unknown>
63
+ write(type: string, key: string, value: unknown): Promise<void>
64
+ }
65
+
66
+ // Where a call keeps what it caches: in setCacheDir's cache where `cache`
67
+ // is left out; in a store of the caller's in place of it; or with `false`,
68
+ // nowhere, though setCacheDir's cache is read all the same. Other tools'
69
+ // caches and ours are read for tarballs whatever it is (getTarball).
70
+ export interface CacheOptions {
71
+ cache?: CacheStore | false
72
+ }
73
+
43
74
  // A failed request: `status` is the HTTP status the registry answered
44
75
  // with (404 for a package or version it does not have).
45
76
  export class HttpError extends Error {
@@ -49,9 +80,9 @@ export class HttpError extends Error {
49
80
 
50
81
  // A package's GitHub repo as its registry metadata names it — getRepo
51
82
  // (package.js) over its `latest` document: `github` is `owner/name`, and
52
- // `directory` is where in the repo the package sits, absent for a
53
- // package at the repo root. Throws when the package cannot be fetched or
54
- // names no GitHub repo.
83
+ // `directory` is where in the repo the package sits, `''` at the repo
84
+ // root, absent where it declares none. Throws when the package cannot be
85
+ // fetched or names no GitHub repo.
55
86
  export interface GitHubLink {
56
87
  github: string
57
88
  directory?: string
@@ -73,7 +104,8 @@ export function resolvePackageRepos(packageNames: Iterable<string>, options?: {
73
104
  // release that resolved repos by other rules included; a write answers
74
105
  // false where it could not write, and never throws. A write takes the
75
106
  // `github` a lookup gives, `owner/name` with no trailing `.git`, so what
76
- // it writes reads back.
107
+ // it writes reads back; a `directory` left out is unknown, and `''` the
108
+ // repo root.
77
109
  export function readPackageRepoCache(name: string): Promise<PackageRepo | null>
78
110
  export function writePackageRepoCache(name: string, github: string, directory?: string): Promise<boolean>
79
111
 
@@ -86,25 +118,83 @@ export interface Dist {
86
118
  integrity: string
87
119
  }
88
120
 
121
+ // `gitHead` is the commit the document names, as npm reads it off the git
122
+ // checkout it publishes from, where it is a full commit id: 40 lowercase
123
+ // hex digits, or 64. `repository`, `homepage` and `bugs` are the
124
+ // document's, as the package.json published had them, or npm filled them
125
+ // in from one another: each a string as it is, or for `repository` and
126
+ // `bugs` an object, of which only the strings named below are read. Each is
127
+ // absent where the document has none, or has another shape, or an object
128
+ // with none of those strings. All of it is the publisher's word, held to no
129
+ // repository.
89
130
  export interface PackageMeta {
90
131
  name: string
91
132
  version: string
92
133
  dist: Dist
134
+ gitHead?: string
135
+ repository?: string | PackageRepository
136
+ homepage?: string
137
+ bugs?: string | PackageBugs
138
+ }
139
+
140
+ export interface PackageRepository {
141
+ type: string | undefined
142
+ url: string | undefined
143
+ directory: string | undefined
144
+ }
145
+
146
+ export interface PackageBugs {
147
+ url: string | undefined
148
+ email: string | undefined
149
+ }
150
+
151
+ // getMeta's options: `cache`, and the `dist` a caller already has of that
152
+ // version, as a lockfile gives it, held to the rules above.
153
+ export interface MetaOptions extends CacheOptions {
154
+ dist?: Dist
93
155
  }
94
156
 
95
157
  // The registry's version document, refused unless it is for that name and
96
158
  // version and its dist is held to the rules above.
97
- export function getMeta(name: string, version: string): Promise<PackageMeta>
159
+ //
160
+ // With `dist`, npm's own cache comes first, where getTarball looks for
161
+ // npm's tarballs: where it keeps the full packument of that name, as npm 10
162
+ // and later fetch it, and that version's entry there has the given dist's
163
+ // tarball and integrity, that entry answers, with no request, and neither
164
+ // `cache` nor setCacheDir's cache is read or written. An abbreviated
165
+ // packument, as earlier npm fetches, is passed over, as is one whose
166
+ // content does not match the sha512 npm filed it by. What it answers
167
+ // beside the dist is taken from npm's cache as it is from setCacheDir's.
168
+ // Else the document `cache` keeps answers, with no request, where its
169
+ // tarball and integrity are the given dist's; else the registry's is asked
170
+ // for, and throws where they are not.
171
+ //
172
+ // Without `dist`, the registry is asked on every call: a cache never
173
+ // supplies a dist. Kept whole, as `cache` says, and for good once it
174
+ // passes, as the registry never takes a version twice: in setCacheDir's
175
+ // cache, compressed, where `cache` is left out. One refused is not kept.
176
+ export function getMeta(name: string, version: string, options?: MetaOptions): Promise<PackageMeta>
177
+
178
+ // When the registry says each version of a package it lists was published,
179
+ // by version, as Date#toISOString writes it: from the whole package's
180
+ // document, the only one with `time`, megabytes for some. A version whose
181
+ // time is missing, not an ISO 8601 UTC time, or not one there is, as
182
+ // 2018-02-29, is left out. Refused unless the document is for that name.
183
+ export function getPublishTimes(name: string): Promise<Map<string, string>>
98
184
 
99
- // Fetches the registry's dist for that version and throws unless its
100
- // integrity is the one given, as for a dist read off a lockfile.
101
- export function verifyDist(name: string, version: string, dist: Dist): Promise<void>
185
+ // Throws unless the integrity of that version is the one given, as for a
186
+ // dist read off a lockfile: by the version document `cache` keeps, with no
187
+ // request, else by the registry's, kept then as getMeta keeps it. One kept
188
+ // with another integrity, refused, or for another name or version, is
189
+ // passed over for the registry's, which throws where it has another.
190
+ export function verifyDist(name: string, version: string, dist: Dist, options?: CacheOptions): Promise<void>
102
191
 
103
192
  // A published version's gzipped tarball, whole, in memory. Without `dist`,
104
- // the version document is fetched on every call, cached tarball or not;
105
- // with one, it is trusted as given and nothing but the tarball is asked
106
- // for. Either way the bytes are checked against `dist.integrity`, whether
107
- // they were downloaded (before they are cached) or read from a cache.
193
+ // the version document is fetched on every call, cached tarball or not,
194
+ // and kept as getMeta keeps it; with one, it is trusted as given and
195
+ // nothing but the tarball is asked for. Either way the bytes are checked
196
+ // against `dist.integrity`, whether they were downloaded (before they are
197
+ // cached) or read from a cache.
108
198
  //
109
199
  // Other caches are read first, and never written: npm's (cacache under
110
200
  // npm_config_cache, else npm's default, `~/.npm` outside Windows; and npm
@@ -112,5 +202,7 @@ export function verifyDist(name: string, version: string, dist: Dist): Promise<v
112
202
  // `~/.audit/cache/tgz/<org>:<name>-<version>.tgz`, then defaultCacheDir
113
203
  // and cacheDirFor('stasis'), as setCacheDir files a tarball in either,
114
204
  // whether set or not. A file there that does not match is passed over.
115
- // Then setCacheDir's cache, where one that does not match throws.
116
- export function getTarball(name: string, version: string, dist?: Dist): Promise<Uint8Array>
205
+ // Then setCacheDir's cache, or a `cache` store in its place, where bytes
206
+ // that do not match throw. A tarball downloaded is kept there, unless
207
+ // `cache` is false.
208
+ 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.d.ts CHANGED
@@ -5,7 +5,10 @@ export interface PackageRepoLink {
5
5
  github?: string
6
6
  // Where in that repo the package sits, `/`-separated, as the tree path
7
7
  // itself (a homepage URL's decoded; a `repository.directory`'s `\` read
8
- // as `/`); absent at its root, and whenever `github` is.
8
+ // as `/`, its empty and `.` parts dropped); `''` at its root, where
9
+ // `repository.directory` declares it (`./`, `/`, `/.`, `.`, `''`). Absent
10
+ // where unknown: neither field names a directory in it (one with a `..`
11
+ // part names none), and whenever `github` is absent.
9
12
  directory?: string
10
13
  // `https://github.com/<github>`, whenever there is a `github`.
11
14
  url?: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.0.0-alpha.10",
3
+ "version": "1.0.0-alpha.12",
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",
@@ -1,6 +1,7 @@
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,6 +9,12 @@ import { advisoryUrl, covered, detailsOf, isText, mayBeInRange, metrics } from '
8
9
  import { soldeerRepos } from './repos.js'
9
10
 
10
11
  const REPOS_AT_ONCE = 4
12
+ const DIR = 'github/advisories'
13
+ // A maintainer publishes one whenever they are ready, and seeing it before
14
+ // GitHub reviews it is what a repository is asked for: 90 minutes, not a month.
15
+ const LISTING_TTL_MS = 90 * 60 * 1000
16
+ // Stamped on each entry, and raised when a listing is kept differently.
17
+ const VERSION = 1
11
18
 
12
19
  const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' && isGhsa(advisory.ghsa_id)
13
20
  && advisory.state === 'published' && isText(advisory.summary)
@@ -15,7 +22,51 @@ const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' &&
15
22
 
16
23
  export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function')
17
24
 
18
- const listAdvisories = (github, repo) => github.listRepoAdvisories({ repo }).catch(recover(isGone, []))
25
+ // What rows are made from, and all that is kept of a listing: each advisory
26
+ // not withdrawn, with its CVSS vector GitHub prefers, and each vulnerable
27
+ // range with the package it names. A listing that is malformed is refused
28
+ // whole, and never kept; one whose only fault is a description (isDigest)
29
+ // is answered, but not kept either.
30
+ function digest(repo, list) {
31
+ return list.flatMap((advisory) => {
32
+ assert.ok(isRepoAdvisory(advisory), `advisories: malformed advisory from ${repo}`)
33
+ if (advisory.withdrawn_at) return []
34
+ const cvss = [advisory.cvss_severities?.cvss_v3, advisory.cvss_severities?.cvss_v4, advisory.cvss].find((entry) => typeof entry?.vector_string === 'string')
35
+ const ranges = (advisory.vulnerabilities ?? []).map((vulnerability) => {
36
+ const range = vulnerability?.vulnerable_version_range ?? ''
37
+ assert.ok(typeof range === 'string', `advisories: malformed range in ${advisory.ghsa_id}`)
38
+ const { ecosystem, name } = vulnerability?.package ?? {}
39
+ return { range, ...(typeof ecosystem === 'string' && { ecosystem }), ...(typeof name === 'string' && { name }) }
40
+ })
41
+ return [{
42
+ ghsa: advisory.ghsa_id,
43
+ title: advisory.summary,
44
+ ...(advisory.description != null && advisory.description !== '' && { description: advisory.description }),
45
+ ...metrics({ severity: advisory.severity, score: cvss?.score, vector: cvss?.vector_string, cwe: advisory.cwe_ids }),
46
+ ranges,
47
+ }]
48
+ })
49
+ }
50
+
51
+ // A description is checked only for `details` (detailsOf), so a malformed
52
+ // one leaves the listing usable, but not kept.
53
+ const isDigest = (advisory) => advisory && typeof advisory === 'object' && isGhsa(advisory.ghsa) && isText(advisory.title)
54
+ && (advisory.description === undefined || isText(advisory.description))
55
+ && Array.isArray(advisory.ranges) && advisory.ranges.every((entry) => typeof entry?.range === 'string')
56
+
57
+ // A repository's listing, digested, through the cache: GitHub's names are
58
+ // case-insensitive, so one entry answers every spelling. A repository gone,
59
+ // renamed or blocked has none, and that is not kept.
60
+ async function listAdvisories(github, repo, cache) {
61
+ const name = repo.toLowerCase()
62
+ const entry = await readRecord(DIR, name, cache, LISTING_TTL_MS)
63
+ if (entry?.v === VERSION && Array.isArray(entry.advisories) && entry.advisories.every(isDigest)) return entry.advisories
64
+ const list = await github.listRepoAdvisories({ repo }).catch(recover(isGone, null))
65
+ if (list === null) return []
66
+ const advisories = digest(repo, list)
67
+ if (advisories.every(isDigest)) await writeRecord(DIR, name, { v: VERSION, advisories }, cache)
68
+ return advisories
69
+ }
19
70
 
20
71
  // GitHub's `>= 1.0.0, < 1.2.6` is npm's with the commas dropped.
21
72
  // Maintainers write these unreviewed: one without a range, or with one
@@ -23,17 +74,16 @@ const listAdvisories = (github, repo) => github.listRepoAdvisories({ repo }).cat
23
74
  // may publish it long before the advisory database has one, and its
24
75
  // `description` is the text `details` asks for: the listing has it all.
25
76
  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
77
  return {
28
78
  name,
29
79
  source: 'repository',
30
- id: advisory.ghsa_id,
31
- ghsa: advisory.ghsa_id,
32
- url: advisoryUrl(advisory.ghsa_id, repo),
80
+ id: advisory.ghsa,
81
+ ghsa: advisory.ghsa,
82
+ url: advisoryUrl(advisory.ghsa, repo),
33
83
  aliases: [],
34
- title: advisory.summary,
35
- ...(details && detailsOf(advisory.description, advisory.ghsa_id)),
36
- ...metrics({ severity: advisory.severity, score: cvss?.score, vector: cvss?.vector_string, cwe: advisory.cwe_ids }),
84
+ title: advisory.title,
85
+ ...(details && detailsOf(advisory.description, advisory.ghsa)),
86
+ ...metrics({ severity: advisory.severity, score: advisory.cvss, vector: advisory.cvssVector, cwe: advisory.cwe }),
37
87
  range,
38
88
  versions: covered(asked, range.replaceAll(',', ' '), covers),
39
89
  }
@@ -41,21 +91,18 @@ function fromRepository(repo, name, advisory, range, asked, { covers, details })
41
91
 
42
92
  // Each asked name's repository, `repoOf` it, is asked once, and its
43
93
  // advisories' vulnerable ranges become rows for the names that `takes` a
44
- // vulnerability, one per name, advisory and range, holding the asked
94
+ // range's package, one per name, advisory and range, holding the asked
45
95
  // versions it `covers`, and with `details`, its text.
46
- async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers, details }) {
96
+ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers, details, cache }) {
47
97
  // GitHub's names are case-insensitive: one spelling asks for all.
48
98
  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])) }))
99
+ 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
100
  const rows = new Map()
51
101
  for (const { repo, names, list } of listed) {
52
102
  for (const advisory of list) {
53
- assert.ok(isRepoAdvisory(advisory), `advisories: malformed advisory from ${repo}`)
54
- for (const vulnerability of advisory.withdrawn_at ? [] : advisory.vulnerabilities ?? []) {
55
- const range = vulnerability?.vulnerable_version_range ?? ''
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 }))
103
+ for (const { range, ...pkg } of advisory.ranges) {
104
+ for (const name of names.filter((candidate) => takes(candidate, pkg))) {
105
+ rows.set(`${name} ${advisory.ghsa} ${range}`, fromRepository(repo, name, advisory, range, asked.get(name), { covers, details }))
59
106
  }
60
107
  }
61
108
  }
@@ -63,9 +110,10 @@ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true,
63
110
  return [...rows.values()]
64
111
  }
65
112
 
66
- // Each asked name's repository: as given, `known`, or else looked up.
67
- async function reposOf(asked, known, lookUp) {
68
- const found = await lookUp([...asked.keys()].filter((name) => !known.has(name)))
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 })
69
117
  return (name) => known.get(name) ?? found.get(name)
70
118
  }
71
119
 
@@ -74,12 +122,12 @@ async function reposOf(asked, known, lookUp) {
74
122
  // each package's repository, `known` or else looked up, adds what it
75
123
  // publishes for GitHub's `ecosystem` entries naming the package, for the
76
124
  // versions `rows` do not already report under that GHSA.
77
- export async function withRepositories(rows, asked, { github, repoAdvisories, known, details }, { ecosystem, lookUp, covers }) {
125
+ export async function withRepositories(rows, asked, { github, repoAdvisories, known, details, cache }, { ecosystem, lookUp, covers }) {
78
126
  if (!repoAdvisories) return rows
79
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}`))))
80
- const repoOf = await reposOf(asked, known, lookUp)
81
- const takes = (name, pkg) => pkg?.ecosystem === ecosystem && pkg.name === name
82
- const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers, details })
128
+ const repoOf = await reposOf(asked, known, lookUp, cache)
129
+ const takes = (name, pkg) => pkg.ecosystem === ecosystem && pkg.name === name
130
+ const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers, details, cache })
83
131
  return [...rows, ...added.map((row) => ({ ...row, versions: row.versions.filter((version) => !reported.has(`${row.name} ${row.id} ${version}`)) }))]
84
132
  }
85
133
 
@@ -93,7 +141,7 @@ export const GITHUB = {
93
141
  repositoryOnly: true,
94
142
  assertName: assertRepo,
95
143
  assertVersion: assertion('a version or a branch name', isRefName),
96
- advisories: (asked, { github, details }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder, details }),
144
+ advisories: (asked, { github, details, cache }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder, details, cache }),
97
145
  }
98
146
 
99
147
  // Soldeer packages, which no advisory database has: the repository each
@@ -104,5 +152,5 @@ export const SOLDEER = {
104
152
  repositoryOnly: true,
105
153
  assertName: assertSoldeerName,
106
154
  assertVersion: assertSoldeerVersion,
107
- advisories: async (asked, { github, known, details }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos), details }),
155
+ advisories: async (asked, { github, known, details, cache }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos, cache), details, cache }),
108
156
  }
@@ -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'
@@ -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))
@@ -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 { lookUpPackageRepo } from '../npm/repos.js'
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
- export const crateRepos = (names) => lookUpCrateRepos('advisories', names)
46
- export const composerRepos = (names) => cachedRepos('composer/repos', names, (missing) => fetchEach(missing, fetchComposerRepo))
47
- export const npmRepos = (names) => fetchEach(names, async (name) => (await lookUpPackageRepo(name))?.github ?? null)
48
- export const soldeerRepos = (names) => cachedRepos('soldeer/repos', names, (missing) => fetchEach(missing, fetchSoldeerRepo))
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
- 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 } = {}) {
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
- return await writeCache(dir, key, JSON.stringify(value))
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
- export async function readRecord(dir, name) {
127
- const record = await readCacheJSON(dir, `${name}.json`)
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 <= RECORD_TTL_MS // An entry from the future is not fresh forever.
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) => writeCacheJSON(dir, `${name}.json`, { at: Date.now(), name, ...value })
191
+ export const writeRecord = (dir, name, value, cache) => writeJSON(dir, name, { at: Date.now(), name, ...value }, cache)
134
192
 
135
- // Name → the GitHub repo cached for it, for those of `names` that have one.
136
- export async function readRepos(dir, names) {
137
- const repos = new Map()
138
- for (const name of names) {
139
- const entry = await readRecord(dir, name)
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 into the cache where it found a repo.
146
- export async function addRepos(dir, repos, answer) {
147
- for (const [name, github] of answer) {
148
- repos.set(name, github)
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
  }
@@ -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: only a repo found is cached,
32
- // for a month. A failed request throws, or with `soft` leaves its names
33
- // out; `cachedOnly` asks nothing.
34
- export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly = false } = {}) {
35
- const repos = await readRepos(DIR, names)
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, readCache, readRegularFile, writeCache } from './cache.js'
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 (a mismatch throws), else what `locate` answers,
22
- // checked and then cached.
23
- export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], ours = false, locate, options = {}, list }) {
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 readCache(dir, key)
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 writeCache(dir, key, bytes)
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 }), ...(directory && { directory }), ...(github && { url: `https://github.com/${github}` }), ...(commit && { commit }) }
57
+ return { ...(github && { github }), directory, ...(github && { url: `https://github.com/${github}` }), ...(commit && { commit }) }
58
58
  }
@@ -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
+ }
@@ -6,13 +6,16 @@ import { NPM_REGISTRY, buildUrl, request } from '../http.js'
6
6
  // Private packages are always scoped.
7
7
  export function withNpmToken(name, url, options = {}) {
8
8
  const method = options.method ?? 'GET'
9
- assert.ok(method === 'GET' && `${new URL(url)}` === url && url.startsWith(`${NPM_REGISTRY}/${name}/`), `Unexpected request for ${name}: ${method} ${url}`)
9
+ const own = url === `${NPM_REGISTRY}/${name}` || url.startsWith(`${NPM_REGISTRY}/${name}/`)
10
+ assert.ok(method === 'GET' && `${new URL(url)}` === url && own, `Unexpected request for ${name}: ${method} ${url}`)
10
11
  const token = process.env.NPM_TOKEN
11
12
  return token && name.startsWith('@') ? { ...options, method, headers: { ...options.headers, Authorization: `Bearer ${token}` } } : options
12
13
  }
13
14
 
14
- export async function getDocument(method, name, spec) {
15
- const url = buildUrl(NPM_REGISTRY, [...name.split('/'), spec])
15
+ // A version's document, or with no `spec` the whole package's, every
16
+ // version's and `time`.
17
+ export async function getDocument(method, name, ...spec) {
18
+ const url = buildUrl(NPM_REGISTRY, [...name.split('/'), ...spec])
16
19
  const json = await request(url, withNpmToken(name, url, { as: 'json' }))
17
20
  assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
18
21
  return json
package/src/npm/repos.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertNames, assertPackageName, assertion, isRepo } from '../args.js'
3
+ import { assertNames, assertPackageName, assertion, isRepo, optional } from '../args.js'
4
4
  import { readRecord, writeRecord } from '../cache.js'
5
5
  import { isNotFound, recover } from '../http.js'
6
6
  import { assertRepoDirectory, getRepo, isRepoDirectory } from '../package.js'
@@ -12,8 +12,10 @@ const CONCURRENCY = 8
12
12
  // Stamped on each entry, and raised when getRepo would answer differently:
13
13
  // 2 is `repository` taking precedence over `bugs`; 3 the shorthand dropping
14
14
  // a `.git`, URLs read past whitespace, and `repository.directory`'s `\` read
15
- // as `/`.
16
- const VERSION = 3
15
+ // as `/`; 4 a declared root answered as `''`, no directory declared left
16
+ // out (unknown) rather than read as the root, and a declared directory's
17
+ // empty and `.` parts dropped.
18
+ const VERSION = 4
17
19
 
18
20
  // A slug a lookup gives: getRepo answers no repo ending in `.git`, the
19
21
  // suffix it drops (withoutDotGit; `.GIT` it keeps, and so does this).
@@ -33,23 +35,23 @@ export async function getGitHub(name) {
33
35
 
34
36
  // An entry under another VERSION was resolved by other rules, and may
35
37
  // name a repo getRepo no longer would (a stale tracker's, or a shorthand's
36
- // with its `.git`): a miss, as is one naming a repo or a directory no
37
- // lookup gives, or without `directory`, which would read as a package at
38
- // the repo root.
38
+ // with its `.git`, or the root where a package declared no directory): a
39
+ // miss, as is one naming a repo or a directory no lookup gives. One without
40
+ // `directory` is a package whose place in its repo is unknown.
39
41
  export async function readPackageRepoCache(name) {
40
42
  assertPackageName('readPackageRepoCache', 'name', name)
41
43
  const entry = await readRecord(DIR, name)
42
- if (entry?.v !== VERSION || !isLookedUpRepo(entry.github) || !isRepoDirectory(entry.directory)) return null
43
- return { github: entry.github, ...(entry.directory && { directory: entry.directory }) }
44
+ if (entry?.v !== VERSION || !isLookedUpRepo(entry.github) || (entry.directory !== undefined && !isRepoDirectory(entry.directory))) return null
45
+ return { github: entry.github, ...(entry.directory !== undefined && { directory: entry.directory }) }
44
46
  }
45
47
 
46
48
  // Only resolved repos are cached: a 404, a rate limit and a package with
47
49
  // no repo link fail alike.
48
- export async function writePackageRepoCache(name, github, directory = '') {
50
+ export async function writePackageRepoCache(name, github, directory) {
49
51
  assertPackageName('writePackageRepoCache', 'name', name)
50
52
  assertLookedUpRepo('writePackageRepoCache', 'github', github)
51
- assertRepoDirectory('writePackageRepoCache', 'directory', directory)
52
- return await writeRecord(DIR, name, { v: VERSION, github, directory })
53
+ optional(assertRepoDirectory)('writePackageRepoCache', 'directory', directory)
54
+ return await writeRecord(DIR, name, { v: VERSION, github, ...(directory !== undefined && { directory }) })
53
55
  }
54
56
 
55
57
  export async function resolvePackageRepos(packageNames, options = {}) {
@@ -60,11 +62,11 @@ export async function resolvePackageRepos(packageNames, options = {}) {
60
62
 
61
63
  // Through the cache. Null for a package the registry does not have, or
62
64
  // one naming no GitHub repo; any other failure throws.
63
- export async function lookUpPackageRepo(name) {
65
+ async function lookUpPackageRepo(name) {
64
66
  const stored = await readPackageRepoCache(name)
65
67
  if (stored) return stored
66
68
  const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch(recover(isNotFound, {}))
67
69
  if (!github) return null
68
70
  await writePackageRepoCache(name, github, directory)
69
- return { github, ...(directory && { directory }) }
71
+ return { github, ...(directory !== undefined && { directory }) }
70
72
  }
@@ -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, assertion, matches, show } from '../args.js'
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, buildUrl } from '../http.js'
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
- export async function getMeta(name, version) {
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
- return { name, version, dist: await getDist('getMeta', name, version) }
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
- const given = checkedDist('verifyDist', name, version, dist)
64
- const { integrity } = await getDist('verifyDist', name, version)
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
- const { tarball, integrity } = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
74
- return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), ours: true, locate: () => tarball, options: withNpmToken(name, tarball) })
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
+ }
package/src/package.js CHANGED
@@ -38,8 +38,16 @@ function repoSubdirectory(value) {
38
38
 
39
39
  // `repository.directory` is a path its author may write with Windows' `\`.
40
40
  // A tree path is `/`-separated: a `\` is a name's own only in a homepage's
41
- // (`%5C`), which is the path itself.
42
- const declaredDirectory = (value) => repoSubdirectory(str(value).replaceAll('\\', '/'))
41
+ // (`%5C`), which is the path itself. Its empty and `.` parts are dropped
42
+ // (`./a//b/.` is `a/b`), and one of nothing else (`./`, `/`, `/.`, `.`, `''`)
43
+ // declares the repo's root, `''`; one with a `..` part names none, whatever
44
+ // it would come to. A package.json that declares none says nothing of where
45
+ // in the repo it sits.
46
+ function declaredDirectory(value) {
47
+ if (typeof value !== 'string') return undefined
48
+ const parts = value.replaceAll('\\', '/').split('/').filter((part) => part !== '' && part !== '.')
49
+ return parts.length === 0 ? '' : repoSubdirectory(parts.join('/'))
50
+ }
43
51
 
44
52
  export const isRepoDirectory = (value) => typeof value === 'string' && (value === '' || repoSubdirectory(value) === value)
45
53
  export const assertRepoDirectory = assertion('a path inside the repository', isRepoDirectory)
@@ -54,7 +62,7 @@ export function getRepo(pkg) {
54
62
  // `bugs` tracker can be left pointing at a former owner or misspelt.
55
63
  const github = [declared, repoIn(bugsRegex, urlOf(bugs)), homepageRepo].find(isRepo)
56
64
  if (github === undefined) return {}
57
- const directory = (sameName(declared, github) && declaredDirectory(repository?.directory))
58
- || (sameName(homepageRepo, github) && repoSubdirectory(decodePath(homepage.directory)))
59
- return { github, ...(directory && { directory }), url: `https://github.com/${github}` }
65
+ const directory = (sameName(declared, github) ? declaredDirectory(repository?.directory) : undefined)
66
+ ?? (sameName(homepageRepo, github) ? repoSubdirectory(decodePath(homepage.directory)) : undefined)
67
+ return { github, ...(directory !== undefined && { directory }), url: `https://github.com/${github}` }
60
68
  }