1use serde::Serialize;
15use sha2::{Digest, Sha256};
16use sqlx::PgPool;
17use uuid::Uuid;
18
19use crate::{
20 auth::Actor,
21 domain::Role,
22 error::{AppError, AppResult},
23 services::audit,
24};
25
26pub const MAX_LOGO_BYTES: usize = 512 * 1024;
28
29pub const MAX_LOGO_PIXELS: u32 = 4096;
31
32pub const MAX_NAME_CHARS: usize = 40;
34
35pub const MAX_SUBTITLE_CHARS: usize = 20;
37
38pub const DEFAULT_SUBTITLE: &str = "PLM";
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Kind {
44 Wide,
45 Square,
46}
47
48impl Kind {
49 pub const ALL: [Kind; 2] = [Kind::Wide, Kind::Square];
50
51 pub fn as_str(self) -> &'static str {
52 match self {
53 Kind::Wide => "wide",
54 Kind::Square => "square",
55 }
56 }
57
58 pub fn parse(value: &str) -> Option<Kind> {
59 Kind::ALL.into_iter().find(|k| k.as_str() == value)
60 }
61
62 pub fn label(self) -> &'static str {
63 match self {
64 Kind::Wide => "Wide logo",
65 Kind::Square => "Square logo",
66 }
67 }
68}
69
70#[derive(Debug, sqlx::FromRow)]
72pub struct Logo {
73 pub content_type: String,
74 pub data: Vec<u8>,
75 pub sha256: String,
76}
77
78#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
82pub struct Branding {
83 pub display_name: Option<String>,
86 pub subtitle: String,
88 pub wide: Option<String>,
89 pub square: Option<String>,
90 pub wide_type: Option<String>,
93 pub square_type: Option<String>,
95}
96
97impl Default for Branding {
98 fn default() -> Self {
99 Branding {
100 display_name: None,
101 subtitle: DEFAULT_SUBTITLE.to_string(),
102 wide: None,
103 square: None,
104 wide_type: None,
105 square_type: None,
106 }
107 }
108}
109
110impl Branding {
111 pub fn get(&self, kind: Kind) -> Option<&str> {
112 match kind {
113 Kind::Wide => self.wide.as_deref(),
114 Kind::Square => self.square.as_deref(),
115 }
116 }
117
118 pub fn name<'a>(&'a self, fallback: &'a str) -> &'a str {
120 self.display_name.as_deref().unwrap_or(fallback)
121 }
122}
123
124pub async fn current(pool: &PgPool) -> AppResult<Branding> {
126 let rows: Vec<(String, String, String)> =
127 sqlx::query_as("SELECT kind, substr(sha256, 1, 12), content_type FROM branding_logos")
128 .fetch_all(pool)
129 .await?;
130 let mut branding = Branding::default();
131 for (kind, token, content_type) in rows {
132 match Kind::parse(&kind) {
133 Some(Kind::Wide) => {
134 branding.wide = Some(token);
135 branding.wide_type = Some(content_type);
136 }
137 Some(Kind::Square) => {
138 branding.square = Some(token);
139 branding.square_type = Some(content_type);
140 }
141 None => {}
142 }
143 }
144 let texts: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM branding_text")
145 .fetch_all(pool)
146 .await?;
147 for (key, value) in texts {
148 match key.as_str() {
149 "display_name" if !value.is_empty() => branding.display_name = Some(value),
150 "subtitle" => branding.subtitle = value,
151 _ => {}
152 }
153 }
154 Ok(branding)
155}
156
157fn clean_text(label: &str, raw: &str, max: usize) -> AppResult<String> {
161 let value = raw.trim();
162 if value.chars().count() > max {
163 return Err(AppError::BadRequest(format!(
164 "The {label} may have at most {max} characters."
165 )));
166 }
167 if value.chars().any(char::is_control) {
168 return Err(AppError::BadRequest(format!(
169 "The {label} must be a single line of plain text."
170 )));
171 }
172 if value.contains(['<', '>']) {
173 return Err(AppError::BadRequest(format!(
174 "The {label} must not contain markup (< or >)."
175 )));
176 }
177 Ok(value.to_string())
178}
179
180pub fn validate_names(display_name: &str, subtitle: &str) -> AppResult<(String, String)> {
183 Ok((
184 clean_text("display name", display_name, MAX_NAME_CHARS)?,
185 clean_text("subtitle", subtitle, MAX_SUBTITLE_CHARS)?,
186 ))
187}
188
189pub async fn set_names(
196 pool: &PgPool,
197 actor: &Actor,
198 display_name: &str,
199 subtitle: &str,
200) -> AppResult<()> {
201 actor.require(Role::Admin)?;
202 let (display_name, subtitle) = validate_names(display_name, subtitle)?;
203 let mut tx = pool.begin().await?;
204 for (key, value, is_default) in [
205 (
206 "display_name",
207 display_name.as_str(),
208 display_name.is_empty(),
209 ),
210 ("subtitle", subtitle.as_str(), subtitle == DEFAULT_SUBTITLE),
211 ] {
212 if is_default {
213 sqlx::query("DELETE FROM branding_text WHERE key = $1")
214 .bind(key)
215 .execute(&mut *tx)
216 .await?;
217 } else {
218 sqlx::query(
219 "INSERT INTO branding_text (key, value, updated_by, updated_at)
220 VALUES ($1, $2, $3, now())
221 ON CONFLICT (key) DO UPDATE
222 SET value = EXCLUDED.value, updated_by = EXCLUDED.updated_by,
223 updated_at = now()",
224 )
225 .bind(key)
226 .bind(value)
227 .bind(actor.id)
228 .execute(&mut *tx)
229 .await?;
230 }
231 }
232 audit::record(
233 &mut tx,
234 Some(actor),
235 "branding.update",
236 "branding",
237 None::<Uuid>,
238 "Changed the header name".to_string(),
239 serde_json::json!({ "display_name": display_name, "subtitle": subtitle }),
240 )
241 .await?;
242 tx.commit().await?;
243 Ok(())
244}
245
246pub async fn get(pool: &PgPool, kind: Kind) -> AppResult<Option<Logo>> {
248 Ok(sqlx::query_as::<_, Logo>(
249 "SELECT content_type, data, sha256 FROM branding_logos WHERE kind = $1",
250 )
251 .bind(kind.as_str())
252 .fetch_optional(pool)
253 .await?)
254}
255
256fn sniff(data: &[u8]) -> Option<&'static str> {
258 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
259 Some("image/png")
260 } else if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
261 Some("image/jpeg")
262 } else if data.len() > 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
263 Some("image/webp")
264 } else if looks_like_svg(data) {
265 Some("image/svg+xml")
266 } else {
267 None
268 }
269}
270
271fn looks_like_svg(data: &[u8]) -> bool {
272 let Ok(text) = std::str::from_utf8(data) else {
273 return false;
274 };
275 let text = text.trim_start_matches('\u{feff}').trim_start();
276 let lower = text.to_ascii_lowercase();
277 (lower.starts_with("<?xml")
278 || lower.starts_with("<svg")
279 || lower.starts_with("<!--")
280 || lower.starts_with("<!doctype svg"))
281 && lower.contains("<svg")
282}
283
284fn check_svg(data: &[u8]) -> AppResult<()> {
286 let lower = String::from_utf8_lossy(data).to_ascii_lowercase();
287 let squeezed: String = lower.chars().filter(|c| !c.is_whitespace()).collect();
288 let forbidden = [
289 "<script",
290 "<foreignobject",
291 "<iframe",
292 "<embed",
293 "<object",
294 "<audio",
295 "<video",
296 "<animate",
297 "<set",
298 "<!entity",
299 "javascript:",
300 "data:text/html",
301 "@import",
302 ];
303 for needle in forbidden {
304 if squeezed.contains(needle) {
305 return Err(AppError::BadRequest(format!(
306 "The SVG contains '{needle}': scripts, animations and external references are not allowed in a logo."
307 )));
308 }
309 }
310 for value in reference_attribute_values(&lower) {
315 if !local_reference(value) {
316 return Err(AppError::BadRequest(format!(
317 "The SVG links to '{}': a logo may reference only parts of itself (#id) or embedded images.",
318 shorten(value)
319 )));
320 }
321 }
322 for (i, _) in squeezed.match_indices("url(") {
323 let rest = &squeezed[i + 4..];
324 let arg = rest.split(')').next().unwrap_or(rest);
325 if !local_reference(arg.trim_matches(|c| c == '"' || c == '\'')) {
326 return Err(AppError::BadRequest(format!(
327 "The SVG contains 'url({})': a logo may reference only parts of itself (#id) or embedded images.",
328 shorten(arg)
329 )));
330 }
331 }
332 let b = lower.as_bytes();
334 for (i, _) in lower.match_indices("on") {
335 let starts_attribute =
336 i == 0 || matches!(b[i - 1], b' ' | b'\t' | b'\r' | b'\n' | b'"' | b'\'' | b'/');
337 if !starts_attribute {
338 continue;
339 }
340 let rest = &b[i + 2..];
341 let letters = rest.iter().take_while(|c| c.is_ascii_alphabetic()).count();
342 let after = rest[letters..].iter().find(|c| !c.is_ascii_whitespace());
343 if letters > 0 && after == Some(&b'=') {
344 return Err(AppError::BadRequest(
345 "The SVG contains an event handler attribute (onload, onclick, …).".into(),
346 ));
347 }
348 }
349 Ok(())
350}
351
352fn local_reference(value: &str) -> bool {
356 let value = value.trim();
357 value.starts_with('#') || value.starts_with("data:image/")
358}
359
360fn shorten(value: &str) -> String {
361 let value = value.trim();
362 if value.chars().count() > 60 {
363 format!("{}…", value.chars().take(60).collect::<String>())
364 } else {
365 value.to_string()
366 }
367}
368
369fn reference_attribute_values(lower: &str) -> Vec<&str> {
373 let b = lower.as_bytes();
374 let mut values = Vec::new();
375 for (eq, _) in lower.match_indices('=') {
376 let mut end = eq;
378 while end > 0 && b[end - 1].is_ascii_whitespace() {
379 end -= 1;
380 }
381 let mut start = end;
382 while start > 0
383 && (b[start - 1].is_ascii_alphanumeric() || matches!(b[start - 1], b':' | b'-' | b'_'))
384 {
385 start -= 1;
386 }
387 let name = &lower[start..end];
388 if !(name == "href" || name == "src" || name.ends_with(":href")) {
389 continue;
390 }
391 let rest = lower[eq + 1..].trim_start();
393 let value = match rest.as_bytes().first() {
394 Some(&q @ (b'"' | b'\'')) => {
395 let inner = &rest[1..];
396 &inner[..inner.find(q as char).unwrap_or(inner.len())]
397 }
398 _ => {
399 let len = rest
400 .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
401 .unwrap_or(rest.len());
402 &rest[..len]
403 }
404 };
405 values.push(value);
406 }
407 values
408}
409
410fn bitmap_size(content_type: &str, data: &[u8]) -> Option<(u32, u32)> {
413 match content_type {
414 "image/png" if data.len() >= 24 => Some((
415 u32::from_be_bytes(data[16..20].try_into().ok()?),
416 u32::from_be_bytes(data[20..24].try_into().ok()?),
417 )),
418 "image/jpeg" => {
419 let mut i = 2;
420 while i + 9 < data.len() {
421 if data[i] != 0xFF {
422 i += 1;
423 continue;
424 }
425 let marker = data[i + 1];
426 if marker == 0xFF {
427 i += 1;
428 continue;
429 }
430 if (0xC0..=0xCF).contains(&marker) && ![0xC4, 0xC8, 0xCC].contains(&marker) {
431 let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
432 let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
433 return Some((w, h));
434 }
435 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
436 i += 2 + len.max(2);
437 }
438 None
439 }
440 "image/webp" => webp_size(data),
441 _ => None,
442 }
443}
444
445fn webp_size(data: &[u8]) -> Option<(u32, u32)> {
447 let chunk = data.get(12..16)?;
448 let body = data.get(20..)?;
449 match chunk {
450 b"VP8 " => {
452 if body.get(3..6)? != [0x9D, 0x01, 0x2A] {
453 return None;
454 }
455 let w = u16::from_le_bytes([*body.get(6)?, *body.get(7)?]) & 0x3FFF;
456 let h = u16::from_le_bytes([*body.get(8)?, *body.get(9)?]) & 0x3FFF;
457 Some((w as u32, h as u32))
458 }
459 b"VP8L" => {
461 if *body.first()? != 0x2F {
462 return None;
463 }
464 let bits = u32::from_le_bytes(body.get(1..5)?.try_into().ok()?);
465 Some(((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1))
466 }
467 b"VP8X" => {
469 let s = body.get(4..10)?;
470 let w = u32::from_le_bytes([s[0], s[1], s[2], 0]) + 1;
471 let h = u32::from_le_bytes([s[3], s[4], s[5], 0]) + 1;
472 Some((w, h))
473 }
474 _ => None,
475 }
476}
477
478pub fn validate(data: &[u8]) -> AppResult<&'static str> {
480 if data.is_empty() {
481 return Err(AppError::BadRequest("Choose an image file.".into()));
482 }
483 if data.len() > MAX_LOGO_BYTES {
484 return Err(AppError::BadRequest(format!(
485 "The logo is {} KiB; the limit is {} KiB. Reduce the image size.",
486 data.len().div_ceil(1024),
487 MAX_LOGO_BYTES / 1024
488 )));
489 }
490 let content_type = sniff(data).ok_or_else(|| {
491 AppError::BadRequest("The file is not a PNG, JPEG, WebP or SVG image.".into())
492 })?;
493 if content_type == "image/svg+xml" {
494 check_svg(data)?;
495 }
496 if content_type != "image/svg+xml" {
497 let (w, h) = bitmap_size(content_type, data).ok_or_else(|| {
500 AppError::BadRequest(
501 "The image size cannot be read; the file is damaged or of an unusual kind. Save it again as PNG."
502 .into(),
503 )
504 })?;
505 if w == 0 || h == 0 || w > MAX_LOGO_PIXELS || h > MAX_LOGO_PIXELS {
506 return Err(AppError::BadRequest(format!(
507 "The image is {w}×{h} pixels; each side must be between 1 and {MAX_LOGO_PIXELS}."
508 )));
509 }
510 }
511 Ok(content_type)
512}
513
514pub async fn set(pool: &PgPool, actor: &Actor, kind: Kind, data: &[u8]) -> AppResult<()> {
519 actor.require(Role::Admin)?;
520 let content_type = validate(data)?;
521 let sha256 = hex::encode(Sha256::digest(data));
522 let mut tx = pool.begin().await?;
523 sqlx::query(
524 "INSERT INTO branding_logos (kind, content_type, data, sha256, updated_by, updated_at)
525 VALUES ($1, $2, $3, $4, $5, now())
526 ON CONFLICT (kind) DO UPDATE
527 SET content_type = EXCLUDED.content_type, data = EXCLUDED.data,
528 sha256 = EXCLUDED.sha256, updated_by = EXCLUDED.updated_by,
529 updated_at = now()",
530 )
531 .bind(kind.as_str())
532 .bind(content_type)
533 .bind(data)
534 .bind(&sha256)
535 .bind(actor.id)
536 .execute(&mut *tx)
537 .await?;
538 audit::record(
539 &mut tx,
540 Some(actor),
541 "branding.set",
542 "branding",
543 None::<Uuid>,
544 format!("Replaced the {} logo", kind.as_str()),
545 serde_json::json!({ "kind": kind.as_str(), "content_type": content_type,
546 "bytes": data.len(), "sha256": sha256 }),
547 )
548 .await?;
549 tx.commit().await?;
550 Ok(())
551}
552
553pub async fn reset(pool: &PgPool, actor: &Actor, kind: Kind) -> AppResult<bool> {
559 actor.require(Role::Admin)?;
560 let mut tx = pool.begin().await?;
561 let deleted = sqlx::query("DELETE FROM branding_logos WHERE kind = $1")
562 .bind(kind.as_str())
563 .execute(&mut *tx)
564 .await?
565 .rows_affected()
566 > 0;
567 if deleted {
568 audit::record(
569 &mut tx,
570 Some(actor),
571 "branding.reset",
572 "branding",
573 None::<Uuid>,
574 format!("Restored the built-in {} logo", kind.as_str()),
575 serde_json::json!({ "kind": kind.as_str() }),
576 )
577 .await?;
578 }
579 tx.commit().await?;
580 Ok(deleted)
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 fn png(w: u32, h: u32) -> Vec<u8> {
588 let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
589 v.extend_from_slice(&w.to_be_bytes());
590 v.extend_from_slice(&h.to_be_bytes());
591 v.extend_from_slice(&[8, 6, 0, 0, 0, 0, 0, 0, 0]);
592 v
593 }
594
595 fn jpeg(w: u16, h: u16) -> Vec<u8> {
597 let mut v = vec![
598 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0, 0xFF, 0xC0, 0x00, 0x0B, 8,
599 ];
600 v.extend_from_slice(&h.to_be_bytes());
601 v.extend_from_slice(&w.to_be_bytes());
602 v.extend_from_slice(&[1, 1, 0x11, 0, 0xFF, 0xD9]);
603 v
604 }
605
606 fn riff(chunk: &[u8; 4], body: &[u8]) -> Vec<u8> {
607 let mut v = b"RIFF".to_vec();
608 v.extend_from_slice(&((body.len() + 12) as u32).to_le_bytes());
609 v.extend_from_slice(b"WEBP");
610 v.extend_from_slice(chunk);
611 v.extend_from_slice(&(body.len() as u32).to_le_bytes());
612 v.extend_from_slice(body);
613 v
614 }
615
616 fn webp_lossy(w: u16, h: u16) -> Vec<u8> {
617 let mut body = vec![0x30, 0x01, 0x00, 0x9D, 0x01, 0x2A];
618 body.extend_from_slice(&w.to_le_bytes());
619 body.extend_from_slice(&h.to_le_bytes());
620 body.extend_from_slice(&[0; 8]);
621 riff(b"VP8 ", &body)
622 }
623
624 fn webp_lossless(w: u32, h: u32) -> Vec<u8> {
625 let bits = (w - 1) | ((h - 1) << 14);
626 let mut body = vec![0x2F];
627 body.extend_from_slice(&bits.to_le_bytes());
628 body.extend_from_slice(&[0; 8]);
629 riff(b"VP8L", &body)
630 }
631
632 fn webp_extended(w: u32, h: u32) -> Vec<u8> {
633 let mut body = vec![0x10, 0, 0, 0];
634 body.extend_from_slice(&(w - 1).to_le_bytes()[..3]);
635 body.extend_from_slice(&(h - 1).to_le_bytes()[..3]);
636 riff(b"VP8X", &body)
637 }
638
639 const SVG: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="#036"/></svg>"##;
640
641 const INKSCAPE_SVG: &str = r##"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
646<!-- Created with Inkscape (http://www.inkscape.org/) -->
647
648<svg
649 width="600"
650 height="120"
651 viewBox="0 0 158.75 31.75"
652 version="1.1"
653 id="svg5"
654 inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
655 sodipodi:docname="logo.svg"
656 inkscape:export-filename="logo.png"
657 inkscape:export-xdpi="96"
658 inkscape:export-ydpi="96"
659 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
660 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
661 xmlns:xlink="http://www.w3.org/1999/xlink"
662 xmlns="http://www.w3.org/2000/svg"
663 xmlns:svg="http://www.w3.org/2000/svg"
664 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
665 xmlns:cc="http://creativecommons.org/ns#"
666 xmlns:dc="http://purl.org/dc/elements/1.1/">
667 <sodipodi:namedview
668 id="namedview7"
669 pagecolor="#ffffff"
670 bordercolor="#666666"
671 borderopacity="1.0"
672 inkscape:showpageshadow="2"
673 inkscape:pageopacity="0.0"
674 inkscape:pagecheckerboard="0"
675 inkscape:deskcolor="#d1d1d1"
676 inkscape:document-units="mm"
677 showgrid="false"
678 inkscape:zoom="1.2"
679 inkscape:window-width="1920"
680 inkscape:window-height="1011"
681 inkscape:window-maximized="1"
682 inkscape:current-layer="layer1" />
683 <defs
684 id="defs2">
685 <linearGradient
686 inkscape:collect="always"
687 id="linearGradient1">
688 <stop
689 style="stop-color:#003366;stop-opacity:1"
690 offset="0"
691 id="stop1" />
692 <stop
693 style="stop-color:#0066cc;stop-opacity:1"
694 offset="1"
695 id="stop2" />
696 </linearGradient>
697 </defs>
698 <metadata
699 id="metadata1">
700 <rdf:RDF>
701 <cc:Work
702 rdf:about="">
703 <dc:format>image/svg+xml</dc:format>
704 <dc:type
705 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
706 <cc:license
707 rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/" />
708 </cc:Work>
709 </rdf:RDF>
710 </metadata>
711 <g
712 inkscape:label="Layer 1"
713 inkscape:groupmode="layer"
714 id="layer1">
715 <rect
716 style="fill:url(#linearGradient1);fill-opacity:1;stroke:none"
717 id="rect1"
718 width="31.75"
719 height="31.75"
720 x="0"
721 y="0"
722 rx="4" />
723 <path
724 style="fill:#ffffff;stroke:none"
725 d="M 6,6 H 26 V 10 H 18 V 26 H 14 V 10 H 6 Z"
726 id="path1"
727 sodipodi:nodetypes="ccccccccc"
728 inkscape:connector-curvature="0" />
729 <use
730 x="0"
731 y="0"
732 xlink:href="#path1"
733 id="use1"
734 transform="translate(40)" />
735 <image
736 width="8"
737 height="8"
738 preserveAspectRatio="none"
739 xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
740 id="image1"
741 x="140"
742 y="2" />
743 <text
744 xml:space="preserve"
745 style="font-size:12px;font-family:sans-serif;fill:#003366"
746 x="70"
747 y="20"
748 id="text1"><tspan
749 sodipodi:role="line"
750 id="tspan1"
751 x="70"
752 y="20">ACME</tspan></text>
753 </g>
754</svg>
755"##;
756
757 #[test]
758 fn names_are_trimmed_and_checked() {
759 assert_eq!(
760 validate_names(" Rocket Team ", " PDM ").unwrap(),
761 ("Rocket Team".to_string(), "PDM".to_string())
762 );
763 assert_eq!(
765 validate_names(" ", "").unwrap(),
766 (String::new(), String::new())
767 );
768 assert!(
770 validate_names(&"ä".repeat(MAX_NAME_CHARS), &"ö".repeat(MAX_SUBTITLE_CHARS)).is_ok()
771 );
772 assert!(validate_names(&"x".repeat(MAX_NAME_CHARS + 1), "").is_err());
773 assert!(validate_names("", &"x".repeat(MAX_SUBTITLE_CHARS + 1)).is_err());
774 for bad in ["a\nb", "a\tb", "a\u{0}b", "<b>x</b>", "a>b", "a\r"] {
775 let r = validate_names(bad, "");
777 if bad == "a\r" {
778 assert!(r.is_ok());
779 } else {
780 assert!(r.is_err(), "{bad:?}");
781 }
782 assert!(validate_names("", bad).is_err() || bad == "a\r");
783 }
784 assert!(validate_names("Tom & Jerry's \"Shop\"", "").is_ok());
785 }
786
787 #[test]
788 fn the_name_falls_back_to_the_instance_name() {
789 let mut b = Branding::default();
790 assert_eq!(b.name("Env"), "Env");
791 assert_eq!(b.subtitle, "PLM");
792 b.display_name = Some("Custom".into());
793 assert_eq!(b.name("Env"), "Custom");
794 }
795
796 #[test]
797 fn kinds_round_trip() {
798 for k in Kind::ALL {
799 assert_eq!(Kind::parse(k.as_str()), Some(k));
800 }
801 assert_eq!(Kind::parse("icon"), None);
802 }
803
804 #[test]
805 fn accepts_png_jpeg_webp_and_plain_svg() {
806 assert_eq!(validate(&png(600, 120)).unwrap(), "image/png");
807 assert_eq!(validate(&jpeg(600, 120)).unwrap(), "image/jpeg");
808 assert_eq!(validate(&webp_lossy(600, 120)).unwrap(), "image/webp");
809 assert_eq!(validate(&webp_lossless(600, 120)).unwrap(), "image/webp");
810 assert_eq!(validate(&webp_extended(600, 120)).unwrap(), "image/webp");
811 assert_eq!(validate(SVG).unwrap(), "image/svg+xml");
812 let with_xml = [b"<?xml version=\"1.0\"?>\n".as_slice(), SVG].concat();
813 assert_eq!(validate(&with_xml).unwrap(), "image/svg+xml");
814 }
815
816 #[test]
817 fn webp_size_is_read_from_every_kind_of_first_chunk() {
818 assert_eq!(webp_size(&webp_lossy(300, 120)), Some((300, 120)));
819 assert_eq!(webp_size(&webp_lossless(300, 120)), Some((300, 120)));
820 assert_eq!(webp_size(&webp_extended(300, 120)), Some((300, 120)));
821 assert_eq!(webp_size(&webp_lossless(16384, 1)), Some((16384, 1)));
822 assert_eq!(
823 webp_size(&webp_extended(16383, 16383)),
824 Some((16383, 16383))
825 );
826 }
827
828 #[test]
829 fn huge_webp_and_bitmaps_without_a_readable_size_are_refused() {
830 assert!(validate(&webp_lossless(16383, 16383)).is_err());
832 assert!(validate(&webp_extended(MAX_LOGO_PIXELS + 1, 10)).is_err());
833 assert!(validate(&webp_lossy(10, MAX_LOGO_PIXELS as u16 + 1)).is_err());
834 assert!(validate(&webp_extended(MAX_LOGO_PIXELS, MAX_LOGO_PIXELS)).is_ok());
835 assert!(validate(b"RIFF\0\0\0\0WEBPVP8 ").is_err());
837 assert!(validate(&riff(b"VP8 ", &[0x30, 0x01, 0x00, 0, 0, 0, 1, 0, 1, 0])).is_err());
838 assert!(validate(&riff(b"VP8L", &[0x00, 1, 2, 3, 4])).is_err());
839 assert!(validate(&riff(b"ALPH", &[0; 16])).is_err());
840 assert!(validate(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 2]).is_err());
842 assert!(validate(&jpeg(MAX_LOGO_PIXELS as u16 + 1, 10)).is_err());
843 assert!(validate(&jpeg(10, 0)).is_err());
844 }
845
846 #[test]
847 fn accepts_an_inkscape_svg_with_namespaces_and_metadata() {
848 assert_eq!(validate(INKSCAPE_SVG.as_bytes()).unwrap(), "image/svg+xml");
849 }
850
851 #[test]
852 fn refuses_external_references_in_an_inkscape_svg() {
853 let linked = [
854 (
856 r#"xlink:href="data:image/png;base64,"#,
857 r#"xlink:href="file:///home/me/logo.png" data-x=""#,
858 ),
859 (
860 r#"xlink:href="data:image/png;base64,"#,
861 r#"xlink:href="https://cdn.example.com/x.png" data-x=""#,
862 ),
863 (r##"xlink:href="#path1""##, r##"href="//evil.example/a.svg#p""##),
864 (r##"xlink:href="#path1""##, r#"xlink:href = 'logo.svg#p'"#),
865 (
867 r##"xlink:href="#path1""##,
868 r#"xlink:href="https://evil.example/""#,
869 ),
870 (
871 "fill:url(#linearGradient1)",
872 "fill:url(https://evil.example/g.svg#g)",
873 ),
874 (
875 "fill:url(#linearGradient1)",
876 "fill:url( 'http://evil.example/g.svg#g' )",
877 ),
878 ("fill:url(#linearGradient1)", "fill:url(//evil.example/g)"),
879 ("<defs", "<script>alert(1)</script><defs"),
881 (r#"id="rect1""#, r#"id="rect1" onload="alert(1)""#),
882 ("<defs", "<foreignObject><p>x</p></foreignObject><defs"),
883 (
884 "<svg\n",
885 "<!DOCTYPE svg [<!ENTITY ns_extend \"http://ns.adobe.com/Extensibility/1.0/\">]>\n<svg\n",
886 ),
887 ];
888 for (from, to) in linked {
889 assert!(INKSCAPE_SVG.contains(from), "test fixture lacks {from}");
890 let bad = INKSCAPE_SVG.replacen(from, to, 1);
891 assert!(validate(bad.as_bytes()).is_err(), "accepted: {to}");
892 }
893 }
894
895 #[test]
896 fn reference_attributes_are_found_in_every_spelling() {
897 let svg = r##"<svg a:href = "#a" src='x' href=y/><use xlink:href="#b"/><x nohref="z" hreflang="w"/></svg>"##;
898 assert_eq!(reference_attribute_values(svg), vec!["#a", "x", "y", "#b"]);
899 }
900
901 #[test]
902 fn refuses_other_files_and_empty_uploads() {
903 assert!(validate(b"").is_err());
904 assert!(validate(b"GIF89a....").is_err());
905 assert!(validate(b"<html><svg></svg></html>").is_err());
906 assert!(validate(b"MZ\x90\0 an executable").is_err());
907 assert!(validate(b"just text").is_err());
908 }
909
910 #[test]
911 fn refuses_oversized_files_and_huge_bitmaps() {
912 let mut big = png(10, 10);
913 big.resize(MAX_LOGO_BYTES + 1, 0);
914 assert!(validate(&big).is_err());
915 assert!(validate(&png(MAX_LOGO_PIXELS + 1, 10)).is_err());
916 assert!(validate(&png(0, 10)).is_err());
917 assert!(validate(&png(MAX_LOGO_PIXELS, MAX_LOGO_PIXELS)).is_ok());
918 }
919
920 #[test]
921 fn refuses_active_svg_content() {
922 for bad in [
923 r#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#,
924 r#"<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>"#,
925 r#"<svg xmlns="http://www.w3.org/2000/svg"><rect onclick = 'x()'/></svg>"#,
926 r#"<svg xmlns="http://www.w3.org/2000/svg"><foreignObject/></svg>"#,
927 r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="http://evil.example/x.png"/></svg>"#,
928 r#"<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)"/></svg>"#,
929 r#"<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(x)</style></svg>"#,
930 r#"<svg xmlns="http://www.w3.org/2000/svg"><SCRIPT>1</SCRIPT></svg>"#,
931 ] {
932 assert!(validate(bad.as_bytes()).is_err(), "accepted: {bad}");
933 }
934 }
935
936 #[test]
937 fn plain_attributes_that_contain_on_are_not_event_handlers() {
938 let ok = br#"<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0" fill-rule="evenodd" stroke-linejoin="round" stroke-width="2"/><g transform="rotate(5)" font-family="Arial"/></svg>"#;
939 assert!(validate(ok).is_ok());
940 }
941
942 #[test]
943 fn jpeg_size_is_read_from_the_frame_header() {
944 let jpeg = [
945 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0, 0xFF, 0xC0, 0x00, 0x0B, 8, 0x00, 0x78, 0x01,
946 0x2C, 1, 1, 0x11, 0,
947 ];
948 assert_eq!(bitmap_size("image/jpeg", &jpeg), Some((300, 120)));
949 }
950}