Skip to main content

document_svg/document/
opml.rs

1//! Bounded OPML 1.0/2.0 outline previews.
2//!
3//! OPML is an XML interchange format for ordered, hierarchical outlines and
4//! feed subscription lists. This adapter renders outline text and inert type
5//! metadata only; feed URLs, HTML links, owner addresses and external
6//! resources are never displayed, resolved, or fetched.
7
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_OPML_BYTES: u64 = 32 * 1024 * 1024;
17const MAX_OPML_XML_EVENTS: usize = 500_000;
18const MAX_OPML_XML_NODES: usize = 300_000;
19const MAX_OPML_XML_DEPTH: usize = 96;
20const MAX_OPML_TEXT_BYTES: usize = 32 * 1024 * 1024;
21const MAX_OPML_ROWS: usize = 200_000;
22const MAX_OPML_DISPLAY_BYTES: usize = 512;
23
24/// Content sniffing for extensionless XML. OPML 1.0/2.0 has an unnamespaced
25/// `opml` root with a required version attribute and head/body children.
26pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
27    if !crate::geospatial::xml_tree::looks_like_root(bytes, b"opml", None) {
28        return false;
29    }
30    let text = String::from_utf8_lossy(bytes);
31    let lower = text.to_ascii_lowercase();
32    let version = lower.contains("version=\"1.0\"")
33        || lower.contains("version='1.0'")
34        || lower.contains("version=\"2.0\"")
35        || lower.contains("version='2.0'");
36    version && has_tag(&lower, "head") && has_tag(&lower, "body")
37}
38
39fn has_tag(text: &str, tag: &str) -> bool {
40    let needle = format!("<{tag}");
41    text.match_indices(&needle).any(|(index, _)| {
42        text.as_bytes()
43            .get(index.saturating_add(needle.len()))
44            .is_some_and(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/'))
45    })
46}
47
48struct OpmlPageSink<'a> {
49    inner: &'a mut dyn PageConsumer,
50    warnings: &'a [String],
51}
52
53impl PageConsumer for OpmlPageSink<'_> {
54    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
55        page.source_format = "opml".into();
56        if page.title.is_empty() {
57            page.title = "OPML outline".into();
58        }
59        page.description =
60            "OPML outline and feed metadata is rendered inertly; URLs, owner addresses and external resources are not resolved or fetched".into();
61        for warning in self.warnings {
62            page.warn(warning.clone());
63        }
64        self.inner.consume(page)
65    }
66}
67
68#[derive(Default)]
69struct Summary {
70    version: String,
71    title: String,
72    date_created: String,
73    outlines: usize,
74    feeds: usize,
75    max_depth: usize,
76    rows: Vec<Vec<String>>,
77}
78
79pub(crate) fn convert(
80    path: &Path,
81    options: &ConvertOptions,
82    sink: &mut dyn PageConsumer,
83) -> Result<Vec<String>> {
84    let bytes = read_limited_file(
85        path,
86        options.max_input_bytes.min(MAX_OPML_BYTES),
87        "OPML input",
88    )?;
89    let root = parse_xml_tree(
90        &bytes,
91        &XmlLimits {
92            max_events: options.max_xml_events.min(MAX_OPML_XML_EVENTS),
93            max_nodes: MAX_OPML_XML_NODES,
94            max_depth: MAX_OPML_XML_DEPTH,
95            max_text_bytes: MAX_OPML_TEXT_BYTES,
96        },
97        "OPML",
98    )?;
99    if root.name != "opml" {
100        return Err(Error::InvalidInput("OPML XML root must be opml".into()));
101    }
102    let version = root.attribute("version").unwrap_or_default();
103    if version != "1.0" && version != "2.0" {
104        return Err(Error::Unsupported(format!(
105            "OPML version '{version}' is unsupported; expected 1.0 or 2.0"
106        )));
107    }
108    let head = root.children_named("head").next();
109    let body = root
110        .children_named("body")
111        .next()
112        .ok_or_else(|| Error::InvalidInput("OPML document requires a body element".into()))?;
113    let mut summary = Summary {
114        version: version.to_owned(),
115        ..Summary::default()
116    };
117    if let Some(head) = head {
118        summary.title = child_text(head, "title");
119        summary.date_created = child_text(head, "dateCreated");
120    }
121    for outline in body.children_named("outline") {
122        collect_outline(outline, 0, &mut summary)?;
123    }
124    let mut warnings = vec![
125        "OPML outline text and type metadata are shown; feed URLs, HTML links, owner email, descriptions and arbitrary extension values are omitted".into(),
126        "OPML XML traversal and rendered rows are bounded; no linked feed, enclosure, image, script or external resource is fetched or executed".into(),
127    ];
128    if summary.outlines == 0 {
129        warnings.push("OPML body contains no outline elements".into());
130    }
131    let metadata = format!(
132        "Version: {}\nTitle: {}\nCreated: {}\nOutlines: {}\nFeed outlines: {}\nMaximum depth: {}",
133        summary.version,
134        display_or_dash(&summary.title),
135        display_or_dash(&summary.date_created),
136        summary.outlines,
137        summary.feeds,
138        summary.max_depth,
139    );
140    let rows = if summary.rows.is_empty() {
141        vec![vec!["—".into(), "—".into(), "0".into(), "outline".into()]]
142    } else {
143        summary.rows
144    };
145    let blocks = vec![
146        HtmlBlock::Heading {
147            level: 1,
148            text: "OPML outline".into(),
149        },
150        HtmlBlock::Paragraph { text: metadata },
151        HtmlBlock::Table(TableData {
152            headers: vec![
153                "Outline".into(),
154                "Type".into(),
155                "Depth".into(),
156                "Kind".into(),
157            ],
158            rows,
159            alignments: vec![TableAlign::Left; 4],
160            raw_source: String::new(),
161        }),
162    ];
163    let mut page_sink = OpmlPageSink {
164        inner: sink,
165        warnings: &warnings,
166    };
167    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
168    Ok(std::mem::take(&mut warnings))
169}
170
171fn child_text(parent: &XmlElement, name: &str) -> String {
172    parent
173        .children_named(name)
174        .next()
175        .map(|child| truncate(child.text.trim()))
176        .unwrap_or_default()
177}
178
179fn collect_outline(element: &XmlElement, depth: usize, summary: &mut Summary) -> Result<()> {
180    if summary.rows.len() >= MAX_OPML_ROWS {
181        return Err(Error::LimitExceeded(format!(
182            "OPML outline rows exceed {MAX_OPML_ROWS}"
183        )));
184    }
185    let level = depth.saturating_add(1);
186    summary.outlines = summary.outlines.saturating_add(1);
187    summary.max_depth = summary.max_depth.max(level);
188    let outline_type = element.attribute("type").unwrap_or("outline");
189    let is_feed = outline_type.eq_ignore_ascii_case("rss")
190        || outline_type.eq_ignore_ascii_case("atom")
191        || element.attribute("xmlUrl").is_some();
192    if is_feed {
193        summary.feeds = summary.feeds.saturating_add(1);
194    }
195    let text = element
196        .attribute("text")
197        .or_else(|| element.attribute("title"))
198        .unwrap_or("—");
199    let indent = "  ".repeat(depth.min(32));
200    summary.rows.push(vec![
201        format!("{indent}{}", truncate(text)),
202        truncate(outline_type),
203        level.to_string(),
204        if is_feed {
205            "feed".into()
206        } else {
207            "outline".into()
208        },
209    ]);
210    for child in element.children_named("outline") {
211        collect_outline(child, level, summary)?;
212    }
213    Ok(())
214}
215
216fn truncate(value: &str) -> String {
217    if value.len() <= MAX_OPML_DISPLAY_BYTES {
218        return value.to_owned();
219    }
220    let mut end = MAX_OPML_DISPLAY_BYTES;
221    while !value.is_char_boundary(end) {
222        end -= 1;
223    }
224    format!("{}…", &value[..end])
225}
226
227fn display_or_dash(value: &str) -> &str {
228    if value.is_empty() { "—" } else { value }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn recognizes_opml_roots_with_required_shape() {
237        assert!(looks_like_prefix(
238            br#"<?xml version="1.0"?><opml version="2.0"><head/><body><outline text="x"/></body></opml>"#
239        ));
240        assert!(!looks_like_prefix(br#"<opml><head/><body/></opml>"#));
241        assert!(!looks_like_prefix(
242            br#"<opml version="2.0"><header/><body/></opml>"#
243        ));
244        assert!(!looks_like_prefix(
245            br#"<description><head/><body/></description>"#
246        ));
247    }
248
249    #[test]
250    fn counts_nested_outlines_and_feeds_without_urls() {
251        let xml = br#"<opml version="2.0"><head><title>Feeds</title></head><body><outline text="News"><outline text="Private" type="rss" xmlUrl="https://private.example.invalid/feed"/></outline></body></opml>"#;
252        let root = parse_xml_tree(
253            xml,
254            &XmlLimits {
255                max_events: 1000,
256                max_nodes: 1000,
257                max_depth: 32,
258                max_text_bytes: 10000,
259            },
260            "OPML",
261        )
262        .unwrap();
263        let body = root.children_named("body").next().unwrap();
264        let mut summary = Summary::default();
265        collect_outline(
266            body.children_named("outline").next().unwrap(),
267            0,
268            &mut summary,
269        )
270        .unwrap();
271        assert_eq!(summary.outlines, 2);
272        assert_eq!(summary.feeds, 1);
273        assert_eq!(summary.max_depth, 2);
274        assert!(
275            !summary
276                .rows
277                .iter()
278                .flatten()
279                .any(|value| value.contains("private.example"))
280        );
281    }
282}