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