Skip to main content

document_svg/document/
feed.rs

1//! Bounded RSS 2.0 / Atom 1.0 syndication-feed previews.
2//!
3//! RSS and Atom entries can contain HTML, links, enclosures, scripts and
4//! private author data. This adapter renders title/date and structural counts
5//! only; URLs, content payloads and external resources remain inert.
6
7use 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}