Skip to main content

hya_core/
format.rs

1//! File-format classification from magic bytes, extension, and media type.
2//!
3//! # Why a retriever needs this
4//!
5//! A download manager sorts what it fetches, and the category decides real
6//! behaviour: which directory the file lands in, and whether the extension can be
7//! trusted. But it decides those things only if the classification is *right*, and
8//! the three available signals disagree constantly on the open web.
9//!
10//! The precedence here is deliberate and is the opposite of what is convenient:
11//!
12//! 1. **Magic bytes win.** They describe the bytes that actually arrived.
13//! 2. **Extension is a weak hint.** It is chosen by whoever named the file, is
14//!    absent from most API URLs, and is trivially wrong.
15//! 3. **`Content-Type` is the weakest signal of the three**, which surprises
16//!    people. Servers routinely serve every archive as
17//!    `application/octet-stream`, mislabel `.tar.gz` as `application/x-gzip` and
18//!    vice versa, and — the case that matters — a captive portal or error page
19//!    returns `text/html` with a 200 status, so a "download" completes and the
20//!    saved file is a login page. Trusting the header there produces a file the
21//!    user cannot open and cannot diagnose.
22//!
23//! When the signals conflict, [`Detection::conflict`] says so, and the CLI warns.
24//! An HTML body delivered where an archive was expected is worth a warning even
25//! though nothing failed: it is the signature of an interception, and the byte
26//! count and status code both look fine.
27//!
28//! # Sniffing is not decompression
29//!
30//! Classification reads a prefix. It never decompresses, never rewrites, and never
31//! renames without being asked. A retriever that silently unpacked its output
32//! would be making a decision the caller did not delegate.
33
34/// Broad category, in the sense a download manager sorts by.
35#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub enum Category {
37    Video,
38    Audio,
39    Image,
40    /// Archive or compressed stream.
41    Archive,
42    /// Document or e-book.
43    Document,
44    /// Executable, installer, or package.
45    Application,
46    /// Disk or filesystem image.
47    DiskImage,
48    Font,
49    /// Structured data, source, or plain text.
50    Data,
51    /// Web page or markup — usually NOT what a download was meant to be.
52    Markup,
53    Unknown,
54}
55
56impl Category {
57    /// Every variant, for exhaustive iteration.
58    ///
59    /// Kept next to the enum so adding a variant means updating this list in
60    /// the same screenful — the compiler cannot enforce it, but the tests that
61    /// iterate `ALL` will fail on a variant whose tables were forgotten.
62    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    /// Conventional subdirectory name, matching what download managers use.
77    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/// One recognised format.
111#[derive(Clone, Copy, PartialEq, Eq, Debug)]
112pub struct Format {
113    /// Short name, e.g. `"mp4"`.
114    pub name: &'static str,
115    pub category: Category,
116    /// Canonical media type.
117    pub media_type: &'static str,
118    /// Usual extension, without the dot.
119    pub extension: &'static str,
120}
121
122/// Where a classification came from, in descending trustworthiness.
123#[derive(Clone, Copy, PartialEq, Eq, Debug)]
124pub enum Evidence {
125    /// Magic bytes in the payload.
126    Magic,
127    /// The URL or filename extension.
128    Extension,
129    /// The server's `Content-Type`.
130    MediaType,
131    /// Nothing matched.
132    None,
133}
134
135/// The result of classifying an object.
136#[derive(Clone, Debug)]
137pub struct Detection {
138    pub format: Option<Format>,
139    pub category: Category,
140    pub evidence: Evidence,
141    /// A human-readable disagreement between signals, when there is one.
142    pub conflict: Option<String>,
143}
144
145impl Detection {
146    /// True when the bytes are markup but the name or media type promised
147    /// something else — the captive-portal and error-page signature.
148    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
167/// Magic-byte signature: bytes to match at an offset.
168struct 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
182/// Signatures, most specific first. Order matters: a Matroska file is a
183/// specialisation of EBML, and OOXML/ODF/APK/JAR are all ZIP containers, so the
184/// container check must come after any attempt to distinguish them.
185static SIGS: &[Sig] = &[
186    // ---- video ----------------------------------------------------------
187    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    // ---- audio ----------------------------------------------------------
199    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    // ---- image ----------------------------------------------------------
210    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    // ---- fonts (before generic containers) ------------------------------
219    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    // ---- archives and compressed streams -------------------------------
225    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    // ---- documents ------------------------------------------------------
237    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    // ---- applications, installers, packages -----------------------------
243    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    // ---- disk images ----------------------------------------------------
251    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    // ---- data and markup ------------------------------------------------
256    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    // ---- ZIP container LAST: OOXML, ODF, APK, JAR, EPUB all match it ----
266    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
270/// ZIP-container formats recognised by two independent signals: a member name
271/// in the payload (ZIP_KINDS) and the filename extension (BY_EXT). Defined once
272/// so the two tables cannot drift — a MIME type that differed between them
273/// would classify the same file differently depending on which evidence won.
274const 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
306/// ZIP-container specialisations, distinguished by a member name appearing in the
307/// first few hundred bytes of the central-directory-adjacent prefix.
308static 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
326/// Extension table, used when the payload is unavailable or unrecognised.
327static 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
539/// Classify by magic bytes alone.
540pub fn from_magic(buf: &[u8]) -> Option<Format> {
541    // RIFF containers carry their kind at offset 8.
542    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    // ISO-BMFF brands live at offset 4 after a size field; `ftyp` then a brand.
551    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            // A ZIP container may be something more specific.
568            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
592/// Classify by filename or URL path extension.
593pub fn from_extension(name: &str) -> Option<Format> {
594    let base = name.split(['?', '#']).next().unwrap_or(name);
595    let lower = base.to_ascii_lowercase();
596    // Compound extensions first: `.tar.gz` is a tar, not merely a gzip, and
597    // sorting it as an archive is right either way but the name should be exact.
598    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
629/// Classify by a `Content-Type` header value.
630pub 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        // The universal "I don't know" of HTTP. Treating it as a classification
634        // would overwrite better evidence with none.
635        return None;
636    }
637    if let Some(hit) = BY_EXT.iter().find(|(_, f)| f.media_type == base) {
638        return Some(hit.1);
639    }
640    // Fall back to the type's top-level category.
641    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
658/// Combine all three signals, with magic bytes taking precedence.
659///
660/// `prefix` may be empty (nothing fetched yet); `name` is the filename or URL;
661/// `content_type` is the server's header if it sent one.
662pub 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    // Conflicts are reported, not resolved silently. The one that matters is
676    // markup arriving where a real file was expected.
677    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
710// ---------------------------------------------------------------------------
711// Human-readable descriptions
712// ---------------------------------------------------------------------------
713
714/// One-line label and a short explanation, keyed by format name.
715///
716/// Keyed by NAME rather than carried as fields on `Format` on purpose: `mp4`
717/// appears in five table entries (four magic brands plus the extension), so a
718/// per-entry field would mean five copies of the same prose to keep in step. One
719/// row per format is the single source of truth, and a test asserts every format
720/// reachable from either table has one.
721///
722/// The text is aimed at a user deciding what to do with a file they just fetched,
723/// so it says what the thing is FOR and what will bite them — that a `.gz` holds
724/// exactly one stream, that re-saving a JPEG degrades it, that an HTML body where
725/// an archive was expected usually means a login wall — rather than restating the
726/// name in longer words.
727static 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
822/// Every known format, for building a help screen, a GUI tooltip table, or a
823/// file-type filter.
824///
825/// Exposed as data rather than as printed text so a GUI can render it however it
826/// likes and a CLI can dump it as JSON. Deduplicated by name, since `mp4` and
827/// friends appear in several signature entries.
828pub 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
856/// Extensions this build recognises, for a GUI open/save filter.
857pub 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
864/// Label and explanation for a format name.
865pub 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    /// Short human label, e.g. "gzip-compressed tar".
874    pub fn label(&self) -> &'static str {
875        describe(self.name)
876            .map(|(l, _)| l)
877            .unwrap_or("Unknown format")
878    }
879
880    /// One-sentence explanation of what the format is for.
881    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    /// `label — description`, for a tooltip or a CLI hint line.
888    pub fn hint(&self) -> String {
889        format!("{} — {}", self.label(), self.description())
890    }
891}
892
893impl Category {
894    /// What this category is, for a GUI group header or a CLI legend.
895    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        // A signature table nobody exercises is a table that silently rots.
925        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        // All three share the RIFF header and differ only at offset 8.
969        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        // ISO-BMFF: brand decides image vs video, both are `ftyp`.
982        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        // ustar lives at 257, not at 0.
1003        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        // CD001 sits at 32769, past any reasonable sniff prefix; the detector must
1012        // handle a buffer that reaches it and not panic on one that does not.
1013        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        // A plain zip stays a zip.
1042        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        // Query strings must not defeat it.
1067        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        // The universal "I don't know" must not overwrite better evidence.
1077        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        // The single most common real mislabelling: a gzip named .zip.
1098        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        // Same category, so no conflict is raised.
1102        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        // The captive-portal / error-page signature: status 200, plausible length,
1108        // and the saved "download" is a login page.
1109        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        // No payload: extension is next best.
1139        let d = detect_format(b"", "clip.mkv", None);
1140        assert_eq!(d.evidence, Evidence::Extension);
1141        assert_eq!(d.category, Category::Video);
1142        // No payload and no extension: the header is the last resort.
1143        let d = detect_format(b"", "stream", Some("audio/mpeg"));
1144        assert_eq!(d.evidence, Evidence::MediaType);
1145        assert_eq!(d.category, Category::Audio);
1146        // Nothing at all.
1147        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        // And on a buffer that is a strict prefix of a long signature.
1161        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    /// Every format reachable from either table must have a description.
1175    ///
1176    /// This is the mechanism that keeps the prose from rotting: adding a signature
1177    /// without a description fails the build rather than silently shipping a
1178    /// tooltip that says "Unknown format".
1179    #[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            // A description that merely repeats the label teaches nothing.
1218            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        // Every entry must be presentable: a GUI shows all of these.
1247        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        // It must span every category a user can be shown. `Unknown` is the
1252        // one exception by construction: it is the absence of a
1253        // classification, so no catalogue entry can carry it.
1254        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        // A Format built outside the tables must not panic when described.
1301        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        // The markup case must warn, since that is the one users get wrong.
1319        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        // The most common real confusion: why a .gz holds only one file.
1329        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}