1
7
8import { describe, expect, it } from "vitest";
9
10import {
11 detectMimeType,
12 isAllowedUpload,
13 isImageMime,
14 isVideoMime,
15 isAudioMime,
16 isDocumentMime,
17 EXTENSION_MIME,
18 MIME_EXTENSION,
19 MIME_SIGNATURES,
20 sniffVerdict,
21} from "./mime.js";
22
23
24
25const png = new Uint8Array([
26 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
27]);
28const jpg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]);
29const gif = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
30const webp = new Uint8Array([
31 0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20,
32]);
33const pdf = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0x0a]);
34const zip = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00]);
35const mp4 = new Uint8Array([
36 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
37]);
38const avif = new Uint8Array([
39 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66,
40]);
41const heic = new Uint8Array([
42 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x69, 0x66, 0x31,
43]);
44const mp3id3 = new Uint8Array([0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
45const mp3frame = new Uint8Array([0xff, 0xfb, 0x90, 0x00, 0x00, 0x00]);
46const wav = new Uint8Array([
47 0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x66, 0x6d, 0x74, 0x20,
48]);
49const ogg = new Uint8Array([0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00]);
50const webm = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 0x00, 0x00, 0x00, 0x01, 0x42, 0x86]);
51
52
53function makeZip(name: string): Uint8Array {
54 const nameBytes = new TextEncoder().encode(name);
55 const buf = new Uint8Array(30 + nameBytes.length);
56 buf[0] = 0x50;
57 buf[1] = 0x4b;
58 buf[2] = 0x03;
59 buf[3] = 0x04;
60 const v = new DataView(buf.buffer);
61 v.setUint16(4, 20, true);
62 v.setUint16(26, nameBytes.length, true);
63 v.setUint16(28, 0, true);
64 buf.set(nameBytes, 30);
65 return buf;
66}
67
68describe("magic signatures (12 real byte fixtures)", () => {
69 const cases: [string, Uint8Array, string, "magic" | "ext" | "text" | "unknown"][] = [
70 ["png", png, "image/png", "magic"],
71 ["jpg", jpg, "image/jpeg", "magic"],
72 ["gif", gif, "image/gif", "magic"],
73 ["webp", webp, "image/webp", "magic"],
74 ["pdf", pdf, "application/pdf", "magic"],
75 ["zip", zip, "application/zip", "magic"],
76 ["mp4", mp4, "video/mp4", "magic"],
77 ["avif", avif, "image/avif", "magic"],
78 ["heic", heic, "image/heic", "magic"],
79 ["mp3-id3", mp3id3, "audio/mpeg", "magic"],
80 ["mp3-frame", mp3frame, "audio/mpeg", "magic"],
81 ["ogg", ogg, "audio/ogg", "magic"],
82 ];
83 for (const [label, fixture, mime, confidence] of cases) {
84 it(`${label} → ${mime} (magic)`, () => {
85 const d = detectMimeType(fixture);
86 expect(d.mime).toBe(mime);
87 expect(d.confidence).toBe(confidence);
88 });
89 }
90
91 it("wav → audio/wav", () => {
92 expect(detectMimeType(wav).mime).toBe("audio/wav");
93 });
94 it("webm → video/webm", () => {
95 expect(detectMimeType(webm).mime).toBe("video/webm");
96 });
97});
98
99describe("zip container refinement", () => {
100 it("refines a docx (word/ first entry)", () => {
101 const d = detectMimeType(makeZip("word/document.xml"));
102 expect(d.isZipContainer).toBe(true);
103 expect(d.refinement).toBe("docx");
104 expect(d.mime).toBe("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
105 });
106 it("refines an xlsx (xl/ first entry)", () => {
107 const d = detectMimeType(makeZip("xl/workbook.xml"));
108 expect(d.refinement).toBe("xlsx");
109 expect(d.mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
110 });
111 it("refines a pptx (ppt/ first entry)", () => {
112 const d = detectMimeType(makeZip("ppt/presentation.xml"));
113 expect(d.refinement).toBe("pptx");
114 expect(d.mime).toBe(
115 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
116 );
117 });
118 it("refines an epub (META-INF/ first entry)", () => {
119 const d = detectMimeType(makeZip("META-INF/container.xml"));
120 expect(d.refinement).toBe("epub");
121 expect(d.mime).toBe("application/epub+zip");
122 });
123 it("plain zip stays application/zip with no refinement", () => {
124 const d = detectMimeType(makeZip("readme.txt"));
125 expect(d.isZipContainer).toBe(true);
126 expect(d.refinement).toBeUndefined();
127 expect(d.mime).toBe("application/zip");
128 });
129});
130
131describe("extension fallback + text heuristic", () => {
132 it("falls back to extension when no magic matches", () => {
133 const binaryish = new Uint8Array([0x00, 0x01, 0x02, 0xff, 0x00]);
134 expect(detectMimeType(binaryish, "notes.md").mime).toBe("text/markdown");
135 expect(detectMimeType(binaryish, "x.md").confidence).toBe("ext");
136 });
137 it("unknown binary without hint → octet-stream/unknown", () => {
138 const d = detectMimeType(new Uint8Array([0x00, 0x01, 0x02, 0x00, 0x03]));
139 expect(d.mime).toBe("application/octet-stream");
140 expect(d.confidence).toBe("unknown");
141 });
142 it("text heuristic: no null in first 8000 bytes → text/plain", () => {
143 const text = new Uint8Array(new TextEncoder().encode("hello world ".repeat(500)));
144 const d = detectMimeType(text);
145 expect(d.mime).toBe("text/plain");
146 expect(d.confidence).toBe("text");
147 });
148 it("extension wins over the text heuristic (binary bytes + .txt)", () => {
149 const d = detectMimeType(new Uint8Array([0x00, 0x01, 0x02]), "data.txt");
150 expect(d.mime).toBe("text/plain");
151 expect(d.confidence).toBe("ext");
152 });
153 it("MIME_EXTENSION is the inverse of EXTENSION_MIME (sample)", () => {
154 expect(MIME_EXTENSION["image/png"]).toBe("png");
155 expect(EXTENSION_MIME["pdf"]).toBe("application/pdf");
156 expect(Object.keys(EXTENSION_MIME).length).toBeGreaterThanOrEqual(60);
157 });
158 it("signature table has no duplicate magic+offset collisions in the image family", () => {
159 expect(MIME_SIGNATURES.length).toBeGreaterThanOrEqual(12);
160 });
161});
162
163describe("classifiers", () => {
164 it("image mimes", () => {
165 expect(isImageMime("image/png")).toBe(true);
166 expect(isImageMime("image/webp")).toBe(true);
167 expect(isImageMime("video/mp4")).toBe(false);
168 });
169 it("video mimes", () => {
170 expect(isVideoMime("video/mp4")).toBe(true);
171 expect(isVideoMime("video/webm")).toBe(true);
172 expect(isVideoMime("image/png")).toBe(false);
173 });
174 it("audio mimes", () => {
175 expect(isAudioMime("audio/mpeg")).toBe(true);
176 expect(isAudioMime("audio/ogg")).toBe(true);
177 expect(isAudioMime("audio/wav")).toBe(true);
178 });
179 it("document mimes", () => {
180 expect(isDocumentMime("application/pdf")).toBe(true);
181 expect(
182 isDocumentMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
183 ).toBe(true);
184 expect(isDocumentMime("image/png")).toBe(false);
185 });
186});
187
188describe("isAllowedUpload policy", () => {
189 it("blocks executables by MIME", () => {
190 expect(isAllowedUpload("application/x-msdownload", { filename: "a.exe" }).allowed).toBe(false);
191 expect(isAllowedUpload("application/vnd.microsoft.dll", { filename: "a.dll" }).reason).toBe(
192 "executable",
193 );
194 });
195 it("blocks executables by extension (even with a benign claimed MIME)", () => {
196 expect(isAllowedUpload("application/octet-stream", { filename: "dropme.bat" }).allowed).toBe(
197 false,
198 );
199 expect(isAllowedUpload("text/plain", { filename: "setup.sh" }).allowed).toBe(false);
200 expect(isAllowedUpload("application/octet-stream", { filename: "app.app" }).allowed).toBe(
201 false,
202 );
203 });
204 it("allows normal files", () => {
205 expect(isAllowedUpload("image/png", { filename: "cat.png" }).allowed).toBe(true);
206 expect(isAllowedUpload("application/pdf", { filename: "doc.pdf" }).allowed).toBe(true);
207 expect(
208 isAllowedUpload("application/vnd.openxmlformats-officedocument.wordprocessingml.document", {
209 filename: "report.docx",
210 }).allowed,
211 ).toBe(true);
212 });
213 it("enforces maxBytes (default 500 MB)", () => {
214 const ok = 500 * 1024 * 1024;
215 expect(isAllowedUpload("image/png", { size: ok }).allowed).toBe(true);
216 expect(isAllowedUpload("image/png", { size: ok + 1 }).allowed).toBe(false);
217 expect(isAllowedUpload("image/png", { size: ok + 1 }).reason).toBe("too_large");
218 });
219 it("honors a custom maxBytes", () => {
220 expect(isAllowedUpload("image/png", { size: 10, maxBytes: 10 }).allowed).toBe(true);
221 expect(isAllowedUpload("image/png", { size: 11, maxBytes: 10 }).allowed).toBe(false);
222 });
223});
224
225
226
227describe("sniffVerdict (bytes vs claimed — completion authority)", () => {
228 const text = new TextEncoder().encode("hello world, this is a text file with no magic bytes\nline2");
229 const mp3 = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]);
230
231 it("PNG claimed as image/png → ok", () => {
232 expect(sniffVerdict(png, "image/png")).toBe("ok");
233 });
234
235 it("TEXT claimed as image/png → mismatch (spec acceptance: 'a text file claimed as image/png fails')", () => {
236 expect(sniffVerdict(text, "image/png")).toBe("mismatch");
237 });
238
239 it("TEXT claimed as text/plain → no-signal (claim has no signature)", () => {
240 expect(sniffVerdict(text, "text/plain")).toBe("no-signal");
241 });
242
243 it("PNG claimed as text/plain → no-signal (claim has no signature — bytes win, worker final)", () => {
244 expect(sniffVerdict(png, "text/plain")).toBe("no-signal");
245 });
246
247 it("ZIP bytes claimed as application/zip → ok", () => {
248 expect(sniffVerdict(zip, "application/zip")).toBe("ok");
249 });
250
251 it("ZIP bytes claimed as image/png → mismatch", () => {
252 expect(sniffVerdict(zip, "image/png")).toBe("mismatch");
253 });
254
255 it("octet-stream claim → no-signal for anything (bytes win the argument)", () => {
256 expect(sniffVerdict(png, "application/octet-stream")).toBe("no-signal");
257 expect(sniffVerdict(zip, "application/octet-stream")).toBe("no-signal");
258 });
259
260 it("ftyp family aliases: qt bytes claimed video/mp4 → ok", () => {
261 const mov = new Uint8Array([0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20]);
262 expect(sniffVerdict(mov, "video/quicktime")).toBe("ok");
263 expect(sniffVerdict(mov, "video/mp4")).toBe("ok");
264 });
265
266 it("MP3 claimed as audio/mpeg → ok", () => {
267 expect(sniffVerdict(mp3, "audio/mpeg")).toBe("ok");
268 });
269});
270