@preventive/upstream 1.0.0-alpha.9 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { Buffer } from 'node:buffer'
3
3
 
4
- import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
4
+ import { assertArgs, assertBoolean, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isRepo, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
5
5
  import { verifiedDownload } from '../download.js'
6
- import { decode, encodeSegment, requestWithHeaders } from '../http.js'
6
+ import { HttpError, decode, encodeSegment, requestWithHeaders } from '../http.js'
7
+ import { FORMATS, lockfileOf, projectFormats } from '../lockfiles.js'
7
8
  import { gitTreeOfListing, objectId } from '../tree.js'
8
9
  import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
9
10
 
@@ -48,10 +49,29 @@ async function listUserRepos(headers, options) {
48
49
  return await paginate('listUserRepos', headers, (paging) => api(['user', 'repos'], { ...paging, sort: 'full_name' }), options?.maxPages)
49
50
  }
50
51
 
52
+ // Where GitHub's API sends a repository renamed or transferred: to that
53
+ // repository by its id.
54
+ const REPO_BY_ID = /^https:\/\/api\.github\.com\/repositories\/([1-9]\d{0,14})$/u
55
+ const REDIRECTS = new Set([301, 302, 307, 308])
56
+
57
+ // With `followRedirect`, GitHub's redirect for a repository renamed or
58
+ // transferred is followed, once, on its API alone, and the answer held to
59
+ // the id it names rather than to the name asked; any other redirect, or a
60
+ // second, is refused as without it.
51
61
  async function getRepo(headers, options) {
52
- assertArgs('getRepo', options, { repo: assertRepo })
53
- const info = await call(headers, repoApi(options.repo))
54
- assert.ok(sameName(info?.full_name, options.repo), `getRepo: answered for ${show(info?.full_name)}, not ${options.repo}`)
62
+ assertArgs('getRepo', options, { repo: assertRepo, followRedirect: optional(assertBoolean) })
63
+ const { repo, followRedirect } = options
64
+ let info
65
+ try {
66
+ info = await call(headers, repoApi(repo))
67
+ } catch (err) {
68
+ const id = followRedirect && err instanceof HttpError && REDIRECTS.has(err.status) ? REPO_BY_ID.exec(err.headers.get('location') ?? '')?.[1] : undefined
69
+ if (id === undefined) throw err
70
+ const moved = await call(headers, api(['repositories', id]))
71
+ assert.ok(moved?.id === Number(id) && isRepo(moved.full_name), `getRepo: ${repo} redirected to repository ${id}, answered for ${show(moved?.id)}, ${show(moved?.full_name)}`)
72
+ return moved
73
+ }
74
+ assert.ok(sameName(info?.full_name, repo), `getRepo: answered for ${show(info?.full_name)}, not ${repo}`)
55
75
  return info
56
76
  }
57
77
 
@@ -175,6 +195,71 @@ async function listRepoDir(headers, options) {
175
195
  return await listTree('listRepoDir', headers, options.repo, tree)
176
196
  }
177
197
 
198
+ // A tree and every tree under it in one listing, their entries by paths
199
+ // from it, or null where GitHub cut it short, as it does past 100,000
200
+ // entries or 7 MB. Each tree in it, the top one too, has to hash from the
201
+ // entries listed in it, so none is left out, changed or added: an entry is
202
+ // filed under the path before its last `/`, a tree's own with a `/` after
203
+ // it, so `/x` or `a//x` is in no tree.
204
+ async function listTreeDeep(method, headers, repo, tree) {
205
+ const listing = await call(headers, repoApi(repo, ['git', 'trees', tree], { recursive: 1 }))
206
+ if (listing?.truncated === true) return null
207
+ const entries = Array.isArray(listing?.tree) && listing.tree.every((entry) => typeof entry?.path === 'string') ? listing.tree : []
208
+ const dirs = new Map([['', { sha: tree, listed: [] }], ...entries.filter((entry) => entry.type === 'tree').map((entry) => [`${entry.path}/`, { sha: entry.sha, listed: [] }])])
209
+ const filed = entries.every(({ path, mode, type, sha }) => {
210
+ const at = path.lastIndexOf('/') + 1
211
+ const dir = dirs.get(path.slice(0, at))
212
+ dir?.listed.push({ path: path.slice(at), mode, type, sha })
213
+ return dir !== undefined
214
+ })
215
+ assert.ok(filed && [...dirs.values()].every(({ sha, listed }) => gitTreeOfListing(listed) === sha), `${method}: GitHub's listing of tree ${tree} in ${repo}, with the trees under it, is not that tree`)
216
+ return entries
217
+ }
218
+
219
+ // Each file in `tree`, which is at `base` in the repo, by its path, its
220
+ // name and its blob's id: from the tree's listing with every tree under
221
+ // it, or, where GitHub cuts that short, from its own and then each
222
+ // subtree's in turn, as GitHub advises for a large tree. A node_modules,
223
+ // of packages installed, is not the repo's own.
224
+ async function* filesIn(method, headers, repo, tree, base) {
225
+ const deep = await listTreeDeep(method, headers, repo, tree)
226
+ for (const { path, mode, type, sha } of deep ?? await listTree(method, headers, repo, tree)) {
227
+ const names = path.split('/')
228
+ if (names.includes('node_modules')) continue
229
+ if (type === 'blob' && mode !== '120000') yield { path: `${base}${path}`, name: names.at(-1), sha }
230
+ else if (deep === null && type === 'tree') yield* filesIn(method, headers, repo, sha, `${base}${path}/`)
231
+ }
232
+ }
233
+
234
+ const byPath = (a, b) => (a.path < b.path ? -1 : 1)
235
+
236
+ async function findRepoLockfiles(headers, options) {
237
+ assertArgs('findRepoLockfiles', options, { repo: assertRepo, sha: assertSha, directory: optional(assertPath) })
238
+ const { repo, sha, directory } = options
239
+ const tree = await treeAt('findRepoLockfiles', headers, options)
240
+ const lockfiles = []
241
+ const projects = new Map()
242
+ const files = new Map()
243
+ const locked = []
244
+ for await (const { path, name, sha: blob } of filesIn('findRepoLockfiles', headers, repo, tree, directory === undefined ? '' : `${directory}/`)) {
245
+ files.set(path, blob)
246
+ const lockfile = lockfileOf(name)
247
+ const formats = projectFormats(name)
248
+ if (lockfile === undefined && formats.length === 0) continue
249
+ assertPath('findRepoLockfiles', `${repo}@${sha}'s ${lockfile === undefined ? 'project file' : 'lockfile'}`, path)
250
+ if (lockfile !== undefined) lockfiles.push({ path, format: lockfile.format, sha: blob })
251
+ if (lockfile?.locks !== undefined) locked.push({ path: `${path.slice(0, -name.length)}${lockfile.locks}`, format: lockfile.format })
252
+ if (formats.length > 0) projects.set(path, { path, formats, sha: blob })
253
+ }
254
+ // A file a lockfile beside it locks is a project file of that format too.
255
+ for (const { path, format } of locked) {
256
+ if (!files.has(path)) continue
257
+ const formats = projects.get(path)?.formats ?? []
258
+ projects.set(path, { path, formats: Object.keys(FORMATS).filter((known) => known === format || formats.includes(known)), sha: files.get(path) })
259
+ }
260
+ return { lockfiles: lockfiles.toSorted(byPath), projects: [...projects.values()].toSorted(byPath) }
261
+ }
262
+
178
263
  async function getPullRequest(headers, options) {
179
264
  assertArgs('getPullRequest', options, { repo: assertRepo, number: assertNumber })
180
265
  const { repo, number } = options
@@ -219,5 +304,5 @@ async function listRepoAdvisories(headers, options) {
219
304
  return (await Array.fromAsync(cursorPages('listRepoAdvisories', headers, pageUrl))).flat()
220
305
  }
221
306
 
222
- export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
307
+ export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, findRepoLockfiles, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
223
308
  export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)
package/src/http.js CHANGED
@@ -32,11 +32,13 @@ const LIMITS = {
32
32
  }
33
33
  const decoder = new TextDecoder('utf-8', { fatal: true })
34
34
 
35
+ // `headers` are the response's, where there was one to read them from.
35
36
  export class HttpError extends Error {
36
- constructor(status, message) {
37
+ constructor(status, message, headers) {
37
38
  super(message)
38
39
  this.name = 'HttpError'
39
40
  this.status = status
41
+ if (headers !== undefined) this.headers = headers
40
42
  }
41
43
  }
42
44
 
@@ -119,7 +121,7 @@ export async function requestWithHeaders(url, options) {
119
121
  if (!res.ok) {
120
122
  // A stream decode holds back a character cut at the limit rather than refusing it.
121
123
  const text = await readBody(res, 4096, { truncate: true }).then((bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })).catch(() => '')
122
- throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`)
124
+ throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`, res.headers)
123
125
  }
124
126
  return { body: parse(await readBody(res, LIMITS[options.as].bytes), url, options.as), headers: res.headers }
125
127
  }
@@ -0,0 +1,113 @@
1
+ // Each tool that writes a lockfile, by the format of it: the names it
2
+ // writes one under, and those of the project files it locks, each a name
3
+ // as git has it, `cargo.lock` not Cargo's, or a pattern. A lockfile's
4
+ // pattern names in a `locks` group the file beside it that it locks, where
5
+ // no name of that file's own tells it is one.
6
+ export const FORMATS = {
7
+ // pnpm-lock.<branch>.yaml with git-branch-lockfile; shrinkwrap.yaml before
8
+ // pnpm 3. package.yaml and package.json5 in place of package.json, and
9
+ // .pnpmfile's hooks, which change what is locked.
10
+ pnpm: {
11
+ lockfiles: ['pnpm-lock.yaml', /^pnpm-lock\..+\.yaml$/u, 'shrinkwrap.yaml'],
12
+ projects: ['package.json', 'package.yaml', 'package.json5', 'pnpm-workspace.yaml', '.pnpmfile.cjs', '.pnpmfile.mjs'],
13
+ },
14
+ // npm-shrinkwrap.json is npm's own format, which npm reads before a
15
+ // package-lock.json beside it.
16
+ npm: { lockfiles: ['package-lock.json', 'npm-shrinkwrap.json'], projects: ['package.json'] },
17
+ // Of Yarn 1 or a later Yarn, which only the text tells apart. .yarnrc.yml
18
+ // has Yarn 4's catalogs.
19
+ yarn: { lockfiles: ['yarn.lock'], projects: ['package.json', '.yarnrc.yml'] },
20
+ // bun.lockb is Bun's binary one, before Bun 1.2.
21
+ bun: { lockfiles: ['bun.lock', 'bun.lockb'], projects: ['package.json'] },
22
+ deno: { lockfiles: ['deno.lock'], projects: ['deno.json', 'deno.jsonc', 'package.json'] },
23
+ // <script>.py.lock, of `uv lock --script`, which locks the script.
24
+ uv: { lockfiles: ['uv.lock', /^(?<locks>.+\.py)\.lock$/u], projects: ['pyproject.toml'] },
25
+ poetry: { lockfiles: ['poetry.lock'], projects: ['pyproject.toml'] },
26
+ // PEP 751's.
27
+ pylock: { lockfiles: ['pylock.toml', /^pylock\.[^.]+\.toml$/u], projects: ['pyproject.toml'] },
28
+ pipenv: { lockfiles: ['Pipfile.lock'], projects: ['Pipfile'] },
29
+ pdm: { lockfiles: ['pdm.lock'], projects: ['pyproject.toml'] },
30
+ pixi: { lockfiles: ['pixi.lock'], projects: ['pixi.toml', 'pyproject.toml'] },
31
+ // A unified lockfile of another name has to end in `.conda-lock.yml`; the
32
+ // explicit and env ones are conda-<platform>.lock and .lock.yml. A
33
+ // conda-build recipe, meta.yaml, is a source too.
34
+ 'conda-lock': {
35
+ lockfiles: ['conda-lock.yml', 'conda-lock.yaml', /^.+\.conda-lock\.yml$/u, /^conda-[\da-z]+-[\da-z]+\.lock(?:\.yml)?$/u],
36
+ projects: ['environment.yml', 'environment.yaml', 'meta.yaml', 'pyproject.toml'],
37
+ },
38
+ // A gemspec, whose dependencies a Gemfile's `gemspec` takes.
39
+ bundler: { lockfiles: ['Gemfile.lock', 'gems.locked'], projects: ['Gemfile', 'gems.rb', /^.+\.gemspec$/u] },
40
+ composer: { lockfiles: ['composer.lock'], projects: ['composer.json'] },
41
+ cargo: { lockfiles: ['Cargo.lock'], projects: ['Cargo.toml'] },
42
+ soldeer: { lockfiles: ['soldeer.lock'], projects: ['soldeer.toml', 'foundry.toml'] },
43
+ foundry: { lockfiles: ['foundry.lock'], projects: ['foundry.toml'] },
44
+ // Each name CocoaPods reads a Podfile under, and a podspec, whose
45
+ // dependencies a Podfile's `podspec` takes.
46
+ cocoapods: { lockfiles: ['Podfile.lock'], projects: ['Podfile', 'Podfile.rb', 'CocoaPods.podfile', 'CocoaPods.podfile.yaml', /^.+\.podspec(?:\.json)?$/u] },
47
+ swiftpm: { lockfiles: ['Package.resolved'], projects: ['Package.swift', /^Package@swift-\d+(?:\.\d+){0,2}\.swift$/u] },
48
+ carthage: { lockfiles: ['Cartfile.resolved'], projects: ['Cartfile', 'Cartfile.private'] },
49
+ // go.sum has the checksums; go.mod, the versions, is locked as it is. A
50
+ // workspace's go.work.sum has those its modules' go.sum do not.
51
+ go: { lockfiles: ['go.sum', 'go.work.sum'], projects: ['go.mod', 'go.work'] },
52
+ dep: { lockfiles: ['Gopkg.lock'], projects: ['Gopkg.toml'] },
53
+ // packages.<project>.lock.json, for one of projects that share a
54
+ // directory; Directory.Packages.props has the versions of Central
55
+ // Package Management, which the project files then leave out.
56
+ nuget: { lockfiles: ['packages.lock.json', /^packages\..+\.lock\.json$/u], projects: [/^.+\.(?:cs|fs|vb)proj$/u, 'Directory.Packages.props'] },
57
+ paket: { lockfiles: ['paket.lock'], projects: ['paket.dependencies'] },
58
+ // Any *.lockfile: Gradle's own, the legacy one of each configuration, or
59
+ // one a build names. Version catalogs, libs.versions.toml and the like.
60
+ gradle: { lockfiles: [/^.+\.lockfile$/u], projects: ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts', /^.+\.versions\.toml$/u] },
61
+ // pubspec_overrides.yaml overrides pubspec.yaml's dependency_overrides,
62
+ // workspace and resolution.
63
+ pub: { lockfiles: ['pubspec.lock'], projects: ['pubspec.yaml', 'pubspec_overrides.yaml'] },
64
+ mix: { lockfiles: ['mix.lock'], projects: ['mix.exs'] },
65
+ // rebar.config.script, where there is one, makes the configuration.
66
+ rebar: { lockfiles: ['rebar.lock'], projects: ['rebar.config', 'rebar.config.script'] },
67
+ julia: { lockfiles: ['Manifest.toml', 'JuliaManifest.toml', /^(?:Julia)?Manifest-v\d+\.\d+\.toml$/u], projects: ['Project.toml', 'JuliaProject.toml'] },
68
+ // Each of a configuration's name, stack.yaml or another, and `.lock`.
69
+ stack: { lockfiles: [/^(?<locks>.+\.ya?ml)\.lock$/u], projects: ['stack.yaml'] },
70
+ // Each of a project file's name, cabal.project or another, and `.freeze`.
71
+ cabal: { lockfiles: [/^(?<locks>.+)\.freeze$/u], projects: ['cabal.project', /^.+\.cabal$/u] },
72
+ shards: { lockfiles: ['shard.lock'], projects: ['shard.yml', 'shard.override.yml'] },
73
+ opam: { lockfiles: ['opam.locked', /^.+\.opam\.locked$/u], projects: ['opam', /^.+\.opam$/u] },
74
+ nix: { lockfiles: ['flake.lock'], projects: ['flake.nix'] },
75
+ conan: { lockfiles: ['conan.lock'], projects: ['conanfile.txt', 'conanfile.py'] },
76
+ // <name>.MODULE.bazel, which MODULE.bazel's include() takes.
77
+ bazel: { lockfiles: ['MODULE.bazel.lock'], projects: ['MODULE.bazel', /^[^.].*\.MODULE\.bazel$/u] },
78
+ helm: { lockfiles: ['Chart.lock'], projects: ['Chart.yaml'] },
79
+ // Of no project file, but the library it is made from.
80
+ renv: { lockfiles: ['renv.lock'], projects: [] },
81
+ // Of the configuration in the directory, each of its .tf files, a child
82
+ // module's too, of which there is none; OpenTofu's .tofu files, and a
83
+ // Stack's components.
84
+ terraform: { lockfiles: ['.terraform.lock.hcl'], projects: [/^.+\.(?:tf|tofu)(?:\.json)?$/u, /^.+\.tfcomponent\.hcl$/u] },
85
+ }
86
+
87
+ const TOOLS = Object.entries(FORMATS)
88
+ const KNOWN = TOOLS.flatMap(([, { lockfiles, projects }]) => [...lockfiles, ...projects])
89
+
90
+ // A name of none of them, as most in a repository are, is passed over at
91
+ // once, by a set of the names and one pattern of all the patterns.
92
+ const NAMES = new Set(KNOWN.filter((known) => typeof known === 'string'))
93
+ const PATTERN = new RegExp(KNOWN.filter((known) => typeof known !== 'string').map(({ source }) => `(?:${source})`).join('|'), 'u')
94
+ const isKnown = (name) => NAMES.has(name) || PATTERN.test(name)
95
+
96
+ // Whether `name` is one of `names`, each a name or a pattern.
97
+ const isOf = (names, name) => names.some((known) => (typeof known === 'string' ? known === name : known.test(name)))
98
+
99
+ // The lockfile a file of this name is, `{ format, locks }`, `locks` the name
100
+ // of the file beside it that it locks where its pattern names one; or
101
+ // undefined.
102
+ export function lockfileOf(name) {
103
+ for (const [format, { lockfiles }] of isKnown(name) ? TOOLS : []) {
104
+ for (const known of lockfiles) {
105
+ const match = typeof known === 'string' ? known === name && {} : known.exec(name)
106
+ if (match) return { format, locks: match.groups?.locks }
107
+ }
108
+ }
109
+ }
110
+
111
+ // The formats of the lockfiles that lock a project of a file of this name,
112
+ // in FORMATS' order, none where it is no project file.
113
+ export const projectFormats = (name) => (isKnown(name) ? TOOLS.filter(([, { projects }]) => isOf(projects, name)).map(([format]) => format) : [])
@@ -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
  }