Skip to main content

agentmux_srv/backend/attachments/
kind.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! What kind of file an attachment is, decided by content first and the
5//! name's extension second (a `.txt` that is really a ZIP is not text).
6//! docs/specs/SPEC_AGENT_PANE_FILE_ATTACHMENTS_2026_09_26.md ยง5.
7
8use std::io::Read;
9use std::path::Path;
10
11use image::ImageFormat;
12
13use super::process::{self, Sniffed};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FileKind {
17    /// Decodable image: the image pipeline (thumbnail + send-copy).
18    Image(ImageFormat),
19    Svg,
20    Text,
21    Pdf,
22    Word,
23    Excel,
24    PowerPoint,
25    Archive,
26    Audio,
27    Video,
28    /// An image format we can't decode (HEIC, AVIF): attached as a file.
29    ImageFile,
30    Other,
31}
32
33impl FileKind {
34    /// The `kind` string the frontend picks tiles by.
35    pub fn as_str(self) -> &'static str {
36        match self {
37            FileKind::Image(_) => "image",
38            FileKind::Svg => "svg",
39            FileKind::Text => "text",
40            FileKind::Pdf => "pdf",
41            FileKind::Word => "word",
42            FileKind::Excel => "excel",
43            FileKind::PowerPoint => "powerpoint",
44            FileKind::Archive => "archive",
45            FileKind::Audio => "audio",
46            FileKind::Video => "video",
47            FileKind::ImageFile => "image_file",
48            FileKind::Other => "other",
49        }
50    }
51}
52
53/// Extensions read as text when the content is also text.
54const TEXT_EXTS: &[&str] = &[
55    "txt",
56    "text",
57    "md",
58    "markdown",
59    "mdx",
60    "rst",
61    "adoc",
62    "csv",
63    "tsv",
64    "json",
65    "jsonl",
66    "ndjson",
67    "yaml",
68    "yml",
69    "toml",
70    "ini",
71    "cfg",
72    "conf",
73    "env",
74    "properties",
75    "xml",
76    "html",
77    "htm",
78    "css",
79    "scss",
80    "sass",
81    "less",
82    "log",
83    "rs",
84    "ts",
85    "tsx",
86    "js",
87    "jsx",
88    "mjs",
89    "cjs",
90    "py",
91    "pyi",
92    "go",
93    "java",
94    "kt",
95    "kts",
96    "scala",
97    "c",
98    "h",
99    "cc",
100    "cpp",
101    "cxx",
102    "hpp",
103    "hh",
104    "cs",
105    "fs",
106    "rb",
107    "php",
108    "swift",
109    "m",
110    "mm",
111    "dart",
112    "lua",
113    "pl",
114    "r",
115    "jl",
116    "ex",
117    "exs",
118    "erl",
119    "hs",
120    "clj",
121    "sh",
122    "bash",
123    "zsh",
124    "fish",
125    "ps1",
126    "psm1",
127    "bat",
128    "cmd",
129    "sql",
130    "graphql",
131    "gql",
132    "proto",
133    "vue",
134    "svelte",
135    "astro",
136    "tf",
137    "hcl",
138    "gradle",
139    "cmake",
140    "make",
141    "mk",
142    "dockerfile",
143    "gitignore",
144    "diff",
145    "patch",
146    "tex",
147    "bib",
148    "srt",
149    "vtt",
150    "ipynb",
151];
152const AUDIO_EXTS: &[&str] = &[
153    "mp3", "wav", "flac", "m4a", "aac", "ogg", "oga", "opus", "wma", "aiff", "aif",
154];
155const VIDEO_EXTS: &[&str] = &[
156    "mp4", "m4v", "mov", "webm", "mkv", "avi", "wmv", "flv", "mpg", "mpeg",
157];
158const ARCHIVE_EXTS: &[&str] = &["zip", "gz", "tgz", "tar", "7z", "rar", "bz2", "xz", "zst"];
159
160/// The file's own extension, lowercased, if it's a short plain one.
161pub fn extension_of(name: &str) -> Option<String> {
162    let ext = Path::new(name).extension()?.to_str()?.to_ascii_lowercase();
163    (!ext.is_empty() && ext.len() <= 10 && ext.bytes().all(|b| b.is_ascii_alphanumeric()))
164        .then_some(ext)
165}
166
167fn read_head(path: &Path, n: usize) -> std::io::Result<Vec<u8>> {
168    let mut f = std::fs::File::open(path)?;
169    let mut head = Vec::with_capacity(n);
170    (&mut f).take(n as u64).read_to_end(&mut head)?;
171    Ok(head)
172}
173
174/// Text if the start is valid UTF-8 (allowing a code point cut at the end)
175/// with no NUL bytes.
176fn looks_like_text(head: &[u8]) -> bool {
177    if head.contains(&0) {
178        return false;
179    }
180    match std::str::from_utf8(head) {
181        Ok(_) => true,
182        // Cut mid-character at the end of the sample is still text.
183        Err(e) => e.error_len().is_none() && e.valid_up_to() + 4 > head.len(),
184    }
185}
186
187/// Which OOXML/ODF document a ZIP holds, from its entry names or the ODF
188/// `mimetype` entry. `None` for an ordinary archive.
189fn office_kind_of_zip(path: &Path) -> Option<FileKind> {
190    let f = std::fs::File::open(path).ok()?;
191    let mut zip = zip::ZipArchive::new(f).ok()?;
192    if zip.len() > super::extract::MAX_ZIP_ENTRIES {
193        return None;
194    }
195    let names: Vec<String> = zip.file_names().map(str::to_string).collect();
196    let has = |n: &str| names.iter().any(|x| x == n);
197    if has("word/document.xml") {
198        return Some(FileKind::Word);
199    }
200    if has("xl/workbook.xml") || has("xl/workbook.bin") {
201        return Some(FileKind::Excel);
202    }
203    if has("ppt/presentation.xml") {
204        return Some(FileKind::PowerPoint);
205    }
206    if has("mimetype") {
207        let mut mt = String::new();
208        if let Ok(e) = zip.by_name("mimetype") {
209            let _ = e.take(200).read_to_string(&mut mt);
210        }
211        return match mt.trim() {
212            "application/vnd.oasis.opendocument.text" => Some(FileKind::Word),
213            "application/vnd.oasis.opendocument.spreadsheet" => Some(FileKind::Excel),
214            "application/vnd.oasis.opendocument.presentation" => Some(FileKind::PowerPoint),
215            _ => None,
216        };
217    }
218    None
219}
220
221/// Classify the file at `path`, whose display name is `name`.
222pub fn classify(path: &Path, name: &str) -> std::io::Result<FileKind> {
223    let head = read_head(path, 8192)?;
224    let ext = extension_of(name);
225    let ext = ext.as_deref();
226    Ok(classify_bytes(&head, ext, || office_kind_of_zip(path)))
227}
228
229/// [`classify`] on the first bytes, with the ZIP inspection injected (tests).
230pub fn classify_bytes(
231    head: &[u8],
232    ext: Option<&str>,
233    zip_kind: impl FnOnce() -> Option<FileKind>,
234) -> FileKind {
235    match process::sniff(head) {
236        Sniffed::Image(f) => return FileKind::Image(f),
237        Sniffed::Heic | Sniffed::Avif => return FileKind::ImageFile,
238        Sniffed::Svg => return FileKind::Svg,
239        Sniffed::NotImage => {}
240    }
241    if head.starts_with(b"%PDF-") {
242        return FileKind::Pdf;
243    }
244    if head.starts_with(b"PK\x03\x04") || head.starts_with(b"PK\x05\x06") {
245        return zip_kind().unwrap_or(FileKind::Archive);
246    }
247    // OLE2 compound file: legacy Office. The extension says which.
248    if head.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) {
249        return match ext {
250            Some("doc") | Some("dot") => FileKind::Word,
251            Some("xls") | Some("xlt") => FileKind::Excel,
252            Some("ppt") | Some("pps") | Some("pot") => FileKind::PowerPoint,
253            _ => FileKind::Other,
254        };
255    }
256    if head.starts_with(b"{\\rtf") {
257        return FileKind::Word;
258    }
259    if head.starts_with(&[0x1F, 0x8B])
260        || head.starts_with(b"7z\xBC\xAF\x27\x1C")
261        || head.starts_with(b"Rar!")
262        || head.starts_with(b"BZh")
263        || head.starts_with(&[0xFD, b'7', b'z', b'X', b'Z', 0x00])
264        || head.starts_with(&[0x28, 0xB5, 0x2F, 0xFD])
265        || (head.len() > 262 && &head[257..262] == b"ustar")
266    {
267        return FileKind::Archive;
268    }
269    if head.starts_with(b"ID3")
270        || head.starts_with(b"fLaC")
271        || head.starts_with(b"OggS")
272        || (head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"WAVE")
273    {
274        return FileKind::Audio;
275    }
276    if head.starts_with(&[0x1A, 0x45, 0xDF, 0xA3])
277        || (head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"AVI ")
278    {
279        return FileKind::Video;
280    }
281    if head.len() >= 12 && &head[4..8] == b"ftyp" {
282        // MP4-family container: audio or video by extension.
283        return if ext.is_some_and(|e| AUDIO_EXTS.contains(&e)) {
284            FileKind::Audio
285        } else {
286            FileKind::Video
287        };
288    }
289    let text = looks_like_text(head);
290    match ext {
291        Some(e) if text && (TEXT_EXTS.contains(&e)) => FileKind::Text,
292        Some(e) if AUDIO_EXTS.contains(&e) => FileKind::Audio,
293        Some(e) if VIDEO_EXTS.contains(&e) => FileKind::Video,
294        Some(e) if ARCHIVE_EXTS.contains(&e) => FileKind::Archive,
295        // An unknown or missing extension on text content is still text;
296        // a known-binary extension on text-looking bytes (rare) stays Other.
297        _ if text && !head.is_empty() => FileKind::Text,
298        _ => FileKind::Other,
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn kind(head: &[u8], ext: Option<&str>) -> FileKind {
307        classify_bytes(head, ext, || None)
308    }
309
310    #[test]
311    fn content_decides_before_the_name() {
312        assert_eq!(kind(b"%PDF-1.7\n...", Some("txt")), FileKind::Pdf);
313        assert_eq!(kind(b"PK\x03\x04rest", Some("txt")), FileKind::Archive);
314        assert_eq!(
315            classify_bytes(b"PK\x03\x04rest", Some("zip"), || Some(FileKind::Word)),
316            FileKind::Word
317        );
318        assert_eq!(
319            kind(&[0x89, b'P', b'N', b'G', 0, 0], Some("docx")),
320            FileKind::Image(ImageFormat::Png)
321        );
322        assert_eq!(
323            kind(b"\0\0\0\x18ftypheic\0\0\0\0", Some("jpg")),
324            FileKind::ImageFile
325        );
326        assert_eq!(kind(b"<svg xmlns='x'/>", Some("svg")), FileKind::Svg);
327    }
328
329    #[test]
330    fn text_needs_text_content() {
331        assert_eq!(kind(b"fn main() {}\n", Some("rs")), FileKind::Text);
332        assert_eq!(kind(b"a,b\n1,2\n", Some("csv")), FileKind::Text);
333        assert_eq!(kind(b"just words", None), FileKind::Text);
334        assert_eq!(
335            kind(b"MZ\x90\0\x03\0\0\0", Some("txt")),
336            FileKind::Other,
337            "an exe renamed .txt"
338        );
339        // An empty .txt is text; an empty file with no name hint is not.
340        assert_eq!(kind(b"", Some("txt")), FileKind::Text);
341        assert_eq!(kind(b"", None), FileKind::Other);
342    }
343
344    #[test]
345    fn an_office_extension_alone_never_makes_an_office_file() {
346        // A Windows executable and an ELF binary renamed as documents.
347        assert_eq!(
348            kind(b"MZ\x90\0\x03\0\0\0\x04\0", Some("doc")),
349            FileKind::Other
350        );
351        assert_eq!(kind(b"\x7fELF\x02\x01\x01\0", Some("xls")), FileKind::Other);
352        assert_eq!(kind(b"MZ\x90\0\x03\0\0\0", Some("rtf")), FileKind::Other);
353    }
354
355    #[test]
356    fn legacy_office_goes_by_extension() {
357        let ole = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0];
358        assert_eq!(kind(&ole, Some("doc")), FileKind::Word);
359        assert_eq!(kind(&ole, Some("xls")), FileKind::Excel);
360        assert_eq!(kind(&ole, Some("ppt")), FileKind::PowerPoint);
361        assert_eq!(kind(&ole, Some("msi")), FileKind::Other);
362        assert_eq!(kind(b"{\\rtf1\\ansi", Some("rtf")), FileKind::Word);
363    }
364
365    #[test]
366    fn media_and_archives() {
367        assert_eq!(kind(b"ID3\x04\0\0", Some("mp3")), FileKind::Audio);
368        assert_eq!(
369            kind(b"\0\0\0\x20ftypisom\0\0", Some("mp4")),
370            FileKind::Video
371        );
372        assert_eq!(
373            kind(b"\0\0\0\x20ftypM4A \0\0", Some("m4a")),
374            FileKind::Audio
375        );
376        assert_eq!(kind(&[0x1F, 0x8B, 8, 0], Some("gz")), FileKind::Archive);
377        assert_eq!(kind(b"7z\xBC\xAF\x27\x1C\0", None), FileKind::Archive);
378    }
379
380    #[test]
381    fn extension_is_sanitized() {
382        assert_eq!(extension_of("Report.DOCX").as_deref(), Some("docx"));
383        assert_eq!(extension_of("noext"), None);
384        assert_eq!(extension_of("weird.e x e"), None);
385    }
386}