1#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub enum Category {
37 Video,
38 Audio,
39 Image,
40 Archive,
42 Document,
44 Application,
46 DiskImage,
48 Font,
49 Data,
51 Markup,
53 Unknown,
54}
55
56impl Category {
57 pub const ALL: [Category; 11] = [
63 Category::Video,
64 Category::Audio,
65 Category::Image,
66 Category::Archive,
67 Category::Document,
68 Category::Application,
69 Category::DiskImage,
70 Category::Font,
71 Category::Data,
72 Category::Markup,
73 Category::Unknown,
74 ];
75
76 pub fn directory(self) -> &'static str {
78 match self {
79 Category::Video => "Video",
80 Category::Audio => "Music",
81 Category::Image => "Images",
82 Category::Archive => "Compressed",
83 Category::Document => "Documents",
84 Category::Application => "Programs",
85 Category::DiskImage => "Images/Disk",
86 Category::Font => "Fonts",
87 Category::Data => "Data",
88 Category::Markup => "Web",
89 Category::Unknown => "Other",
90 }
91 }
92
93 pub fn as_str(self) -> &'static str {
94 match self {
95 Category::Video => "video",
96 Category::Audio => "audio",
97 Category::Image => "image",
98 Category::Archive => "archive",
99 Category::Document => "document",
100 Category::Application => "application",
101 Category::DiskImage => "disk image",
102 Category::Font => "font",
103 Category::Data => "data",
104 Category::Markup => "markup",
105 Category::Unknown => "unknown",
106 }
107 }
108}
109
110#[derive(Clone, Copy, PartialEq, Eq, Debug)]
112pub struct Format {
113 pub name: &'static str,
115 pub category: Category,
116 pub media_type: &'static str,
118 pub extension: &'static str,
120}
121
122#[derive(Clone, Copy, PartialEq, Eq, Debug)]
124pub enum Evidence {
125 Magic,
127 Extension,
129 MediaType,
131 None,
133}
134
135#[derive(Clone, Debug)]
137pub struct Detection {
138 pub format: Option<Format>,
139 pub category: Category,
140 pub evidence: Evidence,
141 pub conflict: Option<String>,
143}
144
145impl Detection {
146 pub fn looks_intercepted(&self) -> bool {
149 self.category == Category::Markup && self.conflict.is_some()
150 }
151}
152
153const fn f(
154 name: &'static str,
155 category: Category,
156 media_type: &'static str,
157 extension: &'static str,
158) -> Format {
159 Format {
160 name,
161 category,
162 media_type,
163 extension,
164 }
165}
166
167struct Sig {
169 offset: usize,
170 magic: &'static [u8],
171 format: Format,
172}
173
174const fn sig(offset: usize, magic: &'static [u8], format: Format) -> Sig {
175 Sig {
176 offset,
177 magic,
178 format,
179 }
180}
181
182static SIGS: &[Sig] = &[
186 sig(4, b"ftypisom", f("mp4", Category::Video, "video/mp4", "mp4")),
188 sig(4, b"ftypmp42", f("mp4", Category::Video, "video/mp4", "mp4")),
189 sig(4, b"ftypM4V", f("m4v", Category::Video, "video/x-m4v", "m4v")),
190 sig(4, b"ftypavc1", f("mp4", Category::Video, "video/mp4", "mp4")),
191 sig(4, b"ftypdash", f("mp4", Category::Video, "video/mp4", "mp4")),
192 sig(4, b"ftypqt", f("mov", Category::Video, "video/quicktime", "mov")),
193 sig(0, b"\x1a\x45\xdf\xa3", f("matroska", Category::Video, "video/x-matroska", "mkv")),
194 sig(0, b"FLV\x01", f("flv", Category::Video, "video/x-flv", "flv")),
195 sig(0, b"\x00\x00\x01\xba", f("mpeg-ps", Category::Video, "video/mpeg", "mpg")),
196 sig(0, b"\x00\x00\x01\xb3", f("mpeg-vid", Category::Video, "video/mpeg", "mpv")),
197 sig(0, b"\x30\x26\xb2\x75", f("asf", Category::Video, "video/x-ms-asf", "wmv")),
198 sig(0, b"ID3", f("mp3", Category::Audio, "audio/mpeg", "mp3")),
200 sig(0, b"\xff\xfb", f("mp3", Category::Audio, "audio/mpeg", "mp3")),
201 sig(0, b"\xff\xf3", f("mp3", Category::Audio, "audio/mpeg", "mp3")),
202 sig(0, b"\xff\xf2", f("mp3", Category::Audio, "audio/mpeg", "mp3")),
203 sig(0, b"fLaC", f("flac", Category::Audio, "audio/flac", "flac")),
204 sig(4, b"ftypM4A", f("m4a", Category::Audio, "audio/mp4", "m4a")),
205 sig(0, b"OggS", f("ogg", Category::Audio, "audio/ogg", "ogg")),
206 sig(0, b"\xff\xf1", f("aac", Category::Audio, "audio/aac", "aac")),
207 sig(0, b"MThd", f("midi", Category::Audio, "audio/midi", "mid")),
208 sig(0, b"#!AMR", f("amr", Category::Audio, "audio/amr", "amr")),
209 sig(0, b"\x89PNG\r\n\x1a\n", f("png", Category::Image, "image/png", "png")),
211 sig(0, b"\xff\xd8\xff", f("jpeg", Category::Image, "image/jpeg", "jpg")),
212 sig(0, b"GIF89a", f("gif", Category::Image, "image/gif", "gif")),
213 sig(0, b"GIF87a", f("gif", Category::Image, "image/gif", "gif")),
214 sig(0, b"BM", f("bmp", Category::Image, "image/bmp", "bmp")),
215 sig(0, b"II*\x00", f("tiff", Category::Image, "image/tiff", "tif")),
216 sig(0, b"MM\x00*", f("tiff", Category::Image, "image/tiff", "tif")),
217 sig(0, b"\x00\x00\x01\x00", f("ico", Category::Image, "image/x-icon", "ico")),
218 sig(0, b"wOFF", f("woff", Category::Font, "font/woff", "woff")),
220 sig(0, b"wOF2", f("woff2", Category::Font, "font/woff2", "woff2")),
221 sig(0, b"\x00\x01\x00\x00\x00", f("truetype", Category::Font, "font/ttf", "ttf")),
222 sig(0, b"OTTO", f("opentype", Category::Font, "font/otf", "otf")),
223 sig(0, b"ttcf", f("ttc", Category::Font, "font/collection", "ttc")),
224 sig(0, b"\x1f\x8b", f("gzip", Category::Archive, "application/gzip", "gz")),
226 sig(0, b"BZh", f("bzip2", Category::Archive, "application/x-bzip2", "bz2")),
227 sig(0, b"\xfd7zXZ\x00", f("xz", Category::Archive, "application/x-xz", "xz")),
228 sig(0, b"\x28\xb5\x2f\xfd", f("zstd", Category::Archive, "application/zstd", "zst")),
229 sig(0, b"\x04\x22\x4d\x18", f("lz4", Category::Archive, "application/x-lz4", "lz4")),
230 sig(0, b"Rar!\x1a\x07", f("rar", Category::Archive, "application/vnd.rar", "rar")),
231 sig(0, b"7z\xbc\xaf\x27\x1c", f("7z", Category::Archive, "application/x-7z-compressed", "7z")),
232 sig(257, b"ustar", f("tar", Category::Archive, "application/x-tar", "tar")),
233 sig(0, b"!<arch>", f("ar", Category::Archive, "application/x-archive", "a")),
234 sig(0, b"\x5d\x00\x00", f("lzma", Category::Archive, "application/x-lzma", "lzma")),
235 sig(0, b"\x1f\x9d", f("compress", Category::Archive, "application/x-compress", "Z")),
236 sig(0, b"%PDF-", f("pdf", Category::Document, "application/pdf", "pdf")),
238 sig(0, b"{\\rtf", f("rtf", Category::Document, "application/rtf", "rtf")),
239 sig(0, b"\xd0\xcf\x11\xe0", f("ole2", Category::Document, "application/x-ole-storage", "doc")),
240 sig(0, b"\x25\x21PS", f("postscript", Category::Document, "application/postscript", "ps")),
241 sig(0, b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x42\x44\x53\x46", f("djvu-ish", Category::Document, "image/vnd.djvu", "djvu")),
242 sig(0, b"MZ", f("pe", Category::Application, "application/vnd.microsoft.portable-executable", "exe")),
244 sig(0, b"\x7fELF", f("elf", Category::Application, "application/x-executable", "")),
245 sig(0, b"\xcf\xfa\xed\xfe", f("mach-o", Category::Application, "application/x-mach-binary", "")),
246 sig(0, b"\xca\xfe\xba\xbe", f("mach-o-fat", Category::Application, "application/x-mach-binary", "")),
247 sig(0, b"\xed\xab\xee\xdb", f("rpm", Category::Application, "application/x-rpm", "rpm")),
248 sig(0, b"!<arch>\ndebian", f("deb", Category::Application, "application/vnd.debian.binary-package", "deb")),
249 sig(0, b"\xde\xc0\x17\x0b", f("dmg-koly", Category::DiskImage, "application/x-apple-diskimage", "dmg")),
250 sig(32769, b"CD001", f("iso9660", Category::DiskImage, "application/x-iso9660-image", "iso")),
252 sig(0, b"conectix", f("vhd", Category::DiskImage, "application/x-vhd", "vhd")),
253 sig(0, b"QFI\xfb", f("qcow", Category::DiskImage, "application/x-qemu-disk", "qcow2")),
254 sig(0, b"KDMV", f("vmdk", Category::DiskImage, "application/x-vmdk", "vmdk")),
255 sig(0, b"SQLite format 3\x00", f("sqlite", Category::Data, "application/vnd.sqlite3", "sqlite")),
257 sig(0, b"PAR1", f("parquet", Category::Data, "application/vnd.apache.parquet", "parquet")),
258 sig(0, b"\x93NUMPY", f("npy", Category::Data, "application/x-npy", "npy")),
259 sig(0, b"\x89HDF\r\n\x1a\n", f("hdf5", Category::Data, "application/x-hdf5", "h5")),
260 sig(0, b"<?xml", f("xml", Category::Data, "application/xml", "xml")),
261 sig(0, b"<!DOCTYPE html", f("html", Category::Markup, "text/html", "html")),
262 sig(0, b"<!doctype html", f("html", Category::Markup, "text/html", "html")),
263 sig(0, b"<html", f("html", Category::Markup, "text/html", "html")),
264 sig(0, b"<HTML", f("html", Category::Markup, "text/html", "html")),
265 sig(0, b"PK\x03\x04", f("zip", Category::Archive, "application/zip", "zip")),
267 sig(0, b"PK\x05\x06", f("zip-empty", Category::Archive, "application/zip", "zip")),
268];
269
270const APK: Format = f(
275 "apk",
276 Category::Application,
277 "application/vnd.android.package-archive",
278 "apk",
279);
280const DOCX: Format = f(
281 "docx",
282 Category::Document,
283 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
284 "docx",
285);
286const XLSX: Format = f(
287 "xlsx",
288 Category::Document,
289 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
290 "xlsx",
291);
292const PPTX: Format = f(
293 "pptx",
294 Category::Document,
295 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
296 "pptx",
297);
298const EPUB: Format = f("epub", Category::Document, "application/epub+zip", "epub");
299const JAR: Format = f(
300 "jar",
301 Category::Application,
302 "application/java-archive",
303 "jar",
304);
305
306static ZIP_KINDS: &[(&[u8], Format)] = &[
309 (b"AndroidManifest.xml", APK),
310 (b"word/", DOCX),
311 (b"xl/", XLSX),
312 (b"ppt/", PPTX),
313 (b"mimetypeapplication/epub", EPUB),
314 (
315 b"mimetypeapplication/vnd.oasis.opendocument.text",
316 f(
317 "odt",
318 Category::Document,
319 "application/vnd.oasis.opendocument.text",
320 "odt",
321 ),
322 ),
323 (b"META-INF/MANIFEST.MF", JAR),
324];
325
326static BY_EXT: &[(&str, Format)] = &[
328 ("mp4", f("mp4", Category::Video, "video/mp4", "mp4")),
329 (
330 "mkv",
331 f("matroska", Category::Video, "video/x-matroska", "mkv"),
332 ),
333 ("avi", f("avi", Category::Video, "video/x-msvideo", "avi")),
334 ("webm", f("webm", Category::Video, "video/webm", "webm")),
335 ("mov", f("mov", Category::Video, "video/quicktime", "mov")),
336 ("flv", f("flv", Category::Video, "video/x-flv", "flv")),
337 ("wmv", f("asf", Category::Video, "video/x-ms-asf", "wmv")),
338 ("m4v", f("m4v", Category::Video, "video/x-m4v", "m4v")),
339 ("ts", f("mpeg-ts", Category::Video, "video/mp2t", "ts")),
340 ("mp3", f("mp3", Category::Audio, "audio/mpeg", "mp3")),
341 ("flac", f("flac", Category::Audio, "audio/flac", "flac")),
342 ("wav", f("wav", Category::Audio, "audio/wav", "wav")),
343 ("aac", f("aac", Category::Audio, "audio/aac", "aac")),
344 ("ogg", f("ogg", Category::Audio, "audio/ogg", "ogg")),
345 ("opus", f("opus", Category::Audio, "audio/opus", "opus")),
346 ("m4a", f("m4a", Category::Audio, "audio/mp4", "m4a")),
347 ("wma", f("wma", Category::Audio, "audio/x-ms-wma", "wma")),
348 ("mid", f("midi", Category::Audio, "audio/midi", "mid")),
349 ("png", f("png", Category::Image, "image/png", "png")),
350 ("jpg", f("jpeg", Category::Image, "image/jpeg", "jpg")),
351 ("jpeg", f("jpeg", Category::Image, "image/jpeg", "jpg")),
352 ("gif", f("gif", Category::Image, "image/gif", "gif")),
353 ("webp", f("webp", Category::Image, "image/webp", "webp")),
354 ("avif", f("avif", Category::Image, "image/avif", "avif")),
355 ("heic", f("heic", Category::Image, "image/heic", "heic")),
356 ("svg", f("svg", Category::Image, "image/svg+xml", "svg")),
357 ("tif", f("tiff", Category::Image, "image/tiff", "tif")),
358 ("tiff", f("tiff", Category::Image, "image/tiff", "tif")),
359 ("zip", f("zip", Category::Archive, "application/zip", "zip")),
360 ("gz", f("gzip", Category::Archive, "application/gzip", "gz")),
361 (
362 "tgz",
363 f("tar.gz", Category::Archive, "application/gzip", "tgz"),
364 ),
365 (
366 "bz2",
367 f("bzip2", Category::Archive, "application/x-bzip2", "bz2"),
368 ),
369 ("xz", f("xz", Category::Archive, "application/x-xz", "xz")),
370 (
371 "zst",
372 f("zstd", Category::Archive, "application/zstd", "zst"),
373 ),
374 (
375 "rar",
376 f("rar", Category::Archive, "application/vnd.rar", "rar"),
377 ),
378 (
379 "7z",
380 f("7z", Category::Archive, "application/x-7z-compressed", "7z"),
381 ),
382 (
383 "tar",
384 f("tar", Category::Archive, "application/x-tar", "tar"),
385 ),
386 (
387 "lz4",
388 f("lz4", Category::Archive, "application/x-lz4", "lz4"),
389 ),
390 (
391 "pdf",
392 f("pdf", Category::Document, "application/pdf", "pdf"),
393 ),
394 ("epub", EPUB),
395 ("docx", DOCX),
396 ("xlsx", XLSX),
397 ("pptx", PPTX),
398 (
399 "doc",
400 f("ole2", Category::Document, "application/msword", "doc"),
401 ),
402 (
403 "rtf",
404 f("rtf", Category::Document, "application/rtf", "rtf"),
405 ),
406 (
407 "djvu",
408 f("djvu", Category::Document, "image/vnd.djvu", "djvu"),
409 ),
410 (
411 "exe",
412 f(
413 "pe",
414 Category::Application,
415 "application/vnd.microsoft.portable-executable",
416 "exe",
417 ),
418 ),
419 (
420 "msi",
421 f("msi", Category::Application, "application/x-msi", "msi"),
422 ),
423 (
424 "dmg",
425 f(
426 "dmg",
427 Category::DiskImage,
428 "application/x-apple-diskimage",
429 "dmg",
430 ),
431 ),
432 (
433 "pkg",
434 f(
435 "pkg",
436 Category::Application,
437 "application/x-newton-compatible-pkg",
438 "pkg",
439 ),
440 ),
441 (
442 "deb",
443 f(
444 "deb",
445 Category::Application,
446 "application/vnd.debian.binary-package",
447 "deb",
448 ),
449 ),
450 (
451 "rpm",
452 f("rpm", Category::Application, "application/x-rpm", "rpm"),
453 ),
454 ("apk", APK),
455 (
456 "appimage",
457 f(
458 "appimage",
459 Category::Application,
460 "application/x-executable",
461 "AppImage",
462 ),
463 ),
464 ("jar", JAR),
465 (
466 "whl",
467 f("wheel", Category::Application, "application/zip", "whl"),
468 ),
469 (
470 "iso",
471 f(
472 "iso9660",
473 Category::DiskImage,
474 "application/x-iso9660-image",
475 "iso",
476 ),
477 ),
478 (
479 "img",
480 f(
481 "raw-image",
482 Category::DiskImage,
483 "application/octet-stream",
484 "img",
485 ),
486 ),
487 (
488 "qcow2",
489 f(
490 "qcow",
491 Category::DiskImage,
492 "application/x-qemu-disk",
493 "qcow2",
494 ),
495 ),
496 (
497 "vmdk",
498 f("vmdk", Category::DiskImage, "application/x-vmdk", "vmdk"),
499 ),
500 (
501 "vhd",
502 f("vhd", Category::DiskImage, "application/x-vhd", "vhd"),
503 ),
504 ("ttf", f("truetype", Category::Font, "font/ttf", "ttf")),
505 ("otf", f("opentype", Category::Font, "font/otf", "otf")),
506 ("woff", f("woff", Category::Font, "font/woff", "woff")),
507 ("woff2", f("woff2", Category::Font, "font/woff2", "woff2")),
508 (
509 "json",
510 f("json", Category::Data, "application/json", "json"),
511 ),
512 ("csv", f("csv", Category::Data, "text/csv", "csv")),
513 ("xml", f("xml", Category::Data, "application/xml", "xml")),
514 ("txt", f("text", Category::Data, "text/plain", "txt")),
515 (
516 "parquet",
517 f(
518 "parquet",
519 Category::Data,
520 "application/vnd.apache.parquet",
521 "parquet",
522 ),
523 ),
524 (
525 "sqlite",
526 f(
527 "sqlite",
528 Category::Data,
529 "application/vnd.sqlite3",
530 "sqlite",
531 ),
532 ),
533 ("h5", f("hdf5", Category::Data, "application/x-hdf5", "h5")),
534 ("npy", f("npy", Category::Data, "application/x-npy", "npy")),
535 ("html", f("html", Category::Markup, "text/html", "html")),
536 ("htm", f("html", Category::Markup, "text/html", "html")),
537];
538
539pub fn from_magic(buf: &[u8]) -> Option<Format> {
541 if buf.len() >= 12 && &buf[0..4] == b"RIFF" {
543 return match &buf[8..12] {
544 b"WAVE" => Some(f("wav", Category::Audio, "audio/wav", "wav")),
545 b"AVI " => Some(f("avi", Category::Video, "video/x-msvideo", "avi")),
546 b"WEBP" => Some(f("webp", Category::Image, "image/webp", "webp")),
547 _ => None,
548 };
549 }
550 if buf.len() >= 12 && &buf[4..8] == b"ftyp" {
552 let brand = &buf[8..12];
553 let hit = match brand {
554 b"avif" | b"avis" => Some(f("avif", Category::Image, "image/avif", "avif")),
555 b"heic" | b"heix" | b"hevc" => Some(f("heic", Category::Image, "image/heic", "heic")),
556 b"M4A " => Some(f("m4a", Category::Audio, "audio/mp4", "m4a")),
557 b"M4V " => Some(f("m4v", Category::Video, "video/x-m4v", "m4v")),
558 _ => None,
559 };
560 if hit.is_some() {
561 return hit;
562 }
563 }
564 for s in SIGS {
565 let end = s.offset + s.magic.len();
566 if buf.len() >= end && &buf[s.offset..end] == s.magic {
567 if s.format.name.starts_with("zip") {
569 if let Some(k) = zip_kind(buf) {
570 return Some(k);
571 }
572 }
573 return Some(s.format);
574 }
575 }
576 None
577}
578
579fn zip_kind(buf: &[u8]) -> Option<Format> {
580 let window = &buf[..buf.len().min(4096)];
581 for (needle, fmt) in ZIP_KINDS {
582 if window
583 .windows(needle.len())
584 .any(|w| w.eq_ignore_ascii_case(needle))
585 {
586 return Some(*fmt);
587 }
588 }
589 None
590}
591
592pub fn from_extension(name: &str) -> Option<Format> {
594 let base = name.split(['?', '#']).next().unwrap_or(name);
595 let lower = base.to_ascii_lowercase();
596 for (suffix, fmt) in [
599 (
600 ".tar.gz",
601 f("tar.gz", Category::Archive, "application/gzip", "tar.gz"),
602 ),
603 (
604 ".tar.bz2",
605 f(
606 "tar.bz2",
607 Category::Archive,
608 "application/x-bzip2",
609 "tar.bz2",
610 ),
611 ),
612 (
613 ".tar.xz",
614 f("tar.xz", Category::Archive, "application/x-xz", "tar.xz"),
615 ),
616 (
617 ".tar.zst",
618 f("tar.zst", Category::Archive, "application/zstd", "tar.zst"),
619 ),
620 ] {
621 if lower.ends_with(suffix) {
622 return Some(fmt);
623 }
624 }
625 let ext = lower.rsplit_once('.')?.1;
626 BY_EXT.iter().find(|(e, _)| *e == ext).map(|(_, fmt)| *fmt)
627}
628
629pub fn from_media_type(ct: &str) -> Option<Format> {
631 let base = ct.split(';').next()?.trim().to_ascii_lowercase();
632 if base.is_empty() || base == "application/octet-stream" {
633 return None;
636 }
637 if let Some(hit) = BY_EXT.iter().find(|(_, f)| f.media_type == base) {
638 return Some(hit.1);
639 }
640 let cat = match base.split('/').next()? {
642 "video" => Category::Video,
643 "audio" => Category::Audio,
644 "image" => Category::Image,
645 "font" => Category::Font,
646 "text" if base == "text/html" => Category::Markup,
647 "text" => Category::Data,
648 _ => return None,
649 };
650 Some(Format {
651 name: "generic",
652 category: cat,
653 media_type: "",
654 extension: "",
655 })
656}
657
658pub fn detect_format(prefix: &[u8], name: &str, content_type: Option<&str>) -> Detection {
663 let magic = from_magic(prefix);
664 let ext = from_extension(name);
665 let mt = content_type.and_then(from_media_type);
666
667 let (format, evidence) = match (magic, ext, mt) {
668 (Some(m), _, _) => (Some(m), Evidence::Magic),
669 (None, Some(e), _) => (Some(e), Evidence::Extension),
670 (None, None, Some(t)) => (Some(t), Evidence::MediaType),
671 (None, None, None) => (None, Evidence::None),
672 };
673 let category = format.map(|f| f.category).unwrap_or(Category::Unknown);
674
675 let mut conflict = None;
678 if let (Some(m), Some(e)) = (magic, ext) {
679 if m.category != e.category {
680 conflict = Some(format!(
681 "content is {} ({}) but the name says {} ({})",
682 m.name,
683 m.category.as_str(),
684 e.name,
685 e.category.as_str()
686 ));
687 }
688 }
689 if conflict.is_none() {
690 if let (Some(m), Some(t)) = (magic, mt) {
691 if m.category != t.category {
692 conflict = Some(format!(
693 "content is {} ({}) but the server said {} ({})",
694 m.name,
695 m.category.as_str(),
696 content_type.unwrap_or("?"),
697 t.category.as_str()
698 ));
699 }
700 }
701 }
702 Detection {
703 format,
704 category,
705 evidence,
706 conflict,
707 }
708}
709
710static DESCRIPTIONS: &[(&str, &str, &str)] = &[
728 ("7z", "7-Zip archive", "Open archive, usually LZMA2. Better ratios than ZIP; supports strong encryption."),
729 ("aac", "AAC audio", "Lossy audio, better quality than MP3 at the same bitrate. Standard for streaming."),
730 ("amr", "AMR speech audio", "Narrowband speech codec from mobile telephony. Poor for music, small for voice."),
731 ("apk", "Android package", "Android application. A ZIP with a manifest and compiled Dalvik bytecode."),
732 ("appimage", "AppImage application", "Self-contained Linux application: mark it executable and run it, no installation."),
733 ("ar", "ar archive", "Unix archive format. Holds static libraries, and is the outer wrapper of a .deb."),
734 ("asf", "Windows Media video", "Microsoft ASF container, usually WMV video. Needs a codec pack outside Windows."),
735 ("avi", "AVI video", "Microsoft's 1992 container. Widely readable but cannot carry modern features like proper subtitles."),
736 ("avif", "AVIF image", "AV1-based still image. Substantially smaller than JPEG at equal quality; newer decoder support."),
737 ("bmp", "Bitmap image", "Uncompressed Windows bitmap. Very large for its content."),
738 ("bzip2", "bzip2 stream", "Slower than gzip, compresses somewhat better. Largely displaced by xz and zstd."),
739 ("compress", "compress (.Z) stream", "Unix compress from the 1980s. Obsolete; kept for old archives."),
740 ("csv", "CSV table", "Delimited plain-text table. No types and no schema, so column meaning is a convention."),
741 ("deb", "Debian package", "Package for Debian, Ubuntu, and derivatives. Installed with apt or dpkg."),
742 ("djvu", "DjVu document", "Scanned-document format aimed at small sizes for text-heavy page images."),
743 ("dmg", "macOS disk image", "Apple disk image. Double-click to mount, then drag the application out; do not run it from inside the mounted image."),
744 ("djvu-ish", "DjVu-like document", "Scanned-document container matching a DjVu-family signature."),
745 ("dmg-koly", "macOS disk image", "Apple disk image. Double-click to mount, then drag the application out."),
746 ("docx", "Word document", "OOXML word processor file. A ZIP of XML parts."),
747 ("elf", "ELF executable", "Linux, BSD, or Unix binary or shared library. Architecture-specific."),
748 ("epub", "EPUB e-book", "Reflowable e-book (a ZIP of XHTML), so text adapts to the screen."),
749 ("flac", "FLAC audio", "Lossless compression, typically 50-60% of WAV size with no quality loss."),
750 ("flv", "Flash video", "Legacy container from the Flash era. Still produced by some streaming tools."),
751 ("generic", "Unclassified", "Recognised only by its media type; the specific format is unknown."),
752 ("gif", "GIF image", "256 colours, supports simple animation. Superseded by PNG for stills and video for animation."),
753 ("gzip", "gzip stream", "Compresses a SINGLE stream, so a .gz almost always wraps a .tar to hold more than one file."),
754 ("hdf5", "HDF5 dataset", "Hierarchical container for large scientific arrays, with internal compression."),
755 ("heic", "HEIC image", "HEIF/HEVC still image. What iPhones shoot by default; limited support outside Apple."),
756 ("html", "HTML page", "A web page. Where a real file was expected, this usually means a login wall, a captive portal, or an error page saved with a success status."),
757 ("ico", "Windows icon", "Container holding several small sizes of the same icon."),
758 ("iso9660", "ISO disk image", "Optical-disc image. Mount it, or write it to a USB stick to install an operating system."),
759 ("jar", "Java archive", "A ZIP of Java classes, run with java -jar."),
760 ("jpeg", "JPEG image", "Lossy photographic image. Re-saving degrades it each time; no transparency."),
761 ("json", "JSON data", "Structured text data. Human-readable, and the usual format for web APIs."),
762 ("lz4", "LZ4 stream", "Optimised for speed over ratio. Used where decompression time matters more than size."),
763 ("lzma", "LZMA stream", "The algorithm behind xz and 7z, in its bare stream form."),
764 ("m4a", "AAC audio (MP4)", "AAC in an MP4 container. What iTunes and most phones produce."),
765 ("m4v", "MPEG-4 video (Apple)", "MP4 with an Apple-specific brand; plays anywhere MP4 does."),
766 ("mach-o", "macOS executable", "Mach-O binary built for a single architecture, so it runs on either Apple silicon or Intel but not both."),
767 ("mach-o-fat", "macOS universal binary", "Mach-O holding several architectures (for example arm64 and x86_64) in one file."),
768 ("matroska", "Matroska video", "Open container that can hold almost any codec, plus multiple subtitle and audio tracks."),
769 ("midi", "MIDI sequence", "Not audio: performance instructions. What it sounds like depends on the synthesiser."),
770 ("mov", "QuickTime movie", "Apple's container. Often used for camera and editing masters, so files are large."),
771 ("mp3", "MP3 audio", "Lossy audio, universally playable. Quality depends on the bitrate it was encoded at."),
772 ("mp4", "MPEG-4 video", "The common web and device video container. Almost always H.264 or H.265 video with AAC audio."),
773 ("mpeg-ps", "MPEG program stream", "DVD-era container. Robust to truncation, which is why broadcast uses its transport-stream sibling."),
774 ("mpeg-ts", "MPEG transport stream", "Broadcast and HLS segment format. Designed to be joined and cut at any point."),
775 ("mpeg-vid", "MPEG elementary video", "Raw MPEG video with no container, so no audio and no timing metadata."),
776 ("msi", "Windows installer", "Windows Installer package, driven by msiexec."),
777 ("npy", "NumPy array", "A single NumPy array with its dtype and shape."),
778 ("odt", "OpenDocument text", "ODF word processor file, the ISO-standard alternative to .docx."),
779 ("ogg", "Ogg audio", "Open container, usually Vorbis or Opus. Royalty-free alternative to MP3/AAC."),
780 ("ole2", "Legacy Office document", "Pre-2007 Office binary (.doc/.xls/.ppt) or another OLE2 compound file."),
781 ("opentype", "OpenType font", "Outline font with advanced typography (ligatures, alternates, variable axes)."),
782 ("opus", "Opus audio", "Modern lossy codec, best-in-class at low bitrates. Used for voice and streaming."),
783 ("parquet", "Parquet dataset", "Columnar analytics format: compressed, typed, and fast to query by column."),
784 ("pdf", "PDF document", "Fixed-layout document that renders identically everywhere. May be text or scanned images."),
785 ("pe", "Windows executable", "Windows PE binary (.exe/.dll). Runs on Windows only."),
786 ("pkg", "macOS installer package", "macOS installer, opened by Installer.app."),
787 ("png", "PNG image", "Lossless, with transparency. Right for screenshots, diagrams, and line art."),
788 ("postscript", "PostScript document", "Page-description program for printers. PDF's predecessor."),
789 ("pptx", "PowerPoint presentation", "OOXML presentation, internally a ZIP of XML parts, so it opens outside PowerPoint too."),
790 ("qcow", "QEMU disk image", "QEMU/KVM virtual disk with copy-on-write and sparse allocation."),
791 ("rar", "RAR archive", "Proprietary archive with strong recovery-record and multi-volume support. Extraction needs unrar."),
792 ("raw-image", "Raw disk image", "Byte-for-byte copy of a disk or partition. Write with care: it overwrites a whole device."),
793 ("rpm", "RPM package", "Package for Fedora, RHEL, SUSE, and derivatives. Installed with dnf or rpm."),
794 ("rtf", "Rich Text Format", "Portable formatted text. Readable by nearly every word processor."),
795 ("sqlite", "SQLite database", "A complete relational database in one file."),
796 ("svg", "SVG vector image", "XML vector graphics: scales to any size without loss. Text, not pixels."),
797 ("tar", "tar archive", "Uncompressed container that preserves permissions, ownership, and symlinks. Usually paired with a compressor."),
798 ("tar.gz", "gzip-compressed tar", "The standard Unix source and release bundle: tar for structure, gzip for size."),
799 ("tar.xz", "xz-compressed tar", "tar with xz, for a smaller download at the cost of slower extraction."),
800 ("tar.zst", "Zstandard-compressed tar", "tar with zstd: near-xz size, far faster to extract. Arch Linux packages use it."),
801 ("text", "Plain text", "Unstructured text with no formatting and no declared encoding, so the character set is a guess."),
802 ("tiff", "TIFF image", "Flexible, often lossless. Standard for scanning, printing, and geospatial rasters."),
803 ("truetype", "TrueType font", "Outline font, installable on every mainstream operating system."),
804 ("ttc", "TrueType collection", "Several related fonts sharing outlines in one file."),
805 ("vhd", "Hyper-V disk image", "Microsoft virtual hard disk, attachable by Hyper-V and by Windows Disk Management."),
806 ("vmdk", "VMware disk image", "VMware virtual disk, also readable by VirtualBox and by qemu-img for conversion."),
807 ("wav", "WAV audio", "Uncompressed PCM. Large but exact; the usual interchange format for editing."),
808 ("webm", "WebM video", "Matroska restricted to royalty-free codecs (VP8/VP9/AV1 with Vorbis/Opus). What browsers play natively."),
809 ("webp", "WebP image", "Google's format, lossy or lossless, with transparency. Smaller than JPEG/PNG at similar quality."),
810 ("wheel", "Python wheel", "Built Python package (a ZIP), installed with pip."),
811 ("wma", "Windows Media audio", "Microsoft's lossy codec. Playable outside Windows only with extra codecs."),
812 ("woff", "WOFF web font", "Compressed font for the web, loaded by CSS @font-face."),
813 ("woff2", "WOFF2 web font", "Brotli-compressed web font, roughly 30% smaller than WOFF."),
814 ("xlsx", "Excel spreadsheet", "OOXML spreadsheet, internally a ZIP of XML parts; formulas are stored, not only their results."),
815 ("xml", "XML document", "Structured markup. Could be data, a configuration file, or a document."),
816 ("xz", "xz stream", "High compression ratio, slow to compress and memory-hungry to decompress."),
817 ("zip", "ZIP archive", "The general-purpose archive. Members are compressed individually, so one can be extracted without the rest."),
818 ("zip-empty", "Empty ZIP archive", "A structurally valid ZIP containing no members at all, which usually signals a failed build."),
819 ("zstd", "Zstandard stream", "Modern compressor: near-xz ratios at gzip-like speed. Increasingly the default."),
820];
821
822pub fn catalogue() -> Vec<(
829 &'static str,
830 Category,
831 &'static str,
832 &'static str,
833 &'static str,
834)> {
835 let mut out: Vec<(&str, Category, &str, &str, &str)> = Vec::new();
836 let mut push = |f: &Format| {
837 if out.iter().any(|(n, ..)| *n == f.name) {
838 return;
839 }
840 let (label, text) = describe(f.name).unwrap_or(("Unknown format", ""));
841 out.push((f.name, f.category, f.extension, label, text));
842 };
843 for s in SIGS {
844 push(&s.format);
845 }
846 for (_, f) in ZIP_KINDS {
847 push(f);
848 }
849 for (_, f) in BY_EXT {
850 push(f);
851 }
852 out.sort_by(|a, b| (a.1.as_str(), a.0).cmp(&(b.1.as_str(), b.0)));
853 out
854}
855
856pub fn known_extensions() -> Vec<&'static str> {
858 let mut v: Vec<&str> = BY_EXT.iter().map(|(e, _)| *e).collect();
859 v.sort_unstable();
860 v.dedup();
861 v
862}
863
864pub fn describe(name: &str) -> Option<(&'static str, &'static str)> {
866 DESCRIPTIONS
867 .iter()
868 .find(|(n, _, _)| *n == name)
869 .map(|(_, label, text)| (*label, *text))
870}
871
872impl Format {
873 pub fn label(&self) -> &'static str {
875 describe(self.name)
876 .map(|(l, _)| l)
877 .unwrap_or("Unknown format")
878 }
879
880 pub fn description(&self) -> &'static str {
882 describe(self.name)
883 .map(|(_, d)| d)
884 .unwrap_or("No description available for this format.")
885 }
886
887 pub fn hint(&self) -> String {
889 format!("{} — {}", self.label(), self.description())
890 }
891}
892
893impl Category {
894 pub fn description(self) -> &'static str {
896 match self {
897 Category::Video => "Moving pictures with sound. Container and codec are separate choices, so a file that will not play usually needs a codec rather than a different container.",
898 Category::Audio => "Sound only. Lossy formats discard detail permanently; lossless ones do not.",
899 Category::Image => "Still pictures. Lossy formats degrade on every re-save; lossless and vector ones do not.",
900 Category::Archive => "One or more files packed together, usually compressed. Extract before use.",
901 Category::Document => "Formatted text for reading or printing, either fixed-layout or reflowable.",
902 Category::Application => "Executable software or an installable package. Platform-specific, and worth verifying before running.",
903 Category::DiskImage => "A whole filesystem or disc in one file. Mount it rather than extracting it.",
904 Category::Font => "Typefaces for installing on a system or loading in a web page.",
905 Category::Data => "Structured or plain data for programs to read rather than for direct viewing.",
906 Category::Markup => "A web page. Where a real file was expected, this usually means a login wall, a captive portal, or an error page served with a success status.",
907 Category::Unknown => "Not recognised from its content, name, or media type.",
908 }
909 }
910}
911
912#[cfg(test)]
913mod tests {
914 use super::*;
915
916 fn pad(head: &[u8], n: usize) -> Vec<u8> {
917 let mut v = head.to_vec();
918 v.resize(n.max(head.len()), 0);
919 v
920 }
921
922 #[test]
923 fn every_magic_signature_is_recognised() {
924 let cases: &[(&[u8], &str, Category)] = &[
926 (b"\x89PNG\r\n\x1a\n", "png", Category::Image),
927 (b"\xff\xd8\xff\xe0", "jpeg", Category::Image),
928 (b"GIF89a", "gif", Category::Image),
929 (b"%PDF-1.7", "pdf", Category::Document),
930 (b"\x1f\x8b\x08\x00", "gzip", Category::Archive),
931 (b"BZh91AY", "bzip2", Category::Archive),
932 (b"\xfd7zXZ\x00\x00", "xz", Category::Archive),
933 (b"\x28\xb5\x2f\xfd\x00", "zstd", Category::Archive),
934 (b"Rar!\x1a\x07\x00", "rar", Category::Archive),
935 (b"7z\xbc\xaf\x27\x1c", "7z", Category::Archive),
936 (b"ID3\x03\x00", "mp3", Category::Audio),
937 (b"fLaC\x00\x00", "flac", Category::Audio),
938 (b"OggS\x00\x02", "ogg", Category::Audio),
939 (b"\x1a\x45\xdf\xa3\x01", "matroska", Category::Video),
940 (b"FLV\x01\x05", "flv", Category::Video),
941 (b"MZ\x90\x00", "pe", Category::Application),
942 (b"\x7fELF\x02\x01", "elf", Category::Application),
943 (b"\xed\xab\xee\xdb", "rpm", Category::Application),
944 (b"wOFF\x00\x01", "woff", Category::Font),
945 (b"wOF2\x00\x01", "woff2", Category::Font),
946 (b"OTTO\x00\x01", "opentype", Category::Font),
947 (b"SQLite format 3\x00", "sqlite", Category::Data),
948 (b"PAR1", "parquet", Category::Data),
949 (b"\x89HDF\r\n\x1a\n", "hdf5", Category::Data),
950 (b"\x93NUMPY\x01", "npy", Category::Data),
951 (b"<!DOCTYPE html><html>", "html", Category::Markup),
952 (b"<?xml version=\"1.0\"?>", "xml", Category::Data),
953 (b"\xd0\xcf\x11\xe0\xa1\xb1", "ole2", Category::Document),
954 (b"{\\rtf1\\ansi", "rtf", Category::Document),
955 (b"QFI\xfb\x00", "qcow", Category::DiskImage),
956 (b"KDMV\x01", "vmdk", Category::DiskImage),
957 (b"conectix\x00", "vhd", Category::DiskImage),
958 ];
959 for (bytes, name, cat) in cases {
960 let got = from_magic(bytes).unwrap_or_else(|| panic!("{name} not recognised"));
961 assert_eq!(got.name, *name, "wrong format for {name}");
962 assert_eq!(got.category, *cat, "wrong category for {name}");
963 }
964 }
965
966 #[test]
967 fn riff_and_isobmff_containers_are_disambiguated() {
968 assert_eq!(
970 from_magic(b"RIFF\x00\x00\x00\x00WAVEfmt ").unwrap().name,
971 "wav"
972 );
973 assert_eq!(
974 from_magic(b"RIFF\x00\x00\x00\x00AVI LIST").unwrap().name,
975 "avi"
976 );
977 assert_eq!(
978 from_magic(b"RIFF\x00\x00\x00\x00WEBPVP8 ").unwrap().name,
979 "webp"
980 );
981 assert_eq!(
983 from_magic(b"\x00\x00\x00\x18ftypavif").unwrap().category,
984 Category::Image
985 );
986 assert_eq!(
987 from_magic(b"\x00\x00\x00\x18ftypheic").unwrap().category,
988 Category::Image
989 );
990 assert_eq!(
991 from_magic(b"\x00\x00\x00\x18ftypisom").unwrap().category,
992 Category::Video
993 );
994 assert_eq!(
995 from_magic(b"\x00\x00\x00\x18ftypM4A ").unwrap().category,
996 Category::Audio
997 );
998 }
999
1000 #[test]
1001 fn tar_is_found_at_its_offset() {
1002 let mut v = pad(b"somefile.txt", 257);
1004 v.extend_from_slice(b"ustar\x0000");
1005 v.resize(1024, 0);
1006 assert_eq!(from_magic(&v).unwrap().name, "tar");
1007 }
1008
1009 #[test]
1010 fn iso9660_is_found_at_its_far_offset() {
1011 let mut v = vec![0u8; 32769];
1014 v.extend_from_slice(b"CD001\x01");
1015 assert_eq!(from_magic(&v).unwrap().name, "iso9660");
1016 assert!(
1017 from_magic(&vec![0u8; 4096]).is_none(),
1018 "a short buffer must not match"
1019 );
1020 }
1021
1022 #[test]
1023 fn zip_specialisations_beat_the_generic_container() {
1024 let mk = |member: &[u8]| {
1025 let mut v = b"PK\x03\x04\x14\x00\x00\x00\x08\x00".to_vec();
1026 v.extend_from_slice(member);
1027 v.resize(512, 0);
1028 v
1029 };
1030 assert_eq!(from_magic(&mk(b"AndroidManifest.xml")).unwrap().name, "apk");
1031 assert_eq!(from_magic(&mk(b"word/document.xml")).unwrap().name, "docx");
1032 assert_eq!(from_magic(&mk(b"xl/workbook.xml")).unwrap().name, "xlsx");
1033 assert_eq!(
1034 from_magic(&mk(b"ppt/presentation.xml")).unwrap().name,
1035 "pptx"
1036 );
1037 assert_eq!(
1038 from_magic(&mk(b"META-INF/MANIFEST.MF")).unwrap().name,
1039 "jar"
1040 );
1041 assert_eq!(from_magic(&mk(b"readme.txt")).unwrap().name, "zip");
1043 }
1044
1045 #[test]
1046 fn extensions_including_compound_ones_resolve() {
1047 assert_eq!(
1048 from_extension("movie.mp4").unwrap().category,
1049 Category::Video
1050 );
1051 assert_eq!(
1052 from_extension("song.FLAC").unwrap().category,
1053 Category::Audio,
1054 "case-insensitive"
1055 );
1056 assert_eq!(from_extension("pkg-1.2.tar.gz").unwrap().name, "tar.gz");
1057 assert_eq!(from_extension("pkg.tar.zst").unwrap().name, "tar.zst");
1058 assert_eq!(
1059 from_extension("app.AppImage").unwrap().category,
1060 Category::Application
1061 );
1062 assert_eq!(
1063 from_extension("disk.qcow2").unwrap().category,
1064 Category::DiskImage
1065 );
1066 assert_eq!(
1068 from_extension("file.zip?token=abc&x=1").unwrap().name,
1069 "zip"
1070 );
1071 assert_eq!(from_extension("no-extension-here"), None);
1072 }
1073
1074 #[test]
1075 fn octet_stream_is_not_a_classification() {
1076 assert!(from_media_type("application/octet-stream").is_none());
1078 assert!(from_media_type("").is_none());
1079 assert_eq!(
1080 from_media_type("video/mp4").unwrap().category,
1081 Category::Video
1082 );
1083 assert_eq!(
1084 from_media_type("text/html; charset=utf-8")
1085 .unwrap()
1086 .category,
1087 Category::Markup
1088 );
1089 assert_eq!(
1090 from_media_type("audio/ogg").unwrap().category,
1091 Category::Audio
1092 );
1093 }
1094
1095 #[test]
1096 fn magic_beats_a_lying_extension() {
1097 let d = detect_format(b"\x1f\x8b\x08\x00", "archive.zip", Some("application/zip"));
1099 assert_eq!(d.evidence, Evidence::Magic);
1100 assert_eq!(d.format.unwrap().name, "gzip");
1101 assert!(d.conflict.is_none(), "gzip and zip are both archives");
1103 }
1104
1105 #[test]
1106 fn an_html_body_where_an_archive_was_expected_is_flagged() {
1107 let d = detect_format(
1110 b"<!DOCTYPE html><html><head><title>Sign in</title>",
1111 "ubuntu-24.04.iso",
1112 Some("text/html"),
1113 );
1114 assert_eq!(d.category, Category::Markup);
1115 assert!(d.conflict.is_some(), "markup vs disk image must conflict");
1116 assert!(
1117 d.looks_intercepted(),
1118 "this is the case a user most needs told about"
1119 );
1120 let msg = d.conflict.unwrap();
1121 assert!(
1122 msg.contains("html") && msg.contains("iso"),
1123 "message must name both: {msg}"
1124 );
1125 }
1126
1127 #[test]
1128 fn a_correct_download_raises_no_conflict() {
1129 let d = detect_format(b"%PDF-1.7\n%\xc7\xec", "paper.pdf", Some("application/pdf"));
1130 assert!(d.conflict.is_none());
1131 assert!(!d.looks_intercepted());
1132 assert_eq!(d.category, Category::Document);
1133 assert_eq!(d.category.directory(), "Documents");
1134 }
1135
1136 #[test]
1137 fn falls_back_through_the_evidence_chain() {
1138 let d = detect_format(b"", "clip.mkv", None);
1140 assert_eq!(d.evidence, Evidence::Extension);
1141 assert_eq!(d.category, Category::Video);
1142 let d = detect_format(b"", "stream", Some("audio/mpeg"));
1144 assert_eq!(d.evidence, Evidence::MediaType);
1145 assert_eq!(d.category, Category::Audio);
1146 let d = detect_format(b"", "stream", None);
1148 assert_eq!(d.evidence, Evidence::None);
1149 assert_eq!(d.category, Category::Unknown);
1150 assert_eq!(d.category.directory(), "Other");
1151 }
1152
1153 #[test]
1154 fn detection_never_panics_on_short_or_empty_input() {
1155 for n in 0..24usize {
1156 let buf = vec![0x1fu8; n];
1157 let _ = from_magic(&buf);
1158 let _ = detect_format(&buf, "x", Some("application/octet-stream"));
1159 }
1160 let _ = from_magic(b"SQLite forma");
1162 let _ = from_magic(b"RIFF");
1163 let _ = from_magic(b"\x00\x00\x00\x18ftyp");
1164 }
1165
1166 #[test]
1167 fn every_category_has_a_directory_and_a_name() {
1168 for c in Category::ALL {
1169 assert!(!c.directory().is_empty(), "{c:?} has no directory");
1170 assert!(!c.as_str().is_empty(), "{c:?} has no name");
1171 }
1172 }
1173
1174 #[test]
1180 fn every_format_has_a_description() {
1181 let mut missing = Vec::new();
1182 for sig in SIGS {
1183 if describe(sig.format.name).is_none() {
1184 missing.push(sig.format.name);
1185 }
1186 }
1187 for (_, f) in ZIP_KINDS {
1188 if describe(f.name).is_none() {
1189 missing.push(f.name);
1190 }
1191 }
1192 for (_, f) in BY_EXT {
1193 if describe(f.name).is_none() {
1194 missing.push(f.name);
1195 }
1196 }
1197 missing.sort_unstable();
1198 missing.dedup();
1199 assert!(
1200 missing.is_empty(),
1201 "these formats have no description: {missing:?} — add them to DESCRIPTIONS"
1202 );
1203 }
1204
1205 #[test]
1206 fn descriptions_are_useful_prose_not_restatements() {
1207 for (name, label, text) in DESCRIPTIONS {
1208 assert!(!label.is_empty(), "{name} has an empty label");
1209 assert!(
1210 text.len() >= 40,
1211 "{name}: description is too short to be worth showing: {text:?}"
1212 );
1213 assert!(
1214 text.ends_with('.'),
1215 "{name}: description should read as a sentence: {text:?}"
1216 );
1217 assert_ne!(
1219 text.trim_end_matches('.').to_ascii_lowercase(),
1220 label.to_ascii_lowercase(),
1221 "{name}: description just restates the label"
1222 );
1223 }
1224 }
1225
1226 #[test]
1227 fn description_table_has_no_duplicate_keys() {
1228 let mut seen = std::collections::BTreeSet::new();
1229 for (name, _, _) in DESCRIPTIONS {
1230 assert!(seen.insert(*name), "duplicate description for {name}");
1231 }
1232 }
1233
1234 #[test]
1235 fn the_catalogue_covers_every_format_once() {
1236 let cat = catalogue();
1237 let mut names: Vec<&str> = cat.iter().map(|(n, ..)| *n).collect();
1238 let before = names.len();
1239 names.sort_unstable();
1240 names.dedup();
1241 assert_eq!(
1242 before,
1243 names.len(),
1244 "the catalogue must not repeat a format"
1245 );
1246 for (name, _, _, label, text) in &cat {
1248 assert!(!label.is_empty(), "{name} has no label");
1249 assert!(!text.is_empty(), "{name} has no description");
1250 }
1251 for c in Category::ALL
1255 .into_iter()
1256 .filter(|c| *c != Category::Unknown)
1257 {
1258 assert!(
1259 cat.iter().any(|(_, cc, ..)| *cc == c),
1260 "no format in the catalogue for {c:?}"
1261 );
1262 }
1263 assert!(
1264 cat.len() >= 60,
1265 "catalogue looks truncated: {} entries",
1266 cat.len()
1267 );
1268 }
1269
1270 #[test]
1271 fn known_extensions_are_sorted_unique_and_dotless() {
1272 let e = known_extensions();
1273 assert!(e.len() >= 60);
1274 let mut sorted = e.clone();
1275 sorted.sort_unstable();
1276 assert_eq!(
1277 e, sorted,
1278 "extensions must come out sorted for a stable UI list"
1279 );
1280 for x in &e {
1281 assert!(!x.starts_with('.'), "{x} should not carry a leading dot");
1282 assert_eq!(*x, x.to_ascii_lowercase(), "{x} should be lowercase");
1283 }
1284 }
1285
1286 #[test]
1287 fn hint_reads_as_one_line() {
1288 let f = from_extension("pkg-1.2.tar.gz").unwrap();
1289 let h = f.hint();
1290 assert!(h.starts_with("gzip-compressed tar"), "got {h}");
1291 assert!(
1292 h.contains(" — "),
1293 "label and description must be joined: {h}"
1294 );
1295 assert!(!h.contains('\n'), "a hint must fit on one line: {h}");
1296 }
1297
1298 #[test]
1299 fn an_unknown_format_name_degrades_gracefully() {
1300 let odd = Format {
1302 name: "not-a-real-format",
1303 category: Category::Unknown,
1304 media_type: "",
1305 extension: "",
1306 };
1307 assert_eq!(odd.label(), "Unknown format");
1308 assert!(odd.description().contains("No description"));
1309 }
1310
1311 #[test]
1312 fn every_category_has_a_description_that_says_what_to_do() {
1313 for c in Category::ALL {
1314 let d = c.description();
1315 assert!(d.len() >= 40, "{c:?} description too short: {d:?}");
1316 assert!(d.ends_with('.'), "{c:?} description is not a sentence");
1317 }
1318 assert!(
1320 Category::Markup.description().contains("login wall")
1321 || Category::Markup.description().contains("captive portal"),
1322 "the markup category must explain why an HTML body is suspicious"
1323 );
1324 }
1325
1326 #[test]
1327 fn the_gzip_description_names_the_single_stream_trap() {
1328 let d = describe("gzip").unwrap().1;
1330 assert!(
1331 d.contains("SINGLE") || d.contains("single"),
1332 "gzip's description should explain why .tar.gz exists: {d}"
1333 );
1334 }
1335}