@preventive/triage 1.0.0-alpha.43 → 1.0.0-alpha.45
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/common/bundle-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-sources.js +20 -1
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +91 -10
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +11 -0
- package/common/workspace-app.js +19 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-5DHL5NG3.js +2 -0
- package/out/chunk-6HNXNIQY.js +52 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-AFLZH7WG.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HMAJDVXN.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NPW52BM2.js +17 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-NV7ES4EW.js +1 -0
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PTGDYLNS.js +36 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-Q6MVYGYV.js +3 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-QRD6GIG5.js +229 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WI7Z5A2T.js +6 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/chunk-XVBEOGOS.js +3 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +31 -31
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +55 -6
- package/risk/actions/findings.js +156 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +165 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +68 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +13 -8
- package/risk/npm/compare.js +34 -10
- package/risk/npm/dependencies.js +11 -0
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +62 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/db-methods.ts +29 -10
- package/server-managed/db-neon.ts +30 -2
- package/server-managed/db-schema.ts +9 -2
- package/server-managed/db.ts +7 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +65 -15
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +23 -9
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http.ts +1219 -81
- package/server-managed/index.ts +6 -2
- package/server-managed/locked-packages.ts +48 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +173 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +51 -4
- package/server-managed/npm-loads.ts +20 -13
- package/server-managed/npm-packages.ts +24 -64
- package/server-managed/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +280 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/repository-actions.ts +209 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +54 -13
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +135 -0
- package/server-managed/repository-history.ts +445 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +432 -0
- package/server-managed/repository-risk-read.ts +165 -0
- package/server-managed/repository-risk.ts +116 -106
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +9 -5
- package/server-managed/sql.ts +1 -1
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/team-app.ts +54 -14
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4F6JSF57.js +0 -3
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-7RU5EDRO.js +0 -226
- package/out/chunk-A7M4GV7M.js +0 -1
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LAAB5SZT.js +0 -2
- package/out/chunk-NWZ4PVPN.js +0 -1
- package/out/chunk-OSIMGFUF.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-RSHMVYNS.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UXNUAIIC.js +0 -33
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-VQJCXGIY.js +0 -7
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-XOCAPDCM.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
- package/out/chunk-ZMQQCGF6.js +0 -2
package/server-managed/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { initializeApp } from '../server-common/initialize.ts'
|
|
|
11
11
|
import { type ManagedConfig, loadManagedConfig } from './config.ts'
|
|
12
12
|
import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
|
|
13
13
|
import { setNpmTarballCache } from './npm-loads.ts'
|
|
14
|
+
import { assembledReviewedDependencies } from './reviewed-dependencies.ts'
|
|
14
15
|
import { setNpmDocumentCache } from './npm-packages.ts'
|
|
15
16
|
import { loadManagedStatic } from './static.ts'
|
|
16
17
|
import { openManagedStorage } from './storage.ts'
|
|
@@ -21,7 +22,7 @@ import { setCacheDir } from '@preventive/upstream/npm.js'
|
|
|
21
22
|
const REAP_INTERVAL_MS = 3_600_000
|
|
22
23
|
const REAP_RETRY_MS = 60_000
|
|
23
24
|
|
|
24
|
-
type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
|
|
25
|
+
type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>>
|
|
25
26
|
|
|
26
27
|
export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
|
|
27
28
|
// Audits keep upstream's records on disk where the config has a cache
|
|
@@ -34,7 +35,10 @@ export async function createManagedApp(config: ManagedConfig, options: ManagedAp
|
|
|
34
35
|
// Load the optional peer only for serverless managed deployments. Embedding
|
|
35
36
|
// hosts can discard listener promises, so retain maintenance inside the app.
|
|
36
37
|
const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
|
|
37
|
-
|
|
38
|
+
// The host's reviewed dependencies, registered before; none after.
|
|
39
|
+
const reviewedDependencies = assembledReviewedDependencies()
|
|
40
|
+
const assembled = { ...options, ...reviewedDependencies ? { reviewedDependencies } : {} }
|
|
41
|
+
return await initializeApp(rollback => assembleManagedApp(config, assembled, rollback, waitUntil))
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
|
|
@@ -7,6 +7,7 @@ import { LockfileError, parseNpmLockfile } from '@preventive/lockfile/npm.js'
|
|
|
7
7
|
import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
|
|
8
8
|
import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
|
|
9
9
|
import type { LockfileFormat } from '@preventive/upstream/github.js'
|
|
10
|
+
import type { ReviewedLockfile } from './reviewed-dependencies.ts'
|
|
10
11
|
|
|
11
12
|
export interface LockedPackage {
|
|
12
13
|
name: string
|
|
@@ -19,9 +20,16 @@ export interface LockedPackage {
|
|
|
19
20
|
// Where one from elsewhere than npm's registry comes from, without any
|
|
20
21
|
// credentials its URL has.
|
|
21
22
|
from?: string
|
|
22
|
-
//
|
|
23
|
-
// and version only where npm's integrity is among it
|
|
23
|
+
// As the lockfile has it: a mirror's tarball's, npm's package of its name
|
|
24
|
+
// and version only where npm's integrity is among it; and, where its
|
|
25
|
+
// reviews ask (`review`), npm's registry's too.
|
|
24
26
|
integrity?: string
|
|
27
|
+
// Where its reviews ask: the sha1 Yarn 1 writes after a tarball's `#`, in
|
|
28
|
+
// hex (`shasum`); the hash of the patch pnpm applies to it
|
|
29
|
+
// (`patchHash`, its patchedDependencies'); and the lockfile's format.
|
|
30
|
+
shasum?: string
|
|
31
|
+
patchHash?: string
|
|
32
|
+
lockfile?: ReviewedLockfile
|
|
25
33
|
// Where only the importers' devDependencies reach it, as npm's `dev` flag
|
|
26
34
|
// says, and an install of no devDependencies leaves it out: in npm's and
|
|
27
35
|
// pnpm's lockfiles. Yarn 1's says nothing of it, which only the
|
|
@@ -32,10 +40,10 @@ export interface LockedPackage {
|
|
|
32
40
|
// A package as each reader resolves one, its name the one it is published
|
|
33
41
|
// under, and a registry tarball's URL held to its name and version.
|
|
34
42
|
type Resolution =
|
|
35
|
-
| { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; gitHosted?: boolean }
|
|
43
|
+
| { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; sha1?: string | undefined; gitHosted?: boolean }
|
|
36
44
|
| { type: 'git'; repo: string }
|
|
37
45
|
| { type: 'directory' }
|
|
38
|
-
interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined }
|
|
46
|
+
interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined; patchHash?: string | undefined }
|
|
39
47
|
|
|
40
48
|
// The packages of a lockfile of `format`, as its reader resolves them.
|
|
41
49
|
// Versions go unchecked against ranges, as only what is locked is asked for.
|
|
@@ -54,44 +62,68 @@ function packagesOf(format: LockfileFormat, text: string): ReadPackage[] {
|
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
// npm's registry, by either of its names, and yarn's mirror of it, as
|
|
57
|
-
// @preventive/lockfile holds their tarballs to a package's name and version
|
|
65
|
+
// @preventive/lockfile holds their tarballs to a package's name and version:
|
|
66
|
+
// its link to one, as it names it, a hash after it alone (Yarn 1's sha1).
|
|
58
67
|
const REGISTRY_HOSTS = new Set(['registry.npmjs.org', 'registry.npmjs.com', 'registry.yarnpkg.com'])
|
|
59
|
-
const isRegistry = (url: URL) => url.protocol === 'https:' && !url.port && !url.username && !url.password
|
|
68
|
+
const isRegistry = (url: URL) => url.protocol === 'https:' && !url.port && !url.username && !url.password && !url.search
|
|
60
69
|
&& REGISTRY_HOSTS.has(url.hostname.replace(/\.+$/u, ''))
|
|
61
70
|
// Where a URL is, as it may be shown: its credentials, query and fragment
|
|
62
71
|
// left out.
|
|
63
72
|
const shown = (url: URL | null) => url ? { from: `${url.protocol}//${url.host}${url.pathname}` } : {}
|
|
64
73
|
|
|
65
|
-
// Where a package read comes from (LockedPackage)
|
|
66
|
-
//
|
|
67
|
-
|
|
74
|
+
// Where a package read comes from (LockedPackage), with npm's registry's
|
|
75
|
+
// integrity where `review`; null for one a package bundles, with no
|
|
76
|
+
// resolution, or one of the project's own directories.
|
|
77
|
+
function located({ name, version, resolution }: ReadPackage, review: boolean): LockedPackage | null {
|
|
68
78
|
const at = { name, version: version ?? null }
|
|
69
79
|
if (resolution === undefined || resolution.type === 'directory') return null
|
|
70
80
|
if (resolution.type === 'git') return { ...at, source: 'git', ...shown(URL.parse(resolution.repo)) }
|
|
71
81
|
const { tarball, integrity } = resolution
|
|
72
82
|
if (resolution.gitHosted) return { ...at, source: 'git', ...shown(URL.parse(tarball ?? '')) }
|
|
73
|
-
|
|
83
|
+
const registry = { ...at, source: 'registry' as const, ...review && integrity ? { integrity } : {} }
|
|
84
|
+
if (tarball === undefined) return registry
|
|
74
85
|
if (tarball.startsWith('file:')) return { ...at, source: 'file' }
|
|
75
86
|
const url = URL.parse(tarball)
|
|
76
|
-
if (url && isRegistry(url)) return
|
|
87
|
+
if (url && isRegistry(url)) return registry
|
|
77
88
|
return integrity ? { ...at, ...shown(url), source: 'mirror', integrity } : { ...at, ...shown(url), source: 'tarball' }
|
|
78
89
|
}
|
|
90
|
+
// As above, and with what its reviews ask (LockedPackage `shasum`,
|
|
91
|
+
// `patchHash`, `lockfile`) where `review`.
|
|
92
|
+
function locked(pkg: ReadPackage, format: LockfileFormat, review: boolean): LockedPackage | null {
|
|
93
|
+
const one = located(pkg, review)
|
|
94
|
+
if (!one || !review) return one
|
|
95
|
+
const sha1 = pkg.resolution?.type === 'tarball' ? pkg.resolution.sha1 : undefined
|
|
96
|
+
return { ...one, ...sha1 ? { shasum: sha1 } : {}, ...pkg.patchHash ? { patchHash: pkg.patchHash } : {}, lockfile: format === 'yarn' ? 'yarn1' : format as ReviewedLockfile }
|
|
97
|
+
}
|
|
79
98
|
|
|
80
99
|
// The packages a lockfile of `format` (npm, pnpm or yarn, as
|
|
81
100
|
// findRepoLockfiles names them) locks, each once, in the lockfile's order:
|
|
82
101
|
// `dev` only where it is wherever it is installed, at each path of npm's
|
|
83
|
-
// tree, and with each of pnpm's peers
|
|
84
|
-
//
|
|
85
|
-
|
|
102
|
+
// tree, and with each of pnpm's peers; with `review`, each with what its
|
|
103
|
+
// reviews hold it to (LockedPackage `integrity`, `shasum`, `patchHash`),
|
|
104
|
+
// each patch of it apart. Throws the reader's LockfileError or YamlError for
|
|
105
|
+
// one it refuses.
|
|
106
|
+
export function lockedPackages(format: LockfileFormat, text: string, { review = false } = {}): LockedPackage[] {
|
|
86
107
|
const found = new Map<string, LockedPackage>()
|
|
87
108
|
for (const pkg of packagesOf(format, text)) {
|
|
88
|
-
const one = locked(pkg)
|
|
109
|
+
const one = locked(pkg, format, review)
|
|
89
110
|
if (!one) continue
|
|
90
111
|
if (pkg.dev === true) one.dev = true
|
|
91
|
-
const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}`
|
|
112
|
+
const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}\0${one.shasum ?? ''}\0${one.patchHash ?? ''}`
|
|
92
113
|
const had = found.get(key)
|
|
93
114
|
if (!had) found.set(key, one)
|
|
94
115
|
else if (had.dev && !one.dev) delete had.dev
|
|
95
116
|
}
|
|
96
117
|
return [...found.values()]
|
|
97
118
|
}
|
|
119
|
+
|
|
120
|
+
// Whether a lockfile at `path` is a test's fixture or example: under a
|
|
121
|
+
// directory of tests (`test`, `tests`, `__tests__` and such), in one of
|
|
122
|
+
// fixtures or examples (`fixture`, `fixtures`, `example`, `examples`) inside
|
|
123
|
+
// it, as a test may keep one its readers refuse on purpose.
|
|
124
|
+
const FIXTURES_DIR = /^(?:fixture|example)s?$/u, TESTS_DIR = /^_*tests?_*$/u
|
|
125
|
+
export function isTestFixture(path: string) {
|
|
126
|
+
const dirs = path.split('/').slice(0, -1)
|
|
127
|
+
const tests = dirs.findIndex(dir => TESTS_DIR.test(dir))
|
|
128
|
+
return tests !== -1 && dirs.slice(tests + 1).some(dir => FIXTURES_DIR.test(dir))
|
|
129
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The lockfiles a repository's dependency review holds to, as an admin sets
|
|
2
|
+
// it (repository-dependency-reviews.ts): every one but those it names
|
|
3
|
+
// (`exclude`, naming none for all of them), or those it names alone
|
|
4
|
+
// (`include`). Each is named by its path in the repository, or, ending in
|
|
5
|
+
// `/`, by a directory, every lockfile under it. A directory of a pull
|
|
6
|
+
// request's lockfiles (pull-dependencies.ts) is reviewed where one of its
|
|
7
|
+
// lockfiles is held to; one none of whose are, not at all (`excluded`), nor
|
|
8
|
+
// failing the review where it couldn't be read.
|
|
9
|
+
export interface LockfileScope { mode: 'exclude' | 'include'; paths: string[] }
|
|
10
|
+
export const EVERY_LOCKFILE: LockfileScope = { mode: 'exclude', paths: [] }
|
|
11
|
+
export const MAX_SCOPED_LOCKFILES = 100
|
|
12
|
+
const MAX_PATH = 300
|
|
13
|
+
|
|
14
|
+
// A path as one is named: relative, of `/`-separated names none of which is
|
|
15
|
+
// empty, `.` or `..`, nor holding a backslash or a control character; a
|
|
16
|
+
// directory's ending in `/`.
|
|
17
|
+
function namedPath(path: unknown): path is string {
|
|
18
|
+
if (typeof path !== 'string' || path.length === 0 || path.length > MAX_PATH || path.startsWith('/')) return false
|
|
19
|
+
if (/[\\\p{Cc}]/u.test(path)) return false
|
|
20
|
+
const names = path.endsWith('/') ? path.slice(0, -1).split('/') : path.split('/')
|
|
21
|
+
return names.every(name => name !== '' && name !== '.' && name !== '..')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A scope as a request sends it, each path once, in order; null for one
|
|
25
|
+
// that isn't one.
|
|
26
|
+
export function scopeOf(value: unknown): LockfileScope | null {
|
|
27
|
+
const { mode, paths } = (value ?? {}) as { mode?: unknown; paths?: unknown }
|
|
28
|
+
if ((mode !== 'exclude' && mode !== 'include') || !Array.isArray(paths) || paths.length > MAX_SCOPED_LOCKFILES || !paths.every(namedPath)) return null
|
|
29
|
+
return { mode, paths: [...new Set(paths as string[])].toSorted() }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A scope as stored (JSON, none for every lockfile), every lockfile where
|
|
33
|
+
// it can't be read.
|
|
34
|
+
export function storedScope(text: string | null): LockfileScope {
|
|
35
|
+
if (!text) return EVERY_LOCKFILE
|
|
36
|
+
try { return scopeOf(JSON.parse(text)) ?? EVERY_LOCKFILE } catch { return EVERY_LOCKFILE }
|
|
37
|
+
}
|
|
38
|
+
export const scopeText = (scope: LockfileScope) => isEvery(scope) ? null : JSON.stringify({ mode: scope.mode, paths: scope.paths })
|
|
39
|
+
export const isEvery = (scope: LockfileScope) => scope.mode === 'exclude' && scope.paths.length === 0
|
|
40
|
+
|
|
41
|
+
const names = (path: string, named: string) => named.endsWith('/') ? path.startsWith(named) : path === named
|
|
42
|
+
// Whether the lockfile at `path` is held to.
|
|
43
|
+
export function heldTo(scope: LockfileScope, path: string) {
|
|
44
|
+
const named = scope.paths.some(one => names(path, one))
|
|
45
|
+
return scope.mode === 'include' ? named : !named
|
|
46
|
+
}
|
|
47
|
+
// Whether a directory of lockfiles at `paths` is reviewed: where one is
|
|
48
|
+
// held to, or it names none.
|
|
49
|
+
export const reviewedIn = (scope: LockfileScope, paths: string[]) => paths.length === 0 || paths.some(path => heldTo(scope, path))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The lines a package's files change from one side to another (npm-delta.ts),
|
|
2
|
+
// as @preventive/compare's compareTrees counts them (git's --numstat), off
|
|
3
|
+
// the server's event loop: of its text files, renames found as git finds
|
|
4
|
+
// them, a side not there empty; and, apart, those of its changes it stamps
|
|
5
|
+
// `typeOnly`, declaration files changed in types alone.
|
|
6
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
7
|
+
import { compareTrees } from '@preventive/compare'
|
|
8
|
+
|
|
9
|
+
const tree = files => files.map(([name, data]) => ({ name, data }))
|
|
10
|
+
const changes = compareTrees(tree(workerData.base), tree(workerData.other), { paths: 'keys', lines: true, renames: true })
|
|
11
|
+
const answer = { additions: 0, deletions: 0, typeAdditions: 0, typeDeletions: 0, typeFiles: 0 }
|
|
12
|
+
for (const change of changes) {
|
|
13
|
+
if (change.binary) continue
|
|
14
|
+
answer.additions += change.insertions ?? 0
|
|
15
|
+
answer.deletions += change.deletions ?? 0
|
|
16
|
+
if (change.typeOnly !== true) continue
|
|
17
|
+
answer.typeAdditions += change.insertions ?? 0
|
|
18
|
+
answer.typeDeletions += change.deletions ?? 0
|
|
19
|
+
answer.typeFiles++
|
|
20
|
+
}
|
|
21
|
+
// Node worker messages have no browser target origin.
|
|
22
|
+
// oxlint-disable-next-line unicorn/require-post-message-target-origin
|
|
23
|
+
parentPort.postMessage(answer)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The lines a version of a package changes against another, or against none,
|
|
2
|
+
// its whole package's (GET /api/npm/delta), as a list of versions wanting
|
|
3
|
+
// reviews tells them beside each: added and removed (`additions`,
|
|
4
|
+
// `deletions`), as git's --numstat counts them, of their text files, a
|
|
5
|
+
// renamed one diffed with what it was; and, where some of the files changed
|
|
6
|
+
// are declaration files @preventive/compare's compareTrees proves changed in
|
|
7
|
+
// types alone (`typeOnly`, proveTypeOnly: types and comments alone erased,
|
|
8
|
+
// the runtime they leave as it was, an added one's empty or a single bare
|
|
9
|
+
// `export {}`), those of the rest (`typeless`), what a reviewer may pass by
|
|
10
|
+
// leaving those out; and whether a binary file, which they count none of,
|
|
11
|
+
// was added, removed or changed (`binary`), one moved as it was not. Worked
|
|
12
|
+
// out in a worker, off the event loop, as many at once per process as
|
|
13
|
+
// MAX_ACTIVE, their files read only once their turn has come, those waiting
|
|
14
|
+
// past MAX_WAITING refused, each waiting a minute at most and counting in a
|
|
15
|
+
// minute at most; one asked again meanwhile sharing the first's; and kept by
|
|
16
|
+
// both sides' integrities, in this instance and beyond it (`kept`), what a
|
|
17
|
+
// version holds never changing. Access is the caller's to check.
|
|
18
|
+
import { Worker } from 'node:worker_threads'
|
|
19
|
+
import { createHash } from 'node:crypto'
|
|
20
|
+
import type { KeptStore } from './npm-kept.ts'
|
|
21
|
+
import { type Lru, lru } from './lru.ts'
|
|
22
|
+
import { NpmPackageError, type NpmPackageFile, npmFileText } from './npm-packages.ts'
|
|
23
|
+
|
|
24
|
+
export interface NpmDelta { additions: number; deletions: number; typeless: { additions: number; deletions: number } | null; binary: boolean }
|
|
25
|
+
|
|
26
|
+
const MAX_ACTIVE = 2
|
|
27
|
+
const MAX_WAITING = 64
|
|
28
|
+
const DELTA_TIMEOUT_MS = 60_000
|
|
29
|
+
// The text the two sides may hold together, past which none is counted.
|
|
30
|
+
const MAX_TEXT_LENGTH = 64 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
const deltas: Lru<NpmDelta> = lru(20_000)
|
|
33
|
+
const working = new Map<string, Promise<NpmDelta>>()
|
|
34
|
+
let active = 0
|
|
35
|
+
const waiting: Array<() => void> = []
|
|
36
|
+
|
|
37
|
+
async function turn(deadline: number): Promise<void> {
|
|
38
|
+
if (active < MAX_ACTIVE) { active++; return }
|
|
39
|
+
if (waiting.length >= MAX_WAITING) throw new NpmPackageError(429, 'delta-busy')
|
|
40
|
+
await new Promise<void>((resolve, reject) => {
|
|
41
|
+
const next = () => { clearTimeout(timer); resolve() }
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
waiting.splice(waiting.indexOf(next), 1)
|
|
44
|
+
reject(new NpmPackageError(504, 'delta-timeout'))
|
|
45
|
+
}, deadline - Date.now())
|
|
46
|
+
waiting.push(next)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
function done() {
|
|
50
|
+
const next = waiting.shift()
|
|
51
|
+
if (next) next()
|
|
52
|
+
else active--
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const lines = (told: unknown): told is { additions: number; deletions: number } => typeof told === 'object' && told !== null
|
|
56
|
+
&& Number.isSafeInteger((told as { additions?: unknown }).additions) && Number.isSafeInteger((told as { deletions?: unknown }).deletions)
|
|
57
|
+
// A delta as kept, none where it isn't one, or was kept before it told of
|
|
58
|
+
// binary files.
|
|
59
|
+
const deltaOf = (told: unknown): NpmDelta | null => lines(told) && ((told as NpmDelta).typeless === null || lines((told as NpmDelta).typeless))
|
|
60
|
+
&& typeof (told as NpmDelta).binary === 'boolean' ? { additions: told.additions, deletions: told.deletions, typeless: (told as NpmDelta).typeless,
|
|
61
|
+
binary: (told as NpmDelta).binary } : null
|
|
62
|
+
|
|
63
|
+
type Counted = { additions: number; deletions: number; typeAdditions: number; typeDeletions: number; typeFiles: number }
|
|
64
|
+
const isCounted = (told: unknown): told is Counted => typeof told === 'object' && told !== null
|
|
65
|
+
&& ['additions', 'deletions', 'typeAdditions', 'typeDeletions', 'typeFiles'].every(key => Number.isSafeInteger((told as Record<string, unknown>)[key]))
|
|
66
|
+
|
|
67
|
+
// What the worker counts of `base` against `other`, each its text files' paths and texts.
|
|
68
|
+
function counted(base: [string, string][], other: [string, string][], deadline: number): Promise<Counted> {
|
|
69
|
+
let worker: Worker
|
|
70
|
+
try {
|
|
71
|
+
worker = new Worker(new URL('./npm-delta-worker.js', import.meta.url), {
|
|
72
|
+
workerData: { base, other }, execArgv: ['--disable-warning=ExperimentalWarning'], resourceLimits: { maxOldGenerationSizeMb: 1536 },
|
|
73
|
+
})
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.warn('managed: a package delta could not start:', err)
|
|
76
|
+
throw new NpmPackageError(503, 'unavailable')
|
|
77
|
+
}
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
let answer: Counted | null = null, failure = new NpmPackageError(422, 'delta-failed')
|
|
80
|
+
const timer = setTimeout(() => { failure = new NpmPackageError(504, 'delta-timeout'); void worker.terminate() }, deadline - Date.now())
|
|
81
|
+
worker.once('message', (told: unknown) => {
|
|
82
|
+
if (isCounted(told)) answer = told
|
|
83
|
+
void worker.terminate()
|
|
84
|
+
})
|
|
85
|
+
worker.once('error', err => { console.warn('managed: a package delta failed:', err) })
|
|
86
|
+
worker.once('exit', () => {
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
if (answer) resolve(answer)
|
|
89
|
+
else reject(failure)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A side's text files, by path, how long their texts are, and its other,
|
|
95
|
+
// binary, files.
|
|
96
|
+
function texts(files: NpmPackageFile[] | null) {
|
|
97
|
+
let length = 0
|
|
98
|
+
const binaries: NpmPackageFile[] = []
|
|
99
|
+
const read = (files ?? []).flatMap(file => {
|
|
100
|
+
const text = npmFileText(file.bytes)
|
|
101
|
+
if (text === null) { binaries.push(file); return [] }
|
|
102
|
+
length += text.length
|
|
103
|
+
return [[file.path, text] as [string, string]]
|
|
104
|
+
})
|
|
105
|
+
return { read, length, binaries }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const same = (a: Uint8Array, b: Uint8Array) => Buffer.from(a.buffer, a.byteOffset, a.byteLength).equals(b)
|
|
109
|
+
const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('base64')
|
|
110
|
+
|
|
111
|
+
// Whether `news`, a side's binary files, differ from `olds`, the other's:
|
|
112
|
+
// one added, removed or changed, one moved where it is the same as one that
|
|
113
|
+
// was elsewhere not, matched by their digests, each file looked at once.
|
|
114
|
+
function binaryChanged(olds: NpmPackageFile[], news: NpmPackageFile[]): boolean {
|
|
115
|
+
if (olds.length !== news.length) return true
|
|
116
|
+
const left = new Map(olds.map(({ path, bytes }) => [path, bytes]))
|
|
117
|
+
const moved: Uint8Array[] = []
|
|
118
|
+
for (const { path, bytes } of news) {
|
|
119
|
+
const was = left.get(path)
|
|
120
|
+
if (was && same(was, bytes)) left.delete(path)
|
|
121
|
+
else moved.push(bytes)
|
|
122
|
+
}
|
|
123
|
+
// Those not where they were, by their digests, as many of each as there are.
|
|
124
|
+
const unmoved = new Map<string, number>()
|
|
125
|
+
for (const bytes of left.values()) {
|
|
126
|
+
const key = digest(bytes)
|
|
127
|
+
unmoved.set(key, (unmoved.get(key) ?? 0) + 1)
|
|
128
|
+
}
|
|
129
|
+
return moved.some(bytes => {
|
|
130
|
+
const key = digest(bytes)
|
|
131
|
+
const count = unmoved.get(key) ?? 0
|
|
132
|
+
if (count === 0) return true
|
|
133
|
+
unmoved.set(key, count - 1)
|
|
134
|
+
return false
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type Side = { integrity: string; files: () => Promise<NpmPackageFile[]> }
|
|
139
|
+
|
|
140
|
+
// The delta of `other` (its integrity, and how to read its files) against
|
|
141
|
+
// `base`, or none, as held, else shared with one asked before it, else
|
|
142
|
+
// looked for where it is kept, else worked out and kept: shared from before
|
|
143
|
+
// it is looked for, lest those asked together each look, find none, and work
|
|
144
|
+
// it out.
|
|
145
|
+
export function npmDelta(base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null = null): Promise<NpmDelta> {
|
|
146
|
+
const key = `${base?.integrity ?? '(empty)'}\0${other.integrity}`
|
|
147
|
+
const known = deltas.get(key)
|
|
148
|
+
if (known) return Promise.resolve(known)
|
|
149
|
+
const shared = working.get(key)
|
|
150
|
+
if (shared) return shared
|
|
151
|
+
const work = worked(key, base, other, kept).finally(() => working.delete(key))
|
|
152
|
+
working.set(key, work)
|
|
153
|
+
return work
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// As kept, else its turn awaited, then both sides' files read, counted and kept.
|
|
157
|
+
async function worked(key: string, base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null): Promise<NpmDelta> {
|
|
158
|
+
const known = deltaOf((await kept?.read(key))?.value)
|
|
159
|
+
if (known) { deltas.set(key, known); return known }
|
|
160
|
+
await turn(Date.now() + DELTA_TIMEOUT_MS)
|
|
161
|
+
let binary, told
|
|
162
|
+
try {
|
|
163
|
+
const [olds, news] = [texts(base ? await base.files() : null), texts(await other.files())]
|
|
164
|
+
if (olds.length + news.length > MAX_TEXT_LENGTH) throw new NpmPackageError(413, 'package-too-large')
|
|
165
|
+
binary = binaryChanged(olds.binaries, news.binaries)
|
|
166
|
+
told = await counted(olds.read, news.read, Date.now() + DELTA_TIMEOUT_MS)
|
|
167
|
+
} finally { done() }
|
|
168
|
+
const delta = { additions: told.additions, deletions: told.deletions,
|
|
169
|
+
typeless: told.typeFiles > 0 ? { additions: told.additions - told.typeAdditions, deletions: told.deletions - told.typeDeletions } : null, binary }
|
|
170
|
+
deltas.set(key, delta)
|
|
171
|
+
await kept?.write(key, delta, Date.now())
|
|
172
|
+
return delta
|
|
173
|
+
}
|
|
@@ -362,25 +362,34 @@ async function askAdvisories(name: string, versions: string[], { githubToken, re
|
|
|
362
362
|
}
|
|
363
363
|
if (result.status !== 200) throw new NpmPackageError(502, 'upstream-unavailable')
|
|
364
364
|
const index = new Map(versions.map((version, i) => [version, i]))
|
|
365
|
-
// npm's registry answers an advisory once a range it covers
|
|
366
|
-
//
|
|
367
|
-
//
|
|
365
|
+
// npm's registry answers an advisory once a range it covers, and upstream
|
|
366
|
+
// answers the versions OSV's record of it, or its repository, covers beyond
|
|
367
|
+
// npm's under the same id, in a row of their own (as hermes-engine's
|
|
368
|
+
// GHSA-f5x2-xv93-4p23: npm's `<=0.4.3`, OSV's fixed in 0.5.2): here it is
|
|
369
|
+
// one row, the first's (npm's, then OSV's, then the repository's, as
|
|
370
|
+
// upstream orders them) with what it lacks taken from the rest, its ranges
|
|
371
|
+
// joined as semver joins them, the versions and CWEs they cover together,
|
|
372
|
+
// and the worst severity and highest score of them: a version only the
|
|
373
|
+
// repository reports keeps the severity it gives.
|
|
368
374
|
const merged = new Map<string, NpmAdvisory>()
|
|
369
375
|
for (const row of result.body) {
|
|
370
376
|
const affected = row.versions.map(version => index.get(version)).filter(at => at !== undefined)
|
|
371
|
-
const
|
|
372
|
-
|
|
377
|
+
const own: NpmAdvisory = {
|
|
378
|
+
id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
|
|
379
|
+
...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
|
|
380
|
+
...row.details && { details: row.details }, affected,
|
|
381
|
+
}
|
|
382
|
+
const known = merged.get(row.id)
|
|
373
383
|
if (!known) {
|
|
374
|
-
merged.set(
|
|
375
|
-
id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
|
|
376
|
-
...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
|
|
377
|
-
...row.details && { details: row.details }, affected,
|
|
378
|
-
})
|
|
384
|
+
merged.set(row.id, own)
|
|
379
385
|
continue
|
|
380
386
|
}
|
|
381
387
|
const range = [known.range, row.range].filter(Boolean).join(' || ')
|
|
382
388
|
const cvss = Math.max(known.cvss ?? -1, row.cvss ?? -1)
|
|
383
|
-
|
|
389
|
+
const rank = (severity: string | undefined) => SEVERITY_RANK[severity ?? ''] ?? 4
|
|
390
|
+
const severity = rank(row.severity) < rank(known.severity) ? row.severity : known.severity
|
|
391
|
+
merged.set(row.id, { ...own, ...known, ...range && { range }, ...severity && { severity }, ...cvss >= 0 && { cvss }, cwe: [...new Set([...known.cwe, ...row.cwe])],
|
|
392
|
+
affected: [...known.affected, ...affected] })
|
|
384
393
|
}
|
|
385
394
|
return {
|
|
386
395
|
versions, repository, ...keptAt && { keptAt },
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
// then, as a version can be deprecated, its package made private, or itself
|
|
7
7
|
// removed); and, for scans, each package's risk line, for as long as a scan
|
|
8
8
|
// keeps it, and what a scan read of GitHub, for a day, and of npm's
|
|
9
|
-
// advisories, for an hour (repository-risk.ts)
|
|
9
|
+
// advisories, for an hour (repository-risk.ts); and what the code viewer read
|
|
10
|
+
// of a repository's GitHub, its issues, pull requests and agents' work, for a
|
|
11
|
+
// day (http.ts keptAnswer), and its history, a page from a commit at a time,
|
|
12
|
+
// for a month (repository-history.ts historyFrom), as are the files each of
|
|
13
|
+
// its commits changes and holds (repository-changes.ts), and the packages a
|
|
14
|
+
// pull request's lockfiles add (pull-dependencies.ts). Each store removes its own rows kept longer
|
|
10
15
|
// than that as it writes, at most once an hour a database (pruneKept), so
|
|
11
16
|
// that rows no read will take again don't pile up.
|
|
12
17
|
// The database is an optimization: a failure reads as a miss, and writes
|
|
@@ -22,6 +27,8 @@ export interface KeptStore<T> {
|
|
|
22
27
|
// Those of `keys` kept, by key, in one read of the database.
|
|
23
28
|
readMany(keys: string[]): Promise<Map<string, { at: number; value: T }>>
|
|
24
29
|
write(key: string, value: T, at: number): Promise<void>
|
|
30
|
+
// Removes what is kept by `key`.
|
|
31
|
+
remove(key: string): Promise<void>
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
// When each database last had the rows under each prefix removed.
|
|
@@ -63,6 +70,9 @@ function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debu
|
|
|
63
70
|
try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
|
|
64
71
|
await prune(at)
|
|
65
72
|
},
|
|
73
|
+
async remove(key) {
|
|
74
|
+
try { await db.deleteUpstreamCacheEntries(keyOf(key), Number.MAX_SAFE_INTEGER) } catch (error) { warn('remove', error) }
|
|
75
|
+
},
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -83,7 +93,44 @@ export const riskLineStore = <T>(db: UpstreamCacheStore, keptMs: number, debug =
|
|
|
83
93
|
// What a scan read, each read for keptMs (repository-risk.ts): of GitHub, a
|
|
84
94
|
// repository's lockfiles as listed at a commit (`listings`) and a lockfile's
|
|
85
95
|
// packages by its blob (`lockfiles`), for SCAN_READ_KEPT_MS; of npm, a
|
|
86
|
-
// lockfile's advisories by its blob (`advisories`), for RISK_KEPT_MS;
|
|
87
|
-
// repositories a scan in parts told whole (`progress`)
|
|
88
|
-
|
|
96
|
+
// lockfile's advisories by its blob (`advisories`), for RISK_KEPT_MS; the
|
|
97
|
+
// repositories a scan in parts told whole (`progress`); a repository's
|
|
98
|
+
// Actions' files by the commit read (`action-files`), and the actions and
|
|
99
|
+
// reusable workflows of others they name, each resolved (`actions`), of
|
|
100
|
+
// public repositories alone (repository-actions.ts).
|
|
101
|
+
export const scanReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles' | 'advisories' | 'progress' | 'action-files' | 'actions', keptMs: number, debug = false): KeptStore<T> =>
|
|
89
102
|
keptStore<T>(db, `repository-risk/${type}`, keptMs, debug)
|
|
103
|
+
// What the code viewer read of a repository (http.ts readWholeRepository), by
|
|
104
|
+
// its id and what was asked, each kept REPOSITORY_READ_KEPT_MS.
|
|
105
|
+
export const REPOSITORY_READ_KEPT_MS = 24 * 60 * 60_000
|
|
106
|
+
export const repositoryReadStore = (db: UpstreamCacheStore, debug = false): KeptStore<object> =>
|
|
107
|
+
keptStore<object>(db, 'repository/reads', REPOSITORY_READ_KEPT_MS, debug)
|
|
108
|
+
// Pages of a repository's history (repository-history.ts historyFrom), by its
|
|
109
|
+
// id and the commit each starts at, which never change: kept a month after
|
|
110
|
+
// they were last read from GitHub.
|
|
111
|
+
export const HISTORY_PAGE_KEPT_MS = 30 * 24 * 60 * 60_000
|
|
112
|
+
export const historyPageStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/history', HISTORY_PAGE_KEPT_MS, debug)
|
|
113
|
+
// The pull requests squashed into a repository's commits, by the commit
|
|
114
|
+
// (repository-history.ts squashedPulls): kept as its history's pages are.
|
|
115
|
+
export const historySquashStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/squashes', HISTORY_PAGE_KEPT_MS, debug)
|
|
116
|
+
// The files each of a repository's commits changes (repository-changes.ts
|
|
117
|
+
// repositoryChanges), by the commit, which never change: kept as its
|
|
118
|
+
// history's pages are.
|
|
119
|
+
export const historyChangeStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/changes', HISTORY_PAGE_KEPT_MS, debug)
|
|
120
|
+
// The files a repository's commit holds (repository-changes.ts
|
|
121
|
+
// repositoryTree), by the commit, which never change: kept as its history's
|
|
122
|
+
// pages are.
|
|
123
|
+
export const historyTreeStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/trees', HISTORY_PAGE_KEPT_MS, debug)
|
|
124
|
+
// The packages a repository's pull request's lockfiles add
|
|
125
|
+
// (pull-dependencies.ts), by the commits of its base and head, which never
|
|
126
|
+
// change: kept as its history's pages are.
|
|
127
|
+
export const pullDependencyStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/pull-dependencies', HISTORY_PAGE_KEPT_MS, debug)
|
|
128
|
+
// The versions a repository's lockfiles locked at the commit its dependencies
|
|
129
|
+
// are reviewed since (pull-dependency-reviews.ts reviewBaseline), by the
|
|
130
|
+
// commit, which never change: kept as its history's pages are, never stored
|
|
131
|
+
// as reviews.
|
|
132
|
+
// The lines a version changes against another, or none (npm-delta.ts), by
|
|
133
|
+
// both sides' integrities, each kept HISTORY_PAGE_KEPT_MS.
|
|
134
|
+
export const npmDeltaStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'npm/deltas', HISTORY_PAGE_KEPT_MS, debug)
|
|
135
|
+
export const reviewBaselineStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ versions: string[]; unread: string[] }> =>
|
|
136
|
+
keptStore<{ versions: string[]; unread: string[] }>(db, 'repository/review-baselines', HISTORY_PAGE_KEPT_MS, debug)
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
import { Buffer } from 'node:buffer'
|
|
6
6
|
import { homedir } from 'node:os'
|
|
7
7
|
import { join, resolve } from 'node:path'
|
|
8
|
-
import {
|
|
9
|
-
import { gunzip } from 'node:zlib'
|
|
8
|
+
import { CompressionError, decompress } from '@preventive/archive/compression.js'
|
|
10
9
|
import { encodeBrotli } from './brotli.ts'
|
|
11
10
|
import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, askUpstream,
|
|
12
11
|
npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
|
|
@@ -21,8 +20,6 @@ const TARBALL_TIMEOUT_MS = 120_000
|
|
|
21
20
|
// and their encoded JSON.
|
|
22
21
|
const MAX_ACTIVE_LOADS = 4
|
|
23
22
|
|
|
24
|
-
const gunzipAsync = promisify(gunzip)
|
|
25
|
-
|
|
26
23
|
// A load's result, shared by its readers, and a reader's release of it.
|
|
27
24
|
export interface NpmLoad<T> { result: Promise<T>; release: () => void }
|
|
28
25
|
|
|
@@ -43,9 +40,16 @@ const refused = <T>(status: number, code: string): NpmLoad<T> => {
|
|
|
43
40
|
function shared<T>(key: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
|
|
44
41
|
const { unpackedSize, fileCount } = doc.dist
|
|
45
42
|
if ((unpackedSize ?? 0) > MAX_NPM_PACKAGE_BYTES || (fileCount ?? 0) > MAX_NPM_PACKAGE_FILES) return refused(413, 'package-too-large')
|
|
43
|
+
return sharedLoad(key, work, 'npm-busy')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A load among the active ones, by `key`, refused as `busy` past them: a
|
|
47
|
+
// repository directory's (repository-code.ts) as a package's, each holding
|
|
48
|
+
// alike a tarball, its tar, its files and their encoded JSON.
|
|
49
|
+
export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string): NpmLoad<T> {
|
|
46
50
|
let load = loads.get(key)
|
|
47
51
|
if (!load) {
|
|
48
|
-
if (loads.size >= MAX_ACTIVE_LOADS) return refused(429,
|
|
52
|
+
if (loads.size >= MAX_ACTIVE_LOADS) return refused(429, busy)
|
|
49
53
|
const created: Load = { job: work(), readers: 0, settled: false }
|
|
50
54
|
const settle = () => {
|
|
51
55
|
created.settled = true
|
|
@@ -125,14 +129,17 @@ async function readTarball(doc: NpmVersionDocument): Promise<Uint8Array> {
|
|
|
125
129
|
return bytes
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
// A gzipped tarball's files, its top directory left out, as npm extracts
|
|
133
|
+
// a package's: a repository tree's as GitHub archives it, too, its symlinks
|
|
134
|
+
// let climb as readNpmTar's `symlinksAbove` says.
|
|
135
|
+
export async function readTarballFiles(tarball: Uint8Array, options: { symlinksAbove?: number } = {}): Promise<NpmPackageFile[]> {
|
|
136
|
+
let tar: Uint8Array
|
|
137
|
+
try { tar = await decompress(tarball, 'gzip', { limit: MAX_TAR_BYTES }) }
|
|
131
138
|
catch (err) {
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
const limited = err instanceof CompressionError && err.limited
|
|
140
|
+
throw new NpmPackageError(limited ? 413 : 422, limited ? 'package-too-large' : 'bad-tarball')
|
|
134
141
|
}
|
|
135
|
-
return readNpmTar(tar)
|
|
142
|
+
return readNpmTar(tar, options)
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
// The version's tarball, for download.
|
|
@@ -142,7 +149,7 @@ export function loadNpmTarball(doc: NpmVersionDocument): NpmLoad<Uint8Array> {
|
|
|
142
149
|
|
|
143
150
|
// The version's files, for one of them to be pretty-printed (pretty-print.ts).
|
|
144
151
|
export function loadNpmFiles(doc: NpmVersionDocument): NpmLoad<NpmPackageFile[]> {
|
|
145
|
-
return shared(`files ${doc.dist.integrity} ${doc.dist.tarball}`, doc, async () =>
|
|
152
|
+
return shared(`files ${doc.dist.integrity} ${doc.dist.tarball}`, doc, async () => readTarballFiles(await readTarball(doc)))
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
// The version as the viewer reads it, as brotli-encoded JSON: `{ name,
|
|
@@ -158,7 +165,7 @@ export function loadNpmFiles(doc: NpmVersionDocument): NpmLoad<NpmPackageFile[]>
|
|
|
158
165
|
export function loadNpmPackageBody(doc: NpmVersionDocument, known: () => object = () => ({})): NpmLoad<Buffer> {
|
|
159
166
|
return shared(`package ${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`, doc, async () => {
|
|
160
167
|
const tarball = await readTarball(doc)
|
|
161
|
-
const files = await
|
|
168
|
+
const files = await readTarballFiles(tarball)
|
|
162
169
|
return encodeBrotli(Buffer.from(JSON.stringify({
|
|
163
170
|
name: doc.name, version: doc.version, private: doc.private, integrity: doc.dist.integrity, tarballSize: tarball.byteLength,
|
|
164
171
|
published: npmPublishTime(doc.name, doc.version), manifest: doc.manifest, files: npmFileRows(files), known: known(),
|