@preventive/triage 1.0.0-alpha.27 → 1.0.0-alpha.29
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/client/linked-findings.js +1 -1
- package/common/bundle-metadata.js +12 -10
- package/common/code-language.js +17 -2
- package/common/default-scan-models.ts +3 -3
- package/common/finding-id.js +1 -1
- package/common/managed/report-content.ts +1 -1
- package/common/managed/report-filter.ts +1 -1
- package/out/client-managed-import.js +16 -16
- package/out/client-managed.js +93 -68
- package/out/graph.js +11 -11
- package/out/index.html +1 -1
- package/out/prism.js +2 -2
- package/out/stasis.svg +0 -25
- package/out/terminal.js +163 -163
- package/out/view.css +1 -1
- package/out/view.js +223 -190
- package/package.json +21 -28
- package/server-e2e/static.ts +3 -4
- 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 +22 -13
- package/server-managed/bundle-summary-cache.ts +27 -9
- package/server-managed/bundle.ts +66 -1
- package/server-managed/cache-storage.ts +4 -0
- package/server-managed/content-access.ts +1 -1
- package/server-managed/db-methods.ts +122 -20
- package/server-managed/db-neon.ts +121 -32
- package/server-managed/db-schema.ts +2 -0
- package/server-managed/db.ts +5 -1
- package/server-managed/github-app.ts +10 -15
- package/server-managed/github-errors.ts +21 -0
- package/server-managed/github-issues.ts +1 -1
- package/server-managed/github-metadata.ts +34 -1
- package/server-managed/github-pulls.ts +142 -30
- package/server-managed/http.ts +244 -131
- package/server-managed/index.ts +28 -11
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/object-storage-vercel.ts +1 -1
- package/server-managed/public-workspace.ts +20 -14
- package/server-managed/report-migration.ts +1 -1
- package/server-managed/report-sources.ts +14 -4
- package/server-managed/repository-browser.ts +3 -0
- package/server-managed/repository-discovery.ts +1 -1
- package/server-managed/revisions.ts +35 -0
- package/server-managed/sql-postgres.ts +1 -0
- package/server-managed/sql.ts +14 -3
- package/server-managed/storage-db.ts +12 -5
- package/server-managed/storage-maintenance.ts +47 -22
- package/server-managed/storage-payload.ts +16 -4
- package/server-managed/storage-stores.ts +2 -2
- package/server-managed/storage.ts +6 -4
- package/server-managed/team-feed.ts +50 -5
- package/server-managed/team-reports.ts +85 -22
- package/server-managed/workspace-shares.ts +15 -3
- package/report/index.js +0 -260
- package/report/src/finding-id.js +0 -80
- package/report/src/finding.js +0 -314
- package/report/src/labels.js +0 -35
- package/report/src/md-structure.js +0 -471
- package/report/src/md-text.js +0 -167
- package/report/src/meta.js +0 -76
- package/report/src/parse-codex.js +0 -149
- package/report/src/parse-deepsec.js +0 -199
- package/report/src/parse-deepview-fields.js +0 -375
- package/report/src/parse-deepview-md.js +0 -186
- package/report/src/parse-md-id.js +0 -137
- package/report/src/parse-md.js +0 -323
- package/report/src/parse-piolium-id.js +0 -79
- package/report/src/parse-piolium-rows.js +0 -126
- package/report/src/parse-piolium-tokens.js +0 -185
- package/report/src/parse-piolium.js +0 -399
- package/report/src/security.js +0 -63
- package/report/src/severity.js +0 -18
- package/report/src/utf8.js +0 -21
- package/report/src/write-md-finding.js +0 -273
- package/report/src/write-md.js +0 -291
- /package/{LICENSE → LICENSE-MIT} +0 -0
package/server-managed/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Managed HTTP app and standalone boot. The combined launcher mounts this
|
|
2
2
|
// same app on e2e's listener; storage, routing and cleanup stay here.
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
3
4
|
import { type Server, createServer } from 'node:http'
|
|
4
5
|
import { fileURLToPath } from 'node:url'
|
|
5
6
|
import { createOriginGate } from '../server-common/origin.ts'
|
|
@@ -48,17 +49,31 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
48
49
|
|
|
49
50
|
let cleanup: Promise<void> | undefined
|
|
50
51
|
let nextCleanupAt = 0
|
|
51
|
-
|
|
52
|
+
const maintenance = new AbortController()
|
|
53
|
+
function reap(automatic = false): Promise<void> {
|
|
52
54
|
if (cleanup) return cleanup
|
|
55
|
+
if (isShuttingDown()) return Promise.resolve()
|
|
53
56
|
const startedAt = Date.now()
|
|
54
57
|
nextCleanupAt = startedAt + REAP_INTERVAL_MS
|
|
55
58
|
let sessions = 0, uploads = 0
|
|
56
|
-
cleanup =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
cleanup = (async () => {
|
|
60
|
+
const owner = automatic && config.serverless ? randomUUID() : null
|
|
61
|
+
if (owner && !await db.claimMaintenanceLease(owner, startedAt, startedAt + Math.max(300_000, (config.storageEncryptionMigrateMaxMs ?? 0) + 60_000), config.storageEncryptionMigrate)) return false
|
|
62
|
+
let succeeded = false
|
|
63
|
+
try {
|
|
64
|
+
await runReapers({
|
|
65
|
+
sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
|
|
66
|
+
...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!(maintenance.signal) } } : {}),
|
|
67
|
+
...('reapStorage' in storage ? { storage: () => storage.reapStorage(maintenance.signal) } : {}),
|
|
68
|
+
})
|
|
69
|
+
succeeded = true
|
|
70
|
+
return true
|
|
71
|
+
} finally {
|
|
72
|
+
if (owner) await db.finishMaintenanceLease(owner, Date.now() + (succeeded ? REAP_INTERVAL_MS : REAP_RETRY_MS))
|
|
73
|
+
}
|
|
74
|
+
})().then(ran => {
|
|
61
75
|
nextCleanupAt = Date.now() + REAP_INTERVAL_MS
|
|
76
|
+
if (!ran) return
|
|
62
77
|
return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
|
|
63
78
|
}, err => {
|
|
64
79
|
nextCleanupAt = Date.now() + REAP_RETRY_MS
|
|
@@ -69,14 +84,15 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
69
84
|
}
|
|
70
85
|
function automaticReap(): Promise<void> {
|
|
71
86
|
if (isShuttingDown() || cleanup || Date.now() < nextCleanupAt) return Promise.resolve()
|
|
72
|
-
return reap().catch(err => console.warn('managed-reaper: cleanup failed:', err))
|
|
87
|
+
return reap(true).catch(err => console.warn('managed-reaper: cleanup failed:', err))
|
|
73
88
|
}
|
|
74
89
|
async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
|
|
75
|
-
const
|
|
76
|
-
// Send the normal response without waiting for storage housekeeping
|
|
77
|
-
//
|
|
90
|
+
const pendingMaintenance = automaticReap()
|
|
91
|
+
// Send the normal response without waiting for storage housekeeping. The
|
|
92
|
+
// Vercel entrypoint registers this whole promise with waitUntil; awaiting
|
|
93
|
+
// work after res.end() alone cannot extend a serverless invocation.
|
|
78
94
|
try { await routeRequest(req, res) }
|
|
79
|
-
finally { await
|
|
95
|
+
finally { await pendingMaintenance }
|
|
80
96
|
}
|
|
81
97
|
const gcTimer = config.serverless ? null : setInterval(() => {
|
|
82
98
|
void automaticReap()
|
|
@@ -85,6 +101,7 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
85
101
|
|
|
86
102
|
function stop(): void {
|
|
87
103
|
shuttingDown = true
|
|
104
|
+
maintenance.abort()
|
|
88
105
|
if (gcTimer) clearInterval(gcTimer)
|
|
89
106
|
}
|
|
90
107
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { roleAtLeast } from '../common/managed/roles.ts'
|
|
2
|
+
import { type RepoScope, contentAccess } from './content-access.ts'
|
|
3
|
+
import type { AdminBundle, AdminReport, ManagedDb } from './db-methods.ts'
|
|
4
|
+
import { ManagedMutationError } from './management.ts'
|
|
5
|
+
|
|
6
|
+
type CatalogAccess = { repos: { repoId: number; fullName: string }[]; repoScopes: RepoScope[] | null }
|
|
7
|
+
export interface ManagementCatalogStore {
|
|
8
|
+
getReportCatalog(sessionId: string, now: number): Promise<CatalogAccess & { reports: (AdminReport & { canChangeRepo: boolean })[] }>
|
|
9
|
+
getBundleCatalog(sessionId: string, now: number): Promise<CatalogAccess & { bundles: (AdminBundle & { canChangeRepo: boolean })[] }>
|
|
10
|
+
}
|
|
11
|
+
type CatalogDb = Pick<ManagedDb, 'getReportAccessSnapshot' | 'listRepoScopesForUser' | 'listSelectedRepos'
|
|
12
|
+
| 'listReports' | 'listBundles' | 'listReadableBundleIds'>
|
|
13
|
+
|
|
14
|
+
// The outer store method owns one read snapshot. Use raw methods here so the
|
|
15
|
+
// session, grants, rows and action flags agree without per-row transactions.
|
|
16
|
+
export function managementCatalogMethods(db: CatalogDb): ManagementCatalogStore {
|
|
17
|
+
async function access(sessionId: string, now: number) {
|
|
18
|
+
const snapshot = await db.getReportAccessSnapshot(sessionId, now, [])
|
|
19
|
+
if (!snapshot) throw new ManagedMutationError(401, 'unauthenticated')
|
|
20
|
+
if (!roleAtLeast(snapshot.user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
|
|
21
|
+
const scope = await contentAccess(db, snapshot.user)
|
|
22
|
+
const repos = (await db.listSelectedRepos()).filter(scope.bundle).map(({ repoId, fullName }) => ({ repoId, fullName }))
|
|
23
|
+
return { userId: snapshot.user.role === 'admin' ? undefined : snapshot.user.id, scope, repos }
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
async getReportCatalog(sessionId, now) {
|
|
27
|
+
const { userId, scope, repos } = await access(sessionId, now)
|
|
28
|
+
const reports = await db.listReports(userId)
|
|
29
|
+
const linked = reports.flatMap(report => report.bundleId ? [report.bundleId] : [])
|
|
30
|
+
const readable = userId === undefined ? new Set(linked) : new Set(await db.listReadableBundleIds(userId, linked))
|
|
31
|
+
return { repos, repoScopes: scope.scopes, reports: reports.map(report => ({ ...report,
|
|
32
|
+
canChangeRepo: report.repoId === null || scope.report(report),
|
|
33
|
+
bundleId: report.bundleId && readable.has(report.bundleId) ? report.bundleId : null,
|
|
34
|
+
bundleFilename: report.bundleId && readable.has(report.bundleId) ? report.bundleFilename : null,
|
|
35
|
+
})) }
|
|
36
|
+
},
|
|
37
|
+
async getBundleCatalog(sessionId, now) {
|
|
38
|
+
const { userId, scope, repos } = await access(sessionId, now)
|
|
39
|
+
const bundles = await db.listBundles(userId)
|
|
40
|
+
return { repos, repoScopes: scope.scopes, bundles: bundles.map(bundle => ({ ...bundle,
|
|
41
|
+
canChangeRepo: bundle.repoId === null || scope.report(bundle),
|
|
42
|
+
})) }
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -24,7 +24,7 @@ export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk
|
|
|
24
24
|
}
|
|
25
25
|
return { size: result.blob.size === 0 ? null : result.blob.size, version: result.blob.etag,
|
|
26
26
|
modifiedAt: new Date(result.blob.uploadedAt ?? Date.now()).getTime(),
|
|
27
|
-
stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0]) }
|
|
27
|
+
stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0], signal ? { signal } : {}) }
|
|
28
28
|
} catch (err) { if (isNotFound(err, blobs)) return null; throw err }
|
|
29
29
|
},
|
|
30
30
|
async put(key, bytes, signal, expected, sizeHint) {
|
|
@@ -5,13 +5,13 @@ 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 } from './http-response.ts'
|
|
8
|
+
import { sendJson, writeResponse } from './http-response.ts'
|
|
9
9
|
import { hashToken } from './crypto.ts'
|
|
10
|
-
import { TeamReportsError,
|
|
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
|
-
import {
|
|
14
|
+
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
15
15
|
import { serveTeamFeed } from './team-feed.ts'
|
|
16
16
|
|
|
17
17
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
@@ -28,7 +28,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
28
28
|
const method = req.method ?? 'GET'
|
|
29
29
|
if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
|
|
30
30
|
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
31
|
-
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed)$/u.exec(url.pathname)
|
|
31
|
+
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
|
|
32
32
|
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
33
33
|
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
|
|
34
34
|
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
@@ -72,10 +72,16 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
72
72
|
if (teamRoute[1] !== snapshot.teamId) { json(res, 404, { error: 'no-team' }); return }
|
|
73
73
|
if (teamRoute[2] === 'feed') {
|
|
74
74
|
if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
|
|
75
|
-
await serveTeamFeed(res, deps, snapshot, recheck); 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
|
|
76
79
|
}
|
|
77
80
|
if (teamRoute[2] === 'shared') { await sendTeam(); return }
|
|
78
|
-
|
|
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
|
|
79
85
|
}
|
|
80
86
|
if (reportRoute) {
|
|
81
87
|
const id = reportRoute[1]!, part = reportRoute[2]!
|
|
@@ -103,16 +109,16 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
|
|
|
103
109
|
if (!deps.bundleCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
104
110
|
if (part === 'advisories') {
|
|
105
111
|
if (bundle.kind !== 'stasis') { json(res, 422, { error: 'unsupported-bundle' }); return }
|
|
106
|
-
const
|
|
112
|
+
const inventory = await deps.bundleCache.advisoryInventory(bundle, url.searchParams.get('reason') ?? '')
|
|
107
113
|
await recheck()
|
|
108
|
-
if (
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
})
|
|
114
120
|
await recheck()
|
|
115
|
-
json(res, result.status, result.status === 200 ? {
|
|
121
|
+
json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
|
|
116
122
|
}
|
|
117
123
|
let cached
|
|
118
124
|
try { cached = await deps.bundleCache.open(bundle, part as 'metadata' | 'contents') }
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { readFileSync } from 'node:fs'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import type { DatabaseSync } from 'node:sqlite'
|
|
7
|
-
import { reportRepoGithub } from '
|
|
7
|
+
import { reportRepoGithub } from '@preventive/report'
|
|
8
8
|
import { readManagedReport } from '../common/managed/report-content.ts'
|
|
9
9
|
import { normalizeTeamPath } from './repo-path.ts'
|
|
10
10
|
|
|
@@ -5,9 +5,10 @@ import { promisify } from 'node:util'
|
|
|
5
5
|
import { gzip } from 'node:zlib'
|
|
6
6
|
import { bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
7
7
|
import { bundleSourceImports } from '../common/bundle-source-links.js'
|
|
8
|
+
import type { BundleDetails } from '../common/bundle-metadata.js'
|
|
8
9
|
import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
|
|
9
10
|
import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
|
|
10
|
-
import type
|
|
11
|
+
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
11
12
|
import { readBundleDetails } from './bundle-cache.ts'
|
|
12
13
|
import type { BlobStore } from './blob-store.ts'
|
|
13
14
|
import type { BundleStore } from './bundle-store.ts'
|
|
@@ -28,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
|
|
|
28
29
|
}
|
|
29
30
|
function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
30
31
|
const key = createHash('sha256').update(JSON.stringify([
|
|
31
|
-
'finding-access-
|
|
32
|
+
'finding-access-v6', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
|
|
32
33
|
])).digest('hex')
|
|
33
34
|
return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
|
|
34
35
|
}
|
|
@@ -60,6 +61,14 @@ function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
|
|
|
60
61
|
return { files: [...files], paths: [...paths] }
|
|
61
62
|
}
|
|
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
|
+
|
|
63
72
|
// Immutable report hashes share a derivative across duplicate uploads. Bundle
|
|
64
73
|
// identity and visibility are part of the key: a broader viewer's sources must
|
|
65
74
|
// never populate a restricted response. Group derivatives by hash and filename
|
|
@@ -89,8 +98,7 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
|
|
|
89
98
|
const details = await readBundleDetails(bundle, bundles)
|
|
90
99
|
if (!details) return false
|
|
91
100
|
const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
|
|
92
|
-
const
|
|
93
|
-
const body = await compress(Buffer.from(JSON.stringify({ integrity: bundle.integrity, ...selection, imports })), { level: 6 })
|
|
101
|
+
const body = await encodeSources(bundle.integrity, details, selection)
|
|
94
102
|
// A duplicate with the same hash AND format can use these parsed bytes.
|
|
95
103
|
// Another format must not keep a deleted variant's late build alive.
|
|
96
104
|
if (!(await referenced(report, bundle))) return false
|
|
@@ -128,6 +136,8 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
|
|
|
128
136
|
async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
|
|
129
137
|
const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
|
|
130
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 }
|
|
131
141
|
if (!(await ensure(target, report, bundle, permissions, repo, sourcePaths))) return null
|
|
132
142
|
const opened = await storage.open(target)
|
|
133
143
|
return { ...opened, repo }
|
|
@@ -97,6 +97,9 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
|
|
|
97
97
|
const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
|
|
98
98
|
return {
|
|
99
99
|
recheckAccess: () => checkAccess(true),
|
|
100
|
+
// Server-only credential, selected by the same live visibility/permission
|
|
101
|
+
// gate as directory browsing. Never included in an HTTP response.
|
|
102
|
+
readToken: () => token,
|
|
100
103
|
async refs() {
|
|
101
104
|
// Suggestions are bounded; the input also accepts any branch or tag name.
|
|
102
105
|
const results = await Promise.all(['branches', 'tags'].map(async kind => {
|
|
@@ -65,7 +65,7 @@ export class RepositoryDiscovery {
|
|
|
65
65
|
// Cache the two sources separately so failed installation discovery is
|
|
66
66
|
// retried without caching a degraded combined list for another minute.
|
|
67
67
|
const [userRepos, installedRepos] = await Promise.all([
|
|
68
|
-
this.read(key, () => listUserRepos(token
|
|
68
|
+
this.read(key, () => listUserRepos(token)),
|
|
69
69
|
installed().catch((err: unknown) => {
|
|
70
70
|
console.warn('managed: installed repo deduplication failed:', err)
|
|
71
71
|
return []
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Internal invalidation counters. Never expose these global values to clients:
|
|
2
|
+
// feeds still publish only the existing user/visibility-scoped revisions.
|
|
3
|
+
const catalogTables = ['managed_team', 'managed_team_user', 'managed_team_repo', 'managed_report', 'managed_bundle', 'managed_selected_repo']
|
|
4
|
+
const annotationTables = ['managed_finding_triage', 'managed_finding_triage_event', 'managed_finding_comment']
|
|
5
|
+
export function revisionSchema(postgres = false): string {
|
|
6
|
+
let sql = `CREATE TABLE IF NOT EXISTS managed_maintenance_lease (
|
|
7
|
+
id INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL
|
|
8
|
+
); CREATE TABLE IF NOT EXISTS managed_change_revision (
|
|
9
|
+
id INTEGER PRIMARY KEY, catalog INTEGER NOT NULL, annotations INTEGER NOT NULL
|
|
10
|
+
); INSERT INTO managed_change_revision VALUES (1, 0, 0) ON CONFLICT(id) DO NOTHING;`
|
|
11
|
+
for (const [column, tables] of [['catalog', catalogTables], ['annotations', annotationTables]] as const) {
|
|
12
|
+
const update = `UPDATE managed_change_revision SET ${column} = ${column} + 1 WHERE id = 1;`
|
|
13
|
+
if (postgres) {
|
|
14
|
+
sql += `CREATE OR REPLACE FUNCTION managed_${column}_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
15
|
+
BEGIN IF EXISTS (SELECT 1 FROM managed_revision_rows) THEN ${update} END IF; RETURN NULL; END $$;`
|
|
16
|
+
}
|
|
17
|
+
for (const table of tables) {
|
|
18
|
+
if (postgres) {
|
|
19
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision ON ${table};`
|
|
20
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
21
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision_${event} ON ${table};
|
|
22
|
+
CREATE TRIGGER ${table}_revision_${event} AFTER ${event} ON ${table}
|
|
23
|
+
REFERENCING ${event === 'DELETE' ? 'OLD' : 'NEW'} TABLE AS managed_revision_rows
|
|
24
|
+
FOR EACH STATEMENT EXECUTE FUNCTION managed_${column}_changed();`
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
28
|
+
sql += `CREATE TRIGGER IF NOT EXISTS ${table}_revision_${event}
|
|
29
|
+
AFTER ${event} ON ${table} BEGIN ${update} END;`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return sql
|
|
35
|
+
}
|
|
@@ -4,6 +4,7 @@ export function postgresSql(source: string): { sql: string; names: string[] } {
|
|
|
4
4
|
const names: string[] = []
|
|
5
5
|
let position = 0
|
|
6
6
|
let sql = source.replaceAll('json_each(:contexts)', 'jsonb_array_elements(:contexts::jsonb)')
|
|
7
|
+
.replaceAll("CAST(unixepoch('subsec') * 1000 AS INTEGER)", 'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)')
|
|
7
8
|
.replaceAll(/json_extract\((\w+\.value), '\$\.(\w+)'\)/gu, "($1->>'$2')")
|
|
8
9
|
.replaceAll('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
|
|
9
10
|
.replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
|
package/server-managed/sql.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Each public store operation owns a transaction. SQLite serializes access to
|
|
2
|
-
// its single connection across awaits; Neon
|
|
2
|
+
// its single connection across awaits; Neon can reuse a request-owned connection.
|
|
3
3
|
import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
|
|
4
4
|
import type { ManagedDb } from './db-methods.ts'
|
|
5
5
|
|
|
@@ -13,14 +13,25 @@ export interface ManagedSql {
|
|
|
13
13
|
close(): MaybePromise<void>
|
|
14
14
|
}
|
|
15
15
|
export interface ManagedSqlDriver extends ManagedSql {
|
|
16
|
-
scope<T>(write: boolean, work: () => Promise<T
|
|
16
|
+
scope<T>(write: boolean, work: () => Promise<T>, options?: { lock?: boolean; statement?: boolean }): Promise<T>
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// These operations execute exactly one SQL statement. Postgres already gives
|
|
20
|
+
// that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
|
|
21
|
+
// Lease writes coordinate through their conditional UPSERT/UPDATE alone.
|
|
22
|
+
const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
|
|
23
|
+
'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
|
|
24
|
+
'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease'])
|
|
25
|
+
|
|
19
26
|
export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
|
|
20
27
|
const entries = Object.entries(methods).map(([name, method]) => {
|
|
21
28
|
if (name === 'close') return [name, async () => { await driver.close() }]
|
|
22
29
|
const write = !/^(?:get|list|userCanRead|reportPermissionsFor)/u.test(name)
|
|
23
|
-
return [name, (...args: unknown[]) => driver.scope(write, () => method(...args)
|
|
30
|
+
return [name, (...args: unknown[]) => driver.scope(write, () => method(...args), {
|
|
31
|
+
// Presence writes only one user's timestamp; they need no application
|
|
32
|
+
// writer lock. Keep the session/user read and timestamp in one transaction.
|
|
33
|
+
lock: write && name !== 'sessionWithUser', statement: SINGLE_STATEMENTS.has(name),
|
|
34
|
+
})]
|
|
24
35
|
})
|
|
25
36
|
return Object.fromEntries(entries) as ManagedDb
|
|
26
37
|
}
|
|
@@ -84,11 +84,18 @@ export function decodeStorageTokens(key: StorageKey, id: string, row: StoredToke
|
|
|
84
84
|
return { access: decode('access', row.access), refresh: decode('refresh', row.refresh) }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
// Fixed-width sizes make the saved position sort by type, then numeric byte
|
|
88
|
+
// size, then ID in both SQLite and Postgres. Sixteen digits cover JS safe sizes.
|
|
89
|
+
const PADDED_SIZE = "substr('0000000000000000', 1, 16 - length(CAST(byte_size AS TEXT))) || CAST(byte_size AS TEXT)"
|
|
90
|
+
const PENDING = `SELECT '0:' || ${PADDED_SIZE} || ':' || id AS position, id, 'report' AS type FROM managed_report WHERE storage_encrypted = 0
|
|
91
|
+
UNION ALL SELECT '1:' || ${PADDED_SIZE} || ':' || id AS position, id, 'bundle' AS type FROM managed_bundle WHERE storage_encrypted = 0
|
|
92
|
+
UNION ALL SELECT '2:0000000000000000:' || id AS position, id, 'user' AS type FROM managed_user
|
|
90
93
|
WHERE gh_tokens_encrypted = 0 AND (gh_access_token IS NOT NULL OR gh_refresh_token IS NOT NULL)`
|
|
91
94
|
|
|
95
|
+
// Restart the pending pass for a cursor saved under the previous ID ordering.
|
|
96
|
+
// Completed rows are excluded by PENDING; the original cursor still fences CAS.
|
|
97
|
+
const orderedCursor = (cursor: string | null): string => cursor && /^[012]:[0-9]{16}:/u.test(cursor) ? cursor : ''
|
|
98
|
+
|
|
92
99
|
export function storageMethods(db: ManagedSql, key: StorageKey | null): StorageDb {
|
|
93
100
|
async function requireEnabled() {
|
|
94
101
|
const state = await storageState(db, key)
|
|
@@ -135,11 +142,11 @@ export function storageMethods(db: ManagedSql, key: StorageKey | null): StorageD
|
|
|
135
142
|
async listStorageMigrationRows(after, limit) {
|
|
136
143
|
await requireEnabled()
|
|
137
144
|
return await db.prepare(`SELECT position, id, type FROM (${PENDING}) AS pending
|
|
138
|
-
WHERE position > ? ORDER BY position LIMIT ?`).all(after
|
|
145
|
+
WHERE position > ? ORDER BY position LIMIT ?`).all(orderedCursor(after), limit) as StorageMigrationRow[]
|
|
139
146
|
},
|
|
140
147
|
async advanceStorageMigration(expected, next) {
|
|
141
148
|
await requireEnabled()
|
|
142
|
-
if (
|
|
149
|
+
if (next !== null && next <= orderedCursor(expected)) return
|
|
143
150
|
// End-of-pass completion checks every pending row, including failed rows
|
|
144
151
|
// before the cursor. A failure cannot be mistaken for a finished pass.
|
|
145
152
|
await db.prepare(`UPDATE managed_storage_encryption SET cursor = ?, complete =
|
|
@@ -2,9 +2,9 @@ import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../
|
|
|
2
2
|
import { ENCRYPTED_CACHE_PREFIX, type RawObjectStorage, deleteObjects, isBlobId, openObjectVersion } from './object-storage.ts'
|
|
3
3
|
import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, unwrapDataKey } from './storage-db.ts'
|
|
4
4
|
import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
|
|
5
|
-
import { inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
|
|
5
|
+
import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
|
|
6
6
|
|
|
7
|
-
async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal) {
|
|
7
|
+
async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal): Promise<string | void> {
|
|
8
8
|
if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
|
|
9
9
|
const row = await db.ensureStorageDataKey(item.type, item.id)
|
|
10
10
|
if (!row || row.encrypted) return
|
|
@@ -13,7 +13,7 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
13
13
|
const source = await raw.open(identity, signal)
|
|
14
14
|
if (!source) {
|
|
15
15
|
if (!await db.getStorageRow(item.type, item.id)) return
|
|
16
|
-
throw new
|
|
16
|
+
throw new StoragePayloadError('Migration payload unavailable')
|
|
17
17
|
}
|
|
18
18
|
let inspected = await inspectStorageObject(source)
|
|
19
19
|
if (inspected.encrypted) {
|
|
@@ -26,7 +26,7 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
26
26
|
// Legacy arbitrary bytes can share the magic prefix. A matching
|
|
27
27
|
// original upload hash is required before encrypting them as plaintext.
|
|
28
28
|
const legacy = await openObjectVersion(raw, identity, source.version, signal)
|
|
29
|
-
if (!legacy) return
|
|
29
|
+
if (!legacy) return 'Payload disappeared or changed version before plaintext verification'
|
|
30
30
|
inspected = { ...legacy, encrypted: false }
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -34,18 +34,18 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
34
34
|
await verifyStoragePayload(item.type, row, inspected.stream, signal)
|
|
35
35
|
signal.throwIfAborted()
|
|
36
36
|
const current = await openObjectVersion(raw, identity, source.version, signal)
|
|
37
|
-
if (!current) return
|
|
37
|
+
if (!current) return 'Payload disappeared or changed version before encryption'
|
|
38
38
|
const encrypted = encryptStorageStream(current.stream, dataKey, identity, current.size)
|
|
39
39
|
let replaced: boolean
|
|
40
40
|
try { replaced = await raw.put(identity, encrypted, signal, source.version, current.size ?? undefined) }
|
|
41
41
|
finally { encrypted.destroy(); current.stream.destroy() }
|
|
42
|
-
if (!replaced) return
|
|
42
|
+
if (!replaced) return 'Conditional replacement rejected (object version precondition failed)'
|
|
43
43
|
// Do not mark the row encrypted until the stored bytes authenticate and
|
|
44
44
|
// match the original upload (including decompression for sourcemaps).
|
|
45
45
|
const replacement = await raw.open(identity, signal)
|
|
46
46
|
if (!replacement) {
|
|
47
47
|
if (!await db.getStorageRow(item.type, item.id)) return
|
|
48
|
-
throw new
|
|
48
|
+
throw new StoragePayloadError('Migrated payload unavailable')
|
|
49
49
|
}
|
|
50
50
|
const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
|
|
51
51
|
await verifyStoragePayload(item.type, row, decoded.stream, signal)
|
|
@@ -130,38 +130,60 @@ async function cleanupLegacy(raw: RawObjectStorage, db: StorageDb, state: Storag
|
|
|
130
130
|
return { processed: page.objects.length || 1, retryAt }
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
interface StorageMigrationResult extends StorageEncryptionState {
|
|
133
|
+
interface StorageMigrationResult extends StorageEncryptionState {
|
|
134
|
+
retryAt: number | null
|
|
135
|
+
failures: { type: StorageMigrationRow['type']; id: string; message: string }[]
|
|
136
|
+
}
|
|
137
|
+
function rowFailure(row: StorageMigrationRow, message: string) {
|
|
138
|
+
const failure = { type: row.type, id: row.id, message }
|
|
139
|
+
// Emit before continuing: a later failure or terminated invocation must not
|
|
140
|
+
// hide earlier diagnostics. Never include provider errors or storage values.
|
|
141
|
+
console.warn('managed-storage-migration-row:', JSON.stringify(failure))
|
|
142
|
+
return failure
|
|
143
|
+
}
|
|
144
|
+
interface MigrationOptions { maxObjects?: number; maxMs?: number | undefined; signal?: AbortSignal | undefined }
|
|
134
145
|
export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
135
|
-
{ maxObjects = 64, maxMs = 150_000 } = {}): Promise<StorageMigrationResult> {
|
|
146
|
+
{ maxObjects = 64, maxMs = 150_000, signal: stopping }: MigrationOptions = {}): Promise<StorageMigrationResult> {
|
|
136
147
|
if (!Number.isSafeInteger(maxObjects) || maxObjects < 1 || !Number.isSafeInteger(maxMs) || maxMs < 1) throw new Error('Invalid migration budget')
|
|
137
|
-
const deadline = Date.now() + maxMs,
|
|
148
|
+
const deadline = Date.now() + maxMs, timeout = AbortSignal.timeout(maxMs)
|
|
149
|
+
const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
|
|
138
150
|
const errors: unknown[] = []
|
|
151
|
+
const failures: StorageMigrationResult['failures'] = []
|
|
139
152
|
let processed = 0, retryAt: number | null = null, state = await db.getStorageEncryption()
|
|
140
153
|
if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
141
154
|
// At most one SQL pass per invocation. Checkpoint each row, including a
|
|
142
155
|
// failure: other rows progress, and the failed row is retried next pass.
|
|
143
|
-
while (!state.complete && processed < maxObjects && Date.now() < deadline) {
|
|
156
|
+
while (!state.complete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
|
|
144
157
|
const rows = await db.listStorageMigrationRows(state.cursor, Math.min(16, maxObjects - processed))
|
|
158
|
+
if (stopping?.aborted) break
|
|
145
159
|
if (rows.length === 0) { await db.advanceStorageMigration(state.cursor, null); break }
|
|
146
160
|
for (const row of rows) {
|
|
147
|
-
if (Date.now() >= deadline) break
|
|
148
|
-
|
|
161
|
+
if (Date.now() >= deadline || signal.aborted) break
|
|
162
|
+
console.info('managed-storage-migration-row-start:', JSON.stringify({ type: row.type, id: row.id }))
|
|
163
|
+
try {
|
|
164
|
+
const pending = await migrateRow(raw, db, key, row, signal)
|
|
165
|
+
if (pending) failures.push(rowFailure(row, pending))
|
|
166
|
+
}
|
|
149
167
|
catch (err) {
|
|
150
168
|
// A row started late gets a full budget next time. Skip an oversized
|
|
151
169
|
// first row until the next pass so later rows and tokens can advance.
|
|
152
|
-
if (signal.aborted && processed > 0) break
|
|
153
|
-
const
|
|
170
|
+
if (stopping?.aborted || (signal.aborted && processed > 0)) break
|
|
171
|
+
const failure = signal.aborted
|
|
154
172
|
? `Could not migrate ${row.type} ${row.id} within MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS=${maxMs}; increase the budget`
|
|
155
|
-
:
|
|
156
|
-
|
|
173
|
+
: err instanceof StoragePayloadError ? err.message : null
|
|
174
|
+
if (failure === null) {
|
|
175
|
+
rowFailure(row, 'Migration operation failed')
|
|
176
|
+
errors.push(new Error(`Could not migrate ${row.type} ${row.id}`, { cause: err }))
|
|
177
|
+
} else failures.push(rowFailure(row, failure))
|
|
157
178
|
}
|
|
179
|
+
if (stopping?.aborted) break
|
|
158
180
|
await db.advanceStorageMigration(state.cursor, row.position)
|
|
159
181
|
processed++
|
|
160
182
|
state = (await db.getStorageEncryption())!
|
|
161
183
|
}
|
|
162
184
|
}
|
|
163
185
|
state = (await db.getStorageEncryption())!
|
|
164
|
-
while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline) {
|
|
186
|
+
while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
|
|
165
187
|
try {
|
|
166
188
|
const cleanup = await cleanupLegacy(raw, db, state, maxObjects - processed, signal)
|
|
167
189
|
processed += cleanup.processed
|
|
@@ -172,11 +194,14 @@ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key:
|
|
|
172
194
|
if (retryAt !== null) break // Wait for staging grace rather than spinning over live temp files.
|
|
173
195
|
}
|
|
174
196
|
if (errors.length > 0) throw new AggregateError(errors, 'Storage migration has pending failures', { cause: errors[0] })
|
|
175
|
-
return { ...state, retryAt }
|
|
197
|
+
return { ...state, retryAt, failures }
|
|
176
198
|
}
|
|
177
199
|
|
|
178
|
-
export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now()): Promise<number> {
|
|
179
|
-
|
|
200
|
+
export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now(), stopping?: AbortSignal): Promise<number> {
|
|
201
|
+
if (stopping?.aborted) return 0
|
|
202
|
+
const timeout = AbortSignal.timeout(150_000)
|
|
203
|
+
const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
|
|
180
204
|
await db.getStorageEncryption()
|
|
181
|
-
return deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS)
|
|
205
|
+
try { return await deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS) }
|
|
206
|
+
catch (err) { if (stopping?.aborted) return 0; throw err }
|
|
182
207
|
}
|
|
@@ -7,6 +7,10 @@ import { STORAGE_MAGIC } from '../server-common/storage-crypto.ts'
|
|
|
7
7
|
import { type RawObject, isBlobId } from './object-storage.ts'
|
|
8
8
|
import type { StorageRow, StorageRowKind } from './storage-db.ts'
|
|
9
9
|
|
|
10
|
+
// A damaged/missing payload needs repair; retrying the whole cleanup job does
|
|
11
|
+
// not fix it. Keep this distinct from database, storage and cancellation errors.
|
|
12
|
+
export class StoragePayloadError extends Error {}
|
|
13
|
+
|
|
10
14
|
export function storageRowPath(type: StorageRowKind, row: Pick<StorageRow, 'id' | 'kind'>): string {
|
|
11
15
|
return `${type === 'report' ? 'reports' : 'bundles'}/${row.id}${type === 'bundle' && row.kind === 'sourcemap' ? '.map.br' : ''}`
|
|
12
16
|
}
|
|
@@ -52,9 +56,17 @@ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow
|
|
|
52
56
|
const hash = createHash(type === 'report' ? 'sha256' : 'sha512')
|
|
53
57
|
const sink = new Writable({ write(chunk, _encoding, next) { hash.update(chunk); next() } })
|
|
54
58
|
const options = signal ? { signal } : {}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
try {
|
|
60
|
+
if (type === 'bundle' && row.kind === 'sourcemap') {
|
|
61
|
+
await pipeline(source, createBrotliDecompress(), sink, options)
|
|
62
|
+
} else await pipeline(source, sink, options)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const code = (err as NodeJS.ErrnoException).code ?? ''
|
|
65
|
+
if (['Z_DATA_ERROR', 'Z_BUF_ERROR'].includes(code) || code.startsWith('ERR__ERROR_FORMAT_')) {
|
|
66
|
+
throw new StoragePayloadError('Stored sourcemap cannot be decompressed', { cause: err })
|
|
67
|
+
}
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
58
70
|
const actual = type === 'report' ? hash.digest('base64url') : `sha512-${hash.digest('base64')}`
|
|
59
|
-
if (actual !== row.hash) throw new
|
|
71
|
+
if (actual !== row.hash) throw new StoragePayloadError('Stored payload does not match its upload hash')
|
|
60
72
|
}
|
|
@@ -5,7 +5,7 @@ import type { AvatarStore } from './avatar-store.ts'
|
|
|
5
5
|
import type { BlobStore } from './blob-store.ts'
|
|
6
6
|
import type { BundleCacheStorage } from './bundle-cache.ts'
|
|
7
7
|
import { createBundleStore } from './bundle-store.ts'
|
|
8
|
-
import { type CacheStorage, validateCacheKey } from './cache-storage.ts'
|
|
8
|
+
import { CacheMissError, type CacheStorage, validateCacheKey } from './cache-storage.ts'
|
|
9
9
|
import { type ObjectStorage, isBlobId } from './object-storage.ts'
|
|
10
10
|
|
|
11
11
|
function id(value: string): string {
|
|
@@ -30,7 +30,7 @@ function cacheStore(objects: ObjectStorage, name: string): CacheStorage {
|
|
|
30
30
|
put: async (key, bytes) => { await objects.put(path(key), bytes) },
|
|
31
31
|
async open(key) {
|
|
32
32
|
const value = await objects.open(path(key))
|
|
33
|
-
if (!value) throw new
|
|
33
|
+
if (!value) throw new CacheMissError()
|
|
34
34
|
return value
|
|
35
35
|
},
|
|
36
36
|
delete: prefix => objects.deletePrefix(`${path(prefix)}/`),
|