1use std::io::Cursor;
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
14use crate::table::{TableAlign, TableData};
15
16const MAX_FEED_BYTES: u64 = 32 * 1024 * 1024;
17const MAX_FEED_XML_EVENTS: usize = 500_000;
18const MAX_FEED_XML_NODES: usize = 300_000;
19const MAX_FEED_XML_DEPTH: usize = 96;
20const MAX_FEED_TEXT_BYTES: usize = 32 * 1024 * 1024;
21const MAX_FEED_ROWS: usize = 100_000;
22const MAX_FEED_DISPLAY_BYTES: usize = 512;
23
24pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
25 let mut reader = quick_xml::Reader::from_reader(Cursor::new(bytes));
26 reader.config_mut().trim_text(true);
27 let mut buffer = Vec::new();
28 loop {
29 match reader.read_event_into(&mut buffer) {
30 Ok(quick_xml::events::Event::Start(element))
31 | Ok(quick_xml::events::Event::Empty(element)) => {
32 let raw_name = element.name();
33 let name = crate::ooxml::local_name(raw_name.as_ref());
34 let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
35 return if name.eq_ignore_ascii_case(b"feed") {
36 has_tag(&text, "title") || has_tag(&text, "entry")
37 } else if name.eq_ignore_ascii_case(b"rss") || name.eq_ignore_ascii_case(b"rdf") {
38 has_tag(&text, "channel") || has_tag(&text, "item")
39 } else {
40 false
41 };
42 }
43 Ok(quick_xml::events::Event::DocType(_)) => return false,
44 Ok(quick_xml::events::Event::Eof) | Err(_) => return false,
45 _ => buffer.clear(),
46 }
47 buffer.clear();
48 }
49}
50
51fn has_tag(text: &str, tag: &str) -> bool {
52 let needle = format!("<{tag}");
53 text.match_indices(&needle).any(|(index, _)| {
54 text.as_bytes()
55 .get(index.saturating_add(needle.len()))
56 .is_some_and(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/'))
57 })
58}
59
60struct FeedPageSink<'a> {
61 inner: &'a mut dyn PageConsumer,
62 warnings: &'a [String],
63}
64
65impl PageConsumer for FeedPageSink<'_> {
66 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
67 page.source_format = "feed".into();
68 if page.title.is_empty() {
69 page.title = "RSS/Atom feed".into();
70 }
71 page.description =
72 "RSS/Atom entry metadata is rendered inertly; links, content, enclosures and external resources are not resolved or fetched".into();
73 for warning in self.warnings {
74 page.warn(warning.clone());
75 }
76 self.inner.consume(page)
77 }
78}
79
80#[derive(Default)]
81struct Summary {
82 kind: String,
83 version: String,
84 title: String,
85 updated: String,
86 items: usize,
87 linked_items: usize,
88 authored_items: usize,
89 content_items: usize,
90 enclosures: usize,
91 rows: Vec<Vec<String>>,
92}
93
94pub(crate) fn convert(
95 path: &Path,
96 options: &ConvertOptions,
97 sink: &mut dyn PageConsumer,
98) -> Result<Vec<String>> {
99 let bytes = read_limited_file(
100 path,
101 options.max_input_bytes.min(MAX_FEED_BYTES),
102 "RSS/Atom feed input",
103 )?;
104 let root = parse_xml_tree(
105 &bytes,
106 &XmlLimits {
107 max_events: options.max_xml_events.min(MAX_FEED_XML_EVENTS),
108 max_nodes: MAX_FEED_XML_NODES,
109 max_depth: MAX_FEED_XML_DEPTH,
110 max_text_bytes: MAX_FEED_TEXT_BYTES,
111 },
112 "RSS/Atom feed",
113 )?;
114 let is_atom = root.name.eq_ignore_ascii_case("feed");
115 let is_rss = root.name.eq_ignore_ascii_case("rss") || root.name.eq_ignore_ascii_case("rdf");
116 if !is_atom && !is_rss {
117 return Err(Error::InvalidInput(
118 "RSS/Atom root must be rss, rdf:RDF, or feed".into(),
119 ));
120 }
121 let mut summary = Summary {
122 kind: if is_atom { "Atom".into() } else { "RSS".into() },
123 version: root.attribute("version").unwrap_or_default().to_owned(),
124 ..Summary::default()
125 };
126 let container = if is_atom {
127 &root
128 } else {
129 root.children_named("channel").next().unwrap_or(&root)
130 };
131 summary.title = child_text(container, "title");
132 summary.updated = if is_atom {
133 first_child_text(container, &["updated", "published"])
134 } else {
135 first_child_text(container, &["lastBuildDate", "pubDate"])
136 };
137 let item_name = if is_atom { "entry" } else { "item" };
138 for item in container.children_named(item_name) {
139 collect_item(item, is_atom, &mut summary)?;
140 }
141 if !is_atom && root.name.eq_ignore_ascii_case("rdf") {
142 for item in root.children_named("item") {
143 collect_item(item, false, &mut summary)?;
144 }
145 }
146 let mut warnings = vec![
147 "RSS/Atom titles and structural metadata are shown; links, descriptions, summaries, content, enclosures, author addresses and extension values are omitted".into(),
148 "RSS/Atom links, enclosures, images, scripts, stylesheets and external resources are never fetched, opened or executed".into(),
149 "RSS/Atom XML traversal and rendered rows are bounded; no feed refresh or network operation runs".into(),
150 ];
151 if summary.items == 0 {
152 warnings.push("feed contains no item/entry elements".into());
153 }
154 let version = if summary.version.is_empty() {
155 "—"
156 } else {
157 summary.version.as_str()
158 };
159 let metadata = format!(
160 "Format: {}\nVersion: {}\nTitle: {}\nUpdated: {}\nItems/entries: {}\nWith links: {}\nWith authors: {}\nWith content: {}\nEnclosures: {}",
161 summary.kind,
162 version,
163 display_or_dash(&summary.title),
164 display_or_dash(&summary.updated),
165 summary.items,
166 summary.linked_items,
167 summary.authored_items,
168 summary.content_items,
169 summary.enclosures,
170 );
171 let rows = if summary.rows.is_empty() {
172 vec![vec!["—".into(), "—".into(), "—".into(), "0".into()]]
173 } else {
174 summary.rows
175 };
176 let blocks = vec![
177 HtmlBlock::Heading {
178 level: 1,
179 text: "RSS/Atom feed".into(),
180 },
181 HtmlBlock::Paragraph { text: metadata },
182 HtmlBlock::Table(TableData {
183 headers: vec![
184 "No.".into(),
185 "Title".into(),
186 "Date".into(),
187 "Signals".into(),
188 ],
189 rows,
190 alignments: vec![
191 TableAlign::Right,
192 TableAlign::Left,
193 TableAlign::Left,
194 TableAlign::Left,
195 ],
196 raw_source: String::new(),
197 }),
198 ];
199 let mut page_sink = FeedPageSink {
200 inner: sink,
201 warnings: &warnings,
202 };
203 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
204 Ok(std::mem::take(&mut warnings))
205}
206
207fn collect_item(element: &XmlElement, is_atom: bool, summary: &mut Summary) -> Result<()> {
208 if summary.rows.len() >= MAX_FEED_ROWS {
209 return Err(Error::LimitExceeded(format!(
210 "RSS/Atom rows exceed {MAX_FEED_ROWS}"
211 )));
212 }
213 summary.items = summary.items.saturating_add(1);
214 let title = child_text(element, "title");
215 let date = if is_atom {
216 first_child_text(element, &["updated", "published"])
217 } else {
218 first_child_text(element, &["pubDate", "date"])
219 };
220 let link_count = element.children_named("link").count();
221 let author_count = element.children_named("author").count()
222 + element.children_named("creator").count()
223 + element.children_named("dc:creator").count();
224 let content_count = if is_atom {
225 element.children_named("summary").count() + element.children_named("content").count()
226 } else {
227 element.children_named("description").count()
228 + element.children_named("encoded").count()
229 + element.children_named("content").count()
230 };
231 let enclosure_count = element.children_named("enclosure").count()
232 + element
233 .children_named("link")
234 .filter(|link| link.attribute("rel") == Some("enclosure"))
235 .count();
236 if link_count > 0 {
237 summary.linked_items = summary.linked_items.saturating_add(1);
238 }
239 if author_count > 0 {
240 summary.authored_items = summary.authored_items.saturating_add(1);
241 }
242 if content_count > 0 {
243 summary.content_items = summary.content_items.saturating_add(1);
244 }
245 summary.enclosures = summary.enclosures.saturating_add(enclosure_count);
246 let mut signals = Vec::new();
247 if link_count > 0 {
248 signals.push(format!("links:{link_count}"));
249 }
250 if author_count > 0 {
251 signals.push("author".into());
252 }
253 if content_count > 0 {
254 signals.push("content".into());
255 }
256 if enclosure_count > 0 {
257 signals.push(format!("enclosure:{enclosure_count}"));
258 }
259 summary.rows.push(vec![
260 summary.items.to_string(),
261 display_or_dash(&truncate(&title)).to_owned(),
262 display_or_dash(&truncate(&date)).to_owned(),
263 if signals.is_empty() {
264 "—".into()
265 } else {
266 signals.join(", ")
267 },
268 ]);
269 Ok(())
270}
271
272fn first_child_text(parent: &XmlElement, names: &[&str]) -> String {
273 names
274 .iter()
275 .find_map(|name| {
276 parent
277 .children_named(name)
278 .next()
279 .map(|child| truncate(child.text.trim()))
280 })
281 .unwrap_or_default()
282}
283
284fn child_text(parent: &XmlElement, name: &str) -> String {
285 parent
286 .children_named(name)
287 .next()
288 .map(|child| truncate(child.text.trim()))
289 .unwrap_or_default()
290}
291
292fn truncate(value: &str) -> String {
293 if value.len() <= MAX_FEED_DISPLAY_BYTES {
294 return value.to_owned();
295 }
296 let mut end = MAX_FEED_DISPLAY_BYTES;
297 while !value.is_char_boundary(end) {
298 end -= 1;
299 }
300 format!("{}…", &value[..end])
301}
302
303fn display_or_dash(value: &str) -> &str {
304 if value.is_empty() { "—" } else { value }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn recognizes_rss_atom_and_rejects_generic_xml() {
313 assert!(looks_like_prefix(
314 br#"<rss version="2.0"><channel><title>Feed</title></channel></rss>"#
315 ));
316 assert!(looks_like_prefix(
317 br#"<feed xmlns="http://www.w3.org/2005/Atom"><title>Feed</title><entry/></feed>"#
318 ));
319 assert!(!looks_like_prefix(
320 br#"<document><channel/><item/></document>"#
321 ));
322 }
323
324 #[test]
325 fn counts_entries_and_omits_sensitive_values() {
326 let xml = br#"<rss version="2.0"><channel><title>News</title><item><title>One</title><link>https://private.example.invalid/one</link><description>secret body</description><enclosure url="https://private.example.invalid/a.mp3"/></item></channel></rss>"#;
327 let root = parse_xml_tree(
328 xml,
329 &XmlLimits {
330 max_events: 1000,
331 max_nodes: 1000,
332 max_depth: 32,
333 max_text_bytes: 10000,
334 },
335 "RSS/Atom feed",
336 )
337 .unwrap();
338 let channel = root.children_named("channel").next().unwrap();
339 let mut summary = Summary::default();
340 collect_item(
341 channel.children_named("item").next().unwrap(),
342 false,
343 &mut summary,
344 )
345 .unwrap();
346 assert_eq!(summary.items, 1);
347 assert_eq!(summary.linked_items, 1);
348 assert_eq!(summary.content_items, 1);
349 assert_eq!(summary.enclosures, 1);
350 assert!(
351 !summary
352 .rows
353 .iter()
354 .flatten()
355 .any(|value| value.contains("private.example"))
356 );
357 assert!(
358 !summary
359 .rows
360 .iter()
361 .flatten()
362 .any(|value| value.contains("secret body"))
363 );
364 }
365}