@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.39
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/README.md +16 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +6 -2
- package/common/bundle-metadata.js +63 -20
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-source-links.js +7 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +3 -1
- package/common/bundle-sources.js +47 -19
- package/common/code-language.js +16 -2
- package/common/default-scan-models.ts +2 -1
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/github-pr.ts +1 -1
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +1 -5
- package/common/managed/routes.js +123 -12
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +1 -1
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-45WIN3SW.js +1 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-6C76GAXK.js +1 -0
- package/out/chunk-7ZKDKGAH.js +1 -0
- package/out/chunk-ABW5FVSN.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-F4OMDNEY.js +1 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GMFXFEUA.js +2 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-HIV7SVI7.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-JVBB4LZX.js +1 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-QSLWGJBQ.js +223 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-TINZZKP5.js +33 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-XNGEQO7Y.js +2 -0
- package/out/chunk-XRWAV3DX.js +7 -0
- package/out/chunk-XYDGU6BM.js +51 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZPD5R5RP.js +3 -0
- package/out/client-managed-import.js +3 -87
- package/out/client-managed.js +26 -338
- package/out/client-sync.js +8 -14
- package/out/graph.js +17 -30
- package/out/index.html +34 -0
- package/out/prism.js +6 -2
- package/out/terminal.js +241 -257
- package/out/view.css +1 -1
- package/out/view.js +76 -520
- package/package.json +89 -7
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +358 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +228 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-e2e/http.ts +7 -7
- package/server-e2e/npm-proxy.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
- package/server-e2e/objstore/handlers.ts +1 -1
- package/server-e2e/objstore/tokens.ts +1 -1
- package/server-e2e/sign.ts +1 -1
- package/server-managed/bundle-advisories.ts +74 -12
- package/server-managed/bundle-cache.ts +37 -4
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-summary-cache.ts +24 -10
- package/server-managed/config.ts +4 -0
- package/server-managed/db-methods.ts +98 -11
- package/server-managed/db-neon.ts +39 -4
- package/server-managed/db-schema.ts +14 -2
- package/server-managed/db-table-names.ts +1 -1
- package/server-managed/db.ts +7 -3
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +9 -3
- package/server-managed/github-errors.ts +21 -2
- package/server-managed/github-issue-oauth.ts +5 -6
- package/server-managed/github-metadata.ts +115 -0
- package/server-managed/github-oauth.ts +38 -2
- package/server-managed/http-response.ts +26 -0
- package/server-managed/http.ts +952 -53
- package/server-managed/index.ts +11 -5
- package/server-managed/kept.ts +28 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +78 -7
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/report-sources.ts +13 -2
- package/server-managed/repository-browser.ts +19 -5
- package/server-managed/repository-risk.ts +418 -0
- package/server-managed/session.ts +16 -2
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage-encryption.ts +5 -3
- package/server-managed/storage-payload.ts +4 -0
- package/server-managed/storage-stores.ts +2 -0
- package/server-managed/storage.ts +6 -3
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-feed.ts +1 -1
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +25 -4
- package/server-managed/upstream-cache.ts +147 -5
- package/server-managed/view-as.ts +58 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// What calls for a look at an npm version, as risk tells it (risk/), for the
|
|
2
|
+
// managed viewer: risk is asked on the server alone, its records kept where
|
|
3
|
+
// each belongs by its type (risk/npm/registry.js, assess.js):
|
|
4
|
+
//
|
|
5
|
+
// - `risk/npm/assessments`, what of an assessment holds whatever the time:
|
|
6
|
+
// a public package's in the public npm cache, as its pretty-printed files
|
|
7
|
+
// are (storage-payload.ts PUBLIC_CACHE), by a hash of its key; a private
|
|
8
|
+
// one's in this instance's memory alone.
|
|
9
|
+
// - `risk/npm/versions`, what a version's document says, which npm never
|
|
10
|
+
// changes once published: a public package's in the database's upstream
|
|
11
|
+
// cache table, shared by every instance; a private one's in memory.
|
|
12
|
+
// - `risk/npm/packages`, `risk/npm/public`, `risk/npm/advisories` and
|
|
13
|
+
// `risk/npm/downloads`, kept an hour or a day by risk itself: in memory.
|
|
14
|
+
// - `npm/tarballs`: none asked, as tarballs are read as the viewer's loads
|
|
15
|
+
// read them, kept where they keep them (npm-loads.ts loadNpmTarball), and
|
|
16
|
+
// an assessment kept needs none.
|
|
17
|
+
//
|
|
18
|
+
// What memory keeps is held to 2,000 records and 64 MiB as JSON, the first
|
|
19
|
+
// kept going first.
|
|
20
|
+
//
|
|
21
|
+
// A cache failure is a miss, never a failed assessment. Packages' documents
|
|
22
|
+
// are read as every other npm document here is, within their bounds and
|
|
23
|
+
// shared budget (npm-packages.ts readNpmPackument), and tarballs as the
|
|
24
|
+
// viewer's loads read them, bounded as they arrive and among the loads made
|
|
25
|
+
// at once. Assessments made at once are bounded as those loads are, each
|
|
26
|
+
// unpacking up to two tarballs: one past them is refused as busy.
|
|
27
|
+
import { Buffer } from 'node:buffer'
|
|
28
|
+
import { createHash } from 'node:crypto'
|
|
29
|
+
import { buffer } from 'node:stream/consumers'
|
|
30
|
+
import type { CacheStore } from '@preventive/upstream/npm.js'
|
|
31
|
+
import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm } from '../risk/index.js'
|
|
32
|
+
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
33
|
+
import { loadNpmTarball } from './npm-loads.ts'
|
|
34
|
+
import { NpmPackageError, type NpmVersionDocument, readNpmPackument, readNpmVersion } from './npm-packages.ts'
|
|
35
|
+
import { npmPackageScope } from '../common/managed/npm-packages.js'
|
|
36
|
+
import type { UpstreamCacheStore } from './upstream-cache.ts'
|
|
37
|
+
|
|
38
|
+
const MEMORY_ENTRIES = 2000
|
|
39
|
+
// As npm-loads.ts MAX_ACTIVE_LOADS.
|
|
40
|
+
const MAX_ACTIVE_ASSESSMENTS = 4
|
|
41
|
+
// Of them, those no reader waits on (`spare`), such as a scan's: the rest are
|
|
42
|
+
// left for readers.
|
|
43
|
+
export const MAX_SPARE_ASSESSMENTS = 2
|
|
44
|
+
let assessing = 0
|
|
45
|
+
// What memory holds at most, by its records' length as JSON: the first kept
|
|
46
|
+
// go first past it, and a record past an eighth of it isn't kept at all.
|
|
47
|
+
const MEMORY_LENGTH = 64 * 1024 * 1024
|
|
48
|
+
let memoryLength = 0
|
|
49
|
+
const memory = new Map<string, { value: unknown; length: number }>()
|
|
50
|
+
function forget(key: string) {
|
|
51
|
+
const kept = memory.get(key)
|
|
52
|
+
if (!kept) return
|
|
53
|
+
memory.delete(key)
|
|
54
|
+
memoryLength -= kept.length
|
|
55
|
+
}
|
|
56
|
+
function remember(key: string, value: unknown) {
|
|
57
|
+
forget(key)
|
|
58
|
+
const length = JSON.stringify(value)?.length ?? 0
|
|
59
|
+
if (length > MEMORY_LENGTH / 8) return
|
|
60
|
+
memory.set(key, { value, length })
|
|
61
|
+
memoryLength += length
|
|
62
|
+
while (memory.size > MEMORY_ENTRIES || memoryLength > MEMORY_LENGTH) forget(memory.keys().next().value!)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hashed = (type: string, key: string) => `risk/${createHash('sha256').update(`${type}\0${key}`).digest('hex')}`
|
|
66
|
+
|
|
67
|
+
export interface NpmRiskStores {
|
|
68
|
+
db: UpstreamCacheStore
|
|
69
|
+
npmCache?: CacheStorage | undefined
|
|
70
|
+
debug?: boolean | undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Risk's cache for one package: `isPublic` whether anyone can read it, so
|
|
74
|
+
// that what is kept of it can be shared.
|
|
75
|
+
export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): CacheStore {
|
|
76
|
+
const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: risk cache ${what} failed:`, error) }
|
|
77
|
+
return {
|
|
78
|
+
async read(type, key) {
|
|
79
|
+
if (type === 'npm/tarballs') return null
|
|
80
|
+
try {
|
|
81
|
+
if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
|
|
82
|
+
if (isPublic && type === 'risk/npm/versions') { const value = await db.getUpstreamCacheEntry(`${type}/${key}`); return value === null ? null : JSON.parse(value) }
|
|
83
|
+
} catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
|
|
84
|
+
return memory.get(`${isPublic ? '' : 'private\0'}${type}\0${key}`)?.value ?? null
|
|
85
|
+
},
|
|
86
|
+
async write(type, key, value) {
|
|
87
|
+
if (type === 'npm/tarballs') return
|
|
88
|
+
try {
|
|
89
|
+
if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
|
|
90
|
+
if (isPublic && type === 'risk/npm/versions') { await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), Date.now()); return }
|
|
91
|
+
} catch (error) { warn('write', error); return }
|
|
92
|
+
remember(`${isPublic ? '' : 'private\0'}${type}\0${key}`, value)
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A version's tarball as the viewer's loads read it (npm-loads.ts), its bytes
|
|
98
|
+
// held to the integrity its reader's document names: given up on, and the
|
|
99
|
+
// load released, once `signal` aborts, the load going on for its other
|
|
100
|
+
// readers, so that a reader gone holds no assessment's place.
|
|
101
|
+
async function tarballOf(doc: NpmVersionDocument | undefined, signal: AbortSignal): Promise<Uint8Array> {
|
|
102
|
+
if (!doc) throw new NpmPackageError(502, 'upstream-invalid')
|
|
103
|
+
signal.throwIfAborted()
|
|
104
|
+
const load = loadNpmTarball(doc)
|
|
105
|
+
let onAbort = () => {}
|
|
106
|
+
const aborted = new Promise<never>((_, reject) => { onAbort = () => reject(signal.reason); signal.addEventListener('abort', onAbort, { once: true }) })
|
|
107
|
+
try { return await Promise.race([load.result, aborted]) } finally {
|
|
108
|
+
signal.removeEventListener('abort', onAbort)
|
|
109
|
+
load.release()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The version `doc` names assessed against `old`'s, where given, each as the
|
|
114
|
+
// reader's readNpmVersion read it, or against the one risk picks of its
|
|
115
|
+
// versions (`'pick'`, assessNpm `oldVersion: true`), its document read as the
|
|
116
|
+
// open version's is where its tarball is wanted: trusted however new and however few
|
|
117
|
+
// download it where a team lists its scope (`teamScoped`), its own packages;
|
|
118
|
+
// else held to risk's minimum release age and flagged as unpopular. A new
|
|
119
|
+
// dependency on a package of a scope a team lists (`ownScope`, asked once a
|
|
120
|
+
// scope) is told no tier nor age, as an own package. Its
|
|
121
|
+
// documents are read with npm's token where the reader may have it
|
|
122
|
+
// (`privileged`, as readNpmVersion), its dependencies' without; what is kept
|
|
123
|
+
// of it is shared only where neither version is private, and OSV is asked of
|
|
124
|
+
// its advisories only where it isn't, a private package's name never sent to
|
|
125
|
+
// OSV, as it never is to Socket (http.ts). A `spare` one, no
|
|
126
|
+
// reader waiting on it, is made only within MAX_SPARE_ASSESSMENTS, and tells
|
|
127
|
+
// its dependencies without asking npm of their packages (assessNpm
|
|
128
|
+
// `dependencies`), nor its advisories, which a scan asks of a lockfile's
|
|
129
|
+
// packages at once (repository-risk.ts).
|
|
130
|
+
export function npmRisk(doc: NpmVersionDocument, old: NpmVersionDocument | 'pick' | null,
|
|
131
|
+
{ teamScoped, ownScope = () => Promise.resolve(false), fromEmpty = false, privileged, signal, spare = false, ...stores }: NpmRiskStores & { teamScoped: boolean;
|
|
132
|
+
ownScope?: (scope: string) => Promise<boolean>; fromEmpty?: boolean; privileged: boolean; signal: AbortSignal; spare?: boolean }): Promise<NpmAssessment> {
|
|
133
|
+
const scopes = new Map<string, Promise<boolean>>()
|
|
134
|
+
const owned = (name: string) => {
|
|
135
|
+
const scope = npmPackageScope(name)
|
|
136
|
+
if (scope === null) return false
|
|
137
|
+
if (!scopes.has(scope)) scopes.set(scope, ownScope(scope))
|
|
138
|
+
return scopes.get(scope)!
|
|
139
|
+
}
|
|
140
|
+
const given = old === 'pick' ? null : old
|
|
141
|
+
const documentOf = async (version: string) => [doc, given].find(read => read?.version === version) ?? await readNpmVersion(doc.name, version, privileged, signal) ?? undefined
|
|
142
|
+
return bounded(spare, () => assessNpm(doc.name, doc.version, { oldVersion: old === 'pick' ? true : old?.version ?? null, fromEmpty,
|
|
143
|
+
minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE,
|
|
144
|
+
popularity: !teamScoped, cache: npmRiskCache(stores, !doc.private && !given?.private), signal, dependencies: !spare, advisories: !spare, osv: !doc.private, owned,
|
|
145
|
+
packument: (name, { authorized }) => readNpmPackument(name, privileged && authorized, signal),
|
|
146
|
+
tarball: async (_name, version) => tarballOf(await documentOf(version), signal) }))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// An assessment made within MAX_ACTIVE_ASSESSMENTS, a `spare` one within
|
|
150
|
+
// MAX_SPARE_ASSESSMENTS, all across the server; one past them refused as
|
|
151
|
+
// busy (`npm-busy`).
|
|
152
|
+
async function bounded(spare: boolean, assess: () => Promise<NpmAssessment>): Promise<NpmAssessment> {
|
|
153
|
+
if (assessing >= (spare ? MAX_SPARE_ASSESSMENTS : MAX_ACTIVE_ASSESSMENTS)) throw new NpmPackageError(429, 'npm-busy')
|
|
154
|
+
assessing++
|
|
155
|
+
try { return await assess() } finally { assessing-- }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A version npm has no document of, as risk tells one npm removed, as it
|
|
159
|
+
// removes every one it finds malware in (assessNpm): its package taken down
|
|
160
|
+
// by npm, as a scan's (`spare`), and its advisories of malware, npm's and,
|
|
161
|
+
// where anyone can read its package, OSV's, which alone has some of those
|
|
162
|
+
// npm removed (nx's), a private package's name never sent to OSV. Its
|
|
163
|
+
// package's document read anonymously, else with npm's token where the
|
|
164
|
+
// reader may have it (`privileged`), and kept as a private package's is; no
|
|
165
|
+
// tarball read, nothing of the version being left. Spare, within
|
|
166
|
+
// MAX_SPARE_ASSESSMENTS as a scan's others are. An NpmRiskError where
|
|
167
|
+
// nothing tells of it.
|
|
168
|
+
export function npmRiskRemoved(name: string, version: string,
|
|
169
|
+
{ teamScoped, privileged, signal, ...stores }: NpmRiskStores & { teamScoped: boolean; privileged: boolean; signal: AbortSignal }): Promise<NpmAssessment> {
|
|
170
|
+
return bounded(true, async () => {
|
|
171
|
+
const open = await readNpmPackument(name, false, signal)
|
|
172
|
+
return assessNpm(name, version, { minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE, popularity: false, downloads: false, dependencies: false,
|
|
173
|
+
osv: open !== null, cache: npmRiskCache(stores, false), signal,
|
|
174
|
+
packument: (read, { authorized }) => read === name && open ? Promise.resolve(open) : readNpmPackument(read, privileged && authorized, signal),
|
|
175
|
+
tarball: () => Promise.reject(new NpmPackageError(502, 'upstream-invalid')) })
|
|
176
|
+
})
|
|
177
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Minified files pretty-printed for the Code tab (GET /api/bundles/:id/pretty
|
|
2
|
+
// one file at a time, GET /api/npm/pretty all a version's at once), and the
|
|
3
|
+
// files that changed between two bundles or versions for Compare (the same
|
|
4
|
+
// with `other`, or `otherVersion`), named by path and content hash, formatted
|
|
5
|
+
// with oxfmt once and kept Brotli-encoded by that hash: a bundle's in its
|
|
6
|
+
// cache directory (so only made from, and read by readers of, that bundle), a
|
|
7
|
+
// public npm version's under `cache/npm/`; a private version's are never
|
|
8
|
+
// kept. Access is the caller's to check.
|
|
9
|
+
import { Buffer } from 'node:buffer'
|
|
10
|
+
import { Readable } from 'node:stream'
|
|
11
|
+
import { promisify } from 'node:util'
|
|
12
|
+
import { brotliDecompress } from 'node:zlib'
|
|
13
|
+
import { format } from '@preventive/compare/pretty.js'
|
|
14
|
+
import { computeFileHash } from '@preventive/report'
|
|
15
|
+
import { FILE_HASH } from '../common/bundle-metadata.js'
|
|
16
|
+
import { bundleCompareFiles } from '../common/bundle-compare-inputs.js'
|
|
17
|
+
import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
18
|
+
import { MAX_PRETTY_BYTES, needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
|
|
19
|
+
import type { OpenedBlob } from './blob-store.ts'
|
|
20
|
+
import { type BundleCacheStorage, readBundleDetails } from './bundle-cache.ts'
|
|
21
|
+
import type { BundleStore } from './bundle-store.ts'
|
|
22
|
+
import { encodeBrotli } from './brotli.ts'
|
|
23
|
+
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
24
|
+
import type { ManagedBundle, ManagedDb } from './db.ts'
|
|
25
|
+
import { loadNpmFiles } from './npm-loads.ts'
|
|
26
|
+
import { type NpmVersionDocument, npmFileText } from './npm-packages.ts'
|
|
27
|
+
|
|
28
|
+
// Part of every kept copy's name; bumped when what the formatter writes for
|
|
29
|
+
// the same file changes, or what its check lets through, so copies written
|
|
30
|
+
// before are no longer read.
|
|
31
|
+
const PRETTY_VERSION = 3
|
|
32
|
+
// Formats running at once per process, each holding its file's syntax tree,
|
|
33
|
+
// and those waiting their turn past them (a version's files are formatted one
|
|
34
|
+
// after another, beside other readers'), any more refused.
|
|
35
|
+
const MAX_ACTIVE_FORMATS = 2
|
|
36
|
+
const MAX_WAITING_FORMATS = 16
|
|
37
|
+
|
|
38
|
+
export class PrettyError extends Error {
|
|
39
|
+
status: number
|
|
40
|
+
constructor(status: number, code: string) { super(code); this.status = status }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PrettyRequest { path: string; hash: string; extension: string }
|
|
44
|
+
|
|
45
|
+
// One of a version's files pretty-printed (npmAll), named by `source`, the
|
|
46
|
+
// hash of the file it was formatted from: its copy's text, or why it has
|
|
47
|
+
// none, as a PrettyError tells it.
|
|
48
|
+
export type PrettyLine = { path: string; source: string; text: string } | { path: string; source: string; error: string; status: number }
|
|
49
|
+
|
|
50
|
+
// Which of a comparison's two its files are of: the bundle or version asked
|
|
51
|
+
// about, or the `other`.
|
|
52
|
+
type Side = 'base' | 'other'
|
|
53
|
+
|
|
54
|
+
// One of a comparison's changed files pretty-printed (compared), as a
|
|
55
|
+
// version's is, with the side it is of.
|
|
56
|
+
export type ComparedLine = PrettyLine & { side: Side }
|
|
57
|
+
|
|
58
|
+
// A side of a comparison: its files that can be formatted, with their texts,
|
|
59
|
+
// and every one of its files' hashes, by path; its packages' directories by
|
|
60
|
+
// path, as a bundle records them (bundlePackageDirs), or null where it
|
|
61
|
+
// records none; the copy kept for one, or null; and one made, kept where the
|
|
62
|
+
// side keeps copies.
|
|
63
|
+
export interface ComparedSide {
|
|
64
|
+
side: Side
|
|
65
|
+
files: Array<PrettyRequest & { text: string }>
|
|
66
|
+
hashes: Map<string, string>
|
|
67
|
+
packageDirs: Map<string, string> | null
|
|
68
|
+
kept(request: PrettyRequest): Promise<OpenedBlob | null>
|
|
69
|
+
made(request: PrettyRequest, text: string): Promise<OpenedBlob>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The file a request's `path` and `hash` name, or null for one that can't be
|
|
73
|
+
// formatted.
|
|
74
|
+
export function prettyRequest(params: URLSearchParams): PrettyRequest | null {
|
|
75
|
+
const path = params.get('path') ?? ''
|
|
76
|
+
const extension = prettyExtension(path), hash = params.get('hash') ?? ''
|
|
77
|
+
return extension !== null && FILE_HASH.test(hash) ? { path, hash, extension } : null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A copy's name: its file's hash in hex, with the extension it was parsed by.
|
|
81
|
+
const prettyFile = ({ hash, extension }: PrettyRequest) =>
|
|
82
|
+
`pretty-v${PRETTY_VERSION}/${Buffer.from(hash.slice('sha512-'.length), 'base64').toString('hex')}.${extension}.br`
|
|
83
|
+
|
|
84
|
+
let activeFormats = 0
|
|
85
|
+
const waitingFormats: Array<() => void> = []
|
|
86
|
+
|
|
87
|
+
// The text formatted and Brotli-encoded, where @preventive/compare's checks
|
|
88
|
+
// find formatting changed only its layout: refused as `unsupported` where it
|
|
89
|
+
// has no check for the kind of file, `unformattable` or `pretty-mismatch`.
|
|
90
|
+
export async function prettyBody(text: string, extension: string): Promise<Buffer> {
|
|
91
|
+
if (Buffer.byteLength(text) > MAX_PRETTY_BYTES) throw new PrettyError(413, 'file-too-large')
|
|
92
|
+
if (activeFormats < MAX_ACTIVE_FORMATS) activeFormats++
|
|
93
|
+
else if (waitingFormats.length >= MAX_WAITING_FORMATS) throw new PrettyError(429, 'pretty-busy')
|
|
94
|
+
else await new Promise<void>(resolve => { waitingFormats.push(resolve) })
|
|
95
|
+
let result
|
|
96
|
+
try { result = await format(text, extension) }
|
|
97
|
+
finally {
|
|
98
|
+
const next = waitingFormats.shift()
|
|
99
|
+
if (next) next()
|
|
100
|
+
else activeFormats--
|
|
101
|
+
}
|
|
102
|
+
if ('error' in result) {
|
|
103
|
+
if (result.error === 'pretty-mismatch') console.warn(`managed: pretty-printing a .${extension} file changed more than its layout; not served`)
|
|
104
|
+
throw new PrettyError(422, result.error)
|
|
105
|
+
}
|
|
106
|
+
return encodeBrotli(Buffer.from(result.code))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const opened = (body: Buffer): OpenedBlob => ({ size: body.byteLength, stream: Readable.from([body]) })
|
|
110
|
+
|
|
111
|
+
const decompress = promisify(brotliDecompress)
|
|
112
|
+
|
|
113
|
+
// A pretty-printed copy's text, from its Brotli-encoded bytes.
|
|
114
|
+
export async function prettyText(copy: OpenedBlob): Promise<string> {
|
|
115
|
+
const chunks: Buffer[] = []
|
|
116
|
+
for await (const chunk of copy.stream) chunks.push(chunk as Buffer)
|
|
117
|
+
return (await decompress(Buffer.concat(chunks))).toString()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Every file of a version that needs pretty-printing (needsPrettyPrint), read
|
|
121
|
+
// in one load of its files, in path order.
|
|
122
|
+
async function npmPrintable(doc: NpmVersionDocument): Promise<Array<PrettyRequest & { text: string }>> {
|
|
123
|
+
const load = loadNpmFiles(doc)
|
|
124
|
+
const files: Array<PrettyRequest & { text: string }> = []
|
|
125
|
+
try {
|
|
126
|
+
for (const { path, bytes } of await load.result) {
|
|
127
|
+
const extension = prettyExtension(path)
|
|
128
|
+
if (extension === null || bytes.byteLength > MAX_PRETTY_BYTES) continue
|
|
129
|
+
const text = npmFileText(bytes)
|
|
130
|
+
if (text !== null && needsPrettyPrint(path, text)) files.push({ path, hash: await computeFileHash(bytes), extension, text })
|
|
131
|
+
}
|
|
132
|
+
} finally { load.release() }
|
|
133
|
+
return files.toSorted((a, b) => (a.path < b.path ? -1 : 1))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A file's line (PrettyLine): the text of its copy, as `copy` has it, or why
|
|
137
|
+
// it has none.
|
|
138
|
+
async function prettyLine({ path, hash }: PrettyRequest, copy: () => Promise<OpenedBlob>): Promise<PrettyLine> {
|
|
139
|
+
try { return { path, source: hash, text: await prettyText(await copy()) } }
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (!(err instanceof PrettyError)) throw err
|
|
142
|
+
return { path, source: hash, error: err.message, status: err.status }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The files that changed between two sides, pretty-printed: each side's
|
|
147
|
+
// formattable files the other has another text at the path of, or, where it
|
|
148
|
+
// has none there, none of their text at a path of its own (a file renamed, or
|
|
149
|
+
// moved with its tree, unchanged, has it there; a copy at a path both have is
|
|
150
|
+
// not one), by the paths each side has them at. Those are both of the pairs
|
|
151
|
+
// the viewer diffs but where it pairs files otherwise (among copies of one
|
|
152
|
+
// text, across packages), whose copies it asks for alone. As they are
|
|
153
|
+
// iterated, in path order, the base's first where both have one: those kept,
|
|
154
|
+
// at once; then the rest, formatted one after another, the minified before
|
|
155
|
+
// the others (needsPrettyPrint), as the viewer prints those unasked.
|
|
156
|
+
export function compared(sides: readonly [ComparedSide, ComparedSide]): AsyncGenerator<ComparedLine> {
|
|
157
|
+
// Each side's texts at the paths the other hasn't: where a file moved has its own.
|
|
158
|
+
const moved = sides.map((own, i) => new Set([...own.hashes].filter(([path]) => !sides[1 - i]!.hashes.has(path)).map(([, hash]) => hash)))
|
|
159
|
+
const changed = sides.flatMap((own, i) => {
|
|
160
|
+
const other = sides[1 - i]!.hashes
|
|
161
|
+
return own.files.filter(({ path, hash }) => (other.has(path) ? other.get(path) !== hash : !moved[1 - i]!.has(hash))).map(file => ({ own, file }))
|
|
162
|
+
}).toSorted((a, b) => (a.file.path < b.file.path ? -1 : a.file.path > b.file.path ? 1 : 0))
|
|
163
|
+
return (async function* () {
|
|
164
|
+
const unkept: typeof changed = []
|
|
165
|
+
for (const { own, file } of changed) {
|
|
166
|
+
const copy = await own.kept(file)
|
|
167
|
+
if (copy) yield { side: own.side, ...await prettyLine(file, () => Promise.resolve(copy)) }
|
|
168
|
+
else unkept.push({ own, file })
|
|
169
|
+
}
|
|
170
|
+
const minified = new Map(unkept.map(({ file }) => [file, needsPrettyPrint(file.path, file.text)]))
|
|
171
|
+
for (const { own, file } of unkept.toSorted((a, b) => Number(minified.get(b.file)) - Number(minified.get(a.file)))) {
|
|
172
|
+
yield { side: own.side, ...await prettyLine(file, () => own.made(file, file.text)) }
|
|
173
|
+
}
|
|
174
|
+
})()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Jobs by key, readers asking for one while it runs sharing it.
|
|
178
|
+
function sharing<T>(): (key: string, make: () => Promise<T>) => Promise<T> {
|
|
179
|
+
const pending = new Map<string, Promise<T>>()
|
|
180
|
+
return (key, make) => {
|
|
181
|
+
let job = pending.get(key)
|
|
182
|
+
if (!job) pending.set(key, job = make().finally(() => pending.delete(key)))
|
|
183
|
+
return job
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Work done one job at a time, in the order asked.
|
|
188
|
+
function serial(): <T>(work: () => Promise<T>) => Promise<T> {
|
|
189
|
+
let queue = Promise.resolve()
|
|
190
|
+
return work => {
|
|
191
|
+
const job = queue.then(work)
|
|
192
|
+
queue = job.then(() => undefined, () => undefined)
|
|
193
|
+
return job
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function keep(put: () => Promise<void>) {
|
|
198
|
+
try { await put() } catch (err) { console.warn('managed: pretty-print cache write failed:', err) }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A copy kept, or null where none is.
|
|
202
|
+
async function kept(open: () => Promise<OpenedBlob>): Promise<OpenedBlob | null> {
|
|
203
|
+
try { return await open() }
|
|
204
|
+
catch (err) { if (err instanceof CacheMissError) return null; throw err }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A side's files that can be formatted, with their texts, and every one of
|
|
208
|
+
// its files' hashes by path, from their paths and contents: texts, or bytes,
|
|
209
|
+
// read as text where they are.
|
|
210
|
+
async function sideFiles(entries: Iterable<[string, string | Uint8Array]>): Promise<Pick<ComparedSide, 'files' | 'hashes'>> {
|
|
211
|
+
const files: ComparedSide['files'] = [], hashes = new Map<string, string>()
|
|
212
|
+
for (const [path, content] of entries) {
|
|
213
|
+
const hash = await computeFileHash(content)
|
|
214
|
+
hashes.set(path, hash)
|
|
215
|
+
const extension = prettyExtension(path)
|
|
216
|
+
if (extension === null || (typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength) > MAX_PRETTY_BYTES) continue
|
|
217
|
+
const text = typeof content === 'string' ? content : npmFileText(content)
|
|
218
|
+
if (text !== null) files.push({ path, hash, extension, text })
|
|
219
|
+
}
|
|
220
|
+
return { files, hashes }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A bundle's side, from its details: its files, as sideFiles reads them,
|
|
224
|
+
// within a reason `scope` as Compare reads them there (bundleCompareFiles),
|
|
225
|
+
// and its packages' directories.
|
|
226
|
+
async function bundleSide(details: Parameters<typeof bundlePackageDirs>[0], scope: string): Promise<Pick<ComparedSide, 'files' | 'hashes' | 'packageDirs'>> {
|
|
227
|
+
const scoped = scope ? new Set(bundleCompareFiles(details, scope).keys()) : null, sources = bundleSourcesAsMap(details)
|
|
228
|
+
return { ...await sideFiles(scoped ? [...sources].filter(([path]) => scoped.has(path)) : sources), packageDirs: bundlePackageDirs(details) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A version's files by path, from one load of them.
|
|
232
|
+
async function npmFiles(doc: NpmVersionDocument): Promise<Array<[string, Uint8Array]>> {
|
|
233
|
+
const load = loadNpmFiles(doc)
|
|
234
|
+
try { return (await load.result).map(({ path, bytes }) => [path, bytes]) }
|
|
235
|
+
finally { load.release() }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Removes the public npm copies of every version before PRETTY_VERSION, which
|
|
239
|
+
// nothing reads any more, for maintenance: once a process, again where
|
|
240
|
+
// removing them failed. A bundle's earlier copies go with the bundle.
|
|
241
|
+
export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) => Promise<void> {
|
|
242
|
+
let removed = false
|
|
243
|
+
return async signal => {
|
|
244
|
+
if (removed) return
|
|
245
|
+
for (let version = 1; version < PRETTY_VERSION; version++) {
|
|
246
|
+
if (signal?.aborted) return
|
|
247
|
+
await npm.delete(`pretty-v${version}`)
|
|
248
|
+
}
|
|
249
|
+
removed = true
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
|
|
254
|
+
// Copies being made; readers asking for one meanwhile share it.
|
|
255
|
+
const shared = sharing<Buffer>()
|
|
256
|
+
// Bundles are read one at a time: reading one decodes all of it.
|
|
257
|
+
const enqueue = serial()
|
|
258
|
+
function bundleDetails(record: ManagedBundle) {
|
|
259
|
+
return enqueue(async () => {
|
|
260
|
+
const details = await readBundleDetails(record, store)
|
|
261
|
+
if (!details) throw new PrettyError(422, 'bundle-unavailable')
|
|
262
|
+
return details
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
// A bundle's file pretty-printed from the text `read` reads, and kept in
|
|
266
|
+
// its cache.
|
|
267
|
+
function bundleCopy(record: ManagedBundle, request: PrettyRequest, read: () => Promise<string>): Promise<Buffer> {
|
|
268
|
+
const file = prettyFile(request)
|
|
269
|
+
return shared(`bundle ${record.id} ${file}`, async () => {
|
|
270
|
+
const body = await prettyBody(await read(), request.extension)
|
|
271
|
+
// As for its metadata (bundle-cache.ts): a bundle deleted meanwhile
|
|
272
|
+
// keeps no copy, nor does one deleted while it was written.
|
|
273
|
+
if (!await db.getBundle(record.id)) throw new PrettyError(404, 'no-bundle')
|
|
274
|
+
await keep(() => bundles.put(record.id, file, body))
|
|
275
|
+
if (!await db.getBundle(record.id)) {
|
|
276
|
+
await bundles.delete(record.id)
|
|
277
|
+
throw new PrettyError(404, 'no-bundle')
|
|
278
|
+
}
|
|
279
|
+
return body
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
// A version's file pretty-printed, `read` reading its text (and checking
|
|
283
|
+
// it is the file named): from the copy kept for a public version, else
|
|
284
|
+
// made, and kept for a public one.
|
|
285
|
+
async function npmCopy(doc: NpmVersionDocument, request: PrettyRequest, read: () => Promise<string>): Promise<OpenedBlob> {
|
|
286
|
+
const file = prettyFile(request)
|
|
287
|
+
if (!doc.private) {
|
|
288
|
+
try { return await npm.open(file) }
|
|
289
|
+
catch (err) { if (!(err instanceof CacheMissError)) throw err }
|
|
290
|
+
}
|
|
291
|
+
return opened(await shared(doc.private ? `npm ${doc.dist.integrity} ${file}` : `npm ${file}`, async () => {
|
|
292
|
+
const body = await prettyBody(await read(), request.extension)
|
|
293
|
+
if (!doc.private) await keep(() => npm.put(file, body))
|
|
294
|
+
return body
|
|
295
|
+
}))
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
async bundle(record: ManagedBundle, request: PrettyRequest): Promise<OpenedBlob> {
|
|
299
|
+
const copy = await kept(() => bundles.open(record.id, prettyFile(request)))
|
|
300
|
+
if (copy) return copy
|
|
301
|
+
return opened(await bundleCopy(record, request, async () => {
|
|
302
|
+
const text = bundleSourcesAsMap(await bundleDetails(record)).get(request.path)
|
|
303
|
+
if (text === undefined) throw new PrettyError(404, 'no-file')
|
|
304
|
+
if (await computeFileHash(text) !== request.hash) throw new PrettyError(409, 'hash-mismatch')
|
|
305
|
+
return text
|
|
306
|
+
}))
|
|
307
|
+
},
|
|
308
|
+
// The version's file, from its files loaded as the viewer's are (and
|
|
309
|
+
// held to the same few loads at once).
|
|
310
|
+
npm(doc: NpmVersionDocument, request: PrettyRequest): Promise<OpenedBlob> {
|
|
311
|
+
return npmCopy(doc, request, async () => {
|
|
312
|
+
const load = loadNpmFiles(doc)
|
|
313
|
+
let text: string | null
|
|
314
|
+
try {
|
|
315
|
+
const bytes = (await load.result).find(entry => entry.path === request.path)?.bytes
|
|
316
|
+
if (!bytes) throw new PrettyError(404, 'no-file')
|
|
317
|
+
if (await computeFileHash(bytes) !== request.hash) throw new PrettyError(409, 'hash-mismatch')
|
|
318
|
+
text = npmFileText(bytes)
|
|
319
|
+
} finally { load.release() }
|
|
320
|
+
if (text === null) throw new PrettyError(422, 'unformattable')
|
|
321
|
+
return text
|
|
322
|
+
})
|
|
323
|
+
},
|
|
324
|
+
// Every file of the version that needs pretty-printing (npmPrintable),
|
|
325
|
+
// read before this resolves; then, as they are iterated, each
|
|
326
|
+
// pretty-printed as `npm` has it, one after another.
|
|
327
|
+
async npmAll(doc: NpmVersionDocument): Promise<AsyncGenerator<PrettyLine>> {
|
|
328
|
+
const files = await npmPrintable(doc)
|
|
329
|
+
return (async function* () {
|
|
330
|
+
for (const { text, ...request } of files) yield await prettyLine(request, () => npmCopy(doc, request, () => Promise.resolve(text)))
|
|
331
|
+
})()
|
|
332
|
+
},
|
|
333
|
+
// Two bundles' sides, or two versions', as a comparison reads them, read
|
|
334
|
+
// before this resolves: for the files that changed between them
|
|
335
|
+
// pretty-printed (compared), and their diffs with renamed names hidden
|
|
336
|
+
// (rename-diff.ts renamedPairs); a bundle's within a reason `scope`
|
|
337
|
+
// where one is asked for, as Compare reads it then.
|
|
338
|
+
async bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
|
|
339
|
+
const sideOf = async (record: ManagedBundle, side: Side): Promise<ComparedSide> => ({ side, ...await bundleSide(await bundleDetails(record), scope),
|
|
340
|
+
kept: request => kept(() => bundles.open(record.id, prettyFile(request))),
|
|
341
|
+
made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
|
|
342
|
+
return [await sideOf(base, 'base'), await sideOf(other, 'other')]
|
|
343
|
+
},
|
|
344
|
+
async npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
|
|
345
|
+
const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)), packageDirs: null,
|
|
346
|
+
kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
|
|
347
|
+
made: (request, text) => npmCopy(doc, request, () => Promise.resolve(text)) })
|
|
348
|
+
return [await sideOf(base, 'base'), await sideOf(other, 'other')]
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
export type PrettyCache = ReturnType<typeof createPrettyCache>
|