@preventive/upstream 1.0.0-alpha.8 → 1.0.0

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.
@@ -4,18 +4,19 @@ import { show } from '../args.js'
4
4
  import { addRepos, readRepos } from '../cache.js'
5
5
  import { lookUpCrateRepos } from '../cargo/repos.js'
6
6
  import { PACKAGIST_REPO, SOLDEER_API, buildUrl, isNotFound, recover, request } from '../http.js'
7
- import { 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
 
11
12
  const PACKAGES_AT_ONCE = 8
12
13
 
13
- // Name → its GitHub repo, or null where the registry has no such package
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
  }
@@ -12,7 +12,7 @@ const PER_REQUEST = 100
12
12
  const PACE_MS = 1000 // crates.io asks for a request a second at most, and a user agent.
13
13
  const USER_AGENT = '@preventive/upstream (https://github.com/PreventiveMeasures/libraries)'
14
14
 
15
- // One request for up to a hundred crates: each name → the GitHub repo its
15
+ // One request for up to a hundred crates: each name -> the GitHub repo its
16
16
  // `repository` names, or null where it names none. A crate crates.io
17
17
  // leaves out of its answer is one it does not have, null too.
18
18
  async function askCrates(method, names) {
@@ -27,12 +27,12 @@ async function askCrates(method, names) {
27
27
  return found
28
28
  }
29
29
 
30
- // Name → 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)
30
+ // Name -> its GitHub repo, or null where crates.io has no such crate or it
31
+ // names no GitHub repo, through the cache, or a `cache` store (readRecord):
32
+ // only a repo found is kept, for a month. A failed request throws, or with
33
+ // `soft` leaves its names out; `cachedOnly` asks nothing.
34
+ export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly = false, cache } = {}) {
35
+ const repos = await readRepos(DIR, names, cache)
36
36
  if (cachedOnly) return repos
37
37
  for (const [i, chunk] of chunks(names.filter((name) => !repos.has(name)), PER_REQUEST).entries()) {
38
38
  if (i > 0) await sleep(PACE_MS)
@@ -40,7 +40,7 @@ export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly
40
40
  if (soft) return new Map()
41
41
  throw error
42
42
  })
43
- await addRepos(DIR, repos, answer)
43
+ await addRepos(DIR, repos, answer, cache)
44
44
  }
45
45
  return repos
46
46
  }
package/src/download.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { createHash } from 'node:crypto'
3
3
 
4
- import { ourCachePaths, 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
+ }
@@ -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
- assert.ok(method === 'GET' && `${new URL(url)}` === url && url.startsWith(`${NPM_REGISTRY}/${name}/`), `Unexpected request for ${name}: ${method} ${url}`)
10
- const token = process.env.NPM_TOKEN
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
- export async function getDocument(method, name, spec) {
15
- const url = buildUrl(NPM_REGISTRY, [...name.split('/'), spec])
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
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertNames, assertPackageName, assertRepo, 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'
@@ -10,8 +10,18 @@ import { getDocument } from './registry.js'
10
10
  const DIR = 'npm/repos'
11
11
  const CONCURRENCY = 8
12
12
  // Stamped on each entry, and raised when getRepo would answer differently:
13
- // 2 is `repository` taking precedence over `bugs`.
14
- const VERSION = 2
13
+ // 2 is `repository` taking precedence over `bugs`; 3 the shorthand dropping
14
+ // a `.git`, URLs read past whitespace, and `repository.directory`'s `\` read
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
19
+
20
+ // A slug a lookup gives: getRepo answers no repo ending in `.git`, the
21
+ // suffix it drops (withoutDotGit; `.GIT` it keeps, and so does this).
22
+ const isLookedUpRepo = (github) => isRepo(github) && !github.endsWith('.git')
23
+ // A write takes no slug a read would discard: whatever it writes reads back.
24
+ const assertLookedUpRepo = assertion('"owner/name" with no trailing ".git"', isLookedUpRepo)
15
25
 
16
26
  // `latest`, not the full packument, which is megabytes of version history.
17
27
  const fetchRepo = async (method, name) => getRepo(await getDocument(method, name, 'latest'))
@@ -24,22 +34,24 @@ export async function getGitHub(name) {
24
34
  }
25
35
 
26
36
  // An entry under another VERSION was resolved by other rules, and may
27
- // name the repo a stale tracker does: a miss, as is one without
28
- // `directory`, which would read as a package at the repo root.
37
+ // name a repo getRepo no longer would (a stale tracker's, or a shorthand's
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.
29
41
  export async function readPackageRepoCache(name) {
30
42
  assertPackageName('readPackageRepoCache', 'name', name)
31
43
  const entry = await readRecord(DIR, name)
32
- if (entry?.v !== VERSION || !isRepo(entry.github) || !isRepoDirectory(entry.directory)) return null
33
- 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 }) }
34
46
  }
35
47
 
36
48
  // Only resolved repos are cached: a 404, a rate limit and a package with
37
49
  // no repo link fail alike.
38
- export async function writePackageRepoCache(name, github, directory = '') {
50
+ export async function writePackageRepoCache(name, github, directory) {
39
51
  assertPackageName('writePackageRepoCache', 'name', name)
40
- assertRepo('writePackageRepoCache', 'github', github)
41
- assertRepoDirectory('writePackageRepoCache', 'directory', directory)
42
- return await writeRecord(DIR, name, { v: VERSION, github, directory })
52
+ assertLookedUpRepo('writePackageRepoCache', 'github', github)
53
+ optional(assertRepoDirectory)('writePackageRepoCache', 'directory', directory)
54
+ return await writeRecord(DIR, name, { v: VERSION, github, ...(directory !== undefined && { directory }) })
43
55
  }
44
56
 
45
57
  export async function resolvePackageRepos(packageNames, options = {}) {
@@ -50,11 +62,11 @@ export async function resolvePackageRepos(packageNames, options = {}) {
50
62
 
51
63
  // Through the cache. Null for a package the registry does not have, or
52
64
  // one naming no GitHub repo; any other failure throws.
53
- export async function lookUpPackageRepo(name) {
65
+ async function lookUpPackageRepo(name) {
54
66
  const stored = await readPackageRepoCache(name)
55
67
  if (stored) return stored
56
68
  const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch(recover(isNotFound, {}))
57
69
  if (!github) return null
58
70
  await writePackageRepoCache(name, github, directory)
59
- return { github, ...(directory && { directory }) }
71
+ return { github, ...(directory !== undefined && { directory }) }
60
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
  }