Skip to main content

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}