@preventive/triage 1.0.0-alpha.46 → 1.0.0-alpha.48
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/client/dependency-paths.d.ts +2 -1
- package/client/dependency-paths.js +16 -4
- package/common/managed/agent-tasks.ts +12 -12
- package/common/managed/github-agents.ts +29 -10
- package/out/activity.js +4 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C2AVODGZ.js +36 -0
- package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
- package/out/chunk-MOACFRJ4.js +3 -0
- package/out/chunk-O6M574JI.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PENGGKX7.js +233 -0
- package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
- package/out/chunk-SZD5CW5R.js +3 -0
- package/out/{chunk-G24A42V6.js → chunk-TQJDYHVE.js} +1 -1
- package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
- package/out/{chunk-7XRR4OFU.js → chunk-WYLQWYLA.js} +1 -1
- package/out/chunk-X6PJAK3T.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +26 -26
- package/out/client-sync.js +2 -2
- package/out/compare.js +14 -14
- package/out/dialogs.js +40 -40
- package/out/graph.js +11 -11
- package/out/index.html +12 -12
- package/out/scan.js +2 -2
- package/out/terminal.js +215 -215
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +9 -5
- package/risk/index.d.ts +4 -2
- package/risk/npm/assess.js +15 -4
- package/risk/npm/registry.js +6 -1
- package/risk/npm/score.js +1 -1
- package/server-managed/activity.ts +24 -11
- package/server-managed/agent-merges.ts +10 -8
- package/server-managed/app-agents.ts +8 -21
- package/server-managed/bundle-build-worker.js +7 -3
- package/server-managed/bundle-build.ts +5 -3
- package/server-managed/bundle-cache.ts +9 -3
- package/server-managed/bundle.ts +3 -4
- package/server-managed/cache-storage.ts +3 -2
- package/server-managed/commit-coauthors.ts +11 -10
- package/server-managed/commit-lockfiles.ts +22 -21
- package/server-managed/db-methods.ts +338 -187
- package/server-managed/db-neon.ts +31 -8
- package/server-managed/db.ts +2 -0
- package/server-managed/dependabot-access.ts +1 -1
- package/server-managed/dependency-review-comments.ts +7 -4
- package/server-managed/dependency-reviews.ts +71 -33
- package/server-managed/github-accounts.ts +28 -1
- package/server-managed/github-app.ts +27 -23
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +8 -1
- package/server-managed/github-metadata.ts +2 -2
- package/server-managed/http-response.ts +28 -10
- package/server-managed/http.ts +736 -247
- package/server-managed/index.ts +2 -2
- package/server-managed/link-reports.ts +5 -1
- package/server-managed/managed-issues.ts +3 -0
- package/server-managed/npm-delta.ts +16 -0
- package/server-managed/npm-kept.ts +51 -10
- package/server-managed/npm-loads.ts +48 -28
- package/server-managed/npm-manifest.ts +1 -1
- package/server-managed/npm-packages.ts +31 -15
- package/server-managed/npm-risk.ts +64 -17
- package/server-managed/object-storage.ts +4 -3
- package/server-managed/pretty-print.ts +45 -12
- package/server-managed/public-workspace.ts +3 -3
- package/server-managed/pull-dependencies.ts +84 -36
- package/server-managed/pull-dependency-reviews.ts +13 -10
- package/server-managed/rename-diff-worker.js +1 -1
- package/server-managed/report-store.ts +16 -11
- package/server-managed/repository-agents.ts +9 -6
- package/server-managed/repository-browser.ts +17 -5
- package/server-managed/repository-code.ts +180 -17
- package/server-managed/repository-dependency-reviews.ts +7 -0
- package/server-managed/repository-history.ts +85 -152
- package/server-managed/repository-issues.ts +27 -13
- package/server-managed/repository-pulse.ts +9 -3
- package/server-managed/repository-squashes.ts +145 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +3 -2
- package/server-managed/session.ts +34 -12
- package/server-managed/solidity-entry-points.ts +5 -3
- package/server-managed/sql-postgres.ts +3 -0
- package/server-managed/sql.ts +17 -8
- package/server-managed/storage-db.ts +11 -3
- package/server-managed/storage-encryption.ts +23 -12
- package/server-managed/storage-maintenance.ts +5 -2
- package/server-managed/storage-payload.ts +18 -0
- package/server-managed/storage-stores.ts +3 -2
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-feed.ts +19 -12
- package/server-managed/team-npm-scopes.ts +29 -0
- package/server-managed/team-reports.ts +74 -17
- package/server-managed/uploads.ts +29 -9
- package/out/chunk-4UFAMJAX.js +0 -1
- package/out/chunk-BOIXMUU5.js +0 -3
- package/out/chunk-CC4OM6TZ.js +0 -3
- package/out/chunk-JKGQ3O7O.js +0 -6
- package/out/chunk-LE5ZWLG4.js +0 -235
- package/out/chunk-MSION3G4.js +0 -36
- package/out/chunk-S6GIXWQW.js +0 -52
- package/out/chunk-V3KWNNMY.js +0 -1
- package/out/chunk-WQ74ZBZF.js +0 -7
package/server-managed/index.ts
CHANGED
|
@@ -44,7 +44,7 @@ export async function createManagedApp(config: ManagedConfig, options: ManagedAp
|
|
|
44
44
|
|
|
45
45
|
async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
|
|
46
46
|
const storage = await openManagedStorage(config)
|
|
47
|
-
const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage } = storage
|
|
47
|
+
const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage } = storage
|
|
48
48
|
rollback.defer(() => db.close())
|
|
49
49
|
const originGate = createOriginGate(config.host, config.trustProxyEnv)
|
|
50
50
|
|
|
@@ -60,7 +60,7 @@ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options
|
|
|
60
60
|
indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
|
|
61
61
|
}) : () => false
|
|
62
62
|
const routeRequest = createManagedRequestHandler({
|
|
63
|
-
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, originGate, serveStatic,
|
|
63
|
+
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage, originGate, serveStatic,
|
|
64
64
|
...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
|
|
65
65
|
isShuttingDown, track,
|
|
66
66
|
})
|
|
@@ -39,7 +39,11 @@ function decryptGroups(key: StorageKey | null, id: string, payload: string): str
|
|
|
39
39
|
try { return JSON.parse(plain.toString('utf8')) as string[][] } finally { plain.fill(0) }
|
|
40
40
|
}
|
|
41
41
|
export async function linkRevision(db: ManagedSql): Promise<string> {
|
|
42
|
-
|
|
42
|
+
return linkRevisionOf(await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all())
|
|
43
|
+
}
|
|
44
|
+
// The revision of the enabled link reports' `{ id, sha256 }` rows, by id,
|
|
45
|
+
// however they were read.
|
|
46
|
+
export function linkRevisionOf(rows: unknown[]): string {
|
|
43
47
|
return rows.length > 0 ? createHash('sha256').update(JSON.stringify(rows)).digest('base64url') : ''
|
|
44
48
|
}
|
|
45
49
|
function parseLinkReport(content: string) {
|
|
@@ -53,6 +53,9 @@ export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
|
|
|
53
53
|
issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, created_by AS createdBy, created_at AS createdAt`
|
|
54
54
|
const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
|
|
55
55
|
const list = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY finding_id`)
|
|
56
|
+
// A lookup's derived Fix replaces the one it read, unless one started later
|
|
57
|
+
// changed it since: the check time, which only changes stamp
|
|
58
|
+
// (github-issue-links.ts issueUpdates), orders them.
|
|
56
59
|
const autoFix = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, auto_fix_checked_at = ?
|
|
57
60
|
WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
|
|
58
61
|
AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
|
|
@@ -154,6 +154,22 @@ export function npmDelta(base: Side | null, other: Side, kept: KeptStore<NpmDelt
|
|
|
154
154
|
return work
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// The deltas of those of `sides` (each the integrities of the version it is
|
|
158
|
+
// against, or none, and of the version) held or kept, by their keys
|
|
159
|
+
// (npmDelta's), in one read of `kept` for those not held: none worked out.
|
|
160
|
+
export async function keptNpmDeltas(sides: [string | null, string][], kept: KeptStore<NpmDelta>): Promise<Map<string, NpmDelta>> {
|
|
161
|
+
const keys = [...new Set(sides.map(([base, other]) => `${base ?? '(empty)'}\0${other}`))]
|
|
162
|
+
const found = new Map(keys.flatMap(key => { const known = deltas.get(key); return known ? [[key, known] as const] : [] }))
|
|
163
|
+
const unheld = keys.filter(key => !found.has(key))
|
|
164
|
+
for (const [key, row] of unheld.length > 0 ? await kept.readMany(unheld) : []) {
|
|
165
|
+
const known = deltaOf(row.value)
|
|
166
|
+
if (!known) continue
|
|
167
|
+
deltas.set(key, known)
|
|
168
|
+
found.set(key, known)
|
|
169
|
+
}
|
|
170
|
+
return found
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
// As kept, else its turn awaited, then both sides' files read, counted and kept.
|
|
158
174
|
async function worked(key: string, base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null): Promise<NpmDelta> {
|
|
159
175
|
const known = deltaOf((await kept?.read(key))?.value)
|
|
@@ -11,10 +11,14 @@
|
|
|
11
11
|
// of a repository's GitHub, its issues, pull requests and agents' work, for a
|
|
12
12
|
// day (http.ts keptAnswer), and its history, a page from a commit at a time,
|
|
13
13
|
// for a month (repository-history.ts historyFrom), as are the files each of
|
|
14
|
-
// its commits changes and holds (repository-changes.ts),
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// its commits changes and holds (repository-changes.ts), its trees as the
|
|
15
|
+
// code viewer reads them (codeTreeStore), the packages a pull request's
|
|
16
|
+
// lockfiles add (pull-dependencies.ts), and the lockfiles a commit lists and
|
|
17
|
+
// each one's packages, as its dependency review reads them
|
|
18
|
+
// (commit-lockfiles.ts); and the commit the code viewer keeps its
|
|
19
|
+
// directories at, for as long as it does (codeHeadStore). Each store removes
|
|
20
|
+
// its own rows kept longer than that as it writes, at most once an hour a
|
|
21
|
+
// database (pruneKept), so that rows no read will take again don't pile up.
|
|
18
22
|
// The database is an optimization: a failure reads as a miss, and writes
|
|
19
23
|
// nothing.
|
|
20
24
|
import { createHash } from 'node:crypto'
|
|
@@ -38,15 +42,25 @@ export interface KeptStore<T> {
|
|
|
38
42
|
// When each database last had the rows under each prefix removed.
|
|
39
43
|
const PRUNE_EVERY_MS = 60 * 60_000
|
|
40
44
|
const pruned = new WeakMap<UpstreamCacheStore, Map<string, number>>()
|
|
45
|
+
// Who sees each database's prunes through (index.ts: its app, which waits
|
|
46
|
+
// for them before closing it, and on serverless keeps its invocation alive
|
|
47
|
+
// till they settle), as a write no longer waits for one.
|
|
48
|
+
const pruneOwners = new WeakMap<UpstreamCacheStore, (prune: Promise<unknown>) => void>()
|
|
49
|
+
export function ownKeptPrunes(db: UpstreamCacheStore, own: (prune: Promise<unknown>) => void) {
|
|
50
|
+
pruneOwners.set(db, own)
|
|
51
|
+
}
|
|
41
52
|
|
|
42
53
|
// Removes the rows under `prefix` kept `keptMs` before `at`, at most once an
|
|
43
54
|
// hour a database, as rows under it are written: here, and risk's downloads
|
|
44
|
-
// (npm-risk.ts)
|
|
45
|
-
|
|
55
|
+
// (npm-risk.ts), each one run handed to the database's owner. A failure is
|
|
56
|
+
// `warn`ed of, and removes nothing.
|
|
57
|
+
export function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void): Promise<void> {
|
|
46
58
|
const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
|
|
47
|
-
if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return
|
|
59
|
+
if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return Promise.resolve()
|
|
48
60
|
times.set(prefix, at)
|
|
49
|
-
try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) }
|
|
61
|
+
const prune = (async () => { try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) } })()
|
|
62
|
+
pruneOwners.get(db)?.(prune)
|
|
63
|
+
return prune
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
// Values of `type`, each read for `keptMs` after it was kept.
|
|
@@ -79,7 +93,11 @@ function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debu
|
|
|
79
93
|
},
|
|
80
94
|
async write(key, value, at) {
|
|
81
95
|
try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
|
|
82
|
-
|
|
96
|
+
// Behind the write, not before it is done: the prune reads the whole
|
|
97
|
+
// table (no index serves its prefix), which no writer need wait on.
|
|
98
|
+
// Its hour is marked before it runs, so writes meanwhile start none,
|
|
99
|
+
// and its database's owner sees it through (ownKeptPrunes).
|
|
100
|
+
void prune(at)
|
|
83
101
|
},
|
|
84
102
|
async remove(key) {
|
|
85
103
|
try { await db.deleteUpstreamCacheEntries(keyOf(key), Number.MAX_SAFE_INTEGER) } catch (error) { warn('remove', error) }
|
|
@@ -141,7 +159,7 @@ export const repositoryReadStore = (db: UpstreamCacheStore, debug = false): Kept
|
|
|
141
159
|
export const HISTORY_PAGE_KEPT_MS = 30 * 24 * 60 * 60_000
|
|
142
160
|
export const historyPageStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/history', HISTORY_PAGE_KEPT_MS, debug)
|
|
143
161
|
// The pull requests squashed into a repository's commits, by the commit
|
|
144
|
-
// (repository-
|
|
162
|
+
// (repository-squashes.ts squashedPulls): kept as its history's pages are.
|
|
145
163
|
export const historySquashStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/squashes', HISTORY_PAGE_KEPT_MS, debug)
|
|
146
164
|
// The files each of a repository's commits changes (repository-changes.ts
|
|
147
165
|
// repositoryChanges), by the commit, which never change: kept as its
|
|
@@ -151,6 +169,21 @@ export const historyChangeStore = <T>(db: UpstreamCacheStore, debug = false): Ke
|
|
|
151
169
|
// repositoryTree), by the commit, which never change: kept as its history's
|
|
152
170
|
// pages are.
|
|
153
171
|
export const historyTreeStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/trees', HISTORY_PAGE_KEPT_MS, debug)
|
|
172
|
+
// What the code viewer read of a repository's trees (http.ts
|
|
173
|
+
// repositoryTreesOf), which never changes once a commit or tree names it: the
|
|
174
|
+
// tree at a directory of a commit, by the repository's id, the commit and the
|
|
175
|
+
// directory (`trees`), and how many files a tree holds and their bytes, by
|
|
176
|
+
// the repository's id and the tree's (`tree-files`): kept as its history's
|
|
177
|
+
// pages are.
|
|
178
|
+
export const codeTreeStore = <T>(db: UpstreamCacheStore, type: 'trees' | 'tree-files', debug = false): KeptStore<T> =>
|
|
179
|
+
keptStore<T>(db, `repository/code-${type}`, HISTORY_PAGE_KEPT_MS, debug)
|
|
180
|
+
// The commit the code viewer keeps a repository's directories at in byte
|
|
181
|
+
// storage, its default branch's head as last read, and the repository's
|
|
182
|
+
// name they are kept under (repository-code.ts repositoryCodeKept), by the
|
|
183
|
+
// repository's id: kept until the head moves, the repository is renamed or
|
|
184
|
+
// it is removed, one for each repository.
|
|
185
|
+
export const codeHeadStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ commit: string; repo: string }> =>
|
|
186
|
+
keptStore<{ commit: string; repo: string }>(db, 'repository/code-heads', Infinity, debug)
|
|
154
187
|
// The packages a repository's pull request's lockfiles add
|
|
155
188
|
// (pull-dependencies.ts), by the commits of its base and head, which never
|
|
156
189
|
// change: kept as its history's pages are.
|
|
@@ -164,3 +197,11 @@ export const pullDependencyStore = <T>(db: UpstreamCacheStore, debug = false): K
|
|
|
164
197
|
export const npmDeltaStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'npm/deltas', HISTORY_PAGE_KEPT_MS, debug)
|
|
165
198
|
export const reviewBaselineStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ versions: string[]; unread: string[] }> =>
|
|
166
199
|
keptStore<{ versions: string[]; unread: string[] }>(db, 'repository/review-baselines', HISTORY_PAGE_KEPT_MS, debug)
|
|
200
|
+
// What a repository's dependency review read of GitHub (pull-dependencies.ts
|
|
201
|
+
// LockfileStore): the lockfiles a commit lists (`listings`), by the commit,
|
|
202
|
+
// and a lockfile's packages with what their reviews are held to
|
|
203
|
+
// (`lockfiles`), by its blob, which never change: kept as its history's
|
|
204
|
+
// pages are, apart from a scan's (scanReadStore), which reads them without
|
|
205
|
+
// what their reviews are held to.
|
|
206
|
+
export const lockfileReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles', debug = false): KeptStore<T> =>
|
|
207
|
+
keptStore<T>(db, `repository/review-${type}`, HISTORY_PAGE_KEPT_MS, debug)
|
|
@@ -9,8 +9,8 @@ import { join, resolve } from 'node:path'
|
|
|
9
9
|
import { CompressionError, decompress } from '@preventive/archive/compression.js'
|
|
10
10
|
import { encodeBrotli } from './brotli.ts'
|
|
11
11
|
import { keptBriefly } from './kept-briefly.ts'
|
|
12
|
-
import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument,
|
|
13
|
-
npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
|
|
12
|
+
import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, type TarReading,
|
|
13
|
+
askUpstream, npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
|
|
14
14
|
import { keepFile, readKeptFile, upstreamCachePath } from './upstream-cache.ts'
|
|
15
15
|
|
|
16
16
|
// The tar stream a package's files may come in, headers and padding included.
|
|
@@ -39,9 +39,10 @@ interface Load { job: Promise<unknown>; readers: number; settled: boolean; slot:
|
|
|
39
39
|
// keeps its place among the active ones until its work is done and each
|
|
40
40
|
// reader has released it, as a reader does once its response is written or
|
|
41
41
|
// abandoned, so what responses still hold counts too. Places by the slot
|
|
42
|
-
// their loads share, with how many hold each.
|
|
42
|
+
// their loads share, with how many hold each and how many places it takes.
|
|
43
43
|
const loads = new Map<string, Load>()
|
|
44
|
-
const slots = new Map<string, number>()
|
|
44
|
+
const slots = new Map<string, { holding: number; places: number }>()
|
|
45
|
+
const placesTaken = () => [...slots.values()].reduce((sum, slot) => sum + slot.places, 0)
|
|
45
46
|
|
|
46
47
|
const refused = <T>(status: number, code: string): NpmLoad<T> => {
|
|
47
48
|
const result = Promise.reject(new NpmPackageError(status, code))
|
|
@@ -49,30 +50,40 @@ const refused = <T>(status: number, code: string): NpmLoad<T> => {
|
|
|
49
50
|
return { result, release() {} }
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
// What names a version's tarball, and so its files: its integrity
|
|
53
|
-
// and whether it is read with the token.
|
|
54
|
-
const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`
|
|
53
|
+
// What names a version's tarball, and so its files: its integrity, its
|
|
54
|
+
// shasum and URL, and whether it is read with the token.
|
|
55
|
+
const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.shasum ?? ''} ${doc.dist.tarball} ${doc.private}`
|
|
55
56
|
|
|
56
57
|
function shared<T>(what: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
|
|
57
58
|
const { unpackedSize, fileCount } = doc.dist
|
|
58
59
|
if ((unpackedSize ?? 0) > MAX_NPM_PACKAGE_BYTES || (fileCount ?? 0) > MAX_NPM_PACKAGE_FILES) return refused(413, 'package-too-large')
|
|
59
60
|
const slot = tarballKey(doc)
|
|
60
|
-
return sharedLoad(`${what} ${slot}`, work, 'npm-busy', slot)
|
|
61
|
+
return sharedLoad(`${what} ${slot}`, work, 'npm-busy', { slot })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The places among the active ones a load takes whose files hold `files` and
|
|
65
|
+
// `bytes`: as many as packages they would fill, up to every one, each place
|
|
66
|
+
// holding what a package may.
|
|
67
|
+
export function loadPlaces({ files, bytes }: { files: number; bytes: number }): number {
|
|
68
|
+
return Math.min(MAX_ACTIVE_LOADS, Math.max(1, Math.ceil(files / MAX_NPM_PACKAGE_FILES), Math.ceil(bytes / MAX_NPM_PACKAGE_BYTES)))
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
// A load among the active ones, by `key`, refused as `busy` past them: a
|
|
64
72
|
// repository directory's (repository-code.ts) as a package's, each holding
|
|
65
73
|
// alike a tarball, its tar, its files and their encoded JSON. Loads of one
|
|
66
|
-
// `slot` take one place between them
|
|
67
|
-
|
|
74
|
+
// `slot` take one place between them, or `places` (loadPlaces) where they
|
|
75
|
+
// hold more.
|
|
76
|
+
export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string,
|
|
77
|
+
{ slot = key, places = 1 }: { slot?: string; places?: number } = {}): NpmLoad<T> {
|
|
68
78
|
let load = loads.get(key)
|
|
69
79
|
if (!load) {
|
|
70
|
-
if (!slots.has(slot) &&
|
|
80
|
+
if (!slots.has(slot) && placesTaken() + places > MAX_ACTIVE_LOADS) return refused(429, busy)
|
|
71
81
|
const created: Load = { job: work(), readers: 0, settled: false, slot }
|
|
72
82
|
const finish = () => { settle(key, created) }
|
|
73
83
|
created.job.then(finish, finish)
|
|
74
84
|
loads.set(key, created)
|
|
75
|
-
|
|
85
|
+
const taken = slots.get(slot)
|
|
86
|
+
slots.set(slot, { holding: (taken?.holding ?? 0) + 1, places: taken?.places ?? places })
|
|
76
87
|
load = created
|
|
77
88
|
}
|
|
78
89
|
const held = load
|
|
@@ -99,15 +110,21 @@ function settle(key: string, load: Load) {
|
|
|
99
110
|
function done(key: string, load: Load) {
|
|
100
111
|
if (load.readers !== 0 || !load.settled || loads.get(key) !== load) return
|
|
101
112
|
loads.delete(key)
|
|
102
|
-
const
|
|
103
|
-
if (holding ===
|
|
104
|
-
else
|
|
113
|
+
const taken = slots.get(load.slot)!
|
|
114
|
+
if (taken.holding === 1) slots.delete(load.slot)
|
|
115
|
+
else taken.holding--
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
// The sha512 an integrity names, in base64, or null where it names none.
|
|
108
119
|
const sha512Of = (integrity: string) => /(?:^|\s)sha512-([\d+/A-Za-z]{86}==)(?=\s|$)/u.exec(integrity)?.[1] ?? null
|
|
109
120
|
// Hashed off the event loop: a tarball runs to tens of MiB.
|
|
110
|
-
const
|
|
121
|
+
const digest = async (algorithm: string, bytes: Uint8Array) => Buffer.from(await crypto.subtle.digest(algorithm, bytes as Uint8Array<ArrayBuffer>))
|
|
122
|
+
// Whether `bytes` are those of the sha512 an integrity names and, where there
|
|
123
|
+
// is one, of the shasum (a sha1, in hex).
|
|
124
|
+
async function hashedAs(bytes: Uint8Array, sha512: string, shasum: string | null): Promise<boolean> {
|
|
125
|
+
const [own512, own1] = await Promise.all([digest('SHA-512', bytes), shasum === null ? null : digest('SHA-1', bytes)])
|
|
126
|
+
return own512.toString('base64') === sha512 && (own1 === null || own1.toString('hex') === shasum)
|
|
127
|
+
}
|
|
111
128
|
|
|
112
129
|
// Upstream's disk cache (setCacheDir), where tarballs are kept between
|
|
113
130
|
// loads, or null to keep none and read none.
|
|
@@ -140,23 +157,25 @@ async function downloadTarball(url: string, isPrivate: boolean): Promise<Buffer>
|
|
|
140
157
|
return bytes
|
|
141
158
|
}
|
|
142
159
|
|
|
143
|
-
// The version's tarball, its bytes held to the document's sha512
|
|
144
|
-
// they come from: kept in the cache,
|
|
145
|
-
// fetch with the token, or in npm's; else
|
|
146
|
-
// matching the
|
|
147
|
-
// reader may download, wherever they
|
|
160
|
+
// The version's tarball, its bytes held to the document's sha512, and to its
|
|
161
|
+
// shasum where it tells one, wherever they come from: kept in the cache,
|
|
162
|
+
// where bundle builds keep what they fetch with the token, or in npm's; else
|
|
163
|
+
// downloaded, and then kept. Bytes matching the hashes the reader's own
|
|
164
|
+
// document names are the tarball that reader may download, wherever they
|
|
165
|
+
// were kept and whoever fetched them.
|
|
148
166
|
async function readTarball(doc: NpmVersionDocument): Promise<Uint8Array> {
|
|
149
|
-
const expected = sha512Of(doc.dist.integrity), url = doc.dist.tarball
|
|
150
|
-
if (!url.startsWith(`${NPM_REGISTRY}/${doc.name}/-/`) || URL.parse(url)?.href !== url || expected === null
|
|
167
|
+
const expected = sha512Of(doc.dist.integrity), shasum = doc.dist.shasum ?? null, url = doc.dist.tarball
|
|
168
|
+
if (!url.startsWith(`${NPM_REGISTRY}/${doc.name}/-/`) || URL.parse(url)?.href !== url || expected === null
|
|
169
|
+
|| (shasum !== null && !/^[\da-f]{40}$/u.test(shasum))) throw new NpmPackageError(502, 'upstream-invalid')
|
|
151
170
|
const kept = tarballCache === null ? null : upstreamCachePath(tarballCache, 'npm/tarballs', `${doc.name}@${doc.version}.tgz`)
|
|
152
171
|
if (kept !== null) {
|
|
153
172
|
for (const path of [kept, npmCachePath(expected)]) {
|
|
154
173
|
const bytes = path === null ? null : await readKeptFile(path, MAX_TAR_BYTES)
|
|
155
|
-
if (bytes && await
|
|
174
|
+
if (bytes && await hashedAs(bytes, expected, shasum)) return bytes
|
|
156
175
|
}
|
|
157
176
|
}
|
|
158
177
|
const bytes = await downloadTarball(url, doc.private)
|
|
159
|
-
if (await
|
|
178
|
+
if (!await hashedAs(bytes, expected, shasum)) throw new NpmPackageError(502, 'upstream-invalid')
|
|
160
179
|
if (kept !== null) await keepFile(kept, bytes)
|
|
161
180
|
return bytes
|
|
162
181
|
}
|
|
@@ -201,10 +220,11 @@ function unpackedOf(doc: NpmVersionDocument): Promise<Unpacked> {
|
|
|
201
220
|
|
|
202
221
|
// A gzipped tarball's files, its top directory left out, as npm extracts
|
|
203
222
|
// a package's: a repository tree's as GitHub archives it, too, its symlinks
|
|
204
|
-
// let climb as readNpmTar's `symlinksAbove` says
|
|
205
|
-
|
|
223
|
+
// let climb as readNpmTar's `symlinksAbove` says, and its tar stream and
|
|
224
|
+
// files bounded as `options` give, a package's bounds unless given.
|
|
225
|
+
export async function readTarballFiles(tarball: Uint8Array, { maxTarBytes = MAX_TAR_BYTES, ...options }: TarReading & { maxTarBytes?: number } = {}): Promise<NpmPackageFile[]> {
|
|
206
226
|
let tar: Uint8Array
|
|
207
|
-
try { tar = await decompress(tarball, 'gzip', { limit:
|
|
227
|
+
try { tar = await decompress(tarball, 'gzip', { limit: maxTarBytes }) }
|
|
208
228
|
catch (err) {
|
|
209
229
|
const limited = err instanceof CompressionError && err.limited
|
|
210
230
|
throw new NpmPackageError(limited ? 413 : 422, limited ? 'package-too-large' : 'bad-tarball')
|
|
@@ -102,7 +102,7 @@ export function npmManifest(json: Record<string, unknown>): Record<string, unkno
|
|
|
102
102
|
// as they read npm's.
|
|
103
103
|
const VERSION_FIELDS = ['name', 'version', 'description', 'license', 'homepage', 'main', 'module', 'types', 'type', 'deprecated', 'gitHead', 'author', '_npmUser',
|
|
104
104
|
'maintainers', 'keywords', 'bin', 'dependencies', 'peerDependencies', 'optionalDependencies', 'engines', 'hasInstallScript', 'repository', 'bugs']
|
|
105
|
-
const DIST_FIELDS = ['tarball', 'integrity', 'unpackedSize', 'fileCount']
|
|
105
|
+
const DIST_FIELDS = ['tarball', 'integrity', 'shasum', 'unpackedSize', 'fileCount']
|
|
106
106
|
const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall']
|
|
107
107
|
const picked = (json: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(key => Object.hasOwn(json, key)).map(key => [key, json[key]]))
|
|
108
108
|
|
|
@@ -31,11 +31,12 @@ const PACKUMENT_BYTES = 64 * 1024 * 1024
|
|
|
31
31
|
// limit until it is read and parsed: four packuments, or 32 version documents.
|
|
32
32
|
const MAX_DOCUMENT_BYTES = 4 * PACKUMENT_BYTES
|
|
33
33
|
// What a package may unpack to: the files' bytes and their count; the tar
|
|
34
|
-
// stream holding them is bounded in npm-loads.ts.
|
|
34
|
+
// stream holding them is bounded in npm-loads.ts. A repository directory's
|
|
35
|
+
// are its own (repository-code.ts).
|
|
35
36
|
export const MAX_NPM_PACKAGE_BYTES = 64 * 1024 * 1024
|
|
36
37
|
export const MAX_NPM_PACKAGE_FILES = 20_000
|
|
37
38
|
// The entries its tar stream may hold, read before any is counted as a file:
|
|
38
|
-
// a directory's too,
|
|
39
|
+
// a directory's too, though npm packs none.
|
|
39
40
|
export const MAX_NPM_TAR_ENTRIES = 2 * MAX_NPM_PACKAGE_FILES
|
|
40
41
|
// What the files may take as the viewer's JSON. Escaping grows a text up to
|
|
41
42
|
// six times (a control character becomes `\u0001`), so it is counted before
|
|
@@ -227,7 +228,9 @@ export interface NpmVersionDocument {
|
|
|
227
228
|
name: string
|
|
228
229
|
version: string
|
|
229
230
|
private: boolean
|
|
230
|
-
|
|
231
|
+
// `shasum`, the tarball's sha1 in hex, where npm tells it: held, with the
|
|
232
|
+
// integrity, to the tarball its files are read from (npm-loads.ts).
|
|
233
|
+
dist: { tarball: string; integrity: string; shasum: string | null; unpackedSize: number | null; fileCount: number | null }
|
|
231
234
|
manifest: Record<string, unknown>
|
|
232
235
|
}
|
|
233
236
|
|
|
@@ -237,10 +240,12 @@ const count = (value: unknown) => Number.isSafeInteger(value) && (value as numbe
|
|
|
237
240
|
function versionDocument(name: string, json: Record<string, unknown>, isPrivate: boolean): NpmVersionDocument {
|
|
238
241
|
const dist = json['dist']
|
|
239
242
|
if (json['name'] !== name || typeof json['version'] !== 'string' || !isNpmPackageSpec(json['version']) || !plainObject(dist)
|
|
240
|
-
|| typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string'
|
|
243
|
+
|| typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string'
|
|
244
|
+
|| (dist['shasum'] !== undefined && typeof dist['shasum'] !== 'string')) throw new NpmPackageError(502, 'upstream-invalid')
|
|
241
245
|
return {
|
|
242
246
|
name, version: json['version'], private: isPrivate,
|
|
243
|
-
dist: { tarball: dist['tarball'], integrity: dist['integrity'],
|
|
247
|
+
dist: { tarball: dist['tarball'], integrity: dist['integrity'], shasum: typeof dist['shasum'] === 'string' ? dist['shasum'] : null,
|
|
248
|
+
unpackedSize: count(dist['unpackedSize']), fileCount: count(dist['fileCount']) },
|
|
244
249
|
manifest: npmManifest(json),
|
|
245
250
|
}
|
|
246
251
|
}
|
|
@@ -261,13 +266,18 @@ export interface NpmVersionStore {
|
|
|
261
266
|
// changes a version's files, but its document can change: a deprecation, the
|
|
262
267
|
// package made private (a scoped one can be), the version removed (which whoever
|
|
263
268
|
// reads it from a store learns elsewhere too: risk does). A day bounds each.
|
|
269
|
+
// One kept with no shasum, as none was before it was kept (npmVersionFields),
|
|
270
|
+
// is asked of npm again, which tells one of each version.
|
|
264
271
|
export const VERSION_KEPT_MS = 24 * 60 * 60_000
|
|
265
272
|
export function readNpmVersion(name: string, spec: string, privileged: boolean, signal: AbortSignal, store?: NpmVersionStore): Promise<NpmVersionDocument | null> {
|
|
266
273
|
if (!isNpmPackageName(name) || !isNpmPackageSpec(spec)) return Promise.reject(new NpmPackageError(400, 'bad-package'))
|
|
267
274
|
// A kept one is read within the same budget as npm's answer.
|
|
268
275
|
return withDocumentBudget(VERSION_DOCUMENT_BYTES, async () => {
|
|
269
276
|
const kept = store && isExactVersion(spec) ? await store.read(name, spec) : null
|
|
270
|
-
|
|
277
|
+
const keptDist = kept?.json['dist']
|
|
278
|
+
if (kept && Date.now() - kept.at < VERSION_KEPT_MS && plainObject(keptDist) && typeof keptDist['shasum'] === 'string') {
|
|
279
|
+
try { return versionDocument(name, kept.json, false) } catch {}
|
|
280
|
+
}
|
|
271
281
|
const found = await readDocument(name, registryUrl(name, spec), privileged, { accept: 'application/json', limit: VERSION_DOCUMENT_BYTES, signal })
|
|
272
282
|
if (!found) return null
|
|
273
283
|
const doc = versionDocument(name, found.json, found.private)
|
|
@@ -367,18 +377,23 @@ export function npmPublishTime(doc: Pick<NpmVersionDocument, 'name' | 'version'
|
|
|
367
377
|
|
|
368
378
|
export interface NpmPackageFile { path: string; bytes: Uint8Array }
|
|
369
379
|
|
|
380
|
+
// How a tar stream is read (readNpmTar): how far its symlinks may climb above
|
|
381
|
+
// it, and the entries, files and bytes it may hold, a package's unless given.
|
|
382
|
+
export interface TarReading { symlinksAbove?: number; maxEntries?: number; maxFiles?: number; maxBytes?: number }
|
|
383
|
+
|
|
370
384
|
// The regular files of a tar stream, by package path: npm drops the first
|
|
371
385
|
// component (`package/`, or whatever the archive used), and a file at the top
|
|
372
386
|
// is not extracted. The stream is read by @preventive/archive, which refuses
|
|
373
387
|
// one malformed, a name or a symlink that would climb out of it -- a symlink
|
|
374
388
|
// past `symlinksAbove` levels above it, none unless given -- and a name
|
|
375
|
-
// repeated as another entry, and gives up past
|
|
376
|
-
//
|
|
377
|
-
//
|
|
389
|
+
// repeated as another entry, and gives up past `maxEntries` before reading
|
|
390
|
+
// on; a file under another top directory at the same path replaces an
|
|
391
|
+
// earlier one, as extraction would. Directories, links and devices hold no
|
|
378
392
|
// file content and are skipped.
|
|
379
|
-
export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0
|
|
393
|
+
export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0, maxEntries = MAX_NPM_TAR_ENTRIES, maxFiles = MAX_NPM_PACKAGE_FILES,
|
|
394
|
+
maxBytes = MAX_NPM_PACKAGE_BYTES }: TarReading = {}): NpmPackageFile[] {
|
|
380
395
|
let entries: Entry[]
|
|
381
|
-
try { entries = unpack(tar, { maxEntries
|
|
396
|
+
try { entries = unpack(tar, { maxEntries, symlinksAbove }) }
|
|
382
397
|
catch (err) {
|
|
383
398
|
if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
|
|
384
399
|
throw err
|
|
@@ -391,7 +406,7 @@ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbo
|
|
|
391
406
|
const path = name.slice(slash + 1)
|
|
392
407
|
total += data.length - (files.get(path)?.length ?? 0)
|
|
393
408
|
files.set(path, data)
|
|
394
|
-
if (files.size >
|
|
409
|
+
if (files.size > maxFiles || total > maxBytes) throw new NpmPackageError(413, 'package-too-large')
|
|
395
410
|
}
|
|
396
411
|
return [...files].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
397
412
|
}
|
|
@@ -421,13 +436,14 @@ const ROW_LENGTH = 80
|
|
|
421
436
|
|
|
422
437
|
// The files as the viewer gets them: `[path, bytes, text]`, text null for a
|
|
423
438
|
// file that is not UTF-8, which instead carries its bytes' sha256, so a
|
|
424
|
-
// comparison of two versions tells a changed one from an unchanged one.
|
|
425
|
-
|
|
439
|
+
// comparison of two versions tells a changed one from an unchanged one. They
|
|
440
|
+
// may take `maxLength` characters of JSON, a package's unless given.
|
|
441
|
+
export function npmFileRows(files: NpmPackageFile[], maxLength = MAX_NPM_JSON_LENGTH): ([string, number, string] | [string, number, null, string])[] {
|
|
426
442
|
let length = 0
|
|
427
443
|
return files.map(({ path, bytes }) => {
|
|
428
444
|
const text = npmFileText(bytes)
|
|
429
445
|
length += ROW_LENGTH + jsonLength(path) + (text === null ? 0 : jsonLength(text))
|
|
430
|
-
if (length >
|
|
446
|
+
if (length > maxLength) throw new NpmPackageError(413, 'package-too-large')
|
|
431
447
|
return text === null ? [path, bytes.byteLength, null, `sha256-${createHash('sha256').update(bytes).digest('base64')}`] : [path, bytes.byteLength, text]
|
|
432
448
|
})
|
|
433
449
|
}
|
|
@@ -14,8 +14,15 @@
|
|
|
14
14
|
// instance, or this one restarted, asks npm's downloads API, which answers
|
|
15
15
|
// some 40 lookups a minute, none of them again that week, each removed a
|
|
16
16
|
// day past it (npm-kept.ts pruneKept); a private one's in memory.
|
|
17
|
-
// - `risk/npm/packages`, `risk/npm/
|
|
18
|
-
//
|
|
17
|
+
// - `risk/npm/packages`, `risk/npm/old-versions`, `risk/npm/public` and
|
|
18
|
+
// `risk/npm/advisories`, which risk asks again after an hour: a public
|
|
19
|
+
// package's in memory and in the database too, read from memory first, so
|
|
20
|
+
// that a cold instance, as a serverless one starts, reads no whole document
|
|
21
|
+
// of npm's again that one before it read within the hour; by a hash of
|
|
22
|
+
// their type and key, as a dependency's spec runs to any length, each
|
|
23
|
+
// removed a day on, and one past 256 KiB as JSON (a package's deprecated
|
|
24
|
+
// versions, its messages and all, run long) in memory alone. A private
|
|
25
|
+
// one's in memory.
|
|
19
26
|
// - `npm/tarballs`: none asked, as tarballs are read as the viewer's loads
|
|
20
27
|
// read them, kept where they keep them (npm-loads.ts loadNpmTarball), and
|
|
21
28
|
// an assessment kept needs none.
|
|
@@ -69,11 +76,20 @@ function remember(key: string, value: unknown) {
|
|
|
69
76
|
while (memory.size > MEMORY_ENTRIES || memoryLength > MEMORY_LENGTH) forget(memory.keys().next().value!)
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
//
|
|
79
|
+
const digest = (type: string, key: string) => createHash('sha256').update(`${type}\0${key}`).digest('hex')
|
|
80
|
+
const hashed = (type: string, key: string) => `risk/${digest(type, key)}`
|
|
81
|
+
// What of a public package is kept in the database by its key, and for how
|
|
82
|
+
// long its downloads are: risk's week, and a day more.
|
|
75
83
|
const IN_DATABASE = new Set(['risk/npm/versions', 'risk/npm/downloads'])
|
|
76
84
|
const DOWNLOADS_KEPT_MS = 8 * 24 * 60 * 60_000
|
|
85
|
+
// What of a public package risk asks again after an hour, kept in memory and
|
|
86
|
+
// in the database (HOURLY_PREFIX), each a day, none past HOURLY_LENGTH as
|
|
87
|
+
// JSON there.
|
|
88
|
+
const HOURLY = new Set(['risk/npm/packages', 'risk/npm/old-versions', 'risk/npm/public', 'risk/npm/advisories'])
|
|
89
|
+
const HOURLY_PREFIX = 'risk/hourly/'
|
|
90
|
+
const HOURLY_KEPT_MS = 24 * 60 * 60_000
|
|
91
|
+
const HOURLY_LENGTH = 256 * 1024
|
|
92
|
+
const databaseKey = (type: string, key: string) => HOURLY.has(type) ? `${HOURLY_PREFIX}${digest(type, key)}` : `${type}/${key}`
|
|
77
93
|
|
|
78
94
|
export interface NpmRiskStores {
|
|
79
95
|
db: UpstreamCacheStore
|
|
@@ -82,10 +98,13 @@ export interface NpmRiskStores {
|
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
// A cache that reads and writes many of a type at once too, as risk's
|
|
85
|
-
// readying of downloads asks (npmReadyDownloads)
|
|
86
|
-
//
|
|
87
|
-
//
|
|
101
|
+
// readying of downloads asks (npmReadyDownloads), and reads records of any
|
|
102
|
+
// types at once, as risk reads a package's record and its versions'
|
|
103
|
+
// (registry.js npmPackageFacts): one read and one write of the database each,
|
|
104
|
+
// not one a key, as a request's queries take turns on its connection
|
|
105
|
+
// (db-neon.ts).
|
|
88
106
|
export interface NpmRiskCache extends CacheStore {
|
|
107
|
+
readEach(entries: [type: string, key: string][]): Promise<unknown[]>
|
|
89
108
|
readMany(type: string, keys: string[]): Promise<Map<string, unknown>>
|
|
90
109
|
writeMany(type: string, entries: [key: string, value: unknown][]): Promise<void>
|
|
91
110
|
}
|
|
@@ -94,13 +113,32 @@ export interface NpmRiskCache extends CacheStore {
|
|
|
94
113
|
// that what is kept of it can be shared.
|
|
95
114
|
export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): NpmRiskCache {
|
|
96
115
|
const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: risk cache ${what} failed:`, error) }
|
|
116
|
+
const memoryKey = (type: string, key: string) => `${isPublic ? '' : 'private\0'}${type}\0${key}`
|
|
117
|
+
// Whether a record of `type` is read of the database: unless it is one
|
|
118
|
+
// kept in memory too (HOURLY) and there.
|
|
119
|
+
const ofDatabase = (type: string, key: string) => isPublic && (IN_DATABASE.has(type) || HOURLY.has(type) && !memory.has(memoryKey(type, key)))
|
|
120
|
+
// A record as the database keeps it, read: an hourly one kept in memory
|
|
121
|
+
// too, for the next to ask.
|
|
122
|
+
const readOf = (type: string, key: string, row: string | null | undefined) => {
|
|
123
|
+
const value: unknown = row == null ? null : JSON.parse(row)
|
|
124
|
+
if (value !== null && HOURLY.has(type)) remember(memoryKey(type, key), value)
|
|
125
|
+
return value
|
|
126
|
+
}
|
|
97
127
|
const cache: NpmRiskCache = {
|
|
128
|
+
async readEach(entries) {
|
|
129
|
+
const asked = new Set(entries.flatMap(([type, key], i) => ofDatabase(type, key) ? [i] : []))
|
|
130
|
+
let rows = new Map<string, string>()
|
|
131
|
+
if (asked.size > 0) {
|
|
132
|
+
try { rows = await db.getUpstreamCacheEntries([...asked].map(i => databaseKey(...entries[i]!))) } catch (error) { warn('read', error) }
|
|
133
|
+
}
|
|
134
|
+
return Promise.all(entries.map(([type, key], i) => {
|
|
135
|
+
if (!asked.has(i)) return cache.read(type, key)
|
|
136
|
+
try { return readOf(type, key, rows.get(databaseKey(type, key))) } catch (error) { warn('read', error); return null }
|
|
137
|
+
}))
|
|
138
|
+
},
|
|
98
139
|
async readMany(type, keys) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const found = await db.getUpstreamCacheEntries(keys.map(key => `${type}/${key}`))
|
|
102
|
-
return new Map(keys.map(key => { const value = found.get(`${type}/${key}`); return [key, value === undefined ? null : JSON.parse(value)] }))
|
|
103
|
-
} catch (error) { warn('read', error); return new Map() }
|
|
140
|
+
const values = await cache.readEach(keys.map(key => [type, key]))
|
|
141
|
+
return new Map(keys.map((key, i) => [key, values[i]]))
|
|
104
142
|
},
|
|
105
143
|
async writeMany(type, entries) {
|
|
106
144
|
if (!isPublic || !IN_DATABASE.has(type)) { for (const [key, value] of entries) await cache.write(type, key, value); return }
|
|
@@ -112,22 +150,31 @@ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: b
|
|
|
112
150
|
if (type === 'npm/tarballs') return null
|
|
113
151
|
try {
|
|
114
152
|
if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
|
|
115
|
-
if (
|
|
153
|
+
if (ofDatabase(type, key)) return readOf(type, key, await db.getUpstreamCacheEntry(databaseKey(type, key)))
|
|
116
154
|
} catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
|
|
117
|
-
return memory.get(
|
|
155
|
+
return memory.get(memoryKey(type, key))?.value ?? null
|
|
118
156
|
},
|
|
119
157
|
async write(type, key, value) {
|
|
120
158
|
if (type === 'npm/tarballs') return
|
|
159
|
+
if (isPublic && HOURLY.has(type)) remember(memoryKey(type, key), value)
|
|
121
160
|
try {
|
|
122
161
|
if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
|
|
123
162
|
if (isPublic && IN_DATABASE.has(type)) {
|
|
124
163
|
const at = Date.now()
|
|
125
|
-
await db.setUpstreamCacheEntry(
|
|
164
|
+
await db.setUpstreamCacheEntry(databaseKey(type, key), JSON.stringify(value), at)
|
|
126
165
|
if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
|
|
127
166
|
return
|
|
128
167
|
}
|
|
168
|
+
if (isPublic && HOURLY.has(type)) {
|
|
169
|
+
const json = JSON.stringify(value)
|
|
170
|
+
if (json.length > HOURLY_LENGTH) return
|
|
171
|
+
const at = Date.now()
|
|
172
|
+
await db.setUpstreamCacheEntry(databaseKey(type, key), json, at)
|
|
173
|
+
await pruneKept(db, HOURLY_PREFIX, HOURLY_KEPT_MS, at, error => { warn('prune', error) })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
129
176
|
} catch (error) { warn('write', error); return }
|
|
130
|
-
remember(
|
|
177
|
+
remember(memoryKey(type, key), value)
|
|
131
178
|
},
|
|
132
179
|
}
|
|
133
180
|
return cache
|
|
@@ -30,7 +30,8 @@ export interface ObjectStorage {
|
|
|
30
30
|
exists(key: string): Promise<boolean>
|
|
31
31
|
put(key: string, bytes: Buffer, options?: { replaceReport?: boolean }): Promise<string | null>
|
|
32
32
|
delete(key: string): Promise<void>
|
|
33
|
-
|
|
33
|
+
// Every object under `prefix`, but those under `keep`, a prefix within it.
|
|
34
|
+
deletePrefix(prefix: string, keep?: string): Promise<void>
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export function isBlobId(value: string): boolean {
|
|
@@ -52,14 +53,14 @@ export async function openObjectVersion(raw: RawObjectStorage, key: string, vers
|
|
|
52
53
|
return null
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number): Promise<number> {
|
|
56
|
+
export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number, keep?: string): Promise<number> {
|
|
56
57
|
const eligible = (modifiedAt: number) => before === undefined || modifiedAt < before
|
|
57
58
|
// Finish listing before deleting: provider cursors may be offset-based.
|
|
58
59
|
const cursors = new Set<string>(), keys = new Set<string>()
|
|
59
60
|
let cursor: string | null = null
|
|
60
61
|
do {
|
|
61
62
|
const page = await raw.list(prefix, cursor, 100, signal)
|
|
62
|
-
for (const object of page.objects) if (eligible(object.modifiedAt)) keys.add(object.key)
|
|
63
|
+
for (const object of page.objects) if (eligible(object.modifiedAt) && (keep === undefined || !object.key.startsWith(keep))) keys.add(object.key)
|
|
63
64
|
cursor = page.cursor
|
|
64
65
|
if (cursor !== null && cursors.has(cursor)) throw new Error('Invalid blob pagination')
|
|
65
66
|
if (cursor !== null) cursors.add(cursor)
|