chore: initialize from frontend template 4dc033c

This commit is contained in:
DongHyeonka
2026-08-13 18:23:26 +09:00
commit 40107eec84
897 changed files with 234824 additions and 0 deletions
@@ -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 }
: {}),
}),
);
}
+441
View File
@@ -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)
);
}
+53
View File
@@ -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;
}