Skip to main content

zeroclaw_api/
media.rs

1/// Classifies an attachment by MIME type or file extension.
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub enum MediaKind {
4    Audio,
5    Image,
6    Video,
7    Unknown,
8}
9
10/// A single media attachment on an inbound message.
11#[derive(Debug, Clone)]
12pub struct MediaAttachment {
13    /// Original file name (e.g. `voice.ogg`, `photo.jpg`).
14    pub file_name: String,
15    /// Raw bytes of the attachment.
16    pub data: Vec<u8>,
17    /// MIME type if known (e.g. `audio/ogg`, `image/jpeg`).
18    pub mime_type: Option<String>,
19}
20
21impl MediaAttachment {
22    /// Load an attachment from a file path on disk.
23    ///
24    /// # Caller path-validation contract
25    ///
26    /// This method reads the path supplied by the caller verbatim.  **Callers
27    /// are responsible for validating or constraining `path` before calling
28    /// this function when the path originates from untrusted input** (e.g. a
29    /// user message, an HTTP request body, or any external data source).  No
30    /// sandboxing or path canonicalization is performed here.
31    ///
32    /// Read errors are propagated as `Err` rather than silently producing an
33    /// empty attachment, so the caller can decide how to handle missing or
34    /// unreadable files.
35    pub fn from_file(path: &str) -> anyhow::Result<Self> {
36        let p = std::path::Path::new(path);
37        let data = std::fs::read(p)?;
38        let file_name = p
39            .file_name()
40            .and_then(|n| n.to_str())
41            .unwrap_or("attachment")
42            .to_string();
43        let mime_type = match p.extension().and_then(|e| e.to_str()) {
44            Some("pdf") => Some("application/pdf".to_string()),
45            Some("xlsx") => Some(
46                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".to_string(),
47            ),
48            Some("docx") => Some(
49                "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
50                    .to_string(),
51            ),
52            Some("csv") => Some("text/csv".to_string()),
53            Some("png") => Some("image/png".to_string()),
54            Some("jpg") | Some("jpeg") => Some("image/jpeg".to_string()),
55            Some("txt") => Some("text/plain".to_string()),
56            _ => Some("application/octet-stream".to_string()),
57        };
58        Ok(Self {
59            file_name,
60            data,
61            mime_type,
62        })
63    }
64
65    /// Classify this attachment into a [`MediaKind`].
66    pub fn kind(&self) -> MediaKind {
67        // Try MIME type first.
68        if let Some(ref mime) = self.mime_type {
69            let lower = mime.to_ascii_lowercase();
70            if lower.starts_with("audio/") {
71                return MediaKind::Audio;
72            }
73            if lower.starts_with("image/") {
74                return MediaKind::Image;
75            }
76            if lower.starts_with("video/") {
77                return MediaKind::Video;
78            }
79        }
80
81        // Fall back to file extension.
82        let ext = self
83            .file_name
84            .rsplit_once('.')
85            .map(|(_, e)| e.to_ascii_lowercase())
86            .unwrap_or_default();
87
88        match ext.as_str() {
89            "flac" | "mp3" | "mpeg" | "mpga" | "m4a" | "ogg" | "oga" | "opus" | "wav" | "webm" => {
90                MediaKind::Audio
91            }
92            "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "heic" | "tiff" | "svg" => {
93                MediaKind::Image
94            }
95            "mp4" | "mkv" | "avi" | "mov" | "wmv" | "flv" => MediaKind::Video,
96            _ => MediaKind::Unknown,
97        }
98    }
99}