Skip to main content

stack_engine/
provider.rs

1//! Validated caller-owned provider packs for pure rendering.
2
3use 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/// One caller-owned processed SVG addressed by its provider-manifest path.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ProviderAsset {
83    path: String,
84    svg: String,
85}
86
87impl ProviderAsset {
88    /// Creates one in-memory provider asset without reading host state.
89    #[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    /// Returns the manifest-relative asset path.
98    #[must_use]
99    pub fn path(&self) -> &str {
100        &self.path
101    }
102
103    /// Returns the complete processed SVG document.
104    #[must_use]
105    pub fn svg(&self) -> &str {
106        &self.svg
107    }
108}
109
110/// One validated, content-addressed provider pack held entirely in memory.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ProviderPack {
113    manifest: ProviderPackManifest,
114    revision: String,
115    assets: Vec<ProviderAsset>,
116}
117
118impl ProviderPack {
119    /// Validates a typed manifest and its exact processed assets.
120    ///
121    /// The host remains responsible for JSON Schema validation, terms review,
122    /// and local file selection. This constructor rechecks the invariants needed
123    /// for safe deterministic embedding and computes a content revision without
124    /// filesystem, network, clock, or environment access.
125    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    /// Returns the validated public provider-pack manifest.
140    #[must_use]
141    pub fn manifest(&self) -> &ProviderPackManifest {
142        &self.manifest
143    }
144
145    /// Returns the deterministic manifest-and-assets SHA-256 revision.
146    #[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}