1189 lines
34 KiB
TypeScript
1189 lines
34 KiB
TypeScript
import type {
|
|
PresignedDownloadCapability,
|
|
PresignedDownloadByteSource,
|
|
PresignedDownloadSourcePort,
|
|
PresignedTransferCapability,
|
|
PresignedTransferReplayGuard,
|
|
PresignedUploadPartCapability,
|
|
PresignedUploadPartOutcome,
|
|
PresignedUploadPartPort,
|
|
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
|
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
|
import type {
|
|
BrowserDataFailureCode,
|
|
BrowserDataObserver,
|
|
BrowserDataOperation,
|
|
BrowserDataRecovery,
|
|
BrowserDataResult,
|
|
} from "../../../application/ports/browser-file-storage/shared.ts";
|
|
import {
|
|
browserDataFailure,
|
|
browserDataSuccess,
|
|
observeBrowserData,
|
|
} from "../../browser-file-storage/result.ts";
|
|
import {
|
|
createStreamingSha256Verifier,
|
|
type StreamingSha256Verifier,
|
|
} from "./incremental-sha256.ts";
|
|
import type {
|
|
PresignedCapabilityBinding,
|
|
PresignedCapabilityVault,
|
|
} from "./presigned-capability-vault.ts";
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const RECEIPT_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._~:+/=-]{0,511}$/;
|
|
|
|
type Scheduler = Readonly<{
|
|
setTimeout(callback: () => void, milliseconds: number): unknown;
|
|
clearTimeout(handle: unknown): void;
|
|
}>;
|
|
|
|
export type PresignedTransferExecutorOptions = Readonly<{
|
|
vault: PresignedCapabilityVault;
|
|
replayGuard: PresignedTransferReplayGuard;
|
|
hardMaxTransferBytes: number;
|
|
hardMaxChunkBytes: number;
|
|
hardMaxUploadResponseBytes: number;
|
|
minimumRemainingLifetimeMs: number;
|
|
timeoutMs: number;
|
|
fetcher?: typeof fetch;
|
|
now?: () => number;
|
|
scheduler?: Scheduler;
|
|
createStreamingVerifier?: (
|
|
expectedSha256: string,
|
|
) => StreamingSha256Verifier;
|
|
digestBytes?: (
|
|
bytes: Uint8Array,
|
|
) => Promise<string>;
|
|
observer?: BrowserDataObserver;
|
|
}>;
|
|
|
|
export type PresignedTransferExecutor = Readonly<{
|
|
downloadSources: PresignedDownloadSourcePort;
|
|
uploadParts: PresignedUploadPartPort;
|
|
}>;
|
|
|
|
/**
|
|
* Consumes only exact handles issued into the supplied identity vault. Raw
|
|
* href/query/header values never enter either public executor method.
|
|
*/
|
|
export function createPresignedTransferExecutor(
|
|
options: PresignedTransferExecutorOptions,
|
|
): PresignedTransferExecutor {
|
|
const resolve = options.vault.resolve.bind(options.vault);
|
|
const consume = options.vault.consume.bind(options.vault);
|
|
const claim = options.replayGuard.claim.bind(options.replayGuard);
|
|
const hardMaxTransferBytes = positiveSafeInteger(
|
|
options.hardMaxTransferBytes,
|
|
);
|
|
const hardMaxChunkBytes = positiveSafeInteger(
|
|
options.hardMaxChunkBytes,
|
|
);
|
|
if (hardMaxChunkBytes > hardMaxTransferBytes) {
|
|
throw new TypeError("Presigned chunk limit exceeds transfer limit.");
|
|
}
|
|
const hardMaxUploadResponseBytes = positiveSafeInteger(
|
|
options.hardMaxUploadResponseBytes,
|
|
);
|
|
const minimumRemainingLifetimeMs = positiveSafeInteger(
|
|
options.minimumRemainingLifetimeMs,
|
|
);
|
|
const timeoutMs = positiveSafeInteger(options.timeoutMs);
|
|
const fetcher = (options.fetcher ?? fetch).bind(globalThis);
|
|
const now = options.now ?? Date.now;
|
|
const scheduler =
|
|
options.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) {
|
|
throw new TypeError(
|
|
"WebCrypto SHA-256 is required for presigned uploads.",
|
|
);
|
|
}
|
|
const digestBytes = options.digestBytes ?? digestSha256WithWebCrypto;
|
|
const observer = options.observer;
|
|
|
|
async function openDownload(
|
|
input: Parameters<PresignedDownloadSourcePort["open"]>[0],
|
|
): Promise<BrowserDataResult<PresignedDownloadByteSource>> {
|
|
let resourceId: string;
|
|
let capability: PresignedDownloadCapability;
|
|
let signal: AbortSignal;
|
|
try {
|
|
resourceId = input.resourceId;
|
|
capability = input.capability;
|
|
signal = input.signal;
|
|
if (!isAbortSignal(signal)) {
|
|
throw new TypeError("Abort signal is invalid.");
|
|
}
|
|
} catch {
|
|
return browserDataFailure("INVALID_INPUT", "PRESIGNED_TRANSFER");
|
|
}
|
|
if (signal.aborted) {
|
|
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
const resolved = resolve(capability);
|
|
if (!resolved.ok) return resolved;
|
|
const binding = resolved.value;
|
|
if (
|
|
binding.method !== "GET" ||
|
|
binding.binding.kind !== "DOWNLOAD" ||
|
|
binding.binding.resourceId !== resourceId ||
|
|
capability.binding.kind !== "DOWNLOAD" ||
|
|
capability.binding.resourceId !== resourceId ||
|
|
!validCommonBinding(binding, capability, hardMaxTransferBytes)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
const active = validateExpiry(
|
|
capability,
|
|
minimumRemainingLifetimeMs,
|
|
now(),
|
|
);
|
|
if (!active.ok) return active;
|
|
const claimed = claim(capability);
|
|
if (!claimed.ok) return claimed;
|
|
const consumed = consume(capability);
|
|
if (!consumed.ok) return consumed;
|
|
|
|
const scope = createAbortScope(signal, timeoutMs, scheduler);
|
|
try {
|
|
const response = await fetcher(binding.href, {
|
|
method: "GET",
|
|
headers: headersFor(binding),
|
|
credentials: "omit",
|
|
redirect: "error",
|
|
referrerPolicy: "no-referrer",
|
|
cache: "no-store",
|
|
signal: scope.signal,
|
|
});
|
|
const validated = validateDownloadResponse(
|
|
response,
|
|
binding,
|
|
);
|
|
if (!validated.ok) {
|
|
cancelBody(response);
|
|
scope.release();
|
|
return validated;
|
|
}
|
|
const source = createDownloadSource({
|
|
response,
|
|
binding,
|
|
capability,
|
|
externalSignal: signal,
|
|
scope,
|
|
hardMaxChunkBytes,
|
|
createVerifier,
|
|
observer,
|
|
});
|
|
return browserDataSuccess(source);
|
|
} catch {
|
|
scope.release();
|
|
return transferFailure(signal, scope.timedOut());
|
|
}
|
|
}
|
|
|
|
async function putUploadPart(
|
|
input: Parameters<PresignedUploadPartPort["put"]>[0],
|
|
): Promise<BrowserDataResult<PresignedUploadPartOutcome>> {
|
|
let capability: PresignedUploadPartCapability;
|
|
let bytes: Uint8Array<ArrayBuffer>;
|
|
let request: Readonly<{
|
|
sessionId: string;
|
|
requestBindingSha256: string;
|
|
uploadBindingSha256: string;
|
|
partNumber: number;
|
|
offset: number;
|
|
byteLength: number;
|
|
checksumSha256: string;
|
|
idempotencyKey: string;
|
|
signal: AbortSignal;
|
|
}>;
|
|
try {
|
|
capability = input.capability;
|
|
if (!(input.bytes instanceof Uint8Array)) {
|
|
throw new TypeError("Upload bytes are invalid.");
|
|
}
|
|
if (
|
|
input.bytes.byteLength > hardMaxTransferBytes ||
|
|
input.bytes.byteLength !== input.byteLength
|
|
) {
|
|
return browserDataFailure(
|
|
input.bytes.byteLength > hardMaxTransferBytes
|
|
? "LIMIT_EXCEEDED"
|
|
: "INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
if (!isAbortSignal(input.signal)) {
|
|
throw new TypeError("Abort signal is invalid.");
|
|
}
|
|
// Snapshot before hashing or network awaits so caller mutation cannot
|
|
// alter the verified bytes after the capability check.
|
|
bytes = input.bytes.slice();
|
|
request = Object.freeze({
|
|
sessionId: opaqueId(input.sessionId),
|
|
requestBindingSha256: normalizedSha256(
|
|
input.requestBindingSha256,
|
|
),
|
|
uploadBindingSha256: normalizedSha256(
|
|
input.uploadBindingSha256,
|
|
),
|
|
partNumber: positiveSafeInteger(input.partNumber),
|
|
offset: nonNegativeSafeInteger(input.offset),
|
|
byteLength: positiveSafeInteger(input.byteLength),
|
|
checksumSha256: normalizedSha256(input.checksumSha256),
|
|
idempotencyKey: opaqueId(input.idempotencyKey),
|
|
signal: input.signal,
|
|
});
|
|
} catch {
|
|
return browserDataFailure("INVALID_INPUT", "PRESIGNED_TRANSFER");
|
|
}
|
|
if (request.signal.aborted) {
|
|
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
const resolved = resolve(capability);
|
|
if (!resolved.ok) return resolved;
|
|
const binding = resolved.value;
|
|
if (
|
|
binding.method !== "PUT" ||
|
|
binding.binding.kind !== "UPLOAD_PART" ||
|
|
capability.binding.kind !== "UPLOAD_PART" ||
|
|
binding.binding.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
|
|
!validCommonBinding(binding, capability, hardMaxTransferBytes) ||
|
|
binding.binding.sessionId !== request.sessionId ||
|
|
binding.binding.requestBindingSha256 !==
|
|
request.requestBindingSha256 ||
|
|
binding.binding.uploadBindingSha256 !==
|
|
request.uploadBindingSha256 ||
|
|
binding.binding.partNumber !== request.partNumber ||
|
|
binding.binding.offset !== request.offset ||
|
|
binding.binding.idempotencyKey !== request.idempotencyKey ||
|
|
capability.byteLength !== request.byteLength ||
|
|
capability.expectedSha256 !== request.checksumSha256 ||
|
|
bytes.byteLength !== capability.byteLength
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
let actualDigest: string;
|
|
try {
|
|
actualDigest = normalizedSha256(await digestBytes(bytes));
|
|
} catch {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
if (actualDigest !== capability.expectedSha256) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
if (request.signal.aborted) {
|
|
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
const active = validateExpiry(
|
|
capability,
|
|
minimumRemainingLifetimeMs,
|
|
now(),
|
|
);
|
|
if (!active.ok) return active;
|
|
const claimed = claim(capability);
|
|
if (!claimed.ok) return claimed;
|
|
const consumed = consume(capability);
|
|
if (!consumed.ok) return consumed;
|
|
|
|
const scope = createAbortScope(request.signal, timeoutMs, scheduler);
|
|
try {
|
|
const response = await fetcher(binding.href, {
|
|
method: "PUT",
|
|
headers: headersFor(binding),
|
|
body: bytes.buffer,
|
|
credentials: "omit",
|
|
redirect: "error",
|
|
referrerPolicy: "no-referrer",
|
|
cache: "no-store",
|
|
signal: scope.signal,
|
|
});
|
|
const validated = validateUploadResponse(
|
|
response,
|
|
binding,
|
|
hardMaxUploadResponseBytes,
|
|
);
|
|
if (!validated.ok) {
|
|
cancelBody(response);
|
|
return validated;
|
|
}
|
|
const drained = await drainUploadResponse(
|
|
response,
|
|
validated.value.responseByteLength,
|
|
hardMaxUploadResponseBytes,
|
|
scope.signal,
|
|
);
|
|
if (!drained.ok) return drained;
|
|
return browserDataSuccess(
|
|
Object.freeze({
|
|
bytesWritten: bytes.byteLength,
|
|
checksumSha256: capability.expectedSha256,
|
|
receiptToken: validated.value.receiptToken,
|
|
} satisfies PresignedUploadPartOutcome),
|
|
);
|
|
} catch {
|
|
return transferFailure(request.signal, scope.timedOut());
|
|
} finally {
|
|
scope.release();
|
|
}
|
|
}
|
|
|
|
const downloadSources: PresignedDownloadSourcePort = Object.freeze({
|
|
async open(
|
|
input: Parameters<PresignedDownloadSourcePort["open"]>[0],
|
|
) {
|
|
let result: BrowserDataResult<PresignedDownloadByteSource>;
|
|
try {
|
|
result = await openDownload(input);
|
|
} catch {
|
|
result = browserDataFailure(
|
|
"UNAVAILABLE",
|
|
"PRESIGNED_TRANSFER",
|
|
{
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
},
|
|
);
|
|
}
|
|
observeTransferResult(
|
|
observer,
|
|
"PRESIGNED_TRANSFER",
|
|
result,
|
|
result.ok
|
|
? result.value.byteLength
|
|
: safeInputByteLength(input),
|
|
);
|
|
return result;
|
|
},
|
|
});
|
|
|
|
const uploadParts: PresignedUploadPartPort = Object.freeze({
|
|
async put(
|
|
input: Parameters<PresignedUploadPartPort["put"]>[0],
|
|
) {
|
|
let result: BrowserDataResult<PresignedUploadPartOutcome>;
|
|
try {
|
|
result = await putUploadPart(input);
|
|
} catch {
|
|
result = browserDataFailure(
|
|
"UNAVAILABLE",
|
|
"PRESIGNED_TRANSFER",
|
|
{
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
},
|
|
);
|
|
}
|
|
observeTransferResult(
|
|
observer,
|
|
"UPLOAD_PART",
|
|
result,
|
|
result.ok
|
|
? result.value.bytesWritten
|
|
: safeInputByteLength(input),
|
|
);
|
|
return result;
|
|
},
|
|
});
|
|
|
|
return Object.freeze({ downloadSources, uploadParts });
|
|
}
|
|
|
|
function createDownloadSource(input: Readonly<{
|
|
response: Response;
|
|
binding: PresignedCapabilityBinding;
|
|
capability: PresignedDownloadCapability;
|
|
externalSignal: AbortSignal;
|
|
scope: ReturnType<typeof createAbortScope>;
|
|
hardMaxChunkBytes: number;
|
|
createVerifier: (
|
|
expectedSha256: string,
|
|
) => StreamingSha256Verifier;
|
|
observer: BrowserDataObserver | undefined;
|
|
}>): PresignedDownloadByteSource {
|
|
let started = false;
|
|
return Object.freeze({
|
|
byteLength: input.capability.byteLength,
|
|
capability: input.capability,
|
|
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
|
|
async *stream(
|
|
consumerSignal: AbortSignal,
|
|
): AsyncIterable<BrowserDataResult<Uint8Array>> {
|
|
if (!isAbortSignal(consumerSignal)) {
|
|
started = true;
|
|
cancelBody(input.response);
|
|
input.scope.release();
|
|
const failure = browserDataFailure(
|
|
"INVALID_INPUT",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
|
yield failure;
|
|
return;
|
|
}
|
|
if (started) {
|
|
const failure = browserDataFailure(
|
|
"CONFLICT",
|
|
"PRESIGNED_TRANSFER",
|
|
{
|
|
recovery: "REISSUE_CAPABILITY",
|
|
},
|
|
);
|
|
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
|
yield failure;
|
|
return;
|
|
}
|
|
started = true;
|
|
let combined:
|
|
| ReturnType<typeof combineConsumerAbort>
|
|
| undefined;
|
|
let reader:
|
|
| ReadableStreamDefaultReader<Uint8Array>
|
|
| undefined;
|
|
let completed = false;
|
|
let transferred = 0;
|
|
let terminalFailureCode: BrowserDataFailureCode | undefined;
|
|
const fail = (
|
|
code: BrowserDataFailureCode,
|
|
options: Readonly<{
|
|
retryable?: boolean;
|
|
recovery?: BrowserDataRecovery;
|
|
}> = {},
|
|
): BrowserDataResult<never> => {
|
|
terminalFailureCode = code;
|
|
return browserDataFailure(code, "PRESIGNED_TRANSFER", options);
|
|
};
|
|
try {
|
|
combined = combineConsumerAbort(
|
|
input.scope,
|
|
consumerSignal,
|
|
);
|
|
const verifier = input.createVerifier(
|
|
input.capability.expectedSha256,
|
|
);
|
|
if (!input.response.body) {
|
|
let verified = false;
|
|
try {
|
|
verified =
|
|
input.capability.byteLength === 0 && verifier.verify();
|
|
} catch {
|
|
verified = false;
|
|
}
|
|
if (verified) {
|
|
completed = true;
|
|
return;
|
|
}
|
|
yield fail("INTEGRITY_FAILED");
|
|
return;
|
|
}
|
|
reader = input.response.body.getReader();
|
|
while (true) {
|
|
if (
|
|
input.externalSignal.aborted ||
|
|
consumerSignal.aborted
|
|
) {
|
|
yield fail("ABORTED");
|
|
return;
|
|
}
|
|
if (input.scope.timedOut()) {
|
|
yield fail("UNAVAILABLE", {
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
});
|
|
return;
|
|
}
|
|
const result = await readWithSignal(
|
|
reader,
|
|
input.scope.signal,
|
|
);
|
|
if (result.done) break;
|
|
const chunk = result.value;
|
|
if (!(chunk instanceof Uint8Array)) {
|
|
yield fail("CORRUPT_DATA");
|
|
return;
|
|
}
|
|
const chunkEnd = transferred + chunk.byteLength;
|
|
if (
|
|
!Number.isSafeInteger(chunkEnd) ||
|
|
chunkEnd > input.capability.maxBytes
|
|
) {
|
|
yield fail("LIMIT_EXCEEDED");
|
|
return;
|
|
}
|
|
if (chunkEnd > input.capability.byteLength) {
|
|
yield fail("INTEGRITY_FAILED");
|
|
return;
|
|
}
|
|
// Browser fetch chunk sizing is implementation-defined. Re-slice into
|
|
// owned bounded chunks instead of rejecting a valid large chunk.
|
|
for (
|
|
let offset = 0;
|
|
offset < chunk.byteLength;
|
|
offset += input.hardMaxChunkBytes
|
|
) {
|
|
if (
|
|
input.externalSignal.aborted ||
|
|
consumerSignal.aborted
|
|
) {
|
|
yield fail("ABORTED");
|
|
return;
|
|
}
|
|
if (input.scope.timedOut()) {
|
|
yield fail("UNAVAILABLE", {
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
});
|
|
return;
|
|
}
|
|
const owned = chunk
|
|
.subarray(
|
|
offset,
|
|
Math.min(
|
|
chunk.byteLength,
|
|
offset + input.hardMaxChunkBytes,
|
|
),
|
|
)
|
|
.slice();
|
|
try {
|
|
verifier.update(owned);
|
|
} catch {
|
|
yield fail("INTEGRITY_FAILED");
|
|
return;
|
|
}
|
|
transferred += owned.byteLength;
|
|
yield browserDataSuccess(owned);
|
|
}
|
|
}
|
|
let verified = false;
|
|
try {
|
|
verified = verifier.verify();
|
|
} catch {
|
|
verified = false;
|
|
}
|
|
if (
|
|
transferred !== input.capability.byteLength ||
|
|
!verified
|
|
) {
|
|
yield fail("INTEGRITY_FAILED");
|
|
return;
|
|
}
|
|
completed = true;
|
|
} catch {
|
|
if (
|
|
input.externalSignal.aborted ||
|
|
consumerSignal.aborted
|
|
) {
|
|
yield fail("ABORTED");
|
|
} else if (input.scope.timedOut()) {
|
|
yield fail("UNAVAILABLE", {
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
});
|
|
} else {
|
|
yield fail("NOT_READABLE", {
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
});
|
|
}
|
|
} finally {
|
|
combined?.release();
|
|
if (!completed) {
|
|
if (reader) cancelReader(reader);
|
|
else cancelBody(input.response);
|
|
}
|
|
try {
|
|
reader?.releaseLock();
|
|
} catch {
|
|
// Reader cleanup cannot change stream success or failure.
|
|
}
|
|
input.scope.release();
|
|
observeBrowserData(input.observer, {
|
|
operation: "DOWNLOAD",
|
|
outcome: completed
|
|
? "SUCCEEDED"
|
|
: terminalFailureCode
|
|
? "FAILED"
|
|
: "DEGRADED",
|
|
...(terminalFailureCode
|
|
? { failureCode: terminalFailureCode }
|
|
: {}),
|
|
byteBucket: byteBucket(transferred),
|
|
});
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
function validateDownloadResponse(
|
|
response: Response,
|
|
binding: PresignedCapabilityBinding,
|
|
): BrowserDataResult<true> {
|
|
if (
|
|
response.redirected ||
|
|
response.type === "opaqueredirect" ||
|
|
!responseUrlMatches(
|
|
response,
|
|
binding.href,
|
|
)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
if (response.status !== binding.expectedStatus) {
|
|
return statusFailure(response.status);
|
|
}
|
|
if (!validateRequiredHeaders(response, binding)) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
const mediaType = response.headers
|
|
.get("content-type")
|
|
?.trim()
|
|
.toLowerCase();
|
|
const declaredLength = response.headers.get("content-length");
|
|
const digestHeader = binding.digestResponseHeader;
|
|
const contentEncoding = response.headers.get("content-encoding");
|
|
if (
|
|
mediaType !== binding.mediaType ||
|
|
declaredLength === null ||
|
|
Number(declaredLength) !== binding.byteLength ||
|
|
digestHeader === null ||
|
|
response.headers.get(digestHeader)?.trim().toLowerCase() !==
|
|
binding.expectedSha256 ||
|
|
(contentEncoding !== null &&
|
|
contentEncoding.trim().toLowerCase() !== "identity") ||
|
|
response.headers.has("content-range") ||
|
|
(binding.byteLength > 0 && !response.body)
|
|
) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
return browserDataSuccess(true);
|
|
}
|
|
|
|
function validateUploadResponse(
|
|
response: Response,
|
|
binding: PresignedCapabilityBinding,
|
|
hardMaxUploadResponseBytes: number,
|
|
): BrowserDataResult<
|
|
Readonly<{
|
|
receiptToken: string;
|
|
responseByteLength: number;
|
|
}>
|
|
> {
|
|
if (
|
|
response.redirected ||
|
|
response.type === "opaqueredirect" ||
|
|
!responseUrlMatches(
|
|
response,
|
|
binding.href,
|
|
)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
if (response.status !== binding.expectedStatus) {
|
|
return statusFailure(response.status);
|
|
}
|
|
if (!validateRequiredHeaders(response, binding)) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
const headerName = binding.receiptResponseHeader;
|
|
const expectedResponseByteLength =
|
|
binding.expectedResponseByteLength;
|
|
const declaredLength = normalizedContentLength(
|
|
response.headers.get("content-length"),
|
|
response.status === 204 && expectedResponseByteLength === 0,
|
|
);
|
|
if (
|
|
!headerName ||
|
|
expectedResponseByteLength === null ||
|
|
expectedResponseByteLength > hardMaxUploadResponseBytes ||
|
|
declaredLength === null ||
|
|
declaredLength !== expectedResponseByteLength ||
|
|
(expectedResponseByteLength > 0 && !response.body)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
const receipt = normalizeReceiptToken(
|
|
response.headers.get(headerName),
|
|
);
|
|
return receipt
|
|
? browserDataSuccess(
|
|
Object.freeze({
|
|
receiptToken: receipt,
|
|
responseByteLength: expectedResponseByteLength,
|
|
}),
|
|
)
|
|
: browserDataFailure("INTEGRITY_FAILED", "PRESIGNED_TRANSFER");
|
|
}
|
|
|
|
async function drainUploadResponse(
|
|
response: Response,
|
|
expectedByteLength: number,
|
|
hardMaxBytes: number,
|
|
signal: AbortSignal,
|
|
): Promise<BrowserDataResult<true>> {
|
|
if (!response.body) {
|
|
return expectedByteLength === 0
|
|
? browserDataSuccess(true)
|
|
: browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
const reader = response.body.getReader();
|
|
let completed = false;
|
|
let total = 0;
|
|
try {
|
|
while (true) {
|
|
const result = await readWithSignal(reader, signal);
|
|
if (result.done) break;
|
|
if (!(result.value instanceof Uint8Array)) {
|
|
return browserDataFailure(
|
|
"CORRUPT_DATA",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
total += result.value.byteLength;
|
|
if (!Number.isSafeInteger(total) || total > hardMaxBytes) {
|
|
return browserDataFailure(
|
|
"LIMIT_EXCEEDED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
if (total > expectedByteLength) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
}
|
|
if (total !== expectedByteLength) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
completed = true;
|
|
return browserDataSuccess(true);
|
|
} finally {
|
|
if (!completed) cancelReader(reader);
|
|
try {
|
|
reader.releaseLock();
|
|
} catch {
|
|
// Reader cleanup cannot alter the already classified result.
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateRequiredHeaders(
|
|
response: Response,
|
|
binding: PresignedCapabilityBinding,
|
|
): boolean {
|
|
return binding.requiredResponseHeaders.every(
|
|
(header) => response.headers.get(header.name) === header.value,
|
|
);
|
|
}
|
|
|
|
function normalizedContentLength(
|
|
value: string | null,
|
|
allowImplicitZero: boolean,
|
|
): number | null {
|
|
if (value === null) return allowImplicitZero ? 0 : null;
|
|
const trimmed = value.trim();
|
|
if (!/^(?:0|[1-9][0-9]{0,15})$/.test(trimmed)) return null;
|
|
const length = Number(trimmed);
|
|
return Number.isSafeInteger(length) ? length : null;
|
|
}
|
|
|
|
function headersFor(binding: PresignedCapabilityBinding): Headers {
|
|
const headers = new Headers();
|
|
for (const header of binding.requestHeaders) {
|
|
headers.set(header.name, header.value);
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function validCommonBinding(
|
|
binding: PresignedCapabilityBinding,
|
|
capability: PresignedTransferCapability,
|
|
hardMaxTransferBytes: number,
|
|
): boolean {
|
|
return (
|
|
binding.capability === capability &&
|
|
binding.capabilityReceipt === capability.capabilityReceipt &&
|
|
binding.method === capability.method &&
|
|
binding.binding === capability.binding &&
|
|
binding.mediaType === capability.mediaType &&
|
|
binding.byteLength === capability.byteLength &&
|
|
binding.maxBytes === capability.maxBytes &&
|
|
binding.expectedSha256 === capability.expectedSha256 &&
|
|
binding.expiresAtEpochMs === capability.expiresAtEpochMs &&
|
|
capability.byteLength >= 0 &&
|
|
capability.byteLength <= capability.maxBytes &&
|
|
capability.maxBytes <= hardMaxTransferBytes &&
|
|
SHA256.test(capability.expectedSha256) &&
|
|
Object.isFrozen(capability) &&
|
|
Object.isFrozen(capability.binding)
|
|
);
|
|
}
|
|
|
|
function validateExpiry(
|
|
capability: PresignedTransferCapability,
|
|
minimumRemainingLifetimeMs: number,
|
|
nowEpochMs: number,
|
|
): BrowserDataResult<true> {
|
|
const remainingLifetimeMs =
|
|
capability.expiresAtEpochMs - nowEpochMs;
|
|
if (
|
|
!Number.isSafeInteger(nowEpochMs) ||
|
|
nowEpochMs < 0 ||
|
|
!Number.isSafeInteger(remainingLifetimeMs)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
return remainingLifetimeMs >= minimumRemainingLifetimeMs
|
|
? browserDataSuccess(true)
|
|
: browserDataFailure(
|
|
"EXPIRED_RESOURCE",
|
|
"PRESIGNED_TRANSFER",
|
|
{ recovery: "REISSUE_CAPABILITY" },
|
|
);
|
|
}
|
|
|
|
function statusFailure(
|
|
status: number,
|
|
): BrowserDataResult<never> {
|
|
let code: BrowserDataFailureCode = "UNAVAILABLE";
|
|
let recovery: BrowserDataRecovery = "REISSUE_CAPABILITY";
|
|
let retryable = status === 429 || status >= 500;
|
|
if (status === 401 || status === 403) {
|
|
code = "PERMISSION_DENIED";
|
|
recovery = "REISSUE_CAPABILITY";
|
|
retryable = false;
|
|
} else if (status === 404) {
|
|
code = "NOT_FOUND";
|
|
recovery = "NONE";
|
|
retryable = false;
|
|
} else if (status === 409) {
|
|
code = "CONFLICT";
|
|
retryable = false;
|
|
} else if (status === 410) {
|
|
code = "EXPIRED_RESOURCE";
|
|
retryable = false;
|
|
} else if (status === 413) {
|
|
code = "LIMIT_EXCEEDED";
|
|
recovery = "NONE";
|
|
retryable = false;
|
|
} else if (status >= 400 && status < 500 && status !== 429) {
|
|
code = "POLICY_REJECTED";
|
|
recovery = "NONE";
|
|
retryable = false;
|
|
}
|
|
return browserDataFailure(code, "PRESIGNED_TRANSFER", {
|
|
retryable,
|
|
recovery,
|
|
});
|
|
}
|
|
|
|
function transferFailure(
|
|
externalSignal: AbortSignal,
|
|
timedOut: boolean,
|
|
): BrowserDataResult<never> {
|
|
if (externalSignal.aborted) {
|
|
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
return browserDataFailure(
|
|
timedOut ? "UNAVAILABLE" : "NOT_READABLE",
|
|
"PRESIGNED_TRANSFER",
|
|
{
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
},
|
|
);
|
|
}
|
|
|
|
function createAbortScope(
|
|
external: AbortSignal,
|
|
timeoutMs: number,
|
|
scheduler: Scheduler,
|
|
) {
|
|
const controller = new AbortController();
|
|
let timedOut = false;
|
|
let released = false;
|
|
const onAbort = () => controller.abort(external.reason);
|
|
const releaseListener = () => {
|
|
external.removeEventListener("abort", onAbort);
|
|
};
|
|
external.addEventListener("abort", onAbort, { once: true });
|
|
if (external.aborted) onAbort();
|
|
const timer = scheduler.setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort("timeout");
|
|
releaseListener();
|
|
}, timeoutMs);
|
|
return Object.freeze({
|
|
signal: controller.signal,
|
|
timedOut: () => timedOut,
|
|
abort(reason?: unknown) {
|
|
controller.abort(reason);
|
|
},
|
|
release() {
|
|
if (released) return;
|
|
released = true;
|
|
scheduler.clearTimeout(timer);
|
|
releaseListener();
|
|
},
|
|
});
|
|
}
|
|
|
|
function combineConsumerAbort(
|
|
scope: ReturnType<typeof createAbortScope>,
|
|
consumer: AbortSignal,
|
|
) {
|
|
const onAbort = () => scope.abort(consumer.reason);
|
|
consumer.addEventListener("abort", onAbort, { once: true });
|
|
if (consumer.aborted) onAbort();
|
|
return Object.freeze({
|
|
release() {
|
|
consumer.removeEventListener("abort", onAbort);
|
|
},
|
|
});
|
|
}
|
|
|
|
function cancelBody(response: Response): void {
|
|
try {
|
|
void response.body?.cancel().catch(() => {
|
|
// Cancellation is best effort after the result is classified.
|
|
});
|
|
} catch {
|
|
// A response already classified as failure/success is not reclassified by
|
|
// best-effort body cancellation.
|
|
}
|
|
}
|
|
|
|
function cancelReader(
|
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
): void {
|
|
try {
|
|
void reader.cancel().catch(() => {
|
|
// Cancellation is best effort after the result is classified.
|
|
});
|
|
} catch {
|
|
// Reader cancellation cannot alter the terminal closed result.
|
|
}
|
|
}
|
|
|
|
function readWithSignal(
|
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
signal: AbortSignal,
|
|
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
|
if (signal.aborted) {
|
|
return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const onAbort = () => {
|
|
reject(new DOMException("Aborted", "AbortError"));
|
|
};
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
reader.read().then(
|
|
(result) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve(result);
|
|
},
|
|
(error: unknown) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function normalizeReceiptToken(value: string | null): string | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
const unquoted =
|
|
trimmed.length >= 2 &&
|
|
trimmed.startsWith("\"") &&
|
|
trimmed.endsWith("\"")
|
|
? trimmed.slice(1, -1)
|
|
: trimmed;
|
|
return RECEIPT_TOKEN.test(unquoted) && !unquoted.includes("://")
|
|
? unquoted
|
|
: null;
|
|
}
|
|
|
|
function responseUrlMatches(
|
|
response: Response,
|
|
expectedHref: string,
|
|
): boolean {
|
|
if (response.url.length === 0) return false;
|
|
try {
|
|
return new URL(response.url).href === new URL(expectedHref).href;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizedSha256(value: unknown): string {
|
|
if (typeof value !== "string") throw new TypeError("SHA-256 is invalid.");
|
|
const normalized = value.toLowerCase();
|
|
if (!SHA256.test(normalized)) throw new TypeError("SHA-256 is invalid.");
|
|
return normalized;
|
|
}
|
|
|
|
function opaqueId(value: unknown): string {
|
|
if (
|
|
typeof value !== "string" ||
|
|
value.length === 0 ||
|
|
value.length > 256 ||
|
|
!/^[a-z0-9][a-z0-9._:-]*$/i.test(value)
|
|
) {
|
|
throw new TypeError("Opaque identifier is invalid.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function positiveSafeInteger(value: unknown): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
|
|
throw new TypeError("Expected a positive safe integer.");
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function nonNegativeSafeInteger(value: unknown): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
throw new TypeError("Expected a non-negative safe integer.");
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
async function digestSha256WithWebCrypto(
|
|
bytes: Uint8Array,
|
|
): Promise<string> {
|
|
if (!(bytes instanceof Uint8Array) || !globalThis.crypto?.subtle) {
|
|
throw new TypeError("WebCrypto SHA-256 is unavailable.");
|
|
}
|
|
const digest = await globalThis.crypto.subtle.digest(
|
|
"SHA-256",
|
|
bytes.slice(),
|
|
);
|
|
return Array.from(new Uint8Array(digest), (value) =>
|
|
value.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
}
|
|
|
|
function isAbortSignal(value: unknown): value is AbortSignal {
|
|
try {
|
|
if (!value || typeof value !== "object") return false;
|
|
const abortedGetter = Object.getOwnPropertyDescriptor(
|
|
AbortSignal.prototype,
|
|
"aborted",
|
|
)?.get;
|
|
return Boolean(
|
|
abortedGetter &&
|
|
typeof abortedGetter.call(value) === "boolean" &&
|
|
typeof (value as AbortSignal).addEventListener === "function" &&
|
|
typeof (value as AbortSignal).removeEventListener === "function",
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function safeInputByteLength(value: unknown): number | undefined {
|
|
try {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
const input = value as Readonly<{
|
|
byteLength?: unknown;
|
|
bytes?: unknown;
|
|
capability?: unknown;
|
|
}>;
|
|
if (
|
|
Number.isSafeInteger(input.byteLength) &&
|
|
(input.byteLength as number) >= 0
|
|
) {
|
|
return input.byteLength as number;
|
|
}
|
|
if (input.bytes instanceof Uint8Array) {
|
|
return input.bytes.byteLength;
|
|
}
|
|
if (
|
|
input.capability &&
|
|
typeof input.capability === "object" &&
|
|
Number.isSafeInteger(
|
|
(input.capability as Readonly<{ byteLength?: unknown }>)
|
|
.byteLength,
|
|
)
|
|
) {
|
|
const byteLength = (
|
|
input.capability as Readonly<{ byteLength: number }>
|
|
).byteLength;
|
|
return byteLength >= 0 ? byteLength : undefined;
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function observeTransferResult(
|
|
observer: BrowserDataObserver | undefined,
|
|
operation: BrowserDataOperation,
|
|
result: BrowserDataResult<unknown>,
|
|
byteLength?: number,
|
|
): void {
|
|
const bucket = byteBucket(byteLength);
|
|
observeBrowserData(observer, {
|
|
operation,
|
|
outcome: result.ok ? "SUCCEEDED" : "FAILED",
|
|
...(!result.ok ? { failureCode: result.error.code } : {}),
|
|
...(bucket !== undefined ? { byteBucket: bucket } : {}),
|
|
});
|
|
}
|
|
|
|
function byteBucket(
|
|
byteLength: number | undefined,
|
|
):
|
|
| "ZERO"
|
|
| "LT1MIB"
|
|
| "1_TO_9MIB"
|
|
| "10_TO_99MIB"
|
|
| "GTE100MIB"
|
|
| undefined {
|
|
if (
|
|
byteLength === undefined ||
|
|
!Number.isSafeInteger(byteLength) ||
|
|
byteLength < 0
|
|
) {
|
|
return undefined;
|
|
}
|
|
if (byteLength === 0) return "ZERO";
|
|
if (byteLength < 1_048_576) return "LT1MIB";
|
|
if (byteLength < 10_485_760) return "1_TO_9MIB";
|
|
if (byteLength < 104_857_600) return "10_TO_99MIB";
|
|
return "GTE100MIB";
|
|
}
|