strypt_core/formats/svg.rs
1//! SVG — the one format in this project where strypt removes *less* than mat2 and says so.
2//!
3//! The fourth tranche of Phase 2's third group (ADR-0032), and the only member of it that is not
4//! a container of encoded pixels. **ADR-0035 is required reading before touching this handler.**
5//!
6//! # Editing is deletion, so a clean drawing comes back byte-identical
7//!
8//! Output is the input's bytes with some ranges cut out — the property GIF has (`THREAT_MODEL`
9//! §7.9) and that TIFF and HEIF cannot promise at all, because those two are rebuilt. Namespace
10//! declarations keep their order, attribute quoting keeps its style, whitespace keeps its shape,
11//! and a diff of input against output shows exactly what strypt did and nothing else. The one
12//! exception is an embedded image that was itself stripped, whose `data:` URI is re-encoded
13//! canonically in place.
14//!
15//! # mat2 is the opposite tool here
16//!
17//! For every raster format in this tree, mat2 re-renders the pixels and strypt does not, so mat2
18//! reaches metadata hidden inside the compressed image data and strypt records that as a
19//! limitation. SVG inverts it: mat2 loads the document through Rsvg and re-renders it onto a
20//! blank Cairo surface (verified against its `libmat2/images.py`, 2026-08-28), which removes
21//! everything — including the accessibility text and the script this handler will not touch — and
22//! also rewrites the whole document, so identifiers, grouping, animation, interactivity, and the
23//! author's editable structure do not survive.
24//!
25//! **Neither behaviour is a defect**, and `docs/THREAT_MODEL.md` §7.11 says which tool is the
26//! better recommendation for which user rather than implying strypt wins (ADR-0012).
27//!
28//! # Where the metadata is
29//!
30//! - `<metadata>` — RDF, Dublin Core, Creative Commons licensing, XMP. SVG 1.1 §5.10 states its
31//! contents are not rendered, so it is the one element in the format that is metadata by
32//! definition.
33//! - **Editor namespaces** — `<sodipodi:namedview>` records the author's window geometry, zoom,
34//! and current layer; `sodipodi:docname` is the file's name on the author's disk;
35//! `inkscape:export-filename` is an absolute path; Illustrator's `<i:pgf>` is a compressed copy
36//! of the original AI document hidden inside the exported SVG.
37//! - **Comments** — `<!-- Generator: Adobe Illustrator 25.0 -->`, and whatever a hand-editing
38//! author left behind.
39//! - **Processing instructions** — an XMP packet's `<?xpacket?>` wrapper.
40//! - **`data:` URIs** — a pasted photograph, complete with its GPS coordinates, its body serial
41//! number, and its own thumbnail, base64-encoded into an attribute of a file the user thinks of
42//! as a drawing.
43//! - **Stylesheet comments**, which fingerprint the producing tool as an XML comment does.
44
45use crate::detect::Format;
46use crate::error::{MalformedDetail, Result, StryptError};
47use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped};
48use crate::report::{InspectOptions, MetadataReport, StripReport};
49
50mod data_uri;
51mod rules;
52
53/// Removal of metadata from SVG documents.
54#[derive(Debug, Clone, Copy)]
55pub struct SvgHandler;
56
57impl MetadataHandler for SvgHandler {
58 fn name(&self) -> &'static str {
59 Format::Svg.id()
60 }
61
62 fn format(&self) -> Format {
63 Format::Svg
64 }
65
66 fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
67 // The same pass stripping uses, with the bytes discarded — so "everything `strip` removes
68 // is something `inspect` can see" holds by construction rather than by two code paths
69 // agreeing to stay in step, which is the ODF handler's arrangement and for the same
70 // reason: the verification pass is only worth anything if the two cannot diverge.
71 let outcome = rules::process(text(input)?, options, &ParseLimits::default())?;
72 Ok(MetadataReport {
73 format: Format::Svg,
74 findings: outcome.findings,
75 notes: outcome.notes,
76 })
77 }
78
79 fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
80 let source = text(input)?;
81 let outcome = rules::process(source, &options.inspect, &options.limits)?;
82 // A document with nothing to remove is not rewritten at all, which is what makes the
83 // byte-identical guarantee above hold rather than merely usually hold.
84 let bytes = outcome.output.unwrap_or_else(|| source.as_bytes().to_vec());
85 Ok(Stripped {
86 report: StripReport {
87 format: Format::Svg,
88 removed: outcome.findings,
89 retained: outcome.retained,
90 notes: outcome.notes,
91 input_bytes: crate::container::package::as_u64(input.len()),
92 output_bytes: crate::container::package::as_u64(bytes.len()),
93 },
94 bytes,
95 })
96 }
97}
98
99/// The document as text, or a refusal.
100///
101/// **Non-UTF-8 input is refused rather than scanned as bytes on a guess about its encoding.** XML
102/// permits UTF-16, and a scanner that read one as UTF-8 would find no tags at all and report a
103/// clean file — which is the silent pass-through of `docs/THREAT_MODEL.md` §5.4 wearing a
104/// success message (ADR-0035 §9).
105fn text(input: &[u8]) -> Result<&str> {
106 std::str::from_utf8(input).map_err(|e| StryptError::Malformed {
107 format: Format::Svg,
108 offset: Some(crate::container::package::as_u64(e.valid_up_to())),
109 detail: MalformedDetail::UnsupportedFeature,
110 })
111}
112
113#[cfg(test)]
114mod tests {
115 #![allow(clippy::unwrap_used, clippy::expect_used)]
116
117 use super::*;
118 use crate::error::UnsupportedKind;
119
120 const CLEAN: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 8 8\">\
121 <rect width=\"8\" height=\"8\" fill=\"#abcdef\"/></svg>";
122
123 fn strip(src: &str) -> Stripped {
124 SvgHandler
125 .strip(src.as_bytes(), &StripOptions::default())
126 .expect("stripping a well-formed drawing")
127 }
128
129 #[test]
130 fn a_clean_drawing_comes_back_byte_identical() {
131 // No other format in this tree can promise this of a whole file except GIF, and both can
132 // only promise it because they are edited by deletion rather than rebuilt.
133 let stripped = strip(CLEAN);
134 assert_eq!(stripped.bytes, CLEAN.as_bytes());
135 assert!(stripped.report.removed.is_empty());
136 }
137
138 #[test]
139 fn stripping_is_idempotent_byte_for_byte() {
140 let dirty = "<?xml version=\"1.0\"?><!-- Generator: A Tool -->\
141 <svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:i=\"http://ns.adobe.com/\" \
142 i:extraneous=\"self\"><metadata><dc:creator>A Name</dc:creator></metadata>\
143 <rect/></svg>";
144 let once = strip(dirty);
145 let twice = SvgHandler
146 .strip(&once.bytes, &StripOptions::default())
147 .unwrap();
148 assert_eq!(once.bytes, twice.bytes);
149 assert!(twice.report.removed.is_empty());
150 }
151
152 #[test]
153 fn what_strip_removes_inspect_can_see() {
154 // The invariant the verification pass depends on. If inspect could not see it, the pass
155 // would be checking nothing.
156 let dirty = "<svg xmlns=\"http://www.w3.org/2000/svg\"><!-- gen -->\
157 <metadata><dc:creator>A Name</dc:creator></metadata><rect/></svg>";
158 let before = SvgHandler
159 .inspect(dirty.as_bytes(), &InspectOptions::names_only())
160 .unwrap();
161 assert!(before.has_findings());
162 let after = SvgHandler
163 .inspect(&strip(dirty).bytes, &InspectOptions::names_only())
164 .unwrap();
165 assert!(!after.has_findings(), "{:?}", after.findings);
166 }
167
168 #[test]
169 fn a_utf16_document_is_refused_rather_than_read_as_bytes() {
170 // A scanner reading UTF-16 as UTF-8 finds no tags at all and reports a clean file.
171 let mut utf16 = vec![0xFF, 0xFE];
172 for unit in "<svg/>".encode_utf16() {
173 utf16.extend_from_slice(&unit.to_le_bytes());
174 }
175 assert!(matches!(
176 SvgHandler.strip(&utf16, &StripOptions::default()),
177 Err(StryptError::Malformed { .. })
178 ));
179 }
180
181 #[test]
182 fn a_scripted_document_is_refused_by_name() {
183 let src = "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>fetch('x')</script></svg>";
184 let e = SvgHandler
185 .strip(src.as_bytes(), &StripOptions::default())
186 .expect_err("a document that can execute code must be refused");
187 assert!(
188 matches!(
189 e,
190 StryptError::UnsupportedFormat {
191 format: UnsupportedKind::ScriptedSvg
192 }
193 ),
194 "{e:?}"
195 );
196 // The message has to name what happened: "malformed SVG" would send the user looking for
197 // a corrupt file they do not have.
198 assert!(e.to_string().contains("script"), "{e}");
199 }
200
201 #[test]
202 fn an_embedded_photograph_is_stripped_through_its_own_handler() {
203 // ADR-0029's descent, applied to a data: URI. The picture stays and its metadata goes.
204 let png = png_with_a_text_chunk();
205 let encoded = data_uri::encode("image/png", &png);
206 let src =
207 format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"{encoded}\"/></svg>");
208 let stripped = strip(&src);
209 let out = String::from_utf8(stripped.bytes).unwrap();
210 assert!(!out.contains(&encoded), "the URI must have been rewritten");
211 assert!(out.starts_with("<svg"), "the drawing itself is untouched");
212 assert!(
213 !stripped.report.removed.is_empty(),
214 "the embedded picture's metadata has to be reported"
215 );
216 }
217
218 /// A minimal PNG carrying one `tEXt` chunk, built by hand so the test needs no fixture file.
219 fn png_with_a_text_chunk() -> Vec<u8> {
220 fn chunk(kind: [u8; 4], data: &[u8]) -> Vec<u8> {
221 let mut out = u32::try_from(data.len()).unwrap().to_be_bytes().to_vec();
222 out.extend_from_slice(&kind);
223 out.extend_from_slice(data);
224 let mut crc = 0xFFFF_FFFFu32;
225 for byte in kind.iter().chain(data) {
226 crc ^= u32::from(*byte);
227 for _ in 0..8 {
228 crc = if crc & 1 == 1 {
229 (crc >> 1) ^ 0xEDB8_8320
230 } else {
231 crc >> 1
232 };
233 }
234 }
235 out.extend_from_slice(&(!crc).to_be_bytes());
236 out
237 }
238
239 let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
240 let mut ihdr = 1u32.to_be_bytes().to_vec();
241 ihdr.extend_from_slice(&1u32.to_be_bytes());
242 ihdr.extend_from_slice(&[8, 0, 0, 0, 0]);
243 png.extend(chunk(*b"IHDR", &ihdr));
244 png.extend(chunk(*b"tEXt", b"Author\0A Name"));
245 // One zlib-stored deflate block holding a single filtered scanline.
246 png.extend(chunk(
247 *b"IDAT",
248 &[
249 0x78, 0x01, 0x01, 0x02, 0x00, 0xFD, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01,
250 ],
251 ));
252 png.extend(chunk(*b"IEND", b""));
253 png
254 }
255}