@preventive/triage 1.0.0-alpha.43 → 1.0.0-alpha.44
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/common/bundle-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/managed/routes.js +3 -3
- package/common/server-info.ts +8 -0
- package/common/workspace-app.js +14 -1
- package/out/chunk-2ABLKCOY.js +46 -0
- package/out/chunk-5LSXOLUS.js +1 -0
- package/out/chunk-5RCM2J63.js +1 -0
- package/out/chunk-7B6HB4D7.js +1 -0
- package/out/{chunk-4F6JSF57.js → chunk-AJ4GTATZ.js} +1 -1
- package/out/chunk-CH4UN6WK.js +225 -0
- package/out/chunk-EDKLBQQN.js +17 -0
- package/out/{chunk-UXNUAIIC.js → chunk-F47DXCEV.js} +1 -1
- package/out/chunk-FK3DZEKL.js +1 -0
- package/out/chunk-G6AJWENO.js +1 -0
- package/out/chunk-GZWM7UQM.js +2 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-LPCTJYNA.js +7 -0
- package/out/{chunk-LAAB5SZT.js → chunk-O4RJY5TP.js} +1 -1
- package/out/chunk-OD4LPN3H.js +1 -0
- package/out/{chunk-A7M4GV7M.js → chunk-QWDWTBM7.js} +1 -1
- package/out/chunk-QYWKC557.js +1 -0
- package/out/chunk-R75XOKUP.js +1 -0
- package/out/{chunk-RSHMVYNS.js → chunk-SWTLADW5.js} +1 -1
- package/out/chunk-T7OKH4VW.js +1 -0
- package/out/{chunk-ZMQQCGF6.js → chunk-UTR2LVME.js} +1 -1
- package/out/chunk-ZESLQCTF.js +6 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +20 -20
- package/out/client-sync.js +1 -1
- package/out/graph.js +1 -1
- package/out/index.html +14 -10
- package/out/terminal.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +48 -48
- package/package.json +7 -3
- package/risk/npm/assess.js +12 -7
- package/risk/npm/dependencies.js +11 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/http.ts +99 -31
- package/server-managed/locked-packages.ts +11 -0
- package/server-managed/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- package/server-managed/repository-risk.ts +11 -9
- package/server-managed/team-app.ts +38 -14
- package/out/chunk-7RU5EDRO.js +0 -226
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-NWZ4PVPN.js +0 -1
- package/out/chunk-OSIMGFUF.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-VQJCXGIY.js +0 -7
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-XOCAPDCM.js +0 -1
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.44",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"server-managed/cache-storage.ts",
|
|
109
109
|
"server-managed/cli.js",
|
|
110
110
|
"server-managed/combined.ts",
|
|
111
|
+
"server-managed/comparison.ts",
|
|
111
112
|
"server-managed/comments.ts",
|
|
112
113
|
"server-managed/config.ts",
|
|
113
114
|
"server-managed/content-access.ts",
|
|
@@ -206,6 +207,9 @@
|
|
|
206
207
|
"common/bundle-commit.d.ts",
|
|
207
208
|
"common/bundle-commit.js",
|
|
208
209
|
"common/bundle-compare-diff.js",
|
|
210
|
+
"common/bundle-compare-versions.js",
|
|
211
|
+
"common/bundle-comparison.d.ts",
|
|
212
|
+
"common/bundle-comparison.js",
|
|
209
213
|
"common/bundle-compare-diff.d.ts",
|
|
210
214
|
"common/bundle-compare-inputs.d.ts",
|
|
211
215
|
"common/bundle-compare-inputs.js",
|
|
@@ -352,9 +356,9 @@
|
|
|
352
356
|
"dependencies": {
|
|
353
357
|
"@exodus/stasis": "1.0.0-beta.9",
|
|
354
358
|
"@exodus/stasis-core": "1.0.0-beta.9",
|
|
355
|
-
"@preventive/compare": "^1.2.
|
|
359
|
+
"@preventive/compare": "^1.2.10",
|
|
356
360
|
"@preventive/diff": "^1.0.2",
|
|
357
|
-
"@preventive/lockfile": "1.1.
|
|
361
|
+
"@preventive/lockfile": "1.1.1",
|
|
358
362
|
"@preventive/publishers": "^1.1.0",
|
|
359
363
|
"@preventive/report": "1.0.0-alpha.9",
|
|
360
364
|
"@preventive/sourcemap": "1.0.0-alpha.1",
|
package/risk/npm/assess.js
CHANGED
|
@@ -6,7 +6,7 @@ import { npmBaseFilesTell, npmCompareFindings } from './compare.js'
|
|
|
6
6
|
import { npmCombinedFindings, npmHeldDependencyFindings } from './combined.js'
|
|
7
7
|
import { npmHeldFindings, npmVersionFindings } from './held.js'
|
|
8
8
|
import { npmVersionFileRisk } from './files.js'
|
|
9
|
-
import { npmDependencyChanges, npmFlagDependencyChanges } from './dependencies.js'
|
|
9
|
+
import { npmDependencyChanges, npmFlagDependencyChanges, npmUnusedDependencyCounted } from './dependencies.js'
|
|
10
10
|
import { npmDownloadMonths, npmDownloadTier, npmSettledDownloads, npmTierLevel } from './downloads.js'
|
|
11
11
|
import { NpmRiskError, npmDownloads, npmPackageFacts, npmPackageReleases, npmPublicFacts, npmVersionAdvisories, npmVersionFiles } from './registry.js'
|
|
12
12
|
import { npmMalwareFinding } from './malware.js'
|
|
@@ -127,7 +127,8 @@ const MAX_TOLD_DEPENDENCIES = 100
|
|
|
127
127
|
// from }`), `uncounted` where npm counts none of them, `created` and `gap`
|
|
128
128
|
// where it is new since the version compared with (`published`, when that
|
|
129
129
|
// and the open version were), and for one nothing imports (flagged so, or
|
|
130
|
-
// among `unusedKeys`)
|
|
130
|
+
// among `unusedKeys`) that risk counts (dependencies.js
|
|
131
|
+
// npmUnusedDependencyCounted), the version its spec installs (`installs`,
|
|
131
132
|
// registry.js npmPublicFacts); `missing` where
|
|
132
133
|
// its package was looked up and npm has none of it for anyone, as once
|
|
133
134
|
// unpublished or taken down; four asked at a time, whether each is the
|
|
@@ -139,7 +140,7 @@ async function addedDependencies(added, published, context, owned, unusedKeys =
|
|
|
139
140
|
let next = 0, taken = 0
|
|
140
141
|
const tell = async row => {
|
|
141
142
|
const downloads = await npmDownloads(row.opens, context)
|
|
142
|
-
const unused = row.flags.some(flag => flag.check === 'unimported') || unusedKeys.has(keyOf(row))
|
|
143
|
+
const unused = (row.flags.some(flag => flag.check === 'unimported') || unusedKeys.has(keyOf(row))) && npmUnusedDependencyCounted(row)
|
|
143
144
|
let facts = npmAddedDependency({ downloads, published, unused }, context.now), missing = false
|
|
144
145
|
if (facts.wanted) {
|
|
145
146
|
const listed = await npmPublicFacts(row.opens, context, unused ? row.range : null)
|
|
@@ -225,8 +226,9 @@ export async function assessNpm(name, version, { oldVersion = null, fromEmpty =
|
|
|
225
226
|
const keyOf = row => `${row.kind ?? ''}\0${row.name}`
|
|
226
227
|
|
|
227
228
|
// The keys of the dependencies a version holds that nothing of it imports
|
|
228
|
-
// (`kept`, npmLastingRisk's: its `held-unused-deps`)
|
|
229
|
-
|
|
229
|
+
// (`kept`, npmLastingRisk's: its `held-unused-deps`), those risk counts
|
|
230
|
+
// (dependencies.js npmUnusedDependencyCounted).
|
|
231
|
+
const unusedKeysOf = kept => new Set((kept.findings.find(({ id }) => id === 'held-unused-deps')?.dependencies ?? []).filter(npmUnusedDependencyCounted).map(keyOf))
|
|
230
232
|
|
|
231
233
|
// The dependencies nothing of a version imports (`held-unused-deps`), each
|
|
232
234
|
// graded by what risk tells of its package fresh (`told`, as
|
|
@@ -236,13 +238,16 @@ const unusedKeysOf = kept => new Set((kept.findings.find(({ id }) => id === 'hel
|
|
|
236
238
|
// version compared with where there was one (`compared`) and it named it as
|
|
237
239
|
// the same kind of dependency by the same spec: not one marked new, nor
|
|
238
240
|
// `added` (its kind and name new, as one moved from another kind is), nor
|
|
239
|
-
// `changed` (each said with the spec it had, `was`)
|
|
241
|
+
// `changed` (each said with the spec it had, `was`); one risk doesn't count
|
|
242
|
+
// (dependencies.js npmUnusedDependencyCounted) a notice, told all the same.
|
|
243
|
+
// The finding as its most severe.
|
|
240
244
|
function unusedGraded(finding, told, compared, { added, changed }) {
|
|
241
245
|
const facts = new Map(told.map(row => [keyOf(row), row])), was = new Map(changed.map(row => [keyOf(row), row.from]))
|
|
242
246
|
const anew = new Set(added.map(keyOf))
|
|
243
247
|
const dependencies = finding.dependencies.map(row => {
|
|
244
248
|
const of = facts.get(keyOf(row))
|
|
245
|
-
return { ...row, ...was.has(keyOf(row)) ? { was: was.get(keyOf(row)) } : {}, severity:
|
|
249
|
+
return { ...row, ...was.has(keyOf(row)) ? { was: was.get(keyOf(row)) } : {}, severity: npmUnusedDependencyCounted(row)
|
|
250
|
+
? npmUnusedDependencySeverity(of, compared && !row.added && !anew.has(keyOf(row)) && !was.has(keyOf(row))) : 'notice',
|
|
246
251
|
...of?.level ? { tier: of.tier, level: of.level } : {}, ...of?.created ? { created: of.created, gap: of.gap } : {}, ...of?.installs ? { installs: of.installs } : {},
|
|
247
252
|
...of?.missing ? { missing: true } : {} }
|
|
248
253
|
})
|
package/risk/npm/dependencies.js
CHANGED
|
@@ -125,6 +125,17 @@ export function npmDependencyUnimported(details, { name, kind, range, to }) {
|
|
|
125
125
|
&& !commands.some(command => commandNames(command, name))
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// Whether a dependency nothing of a version imports (`{ name, range }`,
|
|
129
|
+
// npmDependencyUnimported) counts toward its risk (assess.js): not lodash,
|
|
130
|
+
// which packages keep naming long after their code stopped importing it, nor
|
|
131
|
+
// Babel's own presets (`@babel/preset-*`), which a config names by a string
|
|
132
|
+
// (an ESLint config's parser options); each where npm installs that package
|
|
133
|
+
// from its registry (installsAs), not another under its name.
|
|
134
|
+
export function npmUnusedDependencyCounted({ name, range }) {
|
|
135
|
+
const installs = installsAs(name, range)
|
|
136
|
+
return !(name === 'lodash' && installs === 'lodash') && !(name.startsWith('@babel/preset-') && installs?.startsWith('@babel/preset-'))
|
|
137
|
+
}
|
|
138
|
+
|
|
128
139
|
// Where a source takes a dependency from, in a sentence.
|
|
129
140
|
const SOURCE_NAMES = new Map([['URL', 'a URL'], ['git', 'a git repository']])
|
|
130
141
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Compare's comparison of two managed bundles or two versions of an npm
|
|
2
|
+
// package (GET /api/bundles/:id/comparison?other=, GET /api/npm/comparison):
|
|
3
|
+
// their files paired, at one path or renamed, with the sizes, resolutions,
|
|
4
|
+
// dependency versions and line counts its Overview and Code read beside them,
|
|
5
|
+
// as common/bundle-comparison.js prepareComparison works them out. The viewer
|
|
6
|
+
// of a managed bundle or a package has every comparison worked out here and
|
|
7
|
+
// loads none of the pairing itself. Answered as Brotli-encoded JSON, its
|
|
8
|
+
// `files.basePaths` (a Map) as `[path, as laid out]` pairs where the two
|
|
9
|
+
// sides' roots differ and none else, as the viewer reads none then. Access is
|
|
10
|
+
// the caller's to check.
|
|
11
|
+
import { Buffer } from 'node:buffer'
|
|
12
|
+
import { Readable } from 'node:stream'
|
|
13
|
+
import { prepareComparison } from '../common/bundle-comparison.js'
|
|
14
|
+
import type { BundleDetails } from '../common/bundle-metadata.js'
|
|
15
|
+
import type { OpenedBlob } from './blob-store.ts'
|
|
16
|
+
import { encodeBrotli } from './brotli.ts'
|
|
17
|
+
import { type NpmPackageFile, npmFileRows } from './npm-packages.ts'
|
|
18
|
+
|
|
19
|
+
// A side with no files, as Review compares a package's first version with:
|
|
20
|
+
// one for each comparison, as working one out keeps its line counts on it.
|
|
21
|
+
export const emptySide = (): BundleDetails => ({ integrity: '(empty)', kind: 'sourcemap', size: 0, json: { version: 3, sources: [], sourcesContent: [] } })
|
|
22
|
+
|
|
23
|
+
// A version's files as the viewer reads them for a comparison (ui/view/npm-package.js
|
|
24
|
+
// npmPackageDetails): its texts, and those that are not text by their size and digest.
|
|
25
|
+
export function npmComparedSide(integrity: string, files: NpmPackageFile[]): BundleDetails & { npmBinaries: Map<string, { size: number; digest: string }> } {
|
|
26
|
+
const rows = npmFileRows(files)
|
|
27
|
+
return {
|
|
28
|
+
integrity, kind: 'sourcemap', size: 0,
|
|
29
|
+
json: { version: 3, sources: rows.map(([path]) => path), sourcesContent: rows.map(([, , text]) => text) },
|
|
30
|
+
npmBinaries: new Map(rows.flatMap(([path, size, text, digest]) => (text === null ? [[path, { size, digest: digest! }] as const] : []))),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The comparison of `base` with `other`, within a reason `scope` where given, as sent.
|
|
35
|
+
export async function comparisonBody(base: BundleDetails, other: BundleDetails, scope = ''): Promise<OpenedBlob> {
|
|
36
|
+
const comparison = prepareComparison(base, other, scope)
|
|
37
|
+
const { files } = comparison
|
|
38
|
+
const json = JSON.stringify({ ...comparison, files: { ...files, basePaths: files.root ? [...files.basePaths] : [] } })
|
|
39
|
+
const body = await encodeBrotli(Buffer.from(json))
|
|
40
|
+
return { size: body.byteLength, stream: Readable.from([body]) }
|
|
41
|
+
}
|
package/server-managed/http.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// server doesn't speak the e2e sync protocol yet). Every handler is async; the
|
|
4
4
|
// boot's `track()` keeps in-flight requests drainable on shutdown.
|
|
5
5
|
//
|
|
6
|
-
// GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public
|
|
6
|
+
// GET /api/config → { mode:'managed', managed:{ loginPath, cookieName }, session } (public;
|
|
7
|
+
// `session` is /api/auth/session's answer, null signed out, absent when it could not be read)
|
|
7
8
|
// GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
|
|
8
9
|
// GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
|
|
9
10
|
// GET /api/auth/session → { user, csrfToken } | 401
|
|
@@ -120,7 +121,7 @@ import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedR
|
|
|
120
121
|
import type { ReportSourcesCache } from './report-sources.ts'
|
|
121
122
|
import { normalizeTeamPath } from './repo-path.ts'
|
|
122
123
|
import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
|
|
123
|
-
import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
|
|
124
|
+
import { CONFIG_PATH, type ManagedSessionInfo, type ServerInfo } from '../common/server-info.ts'
|
|
124
125
|
import { bundleCommitHash } from '../common/bundle-commit.js'
|
|
125
126
|
import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
|
|
126
127
|
import { upstreamGithub } from './github-errors.ts'
|
|
@@ -146,7 +147,7 @@ import { attachmentDisposition, sendJson, sendLines, writeResponse } from './htt
|
|
|
146
147
|
import { triageWireEntry } from './triage-response.ts'
|
|
147
148
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
148
149
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
149
|
-
import { teamAppKey, teamAppStates } from './team-app.ts'
|
|
150
|
+
import { teamAppKey, teamAppStates, withWorkspaceSummary } from './team-app.ts'
|
|
150
151
|
import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
151
152
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
152
153
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
@@ -163,6 +164,7 @@ import { NpmRiskError } from '../risk/index.js'
|
|
|
163
164
|
import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
164
165
|
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
165
166
|
import { typeOnlyFiles } from './type-only.ts'
|
|
167
|
+
import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
|
|
166
168
|
import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
167
169
|
|
|
168
170
|
const SESSION_PATH = '/api/auth/session'
|
|
@@ -216,6 +218,7 @@ const NPM_PROVENANCE_PATH = '/api/npm/provenance'
|
|
|
216
218
|
const NPM_PRETTY_PATH = '/api/npm/pretty'
|
|
217
219
|
const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
|
|
218
220
|
const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
221
|
+
const NPM_COMPARISON_PATH = '/api/npm/comparison'
|
|
219
222
|
const NPM_RISK_PATH = '/api/npm/risk'
|
|
220
223
|
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
221
224
|
// having no such package or version, or a tarball unpacking past risk's
|
|
@@ -229,7 +232,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
|
|
|
229
232
|
sendJson(res, status, { error: err.code })
|
|
230
233
|
}
|
|
231
234
|
const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
|
|
232
|
-
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH])
|
|
235
|
+
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH])
|
|
233
236
|
// How long an npm response may go unread before its connection is dropped.
|
|
234
237
|
const NPM_RESPONSE_IDLE_MS = 60_000
|
|
235
238
|
const MAX_TEAM_NAME = 100
|
|
@@ -581,6 +584,24 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
581
584
|
return s
|
|
582
585
|
}
|
|
583
586
|
|
|
587
|
+
// Who am I? While an admin views as another user, that user, plus the admin
|
|
588
|
+
// as `viewer`. A view that has ended (with the admin's session or role, or the
|
|
589
|
+
// viewed account) is cleared, returning to the own session. Null signed out.
|
|
590
|
+
async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
|
|
591
|
+
const now = Date.now()
|
|
592
|
+
let s = await readSession(config, db, cookie, now)
|
|
593
|
+
const ended = s == null && viewToken(config, cookie) != null
|
|
594
|
+
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
595
|
+
return {
|
|
596
|
+
body: s && {
|
|
597
|
+
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
598
|
+
csrfToken: s.session.csrfToken,
|
|
599
|
+
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
600
|
+
},
|
|
601
|
+
headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
584
605
|
// GET /api/admin/scan/models — the server's canonical scan model ids and effort
|
|
585
606
|
// levels. Names are intentionally absent; the client derives them from ids.
|
|
586
607
|
async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
@@ -1515,18 +1536,25 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
|
|
|
1515
1536
|
await sendEncoded(req, res, cached, contentType, headers)
|
|
1516
1537
|
}
|
|
1517
1538
|
|
|
1539
|
+
// Two bundles a reader asks about together, where they may read both, else
|
|
1540
|
+
// null, told as 401 or 404: asked before what is worked out of them and
|
|
1541
|
+
// again before it is sent.
|
|
1542
|
+
function bothBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string) {
|
|
1543
|
+
return async () => {
|
|
1544
|
+
const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1545
|
+
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
1546
|
+
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
1547
|
+
return [base.bundle, other.bundle] as const
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1518
1551
|
// GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
|
|
1519
1552
|
// one of the `other` bundle's, both pretty-printed, renamed names hidden
|
|
1520
1553
|
// (rename-diff.ts), for a reader of both, checked again once it is worked out.
|
|
1521
1554
|
async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
|
|
1522
1555
|
const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
|
|
1523
1556
|
if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
1524
|
-
const readable =
|
|
1525
|
-
const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1526
|
-
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
1527
|
-
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
1528
|
-
return [base.bundle, other.bundle] as const
|
|
1529
|
-
}
|
|
1557
|
+
const readable = bothBundles(res, deps, session, id, otherId)
|
|
1530
1558
|
const records = await readable()
|
|
1531
1559
|
if (!records) return
|
|
1532
1560
|
const { prettyCache } = deps
|
|
@@ -1542,6 +1570,25 @@ async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse,
|
|
|
1542
1570
|
else diff.stream.destroy()
|
|
1543
1571
|
}
|
|
1544
1572
|
|
|
1573
|
+
// GET /api/bundles/:id/comparison?other=: its files paired with the `other`
|
|
1574
|
+
// bundle's as Compare pairs them, with what its Overview reads beside them
|
|
1575
|
+
// (comparison.ts), within a reason `scope` where given; for a reader of
|
|
1576
|
+
// both, checked again once it is worked out.
|
|
1577
|
+
async function handleBundleComparison(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
|
|
1578
|
+
const otherId = params.get('other') ?? ''
|
|
1579
|
+
if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
|
|
1580
|
+
const readable = bothBundles(res, deps, session, id, otherId)
|
|
1581
|
+
const records = await readable()
|
|
1582
|
+
if (!records) return
|
|
1583
|
+
const { prettyCache } = deps
|
|
1584
|
+
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1585
|
+
let body
|
|
1586
|
+
try { body = await comparisonBody(await prettyCache.bundleDetails(records[0]), await prettyCache.bundleDetails(records[1]), params.get('scope') ?? '') }
|
|
1587
|
+
catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
|
|
1588
|
+
if (await readable()) await sendEncoded(req, res, body, 'application/json')
|
|
1589
|
+
else body.stream.destroy()
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1545
1592
|
// GET /api/bundles/:id/pretty?other=: the files that changed between it and
|
|
1546
1593
|
// the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
|
|
1547
1594
|
// line each as it is found kept or formatted; GET
|
|
@@ -1898,7 +1945,7 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
|
|
|
1898
1945
|
// The revision sent is the reloaded catalog's, so classify what changed now.
|
|
1899
1946
|
const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
|
|
1900
1947
|
sendJson(res, 200, {
|
|
1901
|
-
teams: teams.map(team => ({ ...team,
|
|
1948
|
+
teams: teams.map(team => ({ ...withWorkspaceSummary(team, apps.get(teamAppKey(team))),
|
|
1902
1949
|
bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
|
|
1903
1950
|
commitInfo: commits.commitInfo(bundle) })) })),
|
|
1904
1951
|
revision,
|
|
@@ -2854,7 +2901,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2854
2901
|
} finally { res.off('close', onClose) }
|
|
2855
2902
|
}
|
|
2856
2903
|
|
|
2857
|
-
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
|
|
2904
|
+
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
|
|
2858
2905
|
// (canReadPrivateNpm) can be answered from the server's npm token; the rest get
|
|
2859
2906
|
// what the registry answers anonymously, every time (see npm-packages.ts).
|
|
2860
2907
|
// Access is checked again after the registry's answer.
|
|
@@ -2864,14 +2911,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2864
2911
|
// All but the package's own (its versions, figures and advisories) are a version's.
|
|
2865
2912
|
const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
|
|
2866
2913
|
const otherSpec = params.get('otherVersion') ?? ''
|
|
2867
|
-
|
|
2914
|
+
// The package a comparison's other side is a version of, another where Review compares a dependency replaced.
|
|
2915
|
+
const otherName = path === NPM_COMPARISON_PATH ? params.get('otherName') ?? name : name
|
|
2916
|
+
if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
|
|
2868
2917
|
|| (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
|
|
2869
|
-
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
|
|
2918
|
+
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
|
|
2919
|
+
|| (path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
2870
2920
|
sendJson(res, 400, { error: 'bad-package' }); return
|
|
2871
2921
|
}
|
|
2872
2922
|
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
2873
2923
|
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2874
|
-
const reader = await npmReader(deps, cookie, [name])
|
|
2924
|
+
const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
|
|
2875
2925
|
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2876
2926
|
const controller = new AbortController()
|
|
2877
2927
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
@@ -2970,6 +3020,28 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2970
3020
|
return
|
|
2971
3021
|
}
|
|
2972
3022
|
if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
|
|
3023
|
+
// Its files paired with those of `otherVersion` (of `otherName`, else the
|
|
3024
|
+
// same package), as Compare and Review pair them, with what their
|
|
3025
|
+
// Overview reads beside them (comparison.ts): the version shown against
|
|
3026
|
+
// the one compared with, or `(empty)`, none, as Review reads a first
|
|
3027
|
+
// version. Read of both, each package's access checked again once worked out.
|
|
3028
|
+
if (path === NPM_COMPARISON_PATH) {
|
|
3029
|
+
const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(otherName, otherSpec, canReadPrivateNpm(reader, otherName), controller.signal, keptVersions))
|
|
3030
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
3031
|
+
const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
|
|
3032
|
+
let body
|
|
3033
|
+
try {
|
|
3034
|
+
const [files, oldFiles] = await Promise.all(loads.map(load => load.result))
|
|
3035
|
+
body = await comparisonBody(old ? npmComparedSide(old.dist.integrity, oldFiles!) : emptySide(), npmComparedSide(doc.dist.integrity, files!))
|
|
3036
|
+
} finally { for (const load of loads) load.release() }
|
|
3037
|
+
const current = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
|
|
3038
|
+
if (current == null) { body.stream.destroy(); sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3039
|
+
if ((doc.private && !canReadPrivateNpm(current, name)) || (old?.private && !canReadPrivateNpm(current, otherName))) {
|
|
3040
|
+
body.stream.destroy(); sendJson(res, 404, { error: 'package-not-found' }); return
|
|
3041
|
+
}
|
|
3042
|
+
if (!res.destroyed) await sendEncoded(req, res, body, 'application/json')
|
|
3043
|
+
return
|
|
3044
|
+
}
|
|
2973
3045
|
// Every one of its files that needs it pretty-printed (pretty-print.ts
|
|
2974
3046
|
// npmAll), or, against another version, the files that changed between
|
|
2975
3047
|
// them, both sides' (compared), a line each as it is formatted, or found
|
|
@@ -3110,13 +3182,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3110
3182
|
}
|
|
3111
3183
|
|
|
3112
3184
|
// Public mode probe — lets a client detect the managed protocol up front.
|
|
3185
|
+
// It also sends the session, sparing the client's GET /api/auth/session;
|
|
3186
|
+
// a failed read leaves that request to the client, never failing the probe.
|
|
3113
3187
|
if (path === CONFIG_PATH) {
|
|
3114
3188
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3115
3189
|
const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
|
|
3190
|
+
const current = await currentSession(config, db, cookie).catch((err) => { console.warn('managed: config session read failed:', err); return null })
|
|
3116
3191
|
sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
|
|
3117
3192
|
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
3118
3193
|
uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
|
|
3119
|
-
} : {}) } })
|
|
3194
|
+
} : {}) }, ...(current ? { session: current.body } : {}) }, current?.headers)
|
|
3120
3195
|
return
|
|
3121
3196
|
}
|
|
3122
3197
|
if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
|
|
@@ -3159,22 +3234,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3159
3234
|
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
3160
3235
|
res.end(); return
|
|
3161
3236
|
}
|
|
3162
|
-
// Who am I? While an admin views as another user, that user, plus the
|
|
3163
|
-
// admin as `viewer`. A view that has ended (with the admin's session or
|
|
3164
|
-
// role, or the viewed account) is cleared, returning to the own session.
|
|
3165
3237
|
if (path === SESSION_PATH) {
|
|
3166
3238
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3167
|
-
const
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
3171
|
-
const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
|
|
3172
|
-
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
|
|
3173
|
-
sendJson(res, 200, {
|
|
3174
|
-
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
3175
|
-
csrfToken: s.session.csrfToken,
|
|
3176
|
-
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
3177
|
-
}, headers)
|
|
3239
|
+
const { body, headers } = await currentSession(config, db, cookie)
|
|
3240
|
+
if (body == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
|
|
3241
|
+
sendJson(res, 200, body, headers)
|
|
3178
3242
|
return
|
|
3179
3243
|
}
|
|
3180
3244
|
if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
|
|
@@ -3302,9 +3366,13 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3302
3366
|
await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
|
|
3303
3367
|
return
|
|
3304
3368
|
}
|
|
3305
|
-
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
|
|
3369
|
+
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only|comparison)$/iu.exec(path)
|
|
3306
3370
|
if (bundleRead) {
|
|
3307
3371
|
const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
|
|
3372
|
+
if (part === 'comparison') {
|
|
3373
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3374
|
+
await handleBundleComparison(req, res, deps, session, id, url.searchParams); return
|
|
3375
|
+
}
|
|
3308
3376
|
if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
|
|
3309
3377
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3310
3378
|
if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
|
|
@@ -95,3 +95,14 @@ export function lockedPackages(format: LockfileFormat, text: string): LockedPack
|
|
|
95
95
|
}
|
|
96
96
|
return [...found.values()]
|
|
97
97
|
}
|
|
98
|
+
|
|
99
|
+
// Whether a lockfile at `path` is a test's fixture or example: under a
|
|
100
|
+
// directory of tests (`test`, `tests`, `__tests__` and such), in one of
|
|
101
|
+
// fixtures or examples (`fixture`, `fixtures`, `example`, `examples`) inside
|
|
102
|
+
// it, as a test may keep one its readers refuse on purpose.
|
|
103
|
+
const FIXTURES_DIR = /^(?:fixture|example)s?$/u, TESTS_DIR = /^_*tests?_*$/u
|
|
104
|
+
export function isTestFixture(path: string) {
|
|
105
|
+
const dirs = path.split('/').slice(0, -1)
|
|
106
|
+
const tests = dirs.findIndex(dir => TESTS_DIR.test(dir))
|
|
107
|
+
return tests !== -1 && dirs.slice(tests + 1).some(dir => FIXTURES_DIR.test(dir))
|
|
108
|
+
}
|
|
@@ -250,18 +250,21 @@ export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) =>
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
// A bundle's details, from its bytes in `store`: bundles are read one at a
|
|
254
|
+
// time, as reading one decodes all of it.
|
|
255
|
+
function detailsReader(store: BundleStore) {
|
|
256
|
+
const enqueue = serial()
|
|
257
|
+
return (record: ManagedBundle) => enqueue(async () => {
|
|
258
|
+
const details = await readBundleDetails(record, store)
|
|
259
|
+
if (!details) throw new PrettyError(422, 'bundle-unavailable')
|
|
260
|
+
return details
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
253
264
|
export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
|
|
254
265
|
// Copies being made; readers asking for one meanwhile share it.
|
|
255
266
|
const shared = sharing<Buffer>()
|
|
256
|
-
|
|
257
|
-
const enqueue = serial()
|
|
258
|
-
function bundleDetails(record: ManagedBundle) {
|
|
259
|
-
return enqueue(async () => {
|
|
260
|
-
const details = await readBundleDetails(record, store)
|
|
261
|
-
if (!details) throw new PrettyError(422, 'bundle-unavailable')
|
|
262
|
-
return details
|
|
263
|
-
})
|
|
264
|
-
}
|
|
267
|
+
const bundleDetails = detailsReader(store)
|
|
265
268
|
// A bundle's file pretty-printed from the text `read` reads, and kept in
|
|
266
269
|
// its cache.
|
|
267
270
|
function bundleCopy(record: ManagedBundle, request: PrettyRequest, read: () => Promise<string>): Promise<Buffer> {
|
|
@@ -341,6 +344,8 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
|
|
|
341
344
|
made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
|
|
342
345
|
return [await sideOf(base, 'base'), await sideOf(other, 'other')]
|
|
343
346
|
},
|
|
347
|
+
// A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
|
|
348
|
+
bundleDetails,
|
|
344
349
|
async npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
|
|
345
350
|
const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)), packageDirs: null,
|
|
346
351
|
kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
|
|
@@ -17,7 +17,8 @@ import { serveTeamFeed } from './team-feed.ts'
|
|
|
17
17
|
import { type ComparedSide, PrettyError, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
18
18
|
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
19
19
|
import { typeOnlyFiles } from './type-only.ts'
|
|
20
|
-
import {
|
|
20
|
+
import { comparisonBody } from './comparison.ts'
|
|
21
|
+
import { sharedTeamApp, withWorkspaceSummary } from './team-app.ts'
|
|
21
22
|
|
|
22
23
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
23
24
|
sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
|
|
@@ -35,7 +36,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
35
36
|
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
36
37
|
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
|
|
37
38
|
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
38
|
-
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only)$/u.exec(url.pathname)
|
|
39
|
+
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only|comparison)$/u.exec(url.pathname)
|
|
39
40
|
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
40
41
|
const tokenHash = hashToken(token)
|
|
41
42
|
const snapshot = await deps.db.getWorkspaceShare(tokenHash)
|
|
@@ -46,8 +47,8 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
46
47
|
}
|
|
47
48
|
const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
|
|
48
49
|
const sendTeam = async () => {
|
|
49
|
-
const [summaries,
|
|
50
|
-
await send({ user: snapshot.user, team: { ...snapshot.team,
|
|
50
|
+
const [summaries, workspace] = await Promise.all([bundleSummaries(snapshot.team.bundles, deps.bundleCache), sharedTeamApp(deps.db, deps.reportStore, snapshot)])
|
|
51
|
+
await send({ user: snapshot.user, team: { ...withWorkspaceSummary(snapshot.team, workspace),
|
|
51
52
|
bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
|
|
52
53
|
} })
|
|
53
54
|
await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
|
|
@@ -101,7 +102,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
101
102
|
const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
|
|
102
103
|
if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
|
|
103
104
|
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 (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || bundleRoute![2] === 'comparison' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
|
|
105
106
|
if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
|
|
106
107
|
await serveCompared(res, deps, bundle, snapshot.bundles.find(item => item.id === url.searchParams.get('other')), bundleRoute![2], url, recheck, stream); return
|
|
107
108
|
}
|
|
@@ -109,11 +110,12 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
// What a bundle is compared with `other`, another of the link's bundles, by:
|
|
112
|
-
// one of their JavaScript files diffed
|
|
113
|
-
// files that changed between them, or the
|
|
114
|
-
// changed in types alone, a line each.
|
|
113
|
+
// their files paired, as one comparison; one of their JavaScript files diffed
|
|
114
|
+
// with renamed names hidden; or the files that changed between them, or the
|
|
115
|
+
// pairs of those, or those that changed in types alone, a line each.
|
|
115
116
|
function serveCompared(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, part: string, url: URL,
|
|
116
117
|
recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
118
|
+
if (part === 'comparison') return serveComparison(res, deps, bundle, other, url, stream)
|
|
117
119
|
if (part === 'rename-diff' && url.searchParams.has('path')) return serveRenameDiff(res, deps, bundle, other, url, stream)
|
|
118
120
|
return serveChanges(res, deps, bundle, other, recheck, part === 'pretty' ? compared : part === 'type-only' ? typeOnlyFiles : renamedPairs,
|
|
119
121
|
part === 'pretty' ? '' : url.searchParams.get('scope') ?? '')
|
|
@@ -139,6 +141,22 @@ async function serveRenameDiff(res: ServerResponse, deps: ManagedHttpDeps, bundl
|
|
|
139
141
|
await stream(diff, 'br')
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
// A bundle's files paired with `other`'s, another of the link's bundles, as
|
|
145
|
+
// Compare pairs them (comparison.ts), within a reason `scope` where given.
|
|
146
|
+
async function serveComparison(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, url: URL,
|
|
147
|
+
stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
148
|
+
if (!other) { json(res, 404, { error: 'no-bundle' }); return }
|
|
149
|
+
const { prettyCache } = deps
|
|
150
|
+
if (!prettyCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
151
|
+
let body
|
|
152
|
+
try { body = await comparisonBody(await prettyCache.bundleDetails(bundle), await prettyCache.bundleDetails(other), url.searchParams.get('scope') ?? '') }
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
|
|
155
|
+
throw err
|
|
156
|
+
}
|
|
157
|
+
await stream(body, 'br')
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
// The files that changed between a bundle and `other`, another of the link's
|
|
143
161
|
// bundles, both sides' pretty-printed (pretty-print.ts compared), or the pairs
|
|
144
162
|
// of them its viewer diffs with renamed names hidden (rename-diff.ts
|
|
@@ -28,7 +28,7 @@ import type { NpmVersionDocument } from './npm-packages.ts'
|
|
|
28
28
|
import { type Lru, lru } from './lru.ts'
|
|
29
29
|
import type { KeptStore } from './npm-kept.ts'
|
|
30
30
|
import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
|
|
31
|
-
import { type LockedPackage, lockedPackages } from './locked-packages.ts'
|
|
31
|
+
import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
|
|
32
32
|
import { type Queue, queue } from './queue.ts'
|
|
33
33
|
import { type DownloadsSource, readyingDownloads } from './scan-downloads.ts'
|
|
34
34
|
import { type AssessedPackage, type Assessment, BUSY_RETRIES, BUSY_WAIT_MS, RISK_KEPT_MS, type RiskFailure, assessPackage, failureOf, isBusy } from './repository-risk-assess.ts'
|
|
@@ -334,33 +334,35 @@ async function readAdvisories(scan: Scan, { repo, path, format, packages }: Lock
|
|
|
334
334
|
// A repository's line, its lockfiles or why they couldn't be listed, and
|
|
335
335
|
// then its lockfiles', and their advisories: told once they are all read
|
|
336
336
|
// and access to the repository is checked again, as GitHub may have
|
|
337
|
-
// withdrawn it meanwhile.
|
|
337
|
+
// withdrawn it meanwhile. A test's fixture or example its reader refuses
|
|
338
|
+
// (isTestFixture) isn't told at all, as a test may keep one so on purpose.
|
|
338
339
|
async function readRepository(scan: Scan, repo: RepositoryRiskRepo) {
|
|
339
340
|
const listed = await listLockfiles(scan, repo)
|
|
340
341
|
if (!listed) return
|
|
341
|
-
const lockfiles: LockfileLine[] = []
|
|
342
|
+
const lockfiles: { sha: string; line: LockfileLine }[] = []
|
|
342
343
|
for (const lockfile of listed.lockfiles) {
|
|
343
344
|
if (scan.signal.aborted) return
|
|
344
|
-
|
|
345
|
+
const line = await readLockfile(scan, repo.repoId, listed.opened, lockfile)
|
|
346
|
+
if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: lockfile.sha, line })
|
|
345
347
|
}
|
|
346
348
|
try { await listed.opened.recheck() } catch (error) {
|
|
347
349
|
if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, 'checking access to it again') })
|
|
348
350
|
return
|
|
349
351
|
}
|
|
350
352
|
if (scan.signal.aborted) return
|
|
351
|
-
scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles:
|
|
352
|
-
for (const line of lockfiles) {
|
|
353
|
+
scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: lockfiles.map(({ line: { path, format } }) => ({ path, format })) })
|
|
354
|
+
for (const { line } of lockfiles) {
|
|
353
355
|
scan.lines.add(line)
|
|
354
356
|
if ('packages' in line) assessLater(scan, line.packages)
|
|
355
357
|
}
|
|
356
|
-
for (const
|
|
358
|
+
for (const { sha, line } of lockfiles) {
|
|
357
359
|
if (scan.signal.aborted) return
|
|
358
|
-
const advisories = 'packages' in line ? await readAdvisories(scan, line,
|
|
360
|
+
const advisories = 'packages' in line ? await readAdvisories(scan, line, sha) : null
|
|
359
361
|
if (advisories) scan.lines.add(advisories)
|
|
360
362
|
}
|
|
361
363
|
if (scan.signal.aborted) return
|
|
362
364
|
scan.told.set(repo.repoId, { id: repo.repoId, name: repo.fullName, paths: repo.paths ?? null,
|
|
363
|
-
lockfiles: lockfiles.flatMap((
|
|
365
|
+
lockfiles: lockfiles.flatMap(({ sha, line }) => 'packages' in line ? [`${line.format}\0${sha}`] : []) })
|
|
364
366
|
}
|
|
365
367
|
|
|
366
368
|
// Whether an earlier part told `repo` whole, by its name and a team's
|