import { describe, expect, it, vi } from "vitest"; import type { FileVerificationReceipt, LocalFileRef, } from "../../src/application/ports/browser-file-storage/file.ts"; import { BrowserFileVault } from "../../src/adapters/browser-files/browser-file-vault.ts"; import { BrowserTransientPreview, ObjectUrlLeaseRegistry, } from "../../src/adapters/browser-files/object-url-lease.ts"; import { sanitizeSuggestedFileName, type RegisteredFileInspectionPolicy, type RegisteredFileSelectionPolicy, } from "../../src/adapters/browser-files/file-policy.ts"; import { BrowserFilePolicyRegistry, browserFilePolicyReference, } from "../../src/adapters/browser-files/browser-file-policy-registry.ts"; const pngSelectionPolicy: RegisteredFileSelectionPolicy = Object.freeze({ policyId: "avatar-v1", purpose: "avatar", classification: "PERSONAL", multiple: false, maxCount: 1, maxFileBytes: 1_024, maxTotalBytes: 1_024, allowEmpty: false, accept: Object.freeze([ Object.freeze({ mediaType: "image/png", extensions: Object.freeze([".png"]), }), ]), }); const pngInspectionPolicy: RegisteredFileInspectionPolicy = Object.freeze({ policyId: "avatar-v1", maxInspectionBytes: 16, acceptedSignatures: Object.freeze([ Object.freeze({ mediaType: "image/png", extensions: Object.freeze([".png"]), patterns: Object.freeze([ Object.freeze({ offset: 0, bytes: Object.freeze([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]), }), ]), }), ]), }); const pngPolicy = browserFilePolicyReference( "avatar", "inspect-and-preview-png", ); function pngPolicies( maxPreviewBytes = 10, ): BrowserFilePolicyRegistry { return new BrowserFilePolicyRegistry({ profiles: [ { reference: pngPolicy, inspection: pngInspectionPolicy, preview: { allowedMediaTypes: ["image/png"], maxPreviewBytes, }, }, ], hardLimits: { maxInspectionBytes: 64 * 1024, maxRetainedFileBytes: 2_048, maxPreviewBytes, maxObjectUrlBytes: 2_048, maxTransferBytes: 2_048, }, }); } describe("browser file content policy", () => { it("neutralizes path, bidi, device-name and executable filename tricks", () => { expect( sanitizeSuggestedFileName("..\\CON\u202Egpj.exe", { safeExtension: ".pdf", }), ).toBe("CONgpj.pdf"); expect( sanitizeSuggestedFileName("invoice.exe.pdf", { safeExtension: ".pdf", }), ).toBe("invoice_exe.pdf"); expect( sanitizeSuggestedFileName("CON.txt", { safeExtension: ".txt", }), ).toBe("download.txt"); expect( sanitizeSuggestedFileName("report.tar.gz", { safeExtension: ".tar.gz", }), ).toBe("report.tar.gz"); const bounded = sanitizeSuggestedFileName("가".repeat(100), { safeExtension: ".txt", maxUtf8Bytes: 24, }); expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual( 24, ); expect( sanitizeSuggestedFileName("😀", { safeExtension: ".bin", maxUtf8Bytes: 5, }), ).toBe("d.bin"); }); it("keeps native files behind opaque refs and performs bounded reads", async () => { const policies = pngPolicies(); const vault = new BrowserFileVault({ policies, createReference: () => "file:opaque-1", }); const bytes = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, ]); const captured = vault.captureFiles( [ new File([bytes], "portrait.png", { type: "image/png", lastModified: 100, }), ], pngSelectionPolicy, ); expect(captured.ok).toBe(true); if (!captured.ok) return; const candidate = captured.value[0]; expect(candidate).toBeDefined(); if (!candidate) return; expect(candidate.ref).not.toContain(candidate.displayName); const signal = new AbortController().signal; expect( await vault.inspect({ ref: candidate.ref, policy: pngPolicy, signal, }), ).toMatchObject({ ok: true, value: { detectedMediaType: "image/png", normalizedExtension: ".png", signature: "MATCHED", }, }); expect( await vault.readRange({ ref: candidate.ref, offset: 8, length: 2, signal, }), ).toEqual({ ok: true, value: new Uint8Array([1, 2]) }); const opened = await vault.openSource({ ref: candidate.ref, signal, }); expect(opened.ok).toBe(true); if (opened.ok) { const streamed: number[] = []; for await (const chunk of opened.value.stream(signal)) { expect(chunk.ok).toBe(true); if (chunk.ok) streamed.push(...chunk.value); } expect(streamed).toEqual([...bytes]); } vault.release(candidate.ref); expect(vault.activeVerificationCount).toBe(0); expect( await vault.readRange({ ref: candidate.ref, offset: 0, length: 1, signal, }), ).toMatchObject({ ok: false, error: { code: "NOT_FOUND", recovery: "RESELECT" }, }); }); it("snapshots vault requests before asynchronous file resolution", async () => { const references = ["file:a", "file:b"]; const policies = pngPolicies(32); const vault = new BrowserFileVault({ policies, createReference: () => references.shift() ?? "file:unexpected", createVerificationReceipt: () => "verification:a", }); const pngHeader = [ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]; const captured = vault.captureFiles( [ new File( [new Uint8Array([...pngHeader, 1, 2])], "a.png", { type: "image/png", lastModified: 1 }, ), new File( [new Uint8Array([...pngHeader, 9, 8])], "b.png", { type: "image/png", lastModified: 2 }, ), ], { ...pngSelectionPolicy, multiple: true, maxCount: 2, maxTotalBytes: 2_048, }, ); expect(captured.ok).toBe(true); if ( !captured.ok || !captured.value[0] || !captured.value[1] ) { return; } const [first, second] = captured.value; const active = new AbortController(); const replacement = new AbortController(); replacement.abort(); const readRequest = { ref: first.ref, offset: 8, length: 1, signal: active.signal, }; const pendingRead = vault.readRange(readRequest); readRequest.ref = second.ref; readRequest.offset = 9; readRequest.signal = replacement.signal; expect(await pendingRead).toEqual({ ok: true, value: new Uint8Array([1]), }); const inspectionRequest = { ref: first.ref, policy: pngPolicy, signal: active.signal, }; const pendingInspection = vault.inspect(inspectionRequest); inspectionRequest.ref = second.ref; inspectionRequest.policy = browserFilePolicyReference( "forged", "forged", ); inspectionRequest.signal = replacement.signal; expect(await pendingInspection).toMatchObject({ ok: true, value: { byteLength: 10, verificationReceipt: "verification:a", }, }); const sourceRequest = { ref: first.ref, signal: active.signal, }; const pendingSource = vault.openSource(sourceRequest); sourceRequest.ref = second.ref; sourceRequest.signal = replacement.signal; const opened = await pendingSource; expect(opened.ok).toBe(true); if (!opened.ok) return; const streamed: number[] = []; for await (const chunk of opened.value.stream(active.signal)) { expect(chunk.ok).toBe(true); if (chunk.ok) streamed.push(...chunk.value); } expect(streamed).toEqual([...pngHeader, 1, 2]); }); it("snapshots file handles and loader methods before awaiting them", async () => { const policies = pngPolicies(); let reference = 0; const vault = new BrowserFileVault({ policies, createReference: () => `file:handle-${reference++}`, }); let resolveFirst: ((file: File) => void) | undefined; const firstFile = new File(["a"], "a.txt", { type: "text/plain", lastModified: 1, }); const secondFile = new File(["b"], "b.txt", { type: "text/plain", lastModified: 2, }); const originalFirst = vi.fn( () => new Promise((resolve) => { resolveFirst = resolve; }), ); const originalSecond = vi.fn(async () => secondFile); const replaced = vi.fn(async () => new File(["x"], "x.txt"), ); const firstHandle = { kind: "file" as const, name: "a.txt", getFile: originalFirst, }; const secondHandle = { kind: "file" as const, name: "b.txt", getFile: originalSecond, }; const handles = [firstHandle, secondHandle]; const pending = vault.captureHandles( handles, { ...pngSelectionPolicy, multiple: true, maxCount: 2, accept: Object.freeze([]), }, new AbortController().signal, ); firstHandle.getFile = replaced; secondHandle.getFile = replaced; handles.splice(1, 1, { kind: "file", name: "x.txt", getFile: replaced, }); resolveFirst?.(firstFile); const result = await pending; expect(result).toMatchObject({ ok: true, value: [ { displayName: "a.txt" }, { displayName: "b.txt" }, ], }); expect(originalFirst).toHaveBeenCalledOnce(); expect(originalSecond).toHaveBeenCalledOnce(); expect(replaced).not.toHaveBeenCalled(); if (!result.ok || !result.value[1]) return; expect( await vault.resolveFile( result.value[1].ref, new AbortController().signal, ), ).toMatchObject({ ok: true, value: secondFile, }); expect(originalSecond).toHaveBeenCalledTimes(2); }); it("marks MIME/extension/signature disagreement and stale handles", async () => { const refs = ["file:mismatch", "file:stale"]; const policies = pngPolicies(); const vault = new BrowserFileVault({ policies, createReference: () => refs.shift() ?? "file:unexpected", }); const permissive = { ...pngSelectionPolicy, accept: Object.freeze([]), }; const mismatch = vault.captureFiles( [ new File( [ new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]), ], "portrait.jpg", { type: "image/jpeg", lastModified: 100 }, ), ], permissive, ); expect(mismatch.ok).toBe(true); if (!mismatch.ok || !mismatch.value[0]) return; expect( await vault.inspect({ ref: mismatch.value[0].ref, policy: pngPolicy, signal: new AbortController().signal, }), ).toMatchObject({ ok: true, value: { detectedMediaType: "image/png", signature: "MISMATCHED", verificationReceipt: null, }, }); let current = new File([new Uint8Array([1])], "state.bin", { lastModified: 1, }); const handle = { kind: "file" as const, name: "state.bin", getFile: vi.fn(async () => current), }; const handleCapture = await vault.captureHandles( [handle], permissive, new AbortController().signal, ); expect(handleCapture.ok).toBe(true); if (!handleCapture.ok || !handleCapture.value[0]) return; current = new File([new Uint8Array([1, 2])], "state.bin", { lastModified: 2, }); expect( await vault.resolveFile( handleCapture.value[0].ref, new AbortController().signal, ), ).toMatchObject({ ok: false, error: { code: "STALE_RESULT", recovery: "RESELECT" }, }); }); it("owns preview object URL leases and rejects active content", async () => { const revoked: string[] = []; const createObjectURL = vi.fn(() => "blob:test-1"); const leases = new ObjectUrlLeaseRegistry({ createObjectURL, revokeObjectURL: (url) => revoked.push(url), }); const policies = pngPolicies(); const vault = new BrowserFileVault({ policies, createReference: () => "file:preview", createVerificationReceipt: () => "verification:preview", }); const capture = vault.captureFiles( [ new File( [ new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]), ], "preview.png", { type: "image/png", lastModified: 1, }, ), ], pngSelectionPolicy, ); expect(capture.ok).toBe(true); if (!capture.ok || !capture.value[0]) return; const inspected = await vault.inspect({ ref: capture.value[0].ref, policy: pngPolicy, signal: new AbortController().signal, }); expect(inspected.ok).toBe(true); if (!inspected.ok || !inspected.value.verificationReceipt) return; const verificationReceipt = inspected.value.verificationReceipt; const preview = new BrowserTransientPreview({ files: vault, policies, leases, hardMaxPreviewBytes: 10, observer: { record() { throw new Error("ignored"); }, }, }); const created = await preview.create({ ref: capture.value[0].ref, verificationReceipt, policy: pngPolicy, maxPreviewBytes: 10, signal: new AbortController().signal, }); expect(created.ok).toBe(true); if (created.ok) { created.value.release(); created.value.release(); } expect(revoked).toEqual(["blob:test-1"]); expect(leases.activeLeaseCount).toBe(0); expect( await preview.create({ ref: capture.value[0].ref, verificationReceipt, policy: pngPolicy, maxPreviewBytes: 11, signal: new AbortController().signal, }), ).toMatchObject({ ok: false, error: { code: "LIMIT_EXCEEDED" }, }); expect(createObjectURL).toHaveBeenCalledOnce(); expect( await preview.create({ ref: capture.value[0].ref as LocalFileRef, verificationReceipt: "verification:forged" as FileVerificationReceipt, policy: pngPolicy, maxPreviewBytes: 10, signal: new AbortController().signal, }), ).toMatchObject({ ok: false, error: { code: "POLICY_REJECTED" }, }); }); it("cannot weaken the built-in active-content preview denylist", async () => { const htmlPolicy = browserFilePolicyReference( "active-content", "preview-html", ); const policies = new BrowserFilePolicyRegistry({ profiles: [ { reference: htmlPolicy, inspection: { policyId: "html-signature-v1", maxInspectionBytes: 6, acceptedSignatures: [ { mediaType: "text/html", extensions: [".html"], patterns: [ { offset: 0, bytes: [60, 104, 116, 109, 108], }, ], }, ], }, preview: { allowedMediaTypes: ["text/html"], maxPreviewBytes: 10, }, }, ], hardLimits: { maxInspectionBytes: 64 * 1024, maxRetainedFileBytes: 1_024, maxPreviewBytes: 10, maxObjectUrlBytes: 1_024, maxTransferBytes: 1_024, }, }); const vault = new BrowserFileVault({ policies, createReference: () => "file:html", createVerificationReceipt: () => "verification:html", }); const captured = vault.captureFiles( [ new File([new TextEncoder().encode("")], "page.html", { type: "text/html", lastModified: 1, }), ], { ...pngSelectionPolicy, accept: Object.freeze([]), }, ); expect(captured.ok).toBe(true); if (!captured.ok || !captured.value[0]) return; const inspected = await vault.inspect({ ref: captured.value[0].ref, policy: htmlPolicy, signal: new AbortController().signal, }); expect(inspected.ok).toBe(true); if (!inspected.ok || !inspected.value.verificationReceipt) return; const createObjectURL = vi.fn(() => "blob:must-not-exist"); const preview = new BrowserTransientPreview({ files: vault, policies, hardMaxPreviewBytes: 10, hardForbiddenMediaTypes: new Set(["image/jpeg"]), leases: new ObjectUrlLeaseRegistry({ createObjectURL, revokeObjectURL() {}, }), }); expect( await preview.create({ ref: captured.value[0].ref, verificationReceipt: inspected.value.verificationReceipt, policy: htmlPolicy, maxPreviewBytes: 10, signal: new AbortController().signal, }), ).toMatchObject({ ok: false, error: { code: "POLICY_REJECTED" }, }); expect(createObjectURL).not.toHaveBeenCalled(); }); it("rejects count and zero-byte policy violations before retaining refs", () => { const vault = new BrowserFileVault({ policies: pngPolicies(), }); expect(vault.captureFiles([], pngSelectionPolicy)).toMatchObject({ ok: false, error: { code: "INVALID_INPUT" }, }); expect( vault.captureFiles( [new File([], "empty.png", { type: "image/png" })], pngSelectionPolicy, ), ).toMatchObject({ ok: false, error: { code: "LIMIT_EXCEEDED" }, }); expect(vault.activeReferenceCount).toBe(0); }); it("caps retained files and aggregate object URL leases", () => { let referenceSequence = 0; const vault = new BrowserFileVault({ policies: pngPolicies(), createReference: () => `file:bounded-${referenceSequence++}`, hardMaxActiveReferences: 1, hardMaxRetainedBytes: 3, }); const permissive = { ...pngSelectionPolicy, accept: Object.freeze([]), }; const first = vault.captureFiles( [new File([new Uint8Array(2)], "one.bin")], permissive, ); expect(first.ok).toBe(true); expect(vault.retainedByteLength).toBe(2); expect( vault.captureFiles( [new File([new Uint8Array(2)], "two.bin")], permissive, ), ).toMatchObject({ ok: false, error: { code: "LIMIT_EXCEEDED" }, }); if (first.ok && first.value[0]) vault.release(first.value[0].ref); expect(vault.retainedByteLength).toBe(0); let urlSequence = 0; const leases = new ObjectUrlLeaseRegistry( { createObjectURL: () => `blob:bounded-${urlSequence++}`, revokeObjectURL() {}, }, { hardMaxActiveLeases: 1, hardMaxSingleLeaseBytes: 6, hardMaxAggregateLeaseBytes: 6, }, ); const lease = leases.create(new Blob([new Uint8Array(6)])); expect(leases.aggregateLeaseByteLength).toBe(6); expect(() => leases.create(new Blob([new Uint8Array(1)])), ).toThrowError(DOMException); lease.release(); expect(leases.aggregateLeaseByteLength).toBe(0); }); it("converts native stream exceptions to closed chunk failures", async () => { const file = new File([new Uint8Array([1])], "broken.bin", { lastModified: 1, }); Object.defineProperty(file, "stream", { value: () => new ReadableStream({ pull() { throw new DOMException("native detail", "NotReadableError"); }, }), }); const vault = new BrowserFileVault({ policies: pngPolicies(), createReference: () => "file:broken", }); const captured = vault.captureFiles( [file], { ...pngSelectionPolicy, accept: Object.freeze([]) }, ); expect(captured.ok).toBe(true); if (!captured.ok || !captured.value[0]) return; const opened = await vault.openSource({ ref: captured.value[0].ref, signal: new AbortController().signal, }); expect(opened.ok).toBe(true); if (!opened.ok) return; const results = []; for await (const result of opened.value.stream( new AbortController().signal, )) { results.push(result); } expect(results).toEqual([ { ok: false, error: { code: "NOT_READABLE", operation: "FILE_READ", retryable: true, recovery: "REOPEN", }, }, ]); }); });