@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.31
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/LICENSE-APACHE +202 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +182 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +51 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +124 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +83 -0
- package/out/client-managed.js +169 -0
- package/out/client-sync.js +16 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +259 -63
- package/package.json +205 -57
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
- package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
- package/{server → server-e2e}/objstore/blob.ts +9 -2
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +58 -33
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +1 -1
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +53 -0
- package/server-managed/bundle-advisory-inventory.ts +75 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +54 -0
- package/server-managed/bundle-build.ts +168 -0
- package/server-managed/bundle-cache.ts +166 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +116 -0
- package/server-managed/bundle.ts +104 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +149 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1517 -0
- package/server-managed/db-neon.ts +277 -0
- package/server-managed/db-schema.ts +206 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +120 -0
- package/server-managed/github-app.ts +385 -0
- package/server-managed/github-errors.ts +49 -0
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +98 -0
- package/server-managed/github-oauth.ts +199 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2350 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +175 -0
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +86 -0
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +63 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +146 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +166 -0
- package/server-managed/repository-browser.ts +139 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +72 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +223 -0
- package/server-managed/storage-payload.ts +72 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +58 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-reports.ts +219 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +65 -0
- package/server-managed/workspace-shares.ts +162 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer'
|
|
2
|
+
import type { Readable } from 'node:stream'
|
|
3
|
+
import type { OpenedBlob } from './blob-store.ts'
|
|
4
|
+
import { validateCacheKey } from './cache-storage.ts'
|
|
5
|
+
|
|
6
|
+
export const ENCRYPTED_CACHE_PREFIX = 'cache-encrypted-v1/'
|
|
7
|
+
const STORAGE_PREFIXES = ['reports/', 'bundles/', 'avatars/', 'uploads/', 'cache/', ENCRYPTED_CACHE_PREFIX]
|
|
8
|
+
export interface RawObject extends OpenedBlob { version: string; modifiedAt: number }
|
|
9
|
+
type ObjectMetadata = Omit<RawObject, 'stream'>
|
|
10
|
+
interface ListedObject { key: string; modifiedAt: number }
|
|
11
|
+
export interface ObjectPage { objects: ListedObject[]; cursor: string | null }
|
|
12
|
+
export interface RawObjectStorage {
|
|
13
|
+
open(key: string, signal?: AbortSignal): Promise<RawObject | null>
|
|
14
|
+
head(key: string, signal?: AbortSignal): Promise<ObjectMetadata | null>
|
|
15
|
+
prune?(prefix: string, signal?: AbortSignal): Promise<void>
|
|
16
|
+
// Persist an observed disk replacement before its SQL migration checkpoint.
|
|
17
|
+
sync?(key: string, signal?: AbortSignal): Promise<void>
|
|
18
|
+
// A plaintext size hint selects multipart uploads without buffering streams.
|
|
19
|
+
put(key: string, bytes: Buffer | Readable, signal?: AbortSignal, expected?: string, sizeHint?: number): Promise<boolean>
|
|
20
|
+
// Return false if the original version changed; do not delete its replacement.
|
|
21
|
+
delete(key: string, version?: string, signal?: AbortSignal): Promise<boolean>
|
|
22
|
+
list(prefix: string, cursor: string | null, limit: number, signal?: AbortSignal): Promise<ObjectPage>
|
|
23
|
+
}
|
|
24
|
+
export interface ObjectStorage {
|
|
25
|
+
open(key: string): Promise<OpenedBlob | null>
|
|
26
|
+
get(key: string): Promise<Buffer | null>
|
|
27
|
+
exists(key: string): Promise<boolean>
|
|
28
|
+
put(key: string, bytes: Buffer): Promise<string | null>
|
|
29
|
+
delete(key: string): Promise<void>
|
|
30
|
+
deletePrefix(prefix: string): Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isBlobId(value: string): boolean {
|
|
34
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function objectPath(key: string): string {
|
|
38
|
+
validateCacheKey(key)
|
|
39
|
+
if (!STORAGE_PREFIXES.some(prefix => key.startsWith(prefix))) throw new Error('Invalid managed storage namespace')
|
|
40
|
+
return key
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Hash verification and migration may consume a stream before it can be used.
|
|
44
|
+
// Reopen only the same object version, closing any concurrent replacement.
|
|
45
|
+
export async function openObjectVersion(raw: RawObjectStorage, key: string, version: string, signal?: AbortSignal): Promise<RawObject | null> {
|
|
46
|
+
const current = await raw.open(key, signal)
|
|
47
|
+
if (current?.version === version) return current
|
|
48
|
+
current?.stream.destroy()
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number): Promise<number> {
|
|
53
|
+
const eligible = (modifiedAt: number) => before === undefined || modifiedAt < before
|
|
54
|
+
// Finish listing before deleting: provider cursors may be offset-based.
|
|
55
|
+
const cursors = new Set<string>(), keys = new Set<string>()
|
|
56
|
+
let cursor: string | null = null
|
|
57
|
+
do {
|
|
58
|
+
const page = await raw.list(prefix, cursor, 100, signal)
|
|
59
|
+
for (const object of page.objects) if (eligible(object.modifiedAt)) keys.add(object.key)
|
|
60
|
+
cursor = page.cursor
|
|
61
|
+
if (cursor !== null && cursors.has(cursor)) throw new Error('Invalid blob pagination')
|
|
62
|
+
if (cursor !== null) cursors.add(cursor)
|
|
63
|
+
} while (cursor !== null)
|
|
64
|
+
let removed = 0
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
const stored = await raw.head(key, signal)
|
|
67
|
+
// Ignore stale listings and preserve concurrent replacements.
|
|
68
|
+
if (stored && eligible(stored.modifiedAt) && await raw.delete(key, stored.version, signal)) removed++
|
|
69
|
+
}
|
|
70
|
+
return removed
|
|
71
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
3
|
+
|
|
4
|
+
export const MAX_PACKAGE_BYTES = 256 * 1024
|
|
5
|
+
const MAX_ENTRY_POINTS = 100
|
|
6
|
+
|
|
7
|
+
// Declared paths, not a Node resolver: never guess defaults, expand export
|
|
8
|
+
// patterns, or execute package code. Keep every suggestion in its package.
|
|
9
|
+
export function packageEntryPoints(manifest: unknown, directory: string): string[] {
|
|
10
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return []
|
|
11
|
+
const pkg = manifest as { main?: unknown; exports?: unknown; bin?: unknown }
|
|
12
|
+
const paths = new Set<string>()
|
|
13
|
+
const add = (value: unknown) => {
|
|
14
|
+
if (typeof value !== 'string' || value.startsWith('/') || value.endsWith('/') || /[*?]|^[a-z][a-z\d+.-]*:/iu.test(value)) return
|
|
15
|
+
const normalized = normalizeTeamPath(value)
|
|
16
|
+
if (!normalized.ok || !normalized.path || normalized.path === 'package.json') return
|
|
17
|
+
const path = directory ? `${directory}/${normalized.path}` : normalized.path
|
|
18
|
+
if (path.length <= 500 && paths.size < MAX_ENTRY_POINTS) paths.add(path)
|
|
19
|
+
}
|
|
20
|
+
add(pkg.main)
|
|
21
|
+
const pending = [pkg.exports]
|
|
22
|
+
for (let visited = 0; pending.length > 0 && visited < 4096; visited++) {
|
|
23
|
+
const value = pending.pop()
|
|
24
|
+
if (typeof value === 'string') { if (value.startsWith('./')) add(value); continue }
|
|
25
|
+
if (Array.isArray(value)) pending.push(...value.slice(0, 4096 - visited).toReversed())
|
|
26
|
+
else if (value && typeof value === 'object') {
|
|
27
|
+
const entries = Object.entries(value).filter(([key]) => key !== 'types' && !key.startsWith('types@'))
|
|
28
|
+
pending.push(...entries.slice(0, 4096 - visited).toReversed().map(([, target]) => target))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (typeof pkg.bin === 'string') add(pkg.bin)
|
|
32
|
+
else if (pkg.bin && typeof pkg.bin === 'object' && !Array.isArray(pkg.bin)) Object.values(pkg.bin).forEach(add)
|
|
33
|
+
return [...paths]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Use the immutable blob from the authorized listing, never download_url or a
|
|
37
|
+
// resolved symlink target. Malformed or oversized manifests are optional.
|
|
38
|
+
export async function readPackageEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
|
|
39
|
+
try {
|
|
40
|
+
const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
|
|
41
|
+
if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
|
|
42
|
+
|| blob.size < 0 || blob.size > MAX_PACKAGE_BYTES || blob.content.length > MAX_PACKAGE_BYTES * 2) return []
|
|
43
|
+
const bytes = Buffer.from(blob.content, 'base64')
|
|
44
|
+
if (bytes.length !== blob.size || bytes.length > MAX_PACKAGE_BYTES) return []
|
|
45
|
+
return packageEntryPoints(JSON.parse(bytes.toString('utf8')), directory)
|
|
46
|
+
} catch {
|
|
47
|
+
// Suggestions must not prevent browsing. The handler still rechecks
|
|
48
|
+
// GitHub and managed access after this optional upstream read.
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import { pipeline } from 'node:stream/promises'
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import type { ManagedHttpDeps } from './http.ts'
|
|
5
|
+
import type { ManagedBundle } from './db.ts'
|
|
6
|
+
import type { OpenedBlob } from './blob-store.ts'
|
|
7
|
+
import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
|
|
8
|
+
import { sendJson, writeResponse } from './http-response.ts'
|
|
9
|
+
import { hashToken } from './crypto.ts'
|
|
10
|
+
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, teamReportVisibility } from './team-reports.ts'
|
|
11
|
+
import { triageWireEntry } from './triage-response.ts'
|
|
12
|
+
import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
13
|
+
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
14
|
+
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
15
|
+
import { serveTeamFeed } from './team-feed.ts'
|
|
16
|
+
|
|
17
|
+
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
18
|
+
sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Called before ALL other routing, including OAuth, discovery, static and the
|
|
22
|
+
// combined server fallback. A supplied capability never inherits cookie auth.
|
|
23
|
+
export async function handlePublicWorkspace(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, url: URL): Promise<void> {
|
|
24
|
+
const token = req.headers['x-deepview-share']
|
|
25
|
+
if (!deps.config.allowShare || typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(token)) {
|
|
26
|
+
json(res, 401, { error: 'invalid-share' }); return
|
|
27
|
+
}
|
|
28
|
+
const method = req.method ?? 'GET'
|
|
29
|
+
if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
|
|
30
|
+
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
31
|
+
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
|
|
32
|
+
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
33
|
+
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
|
|
34
|
+
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
35
|
+
const tokenHash = hashToken(token)
|
|
36
|
+
const snapshot = await deps.db.getWorkspaceShare(tokenHash)
|
|
37
|
+
if (!snapshot) { json(res, 401, { error: 'invalid-share' }); return }
|
|
38
|
+
const recheck = async () => {
|
|
39
|
+
const current = deps.config.allowShare && await deps.db.getWorkspaceShare(tokenHash)
|
|
40
|
+
if (!current || JSON.stringify(current) !== JSON.stringify(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
|
|
41
|
+
}
|
|
42
|
+
const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
|
|
43
|
+
const sendTeam = async () => {
|
|
44
|
+
const summaries = await bundleSummaries(snapshot.team.bundles, deps.bundleCache)
|
|
45
|
+
await send({ user: snapshot.user, team: { ...snapshot.team,
|
|
46
|
+
bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
|
|
47
|
+
} })
|
|
48
|
+
await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
|
|
49
|
+
}
|
|
50
|
+
const stream = async (stored: OpenedBlob, encoding: string | null, contentType = 'application/json') => {
|
|
51
|
+
try { await recheck() } catch (error) { stored.stream.destroy(); throw error }
|
|
52
|
+
res.writeHead(200, { 'content-type': contentType, 'cache-control': 'private, no-store',
|
|
53
|
+
'x-content-type-options': 'nosniff', ...(encoding ? { 'content-encoding': encoding } : {}),
|
|
54
|
+
...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
|
|
55
|
+
})
|
|
56
|
+
if (method === 'HEAD') { stored.stream.destroy(); res.end(); return }
|
|
57
|
+
try { await pipeline(stored.stream, res) } catch { res.destroy() }
|
|
58
|
+
}
|
|
59
|
+
if (url.searchParams.has('team') && (url.searchParams.getAll('team').length !== 1 || url.searchParams.get('team') !== snapshot.teamId)) {
|
|
60
|
+
json(res, 404, { error: 'no-team' }); return
|
|
61
|
+
}
|
|
62
|
+
if (shareRoute) {
|
|
63
|
+
// The short hash is a recognizable link ID, never an access grant. Resolve
|
|
64
|
+
// with the full token so colliding IDs or slugs cannot broaden its scope.
|
|
65
|
+
// Existing links used the team ID as their fragment prefix.
|
|
66
|
+
if (shareRoute[1] !== tokenHash.slice(0, 8) && shareRoute[1] !== snapshot.teamId) {
|
|
67
|
+
json(res, 404, { error: 'no-share' }); return
|
|
68
|
+
}
|
|
69
|
+
await sendTeam(); return
|
|
70
|
+
}
|
|
71
|
+
if (teamRoute) {
|
|
72
|
+
if (teamRoute[1] !== snapshot.teamId) { json(res, 404, { error: 'no-team' }); return }
|
|
73
|
+
if (teamRoute[2] === 'feed') {
|
|
74
|
+
if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
|
|
75
|
+
await serveTeamFeed(res, deps, snapshot, recheck, { readState: () => deps.db.getWorkspaceShareFeedState(tokenHash) }); return
|
|
76
|
+
}
|
|
77
|
+
if (teamRoute[2] === 'annotations') {
|
|
78
|
+
await send(await loadTeamAnnotations(deps.db, deps.reportStore, snapshot, url.searchParams.get('reportId'))); return
|
|
79
|
+
}
|
|
80
|
+
if (teamRoute[2] === 'shared') { await sendTeam(); return }
|
|
81
|
+
const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
|
|
82
|
+
await recheck()
|
|
83
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
|
|
84
|
+
writeResponse(res, body); return
|
|
85
|
+
}
|
|
86
|
+
if (reportRoute) {
|
|
87
|
+
const id = reportRoute[1]!, part = reportRoute[2]!
|
|
88
|
+
if (!snapshot.reports.some(report => report.id === id)) { json(res, 404, { error: 'no-report' }); return }
|
|
89
|
+
const visible = await teamReportVisibility(deps.db, deps.reportStore, snapshot, id)
|
|
90
|
+
await recheck()
|
|
91
|
+
if (part === 'sources') { await sources(res, deps, snapshot, id, visible.sourcePaths, recheck, stream); return }
|
|
92
|
+
if (part === 'comments') { await send({ comments: await deps.db.listComments([...visible.ids]) }); return }
|
|
93
|
+
const entries = Object.fromEntries((await deps.db.listTriage([...visible.ids])).map(row => [row.findingId, triageWireEntry(row)]))
|
|
94
|
+
await send({ entries }); return
|
|
95
|
+
}
|
|
96
|
+
const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
|
|
97
|
+
if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
|
|
98
|
+
if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
|
|
99
|
+
await serveBundle(res, deps, bundle, bundleRoute![2]!, url, recheck, stream)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, part: string, url: URL,
|
|
103
|
+
recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
104
|
+
if (part === 'download') {
|
|
105
|
+
const stored = await deps.bundleStore.open(bundle.id, bundle.kind)
|
|
106
|
+
if (!stored) { json(res, 503, { error: 'unavailable' }); return }
|
|
107
|
+
await stream(stored, bundle.kind === 'sourcemap' ? 'br' : null, 'application/octet-stream'); return
|
|
108
|
+
}
|
|
109
|
+
if (!deps.bundleCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
110
|
+
if (part === 'advisories') {
|
|
111
|
+
if (bundle.kind !== 'stasis') { json(res, 422, { error: 'unsupported-bundle' }); return }
|
|
112
|
+
const inventory = await deps.bundleCache.advisoryInventory(bundle, url.searchParams.get('reason') ?? '')
|
|
113
|
+
await recheck()
|
|
114
|
+
if (inventory === null) { json(res, 413, { error: 'payload-too-large' }); return }
|
|
115
|
+
if (inventory === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
|
|
116
|
+
if (Buffer.byteLength(JSON.stringify(inventory)) > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
|
|
117
|
+
const result = await fetchBundleAdvisories(inventory.packages, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS), {
|
|
118
|
+
debug: deps.config.debug, repoAdvisories: url.searchParams.get('repoAdvisories') === 'true',
|
|
119
|
+
})
|
|
120
|
+
await recheck()
|
|
121
|
+
json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
|
|
122
|
+
}
|
|
123
|
+
let cached
|
|
124
|
+
try { cached = await deps.bundleCache.open(bundle, part as 'metadata' | 'contents') }
|
|
125
|
+
catch { json(res, 422, { error: 'bundle-unavailable' }); return }
|
|
126
|
+
await stream(cached, 'br')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function sources(res: ServerResponse, deps: ManagedHttpDeps, snapshot: WorkspaceShareSnapshot,
|
|
130
|
+
id: string, paths: Set<string>, recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null) => Promise<void>) {
|
|
131
|
+
const report = await deps.db.getReport(id)
|
|
132
|
+
const bundle = report?.bundleId ? await deps.db.getBundle(report.bundleId) : null
|
|
133
|
+
const empty = async () => { await recheck(); res.writeHead(204, { 'cache-control': 'private, no-store' }); res.end() }
|
|
134
|
+
if (!report || !bundle || !['stasis', 'sourcemap'].includes(bundle.kind ?? '') || bundle.repoId !== report.repoId) { await empty(); return }
|
|
135
|
+
if (!deps.reportSourcesCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
136
|
+
let cached
|
|
137
|
+
try { cached = await deps.reportSourcesCache.open(report, bundle, snapshot.permissions, paths) }
|
|
138
|
+
catch { await empty(); return }
|
|
139
|
+
if (!cached) { await empty(); return }
|
|
140
|
+
const current = await deps.db.getReport(id), currentBundle = await deps.db.getBundle(bundle.id)
|
|
141
|
+
if (JSON.stringify(current) !== JSON.stringify(report) || JSON.stringify(currentBundle) !== JSON.stringify(bundle)
|
|
142
|
+
|| snapshot.reports.find(item => item.id === id)?.repo.github !== cached.repo.github) {
|
|
143
|
+
cached.stream.destroy(); throw new TeamReportsError(404, 'workspace-changed')
|
|
144
|
+
}
|
|
145
|
+
await stream(cached, 'gzip')
|
|
146
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const MAX_TEAM_PATH = 500
|
|
2
|
+
|
|
3
|
+
// Canonicalize only equivalent Git paths: '/' separates components and empty
|
|
4
|
+
// or '.' components do not change the directory. Reject ambiguous input rather
|
|
5
|
+
// than deleting characters from directory names used for authorization.
|
|
6
|
+
// `{ ok:false }` makes the handler 400; `{ path:null }` means the whole repo.
|
|
7
|
+
export function normalizeTeamPath(raw: unknown): { ok: true; path: string | null } | { ok: false } {
|
|
8
|
+
if (typeof raw !== 'string') return { ok: true, path: null }
|
|
9
|
+
// Spaces and backslashes can be literal parts of Git directory names.
|
|
10
|
+
// Neither trimming nor treating '\\' as a separator preserves that identity.
|
|
11
|
+
if (/\p{Cc}/u.test(raw) || raw.includes('\\') || raw !== raw.trim()) return { ok: false }
|
|
12
|
+
const segments: string[] = []
|
|
13
|
+
for (const seg of raw.split('/')) {
|
|
14
|
+
if (seg === '' || seg === '.') continue
|
|
15
|
+
if (seg === '..' || seg !== seg.trim()) return { ok: false }
|
|
16
|
+
segments.push(seg)
|
|
17
|
+
}
|
|
18
|
+
const path = segments.join('/')
|
|
19
|
+
if (path.length > MAX_TEAM_PATH) return { ok: false }
|
|
20
|
+
return { ok: true, path: path === '' ? null : path }
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SQLite deployments store report blobs beside the database. Recover location
|
|
2
|
+
// metadata before introducing directory-based team access. Schema and data move
|
|
3
|
+
// together so a missing/corrupt blob leaves the migration retryable after repair.
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
7
|
+
import { reportRepoGithub } from '@preventive/report'
|
|
8
|
+
import { readManagedReport } from '../common/managed/report-content.ts'
|
|
9
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
10
|
+
|
|
11
|
+
export function migrateReportLocations(db: DatabaseSync, reportDir: string): void {
|
|
12
|
+
const columns = db.prepare('PRAGMA table_info(managed_report)').all() as { name: string }[]
|
|
13
|
+
if (columns.some((column) => column.name === 'repo_directory')) return
|
|
14
|
+
db.exec('BEGIN IMMEDIATE')
|
|
15
|
+
try {
|
|
16
|
+
db.exec("ALTER TABLE managed_report ADD COLUMN repo_directory TEXT NOT NULL DEFAULT ''")
|
|
17
|
+
const update = db.prepare('UPDATE managed_report SET repo_directory = ?, repo_embedded = ? WHERE id = ?')
|
|
18
|
+
const reports = db.prepare('SELECT id, filename FROM managed_report').all() as { id: string, filename: string }[]
|
|
19
|
+
for (const { id, filename } of reports) {
|
|
20
|
+
// Same opaque UUID boundary as the blob store; never splice arbitrary
|
|
21
|
+
// database text into a filesystem path during an upgrade.
|
|
22
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(id)) throw new Error(`Invalid legacy report id: ${id}`)
|
|
23
|
+
const { data } = readManagedReport(readFileSync(join(reportDir, id), 'utf8'), filename)
|
|
24
|
+
if (data == null) throw new Error(`Cannot migrate location of unreadable report ${id}`)
|
|
25
|
+
const embedded = reportRepoGithub(data) != null
|
|
26
|
+
const directory = normalizeTeamPath(data.repo?.directory)
|
|
27
|
+
if (!directory.ok) throw new Error(`Cannot migrate invalid directory of report ${id}`)
|
|
28
|
+
update.run(directory.path ?? '', embedded ? 1 : 0, id)
|
|
29
|
+
}
|
|
30
|
+
db.exec('COMMIT')
|
|
31
|
+
} catch (err) {
|
|
32
|
+
db.exec('ROLLBACK')
|
|
33
|
+
throw err
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Large workspaces remain a single request. Bound both the stored input and
|
|
2
|
+
// the encoded response, independently of the per-upload size limit.
|
|
3
|
+
export const MAX_REPORT_QUERY_COUNT = 4096
|
|
4
|
+
export const MAX_REPORT_QUERY_BYTES = 1024 * 1024 * 1024
|
|
5
|
+
|
|
6
|
+
// The import catalog scans a bounded page, not the whole installation. A
|
|
7
|
+
// report larger than the byte target occupies a page by itself; upload limits
|
|
8
|
+
// still bound that single report and the cursor must always make progress.
|
|
9
|
+
export const FINDING_CATALOG_PAGE_COUNT = 128
|
|
10
|
+
export const FINDING_CATALOG_PAGE_BYTES = 32 * 1024 * 1024
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { MIMEType } from 'node:util'
|
|
2
|
+
|
|
3
|
+
// The metadata envelope is opt-in. Wildcards keep the legacy raw report;
|
|
4
|
+
// an explicit, acceptable application/json range requests the envelope.
|
|
5
|
+
export function acceptsReportMetadata(accept: string | undefined): boolean {
|
|
6
|
+
// Commas inside quoted media-type parameters do not separate ranges.
|
|
7
|
+
for (const range of accept?.match(/(?:[^",]|"(?:\\.|[^"\\])*")+/gu) ?? []) {
|
|
8
|
+
try {
|
|
9
|
+
const media = new MIMEType(range.trim())
|
|
10
|
+
if (media.essence !== 'application/json') continue
|
|
11
|
+
const quality = media.params.get('q') ?? '1'
|
|
12
|
+
if (/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u.test(quality) && Number(quality) > 0) return true
|
|
13
|
+
} catch { /* Ignore malformed media ranges. */ }
|
|
14
|
+
}
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { extname } from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { gzip } from 'node:zlib'
|
|
6
|
+
import { bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
7
|
+
import { bundleSourceImports } from '../common/bundle-source-links.js'
|
|
8
|
+
import type { BundleDetails } from '../common/bundle-metadata.js'
|
|
9
|
+
import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
|
|
10
|
+
import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
|
|
11
|
+
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
12
|
+
import { readBundleDetails } from './bundle-cache.ts'
|
|
13
|
+
import type { BlobStore } from './blob-store.ts'
|
|
14
|
+
import type { BundleStore } from './bundle-store.ts'
|
|
15
|
+
import type { ManagedBundle, ManagedDb, ReportRecord } from './db.ts'
|
|
16
|
+
|
|
17
|
+
const compress = promisify(gzip)
|
|
18
|
+
const reportFormat = (filename: string) => extname(filename).toLowerCase()
|
|
19
|
+
|
|
20
|
+
function bundleDirectory(id: string) {
|
|
21
|
+
if (!/^[a-f\d-]{36}$/iu.test(id)) throw new Error('Invalid bundle id')
|
|
22
|
+
return id
|
|
23
|
+
}
|
|
24
|
+
function reportDirectory(bundleId: string, sha256: string) {
|
|
25
|
+
return `${bundleDirectory(bundleId)}/v1-${createHash('sha256').update(sha256).digest('hex')}`
|
|
26
|
+
}
|
|
27
|
+
function formatDirectory(bundleId: string, sha256: string, name: string) {
|
|
28
|
+
return `${reportDirectory(bundleId, sha256)}/${createHash('sha256').update(reportFormat(name)).digest('hex')}`
|
|
29
|
+
}
|
|
30
|
+
function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
31
|
+
const key = createHash('sha256').update(JSON.stringify([
|
|
32
|
+
'finding-access-v6', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
|
|
33
|
+
])).digest('hex')
|
|
34
|
+
return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Match the report's spelling exactly, then an unambiguous path suffix. Never
|
|
38
|
+
// guess between duplicate basenames or treat a bundle path as a disk pathname.
|
|
39
|
+
function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
|
|
40
|
+
const byBasename = new Map<string, string[]>()
|
|
41
|
+
for (const file of sources.keys()) {
|
|
42
|
+
const basename = file.split('/').at(-1)!
|
|
43
|
+
const candidates = byBasename.get(basename) ?? []
|
|
44
|
+
candidates.push(file)
|
|
45
|
+
byBasename.set(basename, candidates)
|
|
46
|
+
}
|
|
47
|
+
const files = new Map<string, string>(), paths = new Map<string, string>()
|
|
48
|
+
function add(path: unknown) {
|
|
49
|
+
if (typeof path !== 'string' || !path || paths.has(path)) return
|
|
50
|
+
let file = sources.has(path) ? path : null
|
|
51
|
+
if (file === null) {
|
|
52
|
+
const suffix = path.startsWith('/') ? path : `/${path}`
|
|
53
|
+
const matches = byBasename.get(path.split('/').at(-1)!)?.filter(candidate => candidate.endsWith(suffix)) ?? []
|
|
54
|
+
if (matches.length === 1) file = matches[0]!
|
|
55
|
+
}
|
|
56
|
+
if (file === null) return
|
|
57
|
+
paths.set(path, file)
|
|
58
|
+
files.set(file, sources.get(file)!)
|
|
59
|
+
}
|
|
60
|
+
for (const path of sourcePaths) add(path)
|
|
61
|
+
return { files: [...files], paths: [...paths] }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function encodeSources(integrity: string, details: BundleDetails, selection: ReturnType<typeof selectSources>) {
|
|
65
|
+
const sources = new Map(selection.files)
|
|
66
|
+
const imports = [...bundleSourceImports(details, sources)].map(([parent, targets]) => [parent, [...targets]])
|
|
67
|
+
const bundle = details.kind === 'stasis' ? details.bundle as { formats: Map<string, string> } : null
|
|
68
|
+
const formats = [...(bundle?.formats ?? [])].filter(([file]) => sources.has(file))
|
|
69
|
+
return compress(Buffer.from(JSON.stringify({ integrity, ...selection, imports, formats })), { level: 6 })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Immutable report hashes share a derivative across duplicate uploads. Bundle
|
|
73
|
+
// identity and visibility are part of the key: a broader viewer's sources must
|
|
74
|
+
// never populate a restricted response. Group derivatives by hash and filename
|
|
75
|
+
// format so each format's permissions can be removed after its last deletion.
|
|
76
|
+
export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, reports: BlobStore, bundles: BundleStore) {
|
|
77
|
+
const pending = new Map<string, { reportId: string; job: Promise<boolean> }>()
|
|
78
|
+
let queue = Promise.resolve()
|
|
79
|
+
function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
80
|
+
const job = queue.then(work)
|
|
81
|
+
queue = job.then(() => undefined, () => undefined)
|
|
82
|
+
return job
|
|
83
|
+
}
|
|
84
|
+
async function referenced(report: ReportRecord, bundle: ManagedBundle) {
|
|
85
|
+
const names = await db.listReportFilenamesWithBundleHash(bundle.id, report.sha256)
|
|
86
|
+
return names.some(name => reportFormat(name) === reportFormat(report.filename)) && await db.getBundle(bundle.id) != null
|
|
87
|
+
}
|
|
88
|
+
async function build(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
89
|
+
// Team requests supply paths from their already-filtered members. Reopening
|
|
90
|
+
// the original report by ID would conflate visible and hidden occurrences.
|
|
91
|
+
if (!sourcePaths) {
|
|
92
|
+
const bytes = await reports.get(report.id)
|
|
93
|
+
if (!bytes) return false
|
|
94
|
+
const parsed = await loadManagedFindings(filterReportContent(bytes.toString('utf8'), permissions, report.filename, repo), report.filename)
|
|
95
|
+
if (!parsed) return false
|
|
96
|
+
sourcePaths = managedFindingSourcePaths(parsed.findings)
|
|
97
|
+
}
|
|
98
|
+
const details = await readBundleDetails(bundle, bundles)
|
|
99
|
+
if (!details) return false
|
|
100
|
+
const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
|
|
101
|
+
const body = await encodeSources(bundle.integrity, details, selection)
|
|
102
|
+
// A duplicate with the same hash AND format can use these parsed bytes.
|
|
103
|
+
// Another format must not keep a deleted variant's late build alive.
|
|
104
|
+
if (!(await referenced(report, bundle))) return false
|
|
105
|
+
await storage.put(target, body)
|
|
106
|
+
// Another instance may finish deletion while this write is in flight.
|
|
107
|
+
// Recheck after publishing so a late builder cannot resurrect its cache.
|
|
108
|
+
if (!(await referenced(report, bundle))) {
|
|
109
|
+
await storage.delete(formatDirectory(bundle.id, report.sha256, report.filename))
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
async function ensure(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>): Promise<boolean> {
|
|
115
|
+
const existing = pending.get(target)
|
|
116
|
+
if (existing) {
|
|
117
|
+
const ready = await existing.job
|
|
118
|
+
if (ready || existing.reportId === report.id) return ready
|
|
119
|
+
// Deletion can also win before the initiator reads its blob. Retry with
|
|
120
|
+
// this request's row instead of caching that other row's miss as 204.
|
|
121
|
+
const initiator = await db.getReport(existing.reportId)
|
|
122
|
+
if (initiator?.bundleId === bundle.id && initiator.sha256 === report.sha256) return false
|
|
123
|
+
if (pending.get(target) === existing) pending.delete(target)
|
|
124
|
+
return ensure(target, report, bundle, permissions, repo, sourcePaths)
|
|
125
|
+
}
|
|
126
|
+
const job = (async () => {
|
|
127
|
+
if (await storage.exists(target)) return true
|
|
128
|
+
// Serial cold builds bound peak decompression/parsing memory.
|
|
129
|
+
return enqueue(() => build(target, report, bundle, permissions, repo, sourcePaths))
|
|
130
|
+
})()
|
|
131
|
+
const entry = { reportId: report.id, job }
|
|
132
|
+
pending.set(target, entry)
|
|
133
|
+
try { return await job } finally { if (pending.get(target) === entry) pending.delete(target) }
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
|
|
137
|
+
const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
|
|
138
|
+
const target = sourceCacheFilename(report, bundle, permissions, repo, sourcePaths)
|
|
139
|
+
try { return { ...await storage.open(target), repo } }
|
|
140
|
+
catch (error) { if (!(error instanceof CacheMissError)) throw error }
|
|
141
|
+
if (!(await ensure(target, report, bundle, permissions, repo, sourcePaths))) return null
|
|
142
|
+
const opened = await storage.open(target)
|
|
143
|
+
return { ...opened, repo }
|
|
144
|
+
},
|
|
145
|
+
async deleteBundle(id: string) {
|
|
146
|
+
const prefix = `${bundleDirectory(id)}/`
|
|
147
|
+
await Promise.allSettled([...pending].filter(([key]) => key.startsWith(prefix)).map(([, entry]) => entry.job))
|
|
148
|
+
await storage.delete(bundleDirectory(id))
|
|
149
|
+
},
|
|
150
|
+
async deleteReport(report: Pick<ReportRecord, 'bundleId' | 'sha256' | 'filename'>) {
|
|
151
|
+
const { bundleId, sha256 } = report
|
|
152
|
+
if (bundleId === null) return
|
|
153
|
+
// Called after metadata deletion. Serialize with builders so a cold
|
|
154
|
+
// request cannot recreate a derivative after its cleanup has finished.
|
|
155
|
+
await enqueue(async () => {
|
|
156
|
+
const names = await db.listReportFilenamesWithBundleHash(bundleId, sha256)
|
|
157
|
+
if (names.length === 0) {
|
|
158
|
+
await storage.delete(reportDirectory(bundleId, sha256))
|
|
159
|
+
} else if (!names.some(name => reportFormat(name) === reportFormat(report.filename))) {
|
|
160
|
+
await storage.delete(formatDirectory(bundleId, sha256, report.filename))
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export type ReportSourcesCache = ReturnType<typeof createReportSourcesCache>
|