diff --git a/src/adapters/browser-files/download-delivery-adapter.ts b/src/adapters/browser-files/download-delivery-adapter.ts index 687e834..626c0f7 100644 --- a/src/adapters/browser-files/download-delivery-adapter.ts +++ b/src/adapters/browser-files/download-delivery-adapter.ts @@ -1023,6 +1023,32 @@ function createSourceHolder(): CloseableSourceHolder { return { source: null, closed: false }; } +/** + * TR-02. Closes a lease that fulfilled after the delivery already ended. The + * holder's own `closed` latch is the single close-once authority, so a lease + * the holder did adopt is never closed twice and a lease it never saw is still + * closed exactly once. A late rejection is observed and discarded. + */ +function compensateLateSource( + pending: Promise>, + holder: CloseableSourceHolder | undefined, +): void { + if (!holder) return; + void pending.then( + (result) => { + if (!result.ok || !holder.closed) return; + // The holder was already closed, so this lease was never adopted. + if (!isVerifiedPresignedSource(result.value)) return; + try { + result.value.close(); + } catch { + // Compensation is best effort and never changes the outcome. + } + }, + () => undefined, + ); +} + function closeHeldSource(holder: CloseableSourceHolder): void { if (holder.closed) return; holder.closed = true; @@ -1056,14 +1082,17 @@ async function resolveByteSource( } const open = options.openAuthorizedSource; if (!open) return browserDataFailure("UNSUPPORTED", "DOWNLOAD"); - const result = await awaitWithSignal( - open({ - resourceId: source.resourceId, - capability: source.capability, - signal, - }), + const pendingOpen = open({ + resourceId: source.resourceId, + capability: source.capability, signal, - ); + }); + // TR-02. A lease that arrives after the abort already ended the delivery + // never reaches the holder, so nothing would ever close it: the fetch reader + // and the capability lease outlived the terminal result. The compensator and + // the holder share one close-once latch, so exactly one of them closes it. + compensateLateSource(pendingOpen, holder); + const result = await awaitWithSignal(pendingOpen, signal); if (!result.ok) { return browserDataFailure(result.error.code, "DOWNLOAD", { retryable: result.error.retryable, diff --git a/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts b/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts index 800844e..1bba8d9 100644 --- a/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts +++ b/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts @@ -12,6 +12,11 @@ import { browserDataFailure, browserDataSuccess, } from "../../browser-file-storage/result.ts"; +import { + ownDataValue, + snapshotExactArray, + snapshotExactObject, +} from "../../../contracts/exact-snapshot.ts"; export type PresignedHeaderBinding = Readonly<{ name: string; @@ -167,28 +172,6 @@ export function createPresignedCapabilityVault(options: Readonly<{ recovery: "REISSUE_CAPABILITY", }); if (!registration || typeof registration !== "object") return invalid(); - // TR-RR-03. Exact own data only: an accessor re-runs on every later read, - // an inherited field can be replaced through the prototype, and a symbol - // key escapes a name-based sweep. The vault validates what the executor - // will read, so it must read what it validated. - try { - if (Object.getOwnPropertySymbols(registration).length > 0) { - return invalid(); - } - const names = Object.getOwnPropertyNames(registration).sort(); - if ( - names.length !== REGISTRATION_KEYS.length || - names.some((name, index) => name !== REGISTRATION_KEYS[index]) - ) { - return invalid(); - } - for (const name of names) { - const descriptor = Object.getOwnPropertyDescriptor(registration, name); - if (!descriptor || !("value" in descriptor)) return invalid(); - } - } catch { - return invalid(); - } if (registration.protocol !== PRESIGNED_TRANSFER_PROTOCOL) { return invalid(); } @@ -229,14 +212,18 @@ export function createPresignedCapabilityVault(options: Readonly<{ ...registration.requiredResponseHeaders, ]) { if ( - !header || - typeof header.name !== "string" || - typeof header.value !== "string" || FORBIDDEN_CAPABILITY_HEADERS.has(header.name.toLowerCase()) ) { return invalid(); } } + if ( + registration.allowedQueryParameters.some( + (parameter) => typeof parameter !== "string" || parameter.length === 0, + ) + ) { + return invalid(); + } if ( !Number.isSafeInteger(registration.expectedStatus) || registration.expectedStatus < 200 || @@ -270,11 +257,22 @@ export function createPresignedCapabilityVault(options: Readonly<{ if (disposed) { return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER"); } + // TR-01. One owned snapshot first, then validate and store only that + // snapshot. Validating the issuer's own object and reading it again to + // copy it let a stateful answer show an allowed header set to the + // forbidden-header check and hand `Authorization` to the copy, so the + // vault stored a capability no rule had ever seen. + const snapshot = snapshotRegistration(registration); + if (!snapshot) { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", { + recovery: "REISSUE_CAPABILITY", + }); + } // BT-PRE-04. The vault owns its own registration invariants so a second // issuer adapter, a test seam or composition code cannot register a // weaker capability of the same type. The HTTP decoder still owns the // wire shape; this only re-checks runtime invariants. - const invalid = validatePresignedCapabilityRegistration(registration); + const invalid = validatePresignedCapabilityRegistration(snapshot); if (invalid) return invalid; pruneExpired(); if ( @@ -293,14 +291,14 @@ export function createPresignedCapabilityVault(options: Readonly<{ } const capability = Object.freeze({ - capabilityReceipt: registration.capabilityReceipt, - method: registration.method, - binding: freezeBinding(registration.binding), - mediaType: registration.mediaType, - byteLength: registration.byteLength, - maxBytes: registration.maxBytes, - expectedSha256: registration.expectedSha256, - expiresAtEpochMs: registration.expiresAtEpochMs, + capabilityReceipt: snapshot.capabilityReceipt, + method: snapshot.method, + binding: freezeBinding(snapshot.binding), + mediaType: snapshot.mediaType, + byteLength: snapshot.byteLength, + maxBytes: snapshot.maxBytes, + expectedSha256: snapshot.expectedSha256, + expiresAtEpochMs: snapshot.expiresAtEpochMs, }) as PresignedTransferCapability; const binding: PresignedCapabilityBinding = Object.freeze({ capability, @@ -308,22 +306,22 @@ export function createPresignedCapabilityVault(options: Readonly<{ capabilityReceipt: capability.capabilityReceipt, method: capability.method, binding: capability.binding, - href: registration.href, - origin: registration.origin, - path: registration.path, + href: snapshot.href, + origin: snapshot.origin, + path: snapshot.path, allowedQueryParameters: Object.freeze([ - ...registration.allowedQueryParameters, + ...snapshot.allowedQueryParameters, ]), - requestHeaders: freezeHeaders(registration.requestHeaders), + requestHeaders: freezeHeaders(snapshot.requestHeaders), requiredResponseHeaders: freezeHeaders( - registration.requiredResponseHeaders, + snapshot.requiredResponseHeaders, ), - digestRequestHeader: registration.digestRequestHeader, - digestResponseHeader: registration.digestResponseHeader, - receiptResponseHeader: registration.receiptResponseHeader, - expectedStatus: registration.expectedStatus, + digestRequestHeader: snapshot.digestRequestHeader, + digestResponseHeader: snapshot.digestResponseHeader, + receiptResponseHeader: snapshot.receiptResponseHeader, + expectedStatus: snapshot.expectedStatus, expectedResponseByteLength: - registration.expectedResponseByteLength, + snapshot.expectedResponseByteLength, mediaType: capability.mediaType, byteLength: capability.byteLength, maxBytes: capability.maxBytes, @@ -403,6 +401,95 @@ export function createSingleUsePresignedReplayGuard(): }); } +const DOWNLOAD_BINDING_KEYS = Object.freeze(["kind", "resourceId"]); +const UPLOAD_BINDING_KEYS = Object.freeze([ + "kind", + "protocol", + "sessionId", + "requestBindingSha256", + "uploadBindingSha256", + "partNumber", + "offset", + "idempotencyKey", +]); + +/** + * TR-01. Copies a registration and everything nested inside it into owned, + * frozen values, reading each property exactly once. Only this snapshot is + * validated and stored, so a stateful issuer cannot show one value to the + * forbidden-header and HTTPS checks and hand another to the vault. A hostile + * trap, an accessor, an inherited or extra field and a non-iterable header + * array all resolve to `null` — a typed `POLICY_REJECTED` — rather than + * escaping as a native exception. + */ +function snapshotRegistration( + source: unknown, +): PresignedCapabilityRegistration | null { + const outer = snapshotExactObject(source, { + allowed: REGISTRATION_KEYS, + required: REGISTRATION_KEYS, + }); + if (outer === null) return null; + + const binding = snapshotBinding(outer["binding"]); + if (binding === null) return null; + const allowedQueryParameters = snapshotExactArray( + outer["allowedQueryParameters"], + ); + if (allowedQueryParameters === null) return null; + const requestHeaders = snapshotHeaderBindings(outer["requestHeaders"]); + const requiredResponseHeaders = snapshotHeaderBindings( + outer["requiredResponseHeaders"], + ); + if (requestHeaders === null || requiredResponseHeaders === null) return null; + + return Object.freeze({ + ...outer, + binding, + allowedQueryParameters: Object.freeze([...allowedQueryParameters]), + requestHeaders, + requiredResponseHeaders, + }) as PresignedCapabilityRegistration; +} + +function snapshotBinding(source: unknown): PresignedTransferBinding | null { + const kind = ownDataValue(source, "kind"); + if (kind !== "DOWNLOAD" && kind !== "UPLOAD_PART") return null; + const keys = + kind === "DOWNLOAD" ? DOWNLOAD_BINDING_KEYS : UPLOAD_BINDING_KEYS; + const binding = snapshotExactObject(source, { + allowed: keys, + required: keys, + }); + return binding === null + ? null + : (binding as unknown as PresignedTransferBinding); +} + +function snapshotHeaderBindings( + source: unknown, +): readonly PresignedHeaderBinding[] | null { + const rows = snapshotExactArray(source); + if (rows === null) return null; + const headers: PresignedHeaderBinding[] = []; + for (const row of rows) { + const header = snapshotExactObject(row, { + allowed: ["name", "value"], + required: ["name", "value"], + }); + if ( + header === null || + typeof header["name"] !== "string" || + header["name"].length === 0 || + typeof header["value"] !== "string" + ) { + return null; + } + headers.push(header as unknown as PresignedHeaderBinding); + } + return Object.freeze(headers); +} + function freezeBinding( binding: PresignedTransferBinding, ): PresignedTransferBinding { diff --git a/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts b/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts index 8c7d7d9..0dee1b4 100644 --- a/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts +++ b/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts @@ -8,7 +8,11 @@ import type { PresignedUploadPartOutcome, PresignedUploadPartPort, } from "../../../application/ports/browser-transfer/presigned-transfer.ts"; -import { createAbortableOperation } from "../../platform/abortable-operation.ts"; +import { + createAbortableOperation, + snapshotAbortTimers, + type AbortTimerSnapshot, +} from "../../platform/abortable-operation.ts"; import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts"; import type { BrowserDataFailureCode, @@ -92,16 +96,20 @@ export function createPresignedTransferExecutor( const timeoutMs = positiveSafeInteger(options.timeoutMs); const fetcher = (options.fetcher ?? fetch).bind(globalThis); const now = options.now ?? Date.now; - const scheduler = + // X-AUDIT-02. The timer callables are captured once, bound to their + // receiver, so replacing a scheduler method after composition cannot change + // how work already in flight is bounded. + const timers = snapshotAbortTimers( options.scheduler ?? - ({ - setTimeout: (callback, milliseconds) => - globalThis.setTimeout(callback, milliseconds), - clearTimeout: (handle) => - globalThis.clearTimeout( - handle as ReturnType, - ), - } satisfies Scheduler); + ({ + setTimeout: (callback, milliseconds) => + globalThis.setTimeout(callback, milliseconds), + clearTimeout: (handle) => + globalThis.clearTimeout( + handle as ReturnType, + ), + } satisfies Scheduler), + ); const createVerifier = options.createStreamingVerifier ?? createStreamingSha256Verifier; if (options.digestBytes === undefined && !globalThis.crypto?.subtle) { @@ -176,7 +184,7 @@ export function createPresignedTransferExecutor( createAbortScope( signal, timeoutMs, - scheduler, + timers, consumerSignal ? [consumerSignal] : [], ), recheckExpiry: () => @@ -278,7 +286,7 @@ export function createPresignedTransferExecutor( // caller signal and the deadline like every other step. Computing it before // the scope existed meant an abort or a deadline could not reach it, and a // hash that never settled held the whole `put` open. - const scope = createAbortScope(request.signal, timeoutMs, scheduler); + const scope = createAbortScope(request.signal, timeoutMs, timers); try { const digested = await scope.race( Promise.resolve().then(async () => await digestBytes(bytes)), @@ -1063,16 +1071,14 @@ function compensateLateResponse(task: Promise): void { function createAbortScope( external: AbortSignal, timeoutMs: number, - scheduler: Scheduler, + timers: AbortTimerSnapshot, additionalSignals: readonly AbortSignal[] = [], ) { const operation = createAbortableOperation({ signal: external, timeoutMs, - setTimer: (callback, delayMs) => scheduler.setTimeout(callback, delayMs), - clearTimer: (handle) => { - scheduler.clearTimeout(handle as never); - }, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, }); const releases: (() => void)[] = []; for (const extra of additionalSignals) { diff --git a/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts b/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts index 99cb96e..bfd942e 100644 --- a/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts +++ b/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts @@ -15,6 +15,11 @@ import { browserDataFailure, browserDataSuccess, } from "../../browser-file-storage/result.ts"; +import { + ownDataValue, + snapshotExactArray, + snapshotExactObject, +} from "../../../contracts/exact-snapshot.ts"; import { isSafeUploadReceiptToken, isUploadFileFingerprint, @@ -158,7 +163,7 @@ export function createResumableUploadHttpControlPlane( !SHA256_HEX.test(input.uploadBindingSha256) || !isUploadFileFingerprint(input.fingerprint) || !MEDIA_TYPE.test(input.mediaType) || - !isUploadPartReceiptShape(input.part) || + decodeUploadPartShape(input.part) === null || !safeIdempotencyKey(input.idempotencyKey) ) { return browserDataFailure( @@ -303,15 +308,16 @@ export function createResumableUploadHttpControlPlane( "UPLOAD_ABORT", ); if (!response.ok) return response; + const abortState = exactSnapshot(response.value, ["state"]); if ( - !exactKeys(response.value, ["state"]) || - typeof response.value.state !== "string" || + !abortState || + typeof abortState["state"] !== "string" || ![ "ABORTED", "NOT_FOUND", "EXPIRED", "ALREADY_COMPLETED", - ].includes(response.value.state) + ].includes(abortState["state"]) ) { return browserDataFailure( "CORRUPT_DATA", @@ -321,7 +327,7 @@ export function createResumableUploadHttpControlPlane( } return browserDataSuccess( Object.freeze({ - state: response.value.state as + state: abortState["state"] as | "ABORTED" | "NOT_FOUND" | "EXPIRED" @@ -367,66 +373,75 @@ async function invokeJsonTransport( } function decodeSession(value: unknown): UploadSession | null { + const record = exactSnapshot(value, [ + "protocol", + "sessionId", + "requestBindingSha256", + "fingerprint", + "partSizeBytes", + "partCount", + "maxConcurrency", + "expiresAtEpochMs", + ]); + if (!record) return null; + const fingerprint = snapshotFingerprint(record["fingerprint"]); + const sessionId = record["sessionId"]; + const requestBindingSha256 = record["requestBindingSha256"]; + const partSizeBytes = record["partSizeBytes"]; + const partCount = record["partCount"]; + const maxConcurrency = record["maxConcurrency"]; + const expiresAtEpochMs = record["expiresAtEpochMs"]; if ( - !exactKeys(value, [ - "protocol", - "sessionId", - "requestBindingSha256", - "fingerprint", - "partSizeBytes", - "partCount", - "maxConcurrency", - "expiresAtEpochMs", - ]) || - value.protocol !== RESUMABLE_UPLOAD_PROTOCOL || - typeof value.sessionId !== "string" || - !SAFE_OPAQUE_ID.test(value.sessionId) || - typeof value.requestBindingSha256 !== "string" || - !SHA256_HEX.test(value.requestBindingSha256) || - !isUploadFileFingerprint(value.fingerprint) || - !positiveSafeInteger(value.partSizeBytes) || - value.partSizeBytes !== value.fingerprint.partSizeBytes || - !positiveSafeInteger(value.partCount) || - value.partCount !== value.fingerprint.partCount || - value.partCount > MAX_PART_COUNT || - !positiveSafeInteger(value.maxConcurrency) || - !positiveSafeInteger(value.expiresAtEpochMs) + record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL || + typeof sessionId !== "string" || + !SAFE_OPAQUE_ID.test(sessionId) || + typeof requestBindingSha256 !== "string" || + !SHA256_HEX.test(requestBindingSha256) || + fingerprint === null || + !positiveSafeInteger(partSizeBytes) || + partSizeBytes !== fingerprint.partSizeBytes || + !positiveSafeInteger(partCount) || + partCount !== fingerprint.partCount || + partCount > MAX_PART_COUNT || + !positiveSafeInteger(maxConcurrency) || + !positiveSafeInteger(expiresAtEpochMs) ) { return null; } return Object.freeze({ protocol: RESUMABLE_UPLOAD_PROTOCOL, - sessionId: value.sessionId, - requestBindingSha256: value.requestBindingSha256, - fingerprint: snapshotFingerprint(value.fingerprint), - partSizeBytes: value.partSizeBytes, - partCount: value.partCount, - maxConcurrency: value.maxConcurrency, - expiresAtEpochMs: value.expiresAtEpochMs, + sessionId, + requestBindingSha256, + fingerprint, + partSizeBytes, + partCount, + maxConcurrency, + expiresAtEpochMs, }); } function decodeStatus(value: unknown): UploadSessionStatus | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - const record = value as Record; - if ( - record.state === "ACTIVE" && - exactKeys(record, ["state", "session", "acceptedParts"]) - ) { - const session = decodeSession(record.session); - if ( - !session || - !Array.isArray(record.acceptedParts) || - record.acceptedParts.length > MAX_RECEIPT_COUNT || - !record.acceptedParts.every(isUploadPartReceipt) - ) { + // The discriminator is read from the same snapshot the payload comes from. + const state = ownDataValue(value, "state"); + if (state === "ACTIVE") { + const record = exactSnapshot(value, [ + "state", + "session", + "acceptedParts", + ]); + if (!record) return null; + const session = decodeSession(record["session"]); + const rows = snapshotExactArray(record["acceptedParts"]); + if (!session || rows === null || rows.length > MAX_RECEIPT_COUNT) { return null; } - const parts = Object.freeze( - record.acceptedParts.map(snapshotReceipt), - ); + const receipts: UploadPartReceipt[] = []; + for (const row of rows) { + const receipt = snapshotReceipt(row); + if (receipt === null) return null; + receipts.push(receipt); + } + const parts = Object.freeze(receipts); return orderedReceipts(parts, session.fingerprint, false) ? Object.freeze({ state: "ACTIVE", @@ -435,41 +450,49 @@ function decodeStatus(value: unknown): UploadSessionStatus | null { }) : null; } - if ( - record.state === "QUARANTINED" && - exactKeys(record, ["state", "session", "resourceId"]) - ) { - const session = decodeSession(record.session); + if (state === "QUARANTINED") { + const record = exactSnapshot(value, ["state", "session", "resourceId"]); + if (!record) return null; + const session = decodeSession(record["session"]); + const resourceId = record["resourceId"]; return session && - typeof record.resourceId === "string" && - SAFE_OPAQUE_ID.test(record.resourceId) + typeof resourceId === "string" && + SAFE_OPAQUE_ID.test(resourceId) ? Object.freeze({ state: "QUARANTINED", session, - resourceId: record.resourceId, + resourceId, }) : null; } if ( - typeof record.state === "string" && - ["ABORTED", "EXPIRED", "NOT_FOUND"].includes(record.state) && - exactKeys(record, [ + typeof state === "string" && + ["ABORTED", "EXPIRED", "NOT_FOUND"].includes(state) + ) { + const record = exactSnapshot(value, [ "state", "protocol", "sessionId", "requestBindingSha256", - ]) && - record.protocol === RESUMABLE_UPLOAD_PROTOCOL && - typeof record.sessionId === "string" && - SAFE_OPAQUE_ID.test(record.sessionId) && - typeof record.requestBindingSha256 === "string" && - SHA256_HEX.test(record.requestBindingSha256) - ) { + ]); + const sessionId = record?.["sessionId"]; + const requestBindingSha256 = record?.["requestBindingSha256"]; + if ( + !record || + record["state"] !== state || + record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL || + typeof sessionId !== "string" || + !SAFE_OPAQUE_ID.test(sessionId) || + typeof requestBindingSha256 !== "string" || + !SHA256_HEX.test(requestBindingSha256) + ) { + return null; + } return Object.freeze({ - state: record.state as "ABORTED" | "EXPIRED" | "NOT_FOUND", + state: state as "ABORTED" | "EXPIRED" | "NOT_FOUND", protocol: RESUMABLE_UPLOAD_PROTOCOL, - sessionId: record.sessionId, - requestBindingSha256: record.requestBindingSha256, + sessionId, + requestBindingSha256, }); } return null; @@ -484,34 +507,39 @@ function decodeCompletion( > extends UploadProviderResult ? Outcome | null : never { + const record = exactSnapshot(value, [ + "state", + "protocol", + "sessionId", + "requestBindingSha256", + "fingerprint", + "resourceId", + ]); + const sessionId = record?.["sessionId"]; + const requestBindingSha256 = record?.["requestBindingSha256"]; + const resourceId = record?.["resourceId"]; + const fingerprint = record ? snapshotFingerprint(record["fingerprint"]) : null; if ( - !exactKeys(value, [ - "state", - "protocol", - "sessionId", - "requestBindingSha256", - "fingerprint", - "resourceId", - ]) || - value.state !== "QUARANTINED" || - value.protocol !== RESUMABLE_UPLOAD_PROTOCOL || - typeof value.sessionId !== "string" || - !SAFE_OPAQUE_ID.test(value.sessionId) || - typeof value.requestBindingSha256 !== "string" || - !SHA256_HEX.test(value.requestBindingSha256) || - !isUploadFileFingerprint(value.fingerprint) || - typeof value.resourceId !== "string" || - !SAFE_OPAQUE_ID.test(value.resourceId) + !record || + record["state"] !== "QUARANTINED" || + record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL || + typeof sessionId !== "string" || + !SAFE_OPAQUE_ID.test(sessionId) || + typeof requestBindingSha256 !== "string" || + !SHA256_HEX.test(requestBindingSha256) || + fingerprint === null || + typeof resourceId !== "string" || + !SAFE_OPAQUE_ID.test(resourceId) ) { return null; } return Object.freeze({ state: "QUARANTINED", protocol: RESUMABLE_UPLOAD_PROTOCOL, - sessionId: value.sessionId, - requestBindingSha256: value.requestBindingSha256, - fingerprint: snapshotFingerprint(value.fingerprint), - resourceId: value.resourceId, + sessionId, + requestBindingSha256, + fingerprint, + resourceId, }); } @@ -546,72 +574,85 @@ function orderedReceipts( }); } -function isUploadPartReceiptShape( +function decodeUploadPartShape( value: unknown, -): value is Readonly<{ +): Readonly<{ partNumber: number; offset: number; byteLength: number; checksumSha256: string; -}> { - return ( - exactKeys(value, [ - "partNumber", - "offset", - "byteLength", - "checksumSha256", - ]) && - positiveSafeInteger(value.partNumber) && - nonNegativeSafeInteger(value.offset) && - positiveSafeInteger(value.byteLength) && - typeof value.checksumSha256 === "string" && - SHA256_HEX.test(value.checksumSha256) - ); -} - -function snapshotFingerprint( - value: UploadFileFingerprint, -): UploadFileFingerprint { - return Object.freeze({ ...value }); -} - -function snapshotReceipt(value: UploadPartReceipt): UploadPartReceipt { - return Object.freeze({ ...value }); +}> | null { + const record = exactSnapshot(value, [ + "partNumber", + "offset", + "byteLength", + "checksumSha256", + ]); + if (!record) return null; + const partNumber = record["partNumber"]; + const offset = record["offset"]; + const byteLength = record["byteLength"]; + const checksumSha256 = record["checksumSha256"]; + return positiveSafeInteger(partNumber) && + nonNegativeSafeInteger(offset) && + positiveSafeInteger(byteLength) && + typeof checksumSha256 === "string" && + SHA256_HEX.test(checksumSha256) + ? Object.freeze({ partNumber, offset, byteLength, checksumSha256 }) + : null; } /** - * TR-RR-08. `Object.keys` sees only enumerable own string keys, so a symbol or - * non-enumerable extra field passed unseen and a later property read invoked - * whatever accessor the sender installed — escaping the Result contract as a - * rejection of the public method. - * - * Every key is checked against its own property descriptor, and the whole probe - * runs inside a catch so a proxy trap is a decode failure, not an exception. + * TR-05. Copies the nested value first, then validates the copy, so the + * fingerprint the session is checked against is the one it carries. */ -function exactKeys( +function snapshotFingerprint(value: unknown): UploadFileFingerprint | null { + const record = exactSnapshot(value, [ + "algorithm", + "digestHex", + "byteLength", + "partSizeBytes", + "partCount", + ]); + return record && isUploadFileFingerprint(record) + ? (record as unknown as UploadFileFingerprint) + : null; +} + +function snapshotReceipt(value: unknown): UploadPartReceipt | null { + const record = exactSnapshot(value, [ + "partNumber", + "offset", + "byteLength", + "checksumSha256", + "receiptToken", + ]); + return record && isUploadPartReceipt(record) + ? (record as unknown as UploadPartReceipt) + : null; +} + +/** + * TR-RR-08 / TR-05. Copies the value into an owned frozen record, reading every + * property exactly once, and returns `null` for anything that is not an exact + * own-data shape. + * + * `Object.keys` saw only enumerable own string keys, so a symbol or + * non-enumerable extra field passed unseen and a later property read invoked + * whatever accessor the sender installed. Worse, checking the sender's object + * and then reading it again to build the result let a stateful answer show a + * safe `sessionId` to the regex and hand an unvalidated one to the receipt, so + * the value that was checked and the value that was returned differed. + */ +function exactSnapshot( value: unknown, keys: readonly string[], -): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - try { - if (Object.getOwnPropertySymbols(value).length > 0) return false; - const actual = Object.getOwnPropertyNames(value).sort(); - const expected = [...keys].sort(); - if ( - actual.length !== expected.length || - actual.some((key, index) => key !== expected[index]) - ) { - return false; - } - return actual.every((key) => { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - return Boolean(descriptor && "value" in descriptor); - }); - } catch { - return false; - } +): Record | null { + if (Array.isArray(value)) return null; + return snapshotExactObject(value, { + allowed: keys, + required: keys, + }) as Record | null; } /** diff --git a/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts b/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts index 2b4adc6..bd2928c 100644 --- a/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts +++ b/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts @@ -125,6 +125,13 @@ type RuntimeDependencies = Readonly<{ random(): number; sleep(delayMs: number, signal: AbortSignal): Promise; observer?: BrowserDataObserver; + /** + * TR-04. Every raw provider promise, from the moment the collaborator is + * called until it actually settles. The wrapper that bounds the attempt can + * settle long before the provider does, so the wrapper registry alone could + * report an empty set while physical work was still running. + */ + physicalTasks: Set>; }>; type ActiveResolution = @@ -176,7 +183,9 @@ const RECOVERIES: ReadonlySet = new Set([ export function createResumableUploadRuntime( inputDependencies: ResumableUploadRuntimeDependencies, ): ResumableUploadRuntime { - const dependencies = snapshotDependencies(inputDependencies); + /** TR-04. Raw provider work, tracked independently of its bounded wrapper. */ + const physicalTasks = new Set>(); + const dependencies = snapshotDependencies(inputDependencies, physicalTasks); const lifetime = new AbortController(); const localUploads = new Map>(); /** BT-UP-06. Terminal settlement of every admitted operation. */ @@ -339,10 +348,17 @@ export function createResumableUploadRuntime( dependencies.policy.cleanupDeadlineMs, ); }); - const drained = await Promise.race([ - Promise.allSettled([...activeOperations]).then(() => "DRAINED" as const), - expired, - ]); + // TR-04. Quiescence means both registries: the bounded wrappers and the + // raw provider work they may have outlived. A settling wrapper can still + // register more physical work, so the drain repeats until both are empty + // or the cleanup deadline expires. + const quiescent = (async () => { + while (activeOperations.size > 0 || physicalTasks.size > 0) { + await Promise.allSettled([...activeOperations, ...physicalTasks]); + } + return "DRAINED" as const; + })(); + const drained = await Promise.race([quiescent, expired]); if (timer !== undefined) clearTimeout(timer); if (drained === "EXPIRED") { // The store stays open: something can still write a checkpoint. @@ -1310,8 +1326,20 @@ async function invokeProviderAttempt( }, dependencies.policy.providerAttemptTimeoutMs); }); try { + // TR-04. The raw promise enters the physical registry the moment the + // provider is called and stays there until it truly settles. Racing it + // against a deadline let the bounded wrapper settle first and leave the + // set empty, so `dispose()` reported a drained runtime while the provider + // was still running. + const raw = action(controller.signal); + const tracked = Promise.resolve(raw).then( + () => undefined, + () => undefined, + ); + dependencies.physicalTasks.add(tracked); + void tracked.finally(() => dependencies.physicalTasks.delete(tracked)); return await Promise.race([ - invokeProvider(operation, () => action(controller.signal)), + invokeProvider(operation, () => raw), deadline, ]); } finally { @@ -1743,6 +1771,7 @@ function snapshotRequest( function snapshotDependencies( input: ResumableUploadRuntimeDependencies, + physicalTasks: Set>, ): RuntimeDependencies { const policy = resolveResumableUploadRuntimePolicy(input.policy); const controlPlane = snapshotControlPlane(input.controlPlane); @@ -1765,6 +1794,7 @@ function snapshotDependencies( throw new TypeError("Upload runtime dependency is invalid."); } return Object.freeze({ + physicalTasks, controlPlane, partExecutor, checkpoints, diff --git a/tests/unit/presigned-transfer.test.ts b/tests/unit/presigned-transfer.test.ts index fd2eac0..b060660 100644 --- a/tests/unit/presigned-transfer.test.ts +++ b/tests/unit/presigned-transfer.test.ts @@ -914,6 +914,186 @@ describe("presigned transfer", () => { }, ); + /** + * TR-01. The vault validated the issuer's own object and then read it again + * to copy it. Between those reads a stateful issuer could show an allowed + * header set to the forbidden-header check and hand `Authorization` to the + * stored binding, so the executor sent a credential no rule had approved. + */ + describe("TR-01 the stored capability is the one that was validated", () => { + const baseRegistration = () => ({ + protocol: PRESIGNED_TRANSFER_PROTOCOL, + capabilityReceipt: "capability-snapshot-1", + method: "GET" as const, + binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, + href: `${DATA_ORIGIN}${DOWNLOAD_PATH}`, + origin: DATA_ORIGIN, + path: DOWNLOAD_PATH, + allowedQueryParameters: [], + requestHeaders: [{ name: "x-safe", value: "1" }], + requiredResponseHeaders: [], + digestRequestHeader: null, + digestResponseHeader: null, + receiptResponseHeader: null, + expectedStatus: 200, + expectedResponseByteLength: 3, + mediaType: "application/octet-stream", + byteLength: 3, + maxBytes: 3, + expectedSha256: "a".repeat(64), + expiresAtEpochMs: NOW + 60_000, + }); + + const freshVault = () => + createPresignedCapabilityVault({ + now: () => NOW, + maxActiveCapabilities: 4, + }); + + it("refuses a header row that answers differently on a second read", () => { + const vault = freshVault(); + let nameReads = 0; + const header = new Proxy( + { name: "x-safe", value: "1" }, + { + getOwnPropertyDescriptor(target, key) { + if (key === "name") { + nameReads += 1; + return { + configurable: true, + enumerable: true, + value: nameReads > 1 ? "authorization" : "x-safe", + }; + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }, + ); + + const registered = vault.register({ + ...baseRegistration(), + requestHeaders: [header], + } as never); + + if (registered.ok) { + // A single read means the value that was checked is the value stored. + const resolved = vault.resolve(registered.value); + expect(resolved.ok).toBe(true); + if (resolved.ok) { + expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([ + "x-safe", + ]); + } + } + vault.dispose(); + }); + + const hostileRegistrations: readonly (readonly [string, () => unknown])[] = [ + [ + "an accessor field", + () => + Object.defineProperty(baseRegistration(), "href", { + enumerable: true, + get: () => `${DATA_ORIGIN}${DOWNLOAD_PATH}`, + }), + ], + [ + "an inherited field", + () => Object.assign(Object.create({ injected: true }), baseRegistration()), + ], + [ + "a symbol field", + () => ({ ...baseRegistration(), [Symbol.for("injected")]: true }), + ], + [ + "a non-enumerable own field", + () => + Object.defineProperty(baseRegistration(), "injected", { + enumerable: false, + value: true, + }), + ], + [ + "a throwing ownKeys trap", + () => + new Proxy(baseRegistration(), { + ownKeys() { + throw new TypeError("hostile ownKeys trap"); + }, + }), + ], + [ + "a null header array", + () => ({ ...baseRegistration(), requestHeaders: null }), + ], + [ + "a non-iterable header array", + () => ({ ...baseRegistration(), requestHeaders: { length: 1 } }), + ], + [ + "a header row with an extra field", + () => ({ + ...baseRegistration(), + requestHeaders: [{ name: "x-safe", value: "1", injected: true }], + }), + ], + [ + "an accessor header name", + () => ({ + ...baseRegistration(), + requestHeaders: [ + Object.defineProperty({ value: "1" }, "name", { + enumerable: true, + get: () => "x-safe", + }), + ], + }), + ], + [ + "a binding with an extra field", + () => ({ + ...baseRegistration(), + binding: { kind: "DOWNLOAD", resourceId: "r", injected: true }, + }), + ], + [ + "a null binding", + () => ({ ...baseRegistration(), binding: null }), + ], + ]; + + for (const [label, build] of hostileRegistrations) { + it(`rejects ${label} as POLICY_REJECTED`, () => { + const vault = freshVault(); + expect(vault.register(build() as never)).toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + vault.dispose(); + }); + } + + it("does not observe a mutation of the issuer's object after registration", () => { + const vault = freshVault(); + const registration = baseRegistration(); + const registered = vault.register(registration as never); + expect(registered.ok).toBe(true); + if (!registered.ok) return; + + registration.requestHeaders[0]!.name = "authorization"; + registration.expiresAtEpochMs = NOW + 999_999; + + const resolved = vault.resolve(registered.value); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([ + "x-safe", + ]); + expect(resolved.value.expiresAtEpochMs).toBe(NOW + 60_000); + vault.dispose(); + }); + }); + it("does not fetch a presigned download until stream consumption", async () => { const bytes = new Uint8Array([1, 2, 3]); const responsePayload = downloadCapabilityPayload(bytes); @@ -2083,6 +2263,198 @@ describe("presigned transfer", () => { void deliveryResult; expect(closes).toBe(1); }); + + /** + * TR-02. A lease that resolved after the abort already ended the delivery + * never reached the holder, so nothing closed it: the fetch reader and the + * capability lease outlived the terminal result. + */ + it("closes a source lease that arrives after the delivery was aborted", async () => { + const bytes = new Uint8Array([1, 2, 3]); + let closes = 0; + const controller = new AbortController(); + let releaseOpen: + | ((value: { ok: true; value: unknown }) => void) + | undefined; + const source = { + byteLength: bytes.byteLength, + integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const, + capability: undefined as never, + close() { + closes += 1; + }, + async *stream() { + yield { ok: true as const, value: bytes }; + }, + }; + const capability = Object.freeze({ + capabilityReceipt: "capability-late-1", + method: "GET" as const, + binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, + mediaType: "application/octet-stream", + byteLength: bytes.byteLength, + maxBytes: bytes.byteLength, + expectedSha256: "a".repeat(64), + expiresAtEpochMs: NOW + 60_000, + }); + source.capability = capability as never; + + const latePolicy = browserFilePolicyReference("download", "presigned-late"); + const policies = new BrowserFilePolicyRegistry({ + profiles: [ + { + reference: latePolicy, + download: { + strategy: "PROMPT_AND_STREAM", + mediaType: "application/octet-stream", + safeExtension: ".bin", + maxTransferBytes: 64, + maxBufferedBytes: 8, + integrity: "REQUIRED", + }, + }, + ], + hardLimits: { + maxInspectionBytes: 64, + maxRetainedFileBytes: 64, + maxPreviewBytes: 64, + maxObjectUrlBytes: 64, + maxTransferBytes: 64, + }, + }); + const downloads = createDownloadDeliveryAdapter({ + host: { handoff() {} }, + policies, + hardMaxObjectUrlBytes: 64, + hardMaxTransferBytes: 64, + browserManagedCapabilities: { + resolve() { + throw new TypeError("not used"); + }, + }, + // Ignores the signal entirely and resolves only when the test says so. + openAuthorizedSource: () => + new Promise((resolve) => { + releaseOpen = resolve as never; + }) as never, + showSaveFilePicker: async () => ({ + async createWritable() { + return new WritableStream({ write() {} }); + }, + }), + userActivation: { isActive: true }, + now: () => NOW, + }); + + const delivering = downloads.deliver({ + policy: latePolicy, + source: { + kind: "AUTHORIZED_STREAM_RESOURCE", + resourceId: "resource-1", + capability: capability as never, + }, + suggestedFileName: "artifact.bin", + signal: controller.signal, + onProgress() {}, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(); + const delivered = await delivering; + expect(delivered.ok).toBe(false); + + // The lease arrives only now, long after the terminal result. + releaseOpen?.({ ok: true, value: source }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(closes).toBe(1); + }); + + it("does not leave a late rejection unhandled after an abort", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + const controller = new AbortController(); + let rejectOpen: ((reason: unknown) => void) | undefined; + const rejectPolicy = browserFilePolicyReference( + "download", + "presigned-late-reject", + ); + const policies = new BrowserFilePolicyRegistry({ + profiles: [ + { + reference: rejectPolicy, + download: { + strategy: "PROMPT_AND_STREAM", + mediaType: "application/octet-stream", + safeExtension: ".bin", + maxTransferBytes: 64, + maxBufferedBytes: 8, + integrity: "REQUIRED", + }, + }, + ], + hardLimits: { + maxInspectionBytes: 64, + maxRetainedFileBytes: 64, + maxPreviewBytes: 64, + maxObjectUrlBytes: 64, + maxTransferBytes: 64, + }, + }); + const downloads = createDownloadDeliveryAdapter({ + host: { handoff() {} }, + policies, + hardMaxObjectUrlBytes: 64, + hardMaxTransferBytes: 64, + browserManagedCapabilities: { + resolve() { + throw new TypeError("not used"); + }, + }, + openAuthorizedSource: () => + new Promise((_resolve, reject) => { + rejectOpen = reject; + }) as never, + showSaveFilePicker: async () => ({ + async createWritable() { + return new WritableStream({ write() {} }); + }, + }), + userActivation: { isActive: true }, + now: () => NOW, + }); + + const delivering = downloads.deliver({ + policy: rejectPolicy, + source: { + kind: "AUTHORIZED_STREAM_RESOURCE", + resourceId: "resource-1", + capability: Object.freeze({ + capabilityReceipt: "capability-late-2", + method: "GET" as const, + binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, + mediaType: "application/octet-stream", + byteLength: 3, + maxBytes: 3, + expectedSha256: "a".repeat(64), + expiresAtEpochMs: NOW + 60_000, + }) as never, + }, + suggestedFileName: "artifact.bin", + signal: controller.signal, + onProgress() {}, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(); + await delivering; + + rejectOpen?.(new Error("late open failure")); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); }); /** diff --git a/tests/unit/resumable-upload-http-control-plane.test.ts b/tests/unit/resumable-upload-http-control-plane.test.ts index 7647f5b..5013148 100644 --- a/tests/unit/resumable-upload-http-control-plane.test.ts +++ b/tests/unit/resumable-upload-http-control-plane.test.ts @@ -232,6 +232,30 @@ describe("resumable upload HTTP control plane", () => { }, }), ], + [ + "a nested fingerprint with an extra field", + () => ({ + ...validSession(), + fingerprint: { ...fingerprint, injected: true }, + }), + ], + [ + "a nested fingerprint behind an accessor", + () => { + const value = validSession() as Record; + Object.defineProperty(value, "fingerprint", { + configurable: true, + enumerable: true, + get: () => fingerprint, + }); + return value; + }, + ], + [ + "a custom prototype", + () => + Object.assign(Object.create({ injected: true }), validSession()), + ], ]; for (const [label, build] of hostile) { @@ -261,6 +285,53 @@ describe("resumable upload HTTP control plane", () => { }); expect(JSON.stringify(result)).not.toContain("signature=leak"); } + + /** + * TR-05. The decoder checked the sender's object and then read it again to + * build the result, so a stateful answer could show a safe `sessionId` to + * the regex and hand an unvalidated one to the receipt. Reading once means + * the value that was validated is the value that is returned. + */ + let sessionIdReads = 0; + const statefulControl = createResumableUploadHttpControlPlane({ + transport: { + async execute() { + return browserDataSuccess( + new Proxy(validSession() as Record, { + getOwnPropertyDescriptor(target, key) { + if (key === "sessionId") { + sessionIdReads += 1; + return { + configurable: true, + enumerable: true, + value: sessionIdReads > 1 ? "../../unsafe" : "session_01", + }; + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }), + ); + }, + }, + partCapabilities: { issueUploadPart: vi.fn() }, + }); + const stateful = await statefulControl.createSession({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + uploadKey: "upload_key_strict", + purpose: "attachment", + mediaType: "application/octet-stream", + requestBindingSha256: "b".repeat(64), + fingerprint, + requestedPartSizeBytes: 4, + requestedMaxConcurrency: 1, + idempotencyKey: "upload-create-idempotency-02", + signal: activeSignal, + }); + expect(sessionIdReads).toBe(1); + expect(JSON.stringify(stateful)).not.toContain("../../unsafe"); + if (stateful.ok) { + expect(stateful.value.sessionId).toBe("session_01"); + } }); it("rejects unknown response fields so URLs cannot cross the DTO boundary", async () => { diff --git a/tests/unit/resumable-upload-runtime.test.ts b/tests/unit/resumable-upload-runtime.test.ts index d30dce3..bf4e015 100644 --- a/tests/unit/resumable-upload-runtime.test.ts +++ b/tests/unit/resumable-upload-runtime.test.ts @@ -1343,3 +1343,114 @@ describe("TR-RR-06 bounded resumable teardown", () => { expect(checkpoints.closed).toBe(true); }); }); + +/** + * TR-04. `dispose()` proved quiescence from the wrapper registry only. A + * provider that ignored its attempt deadline let the wrapper settle first and + * leave the set empty, so teardown reported a drained runtime — and closed the + * checkpoint store — while the provider was still running. + */ +describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => { + it("refuses to report a drained runtime while a provider is still running", async () => { + const harness = createControlHarness(); + const checkpoints = new MemoryCheckpointStore(); + const closeStore = vi.spyOn(checkpoints, "close"); + let releaseProvider: (() => void) | undefined; + // Ignores the attempt signal entirely and outlives its own deadline. + harness.control.createSession = () => + new Promise((resolve) => { + releaseProvider = () => + resolve( + browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", { + retryable: true, + recovery: "RESUME", + }), + ); + }); + + const runtime = createResumableUploadRuntime({ + controlPlane: harness.control, + partExecutor: executorFor(harness), + checkpoints, + mutationLock: noContentionLock, + crypto, + policy: runtimePolicy({ + providerAttemptTimeoutMs: 5, + cleanupDeadlineMs: 25, + maxRetries: 0, + }), + now: () => 1_000, + random: () => 0, + sleep: async () => {}, + }); + + const uploading = runtime.upload({ + uploadKey: "upload_key_raw", + purpose: "attachment", + mediaType: "application/octet-stream", + source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])), + signal: activeSignal, + }); + await vi.waitFor(() => expect(releaseProvider).toBeDefined()); + // The wrapper has already given up on the attempt. + await uploading; + + const disposed = await runtime.dispose(); + expect(disposed.ok).toBe(false); + if (!disposed.ok) { + expect(disposed.error.code).toBe("UNAVAILABLE"); + expect(disposed.error.recovery).toBe("RESUME"); + } + // The store stays open while something could still write a checkpoint. + expect(closeStore).not.toHaveBeenCalled(); + + releaseProvider?.(); + }); + + it("reports a drained runtime once the raw provider settles", async () => { + const harness = createControlHarness(); + const checkpoints = new MemoryCheckpointStore(); + let releaseProvider: (() => void) | undefined; + harness.control.createSession = () => + new Promise((resolve) => { + releaseProvider = () => + resolve( + browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", { + retryable: true, + recovery: "RESUME", + }), + ); + }); + + const runtime = createResumableUploadRuntime({ + controlPlane: harness.control, + partExecutor: executorFor(harness), + checkpoints, + mutationLock: noContentionLock, + crypto, + policy: runtimePolicy({ + providerAttemptTimeoutMs: 5, + cleanupDeadlineMs: 1_000, + maxRetries: 0, + }), + now: () => 1_000, + random: () => 0, + sleep: async () => {}, + }); + + const uploading = runtime.upload({ + uploadKey: "upload_key_raw_2", + purpose: "attachment", + mediaType: "application/octet-stream", + source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])), + signal: activeSignal, + }); + await vi.waitFor(() => expect(releaseProvider).toBeDefined()); + await uploading; + + const disposing = runtime.dispose(); + releaseProvider?.(); + await expect(disposing).resolves.toMatchObject({ ok: true }); + expect(runtime.lifecycle()).toBe("CLOSED"); + }); +});