use std::{ collections::HashMap, fmt::{Display, Write}, }; fn escape_xml_text(value: &str) -> String { escape_xml_value(value, false) } fn escape_xml_attribute(value: &str) -> String { escape_xml_value(value, true) } const fn is_valid_xml_char(character: char) -> bool { matches!( character as u32, 0x09 | 0x0A | 0x0D | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x0010_FFFF ) } fn sanitize_xml_chars(value: &str) -> String { value.chars().filter(|character| is_valid_xml_char(*character)).collect() } fn escape_xml_value(value: &str, escape_attribute_whitespace: bool) -> String { let sanitized = sanitize_xml_chars(value); let mut escaped = String::with_capacity(sanitized.len()); for character in sanitized.chars() { match character { '&' => escaped.push_str("&"), '<' => escaped.push_str("<"), '>' => escaped.push_str(">"), '"' => escaped.push_str("""), '\'' => escaped.push_str("'"), '\n' if escape_attribute_whitespace => escaped.push_str(" "), '\r' if escape_attribute_whitespace => escaped.push_str(" "), '\t' if escape_attribute_whitespace => escaped.push_str(" "), _ => escaped.push(character), } } escaped } fn escape_cdata_text(value: &str) -> String { sanitize_xml_chars(value).replace("]]>", "]]]]>") } fn render_field(key: &str, value: &str) -> String { if key == "description" { format!("<{key}>", escape_cdata_text(value)) } else { format!("<{key}>{}", escape_xml_text(value)) } } pub struct Item { fields: HashMap, } impl Item { pub fn builder() -> Self { Self { fields: HashMap::new() } } pub fn add_field(&mut self, key: &str, value: &str) -> &mut Self { self.fields.insert(key.to_string(), value.to_string()); self } } impl Display for Item { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", self.fields.iter().fold(String::new(), |mut acc, (k, v)| { let _ = write!(acc, "{}", render_field(k, v)); acc }) ) } } #[derive(Clone)] pub struct Writer { content: String, fields: HashMap, link: String, } impl Writer { pub fn builder() -> Self { Self { content: String::new(), fields: HashMap::default(), link: String::new(), } } pub fn add_link(&mut self, link: &str) -> &mut Self { self.link = link.to_string(); self } pub fn add_field(&mut self, key: &str, value: &str) -> &mut Self { self.fields.insert(key.to_string(), value.to_string()); self } pub fn add_item(&mut self, item: &Item) -> &mut Self { self.content.push_str(&item.to_string()); self } } impl Display for Writer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}{}", self.fields.iter().fold(String::new(), |mut acc, (k, v)| { let _ = write!(acc, "{}", render_field(k, v)); acc }), escape_xml_attribute(&self.link), self.content ) } } #[cfg(test)] mod tests { use super::{Item, Writer}; #[test] fn multiline_description_is_wrapped_in_cdata() { let mut item = Item::builder(); item.add_field("description", "first line\nsecond line"); let xml = item.to_string(); assert!(xml.contains( "" )); } #[test] fn cdata_end_marker_is_escaped_safely() { let mut item = Item::builder(); item.add_field("description", "before ]]> after"); let xml = item.to_string(); assert!(xml.contains( " after]]>" )); } #[test] fn non_description_fields_are_xml_escaped() { let mut writer = Writer::builder(); writer.add_field("title", "Fish & Chips <3"); let xml = writer.to_string(); assert!(xml.contains("Fish & Chips <3")); } #[test] fn text_fields_escape_quotes_and_apostrophes() { let mut writer = Writer::builder(); writer.add_field("title", "\"quoted\" and 'single'"); let xml = writer.to_string(); assert!( xml.contains(""quoted" and 'single'") ); } #[test] fn link_attribute_is_xml_escaped() { let mut writer = Writer::builder(); writer.add_link("https://example.com/?a=1&b=\"two\"'three'"); let xml = writer.to_string(); assert!(xml.contains( "" )); } #[test] fn invalid_xml_chars_are_filtered() { let mut writer = Writer::builder(); writer.add_field("title", "ok\u{0001}\u{0002}\u{0000}text"); let xml = writer.to_string(); assert!(xml.contains("oktext")); } }