use crate::entities::decode_mermaid_entity_placeholders;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MermaidSvgUriRepresentation {
DomValue,
SerializedSvg,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MermaidNavigationSecurity {
Loose,
Sanitized,
}
impl MermaidNavigationSecurity {
pub const fn from_security_level_loose(loose: bool) -> Self {
if loose { Self::Loose } else { Self::Sanitized }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SerializedMermaidNavigationHref(String);
impl SerializedMermaidNavigationHref {
pub fn as_serialized_str(&self) -> &str {
&self.0
}
}
pub fn prepare_mermaid_navigation_href(
value: &str,
security: MermaidNavigationSecurity,
) -> Option<SerializedMermaidNavigationHref> {
let serialized_before_cleanup = serialize_svg_attribute_value(value);
let cleaned_serialized = decode_mermaid_entity_placeholders(&serialized_before_cleanup);
if security == MermaidNavigationSecurity::Loose {
return Some(SerializedMermaidNavigationHref(
cleaned_serialized.into_owned(),
));
}
let normalized_dom = admit_mermaid_svg_uri_attribute(
cleaned_serialized.as_ref(),
MermaidSvgUriRepresentation::SerializedSvg,
)?;
Some(SerializedMermaidNavigationHref(
serialize_svg_attribute_value(&normalized_dom),
))
}
pub fn admit_mermaid_svg_uri_attribute(
value: &str,
representation: MermaidSvgUriRepresentation,
) -> Option<String> {
match representation {
MermaidSvgUriRepresentation::DomValue => {
crate::sanitize::dompurify_normalize_dom_uri_attribute(value)
}
MermaidSvgUriRepresentation::SerializedSvg => {
crate::sanitize::dompurify_normalize_serialized_uri_attribute(value)
}
}
}
pub fn normalize_mermaid_tooltip_attribute(value: &str) -> &str {
crate::sanitize::dompurify_normalize_dom_attribute_value("title", value)
}
fn serialize_svg_attribute_value(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
if !is_xml_1_0_char(ch) {
continue;
}
match ch {
'\n' => out.push_str(" "),
'\r' => out.push_str(" "),
'\t' => out.push_str("	"),
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
fn is_xml_1_0_char(ch: char) -> bool {
matches!(ch, '\u{9}' | '\u{A}' | '\u{D}')
|| matches!(
ch,
'\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}'
)
}
#[cfg(test)]
mod tests {
use super::{
MermaidNavigationSecurity, MermaidSvgUriRepresentation, admit_mermaid_svg_uri_attribute,
prepare_mermaid_navigation_href,
};
fn strict(value: &str) -> Option<String> {
prepare_mermaid_navigation_href(value, MermaidNavigationSecurity::Sanitized)
.map(|href| href.as_serialized_str().to_string())
}
fn loose(value: &str) -> String {
prepare_mermaid_navigation_href(value, MermaidNavigationSecurity::Loose)
.expect("loose security preserves renderer-created navigation")
.as_serialized_str()
.to_string()
}
#[test]
fn strict_security_runs_cleanup_before_uri_admission() {
assert_eq!(strict("javascriptfl°colon¶ßalert(1)"), None);
assert_eq!(strict("\u{FEFF}javascript:alert(1)"), None);
assert_eq!(
strict(" https://example.test/ticket "),
Some("https://example.test/ticket".into())
);
assert_eq!(strict(" "), Some(String::new()));
}
#[test]
fn strict_security_consumes_exactly_one_serialization_layer() {
assert_eq!(
strict("javascript:ticket"),
Some("jav&#x61;script:ticket".into())
);
assert_eq!(
strict("javascript:ticket"),
Some("javascript&colon;ticket".into())
);
assert_eq!(strict("javascript:ticket"), None);
}
#[test]
fn loose_security_stops_after_mermaid_cleanup() {
assert_eq!(
loose("javascriptfl°colon¶ßalert(1)"),
"javascript:alert(1)"
);
assert_eq!(loose("A&B"), "A&B");
}
#[test]
fn representation_kind_controls_entity_decoding() {
assert_eq!(
admit_mermaid_svg_uri_attribute(
"javascript&colon;ticket",
MermaidSvgUriRepresentation::SerializedSvg,
),
Some("javascript:ticket".into())
);
assert_eq!(
admit_mermaid_svg_uri_attribute(
"javascript&colon;ticket",
MermaidSvgUriRepresentation::DomValue,
),
Some("javascript&colon;ticket".into())
);
}
}