1mod builtin;
2mod context;
3mod embedding;
4mod final_validation;
5mod policy;
6mod preset;
7mod static_validation;
8
9pub(crate) use builtin::util::{
10 SvgTagScanner, checkpoint_loop, end_tag_name, escape_xml_attr_with_checkpoints,
11 escape_xml_text_with_checkpoints, extract_exact_double_quoted_attr_with_checkpoints,
12 find_tag_end_with_checkpoints, find_with_checkpoints, rfind_with_checkpoints, start_tag_name,
13 trim_with_checkpoints,
14};
15pub use builtin::{
16 CssOverridePolicy, CssOverridePostprocessor, ForeignObjectFallbackPostprocessor,
17 RootBackgroundPostprocessor, SanitizeCssPostprocessor, SanitizeSvgAttributesPostprocessor,
18 ScopedCssPostprocessor, StripForeignObjectPostprocessor,
19};
20pub(crate) use builtin::{GitGraphBranchLabelBaselinePostprocessor, RebaseSvgIdsPostprocessor};
21pub(crate) use context::SvgPostprocessExecution;
22pub use context::{SvgPostprocessContext, SvgPostprocessMetadata};
23pub(crate) use final_validation::{SvgStructureMetrics, validate_well_formed_svg_with_controls};
24pub use policy::SvgOutputPolicy;
25pub use preset::SvgPipelinePreset;
26
27pub(crate) fn is_css_value_attribute(name: &str) -> bool {
28 matches!(
29 name.to_ascii_lowercase().as_str(),
30 "style"
31 | "background"
32 | "background-image"
33 | "fill"
34 | "stroke"
35 | "filter"
36 | "clip-path"
37 | "mask"
38 | "marker"
39 | "marker-start"
40 | "marker-mid"
41 | "marker-end"
42 | "cursor"
43 | "color-profile"
44 )
45}
46
47pub(crate) fn is_svg_idref_attribute(name: &str) -> bool {
48 matches!(
49 name.to_ascii_lowercase().as_str(),
50 "aria-activedescendant"
51 | "aria-controls"
52 | "aria-describedby"
53 | "aria-details"
54 | "aria-errormessage"
55 | "aria-flowto"
56 | "aria-labelledby"
57 | "aria-owns"
58 )
59}
60
61#[doc(hidden)]
63pub fn rebase_svg_ids(
64 svg: &str,
65 prefix: impl Into<String>,
66 session: &RenderSession,
67) -> Result<String> {
68 SvgPipeline::parity()
69 .with_rebased_ids(prefix)
70 .process_to_string(svg, session)
71}
72
73#[doc(hidden)]
79pub fn validate_static_inline_svg(svg: &str, session: &RenderSession) -> Result<()> {
80 static_validation::validate_rustdoc_static_svg(svg, SvgPostprocessExecution::new(session))
81}
82
83#[doc(hidden)]
85pub fn validate_static_inline_svg_admission(svg: &str, session: &RenderSession) -> Result<()> {
86 static_validation::validate_rustdoc_admission_svg(svg, SvgPostprocessExecution::new(session))
87}
88
89use crate::environment::RenderSession;
90use crate::{Error, Result};
91use std::borrow::Cow;
92use std::fmt;
93use std::sync::Arc;
94
95pub trait SvgPostprocessor: Send + Sync {
96 fn name(&self) -> &'static str;
97
98 fn scoped_css(&self) -> Option<&str> {
100 None
101 }
102
103 fn process<'a>(
104 &self,
105 svg: Cow<'a, str>,
106 ctx: &SvgPostprocessContext<'_>,
107 ) -> Result<Cow<'a, str>>;
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct ResvgCompatibleSvg {
124 svg: String,
125 reference_plan: SvgReferencePlan,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct SvgReferencePlan {
135 expanded_elements: usize,
136 max_tree_depth: usize,
137 raw_element_occurrences: Box<[usize]>,
138}
139
140impl SvgReferencePlan {
141 pub const fn expanded_elements(&self) -> usize {
143 self.expanded_elements
144 }
145
146 pub const fn max_tree_depth(&self) -> usize {
148 self.max_tree_depth
149 }
150
151 pub fn raw_element_occurrences(&self) -> &[usize] {
153 &self.raw_element_occurrences
154 }
155}
156
157impl ResvgCompatibleSvg {
158 fn finalized(svg: String, reference_plan: SvgReferencePlan) -> Self {
159 Self {
160 svg,
161 reference_plan,
162 }
163 }
164
165 pub fn as_str(&self) -> &str {
166 &self.svg
167 }
168
169 pub fn into_string(self) -> String {
170 self.svg
171 }
172
173 pub const fn reference_plan(&self) -> &SvgReferencePlan {
175 &self.reference_plan
176 }
177}
178
179impl AsRef<str> for ResvgCompatibleSvg {
180 fn as_ref(&self) -> &str {
181 self.as_str()
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum InlineContract {
187 Browser,
188 Static,
189}
190
191#[derive(Clone)]
192pub struct SvgPipeline {
193 preset: SvgPipelinePreset,
194 postprocessors: Vec<Arc<dyn SvgPostprocessor>>,
195 drop_native_duplicate_fallbacks: bool,
196 inline_contract: Option<InlineContract>,
197 isolate_ids: bool,
198}
199
200impl fmt::Debug for SvgPipeline {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 let names = self
203 .postprocessors
204 .iter()
205 .map(|pass| pass.name())
206 .collect::<Vec<_>>();
207
208 f.debug_struct("SvgPipeline")
209 .field("preset", &self.preset)
210 .field("postprocessors", &names)
211 .field(
212 "drop_native_duplicate_fallbacks",
213 &self.drop_native_duplicate_fallbacks,
214 )
215 .field("inline_contract", &self.inline_contract)
216 .field("isolate_ids", &self.isolate_ids)
217 .finish()
218 }
219}
220
221impl Default for SvgPipeline {
222 fn default() -> Self {
223 Self::parity()
224 }
225}
226
227impl SvgPipeline {
228 pub fn parity() -> Self {
230 Self::from_preset(SvgPipelinePreset::Parity)
231 }
232
233 pub fn readable() -> Self {
238 Self::from_preset(SvgPipelinePreset::Readable)
239 }
240
241 pub fn resvg_safe() -> Self {
243 Self::from_preset(SvgPipelinePreset::ResvgSafe)
244 }
245
246 pub fn from_preset(preset: SvgPipelinePreset) -> Self {
247 Self {
248 preset,
249 postprocessors: Vec::new(),
250 drop_native_duplicate_fallbacks: false,
251 inline_contract: None,
252 isolate_ids: false,
253 }
254 }
255
256 pub fn preset(&self) -> SvgPipelinePreset {
257 self.preset
258 }
259
260 pub fn with_preset(mut self, preset: SvgPipelinePreset) -> Self {
262 self.preset = preset;
263 self
264 }
265
266 pub fn into_resvg_safe(self) -> Self {
267 self.with_preset(SvgPipelinePreset::ResvgSafe)
268 }
269
270 pub fn with_drop_native_duplicate_fallbacks(mut self, drop: bool) -> Self {
271 self.drop_native_duplicate_fallbacks = drop;
272 self
273 }
274
275 #[doc(hidden)]
281 pub fn with_static_inline_contract(mut self, id_prefix: impl Into<String>) -> Self {
282 self.inline_contract = Some(InlineContract::Static);
283 self.postprocessors
284 .push(Arc::new(ForeignObjectFallbackPostprocessor));
285 self.postprocessors.push(Arc::new(SanitizeCssPostprocessor));
286 self.postprocessors
287 .push(Arc::new(SanitizeSvgAttributesPostprocessor));
288 self.with_rebased_ids(id_prefix)
289 }
290
291 #[doc(hidden)]
301 pub fn with_browser_inline_contract(mut self, id_prefix: impl Into<String>) -> Self {
302 self.inline_contract = Some(InlineContract::Browser);
303 self.with_rebased_ids(id_prefix)
304 }
305
306 #[doc(hidden)]
311 pub fn with_rebased_ids(mut self, id_prefix: impl Into<String>) -> Self {
312 self.isolate_ids = true;
313 self.with_postprocessor(RebaseSvgIdsPostprocessor::new(id_prefix))
314 }
315
316 pub fn with_postprocessor<P>(mut self, postprocessor: P) -> Self
317 where
318 P: SvgPostprocessor + 'static,
319 {
320 self.postprocessors.push(Arc::new(postprocessor));
321 self
322 }
323
324 pub fn with_shared_postprocessor(mut self, postprocessor: Arc<dyn SvgPostprocessor>) -> Self {
325 self.postprocessors.push(postprocessor);
326 self
327 }
328
329 pub fn push_postprocessor<P>(&mut self, postprocessor: P)
330 where
331 P: SvgPostprocessor + 'static,
332 {
333 self.postprocessors.push(Arc::new(postprocessor));
334 }
335
336 pub fn has_scoped_css(&self) -> bool {
338 self.postprocessors.iter().any(|postprocessor| {
339 postprocessor
340 .scoped_css()
341 .is_some_and(|css| !css.trim().is_empty())
342 })
343 }
344
345 pub fn process<'a>(&self, svg: &'a str, session: &RenderSession) -> Result<Cow<'a, str>> {
346 let execution = SvgPostprocessExecution::new(session);
347 let metadata = SvgPostprocessMetadata::from_svg_with_execution(svg, execution)?;
348 self.process_with_metadata(svg, &metadata, session)
349 }
350
351 pub fn process_with_metadata<'a>(
352 &self,
353 svg: &'a str,
354 metadata: &SvgPostprocessMetadata,
355 session: &RenderSession,
356 ) -> Result<Cow<'a, str>> {
357 self.process_cow_with_metadata(Cow::Borrowed(svg), metadata, session)
358 }
359
360 fn process_cow_with_metadata<'a>(
361 &self,
362 svg: Cow<'a, str>,
363 metadata: &SvgPostprocessMetadata,
364 session: &RenderSession,
365 ) -> Result<Cow<'a, str>> {
366 Ok(self
367 .process_cow_with_reference_plan(svg, metadata, session)?
368 .0)
369 }
370
371 fn process_cow_with_reference_plan<'a>(
372 &self,
373 svg: Cow<'a, str>,
374 metadata: &SvgPostprocessMetadata,
375 session: &RenderSession,
376 ) -> Result<(Cow<'a, str>, Option<SvgReferencePlan>)> {
377 let execution = SvgPostprocessExecution::new(session);
378 execution.checkpoint()?;
379 execution.preflight_svg_byte_count(svg.len())?;
380 let mut current =
381 crate::xml::strip_forbidden_xml_1_0_chars_cow_with_checkpoints(svg, || {
382 execution.checkpoint()
383 })?;
384 execution.preflight_svg_byte_count(current.len())?;
385 let mut structure = match final_validation::validate_well_formed_svg_with_execution(
386 current.as_ref(),
387 execution,
388 ) {
389 Ok(structure) => structure,
390 Err(initial_error) if self.preset == SvgPipelinePreset::ResvgSafe => {
391 let sanitized =
396 builtin::attr_sanitize::sanitize_element_attributes_cow_with_checkpoints(
397 current.clone(),
398 &mut || execution.checkpoint(),
399 )?;
400 execution.preflight_svg_byte_count(sanitized.len())?;
401 if sanitized.as_ref() == current.as_ref() {
402 return Err(initial_error);
403 }
404 current = sanitized;
405 final_validation::validate_well_formed_svg_with_execution(
406 current.as_ref(),
407 execution,
408 )?
409 }
410 Err(error) => return Err(error),
411 };
412 if self.isolate_ids {
413 current = embedding::normalize_renderer_ids(current, metadata, execution)?;
414 }
415 if self.inline_contract.is_some() {
416 static_validation::validate_rustdoc_admission_svg(current.as_ref(), execution)?;
417 }
418
419 for (index, postprocessor) in self.postprocessors.iter().enumerate() {
420 execution.checkpoint()?;
421 let ctx = SvgPostprocessContext::new(
422 self.preset,
423 index,
424 postprocessor.name(),
425 metadata,
426 session,
427 );
428 let result = postprocessor.process(current, &ctx);
429 execution.checkpoint()?;
430 current = match result {
431 Ok(current) => current,
432 Err(Error::Cancelled(error)) => return Err(Error::Cancelled(error)),
433 Err(Error::ResourceLimitExceeded(error)) => {
434 return Err(execution.terminate_resource_error(error));
435 }
436 Err(error) => {
437 return Err(Error::svg_postprocess(
438 postprocessor.name(),
439 error.to_string(),
440 ));
441 }
442 };
443 execution.preflight_svg_byte_count(current.len())?;
444 structure = final_validation::validate_well_formed_svg_with_execution(
445 current.as_ref(),
446 execution,
447 )?;
448 }
449
450 execution.checkpoint()?;
451 let finalized = preset::apply_preset_cow(
452 self.preset,
453 current,
454 metadata,
455 execution,
456 structure,
457 self.drop_native_duplicate_fallbacks,
458 )?;
459 let finalized =
460 crate::xml::strip_forbidden_xml_1_0_chars_cow_with_checkpoints(finalized, || {
461 execution.checkpoint()
462 })?;
463 execution.preflight_svg_byte_count(finalized.len())?;
464 match self.inline_contract {
465 Some(InlineContract::Static) => {
466 static_validation::validate_rustdoc_static_svg(finalized.as_ref(), execution)?;
467 }
468 Some(InlineContract::Browser) => {
469 static_validation::validate_rustdoc_admission_svg(finalized.as_ref(), execution)?;
470 }
471 None => {}
472 }
473 let reference_plan = if self.preset == SvgPipelinePreset::ResvgSafe {
474 Some(
475 final_validation::validate_resvg_compatible_svg_with_execution(
476 finalized.as_ref(),
477 execution,
478 )?,
479 )
480 } else {
481 final_validation::validate_well_formed_svg_with_execution(
482 finalized.as_ref(),
483 execution,
484 )?;
485 None
486 };
487 execution.checkpoint()?;
488 Ok((finalized, reference_plan))
489 }
490
491 pub fn process_to_string(&self, svg: &str, session: &RenderSession) -> Result<String> {
492 Ok(self.process(svg, session)?.into_owned())
493 }
494
495 pub fn process_owned_to_string(&self, svg: String, session: &RenderSession) -> Result<String> {
496 let execution = SvgPostprocessExecution::new(session);
497 let metadata = SvgPostprocessMetadata::from_svg_with_execution(&svg, execution)?;
498 self.process_owned_to_string_with_metadata(svg, &metadata, session)
499 }
500
501 pub fn process_to_string_with_metadata(
502 &self,
503 svg: &str,
504 metadata: &SvgPostprocessMetadata,
505 session: &RenderSession,
506 ) -> Result<String> {
507 Ok(self
508 .process_with_metadata(svg, metadata, session)?
509 .into_owned())
510 }
511
512 pub fn process_owned_to_string_with_metadata(
513 &self,
514 svg: String,
515 metadata: &SvgPostprocessMetadata,
516 session: &RenderSession,
517 ) -> Result<String> {
518 Ok(self
519 .process_cow_with_metadata(Cow::Owned(svg), metadata, session)?
520 .into_owned())
521 }
522
523 pub fn process_resvg_compatible(
524 &self,
525 svg: &str,
526 session: &RenderSession,
527 ) -> Result<ResvgCompatibleSvg> {
528 let execution = SvgPostprocessExecution::new(session);
529 let metadata = SvgPostprocessMetadata::from_svg_with_execution(svg, execution)?;
530 self.process_resvg_compatible_with_metadata(svg, &metadata, session)
531 }
532
533 pub fn process_resvg_compatible_with_metadata(
534 &self,
535 svg: &str,
536 metadata: &SvgPostprocessMetadata,
537 session: &RenderSession,
538 ) -> Result<ResvgCompatibleSvg> {
539 self.ensure_resvg_safe_contract()?;
540 let (svg, reference_plan) =
541 self.process_cow_with_reference_plan(Cow::Borrowed(svg), metadata, session)?;
542 Ok(ResvgCompatibleSvg::finalized(
543 svg.into_owned(),
544 reference_plan.expect("resvg-safe processing always produces a reference plan"),
545 ))
546 }
547
548 pub fn process_owned_resvg_compatible_with_metadata(
549 &self,
550 svg: String,
551 metadata: &SvgPostprocessMetadata,
552 session: &RenderSession,
553 ) -> Result<ResvgCompatibleSvg> {
554 self.ensure_resvg_safe_contract()?;
555 let (svg, reference_plan) =
556 self.process_cow_with_reference_plan(Cow::Owned(svg), metadata, session)?;
557 Ok(ResvgCompatibleSvg::finalized(
558 svg.into_owned(),
559 reference_plan.expect("resvg-safe processing always produces a reference plan"),
560 ))
561 }
562
563 fn ensure_resvg_safe_contract(&self) -> Result<()> {
564 if self.preset != SvgPipelinePreset::ResvgSafe {
565 return Err(Error::svg_postprocess(
566 "resvg-finalize",
567 "ResvgCompatibleSvg requires the resvg-safe terminal preset",
568 ));
569 }
570 Ok(())
571 }
572}
573
574pub fn finalize_resvg_svg(svg: &str, session: &RenderSession) -> Result<ResvgCompatibleSvg> {
576 SvgPipeline::resvg_safe().process_resvg_compatible(svg, session)
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use merman_core::{CancelReason, OperationControl, OperationPhase};
583
584 fn render_session() -> RenderSession {
585 crate::environment::RenderEnvironment::deterministic()
586 .begin_session()
587 .unwrap()
588 }
589
590 #[test]
591 fn parity_pipeline_preserves_svg_exactly() {
592 let svg = r#"<svg><style>@keyframes a{to{opacity:1}}</style><rect width="10"/></svg>"#;
593 let session = render_session();
594 let out = SvgPipeline::parity().process(svg, &session).unwrap();
595 assert!(matches!(out, Cow::Borrowed(_)));
596 assert_eq!(out, svg);
597 }
598
599 #[test]
600 fn browser_inline_contract_preserves_html_labels_and_rebases_references() {
601 let svg = r##"<svg id="root" xmlns="http://www.w3.org/2000/svg" aria-labelledby="title"><title id="title">Diagram</title><defs><marker id="arrow"/></defs><style>@keyframes dash{to{stroke-dashoffset:0}}#root .edge{animation:dash 20s linear infinite}#root #label{color:red}</style><path class="edge" marker-end="url(#arrow)"/><foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml" id="label" style="transform:rotate(5deg)"><strong>Safe label</strong></div></foreignObject></svg>"##;
602 let output = SvgPipeline::parity()
603 .with_browser_inline_contract("embed")
604 .process_to_string(svg, &render_session())
605 .unwrap();
606
607 assert!(output.contains("<foreignObject"), "{output}");
608 assert!(output.contains("<strong>Safe label</strong>"), "{output}");
609 assert!(output.contains("transform:rotate(5deg)"), "{output}");
610 assert!(output.contains("animation:dash 20s"), "{output}");
611 assert!(output.contains(r#"id="embed-root""#), "{output}");
612 assert!(
613 output.contains(r#"aria-labelledby="embed-title""#),
614 "{output}"
615 );
616 assert!(output.contains("url(#embed-arrow)"), "{output}");
617 assert!(output.contains("#embed-root #embed-label"), "{output}");
618 }
619
620 #[test]
621 fn browser_inline_contract_accepts_renderer_owned_family_styles() {
622 for (name, source) in [
623 ("error", "error"),
624 #[cfg(feature = "diagram-event-modeling")]
625 ("eventmodeling", "eventmodeling\ntf 01 event Start"),
626 #[cfg(feature = "diagram-ishikawa")]
627 (
628 "ishikawa",
629 "ishikawa-beta\n Problem\n Cause\n Detail",
630 ),
631 #[cfg(feature = "diagram-tree-view")]
632 ("treeView", "treeView-beta\n root/\n file.txt"),
633 #[cfg(feature = "diagram-zenuml")]
634 ("zenuml", "zenuml\nAlice->Bob: Hello"),
635 #[cfg(all(feature = "diagram-mindmap", feature = "layout-cytoscape"))]
636 ("mindmap", "mindmap\n root\n a\n b"),
637 #[cfg(all(feature = "diagram-architecture", feature = "layout-cytoscape"))]
638 (
639 "architecture",
640 "architecture-beta\nservice worker \"<a href='https://example.test'>Docs</a>\" [Worker]",
641 ),
642 ] {
643 let parsed = merman_core::Engine::new()
644 .parse_diagram_for_render_model_sync(source, merman_core::ParseOptions::strict())
645 .unwrap_or_else(|error| panic!("{name}: {error}"))
646 .unwrap();
647 let artifact =
648 crate::family::prepare(parsed, &crate::LayoutOptions::default(), render_session())
649 .unwrap_or_else(|error| panic!("{name}: {error}"));
650 let rendered = artifact
651 .render_svg(
652 &crate::svg::SvgRenderOptions {
653 diagram_id: Some(name.to_string()),
654 ..Default::default()
655 },
656 &crate::svg::SvgDebugOptions::default(),
657 )
658 .unwrap_or_else(|error| panic!("{name}: {error}"))
659 .apply_pipeline(&SvgPipeline::parity().with_browser_inline_contract("embed"))
660 .unwrap_or_else(|error| panic!("{name}: {error}"));
661 assert!(rendered.svg().contains(&format!("id=\"embed-{name}\"")));
662 }
663 }
664
665 #[test]
666 fn browser_inline_contract_rejects_external_html_and_escaped_css_resources() {
667 for (svg, expected) in [
668 (
669 r#"<svg><foreignObject><img xmlns="http://www.w3.org/1999/xhtml" src="https://tracker.test/image.png"/></foreignObject></svg>"#,
670 "forbidden <img>",
671 ),
672 (
673 r#"<svg><foreignObject><div xmlns="http://www.w3.org/1999/xhtml" style="background:u\72l(https://tracker.test/image.png)">Label</div></foreignObject></svg>"#,
674 "non-local CSS URL",
675 ),
676 (
677 r#"<svg id="root"><style>#root .label{background:u\72l('https://tracker.test/image.png')}</style></svg>"#,
678 "non-local CSS URL",
679 ),
680 ] {
681 let error = SvgPipeline::parity()
682 .with_browser_inline_contract("embed")
683 .process_to_string(svg, &render_session())
684 .unwrap_err();
685 assert!(error.to_string().contains(expected), "{svg}: {error}");
686 }
687 }
688
689 #[test]
690 fn inline_contract_admits_before_any_lossy_postprocessor() {
691 struct MustNotRun;
692 impl SvgPostprocessor for MustNotRun {
693 fn name(&self) -> &'static str {
694 "must-not-run"
695 }
696
697 fn process<'a>(
698 &self,
699 _: Cow<'a, str>,
700 _: &SvgPostprocessContext<'_>,
701 ) -> Result<Cow<'a, str>> {
702 panic!("unsafe SVG must be rejected before postprocessing")
703 }
704 }
705 for pipeline in [
706 SvgPipeline::parity()
707 .with_postprocessor(MustNotRun)
708 .with_browser_inline_contract("embed"),
709 SvgPipeline::parity()
710 .with_postprocessor(MustNotRun)
711 .with_static_inline_contract("embed"),
712 ] {
713 let error = pipeline
714 .process_to_string("<svg><script/></svg>", &render_session())
715 .unwrap_err();
716 assert!(error.to_string().contains("forbidden <script>"), "{error}");
717 }
718 }
719
720 #[test]
721 fn inline_contract_validates_after_all_custom_postprocessors() {
722 struct InjectResource;
723 impl SvgPostprocessor for InjectResource {
724 fn name(&self) -> &'static str {
725 "inject-resource"
726 }
727
728 fn process<'a>(
729 &self,
730 _: Cow<'a, str>,
731 _: &SvgPostprocessContext<'_>,
732 ) -> Result<Cow<'a, str>> {
733 Ok(Cow::Borrowed(
734 r#"<svg><image href="https://tracker.test/image.png"/></svg>"#,
735 ))
736 }
737 }
738 for pipeline in [
739 SvgPipeline::parity().with_browser_inline_contract("embed"),
740 SvgPipeline::parity().with_static_inline_contract("embed"),
741 ] {
742 let error = pipeline
743 .with_postprocessor(InjectResource)
744 .process_to_string("<svg/>", &render_session())
745 .unwrap_err();
746 assert!(error.to_string().contains("non-local resource"), "{error}");
747 }
748 }
749
750 #[test]
751 fn static_inline_contract_still_converts_html_and_removes_animation() {
752 let svg = r#"<svg id="root"><style>@keyframes dash{to{opacity:0}}#root .edge{animation:dash 1s}</style><foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml">Safe label</div></foreignObject></svg>"#;
753 let output = SvgPipeline::parity()
754 .with_static_inline_contract("embed")
755 .process_to_string(svg, &render_session())
756 .unwrap();
757
758 assert!(!output.contains("<foreignObject"), "{output}");
759 assert!(!output.contains("@keyframes"), "{output}");
760 assert!(!output.contains("animation:"), "{output}");
761 assert!(output.contains("Safe label"), "{output}");
762 assert!(output.contains(r#"id="embed-root""#), "{output}");
763 }
764
765 #[test]
766 fn rebasing_without_inline_contract_preserves_external_resources() {
767 let svg = r#"<svg id="root"><image href="https://example.test/image.png"/></svg>"#;
768 let output = SvgPipeline::parity()
769 .with_rebased_ids("embed")
770 .process_to_string(svg, &render_session())
771 .unwrap();
772
773 assert!(output.contains(r#"id="embed-root""#), "{output}");
774 assert!(
775 output.contains("https://example.test/image.png"),
776 "{output}"
777 );
778 }
779
780 #[test]
781 fn parity_pipeline_returns_owned_svg_without_reallocating() {
782 let svg = String::from(r#"<svg><rect width="10"/></svg>"#);
783 let allocation = svg.as_ptr();
784 let session = render_session();
785
786 let out = SvgPipeline::parity()
787 .process_owned_to_string(svg, &session)
788 .unwrap();
789
790 assert_eq!(out.as_ptr(), allocation);
791 }
792
793 #[test]
794 fn every_pipeline_preset_enforces_the_xml_1_0_character_contract() {
795 let svg = "<svg><text>A\u{0}B\u{1c}C\u{fffe}D</text></svg>";
796 let session = render_session();
797
798 for preset in [
799 SvgPipelinePreset::Parity,
800 SvgPipelinePreset::Readable,
801 SvgPipelinePreset::ResvgSafe,
802 ] {
803 let out = SvgPipeline::from_preset(preset)
804 .process_to_string(svg, &session)
805 .unwrap();
806 assert_eq!(out, "<svg><text>ABCD</text></svg>", "{preset:?}");
807 roxmltree::Document::parse(&out).expect("pipeline output must remain XML 1.0");
808 }
809 }
810
811 #[test]
812 fn every_pipeline_preset_rejects_unknown_xml_entities() {
813 let session = render_session();
814
815 for preset in [
816 SvgPipelinePreset::Parity,
817 SvgPipelinePreset::Readable,
818 SvgPipelinePreset::ResvgSafe,
819 ] {
820 let error = SvgPipeline::from_preset(preset)
821 .process_to_string("<svg><text>&unknown;</text></svg>", &session)
822 .unwrap_err();
823 assert!(
824 error.to_string().contains("invalid XML"),
825 "{preset:?}: {error}"
826 );
827 }
828 }
829
830 #[test]
831 fn readable_pipeline_matches_foreign_object_fallback() {
832 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><g transform="translate(10,20)"><foreignObject width="80" height="48"><div xmlns="http://www.w3.org/1999/xhtml"><p>Layer 7\nHTTP</p></div></foreignObject></g></svg>"#;
833 let session = render_session();
834 let measurer = session.text_measurer(crate::environment::TextMeasurementPhase::Wrap);
835
836 let expected = super::builtin::foreign_object::foreign_object_fallback_svg(svg, &measurer);
837 let out = SvgPipeline::readable()
838 .process_to_string(svg, &session)
839 .unwrap();
840
841 assert_eq!(out, expected);
842 assert!(out.contains(">Layer 7</text>"));
843 assert!(out.contains(">HTTP</text>"));
844 }
845
846 #[test]
847 fn resvg_safe_pipeline_strips_generic_raster_hazards() {
848 let svg = r#"<svg id="test" xmlns="http://www.w3.org/2000/svg"><style type="text/css">@keyframes bounce { 0% { transform: scale(1); } 100% { transform: scale(1.1); } } #test :root { --bg: white; } .node rect { animation: dash 1s linear; transform: rotate(45deg); fill: red; }</style><g transform="translate(undefined,NaN)"><foreignObject width="10" height="10"><div xmlns="http://www.w3.org/1999/xhtml"><p>Hello</p></div></foreignObject><rect width="10px" height="12px" stroke="" style="fill: ; stroke: #333; transform: rotate(45deg); animation: dash 1s;"/><rect width="10px" height="" fill="hsl(240, 100%, NaN%)"/></g></svg>"#;
849 let session = render_session();
850
851 let out = SvgPipeline::resvg_safe()
852 .process_to_string(svg, &session)
853 .unwrap();
854
855 assert!(!out.contains("<foreignObject"));
856 assert!(!out.contains("@keyframes"));
857 assert!(!out.contains(":root"));
858 assert!(!out.contains("animation"));
859 assert!(!out.contains("deg"));
860 assert!(!out.contains("NaN"));
861 assert!(!out.contains("undefined"));
862 assert!(!out.contains(r#"height="""#));
863 assert!(!out.contains(r#"fill="hsl"#));
864 assert!(!out.contains(r#"stroke="""#));
865 assert!(out.contains(r#"width="10""#));
866 assert!(out.contains(r#"height="12""#));
867 assert!(out.contains("stroke:#333"));
868 assert!(out.contains(">Hello</text>"));
869 }
870
871 #[test]
872 fn resvg_safe_pipeline_sanitizes_url_attributes_after_malformed_text() {
873 let svg = r##"<svg xmlns="http://www.w3.org/2000/svg">
874"#shape"/>
875 <path stroke="url(javascript:alert(1))" style="filterBurl('file://юmp/xter:');svroke:#033"/>
876</svg>"##;
877 let session = render_session();
878
879 let out = SvgPipeline::resvg_safe()
880 .process_to_string(svg, &session)
881 .expect("resvg-safe pipeline should sanitize the fuzz regression input");
882
883 assert!(!out.to_ascii_lowercase().contains("javascript"), "{out}");
884 assert!(!out.contains(r#"stroke="url("#), "{out}");
885 }
886
887 #[test]
888 fn resvg_safe_finalization_drops_unclosed_inline_css_blocks_idempotently() {
889 let svg = r##"<svg xmlns="http://www.w3.org/2000/svg"><path style="filter:5rl('file:///{animatiEtroke:#333"/></svg>"##;
890 let session = render_session();
891
892 let once = super::finalize_resvg_svg(svg, &session).unwrap();
893 let twice = super::finalize_resvg_svg(once.as_str(), &session).unwrap();
894
895 assert_eq!(twice, once);
896 assert!(!once.as_str().contains("style="), "{}", once.as_str());
897 }
898
899 #[test]
900 fn resvg_safe_pipeline_bounds_css_nesting_on_a_small_thread_stack() {
901 const CSS_NESTING_LIMIT: usize = 64;
902
903 fn nested_function(depth: usize, leaf: &str) -> String {
904 format!("{}{}{}", "f(".repeat(depth), leaf, ")".repeat(depth))
905 }
906
907 fn nested_media(depth: usize, rule: &str) -> String {
908 format!(
909 "{}{}{}",
910 "@media all{".repeat(depth),
911 rule,
912 "}".repeat(depth)
913 )
914 }
915
916 std::thread::Builder::new()
917 .name("bounded-css-nesting".into())
918 .stack_size(512 * 1024)
919 .spawn(|| {
920 let style_exact = nested_function(CSS_NESTING_LIMIT - 1, "red");
921 let style_over = nested_function(CSS_NESTING_LIMIT, "red");
922 let inline_exact = nested_function(CSS_NESTING_LIMIT, "red");
923 let selector_exact = format!(
924 "{}.selector-exact{}",
925 ":is(".repeat(CSS_NESTING_LIMIT),
926 ")".repeat(CSS_NESTING_LIMIT)
927 );
928 let selector_over = format!(
929 "{}.selector-over{}",
930 ":is(".repeat(CSS_NESTING_LIMIT + 1),
931 ")".repeat(CSS_NESTING_LIMIT + 1)
932 );
933 let media_exact = nested_media(CSS_NESTING_LIMIT - 1, ".media-exact{fill:red}");
934 let media_over = nested_media(CSS_NESTING_LIMIT, ".media-over{fill:red}");
935 let very_deep = nested_function(4_096, "red");
936 let svg = format!(
937 r#"<svg xmlns="http://www.w3.org/2000/svg"><style>
938.style-exact{{fill:{style_exact}}}
939.style-over{{fill:{style_over};stroke:blue}}
940{selector_exact}{{fill:red}}
941{selector_over}{{fill:red}}
942{media_exact}
943{media_over}
944</style>
945<path id="inline-exact" style="fill:{inline_exact};stroke:blue"/>
946<path id="inline-over" style="fill:{very_deep};stroke:blue"/>
947<path id="presentation-over" fill="{very_deep}" stroke="blue"/>
948</svg>"#
949 );
950
951 let session = render_session();
952 let out = SvgPipeline::resvg_safe()
953 .process_to_string(&svg, &session)
954 .expect("resvg-safe CSS sanitization must remain bounded");
955
956 assert!(out.contains(".style-exact{fill:"), "{out}");
957 assert!(out.contains(".style-over{stroke:blue}"), "{out}");
958 assert!(out.contains(".selector-exact"), "{out}");
959 assert!(out.contains("){fill:red}"), "{out}");
960 assert!(!out.contains("selector-over"), "{out}");
961 assert!(out.contains("media-exact"), "{out}");
962 assert!(!out.contains("media-over"), "{out}");
963
964 let document = roxmltree::Document::parse(&out).expect("valid sanitized SVG");
965 let inline_exact = document
966 .descendants()
967 .find(|node| node.attribute("id") == Some("inline-exact"))
968 .unwrap();
969 assert!(inline_exact.attribute("style").unwrap().contains("fill:"));
970 let inline_over = document
971 .descendants()
972 .find(|node| node.attribute("id") == Some("inline-over"))
973 .unwrap();
974 assert_eq!(inline_over.attribute("style"), Some("stroke:blue"));
975 let presentation_over = document
976 .descendants()
977 .find(|node| node.attribute("id") == Some("presentation-over"))
978 .unwrap();
979 assert_eq!(presentation_over.attribute("fill"), None);
980 assert_eq!(presentation_over.attribute("stroke"), Some("blue"));
981 })
982 .expect("small-stack CSS regression thread must start")
983 .join()
984 .expect("bounded CSS traversal must not overflow the small thread stack");
985 }
986
987 struct AppendPass(&'static str);
988
989 impl SvgPostprocessor for AppendPass {
990 fn name(&self) -> &'static str {
991 self.0
992 }
993
994 fn process<'a>(
995 &self,
996 svg: Cow<'a, str>,
997 ctx: &SvgPostprocessContext<'_>,
998 ) -> Result<Cow<'a, str>> {
999 Ok(Cow::Owned(format!(
1000 "{}<!--{}:{}:{:?}:{}:{}:{}-->",
1001 svg,
1002 ctx.pass_index(),
1003 ctx.pass_name(),
1004 ctx.preset(),
1005 ctx.diagram_type().unwrap_or("none"),
1006 ctx.diagram_title().unwrap_or("none"),
1007 ctx.svg_id().unwrap_or("none")
1008 )))
1009 }
1010 }
1011
1012 struct CancelOperationPass(OperationControl);
1013
1014 impl SvgPostprocessor for CancelOperationPass {
1015 fn name(&self) -> &'static str {
1016 "cancel-operation"
1017 }
1018
1019 fn process<'a>(
1020 &self,
1021 svg: Cow<'a, str>,
1022 _ctx: &SvgPostprocessContext<'_>,
1023 ) -> Result<Cow<'a, str>> {
1024 self.0.cancel();
1025 Ok(svg)
1026 }
1027 }
1028
1029 #[test]
1030 fn pipeline_checks_control_after_an_opaque_postprocessor() {
1031 let control = OperationControl::new();
1032 let session = crate::environment::RenderEnvironment::deterministic()
1033 .begin_session_with_control(control.clone())
1034 .unwrap();
1035
1036 let error = SvgPipeline::parity()
1037 .with_postprocessor(CancelOperationPass(control))
1038 .process_to_string("<svg/>", &session)
1039 .unwrap_err();
1040
1041 let Error::Cancelled(error) = error else {
1042 panic!("expected structured cancellation");
1043 };
1044 assert_eq!(error.phase, OperationPhase::Postprocess);
1045 assert_eq!(error.reason, CancelReason::Requested);
1046 }
1047
1048 #[test]
1049 fn custom_postprocessors_run_before_builtin_finalizer_in_order() {
1050 let svg = r#"<svg><foreignObject width="10" height="10"><div><p>Hello</p></div></foreignObject></svg>"#;
1051 let pipeline = SvgPipeline::readable()
1052 .with_postprocessor(AppendPass("first"))
1053 .with_postprocessor(AppendPass("second"));
1054 let session = render_session();
1055
1056 let out = pipeline.process_to_string(svg, &session).unwrap();
1057
1058 let first = out.find("<!--0:first:Readable").unwrap();
1059 let second = out.find("<!--1:second:Readable").unwrap();
1060 assert!(first < second);
1061 assert!(out.contains("data-merman-foreignobject"));
1062 }
1063
1064 #[test]
1065 fn custom_postprocessor_output_is_cleaned_by_resvg_finalizer() {
1066 struct InjectActiveContent;
1067
1068 impl SvgPostprocessor for InjectActiveContent {
1069 fn name(&self) -> &'static str {
1070 "inject-active-content"
1071 }
1072
1073 fn process<'a>(
1074 &self,
1075 svg: Cow<'a, str>,
1076 _ctx: &SvgPostprocessContext<'_>,
1077 ) -> Result<Cow<'a, str>> {
1078 Ok(Cow::Owned(svg.replace(
1079 "</svg>",
1080 r#"<script>alert(1)</script><rect animation="spin 1s"/></svg>"#,
1081 )))
1082 }
1083 }
1084
1085 let session = render_session();
1086 let output = SvgPipeline::resvg_safe()
1087 .with_postprocessor(InjectActiveContent)
1088 .process_resvg_compatible("<svg></svg>", &session)
1089 .unwrap();
1090
1091 assert!(!output.as_str().contains("script"));
1092 assert!(!output.as_str().contains("animation"));
1093 }
1094
1095 #[test]
1096 fn expanded_draft_is_budgeted_before_terminal_xml_validation() {
1097 struct ExpandDraft;
1098
1099 impl SvgPostprocessor for ExpandDraft {
1100 fn name(&self) -> &'static str {
1101 "expand-draft"
1102 }
1103
1104 fn process<'a>(
1105 &self,
1106 _svg: Cow<'a, str>,
1107 _ctx: &SvgPostprocessContext<'_>,
1108 ) -> Result<Cow<'a, str>> {
1109 Ok(Cow::Owned(format!("<svg>{}</svg>", "x".repeat(128))))
1110 }
1111 }
1112
1113 let session = crate::environment::RenderEnvironment::deterministic()
1114 .with_resource_policy(
1115 crate::resources::RenderResourcePolicy::unbounded_for_trusted_input()
1116 .with_limit(crate::resources::ResourceLimitId::MaxSvgBytes, 64)
1117 .unwrap(),
1118 )
1119 .begin_session()
1120 .unwrap();
1121 let error = SvgPipeline::resvg_safe()
1122 .with_postprocessor(ExpandDraft)
1123 .process_resvg_compatible("<svg/>", &session)
1124 .unwrap_err();
1125
1126 assert!(error.to_string().contains("max_svg_bytes"), "{error}");
1127 }
1128
1129 #[test]
1130 fn provided_metadata_admits_raw_svg_bytes_before_xml_cleanup() {
1131 let svg = "<svg>\u{0}</svg>";
1132 let maximum = svg.len() - 1;
1133 let control = OperationControl::new();
1134 let session = crate::environment::RenderEnvironment::deterministic()
1135 .with_resource_policy(
1136 crate::resources::RenderResourcePolicy::unbounded_for_trusted_input()
1137 .with_limit(crate::resources::ResourceLimitId::MaxSvgBytes, maximum)
1138 .unwrap(),
1139 )
1140 .begin_session_with_control(control.clone())
1141 .unwrap();
1142 let metadata = SvgPostprocessMetadata::new();
1143
1144 let error = SvgPipeline::parity()
1145 .process_with_metadata(svg, &metadata, &session)
1146 .expect_err("raw SVG bytes must be admitted before XML cleanup allocates");
1147 let Error::ResourceLimitExceeded(first) = error else {
1148 panic!("expected a resource rejection");
1149 };
1150 assert_eq!(first.limit, "max_svg_bytes");
1151 assert_eq!(first.actual, svg.len());
1152 assert_eq!(first.max, maximum);
1153
1154 control.cancel();
1155 let replayed = SvgPipeline::parity()
1156 .process_with_metadata("<svg/>", &metadata, &session)
1157 .expect_err("the first resource terminal must remain sticky");
1158 let Error::ResourceLimitExceeded(replayed) = replayed else {
1159 panic!("expected the resource rejection to replay");
1160 };
1161 assert_eq!(replayed, first);
1162 }
1163
1164 #[test]
1165 fn structure_limit_rejects_before_custom_postprocessors_run() {
1166 struct MustNotRun;
1167
1168 impl SvgPostprocessor for MustNotRun {
1169 fn name(&self) -> &'static str {
1170 "must-not-run"
1171 }
1172
1173 fn process<'a>(
1174 &self,
1175 _svg: Cow<'a, str>,
1176 _ctx: &SvgPostprocessContext<'_>,
1177 ) -> Result<Cow<'a, str>> {
1178 panic!("structure admission must precede custom postprocessing")
1179 }
1180 }
1181
1182 let session = crate::environment::RenderEnvironment::deterministic()
1183 .with_resource_policy(
1184 crate::resources::RenderResourcePolicy::unbounded_for_trusted_input()
1185 .with_limit(crate::resources::ResourceLimitId::MaxSvgElements, 1)
1186 .unwrap(),
1187 )
1188 .begin_session()
1189 .unwrap();
1190 let error = SvgPipeline::parity()
1191 .with_postprocessor(MustNotRun)
1192 .process_to_string("<svg><g/></svg>", &session)
1193 .unwrap_err();
1194
1195 assert!(error.to_string().contains("max_svg_elements"), "{error}");
1196 }
1197
1198 #[test]
1199 fn fallback_generated_elements_are_rejected_before_the_overlay_is_completed() {
1200 let session = crate::environment::RenderEnvironment::deterministic()
1201 .with_resource_policy(
1202 crate::resources::RenderResourcePolicy::unbounded_for_trusted_input()
1203 .with_limit(crate::resources::ResourceLimitId::MaxSvgElements, 4)
1204 .unwrap(),
1205 )
1206 .begin_session()
1207 .unwrap();
1208 let svg = concat!(
1209 r#"<svg xmlns="http://www.w3.org/2000/svg">"#,
1210 r#"<foreignObject width="10" height="10">"#,
1211 r#"<div xmlns="http://www.w3.org/1999/xhtml">Hello</div>"#,
1212 "</foreignObject></svg>",
1213 );
1214 let error = SvgPipeline::readable()
1215 .process_to_string(svg, &session)
1216 .unwrap_err();
1217
1218 assert!(error.to_string().contains("max_svg_elements"), "{error}");
1219 }
1220
1221 #[test]
1222 fn non_resvg_pipeline_cannot_construct_resvg_compatible_svg() {
1223 let session = render_session();
1224 let error = SvgPipeline::parity()
1225 .process_resvg_compatible("<svg/>", &session)
1226 .unwrap_err();
1227
1228 assert!(error.to_string().contains("resvg-safe terminal preset"));
1229 }
1230
1231 #[test]
1232 fn custom_postprocessor_context_exposes_metadata() {
1233 let svg = r#"<svg id="host-diagram"><rect width="10"/></svg>"#;
1234 let metadata = SvgPostprocessMetadata::from_svg(svg)
1235 .with_diagram_type("flowchart-v2")
1236 .with_diagram_title("Host Diagram");
1237 let pipeline = SvgPipeline::parity().with_postprocessor(AppendPass("meta"));
1238 let session = render_session();
1239
1240 let out = pipeline
1241 .process_to_string_with_metadata(svg, &metadata, &session)
1242 .unwrap();
1243
1244 assert!(out.contains("<!--0:meta:Parity:flowchart-v2:Host Diagram:host-diagram-->"));
1245 }
1246
1247 struct ErrorPass;
1248
1249 impl SvgPostprocessor for ErrorPass {
1250 fn name(&self) -> &'static str {
1251 "error-pass"
1252 }
1253
1254 fn process<'a>(
1255 &self,
1256 _svg: Cow<'a, str>,
1257 _ctx: &SvgPostprocessContext<'_>,
1258 ) -> Result<Cow<'a, str>> {
1259 Err(Error::InvalidModel {
1260 message: "boom".to_string(),
1261 })
1262 }
1263 }
1264
1265 #[test]
1266 fn custom_postprocessor_errors_surface_with_pass_name() {
1267 let session = render_session();
1268 let err = SvgPipeline::parity()
1269 .with_postprocessor(ErrorPass)
1270 .process_to_string("<svg/>", &session)
1271 .unwrap_err();
1272
1273 let message = err.to_string();
1274 assert!(message.contains("error-pass"));
1275 assert!(message.contains("boom"));
1276 }
1277
1278 struct CancelAndErrorPass(OperationControl);
1279
1280 impl SvgPostprocessor for CancelAndErrorPass {
1281 fn name(&self) -> &'static str {
1282 "cancel-and-error"
1283 }
1284
1285 fn process<'a>(
1286 &self,
1287 _svg: Cow<'a, str>,
1288 _ctx: &SvgPostprocessContext<'_>,
1289 ) -> Result<Cow<'a, str>> {
1290 self.0.cancel();
1291 Err(Error::InvalidModel {
1292 message: "opaque failure".to_string(),
1293 })
1294 }
1295 }
1296
1297 #[test]
1298 fn cancellation_observed_after_a_failing_postprocessor_wins_over_wrapping() {
1299 let control = OperationControl::new();
1300 let session = crate::environment::RenderEnvironment::deterministic()
1301 .begin_session_with_control(control.clone())
1302 .unwrap();
1303
1304 let error = SvgPipeline::parity()
1305 .with_postprocessor(CancelAndErrorPass(control))
1306 .process_to_string("<svg/>", &session)
1307 .unwrap_err();
1308
1309 let Error::Cancelled(error) = error else {
1310 panic!("expected structured cancellation");
1311 };
1312 assert_eq!(error.phase, OperationPhase::Postprocess);
1313 assert_eq!(error.reason, CancelReason::Requested);
1314 }
1315}