chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,697 @@
|
||||
import type {
|
||||
FilePolicyReference,
|
||||
FilePickerPort,
|
||||
FileSelectionLimitReduction,
|
||||
FileSelectionOutcome,
|
||||
LocalFileRef,
|
||||
} from "../../application/ports/browser-file-storage/file.ts";
|
||||
import type { BrowserDataResult } from "../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
abortedResult,
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
mapBrowserDataException,
|
||||
} from "../browser-file-storage/result.ts";
|
||||
import {
|
||||
BrowserFileVault,
|
||||
type SystemFileHandle,
|
||||
} from "./browser-file-vault.ts";
|
||||
import {
|
||||
byteBucket,
|
||||
observeBrowserFile,
|
||||
type BrowserFileObserver,
|
||||
} from "./file-observer.ts";
|
||||
import type { RegisteredFileSelectionPolicy } from "./file-policy.ts";
|
||||
import { BrowserFilePolicyRegistry } from "./browser-file-policy-registry.ts";
|
||||
|
||||
type UserActivationState = Readonly<{ isActive: boolean }>;
|
||||
|
||||
type PickerScheduler = Readonly<{
|
||||
setTimeout(callback: () => void, delayMs: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
}>;
|
||||
|
||||
type InputEventDependencies = Readonly<{
|
||||
add(
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
options?: AddEventListenerOptions,
|
||||
): void;
|
||||
remove(type: string, listener: EventListener): void;
|
||||
getAttribute(name: string): string | null;
|
||||
activate(): void;
|
||||
}>;
|
||||
|
||||
type WindowEventDependencies = Readonly<{
|
||||
add(type: "focus", listener: EventListener): void;
|
||||
remove(type: "focus", listener: EventListener): void;
|
||||
}>;
|
||||
|
||||
interface DisposableFilePicker extends FilePickerPort {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus can return before some engines dispatch the file input change event.
|
||||
* This grace keeps the fallback from misclassifying a real selection as a
|
||||
* dismissal. The native cancel event remains authoritative and immediate.
|
||||
*/
|
||||
export const DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS = 1_000;
|
||||
|
||||
export type NativeInputFilePickerOptions = Readonly<{
|
||||
/**
|
||||
* A connected, labelled input owned by presentation. The adapter does not
|
||||
* create an inaccessible hidden control.
|
||||
*/
|
||||
input: HTMLInputElement;
|
||||
vault: BrowserFileVault;
|
||||
policies: BrowserFilePolicyRegistry;
|
||||
window?: Pick<Window, "addEventListener" | "removeEventListener">;
|
||||
userActivation?: UserActivationState;
|
||||
scheduler?: PickerScheduler;
|
||||
focusFallbackGraceMs?: number;
|
||||
/** @deprecated Use focusFallbackGraceMs. */
|
||||
cancelFallbackDelayMs?: number;
|
||||
systemOpenPickerSupported?: boolean;
|
||||
systemSavePickerSupported?: boolean;
|
||||
observer?: BrowserFileObserver;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Canonical cross-browser picker. select() must be called directly from the
|
||||
* input's labelled button/keyboard activation.
|
||||
*/
|
||||
export class NativeInputFilePicker implements DisposableFilePicker {
|
||||
readonly support;
|
||||
readonly #input: HTMLInputElement;
|
||||
readonly #captureFiles: BrowserFileVault["captureFiles"];
|
||||
readonly #releaseFile: BrowserFileVault["release"];
|
||||
readonly #resolveSelection:
|
||||
BrowserFilePolicyRegistry["resolveSelection"];
|
||||
readonly #inputEvents: InputEventDependencies;
|
||||
readonly #windowEvents: WindowEventDependencies | undefined;
|
||||
readonly #userActivation: UserActivationState | undefined;
|
||||
readonly #scheduler: PickerScheduler;
|
||||
readonly #focusFallbackGraceMs: number;
|
||||
readonly #observer: BrowserFileObserver | undefined;
|
||||
#pending = false;
|
||||
#disposed = false;
|
||||
#abortPending: (() => void) | undefined;
|
||||
|
||||
constructor(options: NativeInputFilePickerOptions) {
|
||||
this.#input = options.input;
|
||||
this.#captureFiles =
|
||||
options.vault.captureFiles.bind(options.vault);
|
||||
this.#releaseFile = options.vault.release.bind(options.vault);
|
||||
this.#resolveSelection =
|
||||
options.policies.resolveSelection.bind(options.policies);
|
||||
const addInputEvent = options.input.addEventListener;
|
||||
const removeInputEvent = options.input.removeEventListener;
|
||||
const getInputAttribute = options.input.getAttribute;
|
||||
const showPicker = options.input.showPicker;
|
||||
const click = options.input.click;
|
||||
if (
|
||||
typeof addInputEvent !== "function" ||
|
||||
typeof removeInputEvent !== "function" ||
|
||||
typeof getInputAttribute !== "function" ||
|
||||
(typeof showPicker !== "function" &&
|
||||
typeof click !== "function")
|
||||
) {
|
||||
throw new TypeError("Native file input API is invalid.");
|
||||
}
|
||||
this.#inputEvents = Object.freeze({
|
||||
add: addInputEvent.bind(options.input),
|
||||
remove: removeInputEvent.bind(options.input),
|
||||
getAttribute: getInputAttribute.bind(options.input),
|
||||
activate:
|
||||
typeof showPicker === "function"
|
||||
? showPicker.bind(options.input)
|
||||
: click.bind(options.input),
|
||||
});
|
||||
const windowHost = options.window ?? globalThis.window;
|
||||
if (windowHost) {
|
||||
const addWindowEvent = windowHost.addEventListener;
|
||||
const removeWindowEvent = windowHost.removeEventListener;
|
||||
if (
|
||||
typeof addWindowEvent !== "function" ||
|
||||
typeof removeWindowEvent !== "function"
|
||||
) {
|
||||
throw new TypeError("Native picker window API is invalid.");
|
||||
}
|
||||
this.#windowEvents = Object.freeze({
|
||||
add: addWindowEvent.bind(windowHost),
|
||||
remove: removeWindowEvent.bind(windowHost),
|
||||
});
|
||||
} else {
|
||||
this.#windowEvents = undefined;
|
||||
}
|
||||
this.#userActivation =
|
||||
options.userActivation ?? globalThis.navigator?.userActivation;
|
||||
const scheduler =
|
||||
options.scheduler ??
|
||||
({
|
||||
setTimeout: (callback: () => void, delayMs: number) =>
|
||||
globalThis.setTimeout(callback, delayMs),
|
||||
clearTimeout: (handle: unknown) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies PickerScheduler);
|
||||
if (
|
||||
typeof scheduler.setTimeout !== "function" ||
|
||||
typeof scheduler.clearTimeout !== "function"
|
||||
) {
|
||||
throw new TypeError("Native picker scheduler is invalid.");
|
||||
}
|
||||
this.#scheduler = Object.freeze({
|
||||
setTimeout: scheduler.setTimeout.bind(scheduler),
|
||||
clearTimeout: scheduler.clearTimeout.bind(scheduler),
|
||||
});
|
||||
if (
|
||||
options.focusFallbackGraceMs !== undefined &&
|
||||
options.cancelFallbackDelayMs !== undefined &&
|
||||
options.focusFallbackGraceMs !== options.cancelFallbackDelayMs
|
||||
) {
|
||||
throw new TypeError("Native picker focus grace is ambiguous.");
|
||||
}
|
||||
this.#focusFallbackGraceMs =
|
||||
options.focusFallbackGraceMs ??
|
||||
options.cancelFallbackDelayMs ??
|
||||
DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS;
|
||||
this.#observer = options.observer;
|
||||
this.support = Object.freeze({
|
||||
nativeInput: true as const,
|
||||
systemOpenPicker: options.systemOpenPickerSupported ?? false,
|
||||
systemSavePicker: options.systemSavePickerSupported ?? false,
|
||||
});
|
||||
if (
|
||||
!Number.isSafeInteger(this.#focusFallbackGraceMs) ||
|
||||
this.#focusFallbackGraceMs < 0
|
||||
) {
|
||||
throw new TypeError("Native picker cancel fallback delay is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
async select(input: {
|
||||
policy: FilePolicyReference;
|
||||
limits?: FileSelectionLimitReduction;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<BrowserDataResult<FileSelectionOutcome>> {
|
||||
let request: SelectionRequestSnapshot;
|
||||
try {
|
||||
request = snapshotSelectionRequest(input);
|
||||
} catch {
|
||||
return this.#finishObservation(
|
||||
browserDataFailure("INVALID_INPUT", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
if (this.#disposed) {
|
||||
return this.#finishObservation(
|
||||
browserDataFailure("UNAVAILABLE", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
const cancelled = abortedResult(request.signal, "FILE_SELECT");
|
||||
if (cancelled) return this.#finishObservation(cancelled);
|
||||
const resolvedPolicy = this.#resolveSelection(
|
||||
request.policy,
|
||||
request.limits,
|
||||
);
|
||||
if (!resolvedPolicy.ok) {
|
||||
return this.#finishObservation(resolvedPolicy);
|
||||
}
|
||||
const policy = resolvedPolicy.value;
|
||||
if (
|
||||
!isUsableFileInput(
|
||||
this.#input,
|
||||
this.#inputEvents.getAttribute,
|
||||
)
|
||||
) {
|
||||
return this.#finishObservation(
|
||||
browserDataFailure("INVALID_INPUT", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
if (this.#pending) {
|
||||
return this.#finishObservation(
|
||||
browserDataFailure("BLOCKED", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
if (this.#userActivation && !this.#userActivation.isActive) {
|
||||
return this.#finishObservation(
|
||||
browserDataFailure("PERMISSION_DENIED", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
|
||||
this.#pending = true;
|
||||
try {
|
||||
const result =
|
||||
await new Promise<BrowserDataResult<FileSelectionOutcome>>(
|
||||
(resolve) => {
|
||||
let settled = false;
|
||||
let focusTimer: unknown;
|
||||
const signal = request.signal;
|
||||
const finish = (
|
||||
outcome: BrowserDataResult<FileSelectionOutcome>,
|
||||
): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.#inputEvents.remove("change", onChange);
|
||||
this.#inputEvents.remove("cancel", onCancel);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
this.#windowEvents?.remove("focus", onWindowFocus);
|
||||
if (focusTimer !== undefined) {
|
||||
this.#scheduler.clearTimeout(focusTimer);
|
||||
}
|
||||
if (this.#abortPending === abortPending) {
|
||||
this.#abortPending = undefined;
|
||||
}
|
||||
resolve(outcome);
|
||||
};
|
||||
const dismiss = (): void =>
|
||||
finish(
|
||||
browserDataSuccess(
|
||||
Object.freeze({ kind: "DISMISSED" as const }),
|
||||
),
|
||||
);
|
||||
const onChange = (): void => {
|
||||
const files = this.#input.files;
|
||||
if (!files || files.length === 0) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
const captured = this.#captureFiles(
|
||||
files,
|
||||
policy,
|
||||
"NATIVE_INPUT",
|
||||
);
|
||||
finish(
|
||||
captured.ok
|
||||
? browserDataSuccess(
|
||||
Object.freeze({
|
||||
kind: "SELECTED" as const,
|
||||
files: captured.value,
|
||||
}),
|
||||
)
|
||||
: captured,
|
||||
);
|
||||
};
|
||||
const onCancel = (): void => dismiss();
|
||||
const onAbort = (): void =>
|
||||
finish(browserDataFailure("ABORTED", "FILE_SELECT"));
|
||||
const abortPending = (): void =>
|
||||
finish(browserDataFailure("ABORTED", "FILE_SELECT"));
|
||||
const onWindowFocus = (): void => {
|
||||
if (settled || focusTimer !== undefined) return;
|
||||
focusTimer = this.#scheduler.setTimeout(
|
||||
() => {
|
||||
focusTimer = undefined;
|
||||
if (settled) return;
|
||||
// Some engines expose FileList before dispatching change.
|
||||
// Prefer the selected files over a synthetic dismissal.
|
||||
if ((this.#input.files?.length ?? 0) > 0) {
|
||||
onChange();
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
this.#focusFallbackGraceMs,
|
||||
);
|
||||
};
|
||||
|
||||
this.#inputEvents.add("change", onChange, { once: true });
|
||||
this.#inputEvents.add("cancel", onCancel, { once: true });
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
this.#windowEvents?.add("focus", onWindowFocus);
|
||||
this.#abortPending = abortPending;
|
||||
try {
|
||||
this.#input.accept = pickerAcceptValue(policy);
|
||||
this.#input.multiple = policy.multiple;
|
||||
// Allows the same file to produce a new change event.
|
||||
this.#input.value = "";
|
||||
this.#inputEvents.activate();
|
||||
} catch (error) {
|
||||
finish(mapBrowserDataException(error, "FILE_SELECT"));
|
||||
}
|
||||
},
|
||||
);
|
||||
return this.#finishObservation(result);
|
||||
} catch (error) {
|
||||
return this.#finishObservation(
|
||||
mapBrowserDataException(error, "FILE_SELECT"),
|
||||
);
|
||||
} finally {
|
||||
this.#pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
release(ref: LocalFileRef): void {
|
||||
this.#releaseFile(ref);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#abortPending?.();
|
||||
try {
|
||||
this.#input.value = "";
|
||||
} catch {
|
||||
// Some test doubles or constrained DOM hosts expose a readonly value.
|
||||
}
|
||||
}
|
||||
|
||||
#finishObservation(
|
||||
result: BrowserDataResult<FileSelectionOutcome>,
|
||||
): BrowserDataResult<FileSelectionOutcome> {
|
||||
if (result.ok) {
|
||||
const dismissed = result.value.kind === "DISMISSED";
|
||||
const bytes =
|
||||
result.value.kind === "SELECTED"
|
||||
? result.value.files.reduce(
|
||||
(total, candidate) => total + candidate.sizeBytes,
|
||||
0,
|
||||
)
|
||||
: null;
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation: "FILE_SELECT",
|
||||
outcome: dismissed ? "DISMISSED" : "SUCCESS",
|
||||
byteBucket: byteBucket(bytes),
|
||||
});
|
||||
} else {
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation: "FILE_SELECT",
|
||||
outcome: "FAILED",
|
||||
failureCode: result.error.code,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export type SystemOpenPickerAcceptType = Readonly<{
|
||||
description?: string;
|
||||
accept: Readonly<Record<string, readonly string[]>>;
|
||||
}>;
|
||||
|
||||
export type SystemOpenPickerOptions = Readonly<{
|
||||
multiple: boolean;
|
||||
excludeAcceptAllOption: boolean;
|
||||
types: readonly SystemOpenPickerAcceptType[];
|
||||
}>;
|
||||
|
||||
export type SystemOpenPicker = (
|
||||
options: SystemOpenPickerOptions,
|
||||
) => Promise<readonly SystemFileHandle[]>;
|
||||
|
||||
export type EnhancedFilePickerOptions = Readonly<{
|
||||
showOpenFilePicker: SystemOpenPicker;
|
||||
vault: BrowserFileVault;
|
||||
policies: BrowserFilePolicyRegistry;
|
||||
userActivation?: UserActivationState;
|
||||
systemSavePickerSupported?: boolean;
|
||||
observer?: BrowserFileObserver;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Progressive enhancement. Failure never opens the native fallback in the
|
||||
* same activation; presentation may offer a baseline button for the next
|
||||
* explicit user action.
|
||||
*/
|
||||
export class EnhancedFilePicker implements DisposableFilePicker {
|
||||
readonly support;
|
||||
readonly #showOpenFilePicker: SystemOpenPicker;
|
||||
readonly #captureHandles: BrowserFileVault["captureHandles"];
|
||||
readonly #releaseFile: BrowserFileVault["release"];
|
||||
readonly #resolveSelection:
|
||||
BrowserFilePolicyRegistry["resolveSelection"];
|
||||
readonly #userActivation: UserActivationState | undefined;
|
||||
readonly #observer: BrowserFileObserver | undefined;
|
||||
#pending = false;
|
||||
#disposed = false;
|
||||
#abortPending: (() => void) | undefined;
|
||||
|
||||
constructor(options: EnhancedFilePickerOptions) {
|
||||
if (typeof options.showOpenFilePicker !== "function") {
|
||||
throw new TypeError("Enhanced file picker API is invalid.");
|
||||
}
|
||||
this.#showOpenFilePicker =
|
||||
options.showOpenFilePicker.bind(options);
|
||||
this.#captureHandles =
|
||||
options.vault.captureHandles.bind(options.vault);
|
||||
this.#releaseFile = options.vault.release.bind(options.vault);
|
||||
this.#resolveSelection =
|
||||
options.policies.resolveSelection.bind(options.policies);
|
||||
this.#userActivation =
|
||||
options.userActivation ?? globalThis.navigator?.userActivation;
|
||||
this.#observer = options.observer;
|
||||
this.support = Object.freeze({
|
||||
nativeInput: true as const,
|
||||
systemOpenPicker: true,
|
||||
systemSavePicker: options.systemSavePickerSupported ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
async select(input: {
|
||||
policy: FilePolicyReference;
|
||||
limits?: FileSelectionLimitReduction;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<BrowserDataResult<FileSelectionOutcome>> {
|
||||
let request: SelectionRequestSnapshot;
|
||||
try {
|
||||
request = snapshotSelectionRequest(input);
|
||||
} catch {
|
||||
return this.#observe(
|
||||
browserDataFailure("INVALID_INPUT", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
if (this.#disposed) {
|
||||
return this.#observe(
|
||||
browserDataFailure("UNAVAILABLE", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
const cancelled = abortedResult(request.signal, "FILE_SELECT");
|
||||
if (cancelled) return this.#observe(cancelled);
|
||||
const resolvedPolicy = this.#resolveSelection(
|
||||
request.policy,
|
||||
request.limits,
|
||||
);
|
||||
if (!resolvedPolicy.ok) return this.#observe(resolvedPolicy);
|
||||
const policy = resolvedPolicy.value;
|
||||
if (this.#pending) {
|
||||
return this.#observe(
|
||||
browserDataFailure("BLOCKED", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
if (this.#userActivation && !this.#userActivation.isActive) {
|
||||
return this.#observe(
|
||||
browserDataFailure("PERMISSION_DENIED", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
|
||||
this.#pending = true;
|
||||
try {
|
||||
// This call intentionally happens before the first await.
|
||||
const picker = this.#showOpenFilePicker(
|
||||
systemPickerOptions(policy),
|
||||
);
|
||||
const handles = await this.#awaitPickerOrDispose(
|
||||
picker,
|
||||
request.signal,
|
||||
);
|
||||
const aborted = abortedResult(request.signal, "FILE_SELECT");
|
||||
if (aborted) return this.#observe(aborted);
|
||||
if (handles.length === 0) {
|
||||
return this.#observe(
|
||||
browserDataSuccess(
|
||||
Object.freeze({ kind: "DISMISSED" as const }),
|
||||
),
|
||||
);
|
||||
}
|
||||
const captured = await this.#captureHandles(
|
||||
handles,
|
||||
policy,
|
||||
request.signal ?? new AbortController().signal,
|
||||
);
|
||||
return this.#observe(
|
||||
captured.ok
|
||||
? browserDataSuccess(
|
||||
Object.freeze({
|
||||
kind: "SELECTED" as const,
|
||||
files: captured.value,
|
||||
}),
|
||||
)
|
||||
: captured,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PickerDisposedError) {
|
||||
return this.#observe(
|
||||
browserDataFailure("ABORTED", "FILE_SELECT"),
|
||||
);
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
const aborted = abortedResult(input.signal, "FILE_SELECT");
|
||||
if (aborted) return this.#observe(aborted);
|
||||
return this.#observe(
|
||||
browserDataSuccess(
|
||||
Object.freeze({ kind: "DISMISSED" as const }),
|
||||
),
|
||||
);
|
||||
}
|
||||
return this.#observe(
|
||||
mapBrowserDataException(error, "FILE_SELECT"),
|
||||
);
|
||||
} finally {
|
||||
this.#pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
release(ref: LocalFileRef): void {
|
||||
this.#releaseFile(ref);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#abortPending?.();
|
||||
}
|
||||
|
||||
#awaitPickerOrDispose(
|
||||
picker: Promise<readonly SystemFileHandle[]>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly SystemFileHandle[]> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(
|
||||
new DOMException("Picker aborted", "AbortError"),
|
||||
);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback: () => void): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (this.#abortPending === abortPending) {
|
||||
this.#abortPending = undefined;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
const abortPending = (): void =>
|
||||
finish(() => reject(new PickerDisposedError()));
|
||||
const onAbort = (): void =>
|
||||
finish(() =>
|
||||
reject(new DOMException("Picker aborted", "AbortError")),
|
||||
);
|
||||
this.#abortPending = abortPending;
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
picker.then(
|
||||
(handles) => finish(() => resolve(handles)),
|
||||
(error: unknown) => finish(() => reject(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#observe(
|
||||
result: BrowserDataResult<FileSelectionOutcome>,
|
||||
): BrowserDataResult<FileSelectionOutcome> {
|
||||
if (!result.ok) {
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation: "FILE_SELECT",
|
||||
outcome: "FAILED",
|
||||
failureCode: result.error.code,
|
||||
});
|
||||
} else if (result.value.kind === "DISMISSED") {
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation: "FILE_SELECT",
|
||||
outcome: "DISMISSED",
|
||||
});
|
||||
} else {
|
||||
const bytes = result.value.files.reduce(
|
||||
(total, file) => total + file.sizeBytes,
|
||||
0,
|
||||
);
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation: "FILE_SELECT",
|
||||
outcome: "SUCCESS",
|
||||
byteBucket: byteBucket(bytes),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function pickerAcceptValue(
|
||||
policy: RegisteredFileSelectionPolicy,
|
||||
): string {
|
||||
return policy.accept
|
||||
.flatMap((rule) => [rule.mediaType, ...rule.extensions])
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function systemPickerOptions(
|
||||
policy: RegisteredFileSelectionPolicy,
|
||||
): SystemOpenPickerOptions {
|
||||
const types = policy.accept.map((rule) =>
|
||||
Object.freeze({
|
||||
accept: Object.freeze({
|
||||
[rule.mediaType]: Object.freeze([...rule.extensions]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return Object.freeze({
|
||||
multiple: policy.multiple,
|
||||
excludeAcceptAllOption: types.length > 0,
|
||||
types: Object.freeze(types),
|
||||
});
|
||||
}
|
||||
|
||||
type SelectionRequestSnapshot = Readonly<{
|
||||
policy: FilePolicyReference;
|
||||
limits?: FileSelectionLimitReduction;
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
function snapshotSelectionRequest(input: {
|
||||
policy: FilePolicyReference;
|
||||
limits?: FileSelectionLimitReduction;
|
||||
signal?: AbortSignal;
|
||||
}): SelectionRequestSnapshot {
|
||||
const policy = input.policy;
|
||||
const limits = input.limits;
|
||||
const signal = input.signal;
|
||||
return Object.freeze({
|
||||
policy,
|
||||
...(limits
|
||||
? {
|
||||
limits: Object.freeze({
|
||||
...(limits.maxCount !== undefined
|
||||
? { maxCount: limits.maxCount }
|
||||
: {}),
|
||||
...(limits.maxFileBytes !== undefined
|
||||
? { maxFileBytes: limits.maxFileBytes }
|
||||
: {}),
|
||||
...(limits.maxTotalBytes !== undefined
|
||||
? { maxTotalBytes: limits.maxTotalBytes }
|
||||
: {}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function isUsableFileInput(
|
||||
input: HTMLInputElement,
|
||||
getAttribute: (name: string) => string | null,
|
||||
): boolean {
|
||||
if (input.type !== "file" || !input.isConnected) return false;
|
||||
if ((input.labels?.length ?? 0) > 0) return true;
|
||||
return (
|
||||
(getAttribute("aria-label")?.trim().length ?? 0) > 0 ||
|
||||
(getAttribute("aria-labelledby")?.trim().length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
class PickerDisposedError extends Error {
|
||||
constructor() {
|
||||
super("Picker runtime disposed");
|
||||
this.name = "PickerDisposedError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
import type {
|
||||
DownloadStrategy,
|
||||
FilePolicyReference,
|
||||
FileSelectionLimitReduction,
|
||||
} from "../../application/ports/browser-file-storage/file.ts";
|
||||
import type {
|
||||
BrowserDataOperation,
|
||||
BrowserDataResult,
|
||||
} from "../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../browser-file-storage/result.ts";
|
||||
import {
|
||||
assertFileInspectionPolicy,
|
||||
assertFileSelectionPolicy,
|
||||
sanitizeSuggestedFileName,
|
||||
type RegisteredFileInspectionPolicy,
|
||||
type RegisteredFileSelectionPolicy,
|
||||
} from "./file-policy.ts";
|
||||
|
||||
export type RegisteredPreviewPolicy = Readonly<{
|
||||
allowedMediaTypes: readonly string[];
|
||||
maxPreviewBytes: number;
|
||||
}>;
|
||||
|
||||
export type RegisteredDownloadPolicy = Readonly<{
|
||||
strategy: DownloadStrategy;
|
||||
mediaType: string;
|
||||
safeExtension: string;
|
||||
maxTransferBytes: number;
|
||||
maxBufferedBytes: number;
|
||||
integrity: "OPTIONAL" | "REQUIRED";
|
||||
}>;
|
||||
|
||||
export type BrowserFilePolicyProfile = Readonly<{
|
||||
reference: FilePolicyReference;
|
||||
selection?: RegisteredFileSelectionPolicy;
|
||||
inspection?: RegisteredFileInspectionPolicy;
|
||||
/**
|
||||
* Preview is deliberately bound to the inspection policy in this profile.
|
||||
* A verification receipt from another policy can never be replayed here.
|
||||
*/
|
||||
preview?: RegisteredPreviewPolicy;
|
||||
download?: RegisteredDownloadPolicy;
|
||||
}>;
|
||||
|
||||
export type BrowserFilePolicyRegistryOptions = Readonly<{
|
||||
profiles: readonly BrowserFilePolicyProfile[];
|
||||
hardLimits: Readonly<{
|
||||
maxInspectionBytes: number;
|
||||
maxRetainedFileBytes: number;
|
||||
maxPreviewBytes: number;
|
||||
maxObjectUrlBytes: number;
|
||||
maxTransferBytes: number;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ResolvedPreviewPolicy = Readonly<{
|
||||
verificationPolicyBindingId: string;
|
||||
allowedMediaTypes: ReadonlySet<string>;
|
||||
maxPreviewBytes: number;
|
||||
}>;
|
||||
|
||||
export type ResolvedInspectionPolicy =
|
||||
RegisteredFileInspectionPolicy &
|
||||
Readonly<{ receiptBindingId: string }>;
|
||||
|
||||
export type ResolvedDownloadPolicy = RegisteredDownloadPolicy;
|
||||
|
||||
type SnapshotProfile = Readonly<{
|
||||
receiptBindingId: string;
|
||||
reference: FilePolicyReference;
|
||||
selection?: RegisteredFileSelectionPolicy;
|
||||
inspection?: RegisteredFileInspectionPolicy;
|
||||
preview?: Readonly<{
|
||||
allowedMediaTypes: ReadonlySet<string>;
|
||||
maxPreviewBytes: number;
|
||||
}>;
|
||||
download?: RegisteredDownloadPolicy;
|
||||
}>;
|
||||
|
||||
const POLICY_TOKEN = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
|
||||
const MEDIA_TYPE =
|
||||
/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i;
|
||||
const ISSUED_POLICY_REFERENCES = new WeakSet<object>();
|
||||
|
||||
/**
|
||||
* Creates the only policy reference accepted by the browser-file runtime.
|
||||
* Product composition should inject the returned object into its narrow
|
||||
* feature facade instead of exposing the whole registry to presentation.
|
||||
*/
|
||||
export function browserFilePolicyReference(
|
||||
policyKey: string,
|
||||
intention: string,
|
||||
): FilePolicyReference {
|
||||
if (!POLICY_TOKEN.test(policyKey) || !POLICY_TOKEN.test(intention)) {
|
||||
throw new TypeError("Browser file policy reference is invalid.");
|
||||
}
|
||||
const reference = Object.freeze({
|
||||
policyKey:
|
||||
policyKey as FilePolicyReference["policyKey"],
|
||||
intention:
|
||||
intention as FilePolicyReference["intention"],
|
||||
});
|
||||
ISSUED_POLICY_REFERENCES.add(reference);
|
||||
return reference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable composition-time registry. It retains no caller-owned object,
|
||||
* array, Set or byte-pattern reference.
|
||||
*/
|
||||
export class BrowserFilePolicyRegistry {
|
||||
readonly #profiles:
|
||||
ReadonlyMap<FilePolicyReference, SnapshotProfile>;
|
||||
|
||||
constructor(options: BrowserFilePolicyRegistryOptions) {
|
||||
assertHardLimits(options.hardLimits);
|
||||
if (
|
||||
!Array.isArray(options.profiles) ||
|
||||
options.profiles.length < 1 ||
|
||||
options.profiles.length > 128
|
||||
) {
|
||||
throw new TypeError("Browser file policy registry is invalid.");
|
||||
}
|
||||
const profiles =
|
||||
new Map<FilePolicyReference, SnapshotProfile>();
|
||||
const semanticKeys = new Set<string>();
|
||||
for (const input of options.profiles) {
|
||||
const profile = snapshotProfile(input, options.hardLimits);
|
||||
const key = referenceKey(profile.reference);
|
||||
if (semanticKeys.has(key)) {
|
||||
throw new TypeError("Browser file policy reference is duplicated.");
|
||||
}
|
||||
semanticKeys.add(key);
|
||||
profiles.set(profile.reference, profile);
|
||||
}
|
||||
this.#profiles = profiles;
|
||||
}
|
||||
|
||||
resolveSelection(
|
||||
reference: FilePolicyReference,
|
||||
reduction?: FileSelectionLimitReduction,
|
||||
): BrowserDataResult<RegisteredFileSelectionPolicy> {
|
||||
const profile = this.#resolve(reference, "FILE_SELECT");
|
||||
if (!profile.ok) return profile;
|
||||
const policy = profile.value.selection;
|
||||
if (!policy) {
|
||||
return browserDataFailure("POLICY_REJECTED", "FILE_SELECT");
|
||||
}
|
||||
const maxCount = reducedLimit(
|
||||
reduction?.maxCount,
|
||||
policy.maxCount,
|
||||
"FILE_SELECT",
|
||||
);
|
||||
if (!maxCount.ok) return maxCount;
|
||||
const maxTotalBytes = reducedLimit(
|
||||
reduction?.maxTotalBytes,
|
||||
policy.maxTotalBytes,
|
||||
"FILE_SELECT",
|
||||
);
|
||||
if (!maxTotalBytes.ok) return maxTotalBytes;
|
||||
const maxFileBytes = reducedLimit(
|
||||
reduction?.maxFileBytes,
|
||||
Math.min(policy.maxFileBytes, maxTotalBytes.value),
|
||||
"FILE_SELECT",
|
||||
);
|
||||
if (!maxFileBytes.ok) return maxFileBytes;
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
...policy,
|
||||
maxCount: maxCount.value,
|
||||
maxFileBytes: maxFileBytes.value,
|
||||
maxTotalBytes: maxTotalBytes.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
resolveInspection(
|
||||
reference: FilePolicyReference,
|
||||
maxInspectionBytes?: number,
|
||||
): BrowserDataResult<ResolvedInspectionPolicy> {
|
||||
const profile = this.#resolve(reference, "FILE_INSPECT");
|
||||
if (!profile.ok) return profile;
|
||||
const policy = profile.value.inspection;
|
||||
if (!policy) {
|
||||
return browserDataFailure("POLICY_REJECTED", "FILE_INSPECT");
|
||||
}
|
||||
const reduced = reducedLimit(
|
||||
maxInspectionBytes,
|
||||
policy.maxInspectionBytes,
|
||||
"FILE_INSPECT",
|
||||
);
|
||||
if (!reduced.ok) return reduced;
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
...policy,
|
||||
maxInspectionBytes: reduced.value,
|
||||
receiptBindingId: profile.value.receiptBindingId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
resolvePreview(
|
||||
reference: FilePolicyReference,
|
||||
maxPreviewBytes?: number,
|
||||
): BrowserDataResult<ResolvedPreviewPolicy> {
|
||||
const profile = this.#resolve(reference, "PREVIEW");
|
||||
if (!profile.ok) return profile;
|
||||
if (!profile.value.preview || !profile.value.inspection) {
|
||||
return browserDataFailure("POLICY_REJECTED", "PREVIEW");
|
||||
}
|
||||
const reduced = reducedLimit(
|
||||
maxPreviewBytes,
|
||||
profile.value.preview.maxPreviewBytes,
|
||||
"PREVIEW",
|
||||
);
|
||||
if (!reduced.ok) return reduced;
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
verificationPolicyBindingId:
|
||||
profile.value.receiptBindingId,
|
||||
allowedMediaTypes:
|
||||
new Set(profile.value.preview.allowedMediaTypes),
|
||||
maxPreviewBytes: reduced.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
resolveDownload(
|
||||
reference: FilePolicyReference,
|
||||
reductions: Readonly<{
|
||||
maxTransferBytes?: number;
|
||||
maxBufferedBytes?: number;
|
||||
}>,
|
||||
): BrowserDataResult<ResolvedDownloadPolicy> {
|
||||
const profile = this.#resolve(reference, "DOWNLOAD");
|
||||
if (!profile.ok) return profile;
|
||||
const policy = profile.value.download;
|
||||
if (!policy) {
|
||||
return browserDataFailure("POLICY_REJECTED", "DOWNLOAD");
|
||||
}
|
||||
const maxTransferBytes = reducedLimit(
|
||||
reductions.maxTransferBytes,
|
||||
policy.maxTransferBytes,
|
||||
"DOWNLOAD",
|
||||
);
|
||||
if (!maxTransferBytes.ok) return maxTransferBytes;
|
||||
const maxBufferedBytes = reducedLimit(
|
||||
reductions.maxBufferedBytes,
|
||||
Math.min(policy.maxBufferedBytes, maxTransferBytes.value),
|
||||
"DOWNLOAD",
|
||||
);
|
||||
if (!maxBufferedBytes.ok) return maxBufferedBytes;
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
...policy,
|
||||
maxTransferBytes: maxTransferBytes.value,
|
||||
maxBufferedBytes: maxBufferedBytes.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#resolve(
|
||||
reference: FilePolicyReference,
|
||||
operation: BrowserDataOperation,
|
||||
): BrowserDataResult<SnapshotProfile> {
|
||||
try {
|
||||
if (
|
||||
typeof reference !== "object" ||
|
||||
reference === null ||
|
||||
!ISSUED_POLICY_REFERENCES.has(reference)
|
||||
) {
|
||||
return browserDataFailure("POLICY_REJECTED", operation);
|
||||
}
|
||||
const profile = this.#profiles.get(reference);
|
||||
return profile
|
||||
? browserDataSuccess(profile)
|
||||
: browserDataFailure("POLICY_REJECTED", operation);
|
||||
} catch {
|
||||
return browserDataFailure("POLICY_REJECTED", operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotProfile(
|
||||
input: BrowserFilePolicyProfile,
|
||||
hardLimits: BrowserFilePolicyRegistryOptions["hardLimits"],
|
||||
): SnapshotProfile {
|
||||
const reference = input.reference;
|
||||
if (
|
||||
typeof reference !== "object" ||
|
||||
reference === null ||
|
||||
!ISSUED_POLICY_REFERENCES.has(reference) ||
|
||||
!Object.isFrozen(reference)
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Browser file policy reference was not issued by composition.",
|
||||
);
|
||||
}
|
||||
referenceKey(reference);
|
||||
if (
|
||||
input.selection === undefined &&
|
||||
input.inspection === undefined &&
|
||||
input.preview === undefined &&
|
||||
input.download === undefined
|
||||
) {
|
||||
throw new TypeError("Browser file policy profile is empty.");
|
||||
}
|
||||
|
||||
const selection = input.selection
|
||||
? snapshotSelection(input.selection)
|
||||
: undefined;
|
||||
if (
|
||||
selection &&
|
||||
(selection.maxFileBytes > hardLimits.maxRetainedFileBytes ||
|
||||
selection.maxTotalBytes > hardLimits.maxRetainedFileBytes)
|
||||
) {
|
||||
throw new TypeError("File selection policy exceeds runtime limits.");
|
||||
}
|
||||
|
||||
const inspection = input.inspection
|
||||
? snapshotInspection(
|
||||
input.inspection,
|
||||
hardLimits.maxInspectionBytes,
|
||||
)
|
||||
: undefined;
|
||||
if (input.preview && !inspection) {
|
||||
throw new TypeError(
|
||||
"Preview policy requires an inspection policy in the same profile.",
|
||||
);
|
||||
}
|
||||
const preview = input.preview
|
||||
? snapshotPreview(input.preview, hardLimits.maxPreviewBytes)
|
||||
: undefined;
|
||||
const download = input.download
|
||||
? snapshotDownload(input.download, hardLimits)
|
||||
: undefined;
|
||||
|
||||
return Object.freeze({
|
||||
receiptBindingId: referenceKey(reference),
|
||||
reference,
|
||||
...(selection ? { selection } : {}),
|
||||
...(inspection ? { inspection } : {}),
|
||||
...(preview ? { preview } : {}),
|
||||
...(download ? { download } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotSelection(
|
||||
input: RegisteredFileSelectionPolicy,
|
||||
): RegisteredFileSelectionPolicy {
|
||||
const snapshot = Object.freeze({
|
||||
policyId: input.policyId,
|
||||
purpose: input.purpose,
|
||||
classification: input.classification,
|
||||
multiple: input.multiple,
|
||||
maxCount: input.maxCount,
|
||||
maxFileBytes: input.maxFileBytes,
|
||||
maxTotalBytes: input.maxTotalBytes,
|
||||
allowEmpty: input.allowEmpty,
|
||||
accept: Object.freeze(
|
||||
input.accept.map((rule) =>
|
||||
Object.freeze({
|
||||
mediaType: rule.mediaType.trim().toLowerCase(),
|
||||
extensions: Object.freeze(
|
||||
rule.extensions.map((extension) =>
|
||||
extension.trim().toLowerCase(),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
assertFileSelectionPolicy(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function snapshotInspection(
|
||||
input: RegisteredFileInspectionPolicy,
|
||||
hardMaxInspectionBytes: number,
|
||||
): RegisteredFileInspectionPolicy {
|
||||
const snapshot = Object.freeze({
|
||||
policyId: input.policyId,
|
||||
maxInspectionBytes: input.maxInspectionBytes,
|
||||
acceptedSignatures: Object.freeze(
|
||||
input.acceptedSignatures.map((rule) =>
|
||||
Object.freeze({
|
||||
mediaType: rule.mediaType.trim().toLowerCase(),
|
||||
extensions: Object.freeze(
|
||||
rule.extensions.map((extension) =>
|
||||
extension.trim().toLowerCase(),
|
||||
),
|
||||
),
|
||||
patterns: Object.freeze(
|
||||
rule.patterns.map((pattern) =>
|
||||
Object.freeze({
|
||||
offset: pattern.offset,
|
||||
bytes: Object.freeze([...pattern.bytes]),
|
||||
...(pattern.mask
|
||||
? { mask: Object.freeze([...pattern.mask]) }
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
assertFileInspectionPolicy(snapshot, hardMaxInspectionBytes);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function snapshotPreview(
|
||||
input: RegisteredPreviewPolicy,
|
||||
hardMaxPreviewBytes: number,
|
||||
): SnapshotProfile["preview"] {
|
||||
if (
|
||||
!positiveSafeInteger(input.maxPreviewBytes) ||
|
||||
input.maxPreviewBytes > hardMaxPreviewBytes ||
|
||||
!Array.isArray(input.allowedMediaTypes) ||
|
||||
input.allowedMediaTypes.length < 1 ||
|
||||
input.allowedMediaTypes.length > 64
|
||||
) {
|
||||
throw new TypeError("File preview policy is invalid.");
|
||||
}
|
||||
const allowedMediaTypes = new Set<string>();
|
||||
for (const inputMediaType of input.allowedMediaTypes) {
|
||||
const mediaType = inputMediaType.trim().toLowerCase();
|
||||
if (!MEDIA_TYPE.test(mediaType)) {
|
||||
throw new TypeError("File preview media type is invalid.");
|
||||
}
|
||||
allowedMediaTypes.add(mediaType);
|
||||
}
|
||||
return Object.freeze({
|
||||
allowedMediaTypes,
|
||||
maxPreviewBytes: input.maxPreviewBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotDownload(
|
||||
input: RegisteredDownloadPolicy,
|
||||
hardLimits: BrowserFilePolicyRegistryOptions["hardLimits"],
|
||||
): RegisteredDownloadPolicy {
|
||||
const mediaType = input.mediaType.trim().toLowerCase();
|
||||
const safeExtension = input.safeExtension.trim().toLowerCase();
|
||||
if (
|
||||
![
|
||||
"BROWSER_MANAGED",
|
||||
"PROMPT_AND_STREAM",
|
||||
"BOUNDED_OBJECT_URL",
|
||||
].includes(input.strategy) ||
|
||||
!MEDIA_TYPE.test(mediaType) ||
|
||||
!positiveSafeInteger(input.maxTransferBytes) ||
|
||||
!positiveSafeInteger(input.maxBufferedBytes) ||
|
||||
input.maxTransferBytes > hardLimits.maxTransferBytes ||
|
||||
input.maxBufferedBytes > input.maxTransferBytes ||
|
||||
(input.strategy === "BOUNDED_OBJECT_URL" &&
|
||||
input.maxBufferedBytes > hardLimits.maxObjectUrlBytes) ||
|
||||
!["OPTIONAL", "REQUIRED"].includes(input.integrity)
|
||||
) {
|
||||
throw new TypeError("File download policy is invalid.");
|
||||
}
|
||||
// Reuse the production filename extension validator.
|
||||
sanitizeSuggestedFileName("download", { safeExtension });
|
||||
return Object.freeze({
|
||||
strategy: input.strategy,
|
||||
mediaType,
|
||||
safeExtension,
|
||||
maxTransferBytes: input.maxTransferBytes,
|
||||
maxBufferedBytes: input.maxBufferedBytes,
|
||||
integrity: input.integrity,
|
||||
});
|
||||
}
|
||||
|
||||
function assertHardLimits(
|
||||
input: BrowserFilePolicyRegistryOptions["hardLimits"],
|
||||
): void {
|
||||
if (
|
||||
!positiveSafeInteger(input.maxInspectionBytes) ||
|
||||
!positiveSafeInteger(input.maxRetainedFileBytes) ||
|
||||
!positiveSafeInteger(input.maxPreviewBytes) ||
|
||||
!positiveSafeInteger(input.maxObjectUrlBytes) ||
|
||||
!positiveSafeInteger(input.maxTransferBytes) ||
|
||||
input.maxObjectUrlBytes > input.maxTransferBytes
|
||||
) {
|
||||
throw new TypeError("Browser file policy hard limits are invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
function reducedLimit(
|
||||
requested: number | undefined,
|
||||
configured: number,
|
||||
operation: BrowserDataOperation,
|
||||
): BrowserDataResult<number> {
|
||||
if (requested === undefined) {
|
||||
return browserDataSuccess(configured);
|
||||
}
|
||||
if (!positiveSafeInteger(requested)) {
|
||||
return browserDataFailure("INVALID_INPUT", operation);
|
||||
}
|
||||
if (requested > configured) {
|
||||
return browserDataFailure("LIMIT_EXCEEDED", operation);
|
||||
}
|
||||
return browserDataSuccess(requested);
|
||||
}
|
||||
|
||||
function referenceKey(reference: FilePolicyReference): string {
|
||||
if (
|
||||
!reference ||
|
||||
typeof reference !== "object" ||
|
||||
!POLICY_TOKEN.test(reference.policyKey) ||
|
||||
!POLICY_TOKEN.test(reference.intention)
|
||||
) {
|
||||
throw new TypeError("Browser file policy reference is invalid.");
|
||||
}
|
||||
return JSON.stringify([
|
||||
reference.policyKey,
|
||||
reference.intention,
|
||||
]);
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
import type {
|
||||
FileCandidate,
|
||||
FileByteSource,
|
||||
FileContentPort,
|
||||
FileInspection,
|
||||
FilePolicyReference,
|
||||
FileSelectionSource,
|
||||
FileVerificationReceipt,
|
||||
LocalFileRef,
|
||||
} from "../../application/ports/browser-file-storage/file.ts";
|
||||
import type {
|
||||
BrowserDataOperation,
|
||||
BrowserDataResult,
|
||||
} from "../../application/ports/browser-file-storage/shared.ts";
|
||||
import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
abortedResult,
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
mapBrowserDataException,
|
||||
} from "../browser-file-storage/result.ts";
|
||||
import {
|
||||
assertFileInspectionPolicy,
|
||||
assertFileSelectionPolicy,
|
||||
findMatchingSignature,
|
||||
matchesSelectionHint,
|
||||
normalizedExtension,
|
||||
signatureMetadataMatches,
|
||||
signatureWasExpected,
|
||||
type RegisteredFileSelectionPolicy,
|
||||
} from "./file-policy.ts";
|
||||
import { BrowserFilePolicyRegistry } from "./browser-file-policy-registry.ts";
|
||||
import {
|
||||
byteBucket,
|
||||
observeBrowserFile,
|
||||
type BrowserFileObserver,
|
||||
} from "./file-observer.ts";
|
||||
|
||||
export type SystemFileHandle = Readonly<{
|
||||
kind: "file";
|
||||
name: string;
|
||||
getFile(): Promise<File>;
|
||||
}>;
|
||||
|
||||
export interface NativeFileResolver {
|
||||
resolveFile(
|
||||
ref: LocalFileRef,
|
||||
signal: AbortSignal,
|
||||
operation?: BrowserDataOperation,
|
||||
): Promise<BrowserDataResult<File>>;
|
||||
}
|
||||
|
||||
export type VerifiedNativeFile = Readonly<{
|
||||
file: File;
|
||||
mediaType: string;
|
||||
}>;
|
||||
|
||||
export interface NativeVerifiedFileResolver {
|
||||
resolveVerifiedFile(input: {
|
||||
ref: LocalFileRef;
|
||||
verificationReceipt: FileVerificationReceipt;
|
||||
verificationPolicyBindingId: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<BrowserDataResult<VerifiedNativeFile>>;
|
||||
}
|
||||
|
||||
type VaultRecord = Readonly<{
|
||||
displayName: string;
|
||||
expectedSize: number;
|
||||
expectedLastModified: number;
|
||||
load(): Promise<File>;
|
||||
}>;
|
||||
|
||||
type VerificationRecord = Readonly<{
|
||||
ref: LocalFileRef;
|
||||
policyBindingId: string;
|
||||
mediaType: string;
|
||||
file: File;
|
||||
}>;
|
||||
|
||||
export type BrowserFileVaultOptions = Readonly<{
|
||||
policies: BrowserFilePolicyRegistry;
|
||||
createReference?: () => string;
|
||||
createVerificationReceipt?: () => string;
|
||||
hardMaxInspectionBytes?: number;
|
||||
hardMaxRangeBytes?: number;
|
||||
hardMaxActiveReferences?: number;
|
||||
hardMaxRetainedBytes?: number;
|
||||
observer?: BrowserFileObserver;
|
||||
}>;
|
||||
|
||||
export const DEFAULT_MAX_INSPECTION_BYTES = 64 * 1024;
|
||||
const DEFAULT_MAX_RANGE_BYTES = 16 * 1024 * 1024;
|
||||
const DEFAULT_MAX_ACTIVE_REFERENCES = 32;
|
||||
const DEFAULT_MAX_RETAINED_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Transient native-file vault. Opaque references are session-only and are
|
||||
* never derived from a file name or local path.
|
||||
*/
|
||||
export class BrowserFileVault
|
||||
implements FileContentPort, NativeFileResolver, NativeVerifiedFileResolver
|
||||
{
|
||||
readonly #records = new Map<LocalFileRef, VaultRecord>();
|
||||
readonly #verifications = new Map<
|
||||
FileVerificationReceipt,
|
||||
VerificationRecord
|
||||
>();
|
||||
readonly #verificationReceiptsByRef = new Map<
|
||||
LocalFileRef,
|
||||
Set<FileVerificationReceipt>
|
||||
>();
|
||||
readonly #createReference: () => string;
|
||||
readonly #createVerificationReceipt: () => string;
|
||||
readonly #hardMaxInspectionBytes: number;
|
||||
readonly #hardMaxRangeBytes: number;
|
||||
readonly #hardMaxActiveReferences: number;
|
||||
readonly #hardMaxRetainedBytes: number;
|
||||
readonly #observer: BrowserFileObserver | undefined;
|
||||
readonly #resolveInspection:
|
||||
BrowserFilePolicyRegistry["resolveInspection"];
|
||||
readonly #lifetime = new AbortController();
|
||||
#disposed = false;
|
||||
#retainedBytes = 0;
|
||||
|
||||
constructor(options: BrowserFileVaultOptions) {
|
||||
this.#resolveInspection =
|
||||
options.policies.resolveInspection.bind(options.policies);
|
||||
this.#createReference =
|
||||
options.createReference ??
|
||||
(() => `file:${globalThis.crypto.randomUUID()}`);
|
||||
this.#createVerificationReceipt =
|
||||
options.createVerificationReceipt ??
|
||||
(() => `verification:${globalThis.crypto.randomUUID()}`);
|
||||
this.#hardMaxInspectionBytes =
|
||||
options.hardMaxInspectionBytes ?? DEFAULT_MAX_INSPECTION_BYTES;
|
||||
this.#hardMaxRangeBytes =
|
||||
options.hardMaxRangeBytes ?? DEFAULT_MAX_RANGE_BYTES;
|
||||
this.#hardMaxActiveReferences =
|
||||
options.hardMaxActiveReferences ??
|
||||
DEFAULT_MAX_ACTIVE_REFERENCES;
|
||||
this.#hardMaxRetainedBytes =
|
||||
options.hardMaxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES;
|
||||
this.#observer = options.observer;
|
||||
if (
|
||||
!isPositiveSafeInteger(this.#hardMaxInspectionBytes) ||
|
||||
!isPositiveSafeInteger(this.#hardMaxRangeBytes) ||
|
||||
!isPositiveSafeInteger(this.#hardMaxActiveReferences) ||
|
||||
!isPositiveSafeInteger(this.#hardMaxRetainedBytes)
|
||||
) {
|
||||
throw new TypeError("Browser file vault byte limits are invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
captureFiles(
|
||||
files: Iterable<File>,
|
||||
policy: RegisteredFileSelectionPolicy,
|
||||
source: FileSelectionSource = "NATIVE_INPUT",
|
||||
): BrowserDataResult<readonly FileCandidate[]> {
|
||||
if (this.#disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", "FILE_SELECT");
|
||||
}
|
||||
const nativeFiles = Array.from(files);
|
||||
const validated = this.#validateSelection(nativeFiles, policy, source);
|
||||
if (!validated.ok) return validated;
|
||||
|
||||
const pending: Array<Readonly<{
|
||||
candidate: FileCandidate;
|
||||
record: VaultRecord;
|
||||
}>> = [];
|
||||
for (const [index, file] of nativeFiles.entries()) {
|
||||
const candidate = validated.value[index];
|
||||
if (!candidate) {
|
||||
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
|
||||
}
|
||||
pending.push({
|
||||
candidate,
|
||||
record: Object.freeze({
|
||||
displayName: file.name,
|
||||
expectedSize: file.size,
|
||||
expectedLastModified: file.lastModified,
|
||||
load: async () => file,
|
||||
}),
|
||||
});
|
||||
}
|
||||
for (const item of pending) {
|
||||
this.#retainRecord(item.candidate.ref, item.record);
|
||||
}
|
||||
return browserDataSuccess(
|
||||
Object.freeze(pending.map((item) => item.candidate)),
|
||||
);
|
||||
}
|
||||
|
||||
async captureHandles(
|
||||
handles: readonly SystemFileHandle[],
|
||||
policy: RegisteredFileSelectionPolicy,
|
||||
signal: AbortSignal,
|
||||
): Promise<BrowserDataResult<readonly FileCandidate[]>> {
|
||||
if (this.#disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", "FILE_SELECT");
|
||||
}
|
||||
const cancelled = abortedResult(signal, "FILE_SELECT");
|
||||
if (cancelled) return cancelled;
|
||||
try {
|
||||
assertFileSelectionPolicy(policy);
|
||||
} catch {
|
||||
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
|
||||
}
|
||||
let handleSnapshots: readonly SystemFileHandle[];
|
||||
try {
|
||||
handleSnapshots = snapshotSystemFileHandles(handles);
|
||||
} catch {
|
||||
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
|
||||
}
|
||||
if (
|
||||
handleSnapshots.length === 0 ||
|
||||
handleSnapshots.length > policy.maxCount ||
|
||||
(!policy.multiple && handleSnapshots.length > 1) ||
|
||||
this.#records.size + handleSnapshots.length >
|
||||
this.#hardMaxActiveReferences
|
||||
) {
|
||||
return browserDataFailure(
|
||||
handleSnapshots.length === 0
|
||||
? "INVALID_INPUT"
|
||||
: "LIMIT_EXCEEDED",
|
||||
"FILE_SELECT",
|
||||
);
|
||||
}
|
||||
try {
|
||||
const files: File[] = [];
|
||||
for (const handle of handleSnapshots) {
|
||||
if (handle.kind !== "file") {
|
||||
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
|
||||
}
|
||||
const file = await handle.getFile();
|
||||
if (file.name !== handle.name) {
|
||||
return browserDataFailure("STALE_RESULT", "FILE_SELECT", {
|
||||
recovery: "RESELECT",
|
||||
});
|
||||
}
|
||||
files.push(file);
|
||||
if (this.#disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", "FILE_SELECT");
|
||||
}
|
||||
const aborted = abortedResult(signal, "FILE_SELECT");
|
||||
if (aborted) return aborted;
|
||||
}
|
||||
const validated = this.#validateSelection(
|
||||
files,
|
||||
policy,
|
||||
"SYSTEM_PICKER",
|
||||
);
|
||||
if (!validated.ok) return validated;
|
||||
|
||||
for (const [index, handle] of handleSnapshots.entries()) {
|
||||
const candidate = validated.value[index];
|
||||
const file = files[index];
|
||||
if (!candidate || !file) {
|
||||
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
|
||||
}
|
||||
this.#retainRecord(
|
||||
candidate.ref,
|
||||
Object.freeze({
|
||||
displayName: file.name,
|
||||
expectedSize: file.size,
|
||||
expectedLastModified: file.lastModified,
|
||||
load: () => handle.getFile(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return validated;
|
||||
} catch (error) {
|
||||
return mapBrowserDataException(error, "FILE_SELECT");
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(input: {
|
||||
ref: LocalFileRef;
|
||||
policy: FilePolicyReference;
|
||||
maxInspectionBytes?: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<BrowserDataResult<FileInspection>> {
|
||||
if (this.#disposed) {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("UNAVAILABLE", "FILE_INSPECT"),
|
||||
);
|
||||
}
|
||||
let request: Readonly<{
|
||||
ref: LocalFileRef;
|
||||
policy: FilePolicyReference;
|
||||
maxInspectionBytes?: number;
|
||||
signal: AbortSignal;
|
||||
}>;
|
||||
try {
|
||||
const maxInspectionBytes = input.maxInspectionBytes;
|
||||
request = Object.freeze({
|
||||
ref: input.ref,
|
||||
policy: input.policy,
|
||||
...(maxInspectionBytes !== undefined
|
||||
? { maxInspectionBytes }
|
||||
: {}),
|
||||
signal: input.signal,
|
||||
});
|
||||
} catch {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("INVALID_INPUT", "FILE_INSPECT"),
|
||||
);
|
||||
}
|
||||
const resolvedPolicy = this.#resolveInspection(
|
||||
request.policy,
|
||||
request.maxInspectionBytes,
|
||||
);
|
||||
if (!resolvedPolicy.ok) {
|
||||
return this.#observeFailureResult(resolvedPolicy);
|
||||
}
|
||||
const policy = resolvedPolicy.value;
|
||||
try {
|
||||
assertFileInspectionPolicy(
|
||||
policy,
|
||||
this.#hardMaxInspectionBytes,
|
||||
);
|
||||
} catch {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("POLICY_REJECTED", "FILE_INSPECT"),
|
||||
);
|
||||
}
|
||||
this.#invalidateVerifications(request.ref);
|
||||
const resolved = await this.resolveFile(
|
||||
request.ref,
|
||||
request.signal,
|
||||
"FILE_INSPECT",
|
||||
);
|
||||
if (!resolved.ok) return this.#observeFailureResult(resolved);
|
||||
const file = resolved.value;
|
||||
try {
|
||||
const headerLength = Math.min(
|
||||
file.size,
|
||||
policy.maxInspectionBytes,
|
||||
);
|
||||
const header = new Uint8Array(
|
||||
await file.slice(0, headerLength).arrayBuffer(),
|
||||
);
|
||||
if (this.#disposed) {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("UNAVAILABLE", "FILE_INSPECT"),
|
||||
);
|
||||
}
|
||||
const cancelled = abortedResult(
|
||||
request.signal,
|
||||
"FILE_INSPECT",
|
||||
);
|
||||
if (cancelled) return this.#observeFailureResult(cancelled);
|
||||
const matched = findMatchingSignature(
|
||||
header,
|
||||
policy.acceptedSignatures,
|
||||
);
|
||||
const expected = signatureWasExpected(
|
||||
file.name,
|
||||
normalizedMediaType(file.type),
|
||||
policy.acceptedSignatures,
|
||||
);
|
||||
const signature = matched
|
||||
? signatureMetadataMatches(
|
||||
file.name,
|
||||
normalizedMediaType(file.type),
|
||||
matched,
|
||||
)
|
||||
? ("MATCHED" as const)
|
||||
: ("MISMATCHED" as const)
|
||||
: expected
|
||||
? ("MISMATCHED" as const)
|
||||
: ("UNKNOWN" as const);
|
||||
let verificationReceipt: FileVerificationReceipt | null = null;
|
||||
if (matched && signature === "MATCHED") {
|
||||
const issued = this.#issueVerification({
|
||||
ref: request.ref,
|
||||
policyBindingId: policy.receiptBindingId,
|
||||
mediaType: matched.mediaType,
|
||||
file,
|
||||
});
|
||||
if (!issued.ok) {
|
||||
this.#observeFailure("FILE_INSPECT", issued);
|
||||
return issued;
|
||||
}
|
||||
verificationReceipt = issued.value;
|
||||
}
|
||||
const inspection = Object.freeze({
|
||||
byteLength: file.size,
|
||||
reportedMediaType: normalizedMediaType(file.type),
|
||||
detectedMediaType: matched?.mediaType ?? null,
|
||||
normalizedExtension: normalizedExtension(file.name),
|
||||
signature,
|
||||
verificationReceipt,
|
||||
});
|
||||
this.#observeSuccess("FILE_INSPECT", file.size);
|
||||
return browserDataSuccess(inspection);
|
||||
} catch (error) {
|
||||
const failure = mapBrowserDataException(error, "FILE_INSPECT");
|
||||
this.#observeFailure("FILE_INSPECT", failure);
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
||||
async readRange(input: {
|
||||
ref: LocalFileRef;
|
||||
offset: number;
|
||||
length: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<BrowserDataResult<Uint8Array>> {
|
||||
if (this.#disposed) {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("UNAVAILABLE", "FILE_READ"),
|
||||
);
|
||||
}
|
||||
let ref: LocalFileRef;
|
||||
let offset: number;
|
||||
let length: number;
|
||||
let signal: AbortSignal;
|
||||
try {
|
||||
ref = input.ref;
|
||||
offset = input.offset;
|
||||
length = input.length;
|
||||
signal = input.signal;
|
||||
} catch {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("INVALID_INPUT", "FILE_READ"),
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isValidByteLength(offset) ||
|
||||
!isValidByteLength(length) ||
|
||||
length > this.#hardMaxRangeBytes ||
|
||||
!Number.isSafeInteger(offset + length)
|
||||
) {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("LIMIT_EXCEEDED", "FILE_READ"),
|
||||
);
|
||||
}
|
||||
const resolved = await this.resolveFile(
|
||||
ref,
|
||||
signal,
|
||||
"FILE_READ",
|
||||
);
|
||||
if (!resolved.ok) return this.#observeFailureResult(resolved);
|
||||
const file = resolved.value;
|
||||
if (offset + length > file.size) {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("INVALID_INPUT", "FILE_READ"),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const bytes = new Uint8Array(
|
||||
await file
|
||||
.slice(offset, offset + length)
|
||||
.arrayBuffer(),
|
||||
);
|
||||
if (this.#disposed) {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("UNAVAILABLE", "FILE_READ"),
|
||||
);
|
||||
}
|
||||
const cancelled = abortedResult(signal, "FILE_READ");
|
||||
if (cancelled) return this.#observeFailureResult(cancelled);
|
||||
this.#observeSuccess("FILE_READ", bytes.byteLength);
|
||||
return browserDataSuccess(bytes);
|
||||
} catch (error) {
|
||||
const failure = mapBrowserDataException(error, "FILE_READ");
|
||||
this.#observeFailure("FILE_READ", failure);
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
||||
async openSource(input: {
|
||||
ref: LocalFileRef;
|
||||
signal: AbortSignal;
|
||||
}): Promise<BrowserDataResult<FileByteSource>> {
|
||||
if (this.#disposed) {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("UNAVAILABLE", "FILE_READ"),
|
||||
);
|
||||
}
|
||||
let ref: LocalFileRef;
|
||||
let requestSignal: AbortSignal;
|
||||
try {
|
||||
ref = input.ref;
|
||||
requestSignal = input.signal;
|
||||
} catch {
|
||||
return this.#observeFailureResult(
|
||||
browserDataFailure("INVALID_INPUT", "FILE_READ"),
|
||||
);
|
||||
}
|
||||
const resolved = await this.resolveFile(
|
||||
ref,
|
||||
requestSignal,
|
||||
"FILE_READ",
|
||||
);
|
||||
if (!resolved.ok) return this.#observeFailureResult(resolved);
|
||||
const file = resolved.value;
|
||||
const expectedLength = file.size;
|
||||
const vault = this;
|
||||
const source: FileByteSource = Object.freeze({
|
||||
byteLength: expectedLength,
|
||||
async *stream(
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<BrowserDataResult<Uint8Array>> {
|
||||
let transferred = 0;
|
||||
const combined = combineAbortSignals(
|
||||
signal,
|
||||
vault.#lifetime.signal,
|
||||
);
|
||||
try {
|
||||
for await (const chunk of streamNativeFile(
|
||||
file,
|
||||
combined.signal,
|
||||
)) {
|
||||
transferred += chunk.byteLength;
|
||||
yield browserDataSuccess(chunk);
|
||||
}
|
||||
if (vault.#disposed) {
|
||||
const unavailable = browserDataFailure(
|
||||
"UNAVAILABLE",
|
||||
"FILE_READ",
|
||||
);
|
||||
vault.#observeFailureResult(unavailable);
|
||||
yield unavailable;
|
||||
return;
|
||||
}
|
||||
vault.#observeSuccess("FILE_READ", transferred);
|
||||
} catch (error) {
|
||||
const failure = vault.#disposed
|
||||
? browserDataFailure("UNAVAILABLE", "FILE_READ")
|
||||
: mapBrowserDataException(error, "FILE_READ");
|
||||
vault.#observeFailureResult(failure);
|
||||
yield failure;
|
||||
} finally {
|
||||
combined.release();
|
||||
}
|
||||
},
|
||||
});
|
||||
return browserDataSuccess(source);
|
||||
}
|
||||
|
||||
async resolveFile(
|
||||
ref: LocalFileRef,
|
||||
signal: AbortSignal,
|
||||
operation: BrowserDataOperation = "FILE_READ",
|
||||
): Promise<BrowserDataResult<File>> {
|
||||
if (this.#disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", operation);
|
||||
}
|
||||
const cancelled = abortedResult(signal, operation);
|
||||
if (cancelled) return cancelled;
|
||||
const record = this.#records.get(ref);
|
||||
if (!record) {
|
||||
return browserDataFailure("NOT_FOUND", operation, {
|
||||
recovery: "RESELECT",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const file = await record.load();
|
||||
if (this.#disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", operation);
|
||||
}
|
||||
const aborted = abortedResult(signal, operation);
|
||||
if (aborted) return aborted;
|
||||
if (
|
||||
file.name !== record.displayName ||
|
||||
file.size !== record.expectedSize ||
|
||||
file.lastModified !== record.expectedLastModified
|
||||
) {
|
||||
return browserDataFailure("STALE_RESULT", operation, {
|
||||
recovery: "RESELECT",
|
||||
});
|
||||
}
|
||||
return browserDataSuccess(file);
|
||||
} catch (error) {
|
||||
return mapBrowserDataException(error, operation);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveVerifiedFile(input: {
|
||||
ref: LocalFileRef;
|
||||
verificationReceipt: FileVerificationReceipt;
|
||||
verificationPolicyBindingId: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<BrowserDataResult<VerifiedNativeFile>> {
|
||||
if (this.#disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", "PREVIEW");
|
||||
}
|
||||
const cancelled = abortedResult(input.signal, "PREVIEW");
|
||||
if (cancelled) return cancelled;
|
||||
const verification = this.#verifications.get(
|
||||
input.verificationReceipt,
|
||||
);
|
||||
if (
|
||||
!verification ||
|
||||
verification.ref !== input.ref ||
|
||||
verification.policyBindingId !==
|
||||
input.verificationPolicyBindingId ||
|
||||
!this.#records.has(input.ref)
|
||||
) {
|
||||
return browserDataFailure("POLICY_REJECTED", "PREVIEW");
|
||||
}
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
file: verification.file,
|
||||
mediaType: verification.mediaType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
release(ref: LocalFileRef): void {
|
||||
this.#invalidateVerifications(ref);
|
||||
const record = this.#records.get(ref);
|
||||
if (record && this.#records.delete(ref)) {
|
||||
this.#retainedBytes -= record.expectedSize;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#lifetime.abort();
|
||||
this.#verifications.clear();
|
||||
this.#verificationReceiptsByRef.clear();
|
||||
this.#records.clear();
|
||||
this.#retainedBytes = 0;
|
||||
}
|
||||
|
||||
get activeReferenceCount(): number {
|
||||
return this.#records.size;
|
||||
}
|
||||
|
||||
get activeVerificationCount(): number {
|
||||
return this.#verifications.size;
|
||||
}
|
||||
|
||||
get retainedByteLength(): number {
|
||||
return this.#retainedBytes;
|
||||
}
|
||||
|
||||
#validateSelection(
|
||||
files: readonly File[],
|
||||
policy: RegisteredFileSelectionPolicy,
|
||||
source: FileSelectionSource,
|
||||
): BrowserDataResult<readonly FileCandidate[]> {
|
||||
try {
|
||||
assertFileSelectionPolicy(policy);
|
||||
} catch {
|
||||
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
|
||||
}
|
||||
if (
|
||||
files.length === 0 ||
|
||||
files.length > policy.maxCount ||
|
||||
(!policy.multiple && files.length > 1) ||
|
||||
this.#records.size + files.length > this.#hardMaxActiveReferences
|
||||
) {
|
||||
return browserDataFailure(
|
||||
files.length === 0 ? "INVALID_INPUT" : "LIMIT_EXCEEDED",
|
||||
"FILE_SELECT",
|
||||
);
|
||||
}
|
||||
if (!["NATIVE_INPUT", "SYSTEM_PICKER", "DROP"].includes(source)) {
|
||||
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
|
||||
}
|
||||
|
||||
const refs = new Set<LocalFileRef>();
|
||||
const candidates: FileCandidate[] = [];
|
||||
let totalBytes = 0;
|
||||
for (const file of files) {
|
||||
if (
|
||||
!isValidByteLength(file.size) ||
|
||||
file.size > policy.maxFileBytes ||
|
||||
(!policy.allowEmpty && file.size === 0) ||
|
||||
!Number.isSafeInteger(totalBytes + file.size)
|
||||
) {
|
||||
return browserDataFailure("LIMIT_EXCEEDED", "FILE_SELECT");
|
||||
}
|
||||
totalBytes += file.size;
|
||||
if (totalBytes > policy.maxTotalBytes) {
|
||||
return browserDataFailure("LIMIT_EXCEEDED", "FILE_SELECT");
|
||||
}
|
||||
const refValue = this.#createReference();
|
||||
if (!safeOpaqueValue(refValue)) {
|
||||
return browserDataFailure("UNAVAILABLE", "FILE_SELECT");
|
||||
}
|
||||
const ref = refValue as LocalFileRef;
|
||||
if (refs.has(ref) || this.#records.has(ref)) {
|
||||
return browserDataFailure("CONFLICT", "FILE_SELECT");
|
||||
}
|
||||
refs.add(ref);
|
||||
const candidate: FileCandidate = Object.freeze({
|
||||
ref,
|
||||
displayName: file.name,
|
||||
sizeBytes: file.size,
|
||||
reportedMediaType: normalizedMediaType(file.type),
|
||||
lastModifiedEpochMs: isValidByteLength(file.lastModified)
|
||||
? file.lastModified
|
||||
: null,
|
||||
source,
|
||||
});
|
||||
if (!matchesSelectionHint(candidate, policy.accept)) {
|
||||
return browserDataFailure("POLICY_REJECTED", "FILE_SELECT");
|
||||
}
|
||||
candidates.push(candidate);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(this.#retainedBytes + totalBytes) ||
|
||||
this.#retainedBytes + totalBytes > this.#hardMaxRetainedBytes
|
||||
) {
|
||||
return browserDataFailure("LIMIT_EXCEEDED", "FILE_SELECT");
|
||||
}
|
||||
return browserDataSuccess(Object.freeze(candidates));
|
||||
}
|
||||
|
||||
#issueVerification(record: VerificationRecord):
|
||||
BrowserDataResult<FileVerificationReceipt> {
|
||||
const receiptValue = this.#createVerificationReceipt();
|
||||
if (!safeOpaqueValue(receiptValue)) {
|
||||
return browserDataFailure("UNAVAILABLE", "FILE_INSPECT");
|
||||
}
|
||||
const receipt = receiptValue as FileVerificationReceipt;
|
||||
if (this.#verifications.has(receipt)) {
|
||||
return browserDataFailure("CONFLICT", "FILE_INSPECT");
|
||||
}
|
||||
this.#verifications.set(receipt, Object.freeze(record));
|
||||
const receipts =
|
||||
this.#verificationReceiptsByRef.get(record.ref) ??
|
||||
new Set<FileVerificationReceipt>();
|
||||
receipts.add(receipt);
|
||||
this.#verificationReceiptsByRef.set(record.ref, receipts);
|
||||
return browserDataSuccess(receipt);
|
||||
}
|
||||
|
||||
#invalidateVerifications(ref: LocalFileRef): void {
|
||||
const receipts = this.#verificationReceiptsByRef.get(ref);
|
||||
if (!receipts) return;
|
||||
for (const receipt of receipts) {
|
||||
this.#verifications.delete(receipt);
|
||||
}
|
||||
this.#verificationReceiptsByRef.delete(ref);
|
||||
}
|
||||
|
||||
#retainRecord(ref: LocalFileRef, record: VaultRecord): void {
|
||||
this.#records.set(ref, record);
|
||||
this.#retainedBytes += record.expectedSize;
|
||||
}
|
||||
|
||||
#observeSuccess(operation: BrowserDataOperation, bytes: number): void {
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation,
|
||||
outcome: "SUCCESS",
|
||||
byteBucket: byteBucket(bytes),
|
||||
});
|
||||
}
|
||||
|
||||
#observeFailure(
|
||||
operation: BrowserDataOperation,
|
||||
failure: BrowserDataResult<never>,
|
||||
): void {
|
||||
if (failure.ok) return;
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation,
|
||||
outcome: "FAILED",
|
||||
failureCode: failure.error.code,
|
||||
});
|
||||
}
|
||||
|
||||
#observeFailureResult<Value>(
|
||||
failure: BrowserDataResult<Value>,
|
||||
): BrowserDataResult<Value> {
|
||||
if (!failure.ok) {
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation: failure.error.operation,
|
||||
outcome: "FAILED",
|
||||
failureCode: failure.error.code,
|
||||
});
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotSystemFileHandles(
|
||||
handles: readonly SystemFileHandle[],
|
||||
): readonly SystemFileHandle[] {
|
||||
if (!Array.isArray(handles)) {
|
||||
throw new TypeError("System file handles are invalid.");
|
||||
}
|
||||
return Object.freeze(
|
||||
handles.map((handle) => {
|
||||
const kind = handle.kind;
|
||||
const name = handle.name;
|
||||
const getFile = handle.getFile;
|
||||
if (
|
||||
kind !== "file" ||
|
||||
typeof name !== "string" ||
|
||||
name.length === 0 ||
|
||||
typeof getFile !== "function"
|
||||
) {
|
||||
throw new TypeError("System file handle is invalid.");
|
||||
}
|
||||
return Object.freeze({
|
||||
kind,
|
||||
name,
|
||||
getFile: getFile.bind(handle),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function* streamNativeFile(
|
||||
file: File,
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<Uint8Array> {
|
||||
if (signal.aborted) throw abortException();
|
||||
const reader = file.stream().getReader();
|
||||
const abort = () => {
|
||||
void reader.cancel(abortException()).catch(() => {});
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
let transferred = 0;
|
||||
let completed = false;
|
||||
try {
|
||||
while (true) {
|
||||
if (signal.aborted) throw abortException();
|
||||
const result = await reader.read();
|
||||
if (signal.aborted) throw abortException();
|
||||
if (result.done) break;
|
||||
const chunk = result.value;
|
||||
if (!(chunk instanceof Uint8Array)) {
|
||||
throw new DOMException("Unexpected file chunk", "NotReadableError");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(transferred + chunk.byteLength) ||
|
||||
transferred + chunk.byteLength > file.size
|
||||
) {
|
||||
throw new DOMException("File size changed", "NotReadableError");
|
||||
}
|
||||
transferred += chunk.byteLength;
|
||||
if (chunk.byteLength > 0) yield chunk;
|
||||
}
|
||||
if (transferred !== file.size) {
|
||||
throw new DOMException("File read was incomplete", "NotReadableError");
|
||||
}
|
||||
completed = true;
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
if (!completed) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// The stream may already be errored or cancelled by AbortSignal.
|
||||
}
|
||||
}
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedMediaType(value: string): string | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function isPositiveSafeInteger(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
function safeOpaqueValue(value: string): boolean {
|
||||
return /^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value);
|
||||
}
|
||||
|
||||
function combineAbortSignals(
|
||||
caller: AbortSignal,
|
||||
lifetime: AbortSignal,
|
||||
): Readonly<{ signal: AbortSignal; release(): void }> {
|
||||
const controller = new AbortController();
|
||||
const abort = (): void => controller.abort();
|
||||
if (caller.aborted || lifetime.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
caller.addEventListener("abort", abort, { once: true });
|
||||
lifetime.addEventListener("abort", abort, { once: true });
|
||||
}
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
release(): void {
|
||||
caller.removeEventListener("abort", abort);
|
||||
lifetime.removeEventListener("abort", abort);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function abortException(): DOMException {
|
||||
return new DOMException("Operation aborted", "AbortError");
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import type {
|
||||
DownloadDeliveryPort,
|
||||
FileContentPort,
|
||||
FilePickerPort,
|
||||
TransientPreviewPort,
|
||||
} from "../../application/ports/browser-file-storage/file.ts";
|
||||
import {
|
||||
EnhancedFilePicker,
|
||||
NativeInputFilePicker,
|
||||
type NativeInputFilePickerOptions,
|
||||
type SystemOpenPicker,
|
||||
} from "./browser-file-picker.ts";
|
||||
import {
|
||||
BrowserFileVault,
|
||||
DEFAULT_MAX_INSPECTION_BYTES,
|
||||
type BrowserFileVaultOptions,
|
||||
} from "./browser-file-vault.ts";
|
||||
import {
|
||||
BrowserFilePolicyRegistry,
|
||||
type BrowserFilePolicyProfile,
|
||||
} from "./browser-file-policy-registry.ts";
|
||||
import {
|
||||
createDownloadDeliveryAdapter,
|
||||
type DownloadDeliveryAdapterOptions,
|
||||
} from "./download-delivery-adapter.ts";
|
||||
import type { BrowserFileObserver } from "./file-observer.ts";
|
||||
import {
|
||||
BrowserTransientPreview,
|
||||
ObjectUrlLeaseRegistry,
|
||||
type ObjectUrlApi,
|
||||
} from "./object-url-lease.ts";
|
||||
|
||||
type UserActivationState = Readonly<{ isActive: boolean }>;
|
||||
|
||||
export type BrowserFileRuntimeOptions = Readonly<{
|
||||
input: HTMLInputElement;
|
||||
policies: readonly BrowserFilePolicyProfile[];
|
||||
limits: Readonly<{
|
||||
hardMaxPreviewBytes: number;
|
||||
hardMaxObjectUrlBytes: number;
|
||||
hardMaxTransferBytes: number;
|
||||
hardMaxActiveFileReferences?: number;
|
||||
hardMaxRetainedFileBytes?: number;
|
||||
hardMaxActiveObjectUrls?: number;
|
||||
hardMaxObjectUrlAggregateBytes?: number;
|
||||
}>;
|
||||
download: Omit<
|
||||
DownloadDeliveryAdapterOptions,
|
||||
| "objectUrls"
|
||||
| "observer"
|
||||
| "policies"
|
||||
| "userActivation"
|
||||
| "hardMaxObjectUrlBytes"
|
||||
| "hardMaxTransferBytes"
|
||||
>;
|
||||
showOpenFilePicker?: SystemOpenPicker;
|
||||
userActivation?: UserActivationState;
|
||||
observer?: BrowserFileObserver;
|
||||
objectUrlApi?: ObjectUrlApi;
|
||||
vault?: Omit<BrowserFileVaultOptions, "observer" | "policies">;
|
||||
nativePicker?: Pick<
|
||||
NativeInputFilePickerOptions,
|
||||
| "window"
|
||||
| "scheduler"
|
||||
| "focusFallbackGraceMs"
|
||||
| "cancelFallbackDelayMs"
|
||||
>;
|
||||
hardForbiddenPreviewMediaTypes?: ReadonlySet<string>;
|
||||
}>;
|
||||
|
||||
export type BrowserFileRuntime = Readonly<{
|
||||
/**
|
||||
* Canonical cross-browser control. Presentation decides which explicit
|
||||
* user action invokes this baseline.
|
||||
*/
|
||||
baselinePicker: FilePickerPort;
|
||||
/**
|
||||
* Optional enhancement. It is never retried through baselinePicker in the
|
||||
* same user activation.
|
||||
*/
|
||||
enhancedPicker: FilePickerPort | null;
|
||||
content: FileContentPort;
|
||||
previews: TransientPreviewPort;
|
||||
downloads: DownloadDeliveryPort;
|
||||
dispose(): void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Optional feature factory. Nothing imports this from bootstrap, so browser
|
||||
* file code remains outside the default bundle until a feature composes it.
|
||||
*/
|
||||
export function createBrowserFileRuntime(
|
||||
options: BrowserFileRuntimeOptions,
|
||||
): BrowserFileRuntime {
|
||||
const limits = resolveRuntimeLimits(options.limits);
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: options.policies,
|
||||
hardLimits: {
|
||||
maxInspectionBytes:
|
||||
options.vault?.hardMaxInspectionBytes ??
|
||||
DEFAULT_MAX_INSPECTION_BYTES,
|
||||
maxRetainedFileBytes: limits.hardMaxRetainedFileBytes,
|
||||
maxPreviewBytes: limits.hardMaxPreviewBytes,
|
||||
maxObjectUrlBytes: limits.hardMaxObjectUrlBytes,
|
||||
maxTransferBytes: limits.hardMaxTransferBytes,
|
||||
},
|
||||
});
|
||||
const objectUrls = new ObjectUrlLeaseRegistry(
|
||||
options.objectUrlApi,
|
||||
{
|
||||
hardMaxActiveLeases: limits.hardMaxActiveObjectUrls,
|
||||
hardMaxSingleLeaseBytes: Math.max(
|
||||
limits.hardMaxPreviewBytes,
|
||||
limits.hardMaxObjectUrlBytes,
|
||||
),
|
||||
hardMaxAggregateLeaseBytes:
|
||||
limits.hardMaxObjectUrlAggregateBytes,
|
||||
},
|
||||
);
|
||||
const vault = new BrowserFileVault({
|
||||
...options.vault,
|
||||
policies,
|
||||
hardMaxActiveReferences: limits.hardMaxActiveFileReferences,
|
||||
hardMaxRetainedBytes: limits.hardMaxRetainedFileBytes,
|
||||
observer: options.observer,
|
||||
});
|
||||
const commonPickerOptions = {
|
||||
vault,
|
||||
policies,
|
||||
userActivation: options.userActivation,
|
||||
observer: options.observer,
|
||||
};
|
||||
const baselinePicker = new NativeInputFilePicker({
|
||||
...commonPickerOptions,
|
||||
...options.nativePicker,
|
||||
input: options.input,
|
||||
systemOpenPickerSupported:
|
||||
options.showOpenFilePicker !== undefined,
|
||||
systemSavePickerSupported:
|
||||
options.download.showSaveFilePicker !== undefined,
|
||||
});
|
||||
const enhancedPicker = options.showOpenFilePicker
|
||||
? new EnhancedFilePicker({
|
||||
...commonPickerOptions,
|
||||
showOpenFilePicker: options.showOpenFilePicker,
|
||||
systemSavePickerSupported:
|
||||
options.download.showSaveFilePicker !== undefined,
|
||||
})
|
||||
: null;
|
||||
const previews = new BrowserTransientPreview({
|
||||
files: vault,
|
||||
policies,
|
||||
leases: objectUrls,
|
||||
hardMaxPreviewBytes: limits.hardMaxPreviewBytes,
|
||||
hardForbiddenMediaTypes:
|
||||
options.hardForbiddenPreviewMediaTypes,
|
||||
observer: options.observer,
|
||||
});
|
||||
const downloads = createDownloadDeliveryAdapter({
|
||||
...options.download,
|
||||
policies,
|
||||
objectUrls,
|
||||
hardMaxObjectUrlBytes: limits.hardMaxObjectUrlBytes,
|
||||
hardMaxTransferBytes: limits.hardMaxTransferBytes,
|
||||
observer: options.observer,
|
||||
userActivation: options.userActivation,
|
||||
});
|
||||
let disposed = false;
|
||||
|
||||
return Object.freeze({
|
||||
baselinePicker,
|
||||
enhancedPicker,
|
||||
content: vault,
|
||||
previews,
|
||||
downloads,
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
baselinePicker.dispose();
|
||||
enhancedPicker?.dispose();
|
||||
downloads.dispose();
|
||||
previews.dispose();
|
||||
vault.dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ResolvedRuntimeLimits = Readonly<{
|
||||
hardMaxPreviewBytes: number;
|
||||
hardMaxObjectUrlBytes: number;
|
||||
hardMaxTransferBytes: number;
|
||||
hardMaxActiveFileReferences: number;
|
||||
hardMaxRetainedFileBytes: number;
|
||||
hardMaxActiveObjectUrls: number;
|
||||
hardMaxObjectUrlAggregateBytes: number;
|
||||
}>;
|
||||
|
||||
function resolveRuntimeLimits(
|
||||
limits: BrowserFileRuntimeOptions["limits"],
|
||||
): ResolvedRuntimeLimits {
|
||||
const hardMaxSingleObjectUrlBytes = Math.max(
|
||||
limits.hardMaxPreviewBytes,
|
||||
limits.hardMaxObjectUrlBytes,
|
||||
);
|
||||
const derivedAggregate = Math.min(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
hardMaxSingleObjectUrlBytes * 4,
|
||||
);
|
||||
const resolved = Object.freeze({
|
||||
hardMaxPreviewBytes: limits.hardMaxPreviewBytes,
|
||||
hardMaxObjectUrlBytes: limits.hardMaxObjectUrlBytes,
|
||||
hardMaxTransferBytes: limits.hardMaxTransferBytes,
|
||||
hardMaxActiveFileReferences:
|
||||
limits.hardMaxActiveFileReferences ?? 32,
|
||||
hardMaxRetainedFileBytes:
|
||||
limits.hardMaxRetainedFileBytes ??
|
||||
limits.hardMaxTransferBytes,
|
||||
hardMaxActiveObjectUrls:
|
||||
limits.hardMaxActiveObjectUrls ?? 16,
|
||||
hardMaxObjectUrlAggregateBytes:
|
||||
limits.hardMaxObjectUrlAggregateBytes ?? derivedAggregate,
|
||||
});
|
||||
if (
|
||||
!isPositiveSafeInteger(resolved.hardMaxPreviewBytes) ||
|
||||
!isPositiveSafeInteger(resolved.hardMaxObjectUrlBytes) ||
|
||||
!isPositiveSafeInteger(resolved.hardMaxTransferBytes) ||
|
||||
!isPositiveSafeInteger(resolved.hardMaxActiveFileReferences) ||
|
||||
!isPositiveSafeInteger(resolved.hardMaxRetainedFileBytes) ||
|
||||
!isPositiveSafeInteger(resolved.hardMaxActiveObjectUrls) ||
|
||||
!isPositiveSafeInteger(resolved.hardMaxObjectUrlAggregateBytes) ||
|
||||
resolved.hardMaxObjectUrlBytes >
|
||||
resolved.hardMaxTransferBytes ||
|
||||
resolved.hardMaxPreviewBytes >
|
||||
resolved.hardMaxRetainedFileBytes ||
|
||||
hardMaxSingleObjectUrlBytes >
|
||||
resolved.hardMaxObjectUrlAggregateBytes
|
||||
) {
|
||||
throw new TypeError("Browser file runtime hard limits are invalid.");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isPositiveSafeInteger(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
BrowserDataObservation,
|
||||
BrowserDataObserver,
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataOperation,
|
||||
} from "../../application/ports/browser-file-storage/shared.ts";
|
||||
import { observeBrowserData } from "../browser-file-storage/result.ts";
|
||||
|
||||
type BrowserFileObservation = Readonly<{
|
||||
operation: BrowserDataOperation;
|
||||
outcome: "SUCCESS" | "DISMISSED" | "FAILED";
|
||||
failureCode?: BrowserDataFailureCode;
|
||||
byteBucket?: BrowserDataObservation["byteBucket"];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* File adapters use the platform BrowserDataObserver as the telemetry SSOT.
|
||||
* The helper below is the sole mapper from file-local dismissal semantics.
|
||||
*/
|
||||
export type BrowserFileObserver = BrowserDataObserver;
|
||||
|
||||
export function byteBucket(
|
||||
byteLength: number | null,
|
||||
): BrowserFileObservation["byteBucket"] | undefined {
|
||||
if (byteLength === null || !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";
|
||||
}
|
||||
|
||||
export function observeBrowserFile(
|
||||
observer: BrowserFileObserver | undefined,
|
||||
observation: BrowserFileObservation,
|
||||
): void {
|
||||
observeBrowserData(
|
||||
observer,
|
||||
Object.freeze({
|
||||
operation: observation.operation,
|
||||
outcome:
|
||||
observation.outcome === "FAILED" ? "FAILED" : "SUCCEEDED",
|
||||
...(observation.failureCode
|
||||
? { failureCode: observation.failureCode }
|
||||
: {}),
|
||||
...(observation.byteBucket
|
||||
? { byteBucket: observation.byteBucket }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import type {
|
||||
FileCandidate,
|
||||
} from "../../application/ports/browser-file-storage/file.ts";
|
||||
import type { PersistableDataClass } from "../../application/ports/browser-file-storage/shared.ts";
|
||||
import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
export type FileAcceptRule = Readonly<{
|
||||
mediaType: string;
|
||||
extensions: readonly string[];
|
||||
}>;
|
||||
|
||||
export type RegisteredFileSelectionPolicy = Readonly<{
|
||||
policyId: string;
|
||||
purpose: string;
|
||||
classification: PersistableDataClass;
|
||||
multiple: boolean;
|
||||
maxCount: number;
|
||||
maxFileBytes: number;
|
||||
maxTotalBytes: number;
|
||||
allowEmpty: boolean;
|
||||
accept: readonly FileAcceptRule[];
|
||||
}>;
|
||||
|
||||
export type FileBytePattern = Readonly<{
|
||||
offset: number;
|
||||
bytes: readonly number[];
|
||||
mask?: readonly number[];
|
||||
}>;
|
||||
|
||||
export type FileSignatureRule = Readonly<{
|
||||
mediaType: string;
|
||||
extensions: readonly string[];
|
||||
patterns: readonly FileBytePattern[];
|
||||
}>;
|
||||
|
||||
export type RegisteredFileInspectionPolicy = Readonly<{
|
||||
policyId: string;
|
||||
maxInspectionBytes: number;
|
||||
acceptedSignatures: readonly FileSignatureRule[];
|
||||
}>;
|
||||
|
||||
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/(?:[a-z0-9!#$&^_.+-]+|\*)$/i;
|
||||
const EXTENSION =
|
||||
/^\.[a-z0-9][a-z0-9+_-]{0,15}(?:\.[a-z0-9][a-z0-9+_-]{0,15})?$/i;
|
||||
const POLICY_TOKEN = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
|
||||
const FILE_SYSTEM_RESERVED = /[<>:"|?*]/g;
|
||||
const WINDOWS_RESERVED =
|
||||
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
||||
const DEFAULT_FORBIDDEN_EXTENSIONS = Object.freeze([
|
||||
".app",
|
||||
".apk",
|
||||
".bat",
|
||||
".cer",
|
||||
".cmd",
|
||||
".com",
|
||||
".cpl",
|
||||
".deb",
|
||||
".dmg",
|
||||
".exe",
|
||||
".htm",
|
||||
".html",
|
||||
".hta",
|
||||
".inf",
|
||||
".iso",
|
||||
".jar",
|
||||
".js",
|
||||
".lnk",
|
||||
".mjs",
|
||||
".msi",
|
||||
".pif",
|
||||
".ps1",
|
||||
".reg",
|
||||
".rpm",
|
||||
".scr",
|
||||
".sh",
|
||||
".svg",
|
||||
".vb",
|
||||
".vbe",
|
||||
".vbs",
|
||||
".wsf",
|
||||
".wsh",
|
||||
".xll",
|
||||
] as const);
|
||||
|
||||
export type SuggestedFileNamePolicy = Readonly<{
|
||||
safeExtension: string;
|
||||
fallbackBaseName?: string;
|
||||
maxUtf8Bytes?: number;
|
||||
forbiddenExtensions?: readonly string[];
|
||||
}>;
|
||||
|
||||
export function assertFileSelectionPolicy(
|
||||
policy: RegisteredFileSelectionPolicy,
|
||||
): void {
|
||||
if (
|
||||
!POLICY_TOKEN.test(policy.policyId) ||
|
||||
!POLICY_TOKEN.test(policy.purpose) ||
|
||||
![
|
||||
"PUBLIC",
|
||||
"INTERNAL",
|
||||
"PERSONAL",
|
||||
"CONFIDENTIAL",
|
||||
].includes(policy.classification) ||
|
||||
!Number.isSafeInteger(policy.maxCount) ||
|
||||
policy.maxCount < 1 ||
|
||||
!isValidByteLength(policy.maxFileBytes) ||
|
||||
!isValidByteLength(policy.maxTotalBytes) ||
|
||||
policy.maxFileBytes > policy.maxTotalBytes ||
|
||||
(!policy.allowEmpty &&
|
||||
(policy.maxFileBytes === 0 || policy.maxTotalBytes === 0)) ||
|
||||
(!policy.multiple && policy.maxCount !== 1)
|
||||
) {
|
||||
throw new TypeError("File selection policy is invalid.");
|
||||
}
|
||||
|
||||
for (const rule of policy.accept) {
|
||||
assertAcceptRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAcceptRule(rule: FileAcceptRule): void {
|
||||
if (
|
||||
!MEDIA_TYPE.test(rule.mediaType) ||
|
||||
rule.extensions.length === 0 ||
|
||||
rule.extensions.some((extension) => !EXTENSION.test(extension))
|
||||
) {
|
||||
throw new TypeError("File accept rule is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertFileInspectionPolicy(
|
||||
policy: RegisteredFileInspectionPolicy,
|
||||
hardMaxInspectionBytes: number,
|
||||
): void {
|
||||
if (
|
||||
!POLICY_TOKEN.test(policy.policyId) ||
|
||||
!Number.isSafeInteger(policy.maxInspectionBytes) ||
|
||||
policy.maxInspectionBytes < 1 ||
|
||||
policy.maxInspectionBytes > hardMaxInspectionBytes
|
||||
) {
|
||||
throw new TypeError("File inspection policy is invalid.");
|
||||
}
|
||||
|
||||
for (const rule of policy.acceptedSignatures) {
|
||||
assertSignatureRule(rule, policy.maxInspectionBytes);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSignatureRule(
|
||||
rule: FileSignatureRule,
|
||||
maxInspectionBytes: number,
|
||||
): void {
|
||||
if (
|
||||
!MEDIA_TYPE.test(rule.mediaType) ||
|
||||
rule.extensions.length === 0 ||
|
||||
rule.extensions.some((extension) => !EXTENSION.test(extension)) ||
|
||||
rule.patterns.length === 0
|
||||
) {
|
||||
throw new TypeError("File signature rule is invalid.");
|
||||
}
|
||||
for (const pattern of rule.patterns) {
|
||||
assertBytePattern(pattern, maxInspectionBytes);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBytePattern(
|
||||
pattern: FileBytePattern,
|
||||
maxInspectionBytes: number,
|
||||
): void {
|
||||
if (
|
||||
!Number.isSafeInteger(pattern.offset) ||
|
||||
pattern.offset < 0 ||
|
||||
pattern.bytes.length === 0 ||
|
||||
pattern.offset + pattern.bytes.length > maxInspectionBytes ||
|
||||
pattern.bytes.some((byte) => !validByte(byte)) ||
|
||||
(pattern.mask !== undefined &&
|
||||
(pattern.mask.length !== pattern.bytes.length ||
|
||||
pattern.mask.some((byte) => !validByte(byte))))
|
||||
) {
|
||||
throw new TypeError("File signature byte pattern is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
function validByte(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 0xff;
|
||||
}
|
||||
|
||||
export function normalizedExtension(fileName: string): string | null {
|
||||
const name = fileName.normalize("NFC");
|
||||
const separator = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
|
||||
const baseName = name.slice(separator + 1);
|
||||
const index = baseName.lastIndexOf(".");
|
||||
if (index <= 0 || index === baseName.length - 1) return null;
|
||||
const extension = baseName.slice(index).toLowerCase();
|
||||
return EXTENSION.test(extension) ? extension : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picker accept metadata is only an early usability filter. Returning true
|
||||
* here never establishes that the file content is safe.
|
||||
*/
|
||||
export function matchesSelectionHint(
|
||||
candidate: Pick<FileCandidate, "displayName" | "reportedMediaType">,
|
||||
accept: readonly FileAcceptRule[],
|
||||
): boolean {
|
||||
if (accept.length === 0) return true;
|
||||
const extension = normalizedExtension(candidate.displayName);
|
||||
const normalizedName = normalizedFileName(candidate.displayName);
|
||||
const reported = candidate.reportedMediaType?.toLowerCase() ?? null;
|
||||
return accept.some((rule) => {
|
||||
const expected = rule.mediaType.toLowerCase();
|
||||
const mediaMatches =
|
||||
reported !== null &&
|
||||
(expected === reported ||
|
||||
(expected.endsWith("/*") &&
|
||||
reported.startsWith(`${expected.slice(0, -1)}`)));
|
||||
const extensionMatches =
|
||||
rule.extensions.some(
|
||||
(allowed) =>
|
||||
normalizedName.endsWith(allowed.toLowerCase()) ||
|
||||
(extension !== null &&
|
||||
allowed.toLowerCase() === extension),
|
||||
);
|
||||
return mediaMatches || extensionMatches;
|
||||
});
|
||||
}
|
||||
|
||||
export function findMatchingSignature(
|
||||
header: Uint8Array,
|
||||
rules: readonly FileSignatureRule[],
|
||||
): FileSignatureRule | null {
|
||||
for (const rule of rules) {
|
||||
if (rule.patterns.some((pattern) => matchesPattern(header, pattern))) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesPattern(
|
||||
header: Uint8Array,
|
||||
pattern: FileBytePattern,
|
||||
): boolean {
|
||||
if (pattern.offset + pattern.bytes.length > header.byteLength) return false;
|
||||
for (let index = 0; index < pattern.bytes.length; index += 1) {
|
||||
const mask = pattern.mask?.[index] ?? 0xff;
|
||||
const actual = header[pattern.offset + index];
|
||||
const expected = pattern.bytes[index];
|
||||
if (actual === undefined || expected === undefined) return false;
|
||||
if ((actual & mask) !== (expected & mask)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function signatureWasExpected(
|
||||
fileName: string,
|
||||
reportedMediaType: string | null,
|
||||
rules: readonly FileSignatureRule[],
|
||||
): boolean {
|
||||
const extension = normalizedExtension(fileName);
|
||||
const normalizedName = normalizedFileName(fileName);
|
||||
const reported = reportedMediaType?.toLowerCase() ?? null;
|
||||
return rules.some(
|
||||
(rule) =>
|
||||
(reported !== null &&
|
||||
reported === rule.mediaType.toLowerCase()) ||
|
||||
(extension !== null &&
|
||||
rule.extensions.some(
|
||||
(allowed) =>
|
||||
normalizedName.endsWith(allowed.toLowerCase()) ||
|
||||
allowed.toLowerCase() === extension,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
export function signatureMetadataMatches(
|
||||
fileName: string,
|
||||
reportedMediaType: string | null,
|
||||
rule: FileSignatureRule,
|
||||
): boolean {
|
||||
const normalizedName = normalizedFileName(fileName);
|
||||
const extension = normalizedExtension(fileName);
|
||||
const reported = reportedMediaType?.toLowerCase() ?? null;
|
||||
const extensionMatches =
|
||||
extension === null ||
|
||||
rule.extensions.some(
|
||||
(allowed) =>
|
||||
normalizedName.endsWith(allowed.toLowerCase()) ||
|
||||
allowed.toLowerCase() === extension,
|
||||
);
|
||||
const mediaMatches =
|
||||
reported === null || reported === rule.mediaType.toLowerCase();
|
||||
return extensionMatches && mediaMatches;
|
||||
}
|
||||
|
||||
export function sanitizeSuggestedFileName(
|
||||
suggestedName: string,
|
||||
policy: SuggestedFileNamePolicy,
|
||||
): string {
|
||||
const safeExtension = normalizeSafeExtension(policy.safeExtension);
|
||||
const forbidden = new Set(
|
||||
(policy.forbiddenExtensions ?? DEFAULT_FORBIDDEN_EXTENSIONS).map(
|
||||
normalizeSafeExtension,
|
||||
),
|
||||
);
|
||||
const configuredFallback = neutralizeForbiddenExtensions(
|
||||
sanitizeBaseName(policy.fallbackBaseName ?? "download"),
|
||||
forbidden,
|
||||
);
|
||||
const maxUtf8Bytes = policy.maxUtf8Bytes ?? 180;
|
||||
if (
|
||||
!Number.isSafeInteger(maxUtf8Bytes) ||
|
||||
maxUtf8Bytes < utf8Length(`a${safeExtension}`)
|
||||
) {
|
||||
throw new TypeError("Suggested filename byte budget is invalid.");
|
||||
}
|
||||
const fallbackBase = fitFallbackBaseName(
|
||||
configuredFallback,
|
||||
safeExtension,
|
||||
maxUtf8Bytes,
|
||||
);
|
||||
|
||||
const lastPathSegment =
|
||||
suggestedName
|
||||
.normalize("NFC")
|
||||
.split(/[\\/]/)
|
||||
.at(-1) ?? "";
|
||||
const cleaned = lastPathSegment
|
||||
.split("")
|
||||
.filter((character) => !isUnsafeFormatCharacter(character))
|
||||
.join("")
|
||||
.replace(FILE_SYSTEM_RESERVED, "_")
|
||||
.trim()
|
||||
.replace(/[ .]+$/g, "");
|
||||
const lowerCleaned = cleaned.toLowerCase();
|
||||
const existingExtension = lowerCleaned.endsWith(safeExtension)
|
||||
? safeExtension
|
||||
: normalizedExtension(cleaned);
|
||||
const withoutFinalExtension =
|
||||
existingExtension === null
|
||||
? cleaned
|
||||
: cleaned.slice(0, -existingExtension.length);
|
||||
const neutralized = neutralizeForbiddenExtensions(
|
||||
withoutFinalExtension,
|
||||
forbidden,
|
||||
);
|
||||
let base = sanitizeBaseName(neutralized);
|
||||
if (
|
||||
base.length === 0 ||
|
||||
base === "." ||
|
||||
base === ".." ||
|
||||
WINDOWS_RESERVED.test(base)
|
||||
) {
|
||||
base = fallbackBase.length > 0 ? fallbackBase : "download";
|
||||
}
|
||||
|
||||
while (
|
||||
base.length > 0 &&
|
||||
utf8Length(`${base}${safeExtension}`) > maxUtf8Bytes
|
||||
) {
|
||||
base = Array.from(base).slice(0, -1).join("").trimEnd();
|
||||
}
|
||||
if (base.length === 0 || WINDOWS_RESERVED.test(base)) {
|
||||
base = fallbackBase;
|
||||
}
|
||||
return `${base}${safeExtension}`;
|
||||
}
|
||||
|
||||
function normalizeSafeExtension(extension: string): string {
|
||||
const normalized = extension.normalize("NFC").toLowerCase();
|
||||
if (!EXTENSION.test(normalized)) {
|
||||
throw new TypeError("Safe filename extension is invalid.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeBaseName(value: string): string {
|
||||
return value
|
||||
.normalize("NFC")
|
||||
.split("")
|
||||
.filter((character) => !isUnsafeFormatCharacter(character))
|
||||
.join("")
|
||||
.replace(FILE_SYSTEM_RESERVED, "_")
|
||||
.replace(/[\\/]/g, "_")
|
||||
.trim()
|
||||
.replace(/[ .]+$/g, "");
|
||||
}
|
||||
|
||||
function neutralizeForbiddenExtensions(
|
||||
value: string,
|
||||
forbidden: ReadonlySet<string>,
|
||||
): string {
|
||||
return value.replace(/\.[a-z0-9+_-]+/gi, (extension) =>
|
||||
forbidden.has(extension.toLowerCase())
|
||||
? `_${extension.slice(1)}`
|
||||
: extension,
|
||||
);
|
||||
}
|
||||
|
||||
function utf8Length(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
function fitFallbackBaseName(
|
||||
configured: string,
|
||||
extension: string,
|
||||
maxUtf8Bytes: number,
|
||||
): string {
|
||||
let fallback =
|
||||
configured.length > 0 && !WINDOWS_RESERVED.test(configured)
|
||||
? configured
|
||||
: "download";
|
||||
while (
|
||||
fallback.length > 0 &&
|
||||
utf8Length(`${fallback}${extension}`) > maxUtf8Bytes
|
||||
) {
|
||||
fallback = Array.from(fallback).slice(0, -1).join("").trimEnd();
|
||||
}
|
||||
return fallback.length > 0 && !WINDOWS_RESERVED.test(fallback)
|
||||
? fallback
|
||||
: "a";
|
||||
}
|
||||
|
||||
function normalizedFileName(value: string): string {
|
||||
const normalized = value.normalize("NFC").toLowerCase();
|
||||
const separator = Math.max(
|
||||
normalized.lastIndexOf("/"),
|
||||
normalized.lastIndexOf("\\"),
|
||||
);
|
||||
return normalized.slice(separator + 1);
|
||||
}
|
||||
|
||||
function isUnsafeFormatCharacter(character: string): boolean {
|
||||
const code = character.charCodeAt(0);
|
||||
return (
|
||||
code <= 0x1f ||
|
||||
(code >= 0x7f && code <= 0x9f) ||
|
||||
(code >= 0x202a && code <= 0x202e) ||
|
||||
(code >= 0x2066 && code <= 0x2069)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type {
|
||||
BrowserManagedDownloadCapability,
|
||||
BrowserManagedDownloadCapabilityReceipt,
|
||||
BrowserManagedDownloadCapabilityResolver,
|
||||
DownloadOutcome,
|
||||
DownloadSource,
|
||||
DownloadStrategy,
|
||||
FileByteSource,
|
||||
FilePolicyIntention,
|
||||
FilePolicyKey,
|
||||
FilePolicyReference,
|
||||
FileSelectionLimitReduction,
|
||||
FileSelectionOutcome,
|
||||
FileVerificationReceipt,
|
||||
LocalFileRef,
|
||||
} from "../../application/ports/browser-file-storage/file.ts";
|
||||
export {
|
||||
BrowserFilePolicyRegistry,
|
||||
browserFilePolicyReference,
|
||||
type BrowserFilePolicyProfile,
|
||||
type BrowserFilePolicyRegistryOptions,
|
||||
type RegisteredDownloadPolicy,
|
||||
type RegisteredPreviewPolicy,
|
||||
type ResolvedDownloadPolicy,
|
||||
type ResolvedInspectionPolicy,
|
||||
type ResolvedPreviewPolicy,
|
||||
} from "./browser-file-policy-registry.ts";
|
||||
export type {
|
||||
FileAcceptRule,
|
||||
FileBytePattern,
|
||||
FileSignatureRule,
|
||||
RegisteredFileInspectionPolicy,
|
||||
RegisteredFileSelectionPolicy,
|
||||
} from "./file-policy.ts";
|
||||
export {
|
||||
createBrowserFileRuntime,
|
||||
type BrowserFileRuntime,
|
||||
type BrowserFileRuntimeOptions,
|
||||
} from "./create-browser-file-runtime.ts";
|
||||
export {
|
||||
DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS,
|
||||
type SystemOpenPicker,
|
||||
type SystemOpenPickerOptions,
|
||||
} from "./browser-file-picker.ts";
|
||||
export {
|
||||
DEFAULT_OBJECT_URL_RELEASE_GRACE_MS,
|
||||
createAnchorDownloadHost,
|
||||
type BrowserDownloadHost,
|
||||
type SaveFileHandle,
|
||||
type ShowSaveFilePicker,
|
||||
} from "./download-delivery-adapter.ts";
|
||||
export type { BrowserFileObserver } from "./file-observer.ts";
|
||||
export type { ObjectUrlApi } from "./object-url-lease.ts";
|
||||
@@ -0,0 +1,339 @@
|
||||
import type {
|
||||
FilePolicyReference,
|
||||
FileVerificationReceipt,
|
||||
LocalFileRef,
|
||||
PreviewLease,
|
||||
TransientPreviewPort,
|
||||
} from "../../application/ports/browser-file-storage/file.ts";
|
||||
import type { BrowserDataResult } from "../../application/ports/browser-file-storage/shared.ts";
|
||||
import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
abortedResult,
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
mapBrowserDataException,
|
||||
} from "../browser-file-storage/result.ts";
|
||||
import type { NativeVerifiedFileResolver } from "./browser-file-vault.ts";
|
||||
import {
|
||||
byteBucket,
|
||||
observeBrowserFile,
|
||||
type BrowserFileObserver,
|
||||
} from "./file-observer.ts";
|
||||
import { BrowserFilePolicyRegistry } from "./browser-file-policy-registry.ts";
|
||||
|
||||
export type ObjectUrlApi = Readonly<{
|
||||
createObjectURL(blob: Blob): string;
|
||||
revokeObjectURL(url: string): void;
|
||||
}>;
|
||||
|
||||
export type ObjectUrlLease = Readonly<{
|
||||
url: string;
|
||||
release(): void;
|
||||
}>;
|
||||
|
||||
export type ObjectUrlLeaseLimits = Readonly<{
|
||||
hardMaxActiveLeases: number;
|
||||
hardMaxSingleLeaseBytes: number;
|
||||
hardMaxAggregateLeaseBytes: number;
|
||||
}>;
|
||||
|
||||
const DEFAULT_OBJECT_URL_LEASE_LIMITS: ObjectUrlLeaseLimits =
|
||||
Object.freeze({
|
||||
hardMaxActiveLeases: 16,
|
||||
hardMaxSingleLeaseBytes: 64 * 1024 * 1024,
|
||||
hardMaxAggregateLeaseBytes: 256 * 1024 * 1024,
|
||||
});
|
||||
|
||||
/**
|
||||
* The only low-level owner of object URL creation/revocation. Every lease is
|
||||
* idempotent and dispose() is a final safety net for route/runtime teardown.
|
||||
*/
|
||||
export class ObjectUrlLeaseRegistry {
|
||||
readonly #createObjectURL: ObjectUrlApi["createObjectURL"];
|
||||
readonly #revokeObjectURL: ObjectUrlApi["revokeObjectURL"];
|
||||
readonly #limits: ObjectUrlLeaseLimits;
|
||||
readonly #active = new Map<
|
||||
string,
|
||||
Readonly<{ release(): void; byteLength: number }>
|
||||
>();
|
||||
#aggregateByteLength = 0;
|
||||
|
||||
constructor(
|
||||
urlApi: ObjectUrlApi = URL,
|
||||
limits: ObjectUrlLeaseLimits = DEFAULT_OBJECT_URL_LEASE_LIMITS,
|
||||
) {
|
||||
const createObjectURL = urlApi.createObjectURL;
|
||||
const revokeObjectURL = urlApi.revokeObjectURL;
|
||||
if (
|
||||
typeof createObjectURL !== "function" ||
|
||||
typeof revokeObjectURL !== "function"
|
||||
) {
|
||||
throw new TypeError("Object URL API is invalid.");
|
||||
}
|
||||
this.#createObjectURL = createObjectURL.bind(urlApi);
|
||||
this.#revokeObjectURL = revokeObjectURL.bind(urlApi);
|
||||
this.#limits = Object.freeze({
|
||||
hardMaxActiveLeases: limits.hardMaxActiveLeases,
|
||||
hardMaxSingleLeaseBytes: limits.hardMaxSingleLeaseBytes,
|
||||
hardMaxAggregateLeaseBytes:
|
||||
limits.hardMaxAggregateLeaseBytes,
|
||||
});
|
||||
if (
|
||||
!isPositiveSafeInteger(this.#limits.hardMaxActiveLeases) ||
|
||||
!isPositiveSafeInteger(
|
||||
this.#limits.hardMaxSingleLeaseBytes,
|
||||
) ||
|
||||
!isPositiveSafeInteger(
|
||||
this.#limits.hardMaxAggregateLeaseBytes,
|
||||
) ||
|
||||
this.#limits.hardMaxSingleLeaseBytes >
|
||||
this.#limits.hardMaxAggregateLeaseBytes
|
||||
) {
|
||||
throw new TypeError("Object URL lease limits are invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
create(blob: Blob): ObjectUrlLease {
|
||||
if (
|
||||
!isValidByteLength(blob.size) ||
|
||||
blob.size > this.#limits.hardMaxSingleLeaseBytes ||
|
||||
this.#active.size >= this.#limits.hardMaxActiveLeases ||
|
||||
!Number.isSafeInteger(this.#aggregateByteLength + blob.size) ||
|
||||
this.#aggregateByteLength + blob.size >
|
||||
this.#limits.hardMaxAggregateLeaseBytes
|
||||
) {
|
||||
throw new DOMException(
|
||||
"Object URL lease limit exceeded",
|
||||
"FileTooLargeError",
|
||||
);
|
||||
}
|
||||
const url = this.#createObjectURL(blob);
|
||||
if (typeof url !== "string" || url.length === 0) {
|
||||
if (typeof url === "string" && url.length > 0) {
|
||||
try {
|
||||
this.#revokeObjectURL(url);
|
||||
} catch {
|
||||
// The invalid lease is rejected regardless of cleanup support.
|
||||
}
|
||||
}
|
||||
throw new DOMException(
|
||||
"Object URL allocation failed",
|
||||
"InvalidStateError",
|
||||
);
|
||||
}
|
||||
if (this.#active.has(url)) {
|
||||
throw new DOMException(
|
||||
"Object URL allocation was not unique",
|
||||
"InvalidStateError",
|
||||
);
|
||||
}
|
||||
let released = false;
|
||||
const release = (): void => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (this.#active.delete(url)) {
|
||||
this.#aggregateByteLength -= blob.size;
|
||||
}
|
||||
try {
|
||||
this.#revokeObjectURL(url);
|
||||
} catch {
|
||||
// Revocation is best effort and must remain idempotent.
|
||||
}
|
||||
};
|
||||
this.#active.set(
|
||||
url,
|
||||
Object.freeze({ release, byteLength: blob.size }),
|
||||
);
|
||||
this.#aggregateByteLength += blob.size;
|
||||
return Object.freeze({ url, release });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const lease of Array.from(this.#active.values())) {
|
||||
lease.release();
|
||||
}
|
||||
}
|
||||
|
||||
get activeLeaseCount(): number {
|
||||
return this.#active.size;
|
||||
}
|
||||
|
||||
get aggregateLeaseByteLength(): number {
|
||||
return this.#aggregateByteLength;
|
||||
}
|
||||
}
|
||||
|
||||
export type TransientPreviewOptions = Readonly<{
|
||||
files: NativeVerifiedFileResolver;
|
||||
policies: BrowserFilePolicyRegistry;
|
||||
/**
|
||||
* Runtime-owned absolute ceiling. Feature callers may request a lower
|
||||
* maxPreviewBytes but can never raise this limit.
|
||||
*/
|
||||
hardMaxPreviewBytes: number;
|
||||
leases?: ObjectUrlLeaseRegistry;
|
||||
hardForbiddenMediaTypes?: ReadonlySet<string>;
|
||||
observer?: BrowserFileObserver;
|
||||
}>;
|
||||
|
||||
const DEFAULT_ACTIVE_CONTENT = new Set([
|
||||
"application/pdf",
|
||||
"application/xhtml+xml",
|
||||
"application/xml",
|
||||
"image/svg+xml",
|
||||
"text/html",
|
||||
"text/xml",
|
||||
]);
|
||||
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i;
|
||||
|
||||
export class BrowserTransientPreview implements TransientPreviewPort {
|
||||
readonly #resolveVerifiedFile:
|
||||
NativeVerifiedFileResolver["resolveVerifiedFile"];
|
||||
readonly #resolvePreview:
|
||||
BrowserFilePolicyRegistry["resolvePreview"];
|
||||
readonly #createLease: ObjectUrlLeaseRegistry["create"];
|
||||
readonly #disposeLeases: ObjectUrlLeaseRegistry["dispose"];
|
||||
readonly #hardMaxPreviewBytes: number;
|
||||
readonly #hardForbiddenMediaTypes: ReadonlySet<string>;
|
||||
readonly #observer: BrowserFileObserver | undefined;
|
||||
#disposed = false;
|
||||
|
||||
constructor(options: TransientPreviewOptions) {
|
||||
this.#resolveVerifiedFile =
|
||||
options.files.resolveVerifiedFile.bind(options.files);
|
||||
this.#resolvePreview =
|
||||
options.policies.resolvePreview.bind(options.policies);
|
||||
const leases =
|
||||
options.leases ?? new ObjectUrlLeaseRegistry();
|
||||
this.#createLease = leases.create.bind(leases);
|
||||
this.#disposeLeases = leases.dispose.bind(leases);
|
||||
this.#hardMaxPreviewBytes = options.hardMaxPreviewBytes;
|
||||
this.#hardForbiddenMediaTypes = new Set([
|
||||
...Array.from(
|
||||
DEFAULT_ACTIVE_CONTENT,
|
||||
(mediaType) => mediaType.toLowerCase(),
|
||||
),
|
||||
...Array.from(
|
||||
options.hardForbiddenMediaTypes ?? [],
|
||||
(mediaType) => mediaType.toLowerCase(),
|
||||
),
|
||||
]);
|
||||
this.#observer = options.observer;
|
||||
if (
|
||||
!isValidByteLength(this.#hardMaxPreviewBytes) ||
|
||||
this.#hardMaxPreviewBytes === 0
|
||||
) {
|
||||
throw new TypeError("Preview hard byte limit is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
ref: LocalFileRef;
|
||||
verificationReceipt: FileVerificationReceipt;
|
||||
policy: FilePolicyReference;
|
||||
maxPreviewBytes?: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<BrowserDataResult<PreviewLease>> {
|
||||
if (this.#disposed) {
|
||||
return this.#observe(
|
||||
browserDataFailure("UNAVAILABLE", "PREVIEW"),
|
||||
);
|
||||
}
|
||||
const cancelled = abortedResult(input.signal, "PREVIEW");
|
||||
if (cancelled) return this.#observe(cancelled);
|
||||
const resolvedPolicy = this.#resolvePreview(
|
||||
input.policy,
|
||||
input.maxPreviewBytes,
|
||||
);
|
||||
if (!resolvedPolicy.ok) return this.#observe(resolvedPolicy);
|
||||
const policy = resolvedPolicy.value;
|
||||
if (policy.maxPreviewBytes > this.#hardMaxPreviewBytes) {
|
||||
return this.#observe(
|
||||
browserDataFailure("LIMIT_EXCEEDED", "PREVIEW"),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await this.#resolveVerifiedFile({
|
||||
ref: input.ref,
|
||||
verificationReceipt: input.verificationReceipt,
|
||||
verificationPolicyBindingId:
|
||||
policy.verificationPolicyBindingId,
|
||||
signal: input.signal,
|
||||
});
|
||||
if (!resolved.ok) return this.#observe(resolved);
|
||||
if (this.#disposed) {
|
||||
return this.#observe(
|
||||
browserDataFailure("UNAVAILABLE", "PREVIEW"),
|
||||
);
|
||||
}
|
||||
const mediaType = resolved.value.mediaType.trim().toLowerCase();
|
||||
if (
|
||||
!MEDIA_TYPE.test(mediaType) ||
|
||||
!policy.allowedMediaTypes.has(mediaType) ||
|
||||
this.#hardForbiddenMediaTypes.has(mediaType)
|
||||
) {
|
||||
return this.#observe(
|
||||
browserDataFailure("POLICY_REJECTED", "PREVIEW"),
|
||||
);
|
||||
}
|
||||
if (resolved.value.file.size > policy.maxPreviewBytes) {
|
||||
return this.#observe(
|
||||
browserDataFailure("LIMIT_EXCEEDED", "PREVIEW"),
|
||||
);
|
||||
}
|
||||
// A typed slice prevents the untrusted File.type from controlling how
|
||||
// the object URL is interpreted.
|
||||
const typedBlob = resolved.value.file.slice(
|
||||
0,
|
||||
resolved.value.file.size,
|
||||
mediaType,
|
||||
);
|
||||
const lease = this.#createLease(typedBlob);
|
||||
const preview: PreviewLease = Object.freeze({
|
||||
url: lease.url,
|
||||
mediaType,
|
||||
release: lease.release,
|
||||
});
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation: "PREVIEW",
|
||||
outcome: "SUCCESS",
|
||||
byteBucket: byteBucket(resolved.value.file.size),
|
||||
});
|
||||
return browserDataSuccess(preview);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
error.name === "FileTooLargeError"
|
||||
) {
|
||||
return this.#observe(
|
||||
browserDataFailure("LIMIT_EXCEEDED", "PREVIEW"),
|
||||
);
|
||||
}
|
||||
return this.#observe(mapBrowserDataException(error, "PREVIEW"));
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#disposeLeases();
|
||||
}
|
||||
|
||||
#observe<Value>(
|
||||
result: BrowserDataResult<Value>,
|
||||
): BrowserDataResult<Value> {
|
||||
if (!result.ok) {
|
||||
observeBrowserFile(this.#observer, {
|
||||
operation: "PREVIEW",
|
||||
outcome: "FAILED",
|
||||
failureCode: result.error.code,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function isPositiveSafeInteger(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user