/** * @factory/storage — MIME detection (PURE). * * Magic-byte sniffing first (the content is the truth), then zip-container * refinement for the OOXML family, then the extension fallback, then a text * heuristic. The result always carries a `confidence` so the pipeline can * decide how much to trust it (e.g. a `docx` refined from a bare `zip` magic * is still "magic" confidence on the container, "ext" on the refinement). * * NOTE: the `filenameHint` is advisory. The API layer enforces the policy * (`isAllowedUpload`) on the client-claimed type; the pipeline re-detects * from bytes after the object lands and stores the detected type in * `FileMeta.mimeType` (untrusted client input is never trusted, AGENTS.md §20). */ /** How much to trust a detection result. */ export type MimeConfidence = "magic" | "ext" | "text" | "unknown"; /** OOXML/zip refinements (docx/xlsx/pptx/epub are all zip containers). */ export type ZipRefinement = "docx" | "xlsx" | "pptx" | "epub"; export interface MimeDetection { mime: string; confidence: MimeConfidence; /** True when the leading magic says "zip" — the content is a container. */ isZipContainer?: boolean; /** Refinement of a zip container (word/excel/powerpoint/epub). */ refinement?: ZipRefinement; } export type Signature = { mime: string; /** Bytes that must match at `at`. */ bytes: readonly number[]; /** Offset of `bytes` in the buffer (default 0). */ at?: number; }; /** * Leading-byte magic table. Order matters: more specific prefixes first. * (webp/avif/heic all start with `RIFF`/`ftyp` boxes — the box payload at a * fixed offset disambiguates them.) */ export const MIME_SIGNATURES: readonly Signature[] = [ { mime: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }, { mime: "image/jpeg", bytes: [0xff, 0xd8, 0xff] }, { mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] }, // "GIF8" { mime: "image/webp", bytes: [0x57, 0x45, 0x42, 0x50], at: 8 }, // "RIFF....WEBP" // ISO-BMFF family: all of mp4/avif/heic are `ftyp` boxes — the BRAND at // offset 8 disambiguates. Checked BEFORE the generic mp4 signature. { mime: "image/avif", bytes: [0x61, 0x76, 0x69, 0x66], at: 8 }, // ftyp + "avif" { mime: "image/heic", bytes: [0x6d, 0x69, 0x66, 0x31], at: 8 }, // ftyp + "mif1" { mime: "audio/mp4", bytes: [0x4d, 0x34, 0x41, 0x20], at: 8 }, // ftyp + "M4A " (m4a) { mime: "video/quicktime", bytes: [0x71, 0x74, 0x20, 0x20], at: 8 }, // ftyp + "qt " (mov) { mime: "application/pdf", bytes: [0x25, 0x50, 0x44, 0x46] }, // "%PDF" { mime: "video/mp4", bytes: [0x66, 0x74, 0x79, 0x70], at: 4 }, // "....ftyp" { mime: "video/webm", bytes: [0x1a, 0x45, 0xdf, 0xa3] }, // EBML header { mime: "audio/ogg", bytes: [0x4f, 0x67, 0x67, 0x53] }, // "OggS" { mime: "audio/mpeg", bytes: [0x49, 0x44, 0x33] }, // "ID3" (ID3 tag) { mime: "audio/mpeg", bytes: [0xff, 0xfb] }, // MPEG audio frame sync { mime: "audio/wav", bytes: [0x57, 0x41, 0x56, 0x45], at: 8 }, // "RIFF....WAVE" { mime: "application/zip", bytes: [0x50, 0x4b, 0x03, 0x04] }, // local file header ]; /** * ~60 common extensions → MIME (fallback when the magic is inconclusive). * The OOXML family maps to the refined types; bare `.zip` stays generic. */ export const EXTENSION_MIME: Record = { // images png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", heic: "image/heic", heif: "image/heic", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", tif: "image/tiff", tiff: "image/tiff", tga: "image/targa", // video mp4: "video/mp4", m4v: "video/mp4", webm: "video/webm", mov: "video/quicktime", avi: "video/x-msvideo", mkv: "video/x-matroska", // audio mp3: "audio/mpeg", m4a: "audio/mp4", ogg: "audio/ogg", oga: "audio/ogg", opus: "audio/ogg", wav: "audio/wav", flac: "audio/flac", aac: "audio/aac", // documents pdf: "application/pdf", txt: "text/plain", md: "text/markdown", csv: "text/csv", html: "text/html", htm: "text/html", xml: "application/xml", json: "application/json", // archives zip: "application/zip", gz: "application/gzip", tar: "application/x-tar", "7z": "application/x-7z-compressed", rar: "application/vnd.rar", // office doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", odt: "application/vnd.oasis.opendocument.text", ods: "application/vnd.oasis.opendocument.spreadsheet", epub: "application/epub+zip", rtf: "application/rtf", // web/app js: "text/javascript", mjs: "text/javascript", css: "text/css", wasm: "application/wasm", apk: "application/vnd.android.package-archive", exe: "application/x-msdownload", dll: "application/vnd.microsoft.dll", bat: "application/x-dosexec", msi: "application/x-msi-installer", bin: "application/octet-stream", }; /** Inverse of {@link EXTENSION_MIME} (first extension wins on ties). */ export const MIME_EXTENSION: Record = (() => { const out: Record = {}; for (const [ext, mime] of Object.entries(EXTENSION_MIME)) { if (!(mime in out)) out[mime] = ext; } return out; })(); /** Max bytes scanned for the text heuristic (null-free ⇒ text/plain). */ export const TEXT_SCAN_BYTES = 8000; function bytesAt(buf: Uint8Array, bytes: readonly number[], at: number): boolean { if (at + bytes.length > buf.length) return false; for (let i = 0; i < bytes.length; i++) { if (buf[at + i] !== bytes[i]) return false; } return true; } function extensionOf(filename: string): string { const clean = filename.split(/[\\/]/).pop() ?? filename; const dot = clean.lastIndexOf("."); if (dot <= 0 || dot === clean.length - 1) return ""; return clean.slice(dot + 1).toLowerCase(); } /** * Look at the start of a zip archive and tell which OOXML flavor it is. * Scans local file headers (`PK\x03\x04`) until a file name is found under * one of the known roots. Pure + bounded (first 1 MB, first 50 headers). */ function refineZip(buf: Uint8Array): { mime: string; refinement: ZipRefinement } | null { const limit = Math.min(buf.length, 1_000_000); let pos = 0; for (let i = 0; i < 50 && pos + 30 <= limit; i++) { if ( buf[pos] !== 0x50 || buf[pos + 1] !== 0x4b || buf[pos + 2] !== 0x03 || buf[pos + 3] !== 0x04 ) { break; // no more local headers } const nameLen = buf[pos + 26]! | (buf[pos + 27]! << 8); const extraLen = buf[pos + 28]! | (buf[pos + 29]! << 8); const nameStart = pos + 30; if (nameStart + nameLen > limit) break; const name = new TextDecoder("utf-8", { fatal: false }).decode( buf.subarray(nameStart, nameStart + nameLen), ); if (name.startsWith("word/")) { return { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", refinement: "docx", }; } if (name.startsWith("xl/")) { return { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", refinement: "xlsx", }; } if (name.startsWith("ppt/")) { return { mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", refinement: "pptx", }; } if (name.startsWith("META-INF/")) { return { mime: "application/epub+zip", refinement: "epub" }; } pos = nameStart + nameLen + extraLen; // (compressed size would let us skip fully; name/extra scan is enough // for the first entries of every real OOXML file, which come first) } return null; } /** * Detect the MIME type of a buffer. * * Order: magic signatures → zip-container refinement → extension fallback → * text heuristic (no null byte in the first 8000 bytes) → octet-stream. */ export function detectMimeType(buf: Uint8Array, filenameHint?: string): MimeDetection { for (const sig of MIME_SIGNATURES) { const at = sig.at ?? 0; if (bytesAt(buf, sig.bytes, at)) { if (sig.mime === "application/zip") { const refined = refineZip(buf); return { mime: refined?.mime ?? "application/zip", confidence: "magic", isZipContainer: true, ...(refined ? { refinement: refined.refinement } : {}), }; } return { mime: sig.mime, confidence: "magic" }; } } if (filenameHint) { const ext = extensionOf(filenameHint); const byExt = ext !== "" ? EXTENSION_MIME[ext] : undefined; if (byExt) return { mime: byExt, confidence: "ext" }; } // Text heuristic: any null byte in the first TEXT_SCAN_BYTES bytes ⇒ binary. const scan = buf.length <= TEXT_SCAN_BYTES ? buf : buf.subarray(0, TEXT_SCAN_BYTES); let isText = scan.length > 0; for (let i = 0; i < scan.length; i++) { if (scan[i] === 0) { isText = false; break; } } if (isText) return { mime: "text/plain", confidence: "text" }; return { mime: "application/octet-stream", confidence: "unknown" }; } const IMAGE_MIMES = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/heic", "image/svg+xml", "image/bmp", "image/x-icon", ]); const VIDEO_MIMES = new Set([ "video/mp4", "video/webm", "video/quicktime", "video/x-msvideo", "video/x-matroska", "video/ogg", ]); const AUDIO_MIMES = new Set([ "audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/flac", "audio/aac", ]); const DOCUMENT_MIMES = new Set([ "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.text", "application/vnd.oasis.opendocument.spreadsheet", "application/epub+zip", "application/rtf", "text/plain", "text/markdown", "text/csv", "text/html", "text/javascript", "text/css", "application/json", "application/xml", ]); export function isImageMime(mime: string): boolean { return IMAGE_MIMES.has(mime); } export function isVideoMime(mime: string): boolean { return VIDEO_MIMES.has(mime); } export function isAudioMime(mime: string): boolean { return AUDIO_MIMES.has(mime); } export function isDocumentMime(mime: string): boolean { return DOCUMENT_MIMES.has(mime); } /** MIME types that are never allowed to be uploaded (executables/binaries). */ const BLOCKED_MIMES = new Set([ "application/x-msdownload", "application/vnd.microsoft.dll", "application/x-msdos-program", "application/x-executable", "application/x-sharedlib", "application/vnd.microsoft.portable-executable", "application/x-dosexec", ]); /** Extensions that map to blocked content regardless of the claimed MIME. */ const BLOCKED_EXTENSIONS = new Set(["exe", "dll", "bat", "sh", "app", "msi", "com", "scr"]); /** Upload policy input. */ export interface AllowedUploadOptions { /** Max size in bytes — defaults to LIMITS.MAX_FILE_SIZE (500 MB). */ maxBytes?: number; /** Actual (or claimed) size in bytes, when known — enforced against maxBytes. */ size?: number; /** File name, when known — extension is checked against the blocklist. */ filename?: string; } export interface UploadPolicyResult { allowed: boolean; /** Machine reason when blocked (safe to log; not for end users verbatim). */ reason?: "executable" | "too_large"; } /** * Upload policy (runs on the client-claimed type at request time and again * on the detected type after upload — untrusted input is never trusted, * AGENTS.md §20). * * - Block executables by MIME (`application/x-msdownload`, …) or extension * (`.exe/.dll/.bat/.sh/.app`, …) — the classic upload RCE vector. * - Enforce `size ≤ maxBytes` when a size is given * (default: LIMITS.MAX_FILE_SIZE = 500 MB). */ export function isAllowedUpload(mime: string, opts: AllowedUploadOptions = {}): UploadPolicyResult { const maxBytes = opts.maxBytes ?? 500 * 1024 * 1024; const ext = opts.filename !== undefined ? extensionOf(opts.filename) : ""; if (BLOCKED_MIMES.has(mime) || BLOCKED_EXTENSIONS.has(ext)) { return { allowed: false, reason: "executable" }; } if (opts.size !== undefined && opts.size > maxBytes) { return { allowed: false, reason: "too_large" }; } return { allowed: true }; } // ── TASK-0101 upload allow-list + content-type policy (spec §4) ───────────── /** * The CLOSED upload allow-list (TASK-0101 Requirement 4). Anything outside * this set is rejected at session-creation time — including types the * blocklist above would not catch. * * - images: png / jpg / webp / gif / svg * - documents: pdf / docx / xlsx / pptx / md / txt / csv * - archives: zip * - video: mp4 / webm / mov * - audio: mp3 / wav / m4a * - other: application/octet-stream (binary blobs) */ export const UPLOAD_ALLOWED_MIMES: ReadonlySet = new Set([ // images "image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml", // documents "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/markdown", "text/plain", "text/csv", // archives "application/zip", // video "video/mp4", "video/webm", "video/quicktime", // audio "audio/mpeg", "audio/wav", "audio/mp4", // other "application/octet-stream", ]); /** * `isAllowedMime` — the closed allow-list check for the CLIENT-CLAIMED * `mimeType` at session-creation time (TASK-0101 Requirement 4). */ export function isAllowedMime(mime: string): boolean { return UPLOAD_ALLOWED_MIMES.has(mime); } /** * `MIME_SNIFF_TABLE` — the magic-byte signatures used by * {@link sniffMagicBytes} (a subset of {@link MIME_SIGNATURES}; the zip entry * is present, OOXML refinement happens through `detectMimeType` at the * metadata step). Order matters — more specific prefixes first. */ export const MIME_SNIFF_TABLE: readonly Signature[] = MIME_SIGNATURES; /** * `CONTENT_TYPE_OVERRIDES` — per-detected-MIME response-content-type * overrides applied to SIGNED URLs (TASK-0101 Requirement 4, Security): * * - SVG is served as `text/plain` with `Content-Disposition: attachment` * + `X-Content-Type-Options: nosniff` so a browser never renders it as * XML/JS (classic stored-XSS vector). * - everything else is served with its true detected MIME. */ export const CONTENT_TYPE_OVERRIDES: Record = { "image/svg+xml": { contentType: "text/plain", disposition: "attachment" }, }; /** * `sniffMagicBytes` — run the magic-byte table over the leading bytes of the * stored object. Returns the first matching MIME, or `null` when no * signature matches (text-family content has no magic — the caller decides * text vs octet via `detectMimeType`, which also applies the extension + * text heuristics). * * This is the completion-time verifier (TASK-0101 Requirement 4): the * detected type is compared against the client-claimed type. */ export function sniffMagicBytes(buffer: Uint8Array): string | null { for (const sig of MIME_SIGNATURES) { if (bytesAt(buffer, sig.bytes, sig.at ?? 0)) return sig.mime; } return null; } /** * `sniffVerdict` — the AUTHORITY for the completion-time MIME check * (TASK-0101 Requirement 4, Security: "sniffing is authoritative over the * client claim; mismatch = failure, never 'trust and warn'"). * * Verdict rules: * - the CLAIMED type has no magic signature (text family: text/plain, * markdown, csv, svg — and `application/octet-stream`) → `"no-signal"`: * no leading bytes can prove a mismatch, so the claim stands (the * worker re-sniffs at the metadata step and is the final authority on * the stored mime). * - the claimed type HAS a signature → the bytes must match it (or a * same-artifact alias): PNG claiming `image/png` → `"ok"`; TEXT claiming * `image/png` → `"mismatch"` (signature-bearing claim, bytes match no * signature); ZIP claiming `image/png` → `"mismatch"`. * * `"mismatch"` ⇒ the caller must mark the file FAILED (`MIME_MISMATCH`) and * delete the object. */ export type SniffVerdict = "ok" | "no-signal" | "mismatch"; /** Same-artifact aliases within the ISO-BMFF (ftyp) family: the sniff table * returns the most specific brand (`qt ` → video/quicktime, `M4A ` → * audio/mp4); a client claiming the generic `video/mp4` for the same bytes * is not a mismatch. */ const SNIFF_FAMILY_ALIASES: Record = { "video/quicktime": ["video/mp4"], "audio/mp4": ["video/mp4"], }; export function sniffVerdict(buffer: Uint8Array, claimed: string): SniffVerdict { const claimHasSignature = MIME_SIGNATURES.some((s) => s.mime === claimed); if (!claimHasSignature) return "no-signal"; const sniffed = sniffMagicBytes(buffer); if (sniffed === null) return "mismatch"; // signature-bearing claim, no match if (sniffed === claimed) return "ok"; if ((SNIFF_FAMILY_ALIASES[sniffed] ?? []).includes(claimed)) return "ok"; return "mismatch"; }