@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.40
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/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +2 -0
- package/client/dependency-paths.js +24 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +112 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -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 +14 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +310 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- 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/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- 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/comments.ts +34 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +254 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- 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/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -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-4F6JSF57.js +3 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-AQ75QITE.js +1 -0
- package/out/chunk-B2RQY6OP.js +2 -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-GJ4IUCNS.js +1 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-IISEF2BZ.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -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-MYDDTA7W.js +225 -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-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-RSHMVYNS.js +1 -0
- package/out/chunk-TGANXVTA.js +1 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UN5ZSYLE.js +1 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UXNUAIIC.js +33 -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-VQJCXGIY.js +7 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-WZDTZE64.js +1 -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-ZBBOBQ43.js +1 -0
- package/out/chunk-ZMQQCGF6.js +2 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +37 -0
- package/out/client-sync.js +10 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +6 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +365 -39
- package/out/view.css +1 -1
- package/out/view.js +83 -117
- package/package.json +297 -59
- 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-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +116 -18
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +27 -26
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +245 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +111 -0
- package/server-managed/bundle-build.ts +198 -0
- package/server-managed/bundle-cache.ts +199 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +120 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +158 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1725 -0
- package/server-managed/db-neon.ts +367 -0
- package/server-managed/db-schema.ts +227 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +132 -0
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +391 -0
- package/server-managed/github-errors.ts +68 -0
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +235 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +50 -0
- package/server-managed/http.ts +3472 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +187 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +75 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -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/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +74 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +220 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +153 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-risk.ts +418 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +92 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +76 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +279 -0
- package/server-managed/storage-maintenance.ts +256 -0
- package/server-managed/storage-payload.ts +103 -0
- package/server-managed/storage-stores.ts +85 -0
- package/server-managed/storage.ts +78 -0
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/team-reports.ts +228 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +86 -0
- package/server-managed/upstream-cache.ts +215 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +166 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Compare's diff of a JavaScript file minified on both sides with the names a
|
|
2
|
+
// minifier renamed alike hidden (GET /api/bundles/:id/rename-diff, GET
|
|
3
|
+
// /api/npm/rename-diff): both sides pretty-printed, as the pretty-print toggle
|
|
4
|
+
// has them (pretty-print.ts), then diffed by @preventive/compare's
|
|
5
|
+
// renamedDiffs in a worker, off the event loop and two at a time per process,
|
|
6
|
+
// with whitespace changes shown and hidden both. Answered as Brotli-encoded
|
|
7
|
+
// JSON, `{ before, blocks, reason, reads, marks, ignoringWhitespace }`
|
|
8
|
+
// (rename-diff-worker.js), the two files' hashes, `hash` and `otherHash`, and
|
|
9
|
+
// those of the two copies diffed, `prettyHash` and `otherPrettyHash`, which
|
|
10
|
+
// the viewer checks are those it asked about and draws: one file's asked for
|
|
11
|
+
// by both its sides' paths and hashes, or every pair a comparison's viewer
|
|
12
|
+
// diffs so, a line each (renamedPairs). The last few answers are kept in
|
|
13
|
+
// memory by both files' hashes, a private npm version's never, each side read
|
|
14
|
+
// again through the pretty-print cache first, which checks that its source
|
|
15
|
+
// holds a file of that hash. Access is the caller's to check.
|
|
16
|
+
import { Buffer } from 'node:buffer'
|
|
17
|
+
import { Readable } from 'node:stream'
|
|
18
|
+
import { promisify } from 'node:util'
|
|
19
|
+
import { Worker } from 'node:worker_threads'
|
|
20
|
+
import { brotliDecompress } from 'node:zlib'
|
|
21
|
+
import { layoutPackageDirs, treeLayout } from '@preventive/compare'
|
|
22
|
+
import { computeFileHash } from '@preventive/report'
|
|
23
|
+
import { FILE_HASH } from '../common/bundle-metadata.js'
|
|
24
|
+
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
25
|
+
import { bundlePkgOf } from '../common/bundle-pkg-of.js'
|
|
26
|
+
import { needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
|
|
27
|
+
import { type ChangeBlock, blocksHold, textLines } from '../common/line-diff.js'
|
|
28
|
+
import type { OpenedBlob } from './blob-store.ts'
|
|
29
|
+
import { encodeBrotli } from './brotli.ts'
|
|
30
|
+
import { type ComparedSide, PrettyError, type PrettyRequest, prettyText } from './pretty-print.ts'
|
|
31
|
+
|
|
32
|
+
// What a minifier renames: JavaScript, as the viewer offers it for.
|
|
33
|
+
const RENAMABLE = new Set(['js', 'mjs', 'cjs'])
|
|
34
|
+
// Diffs worked out at once per process, each in a worker of its own, and
|
|
35
|
+
// those waiting their turn past them (a Diff view asks for all it shows at
|
|
36
|
+
// once), any more refused.
|
|
37
|
+
const MAX_ACTIVE_DIFFS = 2
|
|
38
|
+
const MAX_WAITING_DIFFS = 16
|
|
39
|
+
// The longest a diff may take, its wait for a worker included, and the most
|
|
40
|
+
// text it is asked of: both sides pretty-printed, which the largest files
|
|
41
|
+
// formatted (MAX_PRETTY_BYTES) stay under.
|
|
42
|
+
const DIFF_TIMEOUT_MS = 60_000
|
|
43
|
+
const MAX_DIFF_CHARS = 64 * 1024 * 1024
|
|
44
|
+
// Answers kept, the one read last last: each holds the old side as diffed.
|
|
45
|
+
// As many as a comparison's viewer may ask for beside the pairs it has come
|
|
46
|
+
// (renamedPairs), so that neither is worked out twice.
|
|
47
|
+
const KEPT_DIFFS = 32
|
|
48
|
+
|
|
49
|
+
// `blocks` and `marks` with whitespace changes shown, `ignoringWhitespace`'s
|
|
50
|
+
// hidden as diff -b hides them.
|
|
51
|
+
export interface RenameDiff {
|
|
52
|
+
before: string; blocks: ChangeBlock[]; reason: string | null; reads: string[]; marks: ChangeBlock[]
|
|
53
|
+
ignoringWhitespace: { blocks: ChangeBlock[]; marks: ChangeBlock[] }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RenameDiffRequest { base: PrettyRequest; other: PrettyRequest }
|
|
57
|
+
|
|
58
|
+
// The two files a request names, a base's `path` and `hash` and the other
|
|
59
|
+
// side's `otherPath` and `otherHash`, or null where either is no JavaScript
|
|
60
|
+
// file named by its hash.
|
|
61
|
+
export function renameDiffRequest(params: URLSearchParams): RenameDiffRequest | null {
|
|
62
|
+
const side = (path: string, hash: string): PrettyRequest | null => {
|
|
63
|
+
const extension = prettyExtension(path)
|
|
64
|
+
return extension !== null && RENAMABLE.has(extension) && FILE_HASH.test(hash) ? { path, hash, extension } : null
|
|
65
|
+
}
|
|
66
|
+
const base = side(params.get('path') ?? '', params.get('hash') ?? ''), other = side(params.get('otherPath') ?? '', params.get('otherHash') ?? '')
|
|
67
|
+
return base && other ? { base, other } : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let activeDiffs = 0
|
|
71
|
+
const waiting: Array<() => void> = []
|
|
72
|
+
const kept = new Map<string, Buffer>()
|
|
73
|
+
const pending = new Map<string, Promise<Buffer>>()
|
|
74
|
+
|
|
75
|
+
// A worker's turn, waited for behind those running until `deadline`; given
|
|
76
|
+
// up, it goes to the next one waiting.
|
|
77
|
+
async function turn(deadline: number): Promise<void> {
|
|
78
|
+
if (activeDiffs < MAX_ACTIVE_DIFFS) { activeDiffs++; return }
|
|
79
|
+
if (waiting.length >= MAX_WAITING_DIFFS) throw new PrettyError(429, 'diff-busy')
|
|
80
|
+
await new Promise<void>((resolve, reject) => {
|
|
81
|
+
const next = () => { clearTimeout(timer); resolve() }
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
waiting.splice(waiting.indexOf(next), 1)
|
|
84
|
+
reject(new PrettyError(504, 'diff-timeout'))
|
|
85
|
+
}, deadline - Date.now())
|
|
86
|
+
waiting.push(next)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
function done() {
|
|
90
|
+
const next = waiting.shift()
|
|
91
|
+
if (next) next()
|
|
92
|
+
else activeDiffs--
|
|
93
|
+
}
|
|
94
|
+
async function inTurn<T>(deadline: number, work: () => Promise<T>): Promise<T> {
|
|
95
|
+
await turn(deadline)
|
|
96
|
+
try { return await work() } finally { done() }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Whether a worker's answer is one as rename-diff-worker.js gives it, its
|
|
100
|
+
// blocks taking the old side it answered to the new one asked of, whitespace
|
|
101
|
+
// changes shown and hidden: checked again here, apart from the worker, as an
|
|
102
|
+
// answer that is not would hide a change.
|
|
103
|
+
function answers(diff: unknown, original: string, after: string): diff is RenameDiff {
|
|
104
|
+
const { before, blocks, reason, reads, marks, ignoringWhitespace } = (diff ?? {}) as Partial<RenameDiff>
|
|
105
|
+
return typeof before === 'string' && (reason === null || typeof reason === 'string') && Array.isArray(reads) && reads.every(read => typeof read === 'string')
|
|
106
|
+
&& blocksHold(before, after, blocks, false) && marksFit(original, after, marks)
|
|
107
|
+
&& blocksHold(before, after, ignoringWhitespace?.blocks, true) && marksFit(original, after, ignoringWhitespace?.marks)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Whether marked blocks (rename-diff-worker.js) are blocks of the diff of the
|
|
111
|
+
// two files as they are: in order, apart, within both, each as many lines on
|
|
112
|
+
// either side.
|
|
113
|
+
function marksFit(before: string, after: string, marks: unknown): boolean {
|
|
114
|
+
if (!Array.isArray(marks)) return false
|
|
115
|
+
const [olds, news] = [textLines(before).length, textLines(after).length]
|
|
116
|
+
let i = 0, j = 0
|
|
117
|
+
for (const mark of marks as Partial<ChangeBlock>[]) {
|
|
118
|
+
const { a0, a1, b0, b1 } = mark ?? {}
|
|
119
|
+
if (![a0, a1, b0, b1].every(Number.isInteger)) return false
|
|
120
|
+
const [x0, x1, y0, y1] = [a0, a1, b0, b1] as [number, number, number, number]
|
|
121
|
+
if (x0 < i || y0 < j || x1 <= x0 || x1 - x0 !== y1 - y0 || x1 > olds || y1 > news) return false
|
|
122
|
+
i = x1
|
|
123
|
+
j = y1
|
|
124
|
+
}
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Settled once the worker is gone, so that its turn is given up only then.
|
|
129
|
+
function inWorker(before: string, after: string, { base, other }: RenameDiffRequest, deadline: number): Promise<RenameDiff> {
|
|
130
|
+
if (before.length + after.length > MAX_DIFF_CHARS) throw new PrettyError(413, 'diff-too-large')
|
|
131
|
+
let worker: Worker
|
|
132
|
+
try {
|
|
133
|
+
worker = new Worker(new URL('./rename-diff-worker.js', import.meta.url), {
|
|
134
|
+
workerData: { before, after, extension: base.extension, otherExtension: other.extension }, execArgv: [],
|
|
135
|
+
resourceLimits: { maxOldGenerationSizeMb: 1024 },
|
|
136
|
+
})
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.warn('managed: a rename diff could not start:', err)
|
|
139
|
+
throw new PrettyError(503, 'unavailable')
|
|
140
|
+
}
|
|
141
|
+
return new Promise<RenameDiff>((resolve, reject) => {
|
|
142
|
+
let diff: RenameDiff | null = null, failure = new PrettyError(422, 'undiffable')
|
|
143
|
+
const timer = setTimeout(() => { failure = new PrettyError(504, 'diff-timeout'); void worker.terminate() }, deadline - Date.now())
|
|
144
|
+
worker.once('message', (answer: unknown) => {
|
|
145
|
+
if (answers(answer, before, after)) diff = answer
|
|
146
|
+
else console.warn('managed: a rename diff answered what does not take its old side to the new')
|
|
147
|
+
void worker.terminate()
|
|
148
|
+
})
|
|
149
|
+
worker.once('error', err => { console.warn('managed: a rename diff failed:', err) })
|
|
150
|
+
// Gone: answered, run out of memory, or terminated.
|
|
151
|
+
worker.once('exit', () => {
|
|
152
|
+
clearTimeout(timer)
|
|
153
|
+
if (diff) resolve(diff)
|
|
154
|
+
else reject(failure)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// `work` given until `deadline`: past it, answered 504 and left to finish on its own.
|
|
160
|
+
function byDeadline<T>(work: Promise<T>, deadline: number): Promise<T> {
|
|
161
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
162
|
+
const late = new Promise<never>((_, reject) => { timer = setTimeout(() => { reject(new PrettyError(504, 'diff-timeout')) }, deadline - Date.now()) })
|
|
163
|
+
return Promise.race([work, late]).finally(() => { clearTimeout(timer) })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// An answer as sent, naming the files asked about and the pretty-printed
|
|
167
|
+
// copies of them it was worked out from.
|
|
168
|
+
async function encoded(diff: RenameDiff, { base, other }: RenameDiffRequest, [before, after]: [string, string]): Promise<Buffer> {
|
|
169
|
+
const [prettyHash, otherPrettyHash] = await Promise.all([computeFileHash(before), computeFileHash(after)])
|
|
170
|
+
return encodeBrotli(Buffer.from(JSON.stringify({ ...diff, hash: base.hash, otherHash: other.hash, prettyHash, otherPrettyHash })))
|
|
171
|
+
}
|
|
172
|
+
const opened = (body: Buffer): OpenedBlob => ({ size: body.byteLength, stream: Readable.from([body]) })
|
|
173
|
+
|
|
174
|
+
// The answer for the two files a request names, Brotli-encoded, `texts`
|
|
175
|
+
// reading both sides pretty-printed (and so checking each is its source's):
|
|
176
|
+
// kept by their hashes where `keep`, readers asking for one meanwhile sharing
|
|
177
|
+
// it. A minute for it, its wait for a worker included.
|
|
178
|
+
export async function renameDiffOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep = true): Promise<OpenedBlob> {
|
|
179
|
+
return opened(await answerOf(request, texts, keep))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep: boolean): Promise<Buffer> {
|
|
183
|
+
// Sides not kept are formatted again for each diff (pretty-print.ts), so
|
|
184
|
+
// they are read in its turn: the files a Diff view asks for at once wait
|
|
185
|
+
// here rather than being refused by the formats running. Within its minute
|
|
186
|
+
// too, its turn not held past it by a version's files still downloading.
|
|
187
|
+
if (!keep) {
|
|
188
|
+
const deadline = Date.now() + DIFF_TIMEOUT_MS
|
|
189
|
+
let sides: [string, string] = ['', '']
|
|
190
|
+
const diff = await inTurn(deadline, async () => {
|
|
191
|
+
sides = await byDeadline(texts(), deadline)
|
|
192
|
+
return inWorker(sides[0], sides[1], request, deadline)
|
|
193
|
+
})
|
|
194
|
+
return encoded(diff, request, sides)
|
|
195
|
+
}
|
|
196
|
+
const [before, after] = await texts()
|
|
197
|
+
// A file's type is part of how it is read: the same text as a script and as a module differ.
|
|
198
|
+
const key = `${request.base.hash}\0${request.base.extension}\0${request.other.hash}\0${request.other.extension}`
|
|
199
|
+
const found = kept.get(key)
|
|
200
|
+
if (found) {
|
|
201
|
+
kept.delete(key)
|
|
202
|
+
kept.set(key, found)
|
|
203
|
+
return found
|
|
204
|
+
}
|
|
205
|
+
let job = pending.get(key)
|
|
206
|
+
if (!job) {
|
|
207
|
+
const deadline = Date.now() + DIFF_TIMEOUT_MS
|
|
208
|
+
pending.set(key, job = inTurn(deadline, () => inWorker(before, after, request, deadline)).then(diff => encoded(diff, request, [before, after])).finally(() => pending.delete(key)))
|
|
209
|
+
}
|
|
210
|
+
const body = await job
|
|
211
|
+
kept.set(key, body)
|
|
212
|
+
if (kept.size > KEPT_DIFFS) kept.delete(kept.keys().next().value!)
|
|
213
|
+
return body
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const decompress = promisify(brotliDecompress)
|
|
217
|
+
|
|
218
|
+
// What a minifier renames, by its path's extension.
|
|
219
|
+
const renamable = ({ extension }: PrettyRequest) => RENAMABLE.has(extension)
|
|
220
|
+
|
|
221
|
+
// One of a comparison's pairs of files (renamedPairs), its sides named as a
|
|
222
|
+
// request names them, with its answer as renameDiffOf sends it, or why it has
|
|
223
|
+
// none, as a PrettyError tells it.
|
|
224
|
+
export type RenamedLine = { path: string; hash: string; otherPath: string; otherHash: string } & (Partial<RenameDiff> | { error: string; status: number })
|
|
225
|
+
|
|
226
|
+
// The files that changed between a comparison's two sides, as its viewer
|
|
227
|
+
// pairs them (ui/view/bundle-comparison.js prepareComparison): at one path as
|
|
228
|
+
// the other side lays them out, or renamed within a package, as the bundles
|
|
229
|
+
// record their packages; every file there, those that can be formatted by
|
|
230
|
+
// their texts and the rest by their hashes. A file the viewer pairs otherwise
|
|
231
|
+
// (its files are its resources too) it asks about alone.
|
|
232
|
+
export function comparedFiles(sides: readonly [ComparedSide, ComparedSide]) {
|
|
233
|
+
const [olds, news] = sides.map(({ files, hashes }) => {
|
|
234
|
+
const texts = new Map(files.map(file => [file.path, file.text]))
|
|
235
|
+
return new Map<string, unknown>([...hashes].map(([path, hash]) => [path, texts.get(path) ?? { format: 'digest', digest: hash }]))
|
|
236
|
+
}) as [Map<string, unknown>, Map<string, unknown>]
|
|
237
|
+
const layout = treeLayout(olds.keys(), news.keys())
|
|
238
|
+
const packageDirs = layoutPackageDirs(sides[0].packageDirs, sides[1].packageDirs, layout)
|
|
239
|
+
return computeBundleDiff(olds, news, path => bundlePkgOf(path, { packageDir: packageDirs?.get(path) }), layout).files
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The pairs of JavaScript files minified on both sides that changed between a
|
|
243
|
+
// comparison's two sides, paired as its viewer pairs them (comparedFiles),
|
|
244
|
+
// each diffed as renameDiffOf diffs it, from both sides' copies, kept or
|
|
245
|
+
// made, as they are iterated: in path order, one after another, leaving a
|
|
246
|
+
// worker to the files a viewer asks for alone (a pair it reads otherwise, one
|
|
247
|
+
// it shows first).
|
|
248
|
+
export function renamedPairs([base, other]: readonly [ComparedSide, ComparedSide], keep = true): AsyncGenerator<RenamedLine> {
|
|
249
|
+
const formattable = ({ files }: ComparedSide) => new Map(files.map(file => [file.path, file]))
|
|
250
|
+
const [olds, news] = [formattable(base), formattable(other)]
|
|
251
|
+
const pairs = comparedFiles([base, other]).changed.flatMap(({ path, basePath = path }) => {
|
|
252
|
+
const [from, to] = [olds.get(basePath), news.get(path)]
|
|
253
|
+
return from && to && from.hash !== to.hash && [from, to].every(file => renamable(file) && needsPrettyPrint(file.path, file.text)) ? [[from, to] as const] : []
|
|
254
|
+
}).toSorted(([, a], [, b]) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
255
|
+
return (async function* () {
|
|
256
|
+
for (const [from, to] of pairs) {
|
|
257
|
+
const request = { base: { path: from.path, hash: from.hash, extension: from.extension }, other: { path: to.path, hash: to.hash, extension: to.extension } }
|
|
258
|
+
const named = { path: from.path, hash: from.hash, otherPath: to.path, otherHash: to.hash }
|
|
259
|
+
const copy = async (side: ComparedSide, { text, ...file }: typeof from) => prettyText(await side.kept(file) ?? await side.made(file, text))
|
|
260
|
+
let body
|
|
261
|
+
try { body = await answerOf(request, async () => [await copy(base, from), await copy(other, to)], keep) }
|
|
262
|
+
catch (err) {
|
|
263
|
+
if (!(err instanceof PrettyError)) throw err
|
|
264
|
+
yield { ...named, error: err.message, status: err.status }
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
yield { ...JSON.parse((await decompress(body)).toString('utf8')) as Partial<RenameDiff>, ...named }
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const MAX_TEAM_PATH = 500
|
|
2
|
+
|
|
3
|
+
// Canonicalize only equivalent Git paths: '/' separates components and empty
|
|
4
|
+
// or '.' components do not change the directory. Reject ambiguous input rather
|
|
5
|
+
// than deleting characters from directory names used for authorization.
|
|
6
|
+
// `{ ok:false }` makes the handler 400; `{ path:null }` means the whole repo.
|
|
7
|
+
export function normalizeTeamPath(raw: unknown): { ok: true; path: string | null } | { ok: false } {
|
|
8
|
+
if (typeof raw !== 'string') return { ok: true, path: null }
|
|
9
|
+
// Spaces and backslashes can be literal parts of Git directory names.
|
|
10
|
+
// Neither trimming nor treating '\\' as a separator preserves that identity.
|
|
11
|
+
if (/\p{Cc}/u.test(raw) || raw.includes('\\') || raw !== raw.trim()) return { ok: false }
|
|
12
|
+
const segments: string[] = []
|
|
13
|
+
for (const seg of raw.split('/')) {
|
|
14
|
+
if (seg === '' || seg === '.') continue
|
|
15
|
+
if (seg === '..' || seg !== seg.trim()) return { ok: false }
|
|
16
|
+
segments.push(seg)
|
|
17
|
+
}
|
|
18
|
+
const path = segments.join('/')
|
|
19
|
+
if (path.length > MAX_TEAM_PATH) return { ok: false }
|
|
20
|
+
return { ok: true, path: path === '' ? null : path }
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SQLite deployments store report blobs beside the database. Recover location
|
|
2
|
+
// metadata before introducing directory-based team access. Schema and data move
|
|
3
|
+
// together so a missing/corrupt blob leaves the migration retryable after repair.
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
7
|
+
import { reportRepoGithub } from '@preventive/report'
|
|
8
|
+
import { readManagedReport } from '../common/managed/report-content.ts'
|
|
9
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
10
|
+
|
|
11
|
+
export function migrateReportLocations(db: DatabaseSync, reportDir: string): void {
|
|
12
|
+
const columns = db.prepare('PRAGMA table_info(managed_report)').all() as { name: string }[]
|
|
13
|
+
if (columns.some((column) => column.name === 'repo_directory')) return
|
|
14
|
+
db.exec('BEGIN IMMEDIATE')
|
|
15
|
+
try {
|
|
16
|
+
db.exec("ALTER TABLE managed_report ADD COLUMN repo_directory TEXT NOT NULL DEFAULT ''")
|
|
17
|
+
const update = db.prepare('UPDATE managed_report SET repo_directory = ?, repo_embedded = ? WHERE id = ?')
|
|
18
|
+
const reports = db.prepare('SELECT id, filename FROM managed_report').all() as { id: string, filename: string }[]
|
|
19
|
+
for (const { id, filename } of reports) {
|
|
20
|
+
// Same opaque UUID boundary as the blob store; never splice arbitrary
|
|
21
|
+
// database text into a filesystem path during an upgrade.
|
|
22
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(id)) throw new Error(`Invalid legacy report id: ${id}`)
|
|
23
|
+
const { data } = readManagedReport(readFileSync(join(reportDir, id), 'utf8'), filename)
|
|
24
|
+
if (data == null) throw new Error(`Cannot migrate location of unreadable report ${id}`)
|
|
25
|
+
const embedded = reportRepoGithub(data) != null
|
|
26
|
+
const directory = normalizeTeamPath(data.repo?.directory)
|
|
27
|
+
if (!directory.ok) throw new Error(`Cannot migrate invalid directory of report ${id}`)
|
|
28
|
+
update.run(directory.path ?? '', embedded ? 1 : 0, id)
|
|
29
|
+
}
|
|
30
|
+
db.exec('COMMIT')
|
|
31
|
+
} catch (err) {
|
|
32
|
+
db.exec('ROLLBACK')
|
|
33
|
+
throw err
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Large workspaces remain a single request. Bound both the stored input and
|
|
2
|
+
// the encoded response, independently of the per-upload size limit.
|
|
3
|
+
export const MAX_REPORT_QUERY_COUNT = 4096
|
|
4
|
+
export const MAX_REPORT_QUERY_BYTES = 1024 * 1024 * 1024
|
|
5
|
+
|
|
6
|
+
// The import catalog scans a bounded page, not the whole installation. A
|
|
7
|
+
// report larger than the byte target occupies a page by itself; upload limits
|
|
8
|
+
// still bound that single report and the cursor must always make progress.
|
|
9
|
+
export const FINDING_CATALOG_PAGE_COUNT = 128
|
|
10
|
+
export const FINDING_CATALOG_PAGE_BYTES = 32 * 1024 * 1024
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { MIMEType } from 'node:util'
|
|
2
|
+
|
|
3
|
+
// The metadata envelope is opt-in. Wildcards keep the legacy raw report;
|
|
4
|
+
// an explicit, acceptable application/json range requests the envelope.
|
|
5
|
+
export function acceptsReportMetadata(accept: string | undefined): boolean {
|
|
6
|
+
// Commas inside quoted media-type parameters do not separate ranges.
|
|
7
|
+
for (const range of accept?.match(/(?:[^",]|"(?:\\.|[^"\\])*")+/gu) ?? []) {
|
|
8
|
+
try {
|
|
9
|
+
const media = new MIMEType(range.trim())
|
|
10
|
+
if (media.essence !== 'application/json') continue
|
|
11
|
+
const quality = media.params.get('q') ?? '1'
|
|
12
|
+
if (/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u.test(quality) && Number(quality) > 0) return true
|
|
13
|
+
} catch { /* Ignore malformed media ranges. */ }
|
|
14
|
+
}
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { extname } from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { gzip } from 'node:zlib'
|
|
6
|
+
import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
7
|
+
import { bundleSourceLinkImports } from '../common/bundle-source-links.js'
|
|
8
|
+
import type { BundleDetails } from '../common/bundle-metadata.js'
|
|
9
|
+
import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
|
|
10
|
+
import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
|
|
11
|
+
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
12
|
+
import { readBundleDetails } from './bundle-cache.ts'
|
|
13
|
+
import type { BlobStore } from './blob-store.ts'
|
|
14
|
+
import type { BundleStore } from './bundle-store.ts'
|
|
15
|
+
import type { ManagedBundle, ManagedDb, ReportRecord } from './db.ts'
|
|
16
|
+
|
|
17
|
+
const compress = promisify(gzip)
|
|
18
|
+
const reportFormat = (filename: string) => extname(filename).toLowerCase()
|
|
19
|
+
|
|
20
|
+
function bundleDirectory(id: string) {
|
|
21
|
+
if (!/^[a-f\d-]{36}$/iu.test(id)) throw new Error('Invalid bundle id')
|
|
22
|
+
return id
|
|
23
|
+
}
|
|
24
|
+
function reportDirectory(bundleId: string, sha256: string) {
|
|
25
|
+
return `${bundleDirectory(bundleId)}/v1-${createHash('sha256').update(sha256).digest('hex')}`
|
|
26
|
+
}
|
|
27
|
+
function formatDirectory(bundleId: string, sha256: string, name: string) {
|
|
28
|
+
return `${reportDirectory(bundleId, sha256)}/${createHash('sha256').update(reportFormat(name)).digest('hex')}`
|
|
29
|
+
}
|
|
30
|
+
function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
31
|
+
const key = createHash('sha256').update(JSON.stringify([
|
|
32
|
+
'finding-access-v9', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
|
|
33
|
+
])).digest('hex')
|
|
34
|
+
return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Match the report's spelling exactly, then an unambiguous path suffix. Never
|
|
38
|
+
// guess between duplicate basenames or treat a bundle path as a disk pathname.
|
|
39
|
+
function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
|
|
40
|
+
const byBasename = new Map<string, string[]>()
|
|
41
|
+
for (const file of sources.keys()) {
|
|
42
|
+
const basename = file.split('/').at(-1)!
|
|
43
|
+
const candidates = byBasename.get(basename) ?? []
|
|
44
|
+
candidates.push(file)
|
|
45
|
+
byBasename.set(basename, candidates)
|
|
46
|
+
}
|
|
47
|
+
const files = new Map<string, string>(), paths = new Map<string, string>()
|
|
48
|
+
function add(path: unknown) {
|
|
49
|
+
if (typeof path !== 'string' || !path || paths.has(path)) return
|
|
50
|
+
let file = sources.has(path) ? path : null
|
|
51
|
+
if (file === null) {
|
|
52
|
+
const suffix = path.startsWith('/') ? path : `/${path}`
|
|
53
|
+
const matches = byBasename.get(path.split('/').at(-1)!)?.filter(candidate => candidate.endsWith(suffix)) ?? []
|
|
54
|
+
if (matches.length === 1) file = matches[0]!
|
|
55
|
+
}
|
|
56
|
+
if (file === null) return
|
|
57
|
+
paths.set(path, file)
|
|
58
|
+
files.set(file, sources.get(file)!)
|
|
59
|
+
}
|
|
60
|
+
for (const path of sourcePaths) add(path)
|
|
61
|
+
return { files: [...files], paths: [...paths] }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function encodeSources(integrity: string, details: BundleDetails, selection: ReturnType<typeof selectSources>) {
|
|
65
|
+
const sources = new Map(selection.files)
|
|
66
|
+
const imports = [...bundleSourceLinkImports(details, sources)].map(([parent, targets]) => [parent, [...targets]])
|
|
67
|
+
const bundle = details.kind === 'stasis' ? details.bundle as { formats: Map<string, string> } : null
|
|
68
|
+
const formats = [...(bundle?.formats ?? [])].filter(([file]) => sources.has(file))
|
|
69
|
+
const packageDirs = [...(bundlePackageDirs(details) ?? [])].filter(([file]) => sources.has(file))
|
|
70
|
+
return compress(Buffer.from(JSON.stringify({ integrity, ...selection, imports, formats, packageDirs })), { level: 6 })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type SourcemapEdges = (bundle: ManagedBundle) => Promise<BundleDetails['edges']>
|
|
74
|
+
|
|
75
|
+
// A sourcemap's imports follow the edges its metadata keeps (the bundle
|
|
76
|
+
// cache's `sourcemapEdges`), read with the parser, else what edges-lite.js reads.
|
|
77
|
+
async function lendSourcemapEdges(details: BundleDetails, bundle: ManagedBundle, sourcemapEdges?: SourcemapEdges) {
|
|
78
|
+
if (details.kind !== 'sourcemap' || !sourcemapEdges) return
|
|
79
|
+
const edges = await sourcemapEdges(bundle).catch(() => undefined)
|
|
80
|
+
if (edges) details.edges = edges
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Immutable report hashes share a derivative across duplicate uploads. Bundle
|
|
84
|
+
// identity and visibility are part of the key: a broader viewer's sources must
|
|
85
|
+
// never populate a restricted response. Group derivatives by hash and filename
|
|
86
|
+
// format so each format's permissions can be removed after its last deletion.
|
|
87
|
+
export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, reports: BlobStore, bundles: BundleStore, sourcemapEdges?: SourcemapEdges) {
|
|
88
|
+
const pending = new Map<string, { reportId: string; job: Promise<boolean> }>()
|
|
89
|
+
let queue = Promise.resolve()
|
|
90
|
+
function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
91
|
+
const job = queue.then(work)
|
|
92
|
+
queue = job.then(() => undefined, () => undefined)
|
|
93
|
+
return job
|
|
94
|
+
}
|
|
95
|
+
async function referenced(report: ReportRecord, bundle: ManagedBundle) {
|
|
96
|
+
const names = await db.listReportFilenamesWithBundleHash(bundle.id, report.sha256)
|
|
97
|
+
return names.some(name => reportFormat(name) === reportFormat(report.filename)) && await db.getBundle(bundle.id) != null
|
|
98
|
+
}
|
|
99
|
+
async function build(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
100
|
+
// Team requests supply paths from their already-filtered members. Reopening
|
|
101
|
+
// the original report by ID would conflate visible and hidden occurrences.
|
|
102
|
+
if (!sourcePaths) {
|
|
103
|
+
const bytes = await reports.get(report.id)
|
|
104
|
+
if (!bytes) return false
|
|
105
|
+
const parsed = await loadManagedFindings(filterReportContent(bytes.toString('utf8'), permissions, report.filename, repo), report.filename)
|
|
106
|
+
if (!parsed) return false
|
|
107
|
+
sourcePaths = managedFindingSourcePaths(parsed.findings)
|
|
108
|
+
}
|
|
109
|
+
const details = await readBundleDetails(bundle, bundles)
|
|
110
|
+
if (!details) return false
|
|
111
|
+
await lendSourcemapEdges(details, bundle, sourcemapEdges)
|
|
112
|
+
const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
|
|
113
|
+
const body = await encodeSources(bundle.integrity, details, selection)
|
|
114
|
+
// A duplicate with the same hash AND format can use these parsed bytes.
|
|
115
|
+
// Another format must not keep a deleted variant's late build alive.
|
|
116
|
+
if (!(await referenced(report, bundle))) return false
|
|
117
|
+
await storage.put(target, body)
|
|
118
|
+
// Another instance may finish deletion while this write is in flight.
|
|
119
|
+
// Recheck after publishing so a late builder cannot resurrect its cache.
|
|
120
|
+
if (!(await referenced(report, bundle))) {
|
|
121
|
+
await storage.delete(formatDirectory(bundle.id, report.sha256, report.filename))
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
async function ensure(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>): Promise<boolean> {
|
|
127
|
+
const existing = pending.get(target)
|
|
128
|
+
if (existing) {
|
|
129
|
+
const ready = await existing.job
|
|
130
|
+
if (ready || existing.reportId === report.id) return ready
|
|
131
|
+
// Deletion can also win before the initiator reads its blob. Retry with
|
|
132
|
+
// this request's row instead of caching that other row's miss as 204.
|
|
133
|
+
const initiator = await db.getReport(existing.reportId)
|
|
134
|
+
if (initiator?.bundleId === bundle.id && initiator.sha256 === report.sha256) return false
|
|
135
|
+
if (pending.get(target) === existing) pending.delete(target)
|
|
136
|
+
return ensure(target, report, bundle, permissions, repo, sourcePaths)
|
|
137
|
+
}
|
|
138
|
+
const job = (async () => {
|
|
139
|
+
if (await storage.exists(target)) return true
|
|
140
|
+
// Serial cold builds bound peak decompression/parsing memory.
|
|
141
|
+
return enqueue(() => build(target, report, bundle, permissions, repo, sourcePaths))
|
|
142
|
+
})()
|
|
143
|
+
const entry = { reportId: report.id, job }
|
|
144
|
+
pending.set(target, entry)
|
|
145
|
+
try { return await job } finally { if (pending.get(target) === entry) pending.delete(target) }
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
|
|
149
|
+
const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
|
|
150
|
+
const target = sourceCacheFilename(report, bundle, permissions, repo, sourcePaths)
|
|
151
|
+
try { return { ...await storage.open(target), repo } }
|
|
152
|
+
catch (error) { if (!(error instanceof CacheMissError)) throw error }
|
|
153
|
+
if (!(await ensure(target, report, bundle, permissions, repo, sourcePaths))) return null
|
|
154
|
+
const opened = await storage.open(target)
|
|
155
|
+
return { ...opened, repo }
|
|
156
|
+
},
|
|
157
|
+
async deleteBundle(id: string) {
|
|
158
|
+
const prefix = `${bundleDirectory(id)}/`
|
|
159
|
+
await Promise.allSettled([...pending].filter(([key]) => key.startsWith(prefix)).map(([, entry]) => entry.job))
|
|
160
|
+
await storage.delete(bundleDirectory(id))
|
|
161
|
+
},
|
|
162
|
+
async deleteReport(report: Pick<ReportRecord, 'bundleId' | 'sha256' | 'filename'>) {
|
|
163
|
+
const { bundleId, sha256 } = report
|
|
164
|
+
if (bundleId === null) return
|
|
165
|
+
// Called after metadata deletion. Serialize with builders so a cold
|
|
166
|
+
// request cannot recreate a derivative after its cleanup has finished.
|
|
167
|
+
await enqueue(async () => {
|
|
168
|
+
const names = await db.listReportFilenamesWithBundleHash(bundleId, sha256)
|
|
169
|
+
if (names.length === 0) {
|
|
170
|
+
await storage.delete(reportDirectory(bundleId, sha256))
|
|
171
|
+
} else if (!names.some(name => reportFormat(name) === reportFormat(report.filename))) {
|
|
172
|
+
await storage.delete(formatDirectory(bundleId, sha256, report.filename))
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export type ReportSourcesCache = ReturnType<typeof createReportSourcesCache>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Compress before the object-storage encryption boundary. Separate paths let
|
|
2
|
+
// legacy arbitrary report bytes remain readable without format sniffing.
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { Readable } from 'node:stream'
|
|
5
|
+
import { pipeline } from 'node:stream/promises'
|
|
6
|
+
import { createBrotliDecompress } from 'node:zlib'
|
|
7
|
+
import type { BlobStore, OpenedBlob } from './blob-store.ts'
|
|
8
|
+
import { encodeBrotli } from './brotli.ts'
|
|
9
|
+
|
|
10
|
+
async function readBytes(stored: OpenedBlob): Promise<Buffer> {
|
|
11
|
+
const parts: Buffer[] = []
|
|
12
|
+
try {
|
|
13
|
+
for await (const part of stored.stream) parts.push(Buffer.from(part))
|
|
14
|
+
return Buffer.concat(parts)
|
|
15
|
+
} finally { stored.stream.destroy() }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createReportStore(legacy: BlobStore, compressed: BlobStore,
|
|
19
|
+
replaceLegacy: (id: string, bytes: Buffer) => Promise<unknown>): BlobStore {
|
|
20
|
+
async function open(id: string): Promise<OpenedBlob | null> {
|
|
21
|
+
// Keep the original available until conversion succeeds. Concurrent reads
|
|
22
|
+
// can finish using an already-open stream while another reader converts it.
|
|
23
|
+
const original = await legacy.open(id)
|
|
24
|
+
if (original) {
|
|
25
|
+
const bytes = await readBytes(original)
|
|
26
|
+
await replaceLegacy(id, await encodeBrotli(bytes, 9))
|
|
27
|
+
return { size: bytes.length, stream: Readable.from([bytes]) }
|
|
28
|
+
}
|
|
29
|
+
const stored = await compressed.open(id)
|
|
30
|
+
if (!stored) return null
|
|
31
|
+
const stream = createBrotliDecompress()
|
|
32
|
+
// pipeline carries storage/authentication errors into the decoded stream
|
|
33
|
+
// and closes the stored source when a reader leaves before EOF.
|
|
34
|
+
void pipeline(stored.stream, stream).catch(() => {})
|
|
35
|
+
return { size: null, stream }
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
put: async (id, bytes) => compressed.put(id, await encodeBrotli(bytes, 9)),
|
|
39
|
+
open,
|
|
40
|
+
async get(id) {
|
|
41
|
+
const stored = await open(id)
|
|
42
|
+
if (!stored) return null
|
|
43
|
+
return readBytes(stored)
|
|
44
|
+
},
|
|
45
|
+
async delete(id) { await Promise.all([compressed.delete(id), legacy.delete(id)]) },
|
|
46
|
+
}
|
|
47
|
+
}
|