/** * @factory/storage — mime.test.ts (magic-byte detection + upload policy). * * Real byte fixtures for every signature, zip→OOXML refinement, text * heuristic, extension fallback, classifier functions, isAllowedUpload. */ import { describe, expect, it } from "vitest"; import { detectMimeType, isAllowedUpload, isImageMime, isVideoMime, isAudioMime, isDocumentMime, EXTENSION_MIME, MIME_EXTENSION, MIME_SIGNATURES, sniffVerdict, } from "./mime.js"; // ── fixtures: real leading bytes ──────────────────────────────────────────── const png = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, ]); const jpg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]); const gif = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); const webp = new Uint8Array([ 0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20, ]); const pdf = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0x0a]); const zip = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00]); const mp4 = new Uint8Array([ 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, ]); const avif = new Uint8Array([ 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66, ]); const heic = new Uint8Array([ 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x69, 0x66, 0x31, ]); const mp3id3 = new Uint8Array([0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); const mp3frame = new Uint8Array([0xff, 0xfb, 0x90, 0x00, 0x00, 0x00]); const wav = new Uint8Array([ 0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x66, 0x6d, 0x74, 0x20, ]); const ogg = new Uint8Array([0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00]); const webm = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 0x00, 0x00, 0x00, 0x01, 0x42, 0x86]); /** Build a minimal zip with one local file header named `name`. */ function makeZip(name: string): Uint8Array { const nameBytes = new TextEncoder().encode(name); const buf = new Uint8Array(30 + nameBytes.length); buf[0] = 0x50; buf[1] = 0x4b; buf[2] = 0x03; buf[3] = 0x04; const v = new DataView(buf.buffer); v.setUint16(4, 20, true); // version needed v.setUint16(26, nameBytes.length, true); v.setUint16(28, 0, true); // extra len buf.set(nameBytes, 30); return buf; } describe("magic signatures (12 real byte fixtures)", () => { const cases: [string, Uint8Array, string, "magic" | "ext" | "text" | "unknown"][] = [ ["png", png, "image/png", "magic"], ["jpg", jpg, "image/jpeg", "magic"], ["gif", gif, "image/gif", "magic"], ["webp", webp, "image/webp", "magic"], ["pdf", pdf, "application/pdf", "magic"], ["zip", zip, "application/zip", "magic"], ["mp4", mp4, "video/mp4", "magic"], ["avif", avif, "image/avif", "magic"], ["heic", heic, "image/heic", "magic"], ["mp3-id3", mp3id3, "audio/mpeg", "magic"], ["mp3-frame", mp3frame, "audio/mpeg", "magic"], ["ogg", ogg, "audio/ogg", "magic"], ]; for (const [label, fixture, mime, confidence] of cases) { it(`${label} → ${mime} (magic)`, () => { const d = detectMimeType(fixture); expect(d.mime).toBe(mime); expect(d.confidence).toBe(confidence); }); } it("wav → audio/wav", () => { expect(detectMimeType(wav).mime).toBe("audio/wav"); }); it("webm → video/webm", () => { expect(detectMimeType(webm).mime).toBe("video/webm"); }); }); describe("zip container refinement", () => { it("refines a docx (word/ first entry)", () => { const d = detectMimeType(makeZip("word/document.xml")); expect(d.isZipContainer).toBe(true); expect(d.refinement).toBe("docx"); expect(d.mime).toBe("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); }); it("refines an xlsx (xl/ first entry)", () => { const d = detectMimeType(makeZip("xl/workbook.xml")); expect(d.refinement).toBe("xlsx"); expect(d.mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); }); it("refines a pptx (ppt/ first entry)", () => { const d = detectMimeType(makeZip("ppt/presentation.xml")); expect(d.refinement).toBe("pptx"); expect(d.mime).toBe( "application/vnd.openxmlformats-officedocument.presentationml.presentation", ); }); it("refines an epub (META-INF/ first entry)", () => { const d = detectMimeType(makeZip("META-INF/container.xml")); expect(d.refinement).toBe("epub"); expect(d.mime).toBe("application/epub+zip"); }); it("plain zip stays application/zip with no refinement", () => { const d = detectMimeType(makeZip("readme.txt")); expect(d.isZipContainer).toBe(true); expect(d.refinement).toBeUndefined(); expect(d.mime).toBe("application/zip"); }); }); describe("extension fallback + text heuristic", () => { it("falls back to extension when no magic matches", () => { const binaryish = new Uint8Array([0x00, 0x01, 0x02, 0xff, 0x00]); // null ⇒ binary expect(detectMimeType(binaryish, "notes.md").mime).toBe("text/markdown"); expect(detectMimeType(binaryish, "x.md").confidence).toBe("ext"); }); it("unknown binary without hint → octet-stream/unknown", () => { const d = detectMimeType(new Uint8Array([0x00, 0x01, 0x02, 0x00, 0x03])); expect(d.mime).toBe("application/octet-stream"); expect(d.confidence).toBe("unknown"); }); it("text heuristic: no null in first 8000 bytes → text/plain", () => { const text = new Uint8Array(new TextEncoder().encode("hello world ".repeat(500))); const d = detectMimeType(text); expect(d.mime).toBe("text/plain"); expect(d.confidence).toBe("text"); }); it("extension wins over the text heuristic (binary bytes + .txt)", () => { const d = detectMimeType(new Uint8Array([0x00, 0x01, 0x02]), "data.txt"); expect(d.mime).toBe("text/plain"); expect(d.confidence).toBe("ext"); }); it("MIME_EXTENSION is the inverse of EXTENSION_MIME (sample)", () => { expect(MIME_EXTENSION["image/png"]).toBe("png"); expect(EXTENSION_MIME["pdf"]).toBe("application/pdf"); expect(Object.keys(EXTENSION_MIME).length).toBeGreaterThanOrEqual(60); }); it("signature table has no duplicate magic+offset collisions in the image family", () => { expect(MIME_SIGNATURES.length).toBeGreaterThanOrEqual(12); }); }); describe("classifiers", () => { it("image mimes", () => { expect(isImageMime("image/png")).toBe(true); expect(isImageMime("image/webp")).toBe(true); expect(isImageMime("video/mp4")).toBe(false); }); it("video mimes", () => { expect(isVideoMime("video/mp4")).toBe(true); expect(isVideoMime("video/webm")).toBe(true); expect(isVideoMime("image/png")).toBe(false); }); it("audio mimes", () => { expect(isAudioMime("audio/mpeg")).toBe(true); expect(isAudioMime("audio/ogg")).toBe(true); expect(isAudioMime("audio/wav")).toBe(true); }); it("document mimes", () => { expect(isDocumentMime("application/pdf")).toBe(true); expect( isDocumentMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), ).toBe(true); expect(isDocumentMime("image/png")).toBe(false); }); }); describe("isAllowedUpload policy", () => { it("blocks executables by MIME", () => { expect(isAllowedUpload("application/x-msdownload", { filename: "a.exe" }).allowed).toBe(false); expect(isAllowedUpload("application/vnd.microsoft.dll", { filename: "a.dll" }).reason).toBe( "executable", ); }); it("blocks executables by extension (even with a benign claimed MIME)", () => { expect(isAllowedUpload("application/octet-stream", { filename: "dropme.bat" }).allowed).toBe( false, ); expect(isAllowedUpload("text/plain", { filename: "setup.sh" }).allowed).toBe(false); expect(isAllowedUpload("application/octet-stream", { filename: "app.app" }).allowed).toBe( false, ); }); it("allows normal files", () => { expect(isAllowedUpload("image/png", { filename: "cat.png" }).allowed).toBe(true); expect(isAllowedUpload("application/pdf", { filename: "doc.pdf" }).allowed).toBe(true); expect( isAllowedUpload("application/vnd.openxmlformats-officedocument.wordprocessingml.document", { filename: "report.docx", }).allowed, ).toBe(true); }); it("enforces maxBytes (default 500 MB)", () => { const ok = 500 * 1024 * 1024; expect(isAllowedUpload("image/png", { size: ok }).allowed).toBe(true); expect(isAllowedUpload("image/png", { size: ok + 1 }).allowed).toBe(false); expect(isAllowedUpload("image/png", { size: ok + 1 }).reason).toBe("too_large"); }); it("honors a custom maxBytes", () => { expect(isAllowedUpload("image/png", { size: 10, maxBytes: 10 }).allowed).toBe(true); expect(isAllowedUpload("image/png", { size: 11, maxBytes: 10 }).allowed).toBe(false); }); }); // ── sniffVerdict (completion-time authority, TASK-0101 §4) ────────────────── describe("sniffVerdict (bytes vs claimed — completion authority)", () => { const text = new TextEncoder().encode("hello world, this is a text file with no magic bytes\nline2"); const mp3 = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]); it("PNG claimed as image/png → ok", () => { expect(sniffVerdict(png, "image/png")).toBe("ok"); }); it("TEXT claimed as image/png → mismatch (spec acceptance: 'a text file claimed as image/png fails')", () => { expect(sniffVerdict(text, "image/png")).toBe("mismatch"); }); it("TEXT claimed as text/plain → no-signal (claim has no signature)", () => { expect(sniffVerdict(text, "text/plain")).toBe("no-signal"); }); it("PNG claimed as text/plain → no-signal (claim has no signature — bytes win, worker final)", () => { expect(sniffVerdict(png, "text/plain")).toBe("no-signal"); }); it("ZIP bytes claimed as application/zip → ok", () => { expect(sniffVerdict(zip, "application/zip")).toBe("ok"); }); it("ZIP bytes claimed as image/png → mismatch", () => { expect(sniffVerdict(zip, "image/png")).toBe("mismatch"); }); it("octet-stream claim → no-signal for anything (bytes win the argument)", () => { expect(sniffVerdict(png, "application/octet-stream")).toBe("no-signal"); expect(sniffVerdict(zip, "application/octet-stream")).toBe("no-signal"); }); it("ftyp family aliases: qt bytes claimed video/mp4 → ok", () => { const mov = new Uint8Array([0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20]); expect(sniffVerdict(mov, "video/quicktime")).toBe("ok"); expect(sniffVerdict(mov, "video/mp4")).toBe("ok"); }); it("MP3 claimed as audio/mpeg → ok", () => { expect(sniffVerdict(mp3, "audio/mpeg")).toBe("ok"); }); });