fix: complete presigned capability and upload transport contracts
BT-PRE-02: add the top-level PRESIGNED_TRANSFER_V1 protocol literal to the capability request and response. A missing, V0 or V2 envelope is closed as POLICY_REJECTED before the vault registers anything. The server negotiates by request shape; fields are never dual-emitted into a strict decoder, and the nested PRESIGNED_MULTIPART_V1 binding protocol is unchanged. BT-PRE-03: aborting a controller does not settle a fetch that ignores its signal, so both presigned scopes now race the task, cancel a late response body and survive a throwing scheduler without leaking the external abort listener. BT-PRE-04: the vault owns its registration invariants, re-checking method, href/origin/path agreement, embedded credentials, byte bounds, digest shape and expiry, so a second issuer cannot register a weaker capability of the same type. BT-PRE-05: decode each path segment once and require it to round-trip through the canonical uppercase percent encoder, closing %2f, %5c, %252e%252e, mixed-case escapes and encoded NUL while still admitting valid opaque UTF-8 segments. BT-UP-02: inject and snapshot the upload transport clock and scheduler, so Retry-After delta-seconds and HTTP-date resolve against the same captured now and a clock rollback clamps to zero instead of producing a negative delay. BT-IMG-01: make the image resolve() lifetime signal required, replacing the hidden PRIMARY_REQUIRED preset precondition with a type-level one, and add the negative typecheck fixture and gate that prove it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
976c8a8da4
commit
000a2581af
@@ -297,16 +297,22 @@ export function createPresignedTransferExecutor(
|
||||
|
||||
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 raced = await scope.race(
|
||||
fetcher(binding.href, {
|
||||
method: "PUT",
|
||||
headers: headersFor(binding),
|
||||
body: bytes.buffer,
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
signal: scope.signal,
|
||||
}),
|
||||
);
|
||||
if (raced === SCOPE_ENDED) {
|
||||
return transferFailure(request.signal, scope.timedOut());
|
||||
}
|
||||
const response = raced;
|
||||
const validated = validateUploadResponse(
|
||||
response,
|
||||
binding,
|
||||
@@ -484,7 +490,20 @@ function createDownloadSource(input: Readonly<{
|
||||
activeScope = scope;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await input.start(scope);
|
||||
// BT-PRE-03. A fetch that ignores its signal cannot outlive the scope.
|
||||
const started = await scope.race(input.start(scope));
|
||||
if (started === SCOPE_ENDED) {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
const failure = transferFailure(
|
||||
input.externalSignal,
|
||||
scope.timedOut(),
|
||||
);
|
||||
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
response = started;
|
||||
} catch {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
@@ -984,6 +1003,19 @@ function transferFailure(
|
||||
);
|
||||
}
|
||||
|
||||
/** BT-PRE-03. The scope ended before the task settled. */
|
||||
const SCOPE_ENDED = Symbol("presigned-scope-ended");
|
||||
|
||||
function compensateLateResponse(task: Promise<unknown>): void {
|
||||
void task
|
||||
.then(async (value) => {
|
||||
const body = (value as { body?: { cancel(): Promise<void> } | null })
|
||||
?.body;
|
||||
await body?.cancel();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function createAbortScope(
|
||||
external: AbortSignal,
|
||||
timeoutMs: number,
|
||||
@@ -994,25 +1026,63 @@ function createAbortScope(
|
||||
let released = false;
|
||||
const onAbort = () => controller.abort(external.reason);
|
||||
const releaseListener = () => {
|
||||
external.removeEventListener("abort", onAbort);
|
||||
try {
|
||||
external.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block the rest of cleanup.
|
||||
}
|
||||
};
|
||||
external.addEventListener("abort", onAbort, { once: true });
|
||||
if (external.aborted) onAbort();
|
||||
const timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
let timer: unknown;
|
||||
try {
|
||||
timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
releaseListener();
|
||||
}, timeoutMs);
|
||||
} catch {
|
||||
// BT-PRE-03. A scheduler that throws must not leave the external listener
|
||||
// attached; the scope stays bounded by the caller signal alone.
|
||||
releaseListener();
|
||||
}, timeoutMs);
|
||||
timer = undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
/**
|
||||
* BT-PRE-03. Aborting the controller does not settle a fetch that ignores
|
||||
* its signal, so the caller-facing wait is raced against the scope and a
|
||||
* late `Response` has its body cancelled.
|
||||
*/
|
||||
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
|
||||
task.catch(() => {});
|
||||
if (controller.signal.aborted) {
|
||||
compensateLateResponse(task);
|
||||
return SCOPE_ENDED;
|
||||
}
|
||||
const ended = new Promise<typeof SCOPE_ENDED>((resolve) => {
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => resolve(SCOPE_ENDED),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
const raced = await Promise.race([task, ended]);
|
||||
if (raced === SCOPE_ENDED) compensateLateResponse(task);
|
||||
return raced;
|
||||
},
|
||||
abort(reason?: unknown) {
|
||||
controller.abort(reason);
|
||||
},
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
scheduler.clearTimeout(timer);
|
||||
try {
|
||||
if (timer !== undefined) scheduler.clearTimeout(timer as never);
|
||||
} catch {
|
||||
// Cleanup failures cannot change the classified result.
|
||||
}
|
||||
releaseListener();
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user