@preventive/upstream 1.0.0-alpha.9 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/advisories.d.ts +21 -10
- package/cargo.d.ts +0 -2
- package/git.d.ts +2 -4
- package/github/write.d.ts +1 -2
- package/github.d.ts +63 -9
- package/npm.d.ts +120 -25
- package/npm.js +1 -1
- package/package.d.ts +5 -4
- package/package.json +4 -1
- package/semver.d.ts +0 -2
- package/soldeer.d.ts +0 -3
- package/src/advisories/github.js +87 -26
- package/src/advisories/index.js +4 -3
- package/src/advisories/osv.js +3 -3
- package/src/advisories/repos.js +21 -10
- package/src/args.js +1 -1
- package/src/cache.js +77 -24
- package/src/cargo/repos.js +8 -8
- package/src/download.js +8 -7
- package/src/git.js +1 -1
- package/src/github/read.js +91 -6
- package/src/http.js +4 -2
- package/src/lockfiles.js +113 -0
- package/src/npm/cacache.js +44 -0
- package/src/npm/registry.js +10 -4
- package/src/npm/repos.js +25 -13
- package/src/npm/tarball.js +99 -37
- package/src/npm/versions.js +97 -0
- package/src/package.js +24 -7
- package/src/remote.js +10 -3
package/src/github/read.js
CHANGED
|
@@ -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
|
|
54
|
-
|
|
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
|
}
|
package/src/lockfiles.js
ADDED
|
@@ -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
|
+
}
|
package/src/npm/registry.js
CHANGED
|
@@ -3,16 +3,22 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { show } from '../args.js'
|
|
4
4
|
import { NPM_REGISTRY, buildUrl, request } from '../http.js'
|
|
5
5
|
|
|
6
|
+
// The first of these that is set and not empty.
|
|
7
|
+
const npmToken = () => process.env.PREVENTIVE_MEASURES_NPM_TOKEN || process.env.STASIS_NPM_TOKEN || process.env.NPM_TOKEN
|
|
8
|
+
|
|
6
9
|
// Private packages are always scoped.
|
|
7
10
|
export function withNpmToken(name, url, options = {}) {
|
|
8
11
|
const method = options.method ?? 'GET'
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
const own = url === `${NPM_REGISTRY}/${name}` || url.startsWith(`${NPM_REGISTRY}/${name}/`)
|
|
13
|
+
assert.ok(method === 'GET' && `${new URL(url)}` === url && own, `Unexpected request for ${name}: ${method} ${url}`)
|
|
14
|
+
const token = npmToken()
|
|
11
15
|
return token && name.startsWith('@') ? { ...options, method, headers: { ...options.headers, Authorization: `Bearer ${token}` } } : options
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
// A version's document, or with no `spec` the whole package's, every
|
|
19
|
+
// version's and `time`.
|
|
20
|
+
export async function getDocument(method, name, ...spec) {
|
|
21
|
+
const url = buildUrl(NPM_REGISTRY, [...name.split('/'), ...spec])
|
|
16
22
|
const json = await request(url, withNpmToken(name, url, { as: 'json' }))
|
|
17
23
|
assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
|
|
18
24
|
return json
|
package/src/npm/repos.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
|
-
import { assertNames, assertPackageName,
|
|
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
|
-
|
|
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
|
|
28
|
-
//
|
|
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 || !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/npm/tarball.js
CHANGED
|
@@ -1,39 +1,16 @@
|
|
|
1
|
-
import assert from 'node:assert/strict'
|
|
2
1
|
import { Buffer } from 'node:buffer'
|
|
3
2
|
import { homedir } from 'node:os'
|
|
4
3
|
import { join, resolve } from 'node:path'
|
|
5
4
|
|
|
6
|
-
import { assertArgs, assertPackage,
|
|
5
|
+
import { assertArgs, assertPackage, assertPackageName, isPlainObject, isSha, optional } from '../args.js'
|
|
6
|
+
import { assertCache } from '../cache.js'
|
|
7
7
|
import { verifiedDownload } from '../download.js'
|
|
8
|
-
import { NPM_REGISTRY,
|
|
8
|
+
import { NPM_REGISTRY, decode } from '../http.js'
|
|
9
|
+
import { readNpmCached } from './cacache.js'
|
|
9
10
|
import { getDocument, withNpmToken } from './registry.js'
|
|
11
|
+
import { checkedDist, getVersion, matchingDist } from './versions.js'
|
|
10
12
|
|
|
11
13
|
const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
|
|
12
|
-
// One sha512 and nothing else, as the registry writes it: a sha1, a second
|
|
13
|
-
// hash or an option is refused rather than trusted. 64 bytes leave the
|
|
14
|
-
// last character before `==` two bits, so only A, Q, g or w is canonical.
|
|
15
|
-
const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
|
|
16
|
-
|
|
17
|
-
// `{ tarball, integrity }` and nothing else, the tarball exactly the
|
|
18
|
-
// registry's own URL for that version. Each field is read once, and only
|
|
19
|
-
// as the object's own, into a copy that is checked and used from then on:
|
|
20
|
-
// the caller's object can change while a request is out, and a field on
|
|
21
|
-
// its prototype is not one it has.
|
|
22
|
-
function checkedDist(method, name, version, dist) {
|
|
23
|
-
assertArgs(method, dist, { tarball: null, integrity: null }, 'dist')
|
|
24
|
-
const [tarball, integrity] = ['tarball', 'integrity'].map((key) => (Object.hasOwn(dist, key) ? dist[key] : undefined))
|
|
25
|
-
assertIntegrity(method, 'dist.integrity', integrity)
|
|
26
|
-
const segments = name.split('/')
|
|
27
|
-
const expected = buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
|
|
28
|
-
assert.ok(tarball === expected, `${method}: dist.tarball must be ${expected}, got ${show(tarball)}`)
|
|
29
|
-
return { tarball, integrity }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function getDist(method, name, version) {
|
|
33
|
-
const json = await getDocument(method, name, version)
|
|
34
|
-
assert.ok(json.version === version, `${method}: the registry answered for ${name}@${show(json.version)}, not ${name}@${version}`)
|
|
35
|
-
return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
|
|
36
|
-
}
|
|
37
14
|
|
|
38
15
|
// Where npm keeps its cache, short of an .npmrc moving it: npm_config_cache,
|
|
39
16
|
// as npm sets it for what it runs, else npm's default. On Windows, npm 4
|
|
@@ -53,23 +30,108 @@ function localPaths(name, version, integrity) {
|
|
|
53
30
|
return [...npm, join(homedir(), '.audit/cache/tgz', `${name.replace(/^@/u, '').replace('/', ':')}-${version}.tgz`)]
|
|
54
31
|
}
|
|
55
32
|
|
|
56
|
-
|
|
33
|
+
// verifyDist's and getTarball's options: `cache` alone.
|
|
34
|
+
const assertOptions = (method, options) => assertArgs(method, options, { cache: optional(assertCache) })
|
|
35
|
+
|
|
36
|
+
const own = (object, key) => (Object.hasOwn(object, key) ? object[key] : undefined)
|
|
37
|
+
const stringOr = (value) => (typeof value === 'string' ? value : undefined)
|
|
38
|
+
|
|
39
|
+
// A string as it is, or an object's strings under `keys`, the rest undefined:
|
|
40
|
+
// none where it is neither, or names none of them.
|
|
41
|
+
function linkOf(value, keys) {
|
|
42
|
+
if (typeof value === 'string') return value
|
|
43
|
+
if (!isPlainObject(value)) return undefined
|
|
44
|
+
const link = Object.fromEntries(keys.map((key) => [key, stringOr(own(value, key))]))
|
|
45
|
+
return keys.some((key) => link[key] !== undefined) ? link : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const mediaType = (value) => (typeof value === 'string' ? value.split(';')[0].trim().toLowerCase() : undefined)
|
|
49
|
+
|
|
50
|
+
function jsonOf(bytes, from) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(decode(bytes, from))
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A version as npm's own cache keeps it, where it keeps its name's full
|
|
59
|
+
// packument, as npm 10 and later fetch it, whose entry for the version
|
|
60
|
+
// matches `given` (matchingDist): that entry is the version's document. An
|
|
61
|
+
// abbreviated packument, as earlier npm fetches, has none of the rest
|
|
62
|
+
// getMeta answers, and is passed over.
|
|
63
|
+
async function npmCachedVersion(name, version, given) {
|
|
64
|
+
const url = `${NPM_REGISTRY}/${name.replace('/', '%2f')}`
|
|
65
|
+
for (const root of npmCacheDirs()) {
|
|
66
|
+
const cached = await readNpmCached(join(root, '_cacache'), url)
|
|
67
|
+
if (!cached || mediaType(cached.metadata?.resHeaders?.['content-type']) !== 'application/json') continue
|
|
68
|
+
const packument = jsonOf(cached.body, url)
|
|
69
|
+
const json = packument?.name === name && isPlainObject(packument.versions) ? own(packument.versions, version) : undefined
|
|
70
|
+
const dist = matchingDist('getMeta', name, version, json, given)
|
|
71
|
+
if (dist) return { dist, json }
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The version's document: with the `dist` the caller has, npm's own cache's
|
|
77
|
+
// where it matches (npmCachedVersion), and neither `cache` nor the cache
|
|
78
|
+
// set is read or written; else as getVersion has it. Beside its dist, the
|
|
79
|
+
// document's gitHead where that is a full commit id, and its repository,
|
|
80
|
+
// homepage and bugs, in the shapes a package.json gives them. Each but
|
|
81
|
+
// dist is left out where there is none.
|
|
82
|
+
export async function getMeta(name, version, options = {}) {
|
|
57
83
|
assertPackage('getMeta', name, version)
|
|
58
|
-
|
|
84
|
+
assertArgs('getMeta', options, { dist: null, cache: optional(assertCache) })
|
|
85
|
+
const given = options.dist === undefined ? undefined : checkedDist('getMeta', name, version, options.dist)
|
|
86
|
+
const { dist, json } = (given && await npmCachedVersion(name, version, given)) ?? await getVersion('getMeta', name, version, given, options.cache)
|
|
87
|
+
const about = {
|
|
88
|
+
gitHead: isSha(own(json, 'gitHead')) ? json.gitHead : undefined,
|
|
89
|
+
repository: linkOf(own(json, 'repository'), ['type', 'url', 'directory']),
|
|
90
|
+
homepage: stringOr(own(json, 'homepage')),
|
|
91
|
+
bugs: linkOf(own(json, 'bugs'), ['url', 'email']),
|
|
92
|
+
}
|
|
93
|
+
return { name, version, dist, ...Object.fromEntries(Object.entries(about).filter(([, value]) => value !== undefined)) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Only the whole package's document has `time`: megabytes for some.
|
|
97
|
+
const PUBLISHED = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.(\d{1,3}))?Z$/u
|
|
98
|
+
const record = (value) => (isPlainObject(value) ? value : {})
|
|
99
|
+
|
|
100
|
+
// A time toISOString writes back as given, its milliseconds padded: none
|
|
101
|
+
// Date.parse moves, as it moves 2018-02-29 to March.
|
|
102
|
+
function readTime(value) {
|
|
103
|
+
const match = typeof value === 'string' ? PUBLISHED.exec(value) : null
|
|
104
|
+
if (match === null) return undefined
|
|
105
|
+
const iso = `${match[1]}.${(match[2] ?? '').padEnd(3, '0')}Z`
|
|
106
|
+
const parsed = Date.parse(iso)
|
|
107
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === iso ? iso : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function getPublishTimes(name) {
|
|
111
|
+
assertPackageName('getPublishTimes', 'name', name)
|
|
112
|
+
const json = await getDocument('getPublishTimes', name)
|
|
113
|
+
const time = record(json.time)
|
|
114
|
+
const times = new Map()
|
|
115
|
+
for (const version of Object.keys(record(json.versions))) {
|
|
116
|
+
const at = readTime(Object.hasOwn(time, version) ? time[version] : undefined)
|
|
117
|
+
if (at !== undefined) times.set(version, at)
|
|
118
|
+
}
|
|
119
|
+
return times
|
|
59
120
|
}
|
|
60
121
|
|
|
61
|
-
export async function verifyDist(name, version, dist) {
|
|
122
|
+
export async function verifyDist(name, version, dist, options = {}) {
|
|
62
123
|
assertPackage('verifyDist', name, version)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
assert.ok(given.integrity === integrity, `verifyDist: ${name}@${version} is ${integrity} on the registry, not ${given.integrity}`)
|
|
124
|
+
assertOptions('verifyDist', options)
|
|
125
|
+
await getVersion('verifyDist', name, version, checkedDist('verifyDist', name, version, dist), options.cache)
|
|
66
126
|
}
|
|
67
127
|
|
|
68
128
|
// Without `dist`, the version document is read every time, cache or not:
|
|
69
129
|
// bytes from a cache are checked against the registry's integrity, never
|
|
70
130
|
// against anything a cache itself holds.
|
|
71
|
-
export async function getTarball(name, version, dist) {
|
|
131
|
+
export async function getTarball(name, version, dist, options = {}) {
|
|
72
132
|
assertPackage('getTarball', name, version)
|
|
73
|
-
|
|
74
|
-
|
|
133
|
+
assertOptions('getTarball', options)
|
|
134
|
+
const { cache } = options
|
|
135
|
+
const { tarball, integrity } = dist === undefined ? (await getVersion('getTarball', name, version, undefined, cache)).dist : checkedDist('getTarball', name, version, dist)
|
|
136
|
+
return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), ours: true, cache, locate: () => tarball, options: withNpmToken(name, tarball) })
|
|
75
137
|
}
|