@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) 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 +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -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 +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2KBF5TZ7.js +2 -0
  72. package/out/chunk-2XUEZHIP.js +28 -0
  73. package/out/chunk-35VE6CDH.js +2 -0
  74. package/out/chunk-3BZ7JERB.js +1 -0
  75. package/out/chunk-4F6JSF57.js +3 -0
  76. package/out/chunk-4XCDFGDP.js +10 -0
  77. package/out/chunk-AQ75QITE.js +1 -0
  78. package/out/chunk-B2RQY6OP.js +2 -0
  79. package/out/chunk-C4Z6ZBXV.js +10 -0
  80. package/out/chunk-CMQA4DM6.js +1 -0
  81. package/out/chunk-CVVJAXVY.js +2 -0
  82. package/out/chunk-DIJJNTRH.js +10 -0
  83. package/out/chunk-GJ4IUCNS.js +1 -0
  84. package/out/chunk-GNXOKIGP.js +80 -0
  85. package/out/chunk-GZ4MS3TE.js +13 -0
  86. package/out/chunk-IISEF2BZ.js +1 -0
  87. package/out/chunk-IU3CCINR.js +3 -0
  88. package/out/chunk-K3X5EJBQ.js +2 -0
  89. package/out/chunk-KEX2ATEW.js +2 -0
  90. package/out/chunk-KYCNJVB7.js +1 -0
  91. package/out/chunk-MYDDTA7W.js +225 -0
  92. package/out/chunk-NRPC74AM.js +1 -0
  93. package/out/chunk-OOJM4CTU.js +1 -0
  94. package/out/chunk-PNHBZGDD.js +1 -0
  95. package/out/chunk-R5O6FAW4.js +2 -0
  96. package/out/chunk-RFZQQXSG.js +17 -0
  97. package/out/chunk-RSHMVYNS.js +1 -0
  98. package/out/chunk-TGANXVTA.js +1 -0
  99. package/out/chunk-TUII34K2.js +1 -0
  100. package/out/chunk-TZ5KB2Y5.js +10 -0
  101. package/out/chunk-UN5ZSYLE.js +1 -0
  102. package/out/chunk-UNTRGE26.js +2 -0
  103. package/out/chunk-UXNUAIIC.js +33 -0
  104. package/out/chunk-UYAXVRVL.js +1 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XYDGU6BM.js +51 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +297 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +358 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +142 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +219 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/specs.js +124 -0
  154. package/risk/npm/tarball.js +113 -0
  155. package/server-common/database-config.ts +16 -0
  156. package/server-common/initialize.ts +18 -0
  157. package/server-common/npm-advisories.ts +101 -0
  158. package/{server → server-common}/origin.ts +5 -5
  159. package/server-common/reap.ts +48 -0
  160. package/server-common/scan-config.ts +19 -0
  161. package/server-common/standalone.ts +29 -0
  162. package/server-common/storage-crypto.ts +182 -0
  163. package/server-common/storage-log.ts +38 -0
  164. package/server-common/vercel-blob.ts +103 -0
  165. package/server-e2e/app.ts +486 -0
  166. package/{server → server-e2e}/auth.ts +5 -1
  167. package/{server → server-e2e}/bus-receiver.ts +2 -1
  168. package/{server → server-e2e}/cli.js +9 -4
  169. package/{server → server-e2e}/config.ts +56 -35
  170. package/{server → server-e2e}/db-neon.ts +2 -2
  171. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  172. package/{server → server-e2e}/db-stmt.ts +2 -2
  173. package/{server → server-e2e}/db.ts +7 -7
  174. package/{server → server-e2e}/http.ts +116 -18
  175. package/{server → server-e2e}/hub.ts +40 -9
  176. package/server-e2e/index.ts +17 -0
  177. package/server-e2e/lifecycle.ts +95 -0
  178. package/{server → server-e2e}/neon-driver.ts +2 -2
  179. package/{server → server-e2e}/npm-proxy.ts +12 -145
  180. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  181. package/{server → server-e2e}/objstore/blob.ts +1 -1
  182. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  183. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  184. package/{server → server-e2e}/objstore/init.ts +35 -18
  185. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  186. package/server-e2e/objstore/rest-deny.ts +28 -0
  187. package/server-e2e/objstore/rest-mint.ts +224 -0
  188. package/{server → server-e2e}/objstore/rest.ts +27 -26
  189. package/{server → server-e2e}/objstore/sign.ts +105 -0
  190. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  191. package/{server → server-e2e}/objstore/store.ts +7 -7
  192. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  193. package/{server → server-e2e}/pubsub.ts +12 -9
  194. package/{server → server-e2e}/sign.ts +4 -4
  195. package/{server → server-e2e}/sse-server.ts +102 -68
  196. package/{server → server-e2e}/sse-session.ts +28 -8
  197. package/{server → server-e2e}/static.ts +31 -21
  198. package/server-e2e/sync-handlers.ts +408 -0
  199. package/{server → server-e2e}/ws-server.ts +20 -9
  200. package/server-managed/activity.ts +245 -0
  201. package/server-managed/avatar-store.ts +12 -0
  202. package/server-managed/blob-store.ts +17 -0
  203. package/server-managed/brotli.ts +10 -0
  204. package/server-managed/bundle-advisories.ts +120 -0
  205. package/server-managed/bundle-advisory-inventory.ts +77 -0
  206. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  207. package/server-managed/bundle-build-diagnostics.js +23 -0
  208. package/server-managed/bundle-build-leases.ts +41 -0
  209. package/server-managed/bundle-build-worker.js +111 -0
  210. package/server-managed/bundle-build.ts +198 -0
  211. package/server-managed/bundle-cache.ts +199 -0
  212. package/server-managed/bundle-catalog.ts +29 -0
  213. package/server-managed/bundle-commits.ts +140 -0
  214. package/server-managed/bundle-store.ts +24 -0
  215. package/server-managed/bundle-summary-cache.ts +130 -0
  216. package/server-managed/bundle.ts +120 -0
  217. package/server-managed/cache-storage.ts +19 -0
  218. package/server-managed/cli.js +21 -0
  219. package/server-managed/combined.ts +46 -0
  220. package/server-managed/comments.ts +151 -0
  221. package/server-managed/config.ts +158 -0
  222. package/server-managed/content-access.ts +15 -0
  223. package/server-managed/crypto.ts +25 -0
  224. package/server-managed/db-methods.ts +1725 -0
  225. package/server-managed/db-neon.ts +367 -0
  226. package/server-managed/db-schema.ts +227 -0
  227. package/server-managed/db-table-names.ts +22 -0
  228. package/server-managed/db.ts +132 -0
  229. package/server-managed/dependency-reviews.ts +248 -0
  230. package/server-managed/github-app.ts +391 -0
  231. package/server-managed/github-errors.ts +68 -0
  232. package/server-managed/github-issue-links.ts +127 -0
  233. package/server-managed/github-issue-oauth.ts +47 -0
  234. package/server-managed/github-issues.ts +152 -0
  235. package/server-managed/github-metadata.ts +213 -0
  236. package/server-managed/github-oauth.ts +235 -0
  237. package/server-managed/github-pulls.ts +227 -0
  238. package/server-managed/http-response.ts +50 -0
  239. package/server-managed/http.ts +3472 -0
  240. package/server-managed/import-triage.ts +51 -0
  241. package/server-managed/index.ts +187 -0
  242. package/server-managed/kept.ts +28 -0
  243. package/server-managed/link-reports.ts +158 -0
  244. package/server-managed/locked-packages.ts +85 -0
  245. package/server-managed/lru.ts +30 -0
  246. package/server-managed/managed-issues.ts +75 -0
  247. package/server-managed/management-catalog.ts +45 -0
  248. package/server-managed/management.ts +99 -0
  249. package/server-managed/npm-insights.ts +440 -0
  250. package/server-managed/npm-kept.ts +65 -0
  251. package/server-managed/npm-loads.ts +167 -0
  252. package/server-managed/npm-manifest.ts +115 -0
  253. package/server-managed/npm-packages.ts +378 -0
  254. package/server-managed/npm-provenance.ts +123 -0
  255. package/server-managed/npm-risk.ts +177 -0
  256. package/server-managed/object-storage-disk.ts +148 -0
  257. package/server-managed/object-storage-vercel.ts +67 -0
  258. package/server-managed/object-storage.ts +74 -0
  259. package/server-managed/package-entry-points.ts +51 -0
  260. package/server-managed/pretty-print.ts +352 -0
  261. package/server-managed/public-workspace.ts +220 -0
  262. package/server-managed/rename-diff-worker.js +26 -0
  263. package/server-managed/rename-diff.ts +270 -0
  264. package/server-managed/repo-path.ts +21 -0
  265. package/server-managed/report-migration.ts +35 -0
  266. package/server-managed/report-query.ts +10 -0
  267. package/server-managed/report-response.ts +16 -0
  268. package/server-managed/report-sources.ts +178 -0
  269. package/server-managed/report-store.ts +47 -0
  270. package/server-managed/repository-aliases.ts +91 -0
  271. package/server-managed/repository-browser.ts +153 -0
  272. package/server-managed/repository-discovery.ts +82 -0
  273. package/server-managed/repository-policy.ts +27 -0
  274. package/server-managed/repository-risk.ts +418 -0
  275. package/server-managed/revisions.ts +35 -0
  276. package/server-managed/session.ts +92 -0
  277. package/server-managed/slugs.ts +38 -0
  278. package/server-managed/solidity-entry-points.ts +69 -0
  279. package/server-managed/sql-postgres.ts +31 -0
  280. package/server-managed/sql.ts +76 -0
  281. package/server-managed/static.ts +28 -0
  282. package/server-managed/storage-command.ts +16 -0
  283. package/server-managed/storage-db.ts +175 -0
  284. package/server-managed/storage-encryption.ts +279 -0
  285. package/server-managed/storage-maintenance.ts +256 -0
  286. package/server-managed/storage-payload.ts +103 -0
  287. package/server-managed/storage-stores.ts +85 -0
  288. package/server-managed/storage.ts +78 -0
  289. package/server-managed/team-app.ts +94 -0
  290. package/server-managed/team-catalog.ts +7 -0
  291. package/server-managed/team-feed.ts +173 -0
  292. package/server-managed/team-npm-scopes.ts +77 -0
  293. package/server-managed/team-reports.ts +228 -0
  294. package/server-managed/triage-response.ts +16 -0
  295. package/server-managed/type-only-worker.js +19 -0
  296. package/server-managed/type-only.ts +103 -0
  297. package/server-managed/uploads.ts +86 -0
  298. package/server-managed/upstream-cache.ts +215 -0
  299. package/server-managed/view-as.ts +58 -0
  300. package/server-managed/workspace-shares.ts +166 -0
  301. package/server.ts +50 -0
  302. package/server/index.ts +0 -477
  303. package/server/lifecycle.ts +0 -204
  304. package/server/sync-handlers.ts +0 -320
  305. /package/{LICENSE → LICENSE-MIT} +0 -0
  306. /package/{server → server-e2e}/config.example.json +0 -0
  307. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  308. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  309. /package/{server → server-e2e}/peer.ts +0 -0
  310. /package/{server → server-e2e}/util.ts +0 -0
  311. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,81 @@
1
+ import { npmMalwareAdvisory } from '../risk/npm/malware.js'
2
+
3
+ export const REVIEW_VERDICTS = {
4
+ accept: 'Accept',
5
+ 'accept-with-nits': 'Accept with nits (non-blocking)',
6
+ 'request-changes': 'Request changes',
7
+ reject: 'Reject',
8
+ }
9
+
10
+ // Keep non-text files in the diff and its denominator. Verdicts for them are
11
+ // disabled for now, including when either side of a change is non-text.
12
+ export function isReviewableFile(before, after) {
13
+ return [before, after].every(content => content === undefined || typeof content === 'string')
14
+ && [before, after].some(content => typeof content === 'string')
15
+ }
16
+
17
+ // Publication saves all changed paths and their non-text subset. Null means
18
+ // an older snapshot without type evidence: it cannot certify full coverage.
19
+ export function summarizeReview(entries, files, nonTextFiles = []) {
20
+ const verdicts = Object.keys(REVIEW_VERDICTS)
21
+ const excluded = new Set(nonTextFiles ?? [])
22
+ const checks = entries.filter(entry => nonTextFiles !== null && !excluded.has(entry.file) && verdicts.includes(entry.status))
23
+ const verdictLimit = verdicts.findLast(verdict => checks.some(entry => entry.status === verdict)) ?? 'accept'
24
+ if (files === null) return { coverage: null, checkedFiles: null, totalFiles: null, verdictLimit }
25
+ const checked = new Set(checks.map(entry => entry.file))
26
+ const checkedFiles = files.filter(file => checked.has(file)).length
27
+ return { coverage: checkedFiles === files.length ? 'full' : 'partial', checkedFiles, totalFiles: files.length, verdictLimit }
28
+ }
29
+
30
+ export function allowedReviewVerdicts(entries, limit = 'accept') {
31
+ const verdicts = Object.keys(REVIEW_VERDICTS)
32
+ return verdicts.slice(Math.max(verdicts.indexOf(limit), verdicts.indexOf(summarizeReview(entries, null).verdictLimit)))
33
+ }
34
+
35
+ // npm's security placeholder, identified by its provenance as well as its name.
36
+ export function npmTakenDown({ npm: { version, manifest = {} } }) {
37
+ const npms = manifest.description === 'security holding package' || manifest.github?.github?.toLowerCase() === 'npm/security-holder'
38
+ return npms && (manifest.publisher === 'npm' || /-security(?:\.\d+)?$/u.test(version))
39
+ }
40
+
41
+ // Only findings affecting the version being reviewed constrain its verdict.
42
+ // Socket's aggregate scores also fall for other reasons; use explicit alerts.
43
+ // Alert identifiers: https://docs.socket.dev/docs/rule-conditions
44
+ export function npmReviewVerdictLimit(npm, advisoryList, socket, risk) {
45
+ if (!npm) return 'accept'
46
+ const index = advisoryList?.versions?.indexOf(npm.version) ?? -1
47
+ const advisories = index < 0 ? [] : advisoryList.advisories.filter(advisory => advisory.affected.includes(index))
48
+ const alerts = npm.private ? [] : socket?.alerts ?? []
49
+ if (risk?.findings?.some(finding => finding.id === 'malware') || npmTakenDown({ npm }) || alerts.some(alert => alert.type === 'malware')
50
+ || advisories.some(advisory => npmMalwareAdvisory(advisory) || advisory.id?.startsWith('MAL-') || /^Malicious package\b/iu.test(advisory.title ?? ''))) return 'reject'
51
+ if (advisories.some(advisory => advisory.severity === 'critical') || alerts.some(alert => alert.type === 'criticalCVE')) return 'request-changes'
52
+ return 'accept'
53
+ }
54
+
55
+ export function needsPackageJsonReview(entries, files, nonTextFiles = []) {
56
+ return summarizeReview(entries, files, nonTextFiles).coverage === 'partial'
57
+ && (nonTextFiles === null || nonTextFiles.includes('package.json')
58
+ || !entries.some(entry => entry.file === 'package.json' && Object.keys(REVIEW_VERDICTS).includes(entry.status)))
59
+ }
60
+
61
+ const markdownText = text => text.replaceAll(/[\\`*_{}[\]<>]/gu, '\\$&').replaceAll(/\r\n?/gu, '\n').replaceAll('\n', '\n ')
62
+
63
+ export function parseReviewNotes(comment) {
64
+ return comment.replaceAll(/\r\n?/gu, '\n').split(/(?:^|\n)[\t ]*\*[\t ]+/u)
65
+ .map((text, index) => ({ text: text.trim(), bullet: index > 0 })).filter(note => note.text)
66
+ }
67
+
68
+ function auditNotes(comment, prefix = '') {
69
+ return parseReviewNotes(comment).map(note => `* ${prefix}${note.text.replaceAll('\n', '\n ')}`)
70
+ }
71
+
72
+ export function auditMarkdown(review) {
73
+ if (review.publishedAt === null || review.revokedAt !== null || review.coverage !== 'full'
74
+ || !['accept', 'accept-with-nits'].includes(review.status)) return null
75
+ const target = `${review.package} ${review.version} ${review.integrity}`
76
+ const dependency = review.basePackage === null ? target
77
+ : review.basePackage === review.package ? `${review.package} ${review.baseVersion} -> ${review.version} ${review.integrity}`
78
+ : `${review.basePackage} ${review.baseVersion} -> ${target}`
79
+ const notes = [...review.entries.filter(entry => entry.status).flatMap(entry => auditNotes(entry.comment, `In ${markdownText(entry.file)}: `)), ...auditNotes(review.comment)]
80
+ return `Audited:\n\`\`\`\n${dependency}\n\`\`\`${notes.length > 0 ? `\n\nNotes:\n${notes.join('\n')}` : ''}`
81
+ }
@@ -0,0 +1,58 @@
1
+ import { effectiveSeverity, revalidateKindOf } from '@preventive/report'
2
+ import { recordRevalidationCopies } from './revalidation-conflicts.js'
3
+
4
+ // Gap-fill a derived finding only within a mergeable row. Raw report copies
5
+ // remain intact so App and code modes can use different row partitions.
6
+ // Provenance stays with the first copy; corrected severity keeps per-report
7
+ // variants. Missing/null fields are gaps, never disagreements.
8
+ const KEEPS_ITS_OWN = new Set(['correctedSeverity', 'correctedSeverityReason', 'isApp', 'isUpstream', 'isSecurity', 'source'])
9
+
10
+ // Contradictory revalidate* answers disable App mode for this loaded set.
11
+ // Retain the first answer on the derived copy; the conflict dialog exposes all
12
+ // answers instead of presenting that arbitrary choice as the pass's verdict.
13
+ export function mergeDuplicateFields(survivor, dup) {
14
+ if (!survivor || !dup || survivor === dup) return false
15
+ let conflicted = false
16
+ for (const [key, value] of Object.entries(dup)) {
17
+ if (key.startsWith('_') || KEEPS_ITS_OWN.has(key)) continue
18
+ if (value === undefined || value === null) continue
19
+ // The stamp is compared as the app READS it, not as the file
20
+ // wrote it: the reader trims and case-folds, and answers "no
21
+ // stamp" for anything it doesn't recognise (@preventive/report/src/finding.js
22
+ // revalidateKindOf). So `confirmed` and ` Confirmed ` agree, and
23
+ // a value the app can't read is no answer at all — it neither
24
+ // blocks the other copy's real stamp from landing nor takes the
25
+ // layer off a whole workspace for a typo.
26
+ if (key === 'revalidate') {
27
+ const theirs = revalidateKindOf(dup)
28
+ if (!theirs) continue
29
+ const mine = revalidateKindOf(survivor)
30
+ if (!mine) survivor[key] = value
31
+ else if (mine !== theirs) conflicted = true
32
+ continue
33
+ }
34
+ const own = survivor[key]
35
+ if (own === undefined || own === null) { survivor[key] = value; continue }
36
+ // The pass's prose either side of the stamp, compared past the
37
+ // whitespace two writers can differ on for the same words.
38
+ if (key.startsWith('revalidate') && String(own).trim() !== String(value).trim()) conflicted = true
39
+ }
40
+ return conflicted
41
+ }
42
+
43
+ // The row merger also retains the evidence behind a conflict. This must
44
+ // run for every duplicate, including agreeing copies and missing-field fills,
45
+ // so the eventual dialog can name the reports that actually supplied a value.
46
+ export function mergeReportDuplicateFields(survivor, dup, reportName, conflicts) {
47
+ if (!survivor || !dup || survivor === dup) return
48
+ if (survivor.correctedSeverity || dup.correctedSeverity) {
49
+ survivor._correctedByReport ??= {
50
+ [survivor._reportName ?? '']: { severity: effectiveSeverity(survivor), reason: survivor.correctedSeverityReason },
51
+ }
52
+ survivor._correctedByReport[reportName] = { severity: effectiveSeverity(dup), reason: dup.correctedSeverityReason }
53
+ }
54
+ const copies = recordRevalidationCopies(survivor, dup, reportName)
55
+ if (mergeDuplicateFields(survivor, dup) || conflicts.has(survivor.id)) {
56
+ conflicts.set(survivor.id, { finding: survivor, copies })
57
+ }
58
+ }
@@ -0,0 +1,274 @@
1
+ // What the confidence range, the Confirmed outcome and a workspace's basic
2
+ // App view mean for a set of rows. Pure: the view passes its current lens
3
+ // (filters.js), and the managed server classifies team workspaces with it.
4
+ import { displayedSeverity, revalidateKindOf } from '@preventive/report'
5
+
6
+ // `tabKey` identifies an individual tab (= finding) across the view.
7
+ export function tabKey(f) { return f.id ?? String(f._id) }
8
+
9
+ // Does this row carry a judgement the pass made ABOUT a finding —
10
+ // any stamp but `revalidation`, which names the pass's own row and
11
+ // judges nothing. Raw for the same reason as the two above: it gates
12
+ // the switch, and a gate that stopped seeing the stamps the moment
13
+ // the layer came off would take the way back with it.
14
+ export function hasRevalidateStamp(f) {
15
+ const kind = revalidateKindOf(f)
16
+ return kind !== '' && kind !== 'revalidation'
17
+ }
18
+
19
+ // What the toolbar dropdown offers, in the order it lists them —
20
+ // answers to "did the pass leave this standing", running from yes to
21
+ // no. Fewer options than the field has values:
22
+ //
23
+ // * the `revalidation` row rides CONFIRMED — it is the pass itself,
24
+ // re-examining a finding it did not knock down, which is the same
25
+ // answer to that question;
26
+ // * `partial` rides it too. A partial confirmation is a yes to
27
+ // "does this still stand" — the pass narrowed the finding rather
28
+ // than knocking it down — and an option of its own would slice
29
+ // the standing findings in two for a distinction the reader wants
30
+ // the STAMP for, not a filter. It keeps its own stamp on the card.
31
+ // * `unknown` gets no option — a pass that couldn't tell hasn't
32
+ // answered it at all, so there is nothing to filter to. Those rows
33
+ // stay visible with no filter on, like every other row.
34
+ export const REVALIDATE_FILTERS = [
35
+ { value: 'confirmed', label: 'Confirmed', kinds: ['confirmed', 'partial', 'revalidation'] },
36
+ { value: 'unreachable', label: 'Unreachable', kinds: ['unreachable'] },
37
+ { value: 'refuted', label: 'Refuted', kinds: ['refuted'] },
38
+ ]
39
+
40
+ // The kinds one dropdown value covers, or null when the value names no
41
+ // option — filters.js reads that as "no filter" rather than hiding
42
+ // every finding behind a value it can't interpret.
43
+ export function revalidateFilterKinds(value) {
44
+ return REVALIDATE_FILTERS.find((o) => o.value === value)?.kinds ?? null
45
+ }
46
+
47
+ // How finely Confirmed is drawn, once it has taken the partial rows
48
+ // in. The toolbar cycles a chip through these inside the Confirmed row
49
+ // (revalidate-filter.js), because "did this survive" and "how
50
+ // completely" are one question asked twice, not two filters — and the
51
+ // second only has an answer once the first is Confirmed.
52
+ export const PARTIAL_MODES = ['', 'exclude', 'only']
53
+
54
+ // The kinds an outcome selection ACTUALLY matches, with that switch
55
+ // applied. All three are the same shape as every other filter here —
56
+ // a list of kinds, matched existentially over the group — so the chip
57
+ // narrows what Confirmed reaches rather than subtracting from it:
58
+ //
59
+ // '' everything the pass left standing: the row that IS the
60
+ // pass, the full confirmations, the partial ones;
61
+ // 'exclude' the full confirmations only. NOT "everything but the
62
+ // partials": a group is shown for carrying a `confirmed`
63
+ // row, not for lacking a `partial` one, so the pass row
64
+ // on its own no longer stands in for a verdict;
65
+ // 'only' the partial ones.
66
+ //
67
+ // It bites only on an option that took the partial rows in —
68
+ // Confirmed — so a mode left set from an earlier selection can't
69
+ // silently narrow Refuted or Unreachable, and callers can pass it
70
+ // through without checking which option is up.
71
+ export function activeRevalidateKinds(value, partialMode) {
72
+ const kinds = revalidateFilterKinds(value)
73
+ if (!kinds?.includes('partial')) return kinds
74
+ if (partialMode === 'only') return ['partial']
75
+ if (partialMode === 'exclude') return ['confirmed']
76
+ return kinds
77
+ }
78
+
79
+ // A finding's place on the 0—10 confidence scale, or undefined when it
80
+ // has none. The one reader every confidence question goes through:
81
+ // what the range matches, what the auto-tune counts, and whether the
82
+ // control is offered at all (render.js hasAnyConfidence).
83
+ //
84
+ // Three ways to have a place:
85
+ //
86
+ // * a `confidence` the analyzer scored it with;
87
+ // * `critical: true` — the boolean, NOT `severity: 'critical'` —
88
+ // which stands in for a top score;
89
+ // * coming from another producer at all. An import carries no
90
+ // confidence because its producer doesn't emit one (Claude
91
+ // Security's parser reads none), not because anyone was unsure —
92
+ // there is no doubt here for a floor to act on. Reading it as 10
93
+ // keeps it on screen under any floor and out only under a cap
94
+ // that excludes the top: `8—10` and `2—10` show it, `0—5` and
95
+ // `7—9` don't. It also stops one imported report from taking the
96
+ // range away from a workspace: an unscored finding DISABLES the
97
+ // control for everyone (render.js), which left the range filtering
98
+ // nothing and every low-confidence row on screen.
99
+ //
100
+ // A row the pass knocked down reads as 0 instead, where that matters
101
+ // (voidsConfidence) — applied by the callers, since whether a finding
102
+ // has a place at all is about the finding, not about the pass.
103
+ export function confidenceOnScale(f) {
104
+ if (f.confidence !== undefined) return f.confidence
105
+ if (f.critical === true || f._source) return 10
106
+ return undefined
107
+ }
108
+
109
+ // Does this finding put itself on the scale, rather than ride the
110
+ // stand-in confidenceOnScale hands a row that carries no score? A
111
+ // number its producer wrote, or the `critical: true` that stands in
112
+ // for one.
113
+ const scoresItself = (f) => f.confidence !== undefined || f.critical === true
114
+
115
+ // Is the confidence range a live control over these rows — offered,
116
+ // and actually filtering? Two conditions, which the toolbar reads as
117
+ // one (render.js hasAnyConfidence):
118
+ //
119
+ // * every row has a place on the scale, or the first lift off 0
120
+ // would silently drop the ones that don't. A single analyzer
121
+ // finding with no confidence and no `critical: true` disables the
122
+ // control for the whole set;
123
+ // * something on screen puts ITSELF on that scale. A row riding the
124
+ // stand-in doesn't: a set of nothing but unscored imports is all
125
+ // 10s by definition, and a range over one value says nothing.
126
+ // There the control isn't disabled, it isn't offered at all — the
127
+ // toolbar drops the whole block when the outcome dropdown beside
128
+ // it has nothing to offer either.
129
+ //
130
+ // What answers this is the SCORE, not who wrote it. Asked as "not
131
+ // an import" it gave the same answer for every producer that emits
132
+ // no confidence — and the wrong one for DeepSec, which rates every
133
+ // finding it reports and whose words this app places on the scale
134
+ // itself (@preventive/report/src/parse-deepsec.js). A workspace of nothing but
135
+ // DeepSec reports is a real range over real numbers, and asking
136
+ // for a non-import took the slider, the confidence sort and the
137
+ // opening floor away from exactly the load whose producer had
138
+ // scored every row in it.
139
+ //
140
+ // Both halves are asked of the rows ON SCREEN by every caller, since
141
+ // this is about a control in front of a reader.
142
+ export function rangeApplies(groups) {
143
+ return groups.every((g) => g.every((f) => confidenceOnScale(f) !== undefined))
144
+ && groups.some((g) => g.some(scoresItself))
145
+ }
146
+
147
+ // Does the confidence floor leave this group on screen? The rule
148
+ // matchesFilters applies (filters.js), with the upper bound at 10 so only the
149
+ // floor bites, hoisted to the group because that is the unit the list
150
+ // shows: a group is on screen when ANY of its rows clears the floor —
151
+ // an unscored row only at floor 0 unless it is flagged `critical`, and
152
+ // a row the pass knocked down reading as 0 whatever number it carries.
153
+ function showsAtConfidence(g, min, kindOf) {
154
+ return g.some((f) => {
155
+ const conf = ['refuted', 'unreachable'].includes(kindOf(f)) ? 0 : confidenceOnScale(f)
156
+ return conf === undefined ? min === 0 : conf >= min
157
+ })
158
+ }
159
+
160
+ // The confidence floor a freshly-loaded set should OPEN on, tuned so
161
+ // the initial view fits ~25 groups. Step up 6 → 7 → 8 until the
162
+ // visible count is within budget; cap at 8 (the old static default).
163
+ // Nothing carrying a confidence at all means no floor — without that
164
+ // guard countAtMin(6) = 0 ≤ 25 lands it at 6, which then excludes
165
+ // every finding; 0 lets the filter no-op instead, and the toolbar
166
+ // hides the control anyway (see toolbarHtml in render.js).
167
+ //
168
+ // After picking the base, walk DOWN while each lower step surfaces no
169
+ // new groups — i.e. there's a "gap" in the confidence distribution
170
+ // below the chosen floor. Lowering for free puts the slider at the
171
+ // natural break: e.g. picked 8, nothing at 7 or 6 but some at 5 →
172
+ // settle at 6 (the lowest step revealing nothing new). Down to 0.
173
+ //
174
+ // Pure in its argument, and paired with defaultRevalidateFilter below:
175
+ // together they are "what does this set open on", asked by ingest.js
176
+ // on a first load and again by the App switch (events.js), which
177
+ // reshapes the set and so has to ask again rather than keep an answer
178
+ // that was about a different one.
179
+ export function defaultConfidenceFloor(groups, tabs) {
180
+ // App mode folds the source tabs a revalidation pass already answered
181
+ // under its App tab. Those hidden copies must not lower the opening floor
182
+ // for a workspace that otherwise has the same visible rows as its App
183
+ // report. Keep source-only rows intact; they are real rows in the App view.
184
+ const visible = groups.map(tabs).filter((g) => g.length > 0)
185
+ if (!visible.some((g) => g.some((f) => confidenceOnScale(f) !== undefined))) return 0
186
+ const countAtMin = (min) => visible.reduce((n, g) =>
187
+ n + (g.some((f) => (confidenceOnScale(f) ?? -1) >= min) ? 1 : 0), 0)
188
+ let base
189
+ if (countAtMin(6) <= 25) base = 6
190
+ else if (countAtMin(7) <= 25) base = 7
191
+ else base = 8
192
+ while (base > 0 && countAtMin(base - 1) === countAtMin(base)) base--
193
+ return base
194
+ }
195
+
196
+ // The floor the default view will REALLY apply. The range is a
197
+ // whole-set control: one finding on screen with no confidence and no
198
+ // `critical: true` and render.js disables it and resets the bounds to
199
+ // 0—10 (hasAnyConfidence there), so the auto-tuned floor never bites
200
+ // and every row is on screen — an unscored finding is not hidden by a
201
+ // filter that isn't running. Measuring the comparison below against a
202
+ // floor the view is about to throw away would be measuring a screen
203
+ // nobody sees.
204
+ //
205
+ // `critical: true` rides the 10 bucket in place of a score
206
+ // (matchesFilters), so it doesn't block the range any more than a
207
+ // number does.
208
+ //
209
+ // render.js asks this of the on-screen bucket and we ask it of the
210
+ // whole set; at open time, before anything is triaged away, they are
211
+ // the same groups.
212
+ function effectiveFloor(groups, confMin) {
213
+ return rangeApplies(groups) ? confMin : 0
214
+ }
215
+
216
+ // Both the opening default and the dropdown lock use the same coverage:
217
+ // findings represented by Confirmed rows, plus findings explicitly ruled out.
218
+ // Read the original groups before `tabs` folds away their source members.
219
+ export function confirmedCoverage(groups, tabs, kindOf) {
220
+ // A workspace may carry an un-stamped source copy from report A while
221
+ // report B explicitly ruled out the same id. The ruled-out copy is
222
+ // removed before workspace rows are merged, so it cannot vouch for its
223
+ // source copy during the coverage check below. Remember those ids before
224
+ // that projection disappears: Confirmed may hide the source copy too,
225
+ // even when the App finding that caused the ruling has no
226
+ // `revalidateInputs` list of its own.
227
+ const covered = new Set(groups.ruledOutIds ?? [])
228
+ for (const raw of groups) {
229
+ for (const f of raw) if (['refuted', 'unreachable'].includes(kindOf(f))) covered.add(tabKey(f))
230
+ }
231
+ for (const raw of groups) {
232
+ const g = tabs(raw)
233
+ if (g.length === 0) continue
234
+ if (!g.some((f) => activeRevalidateKinds('confirmed', '').includes(
235
+ kindOf(f) || (f._source && !f._sourcePass ? 'revalidation' : '')))) continue
236
+ // A folded source copy is not a cost of Confirmed, but its id is still
237
+ // represented by the App row. Keep all raw members in the coverage set
238
+ // so a duplicate source-only row from another report is recognized as
239
+ // the same issue rather than making Confirmed look lossy.
240
+ for (const f of raw) {
241
+ covered.add(tabKey(f))
242
+ // App reports can carry the source findings they represent only as
243
+ // `revalidateInputs`, rather than as members of the same physical row.
244
+ // Those inputs are still present in the App row's visible result, so a
245
+ // duplicate source-only row from another report must not keep the
246
+ // workspace on Confidence.
247
+ if (f.isApp && Array.isArray(f.revalidateInputs)) {
248
+ for (const id of f.revalidateInputs) if (id) covered.add(id)
249
+ }
250
+ }
251
+ }
252
+ return covered
253
+ }
254
+
255
+ export function confirmedIsDefault(visible, confMin, covered, kindOf) {
256
+ const floor = effectiveFloor(visible, confMin)
257
+ const shown = visible.filter((g) => showsAtConfidence(g, floor, kindOf))
258
+ if (shown.length === 0) return false
259
+ const kinds = new Set(activeRevalidateKinds('confirmed', ''))
260
+ if (!visible.some((g) => g.some((f) => kinds.has(kindOf(f))))) return false
261
+ return shown.every((g) => g.every((f) => covered.has(tabKey(f))))
262
+ }
263
+
264
+ // The same coverage test evaluates a workspace's basic App view without the
265
+ // reader's current lens: the view and the managed server pass the lens to use.
266
+ export function canLockConfirmed(groups, { tabs, kindOf, severityMode }) {
267
+ const visible = groups.map(tabs).filter((g) => g.length > 0)
268
+ const covered = confirmedCoverage(groups, tabs, kindOf)
269
+ if (!confirmedIsDefault(visible, defaultConfidenceFloor(groups, tabs), covered, kindOf)) return false
270
+ const floor = effectiveFloor(visible, 6)
271
+ return visible.every((g) => !showsAtConfidence(g, floor, kindOf)
272
+ || !g.some((f) => displayedSeverity(f, severityMode) !== 'low')
273
+ || g.every((f) => covered.has(tabKey(f))))
274
+ }
@@ -0,0 +1,47 @@
1
+ // Session-local finding ids (see `client/triage.js`'s SESSION_ID_RE:
2
+ // purely numeric `_id` fallbacks, handed out by an in-memory counter
3
+ // for findings whose id couldn't be derived) are re-assigned on every
4
+ // load, so a link built on one would point at an arbitrary other
5
+ // finding after a reload — including in the SENDER's own tab. Those are
6
+ // refused up front rather than silently mis-resolving. Everything else
7
+ // the app treats as persistent — the analyzer's uuids, the deterministic
8
+ // uuids `@preventive/report/src/finding-id.js` derives, and the codex importer's
9
+ // finding-URL ids — is linkable, which is why this is a
10
+ // "not session-local" test rather than a uuid-shape test.
11
+ const SESSION_ID_RE = /^\d+$/u
12
+
13
+ // Control characters can't appear in a uuid, a URL, or an OPFS filename
14
+ // the app will hand out, so their presence means a mangled / hand-crafted
15
+ // fragment. Rejecting them keeps a stray `\n` out of an `alert()` and out
16
+ // of the `[data-gid]` selector the reveal path builds. A scan rather than
17
+ // a character-class regex: matching control characters in a literal is a
18
+ // lint error (`no-control-regex`), and spelling the range out in code is
19
+ // clearer than the escaped equivalent anyway.
20
+ function hasControlChar(value) {
21
+ for (let i = 0; i < value.length; i++) {
22
+ const code = value.codePointAt(i)
23
+ if (code < 0x20 || code === 0x7f) return true
24
+ }
25
+ return false
26
+ }
27
+
28
+ // Per-component cap. The longest legitimate value is a codex finding-URL
29
+ // id; report names and workspace ids no longer travel in the fragment at
30
+ // all. The cap exists so a hostile fragment can't push a megabyte of
31
+ // text through `decodeURIComponent` + the "couldn't find it" alert.
32
+ export const MAX_FINDING_ID_LENGTH = 512
33
+
34
+ function isUsablePart(value) {
35
+ return typeof value === 'string'
36
+ && value.length > 0
37
+ && value.length <= MAX_FINDING_ID_LENGTH
38
+ && !hasControlChar(value)
39
+ }
40
+
41
+ // Whether a finding id survives a reload, i.e. whether a link built on
42
+ // it means anything tomorrow. Callers use this to decide whether to
43
+ // OFFER a link at all (the per-finding Link button hides itself for a
44
+ // session-local id) rather than handing out one that silently rots.
45
+ export function isLinkableFindingId(id) {
46
+ return isUsablePart(id) && !SESSION_ID_RE.test(id)
47
+ }
@@ -0,0 +1 @@
1
+ export function newIssueLabels(isSecurity?: boolean, configured?: string): string[]
@@ -0,0 +1,11 @@
1
+ // Shared by GitHub's prefilled form and managed API issue creation.
2
+ export function newIssueLabels(isSecurity = false, configured = '') {
3
+ const seen = new Set()
4
+ return ['deepview', ...(isSecurity ? ['security'] : []), ...configured.split(',')]
5
+ .map(label => label.trim()).filter(label => {
6
+ const key = label.toLowerCase()
7
+ if (!label || seen.has(key)) return false
8
+ seen.add(key)
9
+ return true
10
+ })
11
+ }
@@ -0,0 +1,56 @@
1
+ const MAX_PULL_REQUEST_URL = 2048
2
+
3
+ export interface PullRequestRef { repo: string; number: number }
4
+ export type GithubFixRef = PullRequestRef & { kind: 'pull' | 'issue' }
5
+ export type GithubFixStatus = 'open' | 'draft' | 'closed' | 'merged'
6
+ export type GithubIssueClosedReason = 'completed' | 'not_planned' | 'duplicate' | 'unknown'
7
+ export type GithubFixResult = { url: string } & (
8
+ { title: string; description: string | null; status: GithubFixStatus; stateReason: GithubIssueClosedReason | null } | { error: 'unavailable' }
9
+ )
10
+
11
+ // Unknown closure reasons must never imply that an issue was completed.
12
+ export function githubIssueClosedReason(value: unknown): GithubIssueClosedReason {
13
+ return value === 'completed' || value === 'not_planned' || value === 'duplicate' ? value : 'unknown'
14
+ }
15
+
16
+ export function isGithubRepoName(value: string): boolean {
17
+ const [owner, repo, extra] = value.split('/')
18
+ return extra === undefined && owner !== undefined && repo !== undefined
19
+ && owner.length <= 39 && /^[a-zA-Z\d](?:-?[a-zA-Z\d])*$/u.test(owner)
20
+ && repo.length <= 100 && repo !== '.' && repo !== '..' && /^[a-zA-Z\d._-]+$/u.test(repo)
21
+ }
22
+
23
+ // Only identify the repository and PR number. This never supplies an API URL:
24
+ // the server must match a team repository and use its stored full name.
25
+ function parseGithubNumberedUrl(value: unknown, path: RegExp): PullRequestRef | null {
26
+ if (typeof value !== 'string' || value.length > MAX_PULL_REQUEST_URL) return null
27
+ let url: URL
28
+ try { url = new URL(value) } catch { return null }
29
+ if (url.href !== value || url.origin !== 'https://github.com' || url.username || url.password) return null
30
+ const match = path.exec(url.pathname)
31
+ if (!match) return null
32
+ const repo = `${match[1]}/${match[2]}`
33
+ const number = Number(match[3])
34
+ if (!isGithubRepoName(repo) || !Number.isSafeInteger(number) || number <= 0) return null
35
+ return { repo, number }
36
+ }
37
+
38
+ export function parseGithubPrUrl(value: unknown): PullRequestRef | null {
39
+ return parseGithubNumberedUrl(value, /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/(?:files|commits|checks))?\/?$/u)
40
+ }
41
+
42
+ export function parseGithubIssueUrl(value: unknown): PullRequestRef | null {
43
+ return parseGithubNumberedUrl(value, /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/u)
44
+ }
45
+
46
+ export function parseGithubFixUrl(value: unknown): GithubFixRef | null {
47
+ const pr = parseGithubPrUrl(value)
48
+ if (pr) return { ...pr, kind: 'pull' }
49
+ const issue = parseGithubIssueUrl(value)
50
+ return issue ? { ...issue, kind: 'issue' } : null
51
+ }
52
+
53
+ // Browser-only deduplication key; never used to construct a server API call.
54
+ export function githubFixKey(ref: GithubFixRef): string {
55
+ return `${ref.repo.toLowerCase()}#${ref.kind}:${ref.number}`
56
+ }
package/common/gzip.js ADDED
@@ -0,0 +1,59 @@
1
+ // Gzip helpers built on the platform CompressionStream /
2
+ // DecompressionStream. Centralised so the half-dozen call sites
3
+ // (storage, triage / workspace export + import, sync-crypto) share
4
+ // one implementation of the Blob → stream → Response plumbing instead
5
+ // of hand-copying it. Bytes in / bytes out is the primitive; the text
6
+ // helpers run the bytes through `common/utf8.js` so the same
7
+ // fatal-on-invalid / BOM-preserving decode rules apply everywhere.
8
+ //
9
+ // `deflate` (raw, no gzip framing) is intentionally NOT covered here —
10
+ // `triage.js` uses it for its own at-rest blob and must stay distinct
11
+ // from the gzip-magic sniffing the bundles / import paths rely on.
12
+
13
+ import { decodeUtf8, encodeUtf8 } from './utf8.js'
14
+
15
+ export async function gzipBytes(bytes) {
16
+ const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip'))
17
+ return new Uint8Array(await new Response(stream).arrayBuffer())
18
+ }
19
+
20
+ // `maxBytes` (optional) caps the DECOMPRESSED size: gzip expands up to
21
+ // ~1032:1, so a small hostile payload can balloon to GiBs and OOM the
22
+ // process before any content validation runs. Callers decompressing
23
+ // peer-controlled bytes (sync-crypto's inbound changesets) pass a cap;
24
+ // the read then aborts with a RangeError the moment the budget is crossed,
25
+ // instead of materialising the full expansion. Uncapped callers keep
26
+ // the one-shot Response read.
27
+ export async function gunzipBytes(bytes, { maxBytes } = {}) {
28
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))
29
+ if (maxBytes == null) return new Uint8Array(await new Response(stream).arrayBuffer())
30
+ const reader = stream.getReader()
31
+ const chunks = []
32
+ let total = 0
33
+ for (;;) {
34
+ const { value, done } = await reader.read()
35
+ if (done) break
36
+ total += value.length
37
+ if (total > maxBytes) {
38
+ // Cancel so the DecompressionStream stops inflating the rest.
39
+ try { await reader.cancel() } catch {}
40
+ throw new RangeError(`gunzipBytes: decompressed size exceeds ${maxBytes}-byte cap`)
41
+ }
42
+ chunks.push(value)
43
+ }
44
+ const out = new Uint8Array(total)
45
+ let offset = 0
46
+ for (const chunk of chunks) {
47
+ out.set(chunk, offset)
48
+ offset += chunk.length
49
+ }
50
+ return out
51
+ }
52
+
53
+ export async function gzipText(text) {
54
+ return await gzipBytes(encodeUtf8(text))
55
+ }
56
+
57
+ export async function gunzipToText(bytes) {
58
+ return decodeUtf8(await gunzipBytes(bytes))
59
+ }
@@ -0,0 +1,8 @@
1
+ // Type declarations for `common/line-diff.js`, for `.ts` callers.
2
+
3
+ // Lines `a[a0..a1)` replaced by `b[b0..b1)`.
4
+ export interface ChangeBlock { a0: number; a1: number; b0: number; b1: number }
5
+
6
+ export function textLines(text: string): string[]
7
+ export function changeBlocks(before: string, after: string, ignoreWhitespace?: boolean): ChangeBlock[]
8
+ export function blocksHold(before: string, after: string, blocks: unknown, ignoreWhitespace?: boolean): boolean