@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50

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 (366) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +2 -0
  50. package/common/managed/finding-detail.js +32 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +5 -0
  54. package/common/managed/npm-packages.js +34 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +347 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +5 -0
  64. package/common/managed/team-app.js +31 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2WDZPXVL.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-4XMP7YVH.js +36 -0
  87. package/out/chunk-6LNQTKAV.js +52 -0
  88. package/out/chunk-6S2U2UYG.js +232 -0
  89. package/out/chunk-75YLHBCM.js +3 -0
  90. package/out/chunk-7BKKYVWR.js +7 -0
  91. package/out/chunk-7C7DWL7I.js +1 -0
  92. package/out/chunk-7VAWINQF.js +6 -0
  93. package/out/chunk-C4Z6ZBXV.js +10 -0
  94. package/out/chunk-FC65TPUB.js +1 -0
  95. package/out/chunk-FKBXNUW4.js +1 -0
  96. package/out/chunk-GDPCLSOS.js +2 -0
  97. package/out/chunk-GO7FT4OD.js +1 -0
  98. package/out/chunk-HTBYHSPG.js +1 -0
  99. package/out/chunk-IYPY47MC.js +1 -0
  100. package/out/chunk-IZ7POWI3.js +18 -0
  101. package/out/chunk-JCIIBLKR.js +1 -0
  102. package/out/chunk-NRPC74AM.js +1 -0
  103. package/out/chunk-OOJM4CTU.js +1 -0
  104. package/out/chunk-PBZPYIG7.js +1 -0
  105. package/out/chunk-PNHBZGDD.js +1 -0
  106. package/out/chunk-PWFUOLZK.js +17 -0
  107. package/out/chunk-PXK7EU4V.js +1 -0
  108. package/out/chunk-QB4CBOX2.js +1 -0
  109. package/out/chunk-R3NZFXVR.js +1 -0
  110. package/out/chunk-R5O6FAW4.js +2 -0
  111. package/out/chunk-SXV2T5C4.js +80 -0
  112. package/out/chunk-TQJDYHVE.js +2 -0
  113. package/out/chunk-TUII34K2.js +1 -0
  114. package/out/chunk-UK7RGJT6.js +3 -0
  115. package/out/chunk-VFWMQ3LG.js +2 -0
  116. package/out/chunk-VH4XMZIW.js +2 -0
  117. package/out/chunk-WCPJDAYA.js +10 -0
  118. package/out/chunk-WFYLMF6Z.js +10 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +362 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +21 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +73 -0
  237. package/server-managed/comparison.ts +41 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +458 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +142 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +100 -0
  249. package/server-managed/dependency-reviews.ts +346 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +415 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +39 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5329 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/locked-packages.ts +129 -0
  273. package/server-managed/lockfile-scope.ts +49 -0
  274. package/server-managed/lru.ts +30 -0
  275. package/server-managed/managed-issues.ts +78 -0
  276. package/server-managed/management-catalog.ts +45 -0
  277. package/server-managed/management.ts +99 -0
  278. package/server-managed/npm-delta-worker.js +23 -0
  279. package/server-managed/npm-delta.ts +194 -0
  280. package/server-managed/npm-insights.ts +449 -0
  281. package/server-managed/npm-kept.ts +215 -0
  282. package/server-managed/npm-loads.ts +264 -0
  283. package/server-managed/npm-manifest.ts +115 -0
  284. package/server-managed/npm-packages.ts +454 -0
  285. package/server-managed/npm-provenance.ts +123 -0
  286. package/server-managed/npm-risk.ts +285 -0
  287. package/server-managed/object-storage-disk.ts +148 -0
  288. package/server-managed/object-storage-vercel.ts +67 -0
  289. package/server-managed/object-storage.ts +75 -0
  290. package/server-managed/package-entry-points.ts +51 -0
  291. package/server-managed/pretty-print.ts +434 -0
  292. package/server-managed/public-workspace.ts +238 -0
  293. package/server-managed/pull-comment-reviews.ts +161 -0
  294. package/server-managed/pull-dependencies.ts +348 -0
  295. package/server-managed/pull-dependency-reviews.ts +328 -0
  296. package/server-managed/pull-reviews.ts +205 -0
  297. package/server-managed/queue.ts +39 -0
  298. package/server-managed/release-commits.ts +84 -0
  299. package/server-managed/rename-diff-worker.js +26 -0
  300. package/server-managed/rename-diff.ts +279 -0
  301. package/server-managed/repo-path.ts +21 -0
  302. package/server-managed/report-migration.ts +35 -0
  303. package/server-managed/report-query.ts +10 -0
  304. package/server-managed/report-response.ts +16 -0
  305. package/server-managed/report-sources.ts +178 -0
  306. package/server-managed/report-store.ts +85 -0
  307. package/server-managed/repository-actions.ts +218 -0
  308. package/server-managed/repository-agents.ts +355 -0
  309. package/server-managed/repository-aliases.ts +91 -0
  310. package/server-managed/repository-browser.ts +294 -0
  311. package/server-managed/repository-changes.ts +284 -0
  312. package/server-managed/repository-code.ts +215 -0
  313. package/server-managed/repository-dependency-reviews.ts +165 -0
  314. package/server-managed/repository-discovery.ts +82 -0
  315. package/server-managed/repository-history.ts +432 -0
  316. package/server-managed/repository-issues.ts +195 -0
  317. package/server-managed/repository-policy.ts +27 -0
  318. package/server-managed/repository-pulls.ts +156 -0
  319. package/server-managed/repository-pulse.ts +460 -0
  320. package/server-managed/repository-risk-assess.ts +105 -0
  321. package/server-managed/repository-risk-audit.ts +79 -0
  322. package/server-managed/repository-risk-read.ts +170 -0
  323. package/server-managed/repository-risk.ts +491 -0
  324. package/server-managed/repository-squashes.ts +168 -0
  325. package/server-managed/request-timing.ts +63 -0
  326. package/server-managed/reviewed-dependencies.ts +150 -0
  327. package/server-managed/revisions.ts +35 -0
  328. package/server-managed/scan-downloads.ts +87 -0
  329. package/server-managed/session.ts +132 -0
  330. package/server-managed/slugs.ts +38 -0
  331. package/server-managed/solidity-entry-points.ts +71 -0
  332. package/server-managed/sql-postgres.ts +34 -0
  333. package/server-managed/sql.ts +88 -0
  334. package/server-managed/static.ts +28 -0
  335. package/server-managed/storage-command.ts +16 -0
  336. package/server-managed/storage-db.ts +183 -0
  337. package/server-managed/storage-encryption.ts +290 -0
  338. package/server-managed/storage-maintenance.ts +259 -0
  339. package/server-managed/storage-payload.ts +121 -0
  340. package/server-managed/storage-stores.ts +91 -0
  341. package/server-managed/storage.ts +82 -0
  342. package/server-managed/team-app.ts +134 -0
  343. package/server-managed/team-catalog.ts +7 -0
  344. package/server-managed/team-feed.ts +180 -0
  345. package/server-managed/team-npm-scopes.ts +106 -0
  346. package/server-managed/team-reports.ts +307 -0
  347. package/server-managed/triage-response.ts +16 -0
  348. package/server-managed/type-only-worker.js +19 -0
  349. package/server-managed/type-only.ts +105 -0
  350. package/server-managed/uploads.ts +106 -0
  351. package/server-managed/upstream-cache.ts +243 -0
  352. package/server-managed/view-as.ts +58 -0
  353. package/server-managed/workspace-shares.ts +169 -0
  354. package/server.ts +70 -0
  355. package/server/index.ts +0 -480
  356. package/server/lifecycle.ts +0 -208
  357. /package/{LICENSE → LICENSE-MIT} +0 -0
  358. /package/{server → server-e2e}/auth.ts +0 -0
  359. /package/{server → server-e2e}/config.example.json +0 -0
  360. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  361. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  362. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  363. /package/{server → server-e2e}/peer.ts +0 -0
  364. /package/{server → server-e2e}/sse-session.ts +0 -0
  365. /package/{server → server-e2e}/util.ts +0 -0
  366. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,84 @@
1
+ // The commits that release a repository, for its Pulse (repository-pulse.ts
2
+ // commitStats): one a tag names that changes no code, only package.json
3
+ // files (at least one, in any directory), the lockfiles beside them
4
+ // (RELEASE_LOCKFILES) and changelogs written in Markdown (RELEASE_CHANGELOG,
5
+ // a changeset's file among them), as `npm version` and a release's tooling
6
+ // leave one. What each commit changes is read as Replay reads it, kept by
7
+ // the commit (repository-changes.ts repositoryChanges).
8
+ import { type ChangesStore, type CommitChanges, repositoryChanges } from './repository-changes.ts'
9
+ import { githubGraphql } from './repository-history.ts'
10
+
11
+ // The tags read, a page a query, the latest commits' first, as many pages as
12
+ // TAG_PAGES; of the commits they name, as many as RELEASES_READ have their
13
+ // files read.
14
+ const TAG_PAGE = 100
15
+ const TAG_PAGES = 5
16
+ const RELEASES_READ = 200
17
+ // An annotated tag's target, its own target in turn, through as many as
18
+ // TAG_DEPTH annotated tags.
19
+ const TAG_DEPTH = 3
20
+ const SHA = /^[a-f\d]{40}$/u
21
+ const RELEASE_LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'])
22
+ const RELEASE_CHANGELOG = /^(?:changelog|changes|history|news|releases?)(?:[._-][^/]*)?\.(?:md|markdown)$/iu
23
+ const CHANGESET = /(?:^|\/)\.changeset\/[^/]+\.md$/iu
24
+
25
+ const tagTarget = (depth: number): string => `target { __typename oid ... on Commit { committedDate }${depth > 0 ? ` ... on Tag { ${tagTarget(depth - 1)} }` : ''} }`
26
+ const TAGS_QUERY = `query($owner: String!, $name: String!, $after: String) {
27
+ repository(owner: $owner, name: $name) {
28
+ refs(refPrefix: "refs/tags/", first: ${TAG_PAGE}, after: $after, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {
29
+ pageInfo { hasNextPage endCursor }
30
+ nodes { ${tagTarget(TAG_DEPTH)} }
31
+ }
32
+ }
33
+ }`
34
+ type TagTarget = { __typename?: unknown; oid?: unknown; committedDate?: unknown; target?: TagTarget | null } | null | undefined
35
+ type TagsPage = { repository?: { refs?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: ({ target?: TagTarget } | null)[] } | null } | null }
36
+
37
+ // The commit a tag names, through its annotated tags, and when it was
38
+ // committed; null for one naming no commit (a tree, a blob).
39
+ function taggedCommit(target: TagTarget, depth = TAG_DEPTH): { oid: string; at: number } | null {
40
+ if (target?.__typename === 'Tag' && depth > 0) return taggedCommit(target.target, depth - 1)
41
+ const at = typeof target?.committedDate === 'string' ? Date.parse(target.committedDate) : Number.NaN
42
+ return target?.__typename === 'Commit' && typeof target.oid === 'string' && SHA.test(target.oid) && Number.isSafeInteger(at) ? { oid: target.oid, at } : null
43
+ }
44
+
45
+ // The commits the repository's tags name committed since `since`, the
46
+ // latest first, as many pages of them as TAG_PAGES.
47
+ export async function taggedCommits(fullName: string, token: string, since: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<Set<string>> {
48
+ const [owner, name] = fullName.split('/') as [string, string]
49
+ const tagged = new Set<string>()
50
+ let after: string | null = null
51
+ for (let pages = 0; pages < TAG_PAGES; pages++) {
52
+ const page = await githubGraphql(TAGS_QUERY, { owner, name, after }, token, fetchImpl) as TagsPage | null
53
+ const refs = page?.repository?.refs
54
+ const commits = (refs?.nodes ?? []).flatMap(node => taggedCommit(node?.target) ?? [])
55
+ for (const commit of commits) if (commit.at >= since) tagged.add(commit.oid)
56
+ if (refs?.pageInfo?.hasNextPage !== true || typeof refs.pageInfo.endCursor !== 'string' || commits.some(commit => commit.at < since)) break
57
+ after = refs.pageInfo.endCursor
58
+ }
59
+ return tagged
60
+ }
61
+
62
+ // Whether a path is one a release changes: a package.json, a lockfile beside
63
+ // one, or a changelog in Markdown.
64
+ const releaseFile = (path: string) => {
65
+ const base = path.slice(path.lastIndexOf('/') + 1)
66
+ return base === 'package.json' || RELEASE_LOCKFILES.has(base) || RELEASE_CHANGELOG.test(base) || CHANGESET.test(path)
67
+ }
68
+
69
+ // Whether what a commit changes is a release's: a package.json at least, and
70
+ // nothing a release changes none of, a rename's both paths alike; one
71
+ // GitHub listed only some files of, not.
72
+ export function releaseChanges({ files, more }: CommitChanges): boolean {
73
+ const paths = files.flatMap(([path, status, from]) => status === 'R' ? [path, from] : [path])
74
+ return !more && paths.some(path => path.slice(path.lastIndexOf('/') + 1) === 'package.json') && paths.every(releaseFile)
75
+ }
76
+
77
+ // The commits since `since` that release the repository: those its tags
78
+ // name whose changes are a release's, each read as Replay reads it (`kept`),
79
+ // as many as RELEASES_READ, sorted.
80
+ export async function releaseCommits(fullName: string, token: string, since: number, kept: ChangesStore, fetchImpl: typeof fetch = globalThis.fetch): Promise<string[]> {
81
+ const tagged = [...await taggedCommits(fullName, token, since, fetchImpl)].slice(0, RELEASES_READ)
82
+ const changes = await repositoryChanges(fullName, token, tagged, kept, fetchImpl)
83
+ return tagged.filter(sha => { const read = changes.get(sha); return read ? releaseChanges(read) : false }).toSorted()
84
+ }
@@ -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, not formatted again (each line where the old side's was),
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,279 @@
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
+ // Sides `verified` to be the files the request names (renamedPairs) are read
183
+ // only where no answer is kept or on its way; others are read first, as
184
+ // reading them checks they are.
185
+ async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep: boolean, verified = false): Promise<Buffer> {
186
+ // Sides not kept are formatted again for each diff (pretty-print.ts), so
187
+ // they are read in its turn: the files a Diff view asks for at once wait
188
+ // here rather than being refused by the formats running. Within its minute
189
+ // too, its turn not held past it by a version's files still downloading.
190
+ if (!keep) {
191
+ const deadline = Date.now() + DIFF_TIMEOUT_MS
192
+ let sides: [string, string] = ['', '']
193
+ const diff = await inTurn(deadline, async () => {
194
+ sides = await byDeadline(texts(), deadline)
195
+ return inWorker(sides[0], sides[1], request, deadline)
196
+ })
197
+ return encoded(diff, request, sides)
198
+ }
199
+ // A file's type is part of how it is read: the same text as a script and as a module differ.
200
+ const key = `${request.base.hash}\0${request.base.extension}\0${request.other.hash}\0${request.other.extension}`
201
+ const held = () => {
202
+ const found = kept.get(key)
203
+ if (found) {
204
+ kept.delete(key)
205
+ kept.set(key, found)
206
+ }
207
+ return found ?? pending.get(key)
208
+ }
209
+ let answer = verified ? held() : undefined
210
+ if (!answer) {
211
+ const [before, after] = await texts()
212
+ answer = held()
213
+ if (!answer) {
214
+ const deadline = Date.now() + DIFF_TIMEOUT_MS
215
+ const job = inTurn(deadline, () => inWorker(before, after, request, deadline)).then(diff => encoded(diff, request, [before, after])).finally(() => pending.delete(key))
216
+ pending.set(key, answer = job)
217
+ }
218
+ }
219
+ const body = await answer
220
+ kept.set(key, body)
221
+ if (kept.size > KEPT_DIFFS) kept.delete(kept.keys().next().value!)
222
+ return body
223
+ }
224
+
225
+ const decompress = promisify(brotliDecompress)
226
+
227
+ // What a minifier renames, by its path's extension.
228
+ const renamable = ({ extension }: PrettyRequest) => RENAMABLE.has(extension)
229
+
230
+ // One of a comparison's pairs of files (renamedPairs), its sides named as a
231
+ // request names them, with its answer as renameDiffOf sends it, or why it has
232
+ // none, as a PrettyError tells it.
233
+ export type RenamedLine = { path: string; hash: string; otherPath: string; otherHash: string } & (Partial<RenameDiff> | { error: string; status: number })
234
+
235
+ // The files that changed between a comparison's two sides, as its viewer
236
+ // pairs them (ui/view/bundle-comparison.js prepareComparison): at one path as
237
+ // the other side lays them out, or renamed within a package, as the bundles
238
+ // record their packages; every file there, those that can be formatted by
239
+ // their texts and the rest by their hashes. A file the viewer pairs otherwise
240
+ // (its files are its resources too) it asks about alone.
241
+ export function comparedFiles(sides: readonly [ComparedSide, ComparedSide]) {
242
+ const [olds, news] = sides.map(({ files, hashes }) => {
243
+ const texts = new Map(files.map(file => [file.path, file.text]))
244
+ return new Map<string, unknown>([...hashes].map(([path, hash]) => [path, texts.get(path) ?? { format: 'digest', digest: hash }]))
245
+ }) as [Map<string, unknown>, Map<string, unknown>]
246
+ const layout = treeLayout(olds.keys(), news.keys())
247
+ const packageDirs = layoutPackageDirs(sides[0].packageDirs, sides[1].packageDirs, layout)
248
+ return computeBundleDiff(olds, news, path => bundlePkgOf(path, { packageDir: packageDirs?.get(path) }), layout).files
249
+ }
250
+
251
+ // The pairs of JavaScript files minified on both sides that changed between a
252
+ // comparison's two sides, paired as its viewer pairs them (comparedFiles),
253
+ // each diffed as renameDiffOf diffs it, from both sides' copies, kept or
254
+ // made, as they are iterated: in path order, one after another, leaving a
255
+ // worker to the files a viewer asks for alone (a pair it reads otherwise, one
256
+ // it shows first).
257
+ export function renamedPairs([base, other]: readonly [ComparedSide, ComparedSide], keep = true): AsyncGenerator<RenamedLine> {
258
+ const formattable = ({ files }: ComparedSide) => new Map(files.map(file => [file.path, file]))
259
+ const [olds, news] = [formattable(base), formattable(other)]
260
+ const pairs = comparedFiles([base, other]).changed.flatMap(({ path, basePath = path }) => {
261
+ const [from, to] = [olds.get(basePath), news.get(path)]
262
+ return from && to && from.hash !== to.hash && [from, to].every(file => renamable(file) && needsPrettyPrint(file.path, file.text)) ? [[from, to] as const] : []
263
+ }).toSorted(([, a], [, b]) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
264
+ return (async function* () {
265
+ for (const [from, to] of pairs) {
266
+ const request = { base: { path: from.path, hash: from.hash, extension: from.extension }, other: { path: to.path, hash: to.hash, extension: to.extension } }
267
+ const named = { path: from.path, hash: from.hash, otherPath: to.path, otherHash: to.hash }
268
+ const copy = async (side: ComparedSide, { text, ...file }: typeof from) => prettyText(await side.kept(file) ?? await side.made(file, text))
269
+ let body
270
+ try { body = await answerOf(request, async () => [await copy(base, from), await copy(other, to)], keep, true) }
271
+ catch (err) {
272
+ if (!(err instanceof PrettyError)) throw err
273
+ yield { ...named, error: err.message, status: err.status }
274
+ continue
275
+ }
276
+ yield { ...JSON.parse((await decompress(body)).toString('utf8')) as Partial<RenameDiff>, ...named }
277
+ }
278
+ })()
279
+ }
@@ -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-v10', 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: report.repoId == null ? null : await db.getRepoFullName(report.repoId) }
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>