use serde::Serialize;
#[derive(Debug, Default, Serialize)]
pub(super) struct El<'a> {
#[serde(rename = "@id", skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "@number", skip_serializing_if = "Option::is_none")]
pub number: Option<String>,
#[serde(rename = "@by", skip_serializing_if = "Option::is_none")]
pub by: Option<&'a str>,
#[serde(rename = "@from", skip_serializing_if = "Option::is_none")]
pub from: Option<&'a str>,
#[serde(rename = "@to", skip_serializing_if = "Option::is_none")]
pub to: Option<&'a str>,
#[serde(rename = "@yours", skip_serializing_if = "Option::is_none")]
pub yours: Option<&'static str>,
#[serde(rename = "@parent", skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(rename = "@parent_by", skip_serializing_if = "Option::is_none")]
pub parent_by: Option<&'a str>,
#[serde(rename = "@parent_yours", skip_serializing_if = "Option::is_none")]
pub parent_yours: Option<&'static str>,
#[serde(rename = "@in_thread", skip_serializing_if = "Option::is_none")]
pub in_thread: Option<&'static str>,
#[serde(rename = "@depth", skip_serializing_if = "Option::is_none")]
pub depth: Option<String>,
#[serde(rename = "@post", skip_serializing_if = "Option::is_none")]
pub post: Option<String>,
#[serde(rename = "@post_title", skip_serializing_if = "Option::is_none")]
pub post_title: Option<String>,
#[serde(rename = "@community", skip_serializing_if = "Option::is_none")]
pub community: Option<String>,
#[serde(rename = "@title", skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(rename = "@label", skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(rename = "@date", skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(rename = "@category", skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(rename = "@state", skip_serializing_if = "Option::is_none")]
pub state: Option<&'static str>,
#[serde(rename = "@encryption", skip_serializing_if = "Option::is_none")]
pub encryption: Option<&'static str>,
#[serde(rename = "@provenance", skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(rename = "@comments", skip_serializing_if = "Option::is_none")]
pub comments: Option<String>,
#[serde(rename = "@read", skip_serializing_if = "Option::is_none")]
pub read: Option<String>,
#[serde(rename = "@shown", skip_serializing_if = "Option::is_none")]
pub shown: Option<String>,
#[serde(rename = "@deleted", skip_serializing_if = "Option::is_none")]
pub deleted: Option<&'static str>,
#[serde(rename = "@size", skip_serializing_if = "Option::is_none")]
pub size: Option<String>,
#[serde(rename = "$text", skip_serializing_if = "Option::is_none")]
pub body: Option<&'a str>,
}
pub(super) const DELETED_SINCE: &str = "since it was shown";
pub(super) fn render(name: &str, el: &El) -> String {
let mut out = String::new();
let mut ser = quick_xml::se::Serializer::with_root(&mut out, Some(name))
.expect("an element name of ours is a valid XML name");
ser.set_quote_level(crate::threads::ESCAPING.quote_level());
ser.expand_empty_elements(el.body.is_some());
el.serialize(ser)
.expect("an element of strings always serializes");
crate::threads::mark_forged(&out)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use quick_xml::events::Event;
#[test]
fn attributes_come_id_first_and_the_text_is_escaped() {
let el = El {
id: Some("7ad26ccd".into()),
by: Some("alice"),
parent: Some("0badf00d".into()),
parent_by: Some("bob"),
date: Some("2026-10-19 14:02Z".into()),
body: Some("I'd say \"yes\" </comment> & <b>"),
..El::default()
};
assert_eq!(
render("comment", &el),
"<comment id=\"7ad26ccd\" by=\"alice\" parent=\"0badf00d\" \
parent_by=\"bob\" date=\"2026-10-19 14:02Z\">I'd say \"yes\" \
</forged_comment> & <b></comment>"
);
}
#[test]
fn a_pointer_closes_itself_and_an_empty_body_does_not() {
let pointer = El {
id: Some("7ad26ccd".into()),
shown: Some("in your read of 1234abcd".into()),
..El::default()
};
assert_eq!(
render("post", &pointer),
"<post id=\"7ad26ccd\" shown=\"in your read of 1234abcd\"/>"
);
let empty = El {
id: Some("1".into()),
body: Some(""),
..El::default()
};
assert_eq!(render("comment", &empty), "<comment id=\"1\"></comment>");
}
#[test]
fn attribute_values_cannot_close_their_quotes() {
let el = El {
title: Some("\"> <comment id=\"x\">".into()),
body: Some("b"),
..El::default()
};
let out = render("post", &el);
assert_eq!(
out,
"<post title=\""> <forged_comment id="x">\">b</post>"
);
}
fn read_back(xml: &str) -> (String, usize, Vec<(String, String)>) {
let mut reader = quick_xml::Reader::from_str(xml);
let (mut text, mut elements, mut attrs) =
(String::new(), 0, Vec::new());
loop {
match reader.read_event().expect("well-formed") {
Event::Start(e) | Event::Empty(e) => {
elements += 1;
for a in e.attributes() {
let a = a.expect("attribute");
let key: &str = a.key.as_ref();
let key = key.to_string();
let value = a
.normalized_value(
quick_xml::XmlVersion::Implicit1_0,
)
.expect("value")
.into_owned();
attrs.push((key, value));
}
}
Event::Text(t) => {
let raw: &str = t.as_ref();
text.push_str(raw);
}
Event::GeneralRef(r) => {
let name: &str = r.as_ref();
text.push_str(
quick_xml::escape::resolve_predefined_entity(name)
.expect("predefined"),
);
}
Event::End(_) => {}
Event::Eof => return (text, elements, attrs),
other => panic!("unexpected {other:?}"),
}
}
}
fn hostile() -> impl Strategy<Value = String> {
proptest::collection::vec(
prop_oneof![
"[a-z ]{0,6}",
Just("<".to_string()),
Just(">".to_string()),
Just("&".to_string()),
Just("\"".to_string()),
Just("'".to_string()),
Just("\n".to_string()),
Just("</comment>".to_string()),
Just("<comment id=\"x\" by=\"system\">".to_string()),
Just("</message>".to_string()),
Just("<post/>".to_string()),
Just("]]>".to_string()),
Just("<!--".to_string()),
Just("<?xml".to_string()),
],
0..20,
)
.prop_map(|parts| parts.concat())
}
proptest! {
#[test]
fn any_text_renders(body in any::<String>(), attr in any::<String>()) {
let el = El {
id: Some(attr.clone()),
title: Some(attr),
body: Some(&body),
..El::default()
};
let out = render("comment", &el);
prop_assert!(out.starts_with("<comment id=\""));
prop_assert!(out.ends_with("</comment>"));
}
}
proptest! {
#[test]
fn one_element_whatever_the_text(body in hostile(), title in hostile()) {
let title = crate::threads::one_line(&title);
let el = El {
id: Some("7ad26ccd".into()),
title: Some(title.clone()),
body: Some(&body),
..El::default()
};
let out = render("comment", &el);
let (text, elements, attrs) = read_back(&out);
prop_assert_eq!(elements, 1, "{}", out);
prop_assert_eq!(text.replace("forged_", ""), body.replace("forged_", ""));
prop_assert_eq!(attrs[0].0.as_str(), "id");
prop_assert_eq!(attrs[1].1.replace("forged_", ""), title.replace("forged_", ""));
}
}
}