fix: hold transfer inputs and raw transfer work to what was verified
The capability vault checked an issuer's registration and then read it again to store it, including its nested header rows. A stateful issuer could show an allowed header set to the forbidden-header check and hand `Authorization` to the copy, so the vault stored — and the executor sent — a credential no rule had ever seen. The registration and everything nested in it is now snapshotted once, and only that snapshot is validated, frozen and stored. The upload control plane had the same shape one level down: a `sessionId` that answered `session_01` to the regex and `../../unsafe` to the result snapshot reached a success receipt. Two lifetimes were also unowned. A download source lease that resolved after the caller's abort never reached the holder, so nothing closed it and its fetch reader and capability lease outlived the terminal result; a compensator sharing the holder's close-once latch now closes it exactly once. And `dispose()` proved quiescence from the wrapper registry alone, so a provider that ignored its attempt deadline let teardown report a drained runtime and close the checkpoint store while the provider was still running. Raw provider promises are now their own registry and the drain must prove both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
aa8ac35600
commit
39a4a973a8
@@ -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<BrowserDataResult<FileByteSource>>,
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler);
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} 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<unknown>): 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) {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<infer Outcome>
|
||||
? 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<string, unknown> {
|
||||
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<string, unknown> | null {
|
||||
if (Array.isArray(value)) return null;
|
||||
return snapshotExactObject(value, {
|
||||
allowed: keys,
|
||||
required: keys,
|
||||
}) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,13 @@ type RuntimeDependencies<Capability> = Readonly<{
|
||||
random(): number;
|
||||
sleep(delayMs: number, signal: AbortSignal): Promise<void>;
|
||||
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<Promise<unknown>>;
|
||||
}>;
|
||||
|
||||
type ActiveResolution =
|
||||
@@ -176,7 +183,9 @@ const RECOVERIES: ReadonlySet<string> = new Set([
|
||||
export function createResumableUploadRuntime<Capability>(
|
||||
inputDependencies: ResumableUploadRuntimeDependencies<Capability>,
|
||||
): ResumableUploadRuntime {
|
||||
const dependencies = snapshotDependencies(inputDependencies);
|
||||
/** TR-04. Raw provider work, tracked independently of its bounded wrapper. */
|
||||
const physicalTasks = new Set<Promise<unknown>>();
|
||||
const dependencies = snapshotDependencies(inputDependencies, physicalTasks);
|
||||
const lifetime = new AbortController();
|
||||
const localUploads = new Map<string, Set<AbortController>>();
|
||||
/** BT-UP-06. Terminal settlement of every admitted operation. */
|
||||
@@ -339,10 +348,17 @@ export function createResumableUploadRuntime<Capability>(
|
||||
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<Capability, Value>(
|
||||
}, 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<Capability>(
|
||||
input: ResumableUploadRuntimeDependencies<Capability>,
|
||||
physicalTasks: Set<Promise<unknown>>,
|
||||
): RuntimeDependencies<Capability> {
|
||||
const policy = resolveResumableUploadRuntimePolicy(input.policy);
|
||||
const controlPlane = snapshotControlPlane(input.controlPlane);
|
||||
@@ -1765,6 +1794,7 @@ function snapshotDependencies<Capability>(
|
||||
throw new TypeError("Upload runtime dependency is invalid.");
|
||||
}
|
||||
return Object.freeze({
|
||||
physicalTasks,
|
||||
controlPlane,
|
||||
partExecutor,
|
||||
checkpoints,
|
||||
|
||||
@@ -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<Uint8Array>({ 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<Uint8Array>({ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>, {
|
||||
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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user