Skip to main content

entracte_lib/plugins/
asset.rs

1//! Plugin image assets (#156): a content plugin may ship images and reference
2//! them from routine steps, so a guided break can show what a stretch looks
3//! like rather than only describing it.
4//!
5//! Assets travel **inline** in the manifest as base64, exactly like a
6//! detector's `module_base64` — so a plugin is still one signed file. The
7//! signature binds each asset by the `sha256` declared alongside it (the
8//! `data_base64` blob is excluded from the signing payload; see
9//! [`super::signature::signing_payload`]), and [`validate_asset`] independently
10//! checks the bytes hash to that declared value. A tampered blob therefore
11//! fails either the signature (if the hash was changed) or the hash check (if
12//! only the bytes were swapped).
13//!
14//! Everything here is pure — no I/O — and the format/dimension sniffing reads
15//! only header fields (it never decodes pixels), so a hostile file cannot turn
16//! validation itself into a decompression bomb. The pixel-count cap then bounds
17//! what the overlay will later decode.
18
19use base64::prelude::{Engine, BASE64_STANDARD};
20use serde::{Deserialize, Serialize};
21
22use super::signature::sha256;
23
24/// Most images a pack ever needs; bounds the manifest and the install dialog.
25pub const MAX_ASSETS: usize = 64;
26/// Per-asset decoded-byte cap. Generous for a UI illustration, small enough
27/// that 64 of them stay well under the 8 MiB manifest cap.
28pub const MAX_ASSET_BYTES: usize = 512 * 1024;
29/// Decode-time pixel cap (width × height). The decompression-bomb guard: a tiny
30/// compressed file can claim enormous dimensions, so we reject on the declared
31/// header size before anything decodes it.
32pub const MAX_IMAGE_PIXELS: u64 = 4_000_000;
33/// Tighter byte cap for audio cues. A cue is a short sound, not a track; the
34/// size bounds the playback length without parsing every container's duration.
35pub const MAX_SOUND_BYTES: usize = 256 * 1024;
36const MAX_ASSET_ID_LEN: usize = 128;
37
38/// One inline image declared in a manifest. `data_base64` is excluded from the
39/// signing payload; the signature binds the image through `sha256`.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct ManifestAsset {
42    /// Pack-local identifier a routine step references. Filename-safe so it can
43    /// name the on-disk sidecar without traversal: `[a-z0-9._-]`.
44    pub id: String,
45    /// Lowercase hex SHA-256 of the decoded image bytes. Part of the signed
46    /// canonical manifest (only `data_base64` is stripped before signing).
47    pub sha256: String,
48    /// The image itself, base64 (standard alphabet). Stripped from the signing
49    /// payload — bound by `sha256` instead.
50    #[serde(default)]
51    pub data_base64: String,
52}
53
54/// The image formats a plugin may ship. The raster formats each have a header
55/// we can read dimensions from without decoding pixels; `Svg` is vector XML,
56/// vetted structurally instead (see [`validate_svg`]).
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ImageFormat {
59    Png,
60    Gif,
61    Webp,
62    Svg,
63}
64
65impl ImageFormat {
66    /// File extension used for the on-disk sidecar.
67    pub fn ext(self) -> &'static str {
68        match self {
69            ImageFormat::Png => "png",
70            ImageFormat::Gif => "gif",
71            ImageFormat::Webp => "webp",
72            ImageFormat::Svg => "svg",
73        }
74    }
75}
76
77/// The audio formats a plugin may ship for break/routine cues. Detected by
78/// container magic bytes; the byte cap bounds their length.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum AudioFormat {
81    Ogg,
82    Wav,
83    Mp3,
84}
85
86impl AudioFormat {
87    pub fn ext(self) -> &'static str {
88        match self {
89            AudioFormat::Ogg => "ogg",
90            AudioFormat::Wav => "wav",
91            AudioFormat::Mp3 => "mp3",
92        }
93    }
94}
95
96/// What a validated asset turned out to be. Routine images reference an
97/// `Image`; sound cues reference an `Audio`. The kind lets the manifest check
98/// that each reference points at the right sort of asset.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum AssetKind {
101    Image(ImageFormat),
102    Audio(AudioFormat),
103}
104
105impl AssetKind {
106    /// File extension for the on-disk sidecar.
107    pub fn ext(self) -> &'static str {
108        match self {
109            AssetKind::Image(f) => f.ext(),
110            AssetKind::Audio(f) => f.ext(),
111        }
112    }
113
114    pub fn is_audio(self) -> bool {
115        matches!(self, AssetKind::Audio(_))
116    }
117}
118
119fn u16le(b: &[u8]) -> u64 {
120    b[0] as u64 | (b[1] as u64) << 8
121}
122
123fn u24le(b: &[u8]) -> u64 {
124    b[0] as u64 | (b[1] as u64) << 8 | (b[2] as u64) << 16
125}
126
127fn u32le(b: &[u8]) -> u64 {
128    b[0] as u64 | (b[1] as u64) << 8 | (b[2] as u64) << 16 | (b[3] as u64) << 24
129}
130
131/// Identify an image's format and pixel dimensions from its header alone.
132/// Returns `None` for anything not in the allowlist or with a header too short
133/// or malformed to read. Never decodes pixel data.
134pub fn sniff(bytes: &[u8]) -> Option<(ImageFormat, u64, u64)> {
135    if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
136        // IHDR is the first chunk: width/height are big-endian u32 at 16..24.
137        let w = u32be(bytes.get(16..20)?);
138        let h = u32be(bytes.get(20..24)?);
139        return Some((ImageFormat::Png, w, h));
140    }
141    if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
142        // Logical-screen width/height are little-endian u16 at 6..10.
143        let w = u16le(bytes.get(6..8)?);
144        let h = u16le(bytes.get(8..10)?);
145        return Some((ImageFormat::Gif, w, h));
146    }
147    if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
148        return sniff_webp(bytes);
149    }
150    None
151}
152
153fn u32be(b: &[u8]) -> u64 {
154    (b[0] as u64) << 24 | (b[1] as u64) << 16 | (b[2] as u64) << 8 | b[3] as u64
155}
156
157/// Identify an audio container from its magic bytes. WAV shares the `RIFF`
158/// header with WebP — the `WAVE` form type at 8..12 disambiguates.
159pub fn sniff_audio(bytes: &[u8]) -> Option<AudioFormat> {
160    if bytes.starts_with(b"OggS") {
161        return Some(AudioFormat::Ogg);
162    }
163    if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") {
164        return Some(AudioFormat::Wav);
165    }
166    // MP3: an ID3v2 tag, or a bare MPEG frame sync (11 set bits: FF Ex/Fx).
167    if bytes.starts_with(b"ID3") {
168        return Some(AudioFormat::Mp3);
169    }
170    if let Some(&[b0, b1]) = bytes.get(0..2).and_then(|s| <&[u8; 2]>::try_from(s).ok()) {
171        if b0 == 0xff && (b1 & 0xe0) == 0xe0 {
172            return Some(AudioFormat::Mp3);
173        }
174    }
175    None
176}
177
178/// Dimensions from the three WebP chunk layouts (extended, lossless, lossy).
179fn sniff_webp(bytes: &[u8]) -> Option<(ImageFormat, u64, u64)> {
180    match bytes.get(12..16)? {
181        b"VP8X" => {
182            // Canvas size is two 24-bit little-endian values, each minus one.
183            let w = u24le(bytes.get(24..27)?) + 1;
184            let h = u24le(bytes.get(27..30)?) + 1;
185            Some((ImageFormat::Webp, w, h))
186        }
187        b"VP8L" => {
188            // 0x2f signature, then 14-bit width-1 and 14-bit height-1 packed
189            // into a little-endian u32.
190            if bytes.get(20) != Some(&0x2f) {
191                return None;
192            }
193            let v = u32le(bytes.get(21..25)?);
194            let w = (v & 0x3fff) + 1;
195            let h = ((v >> 14) & 0x3fff) + 1;
196            Some((ImageFormat::Webp, w, h))
197        }
198        b"VP8 " => {
199            // Lossy keyframe: 14-bit width/height little-endian at 26..30,
200            // after the 3-byte start code.
201            let w = u16le(bytes.get(26..28)?) & 0x3fff;
202            let h = u16le(bytes.get(28..30)?) & 0x3fff;
203            Some((ImageFormat::Webp, w, h))
204        }
205        _ => None,
206    }
207}
208
209/// `true` if `id` is a safe single filename component: non-empty, within the
210/// length cap, and only `[a-z0-9._-]` (no path separators, no traversal).
211fn is_safe_asset_id(id: &str) -> bool {
212    !id.is_empty()
213        && id.len() <= MAX_ASSET_ID_LEN
214        && id.bytes().all(|b| {
215            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
216        })
217}
218
219/// Recognise and structurally vet an SVG document.
220///
221/// `None` means the bytes are not an SVG at all, so validation falls through to
222/// the audio sniffer / unsupported-format error. `Some(Ok)` is a safe SVG;
223/// `Some(Err(reason))` is an SVG carrying a dangerous construct.
224///
225/// The overlay renders assets with `<img src=asset:…>` under a CSP of
226/// `script-src 'self'; object-src 'none'`, so scripts, `onload`, and external
227/// fetches inside an SVG never execute — these checks are defense-in-depth, plus
228/// the one guard the `<img>` sandbox does *not* give: rejecting a DTD blocks XML
229/// entity-expansion ("billion laughs") and external-entity (XXE) attacks, which
230/// happen at parse time. There is no pixel cap (vector); the decoded-byte cap
231/// bounds the source, and with no entities it cannot expand.
232///
233/// The `<script>`/handler/external-ref checks below are coarse string/scanner
234/// filters, **not** a general SVG sanitizer: a determined document can evade
235/// them (whitespace around `=`, encoded characters, unquoted attributes). That
236/// is harmless *only* because the render context is `<img>` + CSP, where the
237/// evaded constructs are inert. Revisit them before rendering a plugin SVG any
238/// other way (inline `<svg>`, `<object>`, a rasterizer, top-level navigation) —
239/// in those contexts they would not be sufficient on their own.
240fn validate_svg(bytes: &[u8]) -> Option<Result<(), String>> {
241    let text = std::str::from_utf8(bytes).ok()?;
242    let head = text.trim_start_matches('\u{feff}').trim_start();
243    if !(head.starts_with("<svg") || head.starts_with("<?xml") || head.starts_with("<!--")) {
244        return None;
245    }
246    if !text.contains("<svg") {
247        return None;
248    }
249    let lower = text.to_ascii_lowercase();
250    if lower.contains("<!doctype") || lower.contains("<!entity") {
251        return Some(Err(
252            "must not contain a DOCTYPE or ENTITY declaration".into()
253        ));
254    }
255    if lower.contains("<script") || lower.contains("<foreignobject") {
256        return Some(Err("must not contain <script> or <foreignObject>".into()));
257    }
258    if lower.contains("javascript:") {
259        return Some(Err("must not contain javascript: URIs".into()));
260    }
261    if has_event_handler(&lower) {
262        return Some(Err("must not contain on* event-handler attributes".into()));
263    }
264    if has_external_ref(&lower) {
265        return Some(Err(
266            "must not reference external (http/https) resources".into()
267        ));
268    }
269    Some(Ok(()))
270}
271
272/// `true` if the text holds an inline `on…="` event-handler attribute (a space
273/// or tab/newline, then `on`, then letters, then `=`). No benign SVG attribute
274/// begins with `on`, so the heuristic has no real false positives. Namespace
275/// declarations (`xmlns`) and href/src values are untouched. Coarse by design —
276/// see [`validate_svg`] for why that is sufficient in the `<img>` render context.
277fn has_event_handler(lower: &str) -> bool {
278    let b = lower.as_bytes();
279    let is_sep = |c: u8| matches!(c, b' ' | b'\t' | b'\n' | b'\r');
280    let mut i = 0;
281    while i + 3 < b.len() {
282        if is_sep(b[i]) && b[i + 1] == b'o' && b[i + 2] == b'n' {
283            let mut j = i + 3;
284            while j < b.len() && b[j].is_ascii_alphabetic() {
285                j += 1;
286            }
287            let mut k = j;
288            while k < b.len() && is_sep(b[k]) {
289                k += 1;
290            }
291            if j > i + 3 && k < b.len() && b[k] == b'=' {
292                return true;
293            }
294        }
295        i += 1;
296    }
297    false
298}
299
300/// `true` if a fetching attribute/function (`href`, `src`, CSS `url(...)`,
301/// `@import`) points at a remote scheme (`http`, `https`, or protocol-relative
302/// `//`). Deliberately does *not* match `xmlns="http://…"` namespace URLs (they
303/// carry no `href`/`src`/`url(`), nor self-contained `data:` URIs. Coarse by
304/// design — see [`validate_svg`] for why that is sufficient in the `<img>`
305/// render context.
306fn has_external_ref(lower: &str) -> bool {
307    const PAT: &[&str] = &[
308        "href=\"http",
309        "href='http",
310        "href=\"//",
311        "href='//",
312        "src=\"http",
313        "src='http",
314        "src=\"//",
315        "src='//",
316        "url(http",
317        "url(\"http",
318        "url('http",
319        "url(//",
320        "url(\"//",
321        "url('//",
322        "@import",
323    ];
324    PAT.iter().any(|p| lower.contains(p))
325}
326
327/// Decode and fully validate one declared asset, returning its decoded bytes
328/// and sniffed format on success. Checks, first-error-wins: a filename-safe id,
329/// a 64-char lowercase-hex sha256, valid base64, the decoded-byte cap, that the
330/// bytes hash to the declared sha256, an allowed format, and the pixel cap.
331pub fn validate_asset(asset: &ManifestAsset) -> Result<(Vec<u8>, AssetKind), String> {
332    if !is_safe_asset_id(&asset.id) {
333        return Err(format!(
334            "asset id '{}' must be 1..={MAX_ASSET_ID_LEN} chars of [a-z0-9._-]",
335            asset.id
336        ));
337    }
338    if asset.sha256.len() != 64
339        || !asset
340            .sha256
341            .bytes()
342            .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
343    {
344        return Err(format!(
345            "asset '{}' sha256 must be 64 lowercase hex characters",
346            asset.id
347        ));
348    }
349    let bytes = BASE64_STANDARD
350        .decode(asset.data_base64.as_bytes())
351        .map_err(|_| format!("asset '{}' data is not valid base64", asset.id))?;
352    if bytes.is_empty() {
353        return Err(format!("asset '{}' is empty", asset.id));
354    }
355    if bytes.len() > MAX_ASSET_BYTES {
356        return Err(format!(
357            "asset '{}' is {} bytes, over the {MAX_ASSET_BYTES}-byte cap",
358            asset.id,
359            bytes.len()
360        ));
361    }
362    let actual = hex_lower(&sha256(&bytes));
363    if actual != asset.sha256 {
364        return Err(format!(
365            "asset '{}' bytes do not match its declared sha256",
366            asset.id
367        ));
368    }
369    if let Some((format, w, h)) = sniff(&bytes) {
370        if w == 0 || h == 0 || w.saturating_mul(h) > MAX_IMAGE_PIXELS {
371            return Err(format!(
372                "asset '{}' is {w}x{h}, over the {MAX_IMAGE_PIXELS}-pixel cap",
373                asset.id
374            ));
375        }
376        return Ok((bytes, AssetKind::Image(format)));
377    }
378    match validate_svg(&bytes) {
379        Some(Ok(())) => return Ok((bytes, AssetKind::Image(ImageFormat::Svg))),
380        Some(Err(reason)) => return Err(format!("asset '{}' {reason}", asset.id)),
381        None => {}
382    }
383    if let Some(format) = sniff_audio(&bytes) {
384        if bytes.len() > MAX_SOUND_BYTES {
385            return Err(format!(
386                "sound '{}' is {} bytes, over the {MAX_SOUND_BYTES}-byte cap",
387                asset.id,
388                bytes.len()
389            ));
390        }
391        return Ok((bytes, AssetKind::Audio(format)));
392    }
393    Err(format!(
394        "asset '{}' is not a supported image (png/gif/webp/svg) or sound (ogg/wav/mp3)",
395        asset.id
396    ))
397}
398
399fn hex_lower(bytes: &[u8]) -> String {
400    let mut s = String::with_capacity(bytes.len() * 2);
401    for b in bytes {
402        s.push_str(&format!("{b:02x}"));
403    }
404    s
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn png(w: u32, h: u32) -> Vec<u8> {
412        let mut v = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
413        v.extend_from_slice(&[0, 0, 0, 13]); // IHDR length
414        v.extend_from_slice(b"IHDR");
415        v.extend_from_slice(&w.to_be_bytes());
416        v.extend_from_slice(&h.to_be_bytes());
417        v.extend_from_slice(&[8, 6, 0, 0, 0]); // bit depth, color type, etc.
418        v
419    }
420
421    fn gif(w: u16, h: u16) -> Vec<u8> {
422        let mut v = b"GIF89a".to_vec();
423        v.extend_from_slice(&w.to_le_bytes());
424        v.extend_from_slice(&h.to_le_bytes());
425        v
426    }
427
428    fn webp_vp8x(w: u32, h: u32) -> Vec<u8> {
429        let mut v = b"RIFF".to_vec();
430        v.extend_from_slice(&[0, 0, 0, 0]); // file size (ignored)
431        v.extend_from_slice(b"WEBP");
432        v.extend_from_slice(b"VP8X");
433        v.extend_from_slice(&[0, 0, 0, 0]); // chunk size
434        v.extend_from_slice(&[0, 0, 0, 0]); // flags + reserved (offset 20..24)
435        let wm = w - 1;
436        let hm = h - 1;
437        v.extend_from_slice(&wm.to_le_bytes()[..3]); // 24-bit width-1 at 24..27
438        v.extend_from_slice(&hm.to_le_bytes()[..3]); // 24-bit height-1 at 27..30
439        v
440    }
441
442    fn signed(id: &str, bytes: &[u8]) -> ManifestAsset {
443        ManifestAsset {
444            id: id.to_string(),
445            sha256: hex_lower(&sha256(bytes)),
446            data_base64: BASE64_STANDARD.encode(bytes),
447        }
448    }
449
450    fn webp_vp8l(w: u32, h: u32) -> Vec<u8> {
451        let mut v = b"RIFF".to_vec();
452        v.extend_from_slice(&[0, 0, 0, 0]);
453        v.extend_from_slice(b"WEBP");
454        v.extend_from_slice(b"VP8L");
455        v.extend_from_slice(&[0, 0, 0, 0]); // chunk size (16..20)
456        v.push(0x2f); // signature byte at offset 20
457                      // 14-bit width-1 then 14-bit height-1, packed little-endian (21..25).
458        let packed: u32 = (w - 1) | ((h - 1) << 14);
459        v.extend_from_slice(&packed.to_le_bytes());
460        v
461    }
462
463    fn webp_vp8(w: u16, h: u16) -> Vec<u8> {
464        let mut v = b"RIFF".to_vec();
465        v.extend_from_slice(&[0, 0, 0, 0]);
466        v.extend_from_slice(b"WEBP");
467        v.extend_from_slice(b"VP8 ");
468        v.extend_from_slice(&[0; 10]); // chunk size + frame tag + start code (16..26)
469        v.extend_from_slice(&w.to_le_bytes()); // 26..28
470        v.extend_from_slice(&h.to_le_bytes()); // 28..30
471        v
472    }
473
474    #[test]
475    fn sniffs_png_gif_webp_dimensions() {
476        assert_eq!(sniff(&png(100, 50)), Some((ImageFormat::Png, 100, 50)));
477        assert_eq!(sniff(&gif(64, 48)), Some((ImageFormat::Gif, 64, 48)));
478        assert_eq!(
479            sniff(&webp_vp8x(300, 200)),
480            Some((ImageFormat::Webp, 300, 200))
481        );
482        assert_eq!(
483            sniff(&webp_vp8l(120, 90)),
484            Some((ImageFormat::Webp, 120, 90))
485        );
486        assert_eq!(sniff(&webp_vp8(64, 32)), Some((ImageFormat::Webp, 64, 32)));
487    }
488
489    #[test]
490    fn webp_vp8l_without_signature_byte_is_rejected() {
491        let mut v = webp_vp8l(10, 10);
492        v[20] = 0x00; // corrupt the 0x2f signature
493        assert_eq!(sniff(&v), None);
494    }
495
496    #[test]
497    fn image_format_extensions() {
498        assert_eq!(ImageFormat::Png.ext(), "png");
499        assert_eq!(ImageFormat::Gif.ext(), "gif");
500        assert_eq!(ImageFormat::Webp.ext(), "webp");
501        assert_eq!(ImageFormat::Svg.ext(), "svg");
502    }
503
504    fn ogg() -> Vec<u8> {
505        let mut v = b"OggS".to_vec();
506        v.extend_from_slice(&[0u8; 60]);
507        v
508    }
509
510    fn wav() -> Vec<u8> {
511        let mut v = b"RIFF".to_vec();
512        v.extend_from_slice(&[0, 0, 0, 0]);
513        v.extend_from_slice(b"WAVE");
514        v.extend_from_slice(&[0u8; 40]);
515        v
516    }
517
518    #[test]
519    fn sniffs_audio_containers() {
520        assert_eq!(sniff_audio(&ogg()), Some(AudioFormat::Ogg));
521        assert_eq!(sniff_audio(&wav()), Some(AudioFormat::Wav));
522        assert_eq!(
523            sniff_audio(&[0xff, 0xfb, 0x90, 0x00]),
524            Some(AudioFormat::Mp3)
525        );
526        assert_eq!(sniff_audio(b"ID3\x04\x00"), Some(AudioFormat::Mp3));
527        assert_eq!(sniff_audio(b"not audio"), None);
528        // Too short to hold an MPEG frame sync.
529        assert_eq!(sniff_audio(&[0xff]), None);
530        // WebP's RIFF header must not be mistaken for WAV.
531        assert_eq!(sniff_audio(&webp_vp8x(10, 10)), None);
532    }
533
534    #[test]
535    fn validates_an_audio_asset_as_audio() {
536        let (_, kind) = validate_asset(&signed("cue.ogg", &ogg())).unwrap();
537        assert_eq!(kind, AssetKind::Audio(AudioFormat::Ogg));
538        assert!(kind.is_audio());
539        assert_eq!(kind.ext(), "ogg");
540    }
541
542    #[test]
543    fn rejects_an_oversize_sound() {
544        // Valid Ogg header padded past the sound cap (but under the image cap,
545        // so it reaches the audio-specific check).
546        let mut big = b"OggS".to_vec();
547        big.resize(MAX_SOUND_BYTES + 1, 0);
548        assert!(validate_asset(&signed("cue.ogg", &big))
549            .unwrap_err()
550            .contains("over the"));
551    }
552
553    #[test]
554    fn audio_format_extensions() {
555        assert_eq!(AudioFormat::Ogg.ext(), "ogg");
556        assert_eq!(AudioFormat::Wav.ext(), "wav");
557        assert_eq!(AudioFormat::Mp3.ext(), "mp3");
558    }
559
560    #[test]
561    fn sniff_rejects_unknown_and_truncated() {
562        assert_eq!(sniff(b"not an image"), None);
563        assert_eq!(
564            sniff(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]),
565            None
566        );
567        assert_eq!(sniff(b"RIFF\0\0\0\0WEBPxxxx"), None);
568    }
569
570    #[test]
571    fn validates_a_well_formed_asset() {
572        let (bytes, kind) = validate_asset(&signed("twist.png", &png(100, 50))).unwrap();
573        assert_eq!(kind, AssetKind::Image(ImageFormat::Png));
574        assert_eq!(sniff(&bytes).unwrap().0, ImageFormat::Png);
575    }
576
577    #[test]
578    fn rejects_unsafe_id() {
579        let mut a = signed("../escape.png", &png(10, 10));
580        a.sha256 = hex_lower(&sha256(&png(10, 10)));
581        assert!(validate_asset(&a).unwrap_err().contains("must be 1..="));
582        assert!(validate_asset(&signed("UPPER.png", &png(10, 10)))
583            .unwrap_err()
584            .contains("[a-z0-9._-]"));
585    }
586
587    #[test]
588    fn rejects_bad_sha256_format() {
589        let mut a = signed("a.png", &png(10, 10));
590        a.sha256 = "tooshort".to_string();
591        assert!(validate_asset(&a).unwrap_err().contains("64 lowercase hex"));
592    }
593
594    #[test]
595    fn rejects_hash_mismatch() {
596        let mut a = signed("a.png", &png(10, 10));
597        a.sha256 = hex_lower(&sha256(b"different bytes"));
598        assert!(validate_asset(&a).unwrap_err().contains("do not match"));
599    }
600
601    #[test]
602    fn rejects_oversize_bytes() {
603        let big = png(10, 10);
604        let mut a = signed("a.png", &big);
605        // Re-encode an over-cap blob and re-hash so only the size check trips.
606        let payload = vec![0x89u8; MAX_ASSET_BYTES + 1];
607        // Not a valid PNG, but the size check runs before sniffing.
608        a.data_base64 = BASE64_STANDARD.encode(&payload);
609        a.sha256 = hex_lower(&sha256(&payload));
610        assert!(validate_asset(&a).unwrap_err().contains("over the"));
611    }
612
613    #[test]
614    fn rejects_decompression_bomb_dimensions() {
615        let bomb = png(40_000, 40_000); // 1.6e9 pixels
616        assert!(validate_asset(&signed("a.png", &bomb))
617            .unwrap_err()
618            .contains("pixel cap"));
619    }
620
621    #[test]
622    fn rejects_non_image_bytes() {
623        let junk = b"this is plainly not an image at all".to_vec();
624        assert!(validate_asset(&signed("a.png", &junk))
625            .unwrap_err()
626            .contains("not a supported image"));
627    }
628
629    #[test]
630    fn rejects_an_empty_asset() {
631        let a = ManifestAsset {
632            id: "a.png".to_string(),
633            sha256: hex_lower(&sha256(b"")),
634            data_base64: String::new(),
635        };
636        assert!(validate_asset(&a).unwrap_err().contains("is empty"));
637    }
638
639    #[test]
640    fn rejects_invalid_base64() {
641        let a = ManifestAsset {
642            id: "a.png".to_string(),
643            sha256: hex_lower(&sha256(b"x")),
644            data_base64: "not base64!!!".to_string(),
645        };
646        assert!(validate_asset(&a).unwrap_err().contains("not valid base64"));
647    }
648
649    fn svg(inner: &str) -> Vec<u8> {
650        format!(
651            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\">{inner}</svg>"
652        )
653        .into_bytes()
654    }
655
656    #[test]
657    fn validates_a_plain_svg_as_image() {
658        let (_, kind) = validate_asset(&signed("twist.svg", &svg("<circle r=\"8\"/>"))).unwrap();
659        assert_eq!(kind, AssetKind::Image(ImageFormat::Svg));
660        assert!(!kind.is_audio());
661        assert_eq!(kind.ext(), "svg");
662    }
663
664    #[test]
665    fn accepts_svg_with_xml_prolog_and_namespace_urls() {
666        // An Inkscape-style document: XML prolog, multiple xmlns http URLs, a
667        // data: image. None of these are external *fetches*, so it must pass.
668        let doc = b"<?xml version=\"1.0\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" \
669            xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"8\" height=\"8\">\
670            <image xlink:href=\"data:image/png;base64,AAAA\"/></svg>";
671        let (_, kind) = validate_asset(&signed("pose.svg", doc)).unwrap();
672        assert_eq!(kind, AssetKind::Image(ImageFormat::Svg));
673    }
674
675    #[test]
676    fn accepts_inkscape_style_export() {
677        // The real shape of an Inkscape export: an XML decl, a comment whose
678        // text contains "(http://www.inkscape.org/)", and an xmlns:inkscape
679        // http URL. The comment's URL is not a fetch (no href/src/url()), and
680        // the namespace URL is xmlns, so the document must validate.
681        let doc = b"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n\
682            <!-- Created with Inkscape (http://www.inkscape.org/) -->\n\
683            <svg width=\"330mm\" height=\"200mm\" version=\"1.1\" \
684            xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\" \
685            xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h10\"/></svg>";
686        let (_, kind) = validate_asset(&signed("cat-cow.svg", doc)).unwrap();
687        assert_eq!(kind, AssetKind::Image(ImageFormat::Svg));
688    }
689
690    #[test]
691    fn accepts_svg_led_by_a_license_comment() {
692        // Some tooling emits a license/banner comment before the root element,
693        // so the head sniff must accept a leading `<!-- … -->` (the otherwise
694        // untaken branch) and still recognise the <svg> that follows.
695        let doc = b"<!-- License: CC0 -->\n<svg xmlns=\"http://www.w3.org/2000/svg\" \
696            width=\"4\" height=\"4\"><circle r=\"2\"/></svg>";
697        let (_, kind) = validate_asset(&signed("badge.svg", doc)).unwrap();
698        assert_eq!(kind, AssetKind::Image(ImageFormat::Svg));
699    }
700
701    #[test]
702    fn rejects_svg_with_doctype_or_entity() {
703        let doctype = b"<?xml version=\"1.0\"?><!DOCTYPE svg><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
704        assert!(validate_asset(&signed("a.svg", doctype))
705            .unwrap_err()
706            .contains("DOCTYPE or ENTITY"));
707        let entity = b"<?xml version=\"1.0\"?><!DOCTYPE svg [<!ENTITY x \"y\">]><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
708        assert!(validate_asset(&signed("a.svg", entity))
709            .unwrap_err()
710            .contains("DOCTYPE or ENTITY"));
711    }
712
713    #[test]
714    fn rejects_svg_with_script_or_foreign_object() {
715        assert!(
716            validate_asset(&signed("a.svg", &svg("<script>alert(1)</script>")))
717                .unwrap_err()
718                .contains("script")
719        );
720        assert!(validate_asset(&signed(
721            "a.svg",
722            &svg("<foreignObject><p>hi</p></foreignObject>")
723        ))
724        .unwrap_err()
725        .contains("foreignObject"));
726    }
727
728    #[test]
729    fn rejects_svg_with_event_handler_or_javascript_uri() {
730        assert!(
731            validate_asset(&signed("a.svg", &svg("<rect onload=\"x()\"/>")))
732                .unwrap_err()
733                .contains("event-handler")
734        );
735        assert!(
736            validate_asset(&signed("a.svg", &svg("<a href=\"javascript:x()\">go</a>")))
737                .unwrap_err()
738                .contains("javascript:")
739        );
740    }
741
742    #[test]
743    fn rejects_svg_with_external_reference() {
744        assert!(validate_asset(&signed(
745            "a.svg",
746            &svg("<image href=\"http://evil.test/x.png\"/>")
747        ))
748        .unwrap_err()
749        .contains("external"));
750        assert!(validate_asset(&signed(
751            "a.svg",
752            &svg("<image href=\"//evil.test/x.png\"/>")
753        ))
754        .unwrap_err()
755        .contains("external"));
756    }
757
758    #[test]
759    fn non_svg_xml_is_not_treated_as_an_image() {
760        // Well-formed XML that isn't SVG falls through to the unsupported error,
761        // not a misleading SVG-specific one.
762        let err = validate_asset(&signed("a.svg", b"<html><body>hi</body></html>")).unwrap_err();
763        assert!(err.contains("not a supported image"));
764    }
765
766    #[test]
767    fn xml_prolog_without_svg_element_is_not_an_image() {
768        // Starts with an XML prolog (so it passes the root sniff) but carries no
769        // <svg> element — falls through to the unsupported-format error.
770        let err = validate_asset(&signed("a.svg", b"<?xml version=\"1.0\"?><note>hi</note>"))
771            .unwrap_err();
772        assert!(err.contains("not a supported image"));
773    }
774
775    #[test]
776    fn the_word_on_in_text_is_not_an_event_handler() {
777        // "on" as ordinary word content (with surrounding spaces) exercises the
778        // handler scanner's enter-but-no-match path; it must not be flagged, so
779        // the SVG validates.
780        let (_, kind) =
781            validate_asset(&signed("a.svg", &svg("<text>carry on now</text>"))).unwrap();
782        assert_eq!(kind, AssetKind::Image(ImageFormat::Svg));
783    }
784}