@preventive/triage 1.0.0-alpha.36 → 1.0.0-alpha.38

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.
Files changed (177) hide show
  1. package/README.md +16 -0
  2. package/common/bundle-compare-diff.d.ts +3 -0
  3. package/common/bundle-compare-diff.js +522 -0
  4. package/common/bundle-compare-inputs.d.ts +2 -0
  5. package/common/bundle-compare-inputs.js +65 -0
  6. package/common/bundle-metadata.d.ts +6 -2
  7. package/common/bundle-metadata.js +63 -20
  8. package/common/bundle-pkg-of.d.ts +1 -0
  9. package/common/bundle-pkg-of.js +90 -0
  10. package/common/bundle-source-links.js +7 -0
  11. package/common/bundle-sourcemap.d.ts +10 -0
  12. package/common/bundle-sourcemap.js +100 -0
  13. package/common/bundle-sources.d.ts +3 -1
  14. package/common/bundle-sources.js +47 -19
  15. package/common/code-language.js +16 -2
  16. package/common/default-scan-models.ts +2 -1
  17. package/common/dependency-reviews.d.ts +23 -0
  18. package/common/dependency-reviews.js +81 -0
  19. package/common/finding-duplicates.js +58 -0
  20. package/common/finding-filters.js +274 -0
  21. package/common/github-pr.ts +1 -1
  22. package/common/gzip.js +59 -0
  23. package/common/line-diff.d.ts +8 -0
  24. package/common/line-diff.js +56 -0
  25. package/common/linked-workspace-groups.js +63 -0
  26. package/common/managed/npm-packages.d.ts +5 -0
  27. package/common/managed/npm-packages.js +34 -0
  28. package/common/managed/permissions.ts +1 -5
  29. package/common/managed/routes.js +123 -12
  30. package/common/managed/team-app.d.ts +2 -0
  31. package/common/managed/team-app.js +31 -0
  32. package/common/managed/triage.ts +1 -1
  33. package/common/managed/upload-seal.ts +126 -0
  34. package/common/pretty-print.d.ts +5 -0
  35. package/common/pretty-print.js +24 -0
  36. package/common/revalidation-conflicts.js +48 -0
  37. package/common/revalidation-input-groups.js +56 -0
  38. package/common/workspace-app.js +23 -0
  39. package/common/workspace-groups.js +176 -0
  40. package/out/brotli-fallback.js +3 -3
  41. package/out/chunk-2KBF5TZ7.js +2 -0
  42. package/out/chunk-2XUEZHIP.js +28 -0
  43. package/out/chunk-35VE6CDH.js +2 -0
  44. package/out/chunk-3BZ7JERB.js +1 -0
  45. package/out/chunk-4XCDFGDP.js +10 -0
  46. package/out/chunk-5FKRIJGF.js +1 -0
  47. package/out/chunk-6Z5XLSPJ.js +1 -0
  48. package/out/chunk-ABW5FVSN.js +1 -0
  49. package/out/chunk-BJKRVROW.js +1 -0
  50. package/out/chunk-BYUEDJOM.js +7 -0
  51. package/out/chunk-C4Z6ZBXV.js +10 -0
  52. package/out/chunk-CMQA4DM6.js +1 -0
  53. package/out/chunk-CVVJAXVY.js +2 -0
  54. package/out/chunk-DCQX2J3C.js +223 -0
  55. package/out/chunk-DIJJNTRH.js +10 -0
  56. package/out/chunk-F4OMDNEY.js +1 -0
  57. package/out/chunk-FU4MTW7F.js +1 -0
  58. package/out/chunk-GJ4IUCNS.js +1 -0
  59. package/out/chunk-GMFXFEUA.js +2 -0
  60. package/out/chunk-GNXOKIGP.js +80 -0
  61. package/out/chunk-GZ4MS3TE.js +13 -0
  62. package/out/chunk-HIV7SVI7.js +1 -0
  63. package/out/chunk-IU3CCINR.js +3 -0
  64. package/out/chunk-JAKWIZWL.js +2 -0
  65. package/out/chunk-K3X5EJBQ.js +2 -0
  66. package/out/chunk-KEX2ATEW.js +2 -0
  67. package/out/chunk-KYCNJVB7.js +1 -0
  68. package/out/chunk-NRPC74AM.js +1 -0
  69. package/out/chunk-OJ5DGP5E.js +33 -0
  70. package/out/chunk-OOJM4CTU.js +1 -0
  71. package/out/chunk-PNHBZGDD.js +1 -0
  72. package/out/chunk-R5O6FAW4.js +2 -0
  73. package/out/chunk-RFZQQXSG.js +17 -0
  74. package/out/chunk-SD2WJAXN.js +48 -0
  75. package/out/chunk-TUII34K2.js +1 -0
  76. package/out/chunk-TZ5KB2Y5.js +10 -0
  77. package/out/chunk-UNTRGE26.js +2 -0
  78. package/out/chunk-UYAXVRVL.js +1 -0
  79. package/out/chunk-V3ME6FI5.js +2 -0
  80. package/out/chunk-V5O7M2YT.js +1 -0
  81. package/out/chunk-VIHGO77Y.js +1 -0
  82. package/out/chunk-W4J3MGLB.js +1 -0
  83. package/out/chunk-WGG7KQUH.js +1 -0
  84. package/out/chunk-XYFXXB2G.js +10 -0
  85. package/out/chunk-YVZH5SHP.js +1 -0
  86. package/out/chunk-ZPD5R5RP.js +3 -0
  87. package/out/client-managed-import.js +3 -87
  88. package/out/client-managed.js +26 -338
  89. package/out/client-sync.js +8 -14
  90. package/out/graph.js +17 -30
  91. package/out/index.html +34 -0
  92. package/out/prism.js +6 -2
  93. package/out/terminal.js +241 -257
  94. package/out/view.css +1 -1
  95. package/out/view.js +76 -520
  96. package/package.json +88 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +164 -0
  100. package/risk/files/runs-code.js +329 -0
  101. package/risk/files/script.js +112 -0
  102. package/risk/files/stamps.js +46 -0
  103. package/risk/index.d.ts +142 -0
  104. package/risk/index.js +7 -0
  105. package/risk/npm/assess.js +219 -0
  106. package/risk/npm/combined.js +65 -0
  107. package/risk/npm/compare.js +222 -0
  108. package/risk/npm/dependencies.js +153 -0
  109. package/risk/npm/diff.js +43 -0
  110. package/risk/npm/downloads.js +84 -0
  111. package/risk/npm/files.js +34 -0
  112. package/risk/npm/held.js +60 -0
  113. package/risk/npm/install-scripts.js +33 -0
  114. package/risk/npm/malware.js +36 -0
  115. package/risk/npm/manifest.js +65 -0
  116. package/risk/npm/new-dependency.js +30 -0
  117. package/risk/npm/old-version.js +108 -0
  118. package/risk/npm/registry.js +282 -0
  119. package/risk/npm/release.js +85 -0
  120. package/risk/npm/specs.js +124 -0
  121. package/risk/npm/tarball.js +113 -0
  122. package/server-e2e/http.ts +7 -7
  123. package/server-e2e/npm-proxy.ts +1 -1
  124. package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
  125. package/server-e2e/objstore/handlers.ts +1 -1
  126. package/server-e2e/objstore/tokens.ts +1 -1
  127. package/server-e2e/sign.ts +1 -1
  128. package/server-managed/bundle-advisories.ts +68 -11
  129. package/server-managed/bundle-build-worker.js +1 -1
  130. package/server-managed/bundle-build.ts +6 -2
  131. package/server-managed/bundle-cache.ts +37 -4
  132. package/server-managed/bundle-commits.ts +140 -0
  133. package/server-managed/bundle-summary-cache.ts +24 -10
  134. package/server-managed/config.ts +4 -0
  135. package/server-managed/db-methods.ts +98 -11
  136. package/server-managed/db-neon.ts +39 -4
  137. package/server-managed/db-schema.ts +14 -2
  138. package/server-managed/db-table-names.ts +1 -1
  139. package/server-managed/db.ts +7 -3
  140. package/server-managed/dependency-reviews.ts +248 -0
  141. package/server-managed/github-issue-oauth.ts +5 -6
  142. package/server-managed/github-metadata.ts +115 -0
  143. package/server-managed/github-oauth.ts +38 -2
  144. package/server-managed/http-response.ts +26 -0
  145. package/server-managed/http.ts +943 -52
  146. package/server-managed/index.ts +11 -5
  147. package/server-managed/kept.ts +28 -0
  148. package/server-managed/locked-packages.ts +85 -0
  149. package/server-managed/lru.ts +30 -0
  150. package/server-managed/npm-insights.ts +440 -0
  151. package/server-managed/npm-kept.ts +65 -0
  152. package/server-managed/npm-loads.ts +167 -0
  153. package/server-managed/npm-manifest.ts +115 -0
  154. package/server-managed/npm-packages.ts +378 -0
  155. package/server-managed/npm-provenance.ts +123 -0
  156. package/server-managed/npm-risk.ts +177 -0
  157. package/server-managed/pretty-print.ts +352 -0
  158. package/server-managed/public-workspace.ts +78 -7
  159. package/server-managed/rename-diff-worker.js +26 -0
  160. package/server-managed/rename-diff.ts +270 -0
  161. package/server-managed/report-sources.ts +13 -2
  162. package/server-managed/repository-browser.ts +19 -5
  163. package/server-managed/repository-risk.ts +413 -0
  164. package/server-managed/session.ts +16 -2
  165. package/server-managed/sql.ts +5 -2
  166. package/server-managed/storage-encryption.ts +5 -3
  167. package/server-managed/storage-payload.ts +4 -0
  168. package/server-managed/storage-stores.ts +2 -0
  169. package/server-managed/storage.ts +6 -3
  170. package/server-managed/team-app.ts +94 -0
  171. package/server-managed/team-feed.ts +1 -1
  172. package/server-managed/team-npm-scopes.ts +77 -0
  173. package/server-managed/type-only-worker.js +19 -0
  174. package/server-managed/type-only.ts +103 -0
  175. package/server-managed/uploads.ts +25 -4
  176. package/server-managed/upstream-cache.ts +147 -5
  177. package/server-managed/view-as.ts +58 -0
@@ -5,15 +5,19 @@ import type { ManagedHttpDeps } from './http.ts'
5
5
  import type { ManagedBundle } from './db.ts'
6
6
  import type { OpenedBlob } from './blob-store.ts'
7
7
  import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
8
- import { sendJson, writeResponse } from './http-response.ts'
8
+ import { sendJson, sendLines, writeResponse } from './http-response.ts'
9
9
  import { hashToken } from './crypto.ts'
10
10
  import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, teamReportVisibility } from './team-reports.ts'
11
11
  import { triageWireEntry } from './triage-response.ts'
12
12
  import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
13
13
  import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
14
14
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
15
- import { auditCache } from './upstream-cache.ts'
15
+ import { auditCache, auditedRepos } from './upstream-cache.ts'
16
16
  import { serveTeamFeed } from './team-feed.ts'
17
+ import { type ComparedSide, PrettyError, compared, prettyRequest, prettyText } from './pretty-print.ts'
18
+ import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
19
+ import { typeOnlyFiles } from './type-only.ts'
20
+ import { sharedTeamApp } from './team-app.ts'
17
21
 
18
22
  function json(res: ServerResponse, status: number, body: unknown): void {
19
23
  sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
@@ -31,7 +35,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
31
35
  const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
32
36
  const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
33
37
  const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
34
- const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
38
+ const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only)$/u.exec(url.pathname)
35
39
  if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
36
40
  const tokenHash = hashToken(token)
37
41
  const snapshot = await deps.db.getWorkspaceShare(tokenHash)
@@ -42,8 +46,8 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
42
46
  }
43
47
  const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
44
48
  const sendTeam = async () => {
45
- const summaries = await bundleSummaries(snapshot.team.bundles, deps.bundleCache)
46
- await send({ user: snapshot.user, team: { ...snapshot.team,
49
+ const [summaries, app] = await Promise.all([bundleSummaries(snapshot.team.bundles, deps.bundleCache), sharedTeamApp(deps.db, deps.reportStore, snapshot)])
50
+ await send({ user: snapshot.user, team: { ...snapshot.team, app,
47
51
  bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
48
52
  } })
49
53
  await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
@@ -97,9 +101,63 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
97
101
  const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
98
102
  if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
99
103
  if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
104
+ if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
105
+ if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
106
+ await serveCompared(res, deps, bundle, snapshot.bundles.find(item => item.id === url.searchParams.get('other')), bundleRoute![2], url, recheck, stream); return
107
+ }
100
108
  await serveBundle(res, deps, bundle, bundleRoute![2]!, url, recheck, stream)
101
109
  }
102
110
 
111
+ // What a bundle is compared with `other`, another of the link's bundles, by:
112
+ // one of their JavaScript files diffed with renamed names hidden, or the
113
+ // files that changed between them, or the pairs of those, or those that
114
+ // changed in types alone, a line each.
115
+ function serveCompared(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, part: string, url: URL,
116
+ recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
117
+ if (part === 'rename-diff' && url.searchParams.has('path')) return serveRenameDiff(res, deps, bundle, other, url, stream)
118
+ return serveChanges(res, deps, bundle, other, recheck, part === 'pretty' ? compared : part === 'type-only' ? typeOnlyFiles : renamedPairs,
119
+ part === 'pretty' ? '' : url.searchParams.get('scope') ?? '')
120
+ }
121
+
122
+ // One of a bundle's JavaScript files diffed with one of `other`, another of
123
+ // the link's bundles (rename-diff.ts).
124
+ async function serveRenameDiff(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, url: URL,
125
+ stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
126
+ const request = renameDiffRequest(url.searchParams)
127
+ if (!request) { json(res, 400, { error: 'bad-file' }); return }
128
+ if (!other) { json(res, 404, { error: 'no-bundle' }); return }
129
+ const { prettyCache } = deps
130
+ if (!prettyCache) { json(res, 503, { error: 'unavailable' }); return }
131
+ let diff
132
+ try {
133
+ diff = await renameDiffOf(request, async () => [await prettyText(await prettyCache.bundle(bundle, request.base)),
134
+ await prettyText(await prettyCache.bundle(other, request.other))])
135
+ } catch (err) {
136
+ if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
137
+ throw err
138
+ }
139
+ await stream(diff, 'br')
140
+ }
141
+
142
+ // The files that changed between a bundle and `other`, another of the link's
143
+ // bundles, both sides' pretty-printed (pretty-print.ts compared), or the pairs
144
+ // of them its viewer diffs with renamed names hidden (rename-diff.ts
145
+ // renamedPairs), those within a reason `scope` where given, the link checked
146
+ // again before each line.
147
+ async function serveChanges(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, recheck: () => Promise<void>,
148
+ linesOf: (sides: [ComparedSide, ComparedSide]) => AsyncIterable<unknown>, scope: string) {
149
+ if (!other) { json(res, 404, { error: 'no-bundle' }); return }
150
+ if (!deps.prettyCache) { json(res, 503, { error: 'unavailable' }); return }
151
+ let lines
152
+ try { lines = linesOf(await deps.prettyCache.bundleSides(bundle, other, scope)) }
153
+ catch (err) {
154
+ if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
155
+ throw err
156
+ }
157
+ await recheck()
158
+ await sendLines(res, lines, () => recheck().then(() => true, () => false))
159
+ }
160
+
103
161
  async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, part: string, url: URL,
104
162
  recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
105
163
  if (part === 'download') {
@@ -107,6 +165,18 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
107
165
  if (!stored) { json(res, 503, { error: 'unavailable' }); return }
108
166
  await stream(stored, bundle.kind === 'sourcemap' ? 'br' : null, 'application/octet-stream'); return
109
167
  }
168
+ if (part === 'pretty') {
169
+ const file = prettyRequest(url.searchParams)
170
+ if (!file) { json(res, 400, { error: 'bad-file' }); return }
171
+ if (!deps.prettyCache) { json(res, 503, { error: 'unavailable' }); return }
172
+ let pretty
173
+ try { pretty = await deps.prettyCache.bundle(bundle, file) }
174
+ catch (err) {
175
+ if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
176
+ throw err
177
+ }
178
+ await stream(pretty, 'br', 'text/plain; charset=utf-8'); return
179
+ }
110
180
  if (!deps.bundleCache) { json(res, 503, { error: 'unavailable' }); return }
111
181
  if (part === 'advisories') {
112
182
  if (bundle.kind !== 'stasis') { json(res, 422, { error: 'unsupported-bundle' }); return }
@@ -116,9 +186,10 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
116
186
  if (inventory === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
117
187
  if (Buffer.byteLength(JSON.stringify(inventory)) > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
118
188
  const signal = AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)
189
+ const repoAdvisories = url.searchParams.get('repoAdvisories') === 'true'
119
190
  const result = await fetchBundleAdvisories(inventory.packages, signal, {
120
- debug: deps.config.debug, repoAdvisories: url.searchParams.get('repoAdvisories') === 'true', details: url.searchParams.get('details') === 'true',
121
- cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug),
191
+ debug: deps.config.debug, repoAdvisories, details: url.searchParams.get('details') === 'true',
192
+ cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
122
193
  })
123
194
  await recheck()
124
195
  json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
@@ -0,0 +1,26 @@
1
+ // Compare's diff with renamed names hidden (rename-diff.ts), worked out off the
2
+ // server's event loop by @preventive/compare's renamedDiffs: the old side given
3
+ // the new side's names, formatted again where that changes its layout alone,
4
+ // diffed with whitespace changes shown and hidden, and the blocks checked to
5
+ // take it to the new side. `{ before, blocks, reason, reads, marks,
6
+ // ignoringWhitespace }`: the old side as diffed, the change blocks, why the
7
+ // old side is as it was where its names could not be given safely, the
8
+ // properties through which either side reads names (a view marks renamed
9
+ // lines rather than hiding them where any are read, as a rename may then
10
+ // change what the code does), and the blocks of the diff of the files as they
11
+ // are that differ in renamed names alone; `ignoringWhitespace` the blocks and
12
+ // those marked with whitespace changes hidden, as diff -b hides them. Blocks
13
+ // that would not take the old side to the new are thrown, and so answer
14
+ // nothing.
15
+ import { parentPort, workerData } from 'node:worker_threads'
16
+ import { renamedDiffs } from '@preventive/compare/names.js'
17
+
18
+ const { before, after, extension, otherExtension } = workerData
19
+ const diff = await renamedDiffs(before, after, { extension, otherExtension, whitespace: ['none', 'change'] })
20
+ // A check that should never fail did, or a step gave way: told, the file shown with its names.
21
+ for (const warning of diff.warnings) console.warn(`managed: rename diff: ${warning}`)
22
+ const [shown, hidden] = diff.diffs
23
+ const answer = { before: diff.before, blocks: shown.blocks, reason: diff.reason, reads: diff.reads, marks: shown.marks, ignoringWhitespace: { blocks: hidden.blocks, marks: hidden.marks } }
24
+ // Node worker messages have no browser target origin.
25
+ // oxlint-disable-next-line unicorn/require-post-message-target-origin
26
+ parentPort.postMessage(answer)
@@ -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
+ }
@@ -29,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
29
29
  }
30
30
  function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
31
31
  const key = createHash('sha256').update(JSON.stringify([
32
- 'finding-access-v8', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
32
+ 'finding-access-v9', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
33
33
  ])).digest('hex')
34
34
  return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
35
35
  }
@@ -70,11 +70,21 @@ function encodeSources(integrity: string, details: BundleDetails, selection: Ret
70
70
  return compress(Buffer.from(JSON.stringify({ integrity, ...selection, imports, formats, packageDirs })), { level: 6 })
71
71
  }
72
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
+
73
83
  // Immutable report hashes share a derivative across duplicate uploads. Bundle
74
84
  // identity and visibility are part of the key: a broader viewer's sources must
75
85
  // never populate a restricted response. Group derivatives by hash and filename
76
86
  // format so each format's permissions can be removed after its last deletion.
77
- export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, reports: BlobStore, bundles: BundleStore) {
87
+ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, reports: BlobStore, bundles: BundleStore, sourcemapEdges?: SourcemapEdges) {
78
88
  const pending = new Map<string, { reportId: string; job: Promise<boolean> }>()
79
89
  let queue = Promise.resolve()
80
90
  function enqueue<T>(work: () => Promise<T>): Promise<T> {
@@ -98,6 +108,7 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
98
108
  }
99
109
  const details = await readBundleDetails(bundle, bundles)
100
110
  if (!details) return false
111
+ await lendSourcemapEdges(details, bundle, sourcemapEdges)
101
112
  const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
102
113
  const body = await encodeSources(bundle.integrity, details, selection)
103
114
  // A duplicate with the same hash AND format can use these parsed bytes.
@@ -3,6 +3,9 @@ import type { SelectedRepo } from './db.ts'
3
3
  import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
4
4
  import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
5
5
  import { readSolidityEntryPoints } from './solidity-entry-points.ts'
6
+ import { parseGithubCommit } from './bundle-commits.ts'
7
+
8
+ const REFS_PAGE = 100
6
9
 
7
10
  export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
8
11
 
@@ -102,12 +105,18 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
102
105
  readToken: () => token,
103
106
  async refs() {
104
107
  // Suggestions are bounded; the input also accepts any branch or tag name.
105
- const results = await Promise.all(['branches', 'tags'].map(async kind => {
106
- const data = await read(`/${kind}?per_page=100`)
108
+ const list = async (kind: string) => {
109
+ const data = await read(`/${kind}?per_page=${REFS_PAGE}`)
107
110
  if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
108
- return data.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
109
- }))
110
- return { defaultBranch, branches: results[0], tags: results[1] }
111
+ return data
112
+ }
113
+ const [branches, tags] = await Promise.all([list('branches'), list('tags')])
114
+ const names = (items: { name?: unknown }[]) => items.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
115
+ // Each tag's commit, for the tag cache. A full page may leave tags
116
+ // out; only a shorter one lists every tag.
117
+ const tagCommits = tags.flatMap(item => typeof item?.name === 'string' && item.name && typeof item.commit?.sha === 'string'
118
+ && /^[a-f\d]{40}$/u.test(item.commit.sha) ? [{ name: item.name, sha: item.commit.sha }] : [])
119
+ return { defaultBranch, branches: names(branches), tags: names(tags), tagCommits, tagsComplete: tags.length < REFS_PAGE }
111
120
  },
112
121
  async commit(ref: string) {
113
122
  if (/^[a-f\d]{40}$/iu.test(ref)) return ref
@@ -115,6 +124,11 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
115
124
  if (typeof data?.sha !== 'string' || !/^[a-f\d]{40}$/iu.test(data.sha)) throw new GithubApiError(502, 'github-malformed')
116
125
  return data.sha
117
126
  },
127
+ // The first commit a listing from `sha` returns is that commit, without
128
+ // the files GET /commits/:sha adds.
129
+ async commitDetails(sha: string) {
130
+ return /^[a-f\d]{40}$/u.test(sha) ? parseGithubCommit(sha, await read(`/commits?sha=${sha}&per_page=1`)) : null
131
+ },
118
132
  async directory(path: string, commit: string) {
119
133
  const data = await read(`/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(commit)}`)
120
134
  if (!Array.isArray(data)) throw new GithubApiError(400, 'not-a-directory')