1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt::Write as _;
5
6use roxmltree::{Document, NodeType};
7use sha2::{Digest, Sha256};
8use stack_theme::{
9 ProviderIcon, ProviderPack as ProviderPackManifest, ProviderPackPermittedOutput,
10 ProviderPackSource,
11};
12
13use crate::{OperationResult, OperationalError};
14
15const MAX_PROVIDER_ASSET_BYTES: usize = 1024 * 1024;
16const MAX_PROVIDER_PACK_BYTES: usize = 32 * 1024 * 1024;
17const SVG_NAMESPACE: &str = "http://www.w3.org/2000/svg";
18const ALLOWED_ELEMENTS: &[&str] = &[
19 "circle",
20 "clipPath",
21 "defs",
22 "ellipse",
23 "g",
24 "line",
25 "linearGradient",
26 "mask",
27 "path",
28 "polygon",
29 "polyline",
30 "radialGradient",
31 "rect",
32 "stop",
33 "svg",
34];
35const ALLOWED_ATTRIBUTES: &[&str] = &[
36 "aria-hidden",
37 "clip-path",
38 "clip-rule",
39 "cx",
40 "cy",
41 "d",
42 "fill",
43 "fill-opacity",
44 "fill-rule",
45 "fx",
46 "fy",
47 "gradientTransform",
48 "gradientUnits",
49 "height",
50 "href",
51 "id",
52 "isolation",
53 "mask",
54 "maskUnits",
55 "opacity",
56 "offset",
57 "points",
58 "r",
59 "role",
60 "rx",
61 "ry",
62 "stop-color",
63 "stop-opacity",
64 "stroke",
65 "stroke-linecap",
66 "stroke-linejoin",
67 "stroke-miterlimit",
68 "stroke-width",
69 "transform",
70 "viewBox",
71 "width",
72 "x",
73 "x1",
74 "x2",
75 "y",
76 "y1",
77 "y2",
78];
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ProviderAsset {
83 path: String,
84 svg: String,
85}
86
87impl ProviderAsset {
88 #[must_use]
90 pub fn new(path: impl Into<String>, svg: impl Into<String>) -> Self {
91 Self {
92 path: path.into(),
93 svg: svg.into(),
94 }
95 }
96
97 #[must_use]
99 pub fn path(&self) -> &str {
100 &self.path
101 }
102
103 #[must_use]
105 pub fn svg(&self) -> &str {
106 &self.svg
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ProviderPack {
113 manifest: ProviderPackManifest,
114 revision: String,
115 assets: Vec<ProviderAsset>,
116}
117
118impl ProviderPack {
119 pub fn new(
126 manifest: ProviderPackManifest,
127 assets: Vec<ProviderAsset>,
128 ) -> OperationResult<Self> {
129 validate_manifest_boundary(&manifest)?;
130 validate_assets(&manifest, &assets)?;
131 let revision = pack_revision(&manifest, &assets)?;
132 Ok(Self {
133 manifest,
134 revision,
135 assets,
136 })
137 }
138
139 #[must_use]
141 pub fn manifest(&self) -> &ProviderPackManifest {
142 &self.manifest
143 }
144
145 #[must_use]
147 pub fn revision(&self) -> &str {
148 &self.revision
149 }
150
151 pub(crate) fn icon(&self, identifier: &str) -> Option<(&ProviderIcon, &str)> {
152 let icon = self
153 .manifest
154 .icons
155 .iter()
156 .find(|icon| icon.id == identifier)?;
157 let asset = self
158 .assets
159 .iter()
160 .find(|asset| asset.path == icon.asset.path)?;
161 Some((icon, &asset.svg))
162 }
163}
164
165fn validate_manifest_boundary(manifest: &ProviderPackManifest) -> OperationResult<()> {
166 let redistribution = &manifest.rights.redistribution;
167 let processing = &manifest.rights.processing;
168 if !matches!(manifest.schema_version.as_str(), "1.0" | "1.1")
169 || manifest.icons.is_empty()
170 || manifest.icons.len() > 10_000
171 || manifest.additional_sources.len() > 8
172 || (manifest.schema_version == "1.0"
173 && (!manifest.additional_sources.is_empty()
174 || manifest
175 .icons
176 .iter()
177 .any(|icon| icon.asset.source_id.is_some())))
178 || !manifest.rights.terms_acceptance_required
179 || !manifest
180 .rights
181 .permitted_outputs
182 .contains(&ProviderPackPermittedOutput::ArchitectureDiagram)
183 || redistribution.cargo
184 || redistribution.npm
185 || redistribution.wasm
186 || redistribution.web_asset
187 || redistribution.native_binary
188 || !redistribution.generated_output
189 || !processing.local_only
190 || processing.automatic_download
191 || processing.server_upload
192 || !processing.preserve_colors
193 || !processing.preserve_geometry
194 {
195 return Err(invalid_pack(
196 "provider pack violates the user-imported rendering boundary",
197 ));
198 }
199 if !valid_provider_id(&manifest.provider.id)
200 || !valid_source(&manifest.source)
201 || manifest.notice.attribution.is_empty()
202 || manifest.notice.terms_summary.is_empty()
203 || manifest.notice.non_endorsement.is_empty()
204 {
205 return Err(invalid_pack("provider pack identity or notice is invalid"));
206 }
207
208 let mut source_ids = BTreeSet::new();
209 for additional in &manifest.additional_sources {
210 if additional.id == "primary"
211 || !valid_provider_id(&additional.id)
212 || !source_ids.insert(additional.id.as_str())
213 || !valid_source(&additional.source)
214 {
215 return Err(invalid_pack(
216 "provider pack contains an invalid or duplicate source",
217 ));
218 }
219 }
220
221 let prefix = format!("{}:", manifest.provider.id);
222 let mut identifiers = BTreeSet::new();
223 let mut paths = BTreeSet::new();
224 for icon in &manifest.icons {
225 if !icon.id.starts_with(&prefix)
226 || !valid_namespaced_icon_id(&icon.id)
227 || !identifiers.insert(icon.id.as_str())
228 || !valid_asset_path(&icon.asset.path)
229 || !paths.insert(icon.asset.path.as_str())
230 || !valid_sha256(&icon.asset.original_sha256)
231 || !valid_sha256(&icon.asset.processed_sha256)
232 || icon
233 .brand_source_url
234 .as_deref()
235 .is_some_and(|url| !valid_https_url(url))
236 || icon
237 .brand_guidelines_url
238 .as_deref()
239 .is_some_and(|url| !valid_https_url(url))
240 || icon
241 .asset
242 .source_id
243 .as_deref()
244 .is_some_and(|source_id| !source_ids.contains(source_id))
245 || icon.asset.view_box[2] <= 0
246 || icon.asset.view_box[3] <= 0
247 {
248 return Err(invalid_pack(
249 "provider pack contains an invalid or duplicate icon record",
250 ));
251 }
252 }
253 Ok(())
254}
255
256fn valid_source(source: &ProviderPackSource) -> bool {
257 valid_sha256(&source.archive_sha256)
258 && !source.page_url.is_empty()
259 && !source.terms_url.is_empty()
260 && !source.release.is_empty()
261}
262
263fn valid_https_url(value: &str) -> bool {
264 value.starts_with("https://") && !value.bytes().any(|byte| byte.is_ascii_whitespace())
265}
266
267fn validate_assets(
268 manifest: &ProviderPackManifest,
269 assets: &[ProviderAsset],
270) -> OperationResult<()> {
271 if assets.len() != manifest.icons.len() {
272 return Err(invalid_pack(
273 "provider pack assets do not match the manifest",
274 ));
275 }
276 let mut by_path = BTreeMap::new();
277 let mut total_asset_bytes = 0usize;
278 for asset in assets {
279 total_asset_bytes = total_asset_bytes
280 .checked_add(asset.svg.len())
281 .ok_or_else(|| invalid_pack("provider pack assets exceed the total size limit"))?;
282 if total_asset_bytes > MAX_PROVIDER_PACK_BYTES {
283 return Err(invalid_pack(
284 "provider pack assets exceed the total size limit",
285 ));
286 }
287 if !valid_asset_path(&asset.path)
288 || asset.svg.len() > MAX_PROVIDER_ASSET_BYTES
289 || by_path
290 .insert(asset.path.as_str(), asset.svg.as_str())
291 .is_some()
292 {
293 return Err(invalid_pack(
294 "provider pack contains an invalid or duplicate asset",
295 ));
296 }
297 }
298 for icon in &manifest.icons {
299 let Some(svg) = by_path.get(icon.asset.path.as_str()) else {
300 return Err(invalid_pack(
301 "provider pack assets do not match the manifest",
302 ));
303 };
304 if sha256(svg.as_bytes()) != icon.asset.processed_sha256 {
305 return Err(invalid_pack(
306 "provider pack asset hash does not match the manifest",
307 ));
308 }
309 validate_svg(svg, icon.asset.view_box)?;
310 }
311 Ok(())
312}
313
314fn validate_svg(svg: &str, expected_view_box: [i32; 4]) -> OperationResult<()> {
315 let uppercase = svg.to_ascii_uppercase();
316 if uppercase.contains("<!DOCTYPE") || uppercase.contains("<!ENTITY") || svg.contains("<?") {
317 return Err(unsafe_svg());
318 }
319 let document = Document::parse(svg).map_err(|_| unsafe_svg())?;
320 let root = document.root_element();
321 if root.tag_name().name() != "svg"
322 || root.tag_name().namespace() != Some(SVG_NAMESPACE)
323 || parse_view_box(root.attribute("viewBox")) != Some(expected_view_box)
324 {
325 return Err(unsafe_svg());
326 }
327
328 let mut declared = BTreeSet::new();
329 let mut referenced = BTreeSet::new();
330 for node in document.descendants() {
331 match node.node_type() {
332 NodeType::Root => continue,
333 NodeType::Text if node.text().is_some_and(|text| text.trim().is_empty()) => continue,
334 NodeType::Element => {}
335 _ => return Err(unsafe_svg()),
336 }
337 let name = node.tag_name().name();
338 let parent_name = node.parent_element().map(|parent| parent.tag_name().name());
339 if !ALLOWED_ELEMENTS.contains(&name)
340 || node.tag_name().namespace() != Some(SVG_NAMESPACE)
341 || (name == "svg" && node != root)
342 || (name == "defs" && parent_name != Some("svg"))
343 || (matches!(
344 name,
345 "linearGradient" | "radialGradient" | "clipPath" | "mask"
346 ) && parent_name != Some("defs"))
347 || (name == "stop" && !matches!(parent_name, Some("linearGradient" | "radialGradient")))
348 || (parent_name == Some("defs")
349 && !matches!(
350 name,
351 "linearGradient" | "radialGradient" | "clipPath" | "mask"
352 ))
353 {
354 return Err(unsafe_svg());
355 }
356 for attribute in node.attributes() {
357 let attribute_name = attribute.name();
358 if attribute.namespace().is_some()
359 || attribute_name.starts_with("on")
360 || !ALLOWED_ATTRIBUTES.contains(&attribute_name)
361 {
362 return Err(unsafe_svg());
363 }
364 if attribute_name == "id"
365 && (!matches!(
366 name,
367 "linearGradient" | "radialGradient" | "clipPath" | "mask"
368 ) || !attribute.value().starts_with("stack-")
369 || !declared.insert(attribute.value()))
370 {
371 return Err(unsafe_svg());
372 }
373 if let Some(identifier) = local_url_reference(attribute.value()) {
374 if !matches!(attribute_name, "fill" | "stroke" | "clip-path" | "mask") {
375 return Err(unsafe_svg());
376 }
377 referenced.insert(identifier);
378 } else if attribute_name == "href" {
379 let Some(identifier) = fragment_reference(attribute.value()) else {
380 return Err(unsafe_svg());
381 };
382 if !matches!(name, "linearGradient" | "radialGradient") {
383 return Err(unsafe_svg());
384 }
385 referenced.insert(identifier);
386 } else if contains_unsafe_reference(attribute.value()) {
387 return Err(unsafe_svg());
388 }
389 }
390 }
391 if referenced
392 .iter()
393 .any(|identifier| !declared.contains(identifier))
394 {
395 return Err(unsafe_svg());
396 }
397 Ok(())
398}
399
400fn pack_revision(
401 manifest: &ProviderPackManifest,
402 assets: &[ProviderAsset],
403) -> OperationResult<String> {
404 let manifest = serde_json::to_vec(manifest)
405 .map_err(|_| invalid_pack("provider pack manifest cannot be serialized"))?;
406 let mut digest = Sha256::new();
407 digest.update(b"stack-provider-pack-v1\0");
408 digest.update(manifest);
409 let mut assets = assets.iter().collect::<Vec<_>>();
410 assets.sort_by(|left, right| left.path.cmp(&right.path));
411 for asset in assets {
412 digest.update(b"asset\0");
413 digest.update(asset.path.as_bytes());
414 digest.update(b"\0");
415 digest.update(asset.svg.as_bytes());
416 }
417 Ok(prefixed_digest(digest.finalize()))
418}
419
420fn sha256(bytes: &[u8]) -> String {
421 prefixed_digest(Sha256::digest(bytes))
422}
423
424fn prefixed_digest(digest: impl AsRef<[u8]>) -> String {
425 let mut output = String::from("sha256:");
426 for byte in digest.as_ref() {
427 let _ = write!(output, "{byte:02x}");
428 }
429 output
430}
431
432fn parse_view_box(value: Option<&str>) -> Option<[i32; 4]> {
433 let value = value?;
434 let values = value
435 .split(|character: char| character.is_ascii_whitespace() || character == ',')
436 .filter(|value| !value.is_empty())
437 .map(str::parse::<i32>)
438 .collect::<Result<Vec<_>, _>>()
439 .ok()?;
440 (values.len() == 4 && values[2] > 0 && values[3] > 0)
441 .then(|| [values[0], values[1], values[2], values[3]])
442}
443
444fn local_url_reference(value: &str) -> Option<&str> {
445 value
446 .strip_prefix("url(#")
447 .and_then(|value| value.strip_suffix(')'))
448 .filter(|value| !value.is_empty())
449}
450
451fn fragment_reference(value: &str) -> Option<&str> {
452 value.strip_prefix('#').filter(|value| !value.is_empty())
453}
454
455fn contains_unsafe_reference(value: &str) -> bool {
456 let lowercase = value.to_ascii_lowercase();
457 lowercase.contains("url(")
458 || lowercase.contains("javascript:")
459 || lowercase.contains("data:")
460 || lowercase.contains("http://")
461 || lowercase.contains("https://")
462 || lowercase.contains("//")
463}
464
465fn valid_provider_id(value: &str) -> bool {
466 (2..=32).contains(&value.len())
467 && value.bytes().enumerate().all(|(index, byte)| {
468 if index == 0 {
469 byte.is_ascii_lowercase()
470 } else {
471 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'
472 }
473 })
474}
475
476fn valid_namespaced_icon_id(value: &str) -> bool {
477 let Some((provider, slug)) = value.split_once(':') else {
478 return false;
479 };
480 valid_provider_id(provider)
481 && (1..=64).contains(&slug.len())
482 && slug
483 .bytes()
484 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
485}
486
487fn valid_asset_path(value: &str) -> bool {
488 value.starts_with("assets/")
489 && value.ends_with(".svg")
490 && !value.contains('\0')
491 && !value.split('/').any(|component| component == "..")
492}
493
494fn valid_sha256(value: &str) -> bool {
495 value.strip_prefix("sha256:").is_some_and(|digest| {
496 digest.len() == 64
497 && digest
498 .bytes()
499 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
500 })
501}
502
503fn invalid_pack(reason: &'static str) -> OperationalError {
504 OperationalError::InvalidProviderPack { reason }
505}
506
507fn unsafe_svg() -> OperationalError {
508 invalid_pack("provider pack asset contains unsafe or unsupported SVG")
509}
510
511#[cfg(test)]
512mod tests {
513 use std::error::Error;
514
515 use serde::Deserialize;
516 use stack_theme::ProviderPackAdditionalSource;
517
518 use super::*;
519 use crate::{Engine, Severity};
520
521 #[derive(Deserialize)]
522 #[serde(rename_all = "camelCase", deny_unknown_fields)]
523 struct FixtureInput {
524 manifest: ProviderPackManifest,
525 assets: Vec<FixtureAsset>,
526 }
527
528 #[derive(Deserialize)]
529 #[serde(rename_all = "camelCase", deny_unknown_fields)]
530 struct FixtureAsset {
531 path: String,
532 svg: String,
533 }
534
535 fn fixture_input() -> Result<FixtureInput, Box<dyn Error>> {
536 let mut inputs: Vec<FixtureInput> =
537 serde_json::from_str(include_str!("../tests/fixtures/provider-pack-input.json"))?;
538 inputs
539 .pop()
540 .ok_or_else(|| "missing provider fixture".into())
541 }
542
543 fn fixture_pack() -> Result<ProviderPack, Box<dyn Error>> {
544 let input = fixture_input()?;
545 Ok(ProviderPack::new(
546 input.manifest,
547 input
548 .assets
549 .into_iter()
550 .map(|asset| ProviderAsset::new(asset.path, asset.svg))
551 .collect(),
552 )?)
553 }
554
555 fn pack_with_svg(svg: &str) -> Result<ProviderPack, Box<dyn Error>> {
556 let mut input = fixture_input()?;
557 let digest = sha256(svg.as_bytes());
558 input.manifest.icons[0].asset.original_sha256 = digest.clone();
559 input.manifest.icons[0].asset.processed_sha256 = digest;
560 input.assets[0].svg = svg.to_owned();
561 Ok(ProviderPack::new(
562 input.manifest,
563 input
564 .assets
565 .into_iter()
566 .map(|asset| ProviderAsset::new(asset.path, asset.svg))
567 .collect(),
568 )?)
569 }
570
571 #[test]
572 fn valid_pack_is_content_addressed_and_renders_without_changing_kind()
573 -> Result<(), Box<dyn Error>> {
574 let pack = fixture_pack()?;
575 assert_eq!(pack.manifest().provider.id, "example");
576 assert!(valid_sha256(pack.revision()));
577 assert_eq!(pack.assets[0].path(), "assets/storage.svg");
578 assert!(pack.assets[0].svg().contains("#4285f4"));
579
580 let packs = [pack];
581 let engine = Engine::with_provider_packs(&packs)?;
582 let source = b"stack 1.0 diagram \"Provider\" { node item \"Example Storage\" { kind queue icon \"example:storage\" } }";
583 let checked = engine.check(source)?;
584 let first = engine.render(source)?;
585 let second = engine.render(source)?;
586 assert!(checked.diagnostics.is_empty());
587 assert_eq!(first, second);
588 let svg = first.svg.ok_or("missing rendered SVG")?;
589 assert!(svg.contains("data-node-kind=\"queue\""));
590 assert!(svg.contains("data-icon-id=\"example:storage\""));
591 assert!(svg.contains("fill=\"#4285f4\""));
592 assert!(svg.contains(packs[0].revision()));
593 assert_eq!(first.provider_notices.len(), 1);
594 assert_eq!(first.provider_notices[0].provider_id, "example");
595 assert_eq!(first.provider_notices[0].sources.len(), 1);
596 assert_eq!(first.provider_notices[0].sources[0].id, "primary");
597 assert_eq!(first.provider_notices[0].icons[0].id, "example:storage");
598 assert_eq!(first.provider_notices[0].icons[0].source_id, "primary");
599 assert_eq!(
600 first.provider_notices[0].icons[0].product_name,
601 "Example Storage"
602 );
603 Ok(())
604 }
605
606 #[test]
607 fn missing_pack_preserves_the_existing_warning_and_fallback() -> Result<(), Box<dyn Error>> {
608 let source = b"stack 1.0 diagram \"Missing\" { node item \"Data\" { kind storage icon \"example:storage\" } }";
609 let output = Engine::bundled().render(source)?;
610 assert_eq!(output.diagnostics.len(), 1);
611 assert_eq!(output.diagnostics[0].code, "STK5001");
612 assert_eq!(output.diagnostics[0].severity, Severity::Warning);
613 assert!(output.provider_notices.is_empty());
614 assert!(
615 output
616 .svg
617 .ok_or("missing fallback SVG")?
618 .contains("data-icon-id=\"kind-external\"")
619 );
620 Ok(())
621 }
622
623 #[test]
624 fn multi_source_pack_validates_and_reports_exact_icon_provenance() -> Result<(), Box<dyn Error>>
625 {
626 let mut input = fixture_input()?;
627 input.manifest.schema_version = "1.1".to_owned();
628 input
629 .manifest
630 .additional_sources
631 .push(ProviderPackAdditionalSource {
632 id: "categories".to_owned(),
633 source: input.manifest.source.clone(),
634 });
635 input.manifest.icons[0].asset.source_id = Some("categories".to_owned());
636 input.manifest.icons[0].brand_source_url = Some("https://example.com/brand".to_owned());
637 input.manifest.icons[0].brand_guidelines_url =
638 Some("https://example.com/guidelines".to_owned());
639 let pack = ProviderPack::new(
640 input.manifest,
641 input
642 .assets
643 .into_iter()
644 .map(|asset| ProviderAsset::new(asset.path, asset.svg))
645 .collect(),
646 )?;
647 let packs = [pack];
648 let output = Engine::with_provider_packs(&packs)?.render(
649 b"stack 1.0 diagram \"Provider\" { node item \"Storage\" { icon \"example:storage\" } }",
650 )?;
651
652 assert_eq!(output.provider_notices[0].sources.len(), 2);
653 assert_eq!(output.provider_notices[0].sources[1].id, "categories");
654 assert_eq!(output.provider_notices[0].icons[0].source_id, "categories");
655 assert_eq!(
656 output.provider_notices[0].icons[0]
657 .brand_guidelines_url
658 .as_deref(),
659 Some("https://example.com/guidelines")
660 );
661 Ok(())
662 }
663
664 #[test]
665 fn multi_source_pack_rejects_duplicate_unknown_and_version_mismatched_sources()
666 -> Result<(), Box<dyn Error>> {
667 let input = fixture_input()?;
668 let assets = input
669 .assets
670 .iter()
671 .map(|asset| ProviderAsset::new(&asset.path, &asset.svg))
672 .collect::<Vec<_>>();
673
674 let mut duplicate = input.manifest.clone();
675 duplicate.schema_version = "1.1".to_owned();
676 duplicate.additional_sources = vec![
677 ProviderPackAdditionalSource {
678 id: "categories".to_owned(),
679 source: duplicate.source.clone(),
680 },
681 ProviderPackAdditionalSource {
682 id: "categories".to_owned(),
683 source: duplicate.source.clone(),
684 },
685 ];
686 assert!(ProviderPack::new(duplicate, assets.clone()).is_err());
687
688 let mut unknown = input.manifest.clone();
689 unknown.schema_version = "1.1".to_owned();
690 unknown.icons[0].asset.source_id = Some("categories".to_owned());
691 assert!(ProviderPack::new(unknown, assets.clone()).is_err());
692
693 let mut version_mismatch = input.manifest;
694 version_mismatch
695 .additional_sources
696 .push(ProviderPackAdditionalSource {
697 id: "categories".to_owned(),
698 source: version_mismatch.source.clone(),
699 });
700 assert!(ProviderPack::new(version_mismatch, assets).is_err());
701 Ok(())
702 }
703
704 #[test]
705 fn manifest_boundary_rejects_invalid_rights_identity_and_records() -> Result<(), Box<dyn Error>>
706 {
707 let input = fixture_input()?;
708 let assets = input
709 .assets
710 .iter()
711 .map(|asset| ProviderAsset::new(&asset.path, &asset.svg))
712 .collect::<Vec<_>>();
713
714 for mutate in [
715 |manifest: &mut ProviderPackManifest| manifest.schema_version = "2.0".to_owned(),
716 |manifest: &mut ProviderPackManifest| manifest.rights.redistribution.cargo = true,
717 |manifest: &mut ProviderPackManifest| manifest.rights.processing.server_upload = true,
718 ] {
719 let mut manifest = input.manifest.clone();
720 mutate(&mut manifest);
721 assert!(matches!(
722 ProviderPack::new(manifest, assets.clone()),
723 Err(OperationalError::InvalidProviderPack { .. })
724 ));
725 }
726
727 for mutate in [
728 |manifest: &mut ProviderPackManifest| manifest.provider.id = "X".to_owned(),
729 |manifest: &mut ProviderPackManifest| manifest.source.archive_sha256 = "bad".to_owned(),
730 |manifest: &mut ProviderPackManifest| manifest.notice.attribution.clear(),
731 ] {
732 let mut manifest = input.manifest.clone();
733 mutate(&mut manifest);
734 assert!(matches!(
735 ProviderPack::new(manifest, assets.clone()),
736 Err(OperationalError::InvalidProviderPack { .. })
737 ));
738 }
739
740 for mutate in [
741 |manifest: &mut ProviderPackManifest| manifest.icons[0].id = "other:storage".to_owned(),
742 |manifest: &mut ProviderPackManifest| {
743 manifest.icons[0].asset.path = "../icon.svg".to_owned()
744 },
745 |manifest: &mut ProviderPackManifest| manifest.icons[0].asset.view_box[2] = 0,
746 ] {
747 let mut manifest = input.manifest.clone();
748 mutate(&mut manifest);
749 assert!(matches!(
750 ProviderPack::new(manifest, assets.clone()),
751 Err(OperationalError::InvalidProviderPack { .. })
752 ));
753 }
754 Ok(())
755 }
756
757 #[test]
758 fn assets_must_be_exact_unique_small_and_hash_matched() -> Result<(), Box<dyn Error>> {
759 let input = fixture_input()?;
760 assert!(ProviderPack::new(input.manifest.clone(), Vec::new()).is_err());
761 assert!(
762 ProviderPack::new(
763 input.manifest.clone(),
764 vec![ProviderAsset::new("assets/other.svg", &input.assets[0].svg)],
765 )
766 .is_err()
767 );
768 assert!(
769 ProviderPack::new(
770 input.manifest.clone(),
771 vec![ProviderAsset::new(
772 &input.assets[0].path,
773 format!("{} ", input.assets[0].svg),
774 )],
775 )
776 .is_err()
777 );
778 assert!(
779 ProviderPack::new(
780 input.manifest,
781 vec![ProviderAsset::new(
782 "assets/storage.svg",
783 "x".repeat(MAX_PROVIDER_ASSET_BYTES + 1),
784 )],
785 )
786 .is_err()
787 );
788 Ok(())
789 }
790
791 #[test]
792 fn total_asset_bytes_are_bounded() -> Result<(), Box<dyn Error>> {
793 let mut input = fixture_input()?;
794 let original = input.manifest.icons[0].clone();
795 input.manifest.icons = (0..33)
796 .map(|index| {
797 let mut icon = original.clone();
798 icon.id = format!("example:storage-{index}");
799 icon.asset.path = format!("assets/storage-{index}.svg");
800 icon
801 })
802 .collect();
803 let assets = (0..33)
804 .map(|index| {
805 ProviderAsset::new(
806 format!("assets/storage-{index}.svg"),
807 "x".repeat(MAX_PROVIDER_ASSET_BYTES),
808 )
809 })
810 .collect();
811 assert!(matches!(
812 ProviderPack::new(input.manifest, assets),
813 Err(OperationalError::InvalidProviderPack {
814 reason: "provider pack assets exceed the total size limit"
815 })
816 ));
817 Ok(())
818 }
819
820 #[test]
821 fn unsafe_or_unsupported_svg_is_rejected_after_hash_verification() {
822 for svg in [
823 "<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"/>",
824 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><script/></svg>",
825 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path onload=\"alert(1)\"/></svg>",
826 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"https://example.com/icon.svg\"/></svg>",
827 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"url(#missing)\"/></svg>",
828 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\"/>",
829 "<svg viewBox=\"0 0 24 24\"/>",
830 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">visible</svg>",
831 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><svg viewBox=\"0 0 1 1\"/></svg>",
832 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><defs><path/></defs></svg>",
833 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path id=\"shape\"/></svg>",
834 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><defs><linearGradient id=\"stack-paint\"/></defs><path d=\"url(#stack-paint)\"/></svg>",
835 ] {
836 assert!(
837 pack_with_svg(svg).is_err(),
838 "unsafe SVG was accepted: {svg}"
839 );
840 }
841 }
842
843 #[test]
844 fn namespaced_local_gradients_are_accepted() {
845 let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><defs><linearGradient id=\"stack-paint\"><stop offset=\"0\" stop-color=\"#000000\"/><stop offset=\"1\" stop-color=\"#ffffff\"/></linearGradient></defs><path fill=\"url(#stack-paint)\" d=\"M0 0h24v24H0z\"/></svg>";
846 assert!(pack_with_svg(svg).is_ok());
847 }
848
849 #[test]
850 fn namespaced_local_clip_paths_masks_and_gradient_inheritance_are_accepted() {
851 let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><defs><clipPath id=\"stack-clip\"><rect x=\"0\" y=\"0\" width=\"24\" height=\"24\"/></clipPath><mask id=\"stack-mask\" maskUnits=\"userSpaceOnUse\"><rect x=\"0\" y=\"0\" width=\"24\" height=\"24\" fill=\"#ffffff\"/></mask><linearGradient id=\"stack-base\"><stop offset=\"0\" stop-color=\"#000000\"/><stop offset=\"1\" stop-color=\"#ffffff\"/></linearGradient><linearGradient id=\"stack-paint\" href=\"#stack-base\"/></defs><path clip-path=\"url(#stack-clip)\" mask=\"url(#stack-mask)\" fill=\"url(#stack-paint)\" fill-opacity=\"0.5\" d=\"M0 0h24v24H0z\"/></svg>";
852 assert!(pack_with_svg(svg).is_ok());
853 }
854
855 #[test]
856 fn duplicate_provider_namespaces_and_excessive_pack_counts_are_rejected()
857 -> Result<(), Box<dyn Error>> {
858 let pack = fixture_pack()?;
859 assert!(matches!(
860 Engine::with_provider_packs(&[pack.clone(), pack.clone()]),
861 Err(OperationalError::InvalidProviderPack {
862 reason: "provider namespaces must be unique"
863 })
864 ));
865 let packs = vec![pack; 33];
866 assert!(matches!(
867 Engine::with_provider_packs(&packs),
868 Err(OperationalError::InvalidProviderPack {
869 reason: "an engine may contain at most 32 provider packs"
870 })
871 ));
872 Ok(())
873 }
874}