Skip to main content

rpi_tools/
image.rs

1//! Mirrors `packages/agent/src/harness/tools/image.ts` — image MIME detection
2//! from byte signatures and base64 encoding.
3//!
4//! Detection is signature-based, NOT extension-based. The TS uses a manual
5//! base64 encoder with no trailing newline; the Rust port uses `base64`'s
6//! `STANDARD_NO_LINE_WRAP` engine (equivalent).
7
8use base64::{engine::general_purpose::STANDARD, Engine};
9
10/// Detect a supported image MIME type from a byte signature. Returns `None`
11/// for unrecognized data, the JPEG-XT variant, animated PNG, or invalid BMP.
12///
13/// Supported: JPEG, PNG (non-animated), GIF, WEBP, BMP.
14pub fn detect_supported_image_mime_type(bytes: &[u8]) -> Option<&'static str> {
15    if is_jpeg(bytes) {
16        return Some("image/jpeg");
17    }
18    if is_png(bytes) {
19        return Some("image/png");
20    }
21    if starts_with_ascii_at(bytes, 0, "GIF") && bytes.len() >= 6 {
22        // GIF87a / GIF89a — signature only.
23        return Some("image/gif");
24    }
25    if starts_with_ascii_at(bytes, 0, "RIFF") && starts_with_ascii_at(bytes, 8, "WEBP") {
26        return Some("image/webp");
27    }
28    if starts_with_ascii_at(bytes, 0, "BM") && is_bmp(bytes) {
29        return Some("image/bmp");
30    }
31    None
32}
33
34/// Encode bytes to standard base64 with no line wrapping. Mirrors `encodeBase64`
35/// (no trailing newline).
36pub fn encode_base64(bytes: &[u8]) -> String {
37    STANDARD.encode(bytes)
38}
39
40// ----------------------------------------------------------------------------
41// Signature helpers — all bounds-safe (OOB reads treat as 0 / false).
42// ----------------------------------------------------------------------------
43
44fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
45    if offset + 1 >= bytes.len() {
46        return 0;
47    }
48    u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
49}
50
51fn read_u32_be(bytes: &[u8], offset: usize) -> u32 {
52    if offset + 3 >= bytes.len() {
53        return 0;
54    }
55    u32::from_be_bytes([
56        bytes[offset],
57        bytes[offset + 1],
58        bytes[offset + 2],
59        bytes[offset + 3],
60    ])
61}
62
63fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
64    if offset + 3 >= bytes.len() {
65        return 0;
66    }
67    u32::from_le_bytes([
68        bytes[offset],
69        bytes[offset + 1],
70        bytes[offset + 2],
71        bytes[offset + 3],
72    ])
73}
74
75fn starts_with(bytes: &[u8], prefix: &[u8]) -> bool {
76    bytes.len() >= prefix.len() && &bytes[..prefix.len()] == prefix
77}
78
79fn starts_with_ascii_at(bytes: &[u8], offset: usize, ascii: &str) -> bool {
80    let pat = ascii.as_bytes();
81    if offset + pat.len() > bytes.len() {
82        return false;
83    }
84    &bytes[offset..offset + pat.len()] == pat
85}
86
87fn is_jpeg(bytes: &[u8]) -> bool {
88    // FF D8 FF, but NOT the JPEG-XT variant (FF D8 FF F7).
89    starts_with(bytes, &[0xFF, 0xD8, 0xFF]) && bytes.get(3).copied() != Some(0xF7)
90}
91
92const PNG_SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
93
94fn is_png(bytes: &[u8]) -> bool {
95    if !starts_with(bytes, &PNG_SIGNATURE) {
96        return false;
97    }
98    // First chunk must be IHDR of length 13.
99    bytes.len() >= 16
100        && read_u32_be(bytes, 8) == 13
101        && starts_with_ascii_at(bytes, 12, "IHDR")
102        && !is_animated_png(bytes)
103}
104
105/// Walk PNG chunks; return `true` if an `acTL` chunk appears before `IDAT`.
106/// Stops on overflow / zero-length-next to prevent infinite loops.
107fn is_animated_png(bytes: &[u8]) -> bool {
108    if bytes.len() < 8 {
109        return false;
110    }
111    let mut offset = 8usize;
112    loop {
113        // 4-byte length (BE) + 4-byte type + payload + 4-byte CRC.
114        if offset + 8 > bytes.len() {
115            return false;
116        }
117        let length = read_u32_be(bytes, offset) as usize;
118        let type_offset = offset + 4;
119        if type_offset + 4 > bytes.len() {
120            return false;
121        }
122        let chunk_type = &bytes[type_offset..type_offset + 4];
123        if chunk_type == b"acTL" {
124            return true;
125        }
126        if chunk_type == b"IDAT" {
127            return false;
128        }
129        // next chunk: length field (4) + type (4) + payload (length) + CRC (4)
130        let next = offset + 4 + 4 + length + 4;
131        if next <= offset {
132            // overflow / zero progress guard
133            return false;
134        }
135        offset = next;
136    }
137}
138
139fn is_bmp(bytes: &[u8]) -> bool {
140    if bytes.len() < 26 {
141        return false;
142    }
143    let declared_file_size = read_u32_le(bytes, 2);
144    let pixel_data_offset = read_u32_le(bytes, 10) as usize;
145    let dib_header_size = read_u32_le(bytes, 14) as usize;
146
147    if declared_file_size != 0 && declared_file_size < 26 {
148        return false;
149    }
150    if pixel_data_offset < 14 + dib_header_size {
151        return false;
152    }
153    if declared_file_size != 0 && pixel_data_offset >= declared_file_size as usize {
154        return false;
155    }
156
157    let (planes, bpp) = if dib_header_size == 12 {
158        // BITMAPCOREHEADER: planes @ 22, bpp @ 24 (both LE u16).
159        (read_u16_le(bytes, 22), read_u16_le(bytes, 24))
160    } else if (12..=124).contains(&dib_header_size) {
161        if bytes.len() < 30 {
162            return false;
163        }
164        (read_u16_le(bytes, 26), read_u16_le(bytes, 28))
165    } else {
166        return false;
167    };
168
169    planes == 1 && matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn detects_jpeg() {
178        let bytes = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x00];
179        assert_eq!(detect_supported_image_mime_type(&bytes), Some("image/jpeg"));
180    }
181
182    #[test]
183    fn rejects_jpeg_xt_variant() {
184        // FF D8 FF F7 → JPEG-XT, NOT supported.
185        let bytes = [0xFF, 0xD8, 0xFF, 0xF7, 0x00, 0x00];
186        assert_eq!(detect_supported_image_mime_type(&bytes), None);
187    }
188
189    #[test]
190    fn detects_png_static() {
191        // PNG signature + IHDR chunk (length 13) + 4 bytes of IHDR data + fake
192        // IDAT chunk to prove it's not animated.
193        let mut bytes = PNG_SIGNATURE.to_vec();
194        // length 13 (BE) + "IHDR" + 13 bytes payload + CRC(4)
195        bytes.extend_from_slice(&13u32.to_be_bytes());
196        bytes.extend_from_slice(b"IHDR");
197        bytes.extend_from_slice(&[0u8; 13]);
198        bytes.extend_from_slice(&[0u8; 4]); // CRC placeholder
199                                            // IDAT chunk (length 0) to terminate the acTL walk.
200        bytes.extend_from_slice(&0u32.to_be_bytes());
201        bytes.extend_from_slice(b"IDAT");
202        bytes.extend_from_slice(&[0u8; 4]); // CRC
203        assert_eq!(detect_supported_image_mime_type(&bytes), Some("image/png"));
204    }
205
206    #[test]
207    fn detects_gif_webp() {
208        let gif = b"GIF89a";
209        assert_eq!(detect_supported_image_mime_type(gif), Some("image/gif"));
210        let mut webp = b"RIFF????WEBP".to_vec();
211        // The ???? is 4 placeholder bytes for size — startswith just checks the
212        // ascii positions, so this is fine.
213        let _ = &mut webp;
214        assert_eq!(detect_supported_image_mime_type(&webp), Some("image/webp"));
215    }
216
217    #[test]
218    fn rejects_empty_and_unknown() {
219        assert_eq!(detect_supported_image_mime_type(&[]), None);
220        assert_eq!(detect_supported_image_mime_type(b"hello world"), None);
221    }
222
223    #[test]
224    fn base64_no_newline() {
225        let s = encode_base64(b"hello");
226        assert!(!s.contains('\n'));
227        // base64("hello") = "aGVsbG8="
228        assert_eq!(s, "aGVsbG8=");
229    }
230
231    #[test]
232    fn base64_round_trip_empty() {
233        assert_eq!(encode_base64(&[]), "");
234    }
235}