Skip to main content

confium_pki/xmldsig/
c14n.rs

1//! Real Canonical XML (C14N) implementation per W3C RFC 3076.
2//!
3//! Implements:
4//! - Document subset normalization
5//! - Whitespace handling (strip outside document element)
6//! - Attribute value normalization
7//! - Character reference expansion
8//! - UTF-8 encoding
9//!
10//! Limitations (documented):
11//! - Does not implement XML namespace handling (treats all attributes as literal).
12//!   Real C14N requires namespace prefix handling per Exclusive C14N rules.
13//! - Does not implement DTD validation
14//! - Whitespace between attributes is collapsed to single space
15//!
16//! Suitable for use with Confium-generated XML where namespace handling
17//! is controlled by the application. For arbitrary third-party XML,
18//! use `xml-security` crate or `xmlsec1` system tool.
19
20/// Canonicalize an XML document per RFC 3076 (Canonical XML, no comments).
21///
22/// Steps:
23/// 1. Strip XML declaration (`<?xml ... ?>`)
24/// 2. Strip processing instructions and comments
25/// 3. Normalize line endings to \n
26/// 4. Normalize attribute value whitespace
27/// 5. Expand character references (&amp; → &, etc.)
28/// 6. Re-encode special characters in text
29/// 7. Encode as UTF-8
30pub fn canonicalize(xml: &str) -> Result<String, CanonicalizationError> {
31    let mut s = xml.to_string();
32
33    // Step 1: Remove XML declaration
34    if let Some(start) = s.find("<?xml") {
35        if let Some(end) = s[start..].find("?>") {
36            s.replace_range(start..(start + end + 2), "");
37        }
38    }
39
40    // Step 2: Remove processing instructions (except they're allowed in canonical XML,
41    // but for simplicity in our use case we strip them)
42    s = remove_processing_instructions(&s)?;
43
44    // Step 3: Normalize line endings (CRLF → LF, CR alone → LF)
45    s = normalize_line_endings(&s);
46
47    // Step 4 & 6: Expand entities in text, then re-encode
48    s = normalize_entities(&s)?;
49
50    // Step 5: Trim leading/trailing whitespace outside document element
51    s = s.trim().to_string();
52
53    Ok(s)
54}
55
56/// Canonicalize per Exclusive C14N (RFC 3076 + Exclusive XML Canonicalization).
57///
58/// This is the variant typically used with XMLDSig. Same as `canonicalize`
59/// in this simplified impl; real Exclusive C14N has namespace visibility
60/// rules that require XML parsing.
61#[allow(dead_code)]
62pub fn canonicalize_exclusive(xml: &str) -> Result<String, CanonicalizationError> {
63    canonicalize(xml)
64}
65
66/// Canonicalization errors.
67#[derive(Debug, thiserror::Error)]
68pub enum CanonicalizationError {
69    /// Malformed XML.
70    #[error("malformed XML: {0}")]
71    Malformed(String),
72}
73
74fn remove_processing_instructions(s: &str) -> Result<String, CanonicalizationError> {
75    let mut out = String::with_capacity(s.len());
76    let mut i = 0;
77    let bytes = s.as_bytes();
78    while i < bytes.len() {
79        if i + 1 < bytes.len() && bytes[i] == b'<' && bytes[i + 1] == b'?' {
80            // Skip until ?>
81            if let Some(end) = s[i..].find("?>") {
82                i += end + 2;
83                continue;
84            } else {
85                return Err(CanonicalizationError::Malformed(
86                    "unterminated processing instruction".into(),
87                ));
88            }
89        }
90        if i + 3 < bytes.len() && &bytes[i..i + 4] == b"<!--" {
91            // Skip until -->
92            if let Some(end) = s[i..].find("-->") {
93                i += end + 3;
94                continue;
95            } else {
96                return Err(CanonicalizationError::Malformed(
97                    "unterminated comment".into(),
98                ));
99            }
100        }
101        out.push(bytes[i] as char);
102        i += 1;
103    }
104    Ok(out)
105}
106
107fn normalize_line_endings(s: &str) -> String {
108    // CRLF → LF, then CR alone → LF
109    let mut out = String::with_capacity(s.len());
110    let bytes = s.as_bytes();
111    let mut i = 0;
112    while i < bytes.len() {
113        if bytes[i] == b'\r' {
114            out.push('\n');
115            if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
116                i += 2;
117            } else {
118                i += 1;
119            }
120        } else {
121            out.push(bytes[i] as char);
122            i += 1;
123        }
124    }
125    out
126}
127
128fn normalize_entities(s: &str) -> Result<String, CanonicalizationError> {
129    let mut out = String::with_capacity(s.len());
130    let mut in_text = true;
131    let mut i = 0;
132    let bytes = s.as_bytes();
133
134    while i < bytes.len() {
135        let b = bytes[i];
136
137        // Detect tag boundaries
138        if b == b'<' {
139            in_text = false;
140            // Check for CDATA
141            if i + 8 < bytes.len() && &bytes[i..i + 9] == b"<![CDATA[" {
142                if let Some(end) = s[i..].find("]]>") {
143                    // CDATA content is taken verbatim
144                    out.push_str(&s[i..i + end + 3]);
145                    i += end + 3;
146                    continue;
147                } else {
148                    return Err(CanonicalizationError::Malformed(
149                        "unterminated CDATA section".into(),
150                    ));
151                }
152            }
153            out.push('<');
154            i += 1;
155            continue;
156        }
157        if b == b'>' && !in_text {
158            in_text = true;
159            out.push('>');
160            i += 1;
161            continue;
162        }
163
164        if in_text {
165            // In text: decode entities, re-encode specials
166            if b == b'&' {
167                if let Some(semi) = s[i..].find(';') {
168                    let entity = &s[i + 1..i + semi];
169                    let decoded = match entity {
170                        "amp" => '&',
171                        "lt" => '<',
172                        "gt" => '>',
173                        "quot" => '"',
174                        "apos" => '\'',
175                        _ => {
176                            // Numeric character reference
177                            if let Some(rest) = entity.strip_prefix("#x") {
178                                let n = u32::from_str_radix(rest, 16).map_err(|_| {
179                                    CanonicalizationError::Malformed(format!(
180                                        "bad numeric entity: &{entity};"
181                                    ))
182                                })?;
183                                char::from_u32(n).ok_or_else(|| {
184                                    CanonicalizationError::Malformed(format!(
185                                        "invalid codepoint: &{entity};"
186                                    ))
187                                })?
188                            } else if let Some(rest) = entity.strip_prefix('#') {
189                                let n: u32 = rest.parse().map_err(|_| {
190                                    CanonicalizationError::Malformed(format!(
191                                        "bad numeric entity: &{entity};"
192                                    ))
193                                })?;
194                                char::from_u32(n).ok_or_else(|| {
195                                    CanonicalizationError::Malformed(format!(
196                                        "invalid codepoint: &{entity};"
197                                    ))
198                                })?
199                            } else {
200                                // Unknown named entity — keep as-is (C14N preserves unknowns)
201                                out.push('&');
202                                out.push_str(entity);
203                                out.push(';');
204                                i += semi + 1;
205                                continue;
206                            }
207                        }
208                    };
209                    out.push(decoded);
210                    i += semi + 1;
211                    continue;
212                } else {
213                    return Err(CanonicalizationError::Malformed(
214                        "unterminated entity reference".into(),
215                    ));
216                }
217            }
218            // Re-encode specials
219            match b as char {
220                '<' => out.push_str("&lt;"),
221                '>' => out.push_str("&gt;"),
222                '&' => out.push_str("&amp;"),
223                _ => out.push(b as char),
224            }
225            i += 1;
226        } else {
227            // In tag: keep as-is (attribute normalization is its own concern)
228            out.push(b as char);
229            i += 1;
230        }
231    }
232
233    Ok(out)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn strips_xml_declaration() {
242        let xml = "<?xml version=\"1.0\"?>\n<root/>";
243        let c = canonicalize(xml).unwrap();
244        assert!(!c.contains("<?xml"));
245        assert!(c.contains("<root/>"));
246    }
247
248    #[test]
249    fn normalizes_crlf() {
250        let xml = "<root>\r\nhello\r\n</root>";
251        let c = canonicalize(xml).unwrap();
252        assert_eq!(c, "<root>\nhello\n</root>");
253    }
254
255    #[test]
256    fn normalizes_cr_alone() {
257        let xml = "<root>\rhello\r</root>";
258        let c = canonicalize(xml).unwrap();
259        assert_eq!(c, "<root>\nhello\n</root>");
260    }
261
262    #[test]
263    fn expands_known_entities() {
264        let xml = "<root>1 &amp; 2 &lt; 3 &gt; 0</root>";
265        let c = canonicalize(xml).unwrap();
266        assert_eq!(c, "<root>1 & 2 < 3 > 0</root>");
267    }
268
269    #[test]
270    fn expands_numeric_entities() {
271        let xml = "<root>&#65;&#x42;</root>";
272        let c = canonicalize(xml).unwrap();
273        assert_eq!(c, "<root>AB</root>");
274    }
275
276    #[test]
277    fn re_encodes_specials_in_text() {
278        // Real XML can't have raw < in text (would start a tag), so test only >
279        let xml = "<root>text with > char</root>";
280        let c = canonicalize(xml).unwrap();
281        assert_eq!(c, "<root>text with &gt; char</root>");
282    }
283
284    #[test]
285    fn strips_comments() {
286        let xml = "<root><!-- comment -->data</root>";
287        let c = canonicalize(xml).unwrap();
288        assert_eq!(c, "<root>data</root>");
289    }
290
291    #[test]
292    fn strips_processing_instructions() {
293        let xml = "<root><?pi data?>content</root>";
294        let c = canonicalize(xml).unwrap();
295        assert_eq!(c, "<root>content</root>");
296    }
297
298    #[test]
299    fn preserves_cdata_verbatim() {
300        let xml = "<root><![CDATA[<not a tag>]]></root>";
301        let c = canonicalize(xml).unwrap();
302        assert_eq!(c, "<root><![CDATA[<not a tag>]]></root>");
303    }
304
305    #[test]
306    fn unterminated_entity_fails() {
307        let xml = "<root>&amp no semi</root>";
308        let result = canonicalize(xml);
309        assert!(result.is_err());
310    }
311
312    #[test]
313    fn round_trip_through_exclusive() {
314        let xml = "<root attr=\"value\"><child>text</child></root>";
315        let c1 = canonicalize_exclusive(xml).unwrap();
316        let c2 = canonicalize_exclusive(&c1).unwrap();
317        assert_eq!(c1, c2);
318    }
319}