1use base64::prelude::{Engine, BASE64_STANDARD};
20use serde::{Deserialize, Serialize};
21
22use super::signature::sha256;
23
24pub const MAX_ASSETS: usize = 64;
26pub const MAX_ASSET_BYTES: usize = 512 * 1024;
29pub const MAX_IMAGE_PIXELS: u64 = 4_000_000;
33pub const MAX_SOUND_BYTES: usize = 256 * 1024;
36const MAX_ASSET_ID_LEN: usize = 128;
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct ManifestAsset {
42 pub id: String,
45 pub sha256: String,
48 #[serde(default)]
51 pub data_base64: String,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ImageFormat {
59 Png,
60 Gif,
61 Webp,
62 Svg,
63}
64
65impl ImageFormat {
66 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum AssetKind {
101 Image(ImageFormat),
102 Audio(AudioFormat),
103}
104
105impl AssetKind {
106 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
131pub 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 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 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
157pub 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 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
178fn sniff_webp(bytes: &[u8]) -> Option<(ImageFormat, u64, u64)> {
180 match bytes.get(12..16)? {
181 b"VP8X" => {
182 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 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 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
209fn 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
219fn 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
272fn 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
300fn 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
327pub 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]); 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]); 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]); v.extend_from_slice(b"WEBP");
432 v.extend_from_slice(b"VP8X");
433 v.extend_from_slice(&[0, 0, 0, 0]); v.extend_from_slice(&[0, 0, 0, 0]); let wm = w - 1;
436 let hm = h - 1;
437 v.extend_from_slice(&wm.to_le_bytes()[..3]); v.extend_from_slice(&hm.to_le_bytes()[..3]); 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]); v.push(0x2f); 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]); v.extend_from_slice(&w.to_le_bytes()); v.extend_from_slice(&h.to_le_bytes()); 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; 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 assert_eq!(sniff_audio(&[0xff]), None);
530 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 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 let payload = vec![0x89u8; MAX_ASSET_BYTES + 1];
607 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); 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 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 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 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 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 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 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}