@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.51
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/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- 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/bundle-metadata.d.ts +15 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +333 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +18 -0
- package/common/dependency-reviews.js +64 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -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/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +3 -0
- package/common/managed/finding-detail.js +66 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +7 -0
- package/common/managed/npm-packages.js +51 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +359 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +6 -0
- package/common/managed/team-app.js +47 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2K6UN3PC.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7JBSSRD7.js +3 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FMXWXDR4.js +36 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HT2SSA3V.js +1 -0
- package/out/chunk-IPAH4LKF.js +232 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-KOGNQDSJ.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-VDRXHIHN.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WRAJVD35.js +1 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +364 -59
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +96 -0
- 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 +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- 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 +44 -13
- 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 +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- 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 +19 -27
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- 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 +3 -3
- 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 +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +23 -0
- package/server-managed/avatar-store.ts +13 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -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 +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +76 -0
- package/server-managed/comparison.ts +52 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +468 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +143 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +98 -0
- package/server-managed/dependency-reviews.ts +364 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +376 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +136 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5503 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/listed-activity.ts +39 -0
- package/server-managed/locked-packages.ts +152 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +225 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-patch.ts +325 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +351 -0
- package/server-managed/pull-dependency-reviews.ts +334 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -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 +178 -0
- package/server-managed/report-store.ts +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +448 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +166 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +212 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +319 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { Readable } from 'node:stream'
|
|
4
|
+
import { type StorageKey, decryptStorageStream, encryptStorageStream, wrapStorageValue } from '../server-common/storage-crypto.ts'
|
|
5
|
+
import { ENCRYPTED_CACHE_PREFIX, type ObjectStorage, type RawObject, type RawObjectStorage, deleteObjects, objectPath, openObjectVersion } from './object-storage.ts'
|
|
6
|
+
import { type StorageDb, type StorageEncryptionState, type StorageRow, type StorageRowKind, dataKeyIdentity, unwrapDataKey } from './storage-db.ts'
|
|
7
|
+
import { PUBLIC_CACHE_PREFIX, inspectStorageObject, masterKeyCache, storageOwner, storageRowPaths, verifyStoragePayload } from './storage-payload.ts'
|
|
8
|
+
|
|
9
|
+
const STORAGE_WRITE_MS = 180_000
|
|
10
|
+
export const STORAGE_UPLOAD_TTL_MS = 86_400_000
|
|
11
|
+
export const STORAGE_DISABLED_TTL_MS = 5_000
|
|
12
|
+
|
|
13
|
+
async function readBytes(stored: Awaited<ReturnType<ObjectStorage['open']>>): Promise<Buffer | null> {
|
|
14
|
+
if (!stored) return null
|
|
15
|
+
const parts: Buffer[] = []
|
|
16
|
+
try { for await (const part of stored.stream) parts.push(Buffer.from(part)); return Buffer.concat(parts) }
|
|
17
|
+
finally { stored.stream.destroy() }
|
|
18
|
+
}
|
|
19
|
+
function logicalKey(identity: string) {
|
|
20
|
+
objectPath(identity)
|
|
21
|
+
if (identity.startsWith(ENCRYPTED_CACHE_PREFIX)) throw new Error('Encrypted cache paths are internal')
|
|
22
|
+
}
|
|
23
|
+
const encryptedCachePath = (identity: string) => `${ENCRYPTED_CACHE_PREFIX}${identity.slice('cache/'.length)}`
|
|
24
|
+
const deletionPaths = (identity: string) => identity.startsWith('cache/') ? [identity, encryptedCachePath(identity)] : [identity]
|
|
25
|
+
// Public bytes, stored as they are whether or not storage is encrypted.
|
|
26
|
+
const publicPath = (identity: string) => identity.startsWith('avatars/') || identity.startsWith(PUBLIC_CACHE_PREFIX)
|
|
27
|
+
|
|
28
|
+
// A PUT can commit and still throw. Remove only this attempted plaintext,
|
|
29
|
+
// comparing bytes rather than magic: arbitrary uploads can share the header.
|
|
30
|
+
// A concurrent migration's ciphertext must survive this reconciliation.
|
|
31
|
+
async function discardPlaintextWrite(raw: RawObjectStorage, identity: string, bytes: Buffer): Promise<void> {
|
|
32
|
+
// Leave time to report the original failure inside a serverless invocation.
|
|
33
|
+
const signal = AbortSignal.timeout(30_000)
|
|
34
|
+
const current = await raw.open(identity, signal)
|
|
35
|
+
if (!current) return
|
|
36
|
+
try {
|
|
37
|
+
let offset = 0
|
|
38
|
+
for await (const part of current.stream) {
|
|
39
|
+
const chunk = Buffer.from(part)
|
|
40
|
+
if (!chunk.equals(bytes.subarray(offset, offset + chunk.length))) return
|
|
41
|
+
offset += chunk.length
|
|
42
|
+
}
|
|
43
|
+
if (offset === bytes.length) await raw.delete(identity, current.version, signal)
|
|
44
|
+
} finally { current.stream.destroy() }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function putPlaintext(raw: RawObjectStorage, identity: string, bytes: Buffer, mode: () => Promise<StorageEncryptionState | null>): Promise<null> {
|
|
48
|
+
try {
|
|
49
|
+
await raw.put(identity, bytes, AbortSignal.timeout(STORAGE_WRITE_MS))
|
|
50
|
+
if (!await mode()) return null
|
|
51
|
+
throw new Error('Storage encryption was enabled during upload; retry the upload')
|
|
52
|
+
} catch (err) {
|
|
53
|
+
try { await discardPlaintextWrite(raw, identity, bytes) }
|
|
54
|
+
catch (cleanup) { throw new AggregateError([err, cleanup], 'Storage write and plaintext cleanup failed', { cause: cleanup }) }
|
|
55
|
+
throw err
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function decryptOwned(stored: RawObject, key: StorageKey, type: StorageRowKind, row: StorageRow, identity: string) {
|
|
60
|
+
let dataKey: Buffer | undefined
|
|
61
|
+
try {
|
|
62
|
+
dataKey = unwrapDataKey(key, type, row)
|
|
63
|
+
return await decryptStorageStream(stored.stream, dataKey, identity)
|
|
64
|
+
} catch (err) { stored.stream.destroy(); throw err }
|
|
65
|
+
finally { dataKey?.fill(0) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function openEncrypted(raw: RawObjectStorage, db: StorageDb, key: StorageKey, identity: string, state: StorageEncryptionState): ReturnType<ObjectStorage['open']> {
|
|
69
|
+
const owner = storageOwner(identity)
|
|
70
|
+
if (!owner) {
|
|
71
|
+
// A cache no row owns: its ciphertext alone, what was kept as plaintext
|
|
72
|
+
// before activation never read.
|
|
73
|
+
if (masterKeyCache(identity)) {
|
|
74
|
+
const stored = await raw.open(encryptedCachePath(identity))
|
|
75
|
+
return stored && decryptStorageStream(stored.stream, key.bytes, identity)
|
|
76
|
+
}
|
|
77
|
+
if (!identity.startsWith('uploads/')) throw new Error('Unknown managed storage owner')
|
|
78
|
+
const stored = await raw.open(identity)
|
|
79
|
+
if (!stored) return null
|
|
80
|
+
const inspected = await inspectStorageObject(stored)
|
|
81
|
+
if (inspected.encrypted) return decryptStorageStream(inspected.stream, key.bytes, identity)
|
|
82
|
+
if (stored.modifiedAt <= state.enabledAt && Date.now() < state.enabledAt + STORAGE_UPLOAD_TTL_MS) return inspected
|
|
83
|
+
inspected.stream.destroy()
|
|
84
|
+
throw new Error('Unexpected plaintext upload part')
|
|
85
|
+
}
|
|
86
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
87
|
+
const row = await db.getStorageRow(owner.type, owner.id)
|
|
88
|
+
if (!row) return null
|
|
89
|
+
if (!owner.cache && !storageRowPaths(owner.type, row).includes(identity)) return null
|
|
90
|
+
if (owner.cache && !row.dataKey) return null
|
|
91
|
+
if (!owner.cache && !row.encrypted) state = (await db.getStorageEncryption())!
|
|
92
|
+
const stored = await raw.open(owner.cache ? encryptedCachePath(identity) : identity)
|
|
93
|
+
if (!stored) return null
|
|
94
|
+
// Completed uploads and all new caches are always encrypted. Only legacy
|
|
95
|
+
// pending rows need header inspection and hash-verified plaintext fallback.
|
|
96
|
+
if (row.encrypted || owner.cache || state.complete) {
|
|
97
|
+
return decryptOwned(stored, key, owner.type, row, identity)
|
|
98
|
+
}
|
|
99
|
+
let inspected = await inspectStorageObject(stored)
|
|
100
|
+
if (inspected.encrypted && !row.dataKey && (await db.getStorageRow(owner.type, owner.id))?.dataKey) {
|
|
101
|
+
inspected.stream.destroy(); continue
|
|
102
|
+
}
|
|
103
|
+
if (inspected.encrypted && row.dataKey) {
|
|
104
|
+
try { return await decryptOwned(inspected, key, owner.type, row, identity) }
|
|
105
|
+
catch {
|
|
106
|
+
// An arbitrary legacy upload can begin with our magic bytes. Only
|
|
107
|
+
// its original SQL hash can authorize that plaintext interpretation.
|
|
108
|
+
const legacy = await openObjectVersion(raw, identity, stored.version)
|
|
109
|
+
if (!legacy) continue
|
|
110
|
+
inspected = { ...legacy, encrypted: false }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Only a pending row may read plaintext, and only the original upload.
|
|
114
|
+
// Verify first, then reopen that exact version before exposing bytes.
|
|
115
|
+
await verifyStoragePayload(owner.type, row, inspected.stream, undefined, identity)
|
|
116
|
+
const current = await openObjectVersion(raw, identity, stored.version)
|
|
117
|
+
if (current) return current
|
|
118
|
+
}
|
|
119
|
+
throw new Error('Storage changed repeatedly during read')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function verifyReportConversion(db: StorageDb, identity: string, bytes: Buffer): Promise<string> {
|
|
123
|
+
const owner = storageOwner(identity)
|
|
124
|
+
if (owner?.type !== 'report' || owner.cache || identity !== `reports/${owner.id}.br`) throw new Error('Invalid report conversion')
|
|
125
|
+
const row = await db.getStorageRow('report', owner.id)
|
|
126
|
+
if (!row) throw new Error('Report deleted during compression')
|
|
127
|
+
// Representation changes must preserve the immutable upload hash.
|
|
128
|
+
await verifyStoragePayload('report', row, Readable.from([bytes]), undefined, identity)
|
|
129
|
+
return owner.id
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function finishReportConversion(raw: RawObjectStorage, db: StorageDb, id: string, encrypted: boolean): Promise<void> {
|
|
133
|
+
const row = await db.getStorageRow('report', id)
|
|
134
|
+
if (!row) { await raw.delete(`reports/${id}.br`); throw new Error('Report deleted during compression') }
|
|
135
|
+
// Conversion preserves the original row/key. Keep the original until the
|
|
136
|
+
// compressed PUT has succeeded, and mark encrypted only after removing it.
|
|
137
|
+
await raw.delete(`reports/${id}`)
|
|
138
|
+
if (encrypted) await db.markStorageEncrypted('report', id, row.dataKey!)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function existingReportConversion(raw: RawObjectStorage, identity: string, row: StorageRow, key: StorageKey | null,
|
|
142
|
+
encryptedMode: boolean): Promise<{ version: string; encrypted: boolean } | null> {
|
|
143
|
+
const stored = await raw.open(identity)
|
|
144
|
+
if (!stored) return null
|
|
145
|
+
const inspected = await inspectStorageObject(stored)
|
|
146
|
+
// Activation or a concurrent key allocation can win after the SQL read.
|
|
147
|
+
// Retry with fresh policy/row state before interpreting any ciphertext.
|
|
148
|
+
if (inspected.encrypted && (!encryptedMode || !row.dataKey)) {
|
|
149
|
+
inspected.stream.destroy()
|
|
150
|
+
return { version: stored.version, encrypted: true }
|
|
151
|
+
}
|
|
152
|
+
const source = inspected.encrypted ? await decryptOwned(inspected, key!, 'report', row, identity) : inspected
|
|
153
|
+
await verifyStoragePayload('report', row, source.stream, undefined, identity)
|
|
154
|
+
return { version: stored.version, encrypted: inspected.encrypted }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function publishReportConversion(raw: RawObjectStorage, identity: string, bytes: Buffer, row: StorageRow,
|
|
158
|
+
key: StorageKey | null, expected: string | null, encrypted: boolean): Promise<boolean> {
|
|
159
|
+
if (!encrypted) return raw.put(identity, bytes, AbortSignal.timeout(STORAGE_WRITE_MS), expected)
|
|
160
|
+
const dataKey = unwrapDataKey(key!, 'report', row)
|
|
161
|
+
const source = encryptStorageStream(Readable.from([bytes]), dataKey, identity, bytes.length)
|
|
162
|
+
try { return await raw.put(identity, source, AbortSignal.timeout(STORAGE_WRITE_MS), expected, bytes.length) }
|
|
163
|
+
finally { source.destroy(); dataKey.fill(0) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function convertStoredReport(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null, identity: string, bytes: Buffer,
|
|
167
|
+
mode: (fresh?: boolean) => Promise<StorageEncryptionState | null>): Promise<null> {
|
|
168
|
+
const id = await verifyReportConversion(db, identity, bytes)
|
|
169
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
170
|
+
const state = await mode(true)
|
|
171
|
+
const row = state ? await db.ensureStorageDataKey('report', id) : await db.getStorageRow('report', id)
|
|
172
|
+
if (!row) throw new Error('Report deleted during compression')
|
|
173
|
+
const current = await existingReportConversion(raw, identity, row, key, Boolean(state))
|
|
174
|
+
if (current?.encrypted && (!state || !row.dataKey)) continue
|
|
175
|
+
// A stale plaintext writer must never replace a concurrent encrypted
|
|
176
|
+
// winner. Retain the original on uncertain writes; the next open resumes.
|
|
177
|
+
if ((!current || state && !current.encrypted) &&
|
|
178
|
+
!await publishReportConversion(raw, identity, bytes, row, key, current?.version ?? null, Boolean(state))) continue
|
|
179
|
+
if (!state && await mode(true)) continue
|
|
180
|
+
await raw.sync?.(identity)
|
|
181
|
+
await finishReportConversion(raw, db, id, Boolean(state))
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
throw new Error('Report changed repeatedly during compression')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Startup enables encryption before constructing stores; validate the persisted
|
|
188
|
+
// requirement even when this adapter is opened without a key.
|
|
189
|
+
// `known` is the state the caller read a moment before, where it found it
|
|
190
|
+
// enabled, so a cold start reads it once.
|
|
191
|
+
async function storagePolicy(db: StorageDb, key: StorageKey | null, known: StorageEncryptionState | null) {
|
|
192
|
+
let enabled = known ?? await db.getStorageEncryption()
|
|
193
|
+
let checkedAt = Date.now()
|
|
194
|
+
return async (fresh = false) => {
|
|
195
|
+
// Enabled is irreversible. Keyless instances cache "off" briefly, while
|
|
196
|
+
// writes and detected ciphertext always recheck the activation fence.
|
|
197
|
+
if (!enabled && (fresh || key || Date.now() - checkedAt >= STORAGE_DISABLED_TTL_MS)) {
|
|
198
|
+
const state = await db.getStorageEncryption()
|
|
199
|
+
if (state) enabled = state // A delayed disabled read cannot undo "on".
|
|
200
|
+
checkedAt = Date.now()
|
|
201
|
+
}
|
|
202
|
+
if (enabled && !key) throw new Error('Managed storage requires its configured encryption key')
|
|
203
|
+
return enabled
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null,
|
|
208
|
+
known: StorageEncryptionState | null = null): Promise<ObjectStorage> {
|
|
209
|
+
const mode = await storagePolicy(db, key, known)
|
|
210
|
+
async function open(identity: string): ReturnType<ObjectStorage['open']> {
|
|
211
|
+
logicalKey(identity)
|
|
212
|
+
const state = await mode()
|
|
213
|
+
if (publicPath(identity)) return raw.open(identity)
|
|
214
|
+
if (state) return openEncrypted(raw, db, key!, identity, state)
|
|
215
|
+
const stored = await raw.open(identity)
|
|
216
|
+
if (!stored) return null
|
|
217
|
+
const inspected = await inspectStorageObject(stored)
|
|
218
|
+
// Activation/migration can win after the disabled-state read. Never send
|
|
219
|
+
// that ciphertext to a caller expecting the original plaintext bytes.
|
|
220
|
+
if (inspected.encrypted) {
|
|
221
|
+
let current: StorageEncryptionState | null
|
|
222
|
+
try { current = await mode(true) } catch (err) { inspected.stream.destroy(); throw err }
|
|
223
|
+
if (current) { inspected.stream.destroy(); return openEncrypted(raw, db, key!, identity, current) }
|
|
224
|
+
}
|
|
225
|
+
return inspected
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
open,
|
|
229
|
+
get: async identity => readBytes(await open(identity)),
|
|
230
|
+
async exists(identity) {
|
|
231
|
+
logicalKey(identity)
|
|
232
|
+
const owner = storageOwner(identity), state = await mode()
|
|
233
|
+
if (state && owner) {
|
|
234
|
+
const row = await db.getStorageRow(owner.type, owner.id)
|
|
235
|
+
if (!row || (owner.cache ? !row.dataKey : !storageRowPaths(owner.type, row).includes(identity))) return false
|
|
236
|
+
}
|
|
237
|
+
// Existence is metadata only; open() authenticates the actual bytes.
|
|
238
|
+
return await raw.head(state && (owner?.cache || masterKeyCache(identity)) ? encryptedCachePath(identity) : identity) !== null
|
|
239
|
+
},
|
|
240
|
+
async put(identity, bytes, { replaceReport = false } = {}) {
|
|
241
|
+
logicalKey(identity)
|
|
242
|
+
if (replaceReport) return convertStoredReport(raw, db, key, identity, bytes, mode)
|
|
243
|
+
const state = await mode()
|
|
244
|
+
const owner = storageOwner(identity)
|
|
245
|
+
if (publicPath(identity)) { await raw.put(identity, bytes); return null }
|
|
246
|
+
if (!state) return putPlaintext(raw, identity, bytes, () => mode(true))
|
|
247
|
+
let dataKey: Buffer, wrapped: string | null = null
|
|
248
|
+
if (owner?.cache) {
|
|
249
|
+
const row = await db.ensureStorageDataKey(owner.type, owner.id)
|
|
250
|
+
if (!row) throw new Error('Cache owner unavailable')
|
|
251
|
+
dataKey = unwrapDataKey(key!, owner.type, row)
|
|
252
|
+
} else if (owner) {
|
|
253
|
+
if (await db.getStorageRow(owner.type, owner.id)) throw new Error('Stored payloads are immutable')
|
|
254
|
+
dataKey = randomBytes(32)
|
|
255
|
+
wrapped = wrapStorageValue(key!, dataKeyIdentity(owner.type, owner.id), dataKey)
|
|
256
|
+
} else {
|
|
257
|
+
if (!identity.startsWith('uploads/') && !masterKeyCache(identity)) throw new Error('Unknown managed storage owner')
|
|
258
|
+
dataKey = Buffer.from(key!.bytes)
|
|
259
|
+
}
|
|
260
|
+
const target = owner?.cache || masterKeyCache(identity) ? encryptedCachePath(identity) : identity
|
|
261
|
+
const encrypted = encryptStorageStream(Readable.from([bytes]), dataKey, identity, bytes.length)
|
|
262
|
+
try {
|
|
263
|
+
await raw.put(target, encrypted, AbortSignal.timeout(STORAGE_WRITE_MS), undefined, bytes.length)
|
|
264
|
+
// A late cache builder must not leave a readable cache for a deleted
|
|
265
|
+
// bundle. The missing SQL key also makes failed cleanup unreadable.
|
|
266
|
+
if (owner?.cache && !await db.getStorageRow(owner.type, owner.id)) await raw.delete(target)
|
|
267
|
+
return wrapped
|
|
268
|
+
} finally { encrypted.destroy(); dataKey.fill(0) }
|
|
269
|
+
},
|
|
270
|
+
async delete(identity) {
|
|
271
|
+
logicalKey(identity)
|
|
272
|
+
await mode()
|
|
273
|
+
// Always remove both generations, including caches predating activation
|
|
274
|
+
// when migration is disabled or another instance has just enabled it.
|
|
275
|
+
for (const target of deletionPaths(identity)) await raw.delete(target)
|
|
276
|
+
},
|
|
277
|
+
async deletePrefix(prefix, keep) {
|
|
278
|
+
logicalKey(`${prefix}validate`)
|
|
279
|
+
if (!prefix.endsWith('/') || (keep !== undefined && (!keep.startsWith(prefix) || !keep.endsWith('/')))) throw new Error('Invalid storage prefix')
|
|
280
|
+
await mode()
|
|
281
|
+
const signal = AbortSignal.timeout(STORAGE_WRITE_MS)
|
|
282
|
+
// What is kept, in the same generation as each removed.
|
|
283
|
+
const kept = keep === undefined ? [] : deletionPaths(keep)
|
|
284
|
+
for (const [i, target] of deletionPaths(prefix).entries()) {
|
|
285
|
+
await deleteObjects(raw, target, signal, undefined, kept[i])
|
|
286
|
+
await raw.prune?.(target, signal)
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer'
|
|
2
|
+
import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../server-common/storage-crypto.ts'
|
|
3
|
+
import { ENCRYPTED_CACHE_PREFIX, type RawObjectStorage, deleteObjects, isBlobId, openObjectVersion } from './object-storage.ts'
|
|
4
|
+
import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, type StorageRow, type StorageRowKind, unwrapDataKey } from './storage-db.ts'
|
|
5
|
+
import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
|
|
6
|
+
import { StoragePayloadError, inspectStorageObject, masterKeyCache, storageOwner, storageRowPaths, verifiedStoragePayload, verifyStoragePayload } from './storage-payload.ts'
|
|
7
|
+
|
|
8
|
+
async function replacementFailure(raw: RawObjectStorage, identity: string, version: string, signal: AbortSignal): Promise<string> {
|
|
9
|
+
const format = (value: string) => value.startsWith('W/') ? 'weak' : value.startsWith('"') && value.endsWith('"') ? 'quoted' : 'unquoted'
|
|
10
|
+
const reason = `Conditional replacement rejected (object version precondition failed); download=${format(version)}`
|
|
11
|
+
// Diagnose only failed writes. A metadata read cannot authorize a retry with
|
|
12
|
+
// a different version: the downloaded bytes must remain bound to their own.
|
|
13
|
+
let current
|
|
14
|
+
try { current = await raw.head(identity, signal) }
|
|
15
|
+
catch { return `${reason}; metadata=unavailable` }
|
|
16
|
+
if (!current) return `${reason}; metadata=missing`
|
|
17
|
+
// Normalization is diagnostic only. Never use it for conditional writes or
|
|
18
|
+
// log the actual ETags, which can contain hashes of private payloads.
|
|
19
|
+
const value = (tag: string) => tag.replace(/^W\//u, '').replace(/^"(.*)"$/u, '$1')
|
|
20
|
+
const comparison = current.version === version ? 'same' : value(current.version) === value(version) ? 'format-only' : 'different'
|
|
21
|
+
return `${reason}; metadata=${format(current.version)}; comparison=${comparison}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type MigrationPhase = 'prepare-key' | 'open-payload' | 'verify-existing' | 'encrypt-upload' | 'verify-replacement' | 'checkpoint'
|
|
25
|
+
async function migratePayload(raw: RawObjectStorage, db: StorageDb, type: StorageRowKind, row: StorageRow,
|
|
26
|
+
dataKey: Buffer, identity: string, signal: AbortSignal, progress: (phase: MigrationPhase) => void): Promise<boolean | string | null> {
|
|
27
|
+
progress('open-payload')
|
|
28
|
+
const source = await raw.open(identity, signal)
|
|
29
|
+
if (!source) return false
|
|
30
|
+
let inspected = await inspectStorageObject(source)
|
|
31
|
+
if (inspected.encrypted) {
|
|
32
|
+
progress('verify-existing')
|
|
33
|
+
// A previous PUT may have completed before a timeout or lost SQL ack.
|
|
34
|
+
try {
|
|
35
|
+
const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
|
|
36
|
+
await verifyStoragePayload(type, row, decoded.stream, signal, identity)
|
|
37
|
+
} catch {
|
|
38
|
+
signal.throwIfAborted()
|
|
39
|
+
// Legacy arbitrary bytes can share the magic prefix. A matching
|
|
40
|
+
// original upload hash is required before encrypting them as plaintext.
|
|
41
|
+
const legacy = await openObjectVersion(raw, identity, source.version, signal)
|
|
42
|
+
if (!legacy) return 'Payload disappeared or changed version before plaintext verification'
|
|
43
|
+
inspected = { ...legacy, encrypted: false }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!inspected.encrypted) {
|
|
47
|
+
progress('encrypt-upload')
|
|
48
|
+
signal.throwIfAborted()
|
|
49
|
+
const verified = verifiedStoragePayload(type, row, inspected.stream, signal, identity)
|
|
50
|
+
const encrypted = encryptStorageStream(verified, dataKey, identity, inspected.size)
|
|
51
|
+
let replaced: boolean
|
|
52
|
+
try { replaced = await raw.put(identity, encrypted, signal, source.version, inspected.size ?? undefined) }
|
|
53
|
+
// A provider may wrap a request-body error; keep safe payload diagnostics.
|
|
54
|
+
catch (err) { throw verified.errored ?? err }
|
|
55
|
+
finally { encrypted.destroy(); verified.destroy(); inspected.stream.destroy(); source.stream.destroy() }
|
|
56
|
+
if (!replaced) return await replacementFailure(raw, identity, source.version, signal)
|
|
57
|
+
// Do not mark the row encrypted until the stored bytes authenticate and
|
|
58
|
+
// match the original upload (including Brotli decompression).
|
|
59
|
+
progress('verify-replacement')
|
|
60
|
+
const replacement = await raw.open(identity, signal)
|
|
61
|
+
if (!replacement) {
|
|
62
|
+
if (!await db.getStorageRow(type, row.id)) return null
|
|
63
|
+
throw new StoragePayloadError('Migrated payload unavailable')
|
|
64
|
+
}
|
|
65
|
+
const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
|
|
66
|
+
await verifyStoragePayload(type, row, decoded.stream, signal, identity)
|
|
67
|
+
}
|
|
68
|
+
signal.throwIfAborted()
|
|
69
|
+
// Persist observed replacements, including both report representations
|
|
70
|
+
// if they coexist, before marking the whole row encrypted.
|
|
71
|
+
progress('checkpoint')
|
|
72
|
+
await raw.sync?.(identity, signal)
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal,
|
|
77
|
+
progress: (phase: MigrationPhase) => void): Promise<string | void> {
|
|
78
|
+
if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
|
|
79
|
+
progress('prepare-key')
|
|
80
|
+
const row = await db.ensureStorageDataKey(item.type, item.id)
|
|
81
|
+
if (!row || row.encrypted) return
|
|
82
|
+
const dataKey = unwrapDataKey(key, item.type, row)
|
|
83
|
+
try {
|
|
84
|
+
let found = false
|
|
85
|
+
for (const identity of storageRowPaths(item.type, row)) {
|
|
86
|
+
const result = await migratePayload(raw, db, item.type, row, dataKey, identity, signal, progress)
|
|
87
|
+
if (result === null) return
|
|
88
|
+
if (typeof result === 'string') return result
|
|
89
|
+
found ||= result
|
|
90
|
+
}
|
|
91
|
+
if (!found) {
|
|
92
|
+
if (!await db.getStorageRow(item.type, item.id)) return
|
|
93
|
+
throw new StoragePayloadError('Migration payload unavailable')
|
|
94
|
+
}
|
|
95
|
+
signal.throwIfAborted()
|
|
96
|
+
await db.markStorageEncrypted(item.type, item.id, row.dataKey!)
|
|
97
|
+
} finally { dataKey.fill(0) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const CLEANUP_PREFIXES = ['cache/', 'reports/', 'bundles/', ENCRYPTED_CACHE_PREFIX]
|
|
101
|
+
function cleanupPosition(state: StorageEncryptionState): { prefix: number; cursor: string | null } {
|
|
102
|
+
const value = state.cleanupCursor === null ? { prefix: 0, cursor: null } : JSON.parse(state.cleanupCursor)
|
|
103
|
+
if (!Number.isInteger(value.prefix) || value.prefix < 0 || value.prefix >= CLEANUP_PREFIXES.length
|
|
104
|
+
|| (value.cursor !== null && typeof value.cursor !== 'string')) throw new Error('Invalid legacy cleanup cursor')
|
|
105
|
+
return value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The database directory may be shared. Recognize managed paths, including
|
|
109
|
+
// disk atomic-write temps, before treating any listed file as ours to remove.
|
|
110
|
+
function cleanupTarget(identity: string) {
|
|
111
|
+
const temp = /^(.+)\.([^.]+)\.tmp$/u.exec(identity)
|
|
112
|
+
const temporary = !!temp && isBlobId(temp[2]!)
|
|
113
|
+
const path = temporary ? temp![1]! : identity
|
|
114
|
+
const encryptedCache = path.startsWith(ENCRYPTED_CACHE_PREFIX)
|
|
115
|
+
if (encryptedCache && !temporary) return null
|
|
116
|
+
const logical = encryptedCache ? `cache/${path.slice(ENCRYPTED_CACHE_PREFIX.length)}` : path
|
|
117
|
+
// A cache no row owns is a cache all the same: its plaintext legacy, and
|
|
118
|
+
// its temporary files removed past the staging window.
|
|
119
|
+
const owner = storageOwner(logical) ?? (masterKeyCache(logical) ? { cache: true as const } : null)
|
|
120
|
+
return owner ? { owner, temporary } : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Referenced-data migration never depends on Blob listings. A separate bounded
|
|
124
|
+
// inventory removes legacy caches and unreferenced plaintext; ciphertext
|
|
125
|
+
// orphans do not hold a recoverable data key and need no manifest-based GC.
|
|
126
|
+
async function cleanupLegacy(raw: RawObjectStorage, db: StorageDb, state: StorageEncryptionState, limit: number, signal: AbortSignal) {
|
|
127
|
+
const position = cleanupPosition(state), prefix = CLEANUP_PREFIXES[position.prefix]!
|
|
128
|
+
const page = await raw.list(prefix, position.cursor, Math.min(limit, 16), signal)
|
|
129
|
+
let found = 0, retryAt: number | null = null
|
|
130
|
+
for (const object of page.objects) {
|
|
131
|
+
signal.throwIfAborted()
|
|
132
|
+
const target = cleanupTarget(object.key)
|
|
133
|
+
if (!target) continue
|
|
134
|
+
const { owner, temporary } = target
|
|
135
|
+
if (!temporary && !owner.cache) {
|
|
136
|
+
const row = await db.getStorageRow(owner.type, owner.id)
|
|
137
|
+
if (row && storageRowPaths(owner.type, row).includes(object.key)) continue
|
|
138
|
+
}
|
|
139
|
+
const stored = await raw.head(object.key, signal)
|
|
140
|
+
if (!stored) continue
|
|
141
|
+
// A killed disk writer can leave plaintext or ciphertext in its temp file.
|
|
142
|
+
// Recheck its current age and give live writes a full staging grace window.
|
|
143
|
+
if (temporary && stored.modifiedAt > Date.now() - STORAGE_UPLOAD_TTL_MS) {
|
|
144
|
+
found++
|
|
145
|
+
retryAt = Math.min(retryAt ?? Infinity, stored.modifiedAt + STORAGE_UPLOAD_TTL_MS)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
// Old pre-activation orphans are legacy even when their arbitrary bytes
|
|
149
|
+
// share the magic prefix. Keep a full staging grace window around enable:
|
|
150
|
+
// Blob Last-Modified has only second precision, and a new encrypted upload
|
|
151
|
+
// may still be waiting for its SQL insert.
|
|
152
|
+
let legacy = temporary || owner.cache || stored.modifiedAt < state.enabledAt - STORAGE_UPLOAD_TTL_MS
|
|
153
|
+
if (!legacy) {
|
|
154
|
+
const source = await raw.open(object.key, signal)
|
|
155
|
+
if (!source) continue
|
|
156
|
+
try {
|
|
157
|
+
if (source.version !== stored.version) throw new Error('Legacy object changed during cleanup')
|
|
158
|
+
const inspected = await inspectStorageObject(source)
|
|
159
|
+
legacy = !inspected.encrypted
|
|
160
|
+
inspected.stream.destroy()
|
|
161
|
+
} finally { source.stream.destroy() }
|
|
162
|
+
}
|
|
163
|
+
if (legacy) {
|
|
164
|
+
found++
|
|
165
|
+
if (!await raw.delete(object.key, stored.version, signal)) throw new Error('Legacy object changed during cleanup')
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const next = page.cursor === null ? position.prefix + 1 < CLEANUP_PREFIXES.length ? { prefix: position.prefix + 1, cursor: null } : null
|
|
169
|
+
: { ...position, cursor: page.cursor }
|
|
170
|
+
// A full pass without removals closes offset-pagination gaps left by deletes.
|
|
171
|
+
await db.advanceStorageCleanup(state.cleanupCursor, next === null ? null : JSON.stringify(next), found)
|
|
172
|
+
return { processed: page.objects.length || 1, retryAt }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface StorageMigrationResult extends StorageEncryptionState {
|
|
176
|
+
retryAt: number | null
|
|
177
|
+
failures: { type: StorageMigrationRow['type']; id: string; message: string }[]
|
|
178
|
+
}
|
|
179
|
+
function rowFailure(row: StorageMigrationRow, message: string) {
|
|
180
|
+
const failure = { type: row.type, id: row.id, message }
|
|
181
|
+
// Emit before continuing: a later failure or terminated invocation must not
|
|
182
|
+
// hide earlier diagnostics. Never include provider errors or storage values.
|
|
183
|
+
console.warn('managed-storage-migration-row:', JSON.stringify(failure))
|
|
184
|
+
return failure
|
|
185
|
+
}
|
|
186
|
+
interface MigrationOptions { maxObjects?: number; maxMs?: number | undefined; signal?: AbortSignal | undefined }
|
|
187
|
+
export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
188
|
+
{ maxObjects = 64, maxMs = 150_000, signal: stopping }: MigrationOptions = {}): Promise<StorageMigrationResult> {
|
|
189
|
+
if (!Number.isSafeInteger(maxObjects) || maxObjects < 1 || !Number.isSafeInteger(maxMs) || maxMs < 1) throw new Error('Invalid migration budget')
|
|
190
|
+
const deadline = Date.now() + maxMs, timeout = AbortSignal.timeout(maxMs)
|
|
191
|
+
const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
|
|
192
|
+
const errors: unknown[] = []
|
|
193
|
+
const failures: StorageMigrationResult['failures'] = []
|
|
194
|
+
let processed = 0, retryAt: number | null = null, state = await db.getStorageEncryption()
|
|
195
|
+
if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
196
|
+
// At most one SQL pass per invocation. Checkpoint each row, including a
|
|
197
|
+
// failure: other rows progress, and the failed row is retried next pass.
|
|
198
|
+
while (!state.complete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
|
|
199
|
+
const rows = await db.listStorageMigrationRows(state.cursor, Math.min(16, maxObjects - processed))
|
|
200
|
+
if (stopping?.aborted) break
|
|
201
|
+
if (rows.length === 0) { await db.advanceStorageMigration(state.cursor, null); break }
|
|
202
|
+
for (const row of rows) {
|
|
203
|
+
if (Date.now() >= deadline || signal.aborted) break
|
|
204
|
+
console.info('managed-storage-migration-row-start:', JSON.stringify({ type: row.type, id: row.id }))
|
|
205
|
+
const startedAt = Date.now()
|
|
206
|
+
let phase: MigrationPhase | 'tokens' = 'tokens'
|
|
207
|
+
const logPhase = (aborted = false) => console.info('managed-storage-migration-row-phase:', JSON.stringify({
|
|
208
|
+
type: row.type, id: row.id, phase, elapsedMs: Date.now() - startedAt, ...(aborted ? { aborted: true } : {}),
|
|
209
|
+
}))
|
|
210
|
+
// Log directly on cancellation even if an underlying operation stalls
|
|
211
|
+
// while unwinding. These fixed phases contain no object/provider data.
|
|
212
|
+
const aborted = () => logPhase(true)
|
|
213
|
+
signal.addEventListener('abort', aborted, { once: true })
|
|
214
|
+
try {
|
|
215
|
+
const pending = await migrateRow(raw, db, key, row, signal, next => { phase = next; logPhase() })
|
|
216
|
+
if (pending) failures.push(rowFailure(row, pending))
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
// A row started late gets a full budget next time. Skip an oversized
|
|
220
|
+
// first row until the next pass so later rows and tokens can advance.
|
|
221
|
+
if (stopping?.aborted || (signal.aborted && processed > 0)) break
|
|
222
|
+
const failure = signal.aborted
|
|
223
|
+
? `Could not migrate ${row.type} ${row.id} within MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS=${maxMs}; increase the budget`
|
|
224
|
+
: err instanceof StoragePayloadError ? err.message : null
|
|
225
|
+
if (failure === null) {
|
|
226
|
+
rowFailure(row, 'Migration operation failed')
|
|
227
|
+
errors.push(new Error(`Could not migrate ${row.type} ${row.id}`, { cause: err }))
|
|
228
|
+
} else failures.push(rowFailure(row, failure))
|
|
229
|
+
}
|
|
230
|
+
finally { signal.removeEventListener('abort', aborted) }
|
|
231
|
+
if (stopping?.aborted) break
|
|
232
|
+
await db.advanceStorageMigration(state.cursor, row.position)
|
|
233
|
+
processed++
|
|
234
|
+
state = (await db.getStorageEncryption())!
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
state = (await db.getStorageEncryption())!
|
|
238
|
+
while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
|
|
239
|
+
try {
|
|
240
|
+
const cleanup = await cleanupLegacy(raw, db, state, maxObjects - processed, signal)
|
|
241
|
+
processed += cleanup.processed
|
|
242
|
+
retryAt = cleanup.retryAt
|
|
243
|
+
}
|
|
244
|
+
catch (err) { if (!signal.aborted) errors.push(err); break }
|
|
245
|
+
state = (await db.getStorageEncryption())!
|
|
246
|
+
if (retryAt !== null) break // Wait for staging grace rather than spinning over live temp files.
|
|
247
|
+
}
|
|
248
|
+
if (errors.length > 0) throw new AggregateError(errors, 'Storage migration has pending failures', { cause: errors[0] })
|
|
249
|
+
return { ...state, retryAt, failures }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now(), stopping?: AbortSignal): Promise<number> {
|
|
253
|
+
if (stopping?.aborted) return 0
|
|
254
|
+
const timeout = AbortSignal.timeout(150_000)
|
|
255
|
+
const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
|
|
256
|
+
await db.getStorageEncryption()
|
|
257
|
+
try { return await deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS) }
|
|
258
|
+
catch (err) { if (stopping?.aborted) return 0; throw err }
|
|
259
|
+
}
|