@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +6 -2
- package/common/bundle-metadata.js +63 -20
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-source-links.js +7 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +3 -1
- package/common/bundle-sources.js +47 -19
- package/common/code-language.js +16 -2
- package/common/default-scan-models.ts +2 -1
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/github-pr.ts +1 -1
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +1 -5
- package/common/managed/routes.js +123 -12
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +1 -1
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-5FKRIJGF.js +1 -0
- package/out/chunk-6Z5XLSPJ.js +1 -0
- package/out/chunk-ABW5FVSN.js +1 -0
- package/out/chunk-BJKRVROW.js +1 -0
- package/out/chunk-BYUEDJOM.js +7 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DCQX2J3C.js +223 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-F4OMDNEY.js +1 -0
- package/out/chunk-FU4MTW7F.js +1 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GMFXFEUA.js +2 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-HIV7SVI7.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-JAKWIZWL.js +2 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OJ5DGP5E.js +33 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-SD2WJAXN.js +48 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZPD5R5RP.js +3 -0
- package/out/client-managed-import.js +3 -87
- package/out/client-managed.js +26 -338
- package/out/client-sync.js +8 -14
- package/out/graph.js +17 -30
- package/out/index.html +34 -0
- package/out/prism.js +6 -2
- package/out/terminal.js +241 -257
- package/out/view.css +1 -1
- package/out/view.js +76 -520
- package/package.json +88 -7
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +164 -0
- package/risk/files/runs-code.js +329 -0
- package/risk/files/script.js +112 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +222 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +33 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-e2e/http.ts +7 -7
- package/server-e2e/npm-proxy.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
- package/server-e2e/objstore/handlers.ts +1 -1
- package/server-e2e/objstore/tokens.ts +1 -1
- package/server-e2e/sign.ts +1 -1
- package/server-managed/bundle-advisories.ts +68 -11
- package/server-managed/bundle-cache.ts +37 -4
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-summary-cache.ts +24 -10
- package/server-managed/config.ts +4 -0
- package/server-managed/db-methods.ts +98 -11
- package/server-managed/db-neon.ts +39 -4
- package/server-managed/db-schema.ts +14 -2
- package/server-managed/db-table-names.ts +1 -1
- package/server-managed/db.ts +7 -3
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-issue-oauth.ts +5 -6
- package/server-managed/github-metadata.ts +115 -0
- package/server-managed/github-oauth.ts +38 -2
- package/server-managed/http-response.ts +26 -0
- package/server-managed/http.ts +943 -52
- package/server-managed/index.ts +11 -5
- package/server-managed/kept.ts +28 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +78 -7
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/report-sources.ts +13 -2
- package/server-managed/repository-browser.ts +19 -5
- package/server-managed/repository-risk.ts +413 -0
- package/server-managed/session.ts +16 -2
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage-encryption.ts +5 -3
- package/server-managed/storage-payload.ts +4 -0
- package/server-managed/storage-stores.ts +2 -0
- package/server-managed/storage.ts +6 -3
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-feed.ts +1 -1
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +25 -4
- package/server-managed/upstream-cache.ts +147 -5
- package/server-managed/view-as.ts +58 -0
|
@@ -5,15 +5,19 @@ import type { ManagedHttpDeps } from './http.ts'
|
|
|
5
5
|
import type { ManagedBundle } from './db.ts'
|
|
6
6
|
import type { OpenedBlob } from './blob-store.ts'
|
|
7
7
|
import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
|
|
8
|
-
import { sendJson, writeResponse } from './http-response.ts'
|
|
8
|
+
import { sendJson, sendLines, writeResponse } from './http-response.ts'
|
|
9
9
|
import { hashToken } from './crypto.ts'
|
|
10
10
|
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, teamReportVisibility } from './team-reports.ts'
|
|
11
11
|
import { triageWireEntry } from './triage-response.ts'
|
|
12
12
|
import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
13
13
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
14
14
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
15
|
-
import { auditCache } from './upstream-cache.ts'
|
|
15
|
+
import { auditCache, auditedRepos } from './upstream-cache.ts'
|
|
16
16
|
import { serveTeamFeed } from './team-feed.ts'
|
|
17
|
+
import { type ComparedSide, PrettyError, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
18
|
+
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
19
|
+
import { typeOnlyFiles } from './type-only.ts'
|
|
20
|
+
import { sharedTeamApp } from './team-app.ts'
|
|
17
21
|
|
|
18
22
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
19
23
|
sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
|
|
@@ -31,7 +35,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
31
35
|
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
32
36
|
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
|
|
33
37
|
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
34
|
-
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
|
|
38
|
+
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only)$/u.exec(url.pathname)
|
|
35
39
|
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
36
40
|
const tokenHash = hashToken(token)
|
|
37
41
|
const snapshot = await deps.db.getWorkspaceShare(tokenHash)
|
|
@@ -42,8 +46,8 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
42
46
|
}
|
|
43
47
|
const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
|
|
44
48
|
const sendTeam = async () => {
|
|
45
|
-
const summaries = await bundleSummaries(snapshot.team.bundles, deps.bundleCache)
|
|
46
|
-
await send({ user: snapshot.user, team: { ...snapshot.team,
|
|
49
|
+
const [summaries, app] = await Promise.all([bundleSummaries(snapshot.team.bundles, deps.bundleCache), sharedTeamApp(deps.db, deps.reportStore, snapshot)])
|
|
50
|
+
await send({ user: snapshot.user, team: { ...snapshot.team, app,
|
|
47
51
|
bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
|
|
48
52
|
} })
|
|
49
53
|
await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
|
|
@@ -97,9 +101,63 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
97
101
|
const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
|
|
98
102
|
if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
|
|
99
103
|
if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
|
|
104
|
+
if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
|
|
105
|
+
if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
|
|
106
|
+
await serveCompared(res, deps, bundle, snapshot.bundles.find(item => item.id === url.searchParams.get('other')), bundleRoute![2], url, recheck, stream); return
|
|
107
|
+
}
|
|
100
108
|
await serveBundle(res, deps, bundle, bundleRoute![2]!, url, recheck, stream)
|
|
101
109
|
}
|
|
102
110
|
|
|
111
|
+
// What a bundle is compared with `other`, another of the link's bundles, by:
|
|
112
|
+
// one of their JavaScript files diffed with renamed names hidden, or the
|
|
113
|
+
// files that changed between them, or the pairs of those, or those that
|
|
114
|
+
// changed in types alone, a line each.
|
|
115
|
+
function serveCompared(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, part: string, url: URL,
|
|
116
|
+
recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
117
|
+
if (part === 'rename-diff' && url.searchParams.has('path')) return serveRenameDiff(res, deps, bundle, other, url, stream)
|
|
118
|
+
return serveChanges(res, deps, bundle, other, recheck, part === 'pretty' ? compared : part === 'type-only' ? typeOnlyFiles : renamedPairs,
|
|
119
|
+
part === 'pretty' ? '' : url.searchParams.get('scope') ?? '')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// One of a bundle's JavaScript files diffed with one of `other`, another of
|
|
123
|
+
// the link's bundles (rename-diff.ts).
|
|
124
|
+
async function serveRenameDiff(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, url: URL,
|
|
125
|
+
stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
126
|
+
const request = renameDiffRequest(url.searchParams)
|
|
127
|
+
if (!request) { json(res, 400, { error: 'bad-file' }); return }
|
|
128
|
+
if (!other) { json(res, 404, { error: 'no-bundle' }); return }
|
|
129
|
+
const { prettyCache } = deps
|
|
130
|
+
if (!prettyCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
131
|
+
let diff
|
|
132
|
+
try {
|
|
133
|
+
diff = await renameDiffOf(request, async () => [await prettyText(await prettyCache.bundle(bundle, request.base)),
|
|
134
|
+
await prettyText(await prettyCache.bundle(other, request.other))])
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
|
|
137
|
+
throw err
|
|
138
|
+
}
|
|
139
|
+
await stream(diff, 'br')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The files that changed between a bundle and `other`, another of the link's
|
|
143
|
+
// bundles, both sides' pretty-printed (pretty-print.ts compared), or the pairs
|
|
144
|
+
// of them its viewer diffs with renamed names hidden (rename-diff.ts
|
|
145
|
+
// renamedPairs), those within a reason `scope` where given, the link checked
|
|
146
|
+
// again before each line.
|
|
147
|
+
async function serveChanges(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, recheck: () => Promise<void>,
|
|
148
|
+
linesOf: (sides: [ComparedSide, ComparedSide]) => AsyncIterable<unknown>, scope: string) {
|
|
149
|
+
if (!other) { json(res, 404, { error: 'no-bundle' }); return }
|
|
150
|
+
if (!deps.prettyCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
151
|
+
let lines
|
|
152
|
+
try { lines = linesOf(await deps.prettyCache.bundleSides(bundle, other, scope)) }
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
|
|
155
|
+
throw err
|
|
156
|
+
}
|
|
157
|
+
await recheck()
|
|
158
|
+
await sendLines(res, lines, () => recheck().then(() => true, () => false))
|
|
159
|
+
}
|
|
160
|
+
|
|
103
161
|
async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, part: string, url: URL,
|
|
104
162
|
recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
105
163
|
if (part === 'download') {
|
|
@@ -107,6 +165,18 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
|
|
|
107
165
|
if (!stored) { json(res, 503, { error: 'unavailable' }); return }
|
|
108
166
|
await stream(stored, bundle.kind === 'sourcemap' ? 'br' : null, 'application/octet-stream'); return
|
|
109
167
|
}
|
|
168
|
+
if (part === 'pretty') {
|
|
169
|
+
const file = prettyRequest(url.searchParams)
|
|
170
|
+
if (!file) { json(res, 400, { error: 'bad-file' }); return }
|
|
171
|
+
if (!deps.prettyCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
172
|
+
let pretty
|
|
173
|
+
try { pretty = await deps.prettyCache.bundle(bundle, file) }
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
|
|
176
|
+
throw err
|
|
177
|
+
}
|
|
178
|
+
await stream(pretty, 'br', 'text/plain; charset=utf-8'); return
|
|
179
|
+
}
|
|
110
180
|
if (!deps.bundleCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
111
181
|
if (part === 'advisories') {
|
|
112
182
|
if (bundle.kind !== 'stasis') { json(res, 422, { error: 'unsupported-bundle' }); return }
|
|
@@ -116,9 +186,10 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
|
|
|
116
186
|
if (inventory === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
|
|
117
187
|
if (Buffer.byteLength(JSON.stringify(inventory)) > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
|
|
118
188
|
const signal = AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)
|
|
189
|
+
const repoAdvisories = url.searchParams.get('repoAdvisories') === 'true'
|
|
119
190
|
const result = await fetchBundleAdvisories(inventory.packages, signal, {
|
|
120
|
-
debug: deps.config.debug, repoAdvisories
|
|
121
|
-
cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug),
|
|
191
|
+
debug: deps.config.debug, repoAdvisories, details: url.searchParams.get('details') === 'true',
|
|
192
|
+
cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
|
|
122
193
|
})
|
|
123
194
|
await recheck()
|
|
124
195
|
json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Compare's diff with renamed names hidden (rename-diff.ts), worked out off the
|
|
2
|
+
// server's event loop by @preventive/compare's renamedDiffs: the old side given
|
|
3
|
+
// the new side's names, formatted again where that changes its layout alone,
|
|
4
|
+
// diffed with whitespace changes shown and hidden, and the blocks checked to
|
|
5
|
+
// take it to the new side. `{ before, blocks, reason, reads, marks,
|
|
6
|
+
// ignoringWhitespace }`: the old side as diffed, the change blocks, why the
|
|
7
|
+
// old side is as it was where its names could not be given safely, the
|
|
8
|
+
// properties through which either side reads names (a view marks renamed
|
|
9
|
+
// lines rather than hiding them where any are read, as a rename may then
|
|
10
|
+
// change what the code does), and the blocks of the diff of the files as they
|
|
11
|
+
// are that differ in renamed names alone; `ignoringWhitespace` the blocks and
|
|
12
|
+
// those marked with whitespace changes hidden, as diff -b hides them. Blocks
|
|
13
|
+
// that would not take the old side to the new are thrown, and so answer
|
|
14
|
+
// nothing.
|
|
15
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
16
|
+
import { renamedDiffs } from '@preventive/compare/names.js'
|
|
17
|
+
|
|
18
|
+
const { before, after, extension, otherExtension } = workerData
|
|
19
|
+
const diff = await renamedDiffs(before, after, { extension, otherExtension, whitespace: ['none', 'change'] })
|
|
20
|
+
// A check that should never fail did, or a step gave way: told, the file shown with its names.
|
|
21
|
+
for (const warning of diff.warnings) console.warn(`managed: rename diff: ${warning}`)
|
|
22
|
+
const [shown, hidden] = diff.diffs
|
|
23
|
+
const answer = { before: diff.before, blocks: shown.blocks, reason: diff.reason, reads: diff.reads, marks: shown.marks, ignoringWhitespace: { blocks: hidden.blocks, marks: hidden.marks } }
|
|
24
|
+
// Node worker messages have no browser target origin.
|
|
25
|
+
// oxlint-disable-next-line unicorn/require-post-message-target-origin
|
|
26
|
+
parentPort.postMessage(answer)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Compare's diff of a JavaScript file minified on both sides with the names a
|
|
2
|
+
// minifier renamed alike hidden (GET /api/bundles/:id/rename-diff, GET
|
|
3
|
+
// /api/npm/rename-diff): both sides pretty-printed, as the pretty-print toggle
|
|
4
|
+
// has them (pretty-print.ts), then diffed by @preventive/compare's
|
|
5
|
+
// renamedDiffs in a worker, off the event loop and two at a time per process,
|
|
6
|
+
// with whitespace changes shown and hidden both. Answered as Brotli-encoded
|
|
7
|
+
// JSON, `{ before, blocks, reason, reads, marks, ignoringWhitespace }`
|
|
8
|
+
// (rename-diff-worker.js), the two files' hashes, `hash` and `otherHash`, and
|
|
9
|
+
// those of the two copies diffed, `prettyHash` and `otherPrettyHash`, which
|
|
10
|
+
// the viewer checks are those it asked about and draws: one file's asked for
|
|
11
|
+
// by both its sides' paths and hashes, or every pair a comparison's viewer
|
|
12
|
+
// diffs so, a line each (renamedPairs). The last few answers are kept in
|
|
13
|
+
// memory by both files' hashes, a private npm version's never, each side read
|
|
14
|
+
// again through the pretty-print cache first, which checks that its source
|
|
15
|
+
// holds a file of that hash. Access is the caller's to check.
|
|
16
|
+
import { Buffer } from 'node:buffer'
|
|
17
|
+
import { Readable } from 'node:stream'
|
|
18
|
+
import { promisify } from 'node:util'
|
|
19
|
+
import { Worker } from 'node:worker_threads'
|
|
20
|
+
import { brotliDecompress } from 'node:zlib'
|
|
21
|
+
import { layoutPackageDirs, treeLayout } from '@preventive/compare'
|
|
22
|
+
import { computeFileHash } from '@preventive/report'
|
|
23
|
+
import { FILE_HASH } from '../common/bundle-metadata.js'
|
|
24
|
+
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
25
|
+
import { bundlePkgOf } from '../common/bundle-pkg-of.js'
|
|
26
|
+
import { needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
|
|
27
|
+
import { type ChangeBlock, blocksHold, textLines } from '../common/line-diff.js'
|
|
28
|
+
import type { OpenedBlob } from './blob-store.ts'
|
|
29
|
+
import { encodeBrotli } from './brotli.ts'
|
|
30
|
+
import { type ComparedSide, PrettyError, type PrettyRequest, prettyText } from './pretty-print.ts'
|
|
31
|
+
|
|
32
|
+
// What a minifier renames: JavaScript, as the viewer offers it for.
|
|
33
|
+
const RENAMABLE = new Set(['js', 'mjs', 'cjs'])
|
|
34
|
+
// Diffs worked out at once per process, each in a worker of its own, and
|
|
35
|
+
// those waiting their turn past them (a Diff view asks for all it shows at
|
|
36
|
+
// once), any more refused.
|
|
37
|
+
const MAX_ACTIVE_DIFFS = 2
|
|
38
|
+
const MAX_WAITING_DIFFS = 16
|
|
39
|
+
// The longest a diff may take, its wait for a worker included, and the most
|
|
40
|
+
// text it is asked of: both sides pretty-printed, which the largest files
|
|
41
|
+
// formatted (MAX_PRETTY_BYTES) stay under.
|
|
42
|
+
const DIFF_TIMEOUT_MS = 60_000
|
|
43
|
+
const MAX_DIFF_CHARS = 64 * 1024 * 1024
|
|
44
|
+
// Answers kept, the one read last last: each holds the old side as diffed.
|
|
45
|
+
// As many as a comparison's viewer may ask for beside the pairs it has come
|
|
46
|
+
// (renamedPairs), so that neither is worked out twice.
|
|
47
|
+
const KEPT_DIFFS = 32
|
|
48
|
+
|
|
49
|
+
// `blocks` and `marks` with whitespace changes shown, `ignoringWhitespace`'s
|
|
50
|
+
// hidden as diff -b hides them.
|
|
51
|
+
export interface RenameDiff {
|
|
52
|
+
before: string; blocks: ChangeBlock[]; reason: string | null; reads: string[]; marks: ChangeBlock[]
|
|
53
|
+
ignoringWhitespace: { blocks: ChangeBlock[]; marks: ChangeBlock[] }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RenameDiffRequest { base: PrettyRequest; other: PrettyRequest }
|
|
57
|
+
|
|
58
|
+
// The two files a request names, a base's `path` and `hash` and the other
|
|
59
|
+
// side's `otherPath` and `otherHash`, or null where either is no JavaScript
|
|
60
|
+
// file named by its hash.
|
|
61
|
+
export function renameDiffRequest(params: URLSearchParams): RenameDiffRequest | null {
|
|
62
|
+
const side = (path: string, hash: string): PrettyRequest | null => {
|
|
63
|
+
const extension = prettyExtension(path)
|
|
64
|
+
return extension !== null && RENAMABLE.has(extension) && FILE_HASH.test(hash) ? { path, hash, extension } : null
|
|
65
|
+
}
|
|
66
|
+
const base = side(params.get('path') ?? '', params.get('hash') ?? ''), other = side(params.get('otherPath') ?? '', params.get('otherHash') ?? '')
|
|
67
|
+
return base && other ? { base, other } : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let activeDiffs = 0
|
|
71
|
+
const waiting: Array<() => void> = []
|
|
72
|
+
const kept = new Map<string, Buffer>()
|
|
73
|
+
const pending = new Map<string, Promise<Buffer>>()
|
|
74
|
+
|
|
75
|
+
// A worker's turn, waited for behind those running until `deadline`; given
|
|
76
|
+
// up, it goes to the next one waiting.
|
|
77
|
+
async function turn(deadline: number): Promise<void> {
|
|
78
|
+
if (activeDiffs < MAX_ACTIVE_DIFFS) { activeDiffs++; return }
|
|
79
|
+
if (waiting.length >= MAX_WAITING_DIFFS) throw new PrettyError(429, 'diff-busy')
|
|
80
|
+
await new Promise<void>((resolve, reject) => {
|
|
81
|
+
const next = () => { clearTimeout(timer); resolve() }
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
waiting.splice(waiting.indexOf(next), 1)
|
|
84
|
+
reject(new PrettyError(504, 'diff-timeout'))
|
|
85
|
+
}, deadline - Date.now())
|
|
86
|
+
waiting.push(next)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
function done() {
|
|
90
|
+
const next = waiting.shift()
|
|
91
|
+
if (next) next()
|
|
92
|
+
else activeDiffs--
|
|
93
|
+
}
|
|
94
|
+
async function inTurn<T>(deadline: number, work: () => Promise<T>): Promise<T> {
|
|
95
|
+
await turn(deadline)
|
|
96
|
+
try { return await work() } finally { done() }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Whether a worker's answer is one as rename-diff-worker.js gives it, its
|
|
100
|
+
// blocks taking the old side it answered to the new one asked of, whitespace
|
|
101
|
+
// changes shown and hidden: checked again here, apart from the worker, as an
|
|
102
|
+
// answer that is not would hide a change.
|
|
103
|
+
function answers(diff: unknown, original: string, after: string): diff is RenameDiff {
|
|
104
|
+
const { before, blocks, reason, reads, marks, ignoringWhitespace } = (diff ?? {}) as Partial<RenameDiff>
|
|
105
|
+
return typeof before === 'string' && (reason === null || typeof reason === 'string') && Array.isArray(reads) && reads.every(read => typeof read === 'string')
|
|
106
|
+
&& blocksHold(before, after, blocks, false) && marksFit(original, after, marks)
|
|
107
|
+
&& blocksHold(before, after, ignoringWhitespace?.blocks, true) && marksFit(original, after, ignoringWhitespace?.marks)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Whether marked blocks (rename-diff-worker.js) are blocks of the diff of the
|
|
111
|
+
// two files as they are: in order, apart, within both, each as many lines on
|
|
112
|
+
// either side.
|
|
113
|
+
function marksFit(before: string, after: string, marks: unknown): boolean {
|
|
114
|
+
if (!Array.isArray(marks)) return false
|
|
115
|
+
const [olds, news] = [textLines(before).length, textLines(after).length]
|
|
116
|
+
let i = 0, j = 0
|
|
117
|
+
for (const mark of marks as Partial<ChangeBlock>[]) {
|
|
118
|
+
const { a0, a1, b0, b1 } = mark ?? {}
|
|
119
|
+
if (![a0, a1, b0, b1].every(Number.isInteger)) return false
|
|
120
|
+
const [x0, x1, y0, y1] = [a0, a1, b0, b1] as [number, number, number, number]
|
|
121
|
+
if (x0 < i || y0 < j || x1 <= x0 || x1 - x0 !== y1 - y0 || x1 > olds || y1 > news) return false
|
|
122
|
+
i = x1
|
|
123
|
+
j = y1
|
|
124
|
+
}
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Settled once the worker is gone, so that its turn is given up only then.
|
|
129
|
+
function inWorker(before: string, after: string, { base, other }: RenameDiffRequest, deadline: number): Promise<RenameDiff> {
|
|
130
|
+
if (before.length + after.length > MAX_DIFF_CHARS) throw new PrettyError(413, 'diff-too-large')
|
|
131
|
+
let worker: Worker
|
|
132
|
+
try {
|
|
133
|
+
worker = new Worker(new URL('./rename-diff-worker.js', import.meta.url), {
|
|
134
|
+
workerData: { before, after, extension: base.extension, otherExtension: other.extension }, execArgv: [],
|
|
135
|
+
resourceLimits: { maxOldGenerationSizeMb: 1024 },
|
|
136
|
+
})
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.warn('managed: a rename diff could not start:', err)
|
|
139
|
+
throw new PrettyError(503, 'unavailable')
|
|
140
|
+
}
|
|
141
|
+
return new Promise<RenameDiff>((resolve, reject) => {
|
|
142
|
+
let diff: RenameDiff | null = null, failure = new PrettyError(422, 'undiffable')
|
|
143
|
+
const timer = setTimeout(() => { failure = new PrettyError(504, 'diff-timeout'); void worker.terminate() }, deadline - Date.now())
|
|
144
|
+
worker.once('message', (answer: unknown) => {
|
|
145
|
+
if (answers(answer, before, after)) diff = answer
|
|
146
|
+
else console.warn('managed: a rename diff answered what does not take its old side to the new')
|
|
147
|
+
void worker.terminate()
|
|
148
|
+
})
|
|
149
|
+
worker.once('error', err => { console.warn('managed: a rename diff failed:', err) })
|
|
150
|
+
// Gone: answered, run out of memory, or terminated.
|
|
151
|
+
worker.once('exit', () => {
|
|
152
|
+
clearTimeout(timer)
|
|
153
|
+
if (diff) resolve(diff)
|
|
154
|
+
else reject(failure)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// `work` given until `deadline`: past it, answered 504 and left to finish on its own.
|
|
160
|
+
function byDeadline<T>(work: Promise<T>, deadline: number): Promise<T> {
|
|
161
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
162
|
+
const late = new Promise<never>((_, reject) => { timer = setTimeout(() => { reject(new PrettyError(504, 'diff-timeout')) }, deadline - Date.now()) })
|
|
163
|
+
return Promise.race([work, late]).finally(() => { clearTimeout(timer) })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// An answer as sent, naming the files asked about and the pretty-printed
|
|
167
|
+
// copies of them it was worked out from.
|
|
168
|
+
async function encoded(diff: RenameDiff, { base, other }: RenameDiffRequest, [before, after]: [string, string]): Promise<Buffer> {
|
|
169
|
+
const [prettyHash, otherPrettyHash] = await Promise.all([computeFileHash(before), computeFileHash(after)])
|
|
170
|
+
return encodeBrotli(Buffer.from(JSON.stringify({ ...diff, hash: base.hash, otherHash: other.hash, prettyHash, otherPrettyHash })))
|
|
171
|
+
}
|
|
172
|
+
const opened = (body: Buffer): OpenedBlob => ({ size: body.byteLength, stream: Readable.from([body]) })
|
|
173
|
+
|
|
174
|
+
// The answer for the two files a request names, Brotli-encoded, `texts`
|
|
175
|
+
// reading both sides pretty-printed (and so checking each is its source's):
|
|
176
|
+
// kept by their hashes where `keep`, readers asking for one meanwhile sharing
|
|
177
|
+
// it. A minute for it, its wait for a worker included.
|
|
178
|
+
export async function renameDiffOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep = true): Promise<OpenedBlob> {
|
|
179
|
+
return opened(await answerOf(request, texts, keep))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep: boolean): Promise<Buffer> {
|
|
183
|
+
// Sides not kept are formatted again for each diff (pretty-print.ts), so
|
|
184
|
+
// they are read in its turn: the files a Diff view asks for at once wait
|
|
185
|
+
// here rather than being refused by the formats running. Within its minute
|
|
186
|
+
// too, its turn not held past it by a version's files still downloading.
|
|
187
|
+
if (!keep) {
|
|
188
|
+
const deadline = Date.now() + DIFF_TIMEOUT_MS
|
|
189
|
+
let sides: [string, string] = ['', '']
|
|
190
|
+
const diff = await inTurn(deadline, async () => {
|
|
191
|
+
sides = await byDeadline(texts(), deadline)
|
|
192
|
+
return inWorker(sides[0], sides[1], request, deadline)
|
|
193
|
+
})
|
|
194
|
+
return encoded(diff, request, sides)
|
|
195
|
+
}
|
|
196
|
+
const [before, after] = await texts()
|
|
197
|
+
// A file's type is part of how it is read: the same text as a script and as a module differ.
|
|
198
|
+
const key = `${request.base.hash}\0${request.base.extension}\0${request.other.hash}\0${request.other.extension}`
|
|
199
|
+
const found = kept.get(key)
|
|
200
|
+
if (found) {
|
|
201
|
+
kept.delete(key)
|
|
202
|
+
kept.set(key, found)
|
|
203
|
+
return found
|
|
204
|
+
}
|
|
205
|
+
let job = pending.get(key)
|
|
206
|
+
if (!job) {
|
|
207
|
+
const deadline = Date.now() + DIFF_TIMEOUT_MS
|
|
208
|
+
pending.set(key, job = inTurn(deadline, () => inWorker(before, after, request, deadline)).then(diff => encoded(diff, request, [before, after])).finally(() => pending.delete(key)))
|
|
209
|
+
}
|
|
210
|
+
const body = await job
|
|
211
|
+
kept.set(key, body)
|
|
212
|
+
if (kept.size > KEPT_DIFFS) kept.delete(kept.keys().next().value!)
|
|
213
|
+
return body
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const decompress = promisify(brotliDecompress)
|
|
217
|
+
|
|
218
|
+
// What a minifier renames, by its path's extension.
|
|
219
|
+
const renamable = ({ extension }: PrettyRequest) => RENAMABLE.has(extension)
|
|
220
|
+
|
|
221
|
+
// One of a comparison's pairs of files (renamedPairs), its sides named as a
|
|
222
|
+
// request names them, with its answer as renameDiffOf sends it, or why it has
|
|
223
|
+
// none, as a PrettyError tells it.
|
|
224
|
+
export type RenamedLine = { path: string; hash: string; otherPath: string; otherHash: string } & (Partial<RenameDiff> | { error: string; status: number })
|
|
225
|
+
|
|
226
|
+
// The files that changed between a comparison's two sides, as its viewer
|
|
227
|
+
// pairs them (ui/view/bundle-comparison.js prepareComparison): at one path as
|
|
228
|
+
// the other side lays them out, or renamed within a package, as the bundles
|
|
229
|
+
// record their packages; every file there, those that can be formatted by
|
|
230
|
+
// their texts and the rest by their hashes. A file the viewer pairs otherwise
|
|
231
|
+
// (its files are its resources too) it asks about alone.
|
|
232
|
+
export function comparedFiles(sides: readonly [ComparedSide, ComparedSide]) {
|
|
233
|
+
const [olds, news] = sides.map(({ files, hashes }) => {
|
|
234
|
+
const texts = new Map(files.map(file => [file.path, file.text]))
|
|
235
|
+
return new Map<string, unknown>([...hashes].map(([path, hash]) => [path, texts.get(path) ?? { format: 'digest', digest: hash }]))
|
|
236
|
+
}) as [Map<string, unknown>, Map<string, unknown>]
|
|
237
|
+
const layout = treeLayout(olds.keys(), news.keys())
|
|
238
|
+
const packageDirs = layoutPackageDirs(sides[0].packageDirs, sides[1].packageDirs, layout)
|
|
239
|
+
return computeBundleDiff(olds, news, path => bundlePkgOf(path, { packageDir: packageDirs?.get(path) }), layout).files
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The pairs of JavaScript files minified on both sides that changed between a
|
|
243
|
+
// comparison's two sides, paired as its viewer pairs them (comparedFiles),
|
|
244
|
+
// each diffed as renameDiffOf diffs it, from both sides' copies, kept or
|
|
245
|
+
// made, as they are iterated: in path order, one after another, leaving a
|
|
246
|
+
// worker to the files a viewer asks for alone (a pair it reads otherwise, one
|
|
247
|
+
// it shows first).
|
|
248
|
+
export function renamedPairs([base, other]: readonly [ComparedSide, ComparedSide], keep = true): AsyncGenerator<RenamedLine> {
|
|
249
|
+
const formattable = ({ files }: ComparedSide) => new Map(files.map(file => [file.path, file]))
|
|
250
|
+
const [olds, news] = [formattable(base), formattable(other)]
|
|
251
|
+
const pairs = comparedFiles([base, other]).changed.flatMap(({ path, basePath = path }) => {
|
|
252
|
+
const [from, to] = [olds.get(basePath), news.get(path)]
|
|
253
|
+
return from && to && from.hash !== to.hash && [from, to].every(file => renamable(file) && needsPrettyPrint(file.path, file.text)) ? [[from, to] as const] : []
|
|
254
|
+
}).toSorted(([, a], [, b]) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
255
|
+
return (async function* () {
|
|
256
|
+
for (const [from, to] of pairs) {
|
|
257
|
+
const request = { base: { path: from.path, hash: from.hash, extension: from.extension }, other: { path: to.path, hash: to.hash, extension: to.extension } }
|
|
258
|
+
const named = { path: from.path, hash: from.hash, otherPath: to.path, otherHash: to.hash }
|
|
259
|
+
const copy = async (side: ComparedSide, { text, ...file }: typeof from) => prettyText(await side.kept(file) ?? await side.made(file, text))
|
|
260
|
+
let body
|
|
261
|
+
try { body = await answerOf(request, async () => [await copy(base, from), await copy(other, to)], keep) }
|
|
262
|
+
catch (err) {
|
|
263
|
+
if (!(err instanceof PrettyError)) throw err
|
|
264
|
+
yield { ...named, error: err.message, status: err.status }
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
yield { ...JSON.parse((await decompress(body)).toString('utf8')) as Partial<RenameDiff>, ...named }
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
}
|
|
@@ -29,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
|
|
|
29
29
|
}
|
|
30
30
|
function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
31
31
|
const key = createHash('sha256').update(JSON.stringify([
|
|
32
|
-
'finding-access-
|
|
32
|
+
'finding-access-v9', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
|
|
33
33
|
])).digest('hex')
|
|
34
34
|
return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
|
|
35
35
|
}
|
|
@@ -70,11 +70,21 @@ function encodeSources(integrity: string, details: BundleDetails, selection: Ret
|
|
|
70
70
|
return compress(Buffer.from(JSON.stringify({ integrity, ...selection, imports, formats, packageDirs })), { level: 6 })
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
type SourcemapEdges = (bundle: ManagedBundle) => Promise<BundleDetails['edges']>
|
|
74
|
+
|
|
75
|
+
// A sourcemap's imports follow the edges its metadata keeps (the bundle
|
|
76
|
+
// cache's `sourcemapEdges`), read with the parser, else what edges-lite.js reads.
|
|
77
|
+
async function lendSourcemapEdges(details: BundleDetails, bundle: ManagedBundle, sourcemapEdges?: SourcemapEdges) {
|
|
78
|
+
if (details.kind !== 'sourcemap' || !sourcemapEdges) return
|
|
79
|
+
const edges = await sourcemapEdges(bundle).catch(() => undefined)
|
|
80
|
+
if (edges) details.edges = edges
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
// Immutable report hashes share a derivative across duplicate uploads. Bundle
|
|
74
84
|
// identity and visibility are part of the key: a broader viewer's sources must
|
|
75
85
|
// never populate a restricted response. Group derivatives by hash and filename
|
|
76
86
|
// format so each format's permissions can be removed after its last deletion.
|
|
77
|
-
export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, reports: BlobStore, bundles: BundleStore) {
|
|
87
|
+
export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, reports: BlobStore, bundles: BundleStore, sourcemapEdges?: SourcemapEdges) {
|
|
78
88
|
const pending = new Map<string, { reportId: string; job: Promise<boolean> }>()
|
|
79
89
|
let queue = Promise.resolve()
|
|
80
90
|
function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
@@ -98,6 +108,7 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
|
|
|
98
108
|
}
|
|
99
109
|
const details = await readBundleDetails(bundle, bundles)
|
|
100
110
|
if (!details) return false
|
|
111
|
+
await lendSourcemapEdges(details, bundle, sourcemapEdges)
|
|
101
112
|
const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
|
|
102
113
|
const body = await encodeSources(bundle.integrity, details, selection)
|
|
103
114
|
// A duplicate with the same hash AND format can use these parsed bytes.
|
|
@@ -3,6 +3,9 @@ import type { SelectedRepo } from './db.ts'
|
|
|
3
3
|
import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
|
|
4
4
|
import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
|
|
5
5
|
import { readSolidityEntryPoints } from './solidity-entry-points.ts'
|
|
6
|
+
import { parseGithubCommit } from './bundle-commits.ts'
|
|
7
|
+
|
|
8
|
+
const REFS_PAGE = 100
|
|
6
9
|
|
|
7
10
|
export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
|
|
8
11
|
|
|
@@ -102,12 +105,18 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
|
|
|
102
105
|
readToken: () => token,
|
|
103
106
|
async refs() {
|
|
104
107
|
// Suggestions are bounded; the input also accepts any branch or tag name.
|
|
105
|
-
const
|
|
106
|
-
const data = await read(`/${kind}?per_page
|
|
108
|
+
const list = async (kind: string) => {
|
|
109
|
+
const data = await read(`/${kind}?per_page=${REFS_PAGE}`)
|
|
107
110
|
if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
|
|
108
|
-
return data
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
+
return data
|
|
112
|
+
}
|
|
113
|
+
const [branches, tags] = await Promise.all([list('branches'), list('tags')])
|
|
114
|
+
const names = (items: { name?: unknown }[]) => items.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
|
|
115
|
+
// Each tag's commit, for the tag cache. A full page may leave tags
|
|
116
|
+
// out; only a shorter one lists every tag.
|
|
117
|
+
const tagCommits = tags.flatMap(item => typeof item?.name === 'string' && item.name && typeof item.commit?.sha === 'string'
|
|
118
|
+
&& /^[a-f\d]{40}$/u.test(item.commit.sha) ? [{ name: item.name, sha: item.commit.sha }] : [])
|
|
119
|
+
return { defaultBranch, branches: names(branches), tags: names(tags), tagCommits, tagsComplete: tags.length < REFS_PAGE }
|
|
111
120
|
},
|
|
112
121
|
async commit(ref: string) {
|
|
113
122
|
if (/^[a-f\d]{40}$/iu.test(ref)) return ref
|
|
@@ -115,6 +124,11 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
|
|
|
115
124
|
if (typeof data?.sha !== 'string' || !/^[a-f\d]{40}$/iu.test(data.sha)) throw new GithubApiError(502, 'github-malformed')
|
|
116
125
|
return data.sha
|
|
117
126
|
},
|
|
127
|
+
// The first commit a listing from `sha` returns is that commit, without
|
|
128
|
+
// the files GET /commits/:sha adds.
|
|
129
|
+
async commitDetails(sha: string) {
|
|
130
|
+
return /^[a-f\d]{40}$/u.test(sha) ? parseGithubCommit(sha, await read(`/commits?sha=${sha}&per_page=1`)) : null
|
|
131
|
+
},
|
|
118
132
|
async directory(path: string, commit: string) {
|
|
119
133
|
const data = await read(`/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(commit)}`)
|
|
120
134
|
if (!Array.isArray(data)) throw new GithubApiError(400, 'not-a-directory')
|