1use 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
24pub(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}