use crate::core::{
Artifact, ArtifactMetadata, MAX_OUTPUT_PIXELS, MediaType, TransformError, TransformRequest,
TransformResult,
};
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
use image::codecs::webp::WebPEncoder;
use image::{ColorType, ImageEncoder, RgbaImage};
use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
use std::io::Cursor;
use std::time::Instant;
#[must_use = "this function returns the transform result without side effects"]
pub fn transform_svg(request: TransformRequest) -> Result<TransformResult, TransformError> {
if request.options.blur.is_some() {
return Err(TransformError::InvalidOptions(
"blur is not supported for SVG inputs".to_string(),
));
}
if request.options.sharpen.is_some() {
return Err(TransformError::InvalidOptions(
"sharpen is not supported for SVG inputs".to_string(),
));
}
if request.watermark.is_some() {
return Err(TransformError::InvalidOptions(
"watermark is not supported for SVG inputs".to_string(),
));
}
if request.options.crop.is_some() {
return Err(TransformError::InvalidOptions(
"crop is not supported for SVG inputs".to_string(),
));
}
let normalized = request.normalize()?;
let deadline = normalized.options.deadline;
let start = deadline.map(|_| Instant::now());
let sanitized = sanitize_svg(&normalized.input.bytes)?;
if let (Some(start), Some(limit)) = (start, deadline) {
crate::codecs::raster::check_deadline(start.elapsed(), limit, "sanitize")?;
}
if normalized.options.format == MediaType::Svg {
return Ok(TransformResult {
artifact: Artifact::new(
sanitized.into_bytes(),
MediaType::Svg,
ArtifactMetadata {
width: None,
height: None,
frame_count: 1,
duration: None,
has_alpha: Some(true),
orientation: None,
},
),
warnings: vec![],
});
}
let tree = resvg::usvg::Tree::from_str(&sanitized, &resvg::usvg::Options::default())
.map_err(|e| TransformError::DecodeFailed(format!("SVG parse error: {e}")))?;
let (width, height) =
determine_render_size(&tree, normalized.options.width, normalized.options.height);
let pixel_count = width as u64 * height as u64;
if pixel_count > MAX_OUTPUT_PIXELS {
return Err(TransformError::LimitExceeded(format!(
"requested SVG rasterization size {width}x{height} ({pixel_count} pixels) exceeds limit of {MAX_OUTPUT_PIXELS}"
)));
}
let rgba_image = rasterize_svg(&tree, width, height)?;
if let (Some(start), Some(limit)) = (start, deadline) {
crate::codecs::raster::check_deadline(start.elapsed(), limit, "rasterize")?;
}
let rgba_image = if normalized.options.rotate.is_identity() {
rgba_image
} else {
crate::codecs::raster::apply_rotation(
image::DynamicImage::ImageRgba8(rgba_image),
normalized.options.rotate,
normalized.options.background,
normalized.options.format,
)?
.into_rgba8()
};
let rgba_image = if normalized.options.grayscale {
image::DynamicImage::ImageRgba8(rgba_image)
.grayscale()
.into_rgba8()
} else {
rgba_image
};
let rgba_image = crate::codecs::raster::flatten_for_opaque_output(
image::DynamicImage::ImageRgba8(rgba_image),
normalized.options.background,
normalized.options.format,
)
.into_rgba8();
let (out_width, out_height) = (rgba_image.width(), rgba_image.height());
let bytes = encode_raster_output(
&rgba_image,
normalized.options.format,
normalized.options.quality,
)?;
if let (Some(start), Some(limit)) = (start, deadline) {
crate::codecs::raster::check_deadline(start.elapsed(), limit, "encode")?;
}
let format = normalized.options.format;
Ok(TransformResult {
artifact: Artifact::new(
bytes,
format,
ArtifactMetadata {
width: Some(out_width),
height: Some(out_height),
frame_count: 1,
duration: None,
has_alpha: Some(crate::codecs::raster::format_carries_alpha(format)),
orientation: None,
},
),
warnings: vec![],
})
}
const MAX_SVG_ELEMENTS: usize = 100_000;
const MAX_SVG_NESTING_DEPTH: usize = 256;
fn sanitize_svg(bytes: &[u8]) -> Result<String, TransformError> {
let input = std::str::from_utf8(bytes)
.map_err(|e| TransformError::DecodeFailed(format!("SVG is not valid UTF-8: {e}")))?;
let mut reader = Reader::from_str(input);
let mut writer = Writer::new(Cursor::new(Vec::new()));
let mut skip_depth: usize = 0;
let mut in_style = false;
let mut element_count: usize = 0;
let mut nesting_depth: usize = 0;
loop {
match reader.read_event() {
Ok(Event::Eof) => break,
Ok(Event::Start(ref e)) => {
let name = local_name(e.name().as_ref());
if skip_depth > 0 {
skip_depth += 1;
continue;
}
if is_forbidden_element(&name) {
skip_depth = 1;
continue;
}
element_count += 1;
if element_count > MAX_SVG_ELEMENTS {
return Err(TransformError::LimitExceeded(format!(
"SVG exceeds maximum element count ({MAX_SVG_ELEMENTS})"
)));
}
nesting_depth += 1;
if nesting_depth > MAX_SVG_NESTING_DEPTH {
return Err(TransformError::LimitExceeded(format!(
"SVG exceeds maximum nesting depth ({MAX_SVG_NESTING_DEPTH})"
)));
}
if name == "style" {
in_style = true;
}
let sanitized = sanitize_attributes(e);
writer
.write_event(Event::Start(sanitized))
.map_err(|e| TransformError::DecodeFailed(format!("SVG write error: {e}")))?;
}
Ok(Event::End(ref e)) => {
if skip_depth > 0 {
skip_depth -= 1;
continue;
}
let name = local_name(e.name().as_ref());
if name == "style" {
in_style = false;
}
nesting_depth = nesting_depth.saturating_sub(1);
writer
.write_event(Event::End(e.to_owned()))
.map_err(|e| TransformError::DecodeFailed(format!("SVG write error: {e}")))?;
}
Ok(Event::Empty(ref e)) => {
if skip_depth > 0 {
continue;
}
let name = local_name(e.name().as_ref());
if is_forbidden_element(&name) {
continue;
}
element_count += 1;
if element_count > MAX_SVG_ELEMENTS {
return Err(TransformError::LimitExceeded(format!(
"SVG exceeds maximum element count ({MAX_SVG_ELEMENTS})"
)));
}
let sanitized = sanitize_attributes(e);
writer
.write_event(Event::Empty(sanitized))
.map_err(|e| TransformError::DecodeFailed(format!("SVG write error: {e}")))?;
}
Ok(Event::Text(ref e)) => {
if skip_depth > 0 {
continue;
}
if in_style {
let decoded = e.decode().unwrap_or_default();
let text = quick_xml::escape::unescape(&decoded).unwrap_or_default();
let sanitized_css = sanitize_css_urls(&text);
let text_event = quick_xml::events::BytesText::new(&sanitized_css);
writer
.write_event(Event::Text(text_event.into_owned()))
.map_err(|e| {
TransformError::DecodeFailed(format!("SVG write error: {e}"))
})?;
} else {
writer.write_event(Event::Text(e.to_owned())).map_err(|e| {
TransformError::DecodeFailed(format!("SVG write error: {e}"))
})?;
}
}
Ok(Event::CData(ref e)) => {
if skip_depth > 0 {
continue;
}
if in_style {
let text = String::from_utf8_lossy(e.as_ref());
let sanitized_css = sanitize_css_urls(&text);
let text_event = quick_xml::events::BytesText::new(&sanitized_css);
writer
.write_event(Event::Text(text_event.into_owned()))
.map_err(|e| {
TransformError::DecodeFailed(format!("SVG write error: {e}"))
})?;
} else {
writer
.write_event(Event::CData(e.to_owned()))
.map_err(|e| {
TransformError::DecodeFailed(format!("SVG write error: {e}"))
})?;
}
}
Ok(event) => {
if skip_depth > 0 {
continue;
}
writer
.write_event(event)
.map_err(|e| TransformError::DecodeFailed(format!("SVG write error: {e}")))?;
}
Err(e) => {
return Err(TransformError::DecodeFailed(format!(
"SVG parse error: {e}"
)));
}
}
}
let result = writer.into_inner().into_inner();
String::from_utf8(result)
.map_err(|e| TransformError::DecodeFailed(format!("SVG output is not valid UTF-8: {e}")))
}
fn local_name(name: &[u8]) -> String {
let name_str = std::str::from_utf8(name).unwrap_or("");
name_str
.rsplit_once(':')
.map_or(name_str, |(_, local)| local)
.to_ascii_lowercase()
}
fn is_forbidden_element(local_name: &str) -> bool {
matches!(
local_name,
"script"
| "foreignobject"
| "iframe"
| "embed"
| "object"
| "animate"
| "set"
| "animatetransform"
| "animatemotion"
| "animatecolor"
| "handler"
)
}
fn is_event_handler(attr_name: &str) -> bool {
let lower = attr_name.to_ascii_lowercase();
lower.starts_with("on") && lower.len() > 2 && lower.as_bytes()[2].is_ascii_alphabetic()
}
fn is_dangerous_href(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with('#') {
return false;
}
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("data:image/") {
return lower.starts_with("data:image/svg");
}
true
}
fn sanitize_attributes<'a>(element: &'a BytesStart<'a>) -> BytesStart<'a> {
let mut sanitized = BytesStart::new(
std::str::from_utf8(element.name().as_ref())
.unwrap_or("unknown")
.to_string(),
);
for attr in element.attributes().flatten() {
let Ok(key) = std::str::from_utf8(attr.key.as_ref()) else {
continue;
};
let Ok(value) = std::str::from_utf8(&attr.value) else {
continue;
};
if is_event_handler(key) {
continue;
}
let key_lower = key.to_ascii_lowercase();
let key_local = key_lower
.rsplit_once(':')
.map_or(key_lower.as_str(), |(_, local)| local);
if key_lower == "xml:base" {
continue;
}
if key_local == "href" && is_dangerous_href(value) {
continue;
}
if key_local == "style" {
let sanitized_value = sanitize_css_urls(value);
sanitized.push_attribute((key, sanitized_value.as_str()));
continue;
}
sanitized.push_attribute((key, value));
}
sanitized
}
fn sanitize_css_urls(css: &str) -> String {
let lower = css.to_ascii_lowercase();
let mut result = String::with_capacity(css.len());
let mut offset = 0;
while let Some(pos) = lower[offset..].find("@import") {
result.push_str(&css[offset..offset + pos]);
let after_import = offset + pos + 7;
if let Some(semi) = css[after_import..].find(';') {
offset = after_import + semi + 1;
} else {
offset = css.len();
}
}
result.push_str(&css[offset..]);
let css_after_import = result;
let lower_after_import = css_after_import.to_ascii_lowercase();
let mut result = String::with_capacity(css_after_import.len());
let mut offset = 0;
while let Some(start) = lower_after_import[offset..].find("url(") {
result.push_str(&css_after_import[offset..offset + start]);
let url_open = offset + start + 4;
let after_url = &css_after_import[url_open..];
let (url_value, rest) = extract_css_url_value(after_url);
let consumed = after_url.len() - rest.len();
let trimmed = url_value
.trim()
.trim_matches(|c| c == '\'' || c == '"')
.trim();
if is_dangerous_css_url(trimmed) {
result.push_str("url()");
} else {
result.push_str("url(");
result.push_str(url_value);
result.push(')');
}
offset = url_open + consumed;
}
result.push_str(&css_after_import[offset..]);
result
}
fn extract_css_url_value(s: &str) -> (&str, &str) {
let mut depth = 0u32;
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => {
if depth == 0 {
return (&s[..i], &s[i + 1..]);
}
depth -= 1;
}
_ => {}
}
}
(s, "")
}
fn is_dangerous_css_url(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with('#') {
return false;
}
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("data:image/") {
return lower.starts_with("data:image/svg");
}
true
}
fn determine_render_size(
tree: &resvg::usvg::Tree,
requested_width: Option<u32>,
requested_height: Option<u32>,
) -> (u32, u32) {
if let (Some(w), Some(h)) = (requested_width, requested_height) {
return (w, h);
}
let size = tree.size();
let intrinsic_w = size.width() as u32;
let intrinsic_h = size.height() as u32;
let (w, h) = match (requested_width, requested_height) {
(Some(w), None) => {
let h = if intrinsic_w > 0 {
(w as f64 * intrinsic_h as f64 / intrinsic_w as f64).round() as u32
} else {
intrinsic_h
};
(w, h.max(1))
}
(None, Some(h)) => {
let w = if intrinsic_h > 0 {
(h as f64 * intrinsic_w as f64 / intrinsic_h as f64).round() as u32
} else {
intrinsic_w
};
(w.max(1), h)
}
(None, None) => {
let w = if intrinsic_w > 0 { intrinsic_w } else { 300 };
let h = if intrinsic_h > 0 { intrinsic_h } else { 150 };
(w, h)
}
_ => unreachable!(),
};
(w.max(1u32), h.max(1u32))
}
fn rasterize_svg(
tree: &resvg::usvg::Tree,
width: u32,
height: u32,
) -> Result<RgbaImage, TransformError> {
let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height).ok_or_else(|| {
TransformError::DecodeFailed(format!(
"failed to create {width}x{height} pixel buffer for SVG rasterization"
))
})?;
let scale_x = width as f32 / tree.size().width();
let scale_y = height as f32 / tree.size().height();
let transform = resvg::tiny_skia::Transform::from_scale(scale_x, scale_y);
resvg::render(tree, transform, &mut pixmap.as_mut());
let mut rgba_data = pixmap.take();
let (pixels, _) = rgba_data.as_chunks_mut::<4>();
for pixel in pixels {
let a = u16::from(pixel[3]);
if a > 0 && a < 255 {
pixel[0] = ((u16::from(pixel[0]) * 255 + a / 2) / a).min(255) as u8;
pixel[1] = ((u16::from(pixel[1]) * 255 + a / 2) / a).min(255) as u8;
pixel[2] = ((u16::from(pixel[2]) * 255 + a / 2) / a).min(255) as u8;
}
}
RgbaImage::from_raw(width, height, rgba_data)
.ok_or_else(|| TransformError::DecodeFailed("SVG rasterization buffer mismatch".into()))
}
fn encode_raster_output(
image: &RgbaImage,
format: MediaType,
quality: Option<u8>,
) -> Result<Vec<u8>, TransformError> {
let mut bytes = Vec::new();
let (width, height) = (image.width(), image.height());
match format {
MediaType::Jpeg => {
let quality = quality.unwrap_or(80);
let encoder = JpegEncoder::new_with_quality(&mut bytes, quality);
let rgb: Vec<u8> = image.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect();
encoder
.write_image(&rgb, width, height, ColorType::Rgb8.into())
.map_err(|e| TransformError::EncodeFailed(format!("JPEG encode failed: {e}")))?;
}
MediaType::Png => {
let encoder = PngEncoder::new(&mut bytes);
encoder
.write_image(image.as_ref(), width, height, ColorType::Rgba8.into())
.map_err(|e| TransformError::EncodeFailed(format!("PNG encode failed: {e}")))?;
}
MediaType::Webp => {
if let Some(q) = quality {
#[cfg(feature = "webp-lossy")]
{
let lossy_encoder = webp::Encoder::from_rgba(image.as_ref(), width, height);
let encoded = lossy_encoder.encode(q as f32);
bytes = encoded.to_vec();
}
#[cfg(not(feature = "webp-lossy"))]
{
let _ = q;
return Err(TransformError::CapabilityMissing(
"lossy WebP encoding is not enabled in this build".into(),
));
}
} else {
let encoder = WebPEncoder::new_lossless(&mut bytes);
encoder
.write_image(image.as_ref(), width, height, ColorType::Rgba8.into())
.map_err(|e| {
TransformError::EncodeFailed(format!("WebP encode failed: {e}"))
})?;
}
}
MediaType::Avif => {
#[cfg(feature = "avif")]
{
let quality = quality.unwrap_or(80);
let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality(
&mut bytes, 4, quality,
);
encoder
.write_image(image.as_ref(), width, height, ColorType::Rgba8.into())
.map_err(|e| {
TransformError::EncodeFailed(format!("AVIF encode failed: {e}"))
})?;
}
#[cfg(not(feature = "avif"))]
{
let _ = quality;
return Err(TransformError::CapabilityMissing(
"AVIF encoding is not enabled in this build".to_string(),
));
}
}
MediaType::Bmp => {
let encoder = image::codecs::bmp::BmpEncoder::new(&mut bytes);
encoder
.write_image(image.as_ref(), width, height, ColorType::Rgba8.into())
.map_err(|e| TransformError::EncodeFailed(format!("BMP encode failed: {e}")))?;
}
MediaType::Tiff => {
let mut cursor = std::io::Cursor::new(bytes);
image::codecs::tiff::TiffEncoder::new(&mut cursor)
.write_image(image.as_ref(), width, height, ColorType::Rgba8.into())
.map_err(|e| TransformError::EncodeFailed(format!("TIFF encode failed: {e}")))?;
bytes = cursor.into_inner();
}
MediaType::Svg => {
return Err(TransformError::InvalidOptions(
"SVG-to-SVG rasterization is not meaningful".into(),
));
}
MediaType::Gif => {
return Err(TransformError::UnsupportedOutputMediaType(MediaType::Gif));
}
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{RawArtifact, Rotation, TransformOptions, sniff_artifact};
fn svg_with_script() -> Vec<u8> {
b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><rect width=\"10\" height=\"10\"/></svg>".to_vec()
}
fn svg_with_event_handler() -> Vec<u8> {
b"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect onclick=\"alert('xss')\" width=\"10\" height=\"10\"/></svg>".to_vec()
}
fn svg_with_foreign_object() -> Vec<u8> {
b"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>hi</body></foreignObject></svg>".to_vec()
}
fn svg_with_external_href() -> Vec<u8> {
b"<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"https://evil.com/img.png\"/></svg>".to_vec()
}
fn svg_with_data_script() -> Vec<u8> {
b"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"data:text/html,<script>alert(1)</script>\">click</a></svg>".to_vec()
}
fn simple_svg() -> Vec<u8> {
b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"10\"><rect width=\"20\" height=\"10\" fill=\"blue\"/></svg>".to_vec()
}
#[test]
fn sanitize_removes_animation_elements() {
for element in ["animate", "set", "animateTransform", "animateMotion"] {
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a><{element} attributeName=\"href\" to=\"#a\"/><text>x</text></a></svg>"
);
let result = sanitize_svg(svg.as_bytes()).unwrap();
assert!(
!result
.to_ascii_lowercase()
.contains(&element.to_ascii_lowercase()),
"<{element}> should be removed, got: {result}"
);
assert!(result.contains("<text"), "<text> should be preserved");
}
}
#[test]
fn sanitize_removes_javascript_uri_in_animation_values() {
for attribute in ["to", "values", "from", "by"] {
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a><animate attributeName=\"href\" {attribute}=\"javascript:alert(1)\" begin=\"0s\"/><text>click</text></a></svg>"
);
let result = sanitize_svg(svg.as_bytes()).unwrap();
assert!(
!result.contains("javascript:"),
"javascript: survived through {attribute}: {result}"
);
}
}
#[test]
fn sanitize_removes_external_reference_in_animation_values() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg"><image x="0" y="0" width="100" height="100"><set attributeName="href" to="https://evil.example.com/track.png" begin="0s"/></image></svg>"#;
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("evil.example.com"),
"external reference survived: {result}"
);
}
#[test]
fn sanitize_removes_animation_targeting_xlink_href() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg"><a><set attributeName="xlink:href" to="javascript:alert(1)"/><text>x</text></a></svg>"#;
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("javascript:"),
"javascript: survived: {result}"
);
}
#[test]
fn sanitize_removes_handler_element() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg"><handler type="text/javascript">alert(1)</handler><rect/></svg>"#;
let result = sanitize_svg(svg).unwrap();
assert!(!result.contains("handler"), "handler survived: {result}");
assert!(!result.contains("alert"), "script body survived: {result}");
assert!(result.contains("<rect"), "rect should be preserved");
}
#[test]
fn sanitize_removes_script_element() {
let result = sanitize_svg(&svg_with_script()).unwrap();
assert!(
!result.contains("<script"),
"script element should be removed"
);
assert!(
!result.contains("alert"),
"script content should be removed"
);
assert!(result.contains("<rect"), "rect element should be preserved");
}
#[test]
fn sanitize_removes_event_handlers() {
let result = sanitize_svg(&svg_with_event_handler()).unwrap();
assert!(!result.contains("onclick"), "onclick should be removed");
assert!(result.contains("<rect"), "rect element should be preserved");
assert!(
result.contains("width"),
"width attribute should be preserved"
);
}
#[test]
fn sanitize_removes_foreign_object() {
let result = sanitize_svg(&svg_with_foreign_object()).unwrap();
assert!(
!result.contains("foreignObject"),
"foreignObject should be removed"
);
}
#[test]
fn sanitize_removes_external_href() {
let result = sanitize_svg(&svg_with_external_href()).unwrap();
assert!(
!result.contains("https://evil.com"),
"external href should be removed"
);
}
#[test]
fn sanitize_removes_data_script_href() {
let result = sanitize_svg(&svg_with_data_script()).unwrap();
assert!(
!result.contains("data:text/html"),
"data script href should be removed"
);
}
#[test]
fn sanitize_preserves_valid_svg() {
let result = sanitize_svg(&simple_svg()).unwrap();
assert!(result.contains("<svg"), "svg element should be preserved");
assert!(result.contains("<rect"), "rect element should be preserved");
assert!(
result.contains("fill=\"blue\""),
"fill attribute should be preserved"
);
}
#[test]
fn sanitize_allows_data_image_href() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"data:image/png;base64,abc\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
result.contains("data:image/png"),
"data:image/* href should be preserved"
);
}
#[test]
fn sanitize_allows_internal_fragment_href() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#myShape\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
result.contains("#myShape"),
"internal fragment href should be preserved"
);
}
#[test]
fn sanitize_removes_external_css_url() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>rect { fill: url(https://evil.com/style.css) }</style><rect width=\"10\" height=\"10\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("evil.com"),
"external CSS url() should be removed"
);
assert!(
result.contains("url()"),
"dangerous url() should be emptied"
);
}
#[test]
fn sanitize_preserves_local_css_url() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>rect { fill: url(#myGradient) }</style><rect width=\"10\" height=\"10\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
result.contains("url(#myGradient)"),
"local CSS url(#id) should be preserved"
);
}
#[test]
fn sanitize_removes_data_script_css_url() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>rect { background: url(data:text/html,<script>alert(1)</script>) }</style></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("data:text/html"),
"data:text/html CSS url() should be removed"
);
}
#[test]
fn sanitize_removes_javascript_href() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"javascript:alert(1)\">click</a></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("javascript:"),
"javascript: href should be removed"
);
}
#[test]
fn sanitize_removes_mixed_case_javascript_href() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"JaVaScRiPt:alert(1)\">click</a></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("alert"),
"mixed-case javascript: href should be removed"
);
}
#[test]
fn sanitize_removes_mixed_case_data_href() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"DATA:text/html,evil\">click</a></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("DATA:text/html"),
"mixed-case DATA: href should be removed"
);
}
#[test]
fn sanitize_removes_iframe_element() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><iframe src=\"https://evil.com\"></iframe></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(!result.contains("iframe"), "iframe should be removed");
}
#[test]
fn sanitize_removes_xml_base_attribute() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" xml:base=\"https://evil.com/\"><use href=\"img.svg\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("xml:base"),
"xml:base attribute should be removed"
);
}
#[test]
fn sanitize_removes_inline_style_external_url() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect style=\"background:url(https://evil.com/track)\" width=\"10\" height=\"10\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("evil.com"),
"external url() in inline style should be removed"
);
assert!(
result.contains("url()"),
"dangerous url() should be emptied"
);
}
#[test]
fn sanitize_removes_entity_escaped_external_css_url() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>rect { fill: url(https://evil.example/a?x=1&y=2) }</style><rect width=\"10\" height=\"10\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("evil.example"),
"entity-escaped external CSS url() should be removed"
);
assert!(
result.contains("url()"),
"dangerous url() should be emptied"
);
}
#[test]
fn sanitize_removes_css_import() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>@import url(\"https://evil.com/style.css\"); rect { fill: red }</style></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("@import"),
"@import should be removed from style"
);
assert!(
!result.contains("evil.com"),
"imported URL should be removed"
);
assert!(
result.contains("fill: red"),
"legitimate CSS should be preserved"
);
}
#[test]
fn sniff_detects_svg_input() {
let artifact =
sniff_artifact(RawArtifact::new(simple_svg(), None)).expect("should detect SVG");
assert_eq!(artifact.media_type, MediaType::Svg);
assert_eq!(artifact.metadata.has_alpha, Some(true));
}
#[test]
fn sniff_detects_svg_with_xml_declaration() {
let svg = b"<?xml version=\"1.0\" encoding=\"UTF-8\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
let artifact =
sniff_artifact(RawArtifact::new(svg.to_vec(), None)).expect("should detect SVG");
assert_eq!(artifact.media_type, MediaType::Svg);
}
#[test]
fn transform_svg_sanitize_only() {
let input = sniff_artifact(RawArtifact::new(svg_with_script(), None)).unwrap();
let result = transform_svg(TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Svg),
..TransformOptions::default()
},
))
.expect("sanitize should succeed");
assert_eq!(result.artifact.media_type, MediaType::Svg);
let output = std::str::from_utf8(&result.artifact.bytes).unwrap();
assert!(!output.contains("<script"), "script should be removed");
assert!(output.contains("<rect"), "rect should be preserved");
}
#[test]
fn transform_svg_to_png() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let result = transform_svg(TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Png),
width: Some(20),
height: Some(10),
..TransformOptions::default()
},
))
.expect("SVG to PNG should succeed");
assert_eq!(result.artifact.media_type, MediaType::Png);
assert_eq!(result.artifact.metadata.width, Some(20));
assert_eq!(result.artifact.metadata.height, Some(10));
}
#[test]
fn transform_svg_to_jpeg() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let result = transform_svg(TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Jpeg),
width: Some(20),
height: Some(10),
..TransformOptions::default()
},
))
.expect("SVG to JPEG should succeed");
assert_eq!(result.artifact.media_type, MediaType::Jpeg);
}
#[test]
fn transform_svg_uses_intrinsic_dimensions() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let result = transform_svg(TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Png),
..TransformOptions::default()
},
))
.expect("SVG to PNG with intrinsic size should succeed");
assert_eq!(result.artifact.metadata.width, Some(20));
assert_eq!(result.artifact.metadata.height, Some(10));
}
#[test]
fn transform_svg_to_png_with_rotate_90() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let result = transform_svg(TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Png),
rotate: Rotation::DEG_90,
..TransformOptions::default()
},
))
.expect("SVG to PNG with rotate 90 should succeed");
assert_eq!(result.artifact.media_type, MediaType::Png);
assert_eq!(
result.artifact.metadata.width,
Some(10),
"width should be swapped after 90 degree rotation"
);
assert_eq!(
result.artifact.metadata.height,
Some(20),
"height should be swapped after 90 degree rotation"
);
}
#[test]
fn transform_svg_to_png_with_grayscale() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let result = transform_svg(TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Png),
grayscale: true,
..TransformOptions::default()
},
))
.expect("SVG to PNG with grayscale should succeed");
let output =
image::load_from_memory_with_format(&result.artifact.bytes, image::ImageFormat::Png)
.expect("decode rasterized output")
.to_rgba8();
for (x, y, pixel) in output.enumerate_pixels() {
assert!(
pixel[0] == pixel[1] && pixel[1] == pixel[2],
"pixel ({x},{y}) is not neutral gray: {pixel:?}"
);
}
}
#[test]
fn transform_svg_to_png_with_rotate_180() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let result = transform_svg(TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Png),
rotate: Rotation::DEG_180,
..TransformOptions::default()
},
))
.expect("SVG to PNG with rotate 180 should succeed");
assert_eq!(result.artifact.metadata.width, Some(20));
assert_eq!(result.artifact.metadata.height, Some(10));
}
#[test]
fn transform_svg_rejects_preserve_exif_with_svg_output() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let err = transform_svg(TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Svg),
preserve_exif: true,
strip_metadata: false,
..TransformOptions::default()
},
))
.expect_err("preserveExif + svg should fail");
assert!(
matches!(err, TransformError::InvalidOptions(_)),
"expected InvalidOptions, got {err:?}"
);
}
#[test]
fn transform_svg_rejects_invalid_svg() {
let artifact = Artifact::new(
b"not an svg".to_vec(),
MediaType::Svg,
ArtifactMetadata {
width: None,
height: None,
frame_count: 1,
duration: None,
has_alpha: Some(true),
orientation: None,
},
);
let err = transform_svg(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Png),
width: Some(100),
height: Some(100),
..TransformOptions::default()
},
))
.expect_err("invalid SVG should fail");
assert!(
matches!(err, TransformError::DecodeFailed(_)),
"expected DecodeFailed, got {err:?}"
);
}
#[test]
fn sanitize_removes_file_scheme_href() {
let svg =
b"<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"file:///etc/passwd\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("file:///etc/passwd"),
"file: href should be removed"
);
}
#[test]
fn sanitize_removes_ftp_scheme_href() {
let svg =
b"<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"ftp://evil.com/img.png\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(!result.contains("ftp://"), "ftp: href should be removed");
}
#[test]
fn sanitize_keeps_fragment_href() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#myShape\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
result.contains("#myShape"),
"fragment href should be preserved"
);
}
#[test]
fn sanitize_removes_cdata_import_in_style() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><style><![CDATA[@import url(https://evil.example/a.css); rect { fill: red }]]></style></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("@import"),
"@import inside CDATA should be removed"
);
assert!(
!result.contains("evil.example"),
"external URL inside CDATA should be removed"
);
assert!(
result.contains("fill: red"),
"legitimate CSS should be preserved"
);
}
#[test]
fn sanitize_removes_cdata_external_url_in_style() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><style><![CDATA[rect { background: url(https://evil.example/bg.png) }]]></style></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("evil.example"),
"external url() inside CDATA should be removed"
);
}
#[test]
fn sanitize_removes_file_scheme_in_css_url() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>rect { fill: url(file:///etc/passwd) }</style></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
!result.contains("file:///etc/passwd"),
"file: url() in CSS should be removed"
);
}
#[test]
fn sanitize_keeps_local_css_url_fragment() {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect style=\"fill: url(#gradient1)\"/></svg>";
let result = sanitize_svg(svg).unwrap();
assert!(
result.contains("#gradient1"),
"local fragment url() should be preserved"
);
}
#[test]
fn is_dangerous_href_blocks_file_scheme() {
assert!(is_dangerous_href("file:///etc/passwd"));
}
#[test]
fn is_dangerous_href_blocks_ftp_scheme() {
assert!(is_dangerous_href("ftp://evil.com/file"));
}
#[test]
fn is_dangerous_href_allows_fragment() {
assert!(!is_dangerous_href("#myId"));
}
#[test]
fn is_dangerous_href_allows_data_image() {
assert!(!is_dangerous_href("data:image/png;base64,abc"));
}
#[test]
fn is_dangerous_href_blocks_data_text() {
assert!(is_dangerous_href(
"data:text/html,<script>alert(1)</script>"
));
}
#[test]
fn is_dangerous_css_url_blocks_file_scheme() {
assert!(is_dangerous_css_url("file:///etc/passwd"));
}
#[test]
fn is_dangerous_css_url_allows_fragment() {
assert!(!is_dangerous_css_url("#gradientId"));
}
#[test]
fn is_dangerous_css_url_allows_data_image() {
assert!(!is_dangerous_css_url("data:image/png;base64,abc"));
}
#[test]
fn svg_rejects_blur() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let request = TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Png),
blur: Some(2.0),
..TransformOptions::default()
},
);
let err = transform_svg(request).unwrap_err();
assert!(
matches!(err, TransformError::InvalidOptions(ref msg) if msg.contains("blur")),
"expected InvalidOptions about blur, got: {err}"
);
}
#[test]
fn svg_rejects_sharpen() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let request = TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Png),
sharpen: Some(2.0),
..TransformOptions::default()
},
);
let err = transform_svg(request).unwrap_err();
assert!(
matches!(err, TransformError::InvalidOptions(ref msg) if msg.contains("sharpen")),
"expected InvalidOptions about sharpen, got: {err}"
);
}
#[test]
fn svg_rejects_watermark() {
let input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let wm_input = sniff_artifact(RawArtifact::new(simple_svg(), None)).unwrap();
let mut request = TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Png),
..TransformOptions::default()
},
);
request.watermark = Some(crate::core::WatermarkInput {
image: wm_input,
position: crate::core::Position::Center,
opacity: 50,
margin: 0,
});
let err = transform_svg(request).unwrap_err();
assert!(
matches!(err, TransformError::InvalidOptions(ref msg) if msg.contains("watermark")),
"expected InvalidOptions about watermark, got: {err}"
);
}
}