use crate::detect::{self, Format};
use crate::error::{MalformedDetail, Result, StryptError, UnsupportedKind};
use crate::formats::ParseLimits;
use crate::formats::xml::{self, Kind, NonElementKind, Tag};
use crate::report::{
Finding, InspectOptions, MetadataKind, MetadataValue, Note, Retained, RetentionReason,
};
use super::data_uri;
const SCRIPT_ELEMENTS: [&str; 3] = ["script", "foreignObject", "handler"];
const RENDERING_PREFIXES: [&str; 2] = ["xlink", "xml"];
const METADATA_ELEMENT: &str = "metadata";
const ACCESSIBILITY_ELEMENTS: [&str; 2] = ["title", "desc"];
const STYLE_ELEMENT: &str = "style";
const REFERENCE_ATTRIBUTES: [&str; 3] = ["href", "xlink:href", "src"];
const PERSONAL_LOCAL_NAMES: [&str; 8] = [
"creator",
"contributor",
"publisher",
"docname",
"docbase",
"export-filename",
"absref",
"sourcefile",
];
const TIME_LOCAL_NAMES: [&str; 3] = ["modified", "created", "timestamp"];
struct Edit {
start: usize,
end: usize,
replacement: Option<String>,
}
#[derive(Debug)]
pub(super) struct Outcome {
pub(super) output: Option<Vec<u8>>,
pub(super) findings: Vec<Finding>,
pub(super) retained: Vec<Retained>,
pub(super) notes: Vec<Note>,
}
struct Pass<'a> {
src: &'a str,
options: &'a InspectOptions,
limits: &'a ParseLimits,
budget: u64,
edits: Vec<Edit>,
findings: Vec<Finding>,
retained: Vec<Retained>,
notes: Vec<Note>,
external: usize,
}
pub(super) fn process(
src: &str,
options: &InspectOptions,
limits: &ParseLimits,
) -> Result<Outcome> {
let scan = xml::scan(src);
if scan.tags.len() > limits.max_items as usize {
return Err(StryptError::LimitExceeded {
format: Format::Svg,
limit: crate::error::ResourceLimit::ItemCount,
});
}
let mut pass = Pass {
src,
options,
limits,
budget: limits.max_expanded_bytes,
edits: Vec::new(),
findings: Vec::new(),
retained: Vec::new(),
notes: Vec::new(),
external: 0,
};
pass.walk_non_elements(&scan.others)?;
pass.walk_elements(&scan.tags)?;
pass.summarise_external();
Ok(Outcome {
output: pass.apply(),
findings: pass.findings,
retained: pass.retained,
notes: pass.notes,
})
}
impl<'a> Pass<'a> {
fn walk_non_elements(&mut self, others: &[xml::NonElement]) -> Result<()> {
for other in others {
let text = self.src.get(other.start..other.end).unwrap_or_default();
match other.kind {
NonElementKind::Comment => {
self.cut(other.start, other.end);
self.find(
MetadataKind::Comment,
"<!-- -->",
None,
other.end.saturating_sub(other.start),
text,
);
}
NonElementKind::ProcessingInstruction => {
if is_xml_declaration(text) {
continue;
}
self.cut(other.start, other.end);
self.find(
MetadataKind::SoftwareFingerprint,
"<? ?>",
None,
other.end.saturating_sub(other.start),
text,
);
}
NonElementKind::Doctype => {
if text.contains('[') {
return Err(StryptError::Malformed {
format: Format::Svg,
offset: Some(crate::container::package::as_u64(other.start)),
detail: MalformedDetail::UnsupportedFeature,
});
}
}
NonElementKind::Cdata => {}
}
}
Ok(())
}
fn walk_elements(&mut self, tags: &[Tag<'a>]) -> Result<()> {
let mut skip_until = 0usize;
for (index, tag) in tags.iter().enumerate() {
if tag.start < skip_until || tag.kind == Kind::Close {
continue;
}
let name = tag.name;
if SCRIPT_ELEMENTS.contains(&name) {
return Err(scripted());
}
if let Some(prefix) = prefix_of(name) {
if !RENDERING_PREFIXES.contains(&prefix) {
if let Some(span) = xml::element_span(tags, index) {
skip_until = span.end;
self.cut(span.start, span.end);
self.find(
kind_for(name),
format!("<{name}>"),
None,
span.len(),
self.src.get(span.start..span.end).unwrap_or_default(),
);
continue;
}
}
} else if name == METADATA_ELEMENT {
if let Some(span) = xml::element_span(tags, index) {
skip_until = span.end;
self.cut(span.start, span.end);
self.report_metadata(span.start, span.end);
continue;
}
} else if ACCESSIBILITY_ELEMENTS.contains(&name) {
self.keep_accessibility_text(tags, index, name);
} else if name == STYLE_ELEMENT {
self.scrub_stylesheet(tags, index);
}
self.walk_attributes(tag)?;
}
Ok(())
}
fn walk_attributes(&mut self, tag: &Tag<'a>) -> Result<()> {
let base = tag.start;
for attribute in xml::attributes(tag.raw) {
let name = attribute.name;
let start = base.saturating_add(attribute.start);
let end = base.saturating_add(attribute.end);
if is_event_handler(name) {
return Err(scripted());
}
if let Some(bound) = name.strip_prefix("xmlns:") {
if !RENDERING_PREFIXES.contains(&bound) {
self.cut(start, end);
self.find(
MetadataKind::SoftwareFingerprint,
format!("<{} {name}>", tag.name),
Some(name.to_owned()),
end.saturating_sub(start),
attribute.value,
);
}
continue;
}
if let Some(prefix) = prefix_of(name)
&& !RENDERING_PREFIXES.contains(&prefix)
{
self.cut(start, end);
self.find(
kind_for(name),
format!("<{} {name}>", tag.name),
Some(name.to_owned()),
end.saturating_sub(start),
attribute.value,
);
continue;
}
self.examine_reference(tag, name, attribute.value, start, end)?;
}
Ok(())
}
fn examine_reference(
&mut self,
tag: &Tag<'a>,
name: &str,
value: &'a str,
start: usize,
end: usize,
) -> Result<()> {
let whole = REFERENCE_ATTRIBUTES.contains(&name);
if !whole && !value.contains("url(") {
return Ok(());
}
if whole {
if is_javascript(value) {
return Err(scripted());
}
if let Some(uri) = data_uri::parse(value) {
return self.descend(tag, name, &uri, start, end);
}
if is_external(value) {
self.external = self.external.saturating_add(1);
self.retain_reference(tag.name, name);
}
return Ok(());
}
for target in url_targets(value) {
if is_javascript(target) {
return Err(scripted());
}
if is_external(target) {
self.external = self.external.saturating_add(1);
self.retain_reference(tag.name, name);
}
}
Ok(())
}
fn descend(
&mut self,
tag: &Tag<'a>,
name: &str,
uri: &data_uri::DataUri<'_>,
start: usize,
end: usize,
) -> Result<()> {
let location = format!("<{} {name}> (data: URI)", tag.name);
let Some(bytes) = uri
.base64
.then(|| data_uri::decode(uri.payload, &mut self.budget))
.flatten()
else {
self.notes.push(Note::UnparsedRegion {
location,
bytes: crate::container::package::as_u64(uri.payload.len()),
});
return Ok(());
};
let Some(format) = crate::container::package::embedded_image_format(&bytes) else {
if is_nested_container(&bytes) {
self.notes.push(Note::OutOfScopeContent {
location: format!("{location} (a container, not a picture)"),
});
return Err(StryptError::Malformed {
format: Format::Svg,
offset: Some(crate::container::package::as_u64(start)),
detail: MalformedDetail::UnsupportedFeature,
});
}
self.notes.push(Note::UnparsedRegion {
location,
bytes: crate::container::package::as_u64(bytes.len()),
});
return Ok(());
};
match crate::container::package::strip_embedded_image(
format,
&bytes,
&location,
self.options,
self.limits,
)? {
crate::container::package::Embedded::Unchanged => {}
crate::container::package::Embedded::Stripped {
bytes: cleaned,
findings,
notes,
} => {
let encoded = data_uri::encode(&uri.media_type, &cleaned);
let quote = quote_of(self.src.get(start..end).unwrap_or_default()).unwrap_or('"');
self.edits.push(Edit {
start,
end,
replacement: Some(format!(" {name}={quote}{encoded}{quote}")),
});
self.findings.extend(findings);
self.notes.extend(notes);
}
}
Ok(())
}
fn report_metadata(&mut self, start: usize, end: usize) {
let inner = self.src.get(start..end).unwrap_or_default();
let tags = xml::tags(inner);
let mut ancestors: Vec<&str> = Vec::new();
let mut named = false;
for (index, tag) in tags.iter().enumerate() {
if tag.kind == Kind::Close {
if ancestors.last() == Some(&tag.name) {
ancestors.pop();
}
continue;
}
for attribute in xml::attributes(tag.raw) {
if attribute.name.starts_with("xmlns") || attribute.value.trim().is_empty() {
continue;
}
named = true;
self.find(
kind_for(attribute.name),
"<metadata>",
Some(format!("{} {}", tag.name, attribute.name)),
attribute.value.len(),
attribute.value,
);
}
if let Some(text) = xml::element_text(&tags, index, inner)
&& !text.value.trim().is_empty()
{
named = true;
self.find(
kind_in_context(&ancestors, tag.name),
"<metadata>",
Some(tag.name.to_owned()),
text.value.len(),
text.value,
);
}
if tag.kind == Kind::Open {
ancestors.push(tag.name);
}
}
if !named {
self.find(
MetadataKind::Other,
"<metadata>",
None,
end.saturating_sub(start),
inner,
);
}
}
fn keep_accessibility_text(&mut self, tags: &[Tag<'a>], index: usize, name: &str) {
let Some(text) = xml::element_text(tags, index, self.src) else {
return;
};
if text.value.trim().is_empty() {
return;
}
self.retained.push(Retained {
location: format!("<{name}>"),
reason: RetentionReason::RemovalWouldAlterPayload,
});
self.notes.push(Note::OutOfScopeContent {
location: format!("<{name}> (accessibility text, which can name its author)"),
});
}
fn scrub_stylesheet(&mut self, tags: &[Tag<'a>], index: usize) {
let Some(text) = xml::element_text(tags, index, self.src) else {
return;
};
for span in css_comments(text.value) {
let start = text.range.start.saturating_add(span.0);
let end = text.range.start.saturating_add(span.1);
self.cut(start, end);
self.find(
MetadataKind::Comment,
"<style> (/* */)",
None,
end.saturating_sub(start),
self.src.get(start..end).unwrap_or_default(),
);
}
for target in url_targets(text.value) {
if is_external(target) {
self.external = self.external.saturating_add(1);
self.retain_reference("style", "url()");
}
}
}
fn retain_reference(&mut self, element: &str, attribute: &str) {
let location = format!("<{element} {attribute}> (a reference outside this document)");
if self.retained.iter().any(|r| r.location == location) {
return;
}
self.retained.push(Retained {
location,
reason: RetentionReason::RemovalWouldAlterPayload,
});
}
fn summarise_external(&mut self) {
if self.external == 0 {
return;
}
self.notes.push(Note::OutOfScopeContent {
location: format!(
"{} reference(s) to files outside this document",
self.external
),
});
}
fn cut(&mut self, start: usize, end: usize) {
if start < end {
self.edits.push(Edit {
start,
end,
replacement: None,
});
}
}
fn find(
&mut self,
kind: MetadataKind,
location: impl Into<String>,
field: Option<String>,
bytes: usize,
value: &str,
) {
let mut finding = Finding::new(kind, location, crate::container::package::as_u64(bytes));
if let Some(field) = field {
finding = finding.with_field(field);
}
self.findings.push(finding.with_value(self.options, || {
MetadataValue::Text(value.trim().to_owned())
}));
}
fn apply(&mut self) -> Option<Vec<u8>> {
if self.edits.is_empty() {
return None;
}
self.edits
.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
let mut out = String::with_capacity(self.src.len());
let mut copied = 0usize;
for edit in &self.edits {
if edit.start < copied {
continue;
}
out.push_str(self.src.get(copied..edit.start)?);
if let Some(replacement) = &edit.replacement {
out.push_str(replacement);
}
copied = edit.end;
}
out.push_str(self.src.get(copied..)?);
Some(out.into_bytes())
}
}
const fn scripted() -> StryptError {
StryptError::UnsupportedFormat {
format: UnsupportedKind::ScriptedSvg,
}
}
fn prefix_of(name: &str) -> Option<&str> {
name.split_once(':').map(|(prefix, _)| prefix)
}
fn local_of(name: &str) -> &str {
name.split_once(':').map_or(name, |(_, local)| local)
}
fn kind_for(name: &str) -> MetadataKind {
let local = local_of(name).to_ascii_lowercase();
if PERSONAL_LOCAL_NAMES.contains(&local.as_str()) {
return MetadataKind::PersonalIdentity;
}
if TIME_LOCAL_NAMES.contains(&local.as_str())
|| local.ends_with("date")
|| local.ends_with("time")
{
return MetadataKind::Timestamp;
}
match local.as_str() {
"title" | "description" | "subject" | "rights" | "license" => MetadataKind::Comment,
_ => MetadataKind::SoftwareFingerprint,
}
}
fn kind_in_context(ancestors: &[&str], name: &str) -> MetadataKind {
let personal = |candidate: &str| {
PERSONAL_LOCAL_NAMES.contains(&local_of(candidate).to_ascii_lowercase().as_str())
};
if ancestors.iter().copied().any(personal) {
return MetadataKind::PersonalIdentity;
}
kind_for(name)
}
fn is_xml_declaration(text: &str) -> bool {
let Some(rest) = text.strip_prefix("<?") else {
return false;
};
let target = rest
.split(|c: char| c.is_whitespace() || c == '?')
.next()
.unwrap_or_default();
target == "xml"
}
fn is_event_handler(name: &str) -> bool {
let Some(rest) = name.strip_prefix("on") else {
return false;
};
!rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric())
}
fn is_javascript(value: &str) -> bool {
let trimmed = value.trim_start();
trimmed
.get(..11)
.is_some_and(|head| head.eq_ignore_ascii_case("javascript:"))
}
fn is_external(value: &str) -> bool {
let trimmed = value.trim();
!trimmed.is_empty() && !trimmed.starts_with('#') && data_uri::parse(trimmed).is_none()
}
fn is_nested_container(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04")
|| bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1])
|| matches!(detect::detect(bytes), Ok(Format::Pdf | Format::Svg))
}
fn quote_of(raw: &str) -> Option<char> {
raw.split_once('=')
.and_then(|(_, value)| value.trim_start().chars().next())
.filter(|c| *c == '"' || *c == '\'')
}
fn url_targets(value: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut rest = value;
while let Some(at) = rest.find("url(") {
let Some(after) = rest.get(at.saturating_add(4)..) else {
break;
};
let Some(close) = after.find(')') else {
break;
};
let target = after.get(..close).unwrap_or_default().trim();
out.push(target.trim_matches(['"', '\'']));
rest = after.get(close.saturating_add(1)..).unwrap_or_default();
}
out
}
fn css_comments(text: &str) -> Vec<(usize, usize)> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut quote: Option<u8> = None;
let mut i = 0usize;
while let Some(&byte) = bytes.get(i) {
match quote {
Some(q) => {
if byte == b'\\' {
i = i.saturating_add(2);
continue;
}
if byte == q {
quote = None;
}
}
None => {
if byte == b'"' || byte == b'\'' {
quote = Some(byte);
} else if byte == b'/' && bytes.get(i.saturating_add(1)) == Some(&b'*') {
let from = i.saturating_add(2);
let end = text
.get(from..)
.and_then(|rest| rest.find("*/"))
.map_or(text.len(), |n| from.saturating_add(n).saturating_add(2));
out.push((i, end));
i = end;
continue;
}
}
}
i = i.saturating_add(1);
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
use super::*;
fn run(src: &str) -> Outcome {
process(src, &InspectOptions::names_only(), &ParseLimits::default()).unwrap()
}
fn output(src: &str) -> String {
String::from_utf8(run(src).output.unwrap()).unwrap()
}
#[test]
fn a_clean_drawing_is_returned_untouched() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 10 10\">\
<rect width=\"10\" height=\"10\" fill=\"#123456\"/></svg>";
let outcome = run(src);
assert!(outcome.output.is_none());
assert!(outcome.findings.is_empty());
}
#[test]
fn an_editors_private_namespace_goes_whole_and_so_does_its_declaration() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:sodipodi=\"http://sodipodi.sf.net/\" \
xmlns:inkscape=\"http://inkscape.org/\" \
sodipodi:docname=\"leak-from-my-desktop.svg\" inkscape:version=\"1.1\">\
<sodipodi:namedview inkscape:current-layer=\"layer1\" inkscape:zoom=\"3.7\"/>\
<rect width=\"1\" height=\"1\"/></svg>";
let out = output(src);
assert!(!out.contains("sodipodi"), "{out}");
assert!(!out.contains("inkscape"), "{out}");
assert!(out.contains("<rect width=\"1\" height=\"1\"/>"));
assert!(out.contains("xmlns=\"http://www.w3.org/2000/svg\""));
}
#[test]
fn an_unknown_editors_namespace_goes_for_the_same_reason() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:notatoolweveheardof=\"http://example.invalid/\">\
<notatoolweveheardof:private author=\"A Name\"/><rect/></svg>";
let out = output(src);
assert!(!out.contains("A Name"));
assert!(!out.contains("notatoolweveheardof"));
assert!(out.contains("<rect/>"));
}
#[test]
fn xlink_and_xml_survive_because_the_picture_needs_them() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:xlink=\"http://www.w3.org/1999/xlink\">\
<text xml:space=\"preserve\">x</text>\
<use xlink:href=\"#shape\"/></svg>";
assert!(run(src).output.is_none(), "nothing here is removable");
}
#[test]
fn the_metadata_element_goes_and_its_fields_are_named() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\"><metadata><rdf:RDF>\
<dc:creator>A Name</dc:creator><dc:date>2021-03-04</dc:date>\
</rdf:RDF></metadata><rect/></svg>";
let outcome = run(src);
let out = String::from_utf8(outcome.output.clone().unwrap()).unwrap();
assert!(!out.contains("A Name"));
assert!(out.contains("<rect/>"));
let fields: Vec<&str> = outcome
.findings
.iter()
.filter_map(|f| f.field.as_deref())
.collect();
assert!(fields.contains(&"dc:creator"), "{fields:?}");
assert!(
outcome
.findings
.iter()
.any(|f| f.kind == MetadataKind::PersonalIdentity)
);
}
#[test]
fn a_name_rdf_nested_under_an_agent_is_still_reported_as_a_person() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\"><metadata><rdf:RDF><cc:Work>\
<dc:creator><cc:Agent><dc:title>A Name</dc:title></cc:Agent></dc:creator>\
<dc:title>A Drawing</dc:title></cc:Work></rdf:RDF></metadata></svg>";
let outcome = run(src);
let person = outcome
.findings
.iter()
.find(|f| f.kind == MetadataKind::PersonalIdentity)
.expect("the nested name must be reported as a person");
assert_eq!(person.field.as_deref(), Some("dc:title"));
assert!(
outcome
.findings
.iter()
.any(|f| f.kind == MetadataKind::Comment)
);
}
#[test]
fn xmp_written_as_attributes_is_itemised_rather_than_lumped_together() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\"><metadata><rdf:RDF>\
<rdf:Description xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" \
xmp:CreatorTool=\"Some Editor 3.1\" xmp:CreateDate=\"2021-03-04\"/>\
</rdf:RDF></metadata></svg>";
let outcome = run(src);
let fields: Vec<&str> = outcome
.findings
.iter()
.filter_map(|f| f.field.as_deref())
.collect();
assert!(
fields.contains(&"rdf:Description xmp:CreatorTool"),
"{fields:?}"
);
assert!(
outcome
.findings
.iter()
.any(|f| f.kind == MetadataKind::Timestamp),
"`xmp:CreateDate` is a timestamp"
);
assert!(!fields.iter().any(|f| f.contains("xmlns")), "{fields:?}");
}
#[test]
fn comments_go_and_the_xml_declaration_stays() {
let src = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
<!-- Generator: Adobe Illustrator 25.0, SVG Export Plug-In -->\
<?xpacket begin=\"\" id=\"W5M0Mp\"?>\
<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>";
let out = output(src);
assert!(out.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(!out.contains("Adobe Illustrator"));
assert!(!out.contains("xpacket"));
}
#[test]
fn a_script_or_an_event_handler_refuses_the_document() {
for src in [
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>",
"<svg xmlns=\"http://www.w3.org/2000/svg\" onload=\"alert(1)\"><rect/></svg>",
"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect onclick=\"x()\"/></svg>",
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><p/></foreignObject></svg>",
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"javascript:x()\"/></svg>",
] {
let e = process(src, &InspectOptions::names_only(), &ParseLimits::default())
.expect_err("a document that can execute code must be refused");
assert!(
matches!(
e,
StryptError::UnsupportedFormat {
format: UnsupportedKind::ScriptedSvg
}
),
"{src} gave {e:?}"
);
}
}
#[test]
fn an_ordinary_attribute_beginning_on_is_not_mistaken_for_a_handler() {
assert!(is_event_handler("onload"));
assert!(is_event_handler("onmouseover"));
assert!(!is_event_handler("on"));
assert!(!is_event_handler("once-upon"));
assert!(!is_event_handler("opacity"));
}
#[test]
fn accessibility_text_is_kept_and_declared() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\">\
<title>A photograph by A Name</title><desc>Taken at home</desc>\
<rect/></svg>";
let outcome = run(src);
assert!(outcome.output.is_none(), "the text stays");
assert!(
outcome.retained.iter().any(|r| r.location == "<title>"),
"an empty retained list is a claim: it has to say what was kept"
);
assert!(outcome.retained.iter().any(|r| r.location == "<desc>"));
assert!(
outcome
.notes
.iter()
.any(|n| matches!(n, Note::OutOfScopeContent { .. }))
);
}
#[test]
fn an_external_reference_is_reported_and_never_removed() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\">\
<image href=\"../../Users/aname/Desktop/photo.png\"/>\
<rect fill=\"url(https://tracker.invalid/p.png)\"/>\
<circle fill=\"url(#gradient)\"/></svg>";
let outcome = run(src);
assert!(outcome.output.is_none(), "removing it would break the file");
assert_eq!(outcome.retained.len(), 2, "{:?}", outcome.retained);
for retained in &outcome.retained {
assert!(!retained.location.contains("aname"));
assert!(!retained.location.contains("tracker"));
}
assert!(
outcome.findings.is_empty(),
"a kept reference must not be a finding, or verification would reject our own output"
);
}
#[test]
fn a_fragment_reference_is_not_a_leak() {
assert!(!is_external("#gradient"));
assert!(!is_external(""));
assert!(is_external("photo.png"));
assert!(is_external("https://example.invalid/x.png"));
assert!(!is_external("data:image/png;base64,AA=="));
}
#[test]
fn a_stylesheet_loses_its_comments_and_keeps_its_rules() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\"><style>\
/* Generator: A Tool 1.2 by A Name */\
.a{fill:red;content:\"/* not a comment */\"}</style><rect class=\"a\"/></svg>";
let out = output(src);
assert!(!out.contains("A Name"));
assert!(out.contains(".a{fill:red"));
assert!(
out.contains("/* not a comment */"),
"a quoted string is not a comment: {out}"
);
}
#[test]
fn a_doctype_stays_and_an_entity_subset_refuses() {
let plain = "<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \
\"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\
<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>";
assert!(run(plain).output.is_none());
let subset = "<!DOCTYPE svg [<!ENTITY who \"A Name\">]>\
<svg xmlns=\"http://www.w3.org/2000/svg\"><text>&who;</text></svg>";
assert!(
process(
subset,
&InspectOptions::names_only(),
&ParseLimits::default()
)
.is_err(),
"removing the subset would leave &who; pointing at nothing"
);
}
#[test]
fn scrubbing_is_idempotent() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:i=\"http://ns.adobe.com/\" \
i:extraneous=\"self\"><!-- Generator --><metadata><dc:creator>A</dc:creator>\
</metadata><rect/></svg>";
let once = output(src);
assert!(
run(&once).output.is_none(),
"a second pass must find nothing, or verification would never converge"
);
}
#[test]
fn overlapping_edits_take_the_outermost() {
let src = "<svg xmlns=\"http://www.w3.org/2000/svg\">\
<sodipodi:namedview inkscape:zoom=\"1\" xmlns:x=\"y\"/><rect/></svg>";
let out = output(src);
assert!(!out.contains("namedview"));
assert!(out.contains("<rect/>"));
}
#[test]
fn arbitrary_text_does_not_panic_the_rules() {
let cases = [
"",
"<",
"<svg",
"<svg>",
"<!--",
"<?",
"<![CDATA[",
"<!DOCTYPE [",
"<svg xmlns:=\"\"/>",
"<svg :name=\"x\"/>",
"<metadata>",
"<style>/*",
"<style>\"",
"<image href=\"data:\"/>",
"<image href=\"data:image/png;base64,====\"/>",
"<rect fill=\"url(\"/>",
"<<<<>>>>",
];
for case in cases {
let _ = process(
case,
&InspectOptions::with_values(),
&ParseLimits::default(),
);
}
}
#[test]
fn every_prefix_of_a_real_drawing_is_survivable() {
let src = "<?xml version=\"1.0\"?><!-- gen --><svg xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:inkscape=\"http://inkscape.org/\"><metadata><dc:creator>A</dc:creator>\
</metadata><title>T</title><style>/* c */.a{fill:red}</style>\
<image href=\"data:image/png;base64,iVBORw==\"/><rect/></svg>";
for n in 0..=src.len() {
let prefix = src.get(0..n).unwrap_or_default();
let _ = process(
prefix,
&InspectOptions::names_only(),
&ParseLimits::default(),
);
}
}
}