@preventive/triage 1.0.0-alpha.46 → 1.0.0-alpha.47
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/managed/agent-tasks.ts +5 -3
- package/common/managed/github-agents.ts +28 -10
- package/out/activity.js +4 -0
- package/out/chunk-5AHIQFFG.js +36 -0
- package/out/chunk-6ANSHRFQ.js +6 -0
- package/out/chunk-6WF3KNTL.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/{chunk-7XRR4OFU.js → chunk-FH24VHSJ.js} +1 -1
- package/out/chunk-NOZJULWS.js +52 -0
- package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
- package/out/chunk-S6U2J62U.js +232 -0
- package/out/{chunk-G24A42V6.js → chunk-SPEFPML6.js} +1 -1
- package/out/chunk-UENXT2HK.js +3 -0
- package/out/chunk-VL43BTSO.js +1 -0
- package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
- package/out/chunk-ZCS4UYHI.js +1 -0
- package/out/chunk-ZGNMDGSB.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +22 -22
- 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 +11 -11
- package/out/scan.js +2 -2
- package/out/terminal.js +6 -6
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +2 -1
- package/risk/index.d.ts +4 -2
- package/risk/npm/assess.js +15 -4
- package/risk/npm/registry.js +6 -1
- package/server-managed/activity.ts +24 -11
- package/server-managed/app-agents.ts +10 -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/commit-coauthors.ts +5 -9
- package/server-managed/db-methods.ts +286 -149
- package/server-managed/db-neon.ts +11 -4
- package/server-managed/http.ts +75 -33
- package/server-managed/link-reports.ts +5 -1
- package/server-managed/npm-kept.ts +19 -5
- package/server-managed/npm-loads.ts +27 -15
- package/server-managed/npm-packages.ts +18 -11
- package/server-managed/npm-risk.ts +64 -17
- package/server-managed/pretty-print.ts +45 -12
- package/server-managed/report-store.ts +16 -11
- package/server-managed/repository-agents.ts +1 -1
- package/server-managed/repository-browser.ts +4 -2
- package/server-managed/repository-code.ts +28 -11
- package/server-managed/repository-pulse.ts +1 -1
- package/server-managed/solidity-entry-points.ts +5 -3
- package/server-managed/sql-postgres.ts +3 -0
- package/server-managed/sql.ts +2 -1
- package/server-managed/storage-db.ts +11 -3
- package/server-managed/storage-encryption.ts +7 -4
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-feed.ts +3 -1
- package/server-managed/team-reports.ts +6 -1
- 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
|
@@ -35,11 +35,18 @@ export type PgConnect = () => Promise<PgConnection>
|
|
|
35
35
|
// presence updates, and maintenance leases do not take it. Also fences migrations.
|
|
36
36
|
const LOCK = 'SELECT pg_advisory_xact_lock(1937006964, 1835101793)'
|
|
37
37
|
|
|
38
|
+
// Whether every version up to the latest is recorded, in one round trip at
|
|
39
|
+
// a cold start: a database with no versions table yet (undefined_table) has
|
|
40
|
+
// none. Outside a transaction, so the failed read aborts nothing.
|
|
41
|
+
const SCHEMA_VERSIONS = 31
|
|
38
42
|
async function currentSchema(db: PgConnection): Promise<boolean> {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
try {
|
|
44
|
+
const { rows } = await db.query(`SELECT count(*) AS n FROM managed_schema_version WHERE version BETWEEN 1 AND ${SCHEMA_VERSIONS}`)
|
|
45
|
+
return Number(rows[0]?.['n']) === SCHEMA_VERSIONS
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if ((error as { code?: unknown })?.code === '42P01') return false
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
|
package/server-managed/http.ts
CHANGED
|
@@ -135,7 +135,7 @@ import { mergeLinkGroups } from './link-reports.ts'
|
|
|
135
135
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
136
136
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
137
137
|
import { pipeline } from 'node:stream/promises'
|
|
138
|
-
import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmPublishTimeStore, npmVersionStore, pullDependencyStore,
|
|
138
|
+
import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore,
|
|
139
139
|
repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
140
140
|
import { type NpmDelta, npmDelta } from './npm-delta.ts'
|
|
141
141
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
@@ -151,7 +151,7 @@ import { contentAccess } from './content-access.ts'
|
|
|
151
151
|
import type { BundleStore } from './bundle-store.ts'
|
|
152
152
|
import { Buffer } from 'node:buffer'
|
|
153
153
|
import { createHash, randomUUID } from 'node:crypto'
|
|
154
|
-
import type { AvatarStore } from './avatar-store.ts'
|
|
154
|
+
import type { AvatarStore, CachedAvatar } from './avatar-store.ts'
|
|
155
155
|
import type { BlobStore, OpenedBlob } from './blob-store.ts'
|
|
156
156
|
import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
|
|
157
157
|
import { resolveRepositoryImportLocation } from './repository-aliases.ts'
|
|
@@ -181,6 +181,7 @@ import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines
|
|
|
181
181
|
import { WORKFLOWS_DIR } from './repository-actions.ts'
|
|
182
182
|
import { githubActionsSource } from './github-actions.ts'
|
|
183
183
|
import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
|
|
184
|
+
import { type Lru, lru } from './lru.ts'
|
|
184
185
|
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
185
186
|
import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
|
|
186
187
|
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
@@ -468,14 +469,18 @@ function firstHeader(v: string | string[] | undefined): string | null {
|
|
|
468
469
|
}
|
|
469
470
|
|
|
470
471
|
// `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
|
|
471
|
-
|
|
472
|
-
|
|
472
|
+
// A user's is never kept by the browser, as it changes with their profile.
|
|
473
|
+
async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = 'no-store'): Promise<void> {
|
|
474
|
+
sendAvatar(res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheControl = 'no-store'): void {
|
|
473
478
|
if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
|
|
474
479
|
res.writeHead(200, {
|
|
475
480
|
'content-type': avatar.contentType,
|
|
476
481
|
'content-length': String(avatar.bytes.length),
|
|
477
482
|
'x-content-type-options': 'nosniff',
|
|
478
|
-
'cache-control':
|
|
483
|
+
'cache-control': cacheControl,
|
|
479
484
|
})
|
|
480
485
|
res.end(avatar.bytes)
|
|
481
486
|
}
|
|
@@ -489,15 +494,20 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: st
|
|
|
489
494
|
const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
|
|
490
495
|
.flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
|
|
491
496
|
const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
|
|
497
|
+
// An agent's mark is a public GitHub image that never changes with anyone's
|
|
498
|
+
// profile, drawn on every tile and comment by it: kept by the browser a day,
|
|
499
|
+
// for it alone.
|
|
500
|
+
const AGENT_AVATAR_CACHE = 'private, max-age=86400'
|
|
492
501
|
|
|
493
502
|
async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
|
|
494
503
|
const [, kind, id] = AGENT_AVATAR.exec(avatar)!
|
|
495
504
|
const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
|
|
505
|
+
// Read once where it is kept; read again only past fetching it.
|
|
506
|
+
const kept = await avatarStore.get(key)
|
|
507
|
+
if (kept != null) { sendAvatar(res, kept, AGENT_AVATAR_CACHE); return }
|
|
508
|
+
await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
|
|
509
|
+
.catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
|
|
510
|
+
await serveAvatar(res, avatarStore, key, key, AGENT_AVATAR_CACHE)
|
|
501
511
|
}
|
|
502
512
|
|
|
503
513
|
const MAX_JSON_BODY_BYTES = 4096
|
|
@@ -518,14 +528,17 @@ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Bu
|
|
|
518
528
|
}
|
|
519
529
|
|
|
520
530
|
// A file upload's content, opening a body the browser sealed to this session
|
|
521
|
-
// (common/managed/upload-seal.ts). Link reports are never sent in parts
|
|
531
|
+
// (common/managed/upload-seal.ts). Link reports are never sent in parts; the
|
|
532
|
+
// parts of one sent in them are deleted behind its answer.
|
|
522
533
|
async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
|
|
523
534
|
const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
|
|
524
535
|
const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
|
|
525
536
|
const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
|
|
526
537
|
let bytes
|
|
527
538
|
if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
|
|
528
|
-
else if (deps.uploadStore)
|
|
539
|
+
else if (deps.uploadStore) {
|
|
540
|
+
bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit, cleanup => { deps.track(cleanup); deps.waitUntil?.(cleanup) })
|
|
541
|
+
}
|
|
529
542
|
else throw new Error('bad-upload')
|
|
530
543
|
return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
|
|
531
544
|
}
|
|
@@ -640,7 +653,7 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
|
|
|
640
653
|
const userId = (body as { userId?: unknown } | null)?.userId
|
|
641
654
|
if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
642
655
|
if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
|
|
643
|
-
const target =
|
|
656
|
+
const target = await deps.db.getUser(userId)
|
|
644
657
|
if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
645
658
|
// The insert itself rechecks the admin session and role.
|
|
646
659
|
const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
|
|
@@ -723,7 +736,7 @@ async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: Ma
|
|
|
723
736
|
const role = (body as { role?: unknown } | null)?.role
|
|
724
737
|
if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
725
738
|
if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
|
|
726
|
-
const target =
|
|
739
|
+
const target = await deps.db.getUser(userId)
|
|
727
740
|
// A caller can hold the body open while their admin access is revoked.
|
|
728
741
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
729
742
|
const ok = await deps.db.setUserRole(userId, role)
|
|
@@ -1260,8 +1273,8 @@ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, ses
|
|
|
1260
1273
|
// declared integrity that has a stored bundle wins (bundleId set). When none is
|
|
1261
1274
|
// stored yet, keep the first declared integrity so a later bundle upload of it
|
|
1262
1275
|
// re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
|
|
1263
|
-
async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser,
|
|
1264
|
-
const hashes = reportBundleHashes(
|
|
1276
|
+
async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, json: unknown): Promise<{ bundleId: string | null; integrity: string | null }> {
|
|
1277
|
+
const hashes = reportBundleHashes(json)
|
|
1265
1278
|
for (const h of hashes) {
|
|
1266
1279
|
const b = await deps.db.getBundleByIntegrity(h)
|
|
1267
1280
|
if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
|
|
@@ -1371,7 +1384,8 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1371
1384
|
if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
|
|
1372
1385
|
const id = randomUUID()
|
|
1373
1386
|
const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
|
|
1374
|
-
|
|
1387
|
+
// A JSON report's parse already in hand, not parsed again for its hashes.
|
|
1388
|
+
const { bundleId, integrity } = await resolveReportBundle(deps, s.user, parsed.format === 'json' ? parsed.data : null)
|
|
1375
1389
|
const dataKey = await deps.reportStore.put(id, bytes)
|
|
1376
1390
|
let report: ReportRecord
|
|
1377
1391
|
try {
|
|
@@ -1461,8 +1475,10 @@ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
|
|
|
1461
1475
|
// user's repository access, for the next catalog to send.
|
|
1462
1476
|
async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
|
|
1463
1477
|
let browser: ReturnType<typeof createRepositoryBrowser> | undefined
|
|
1478
|
+
let repos: ReturnType<ManagedDb['listAllRepos']> | undefined
|
|
1464
1479
|
await backfillBundleCommits(deps.db, userId, missing, async repoId => {
|
|
1465
|
-
|
|
1480
|
+
// Read once for the repositories the backfill reaches, not once each.
|
|
1481
|
+
const repo = (await (repos ??= deps.db.listAllRepos())).find(item => item.repoId === repoId)
|
|
1466
1482
|
if (!repo) return null
|
|
1467
1483
|
browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
|
|
1468
1484
|
return browser.reader(repo)
|
|
@@ -1534,9 +1550,11 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
|
|
|
1534
1550
|
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
|
|
1535
1551
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1536
1552
|
if (!s) return null
|
|
1537
|
-
|
|
1553
|
+
// bundleRepos's reads, the reader's scopes read once for both.
|
|
1554
|
+
const access = await contentAccess(deps.db, s.user)
|
|
1555
|
+
const repo = (await deps.db.listSelectedRepos()).filter(access.bundle).find(item => item.repoId === repoId)
|
|
1538
1556
|
if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
|
|
1539
|
-
const scopes =
|
|
1557
|
+
const scopes = access.scopes == null ? [null] : access.scopes.filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1540
1558
|
return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
|
|
1541
1559
|
}
|
|
1542
1560
|
|
|
@@ -1602,6 +1620,22 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1602
1620
|
// read, before any of it is sent. The load stays held until this response is
|
|
1603
1621
|
// written or abandoned, and a reader who stops reading it gives it up after
|
|
1604
1622
|
// a while.
|
|
1623
|
+
// What GitHub answered of a repository's trees for the code viewer, which a
|
|
1624
|
+
// commit or tree it names never changes: the tree at a directory of a commit
|
|
1625
|
+
// (getRepoTreeId reads the commit, then a listing a level down to it), and
|
|
1626
|
+
// how many files a tree holds under it and their bytes (repositoryTreeFiles),
|
|
1627
|
+
// by the repository's id, for the next to open it. Asked of them only once
|
|
1628
|
+
// the reader's own access has named the commit, as GitHub would have
|
|
1629
|
+
// answered them; by the database, as each instance has one.
|
|
1630
|
+
const REPOSITORY_TREES_KEPT = 4096
|
|
1631
|
+
type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
|
|
1632
|
+
const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles> }>()
|
|
1633
|
+
function repositoryTreesOf(db: ManagedDb) {
|
|
1634
|
+
let kept = repositoryTrees.get(db)
|
|
1635
|
+
if (!kept) repositoryTrees.set(db, kept = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT) })
|
|
1636
|
+
return kept
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1605
1639
|
async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1606
1640
|
const asked = repositoryQuery(res, query)
|
|
1607
1641
|
if (!asked) return
|
|
@@ -1618,10 +1652,12 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1618
1652
|
const commit = await reader.commit(ref)
|
|
1619
1653
|
const repo = access.repo.fullName
|
|
1620
1654
|
const client = createClient({ token: reader.readToken() })
|
|
1655
|
+
const treeKey = `${access.repo.repoId}\0${commit}\0${path}`, trees = repositoryTreesOf(deps.db)
|
|
1621
1656
|
// A directory the commit has none at is none to read, as browsing it says.
|
|
1622
|
-
const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1657
|
+
const tree = trees.ids.get(treeKey) ?? await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1623
1658
|
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1624
1659
|
}))
|
|
1660
|
+
trees.ids.set(treeKey, tree)
|
|
1625
1661
|
// Access still held, and the same repository, before anything read is sent.
|
|
1626
1662
|
const fenced = async () => {
|
|
1627
1663
|
await reader.recheckAccess()
|
|
@@ -1631,12 +1667,15 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1631
1667
|
return true
|
|
1632
1668
|
}
|
|
1633
1669
|
// Too large names the commit it was read at, for History to read.
|
|
1634
|
-
|
|
1670
|
+
const filesKey = `${access.repo.repoId}\0${tree}`
|
|
1671
|
+
let listed = trees.files.get(filesKey)
|
|
1672
|
+
if (listed === undefined) trees.files.set(filesKey, listed = await repositoryTreeFiles(repo, reader.readToken(), tree))
|
|
1673
|
+
if (!repositoryCodeFits(listed)) {
|
|
1635
1674
|
if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
|
|
1636
1675
|
return
|
|
1637
1676
|
}
|
|
1638
1677
|
if (res.destroyed) return
|
|
1639
|
-
const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
|
|
1678
|
+
const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree, listed }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
|
|
1640
1679
|
res.once('close', load.release)
|
|
1641
1680
|
const bytes = await load.result
|
|
1642
1681
|
if (!(await fenced()) || res.destroyed) return
|
|
@@ -2763,9 +2802,8 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2763
2802
|
throw new BundleBuildError(409, 'repository-changed')
|
|
2764
2803
|
}
|
|
2765
2804
|
signal.throwIfAborted()
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
bundleIntegrity(bytes), input.conditions)
|
|
2805
|
+
await storeUploadedBundle(req, res, deps, cookie, current, built.bytes, input.repoId, built.directory, built.filename, 'build',
|
|
2806
|
+
bundleIntegrity(built.bytes), input.conditions)
|
|
2769
2807
|
})
|
|
2770
2808
|
} catch (error) {
|
|
2771
2809
|
if (res.destroyed || controller.signal.aborted) return
|
|
@@ -2846,15 +2884,17 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2846
2884
|
repo.repoId = null
|
|
2847
2885
|
directory = ''
|
|
2848
2886
|
}
|
|
2849
|
-
await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity)
|
|
2887
|
+
await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity, null, existing === null)
|
|
2850
2888
|
}
|
|
2851
2889
|
|
|
2890
|
+
// `absent` where the caller has just found no bundle of `integrity`: not read
|
|
2891
|
+
// again, one inserted since meeting the insert's UNIQUE below as a race does.
|
|
2852
2892
|
async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
|
|
2853
2893
|
s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
|
|
2854
|
-
provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null): Promise<void> {
|
|
2894
|
+
provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null, absent = false): Promise<void> {
|
|
2855
2895
|
const kind = bundleKind(filename)
|
|
2856
2896
|
const reuse = { provenance, filename, kind, buildConditions }
|
|
2857
|
-
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
2897
|
+
const existing = absent ? null : await deps.db.getBundleByIntegrity(integrity)
|
|
2858
2898
|
if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
|
|
2859
2899
|
const id = randomUUID()
|
|
2860
2900
|
const dataKey = await deps.bundleStore.put(id, bytes, kind)
|
|
@@ -2984,12 +3024,11 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
|
|
|
2984
3024
|
// need a published report in their teams; ownership never overrides role none.
|
|
2985
3025
|
async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
2986
3026
|
if (!roleAtLeast(user.role, 'view')) return false
|
|
2987
|
-
const report = await deps.db.
|
|
3027
|
+
const report = await deps.db.getReportView(user.id, reportId)
|
|
2988
3028
|
if (report == null) return false
|
|
2989
3029
|
if (user.role === 'admin') return true
|
|
2990
|
-
if (user.role === 'manage') return report.uploadedBy === user.id ||
|
|
2991
|
-
|
|
2992
|
-
return deps.db.userCanReadReport(user.id, reportId)
|
|
3030
|
+
if (user.role === 'manage') return report.uploadedBy === user.id || report.readable
|
|
3031
|
+
return report.visible && report.readable
|
|
2993
3032
|
}
|
|
2994
3033
|
|
|
2995
3034
|
// GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
|
|
@@ -4230,6 +4269,9 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
|
|
|
4230
4269
|
export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
4231
4270
|
const { config, db, avatarStore, isShuttingDown, track } = deps
|
|
4232
4271
|
const repositoryDiscovery = new RepositoryDiscovery(config)
|
|
4272
|
+
// Kept reads' prunes run behind their writes: tracked, so the database
|
|
4273
|
+
// outlives them, and on serverless kept alive past the answer.
|
|
4274
|
+
ownKeptPrunes(db, prune => { track(prune); deps.waitUntil?.(prune) })
|
|
4233
4275
|
|
|
4234
4276
|
async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
4235
4277
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
@@ -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) {
|
|
@@ -38,15 +38,25 @@ export interface KeptStore<T> {
|
|
|
38
38
|
// When each database last had the rows under each prefix removed.
|
|
39
39
|
const PRUNE_EVERY_MS = 60 * 60_000
|
|
40
40
|
const pruned = new WeakMap<UpstreamCacheStore, Map<string, number>>()
|
|
41
|
+
// Who sees each database's prunes through (index.ts: its app, which waits
|
|
42
|
+
// for them before closing it, and on serverless keeps its invocation alive
|
|
43
|
+
// till they settle), as a write no longer waits for one.
|
|
44
|
+
const pruneOwners = new WeakMap<UpstreamCacheStore, (prune: Promise<unknown>) => void>()
|
|
45
|
+
export function ownKeptPrunes(db: UpstreamCacheStore, own: (prune: Promise<unknown>) => void) {
|
|
46
|
+
pruneOwners.set(db, own)
|
|
47
|
+
}
|
|
41
48
|
|
|
42
49
|
// Removes the rows under `prefix` kept `keptMs` before `at`, at most once an
|
|
43
50
|
// hour a database, as rows under it are written: here, and risk's downloads
|
|
44
|
-
// (npm-risk.ts)
|
|
45
|
-
|
|
51
|
+
// (npm-risk.ts), each one run handed to the database's owner. A failure is
|
|
52
|
+
// `warn`ed of, and removes nothing.
|
|
53
|
+
export function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void): Promise<void> {
|
|
46
54
|
const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
|
|
47
|
-
if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return
|
|
55
|
+
if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return Promise.resolve()
|
|
48
56
|
times.set(prefix, at)
|
|
49
|
-
try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) }
|
|
57
|
+
const prune = (async () => { try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) } })()
|
|
58
|
+
pruneOwners.get(db)?.(prune)
|
|
59
|
+
return prune
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
// Values of `type`, each read for `keptMs` after it was kept.
|
|
@@ -79,7 +89,11 @@ function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debu
|
|
|
79
89
|
},
|
|
80
90
|
async write(key, value, at) {
|
|
81
91
|
try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
|
|
82
|
-
|
|
92
|
+
// Behind the write, not before it is done: the prune reads the whole
|
|
93
|
+
// table (no index serves its prefix), which no writer need wait on.
|
|
94
|
+
// Its hour is marked before it runs, so writes meanwhile start none,
|
|
95
|
+
// and its database's owner sees it through (ownKeptPrunes).
|
|
96
|
+
void prune(at)
|
|
83
97
|
},
|
|
84
98
|
async remove(key) {
|
|
85
99
|
try { await db.deleteUpstreamCacheEntries(keyOf(key), Number.MAX_SAFE_INTEGER) } catch (error) { warn('remove', error) }
|
|
@@ -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))
|
|
@@ -57,22 +58,32 @@ function shared<T>(what: string, doc: NpmVersionDocument, work: () => Promise<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,9 +110,9 @@ 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.
|
|
@@ -201,10 +212,11 @@ function unpackedOf(doc: NpmVersionDocument): Promise<Unpacked> {
|
|
|
201
212
|
|
|
202
213
|
// A gzipped tarball's files, its top directory left out, as npm extracts
|
|
203
214
|
// a package's: a repository tree's as GitHub archives it, too, its symlinks
|
|
204
|
-
// let climb as readNpmTar's `symlinksAbove` says
|
|
205
|
-
|
|
215
|
+
// let climb as readNpmTar's `symlinksAbove` says, and its tar stream and
|
|
216
|
+
// files bounded as `options` give, a package's bounds unless given.
|
|
217
|
+
export async function readTarballFiles(tarball: Uint8Array, { maxTarBytes = MAX_TAR_BYTES, ...options }: TarReading & { maxTarBytes?: number } = {}): Promise<NpmPackageFile[]> {
|
|
206
218
|
let tar: Uint8Array
|
|
207
|
-
try { tar = await decompress(tarball, 'gzip', { limit:
|
|
219
|
+
try { tar = await decompress(tarball, 'gzip', { limit: maxTarBytes }) }
|
|
208
220
|
catch (err) {
|
|
209
221
|
const limited = err instanceof CompressionError && err.limited
|
|
210
222
|
throw new NpmPackageError(limited ? 413 : 422, limited ? 'package-too-large' : 'bad-tarball')
|
|
@@ -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
|
|
@@ -367,18 +368,23 @@ export function npmPublishTime(doc: Pick<NpmVersionDocument, 'name' | 'version'
|
|
|
367
368
|
|
|
368
369
|
export interface NpmPackageFile { path: string; bytes: Uint8Array }
|
|
369
370
|
|
|
371
|
+
// How a tar stream is read (readNpmTar): how far its symlinks may climb above
|
|
372
|
+
// it, and the entries, files and bytes it may hold, a package's unless given.
|
|
373
|
+
export interface TarReading { symlinksAbove?: number; maxEntries?: number; maxFiles?: number; maxBytes?: number }
|
|
374
|
+
|
|
370
375
|
// The regular files of a tar stream, by package path: npm drops the first
|
|
371
376
|
// component (`package/`, or whatever the archive used), and a file at the top
|
|
372
377
|
// is not extracted. The stream is read by @preventive/archive, which refuses
|
|
373
378
|
// one malformed, a name or a symlink that would climb out of it -- a symlink
|
|
374
379
|
// past `symlinksAbove` levels above it, none unless given -- and a name
|
|
375
|
-
// repeated as another entry, and gives up past
|
|
376
|
-
//
|
|
377
|
-
//
|
|
380
|
+
// repeated as another entry, and gives up past `maxEntries` before reading
|
|
381
|
+
// on; a file under another top directory at the same path replaces an
|
|
382
|
+
// earlier one, as extraction would. Directories, links and devices hold no
|
|
378
383
|
// file content and are skipped.
|
|
379
|
-
export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0
|
|
384
|
+
export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0, maxEntries = MAX_NPM_TAR_ENTRIES, maxFiles = MAX_NPM_PACKAGE_FILES,
|
|
385
|
+
maxBytes = MAX_NPM_PACKAGE_BYTES }: TarReading = {}): NpmPackageFile[] {
|
|
380
386
|
let entries: Entry[]
|
|
381
|
-
try { entries = unpack(tar, { maxEntries
|
|
387
|
+
try { entries = unpack(tar, { maxEntries, symlinksAbove }) }
|
|
382
388
|
catch (err) {
|
|
383
389
|
if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
|
|
384
390
|
throw err
|
|
@@ -391,7 +397,7 @@ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbo
|
|
|
391
397
|
const path = name.slice(slash + 1)
|
|
392
398
|
total += data.length - (files.get(path)?.length ?? 0)
|
|
393
399
|
files.set(path, data)
|
|
394
|
-
if (files.size >
|
|
400
|
+
if (files.size > maxFiles || total > maxBytes) throw new NpmPackageError(413, 'package-too-large')
|
|
395
401
|
}
|
|
396
402
|
return [...files].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
397
403
|
}
|
|
@@ -421,13 +427,14 @@ const ROW_LENGTH = 80
|
|
|
421
427
|
|
|
422
428
|
// The files as the viewer gets them: `[path, bytes, text]`, text null for a
|
|
423
429
|
// 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
|
-
|
|
430
|
+
// comparison of two versions tells a changed one from an unchanged one. They
|
|
431
|
+
// may take `maxLength` characters of JSON, a package's unless given.
|
|
432
|
+
export function npmFileRows(files: NpmPackageFile[], maxLength = MAX_NPM_JSON_LENGTH): ([string, number, string] | [string, number, null, string])[] {
|
|
426
433
|
let length = 0
|
|
427
434
|
return files.map(({ path, bytes }) => {
|
|
428
435
|
const text = npmFileText(bytes)
|
|
429
436
|
length += ROW_LENGTH + jsonLength(path) + (text === null ? 0 : jsonLength(text))
|
|
430
|
-
if (length >
|
|
437
|
+
if (length > maxLength) throw new NpmPackageError(413, 'package-too-large')
|
|
431
438
|
return text === null ? [path, bytes.byteLength, null, `sha256-${createHash('sha256').update(bytes).digest('base64')}`] : [path, bytes.byteLength, text]
|
|
432
439
|
})
|
|
433
440
|
}
|