use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use quick_xml::events::Event;
use quick_xml::{Reader, XmlVersion};
use super::ResolvedSubscription;
use super::config::{RssConfig, finalize};
use super::error::RssError;
pub(crate) const MAX_OPML_BYTES: u64 = 1024 * 1024;
pub(crate) const OPML_READ_TIMEOUT: Duration = Duration::from_secs(5);
pub fn resolve_subscriptions(
name: &str,
config: &RssConfig,
) -> Result<Vec<ResolvedSubscription>, RssError> {
let raw = if let Some(path) = &config.opml {
read_opml(name, path)?
} else if let Some(feeds) = &config.feeds {
feeds
.iter()
.map(|feed| (feed.url.clone(), feed.name.clone()))
.collect()
} else {
return Err(RssError::InvalidConfig {
reason: "exactly one of `feeds` or `opml` must be set".to_string(),
});
};
finalize(raw)
}
fn read_opml(name: &str, path: &Path) -> Result<Vec<(String, Option<String>)>, RssError> {
let unreadable = |reason: String| RssError::OpmlUnreadable {
name: name.to_string(),
path: path.display().to_string(),
reason,
};
let meta = fs::metadata(path).map_err(|e| unreadable(e.to_string()))?;
if !meta.is_file() {
return Err(unreadable(
"not a regular file (FIFOs and device files are refused)".to_string(),
));
}
let bytes = read_bytes_bounded(path, OPML_READ_TIMEOUT).map_err(unreadable)?;
if bytes.len() as u64 > MAX_OPML_BYTES {
return Err(unreadable(format!(
"file exceeds the {MAX_OPML_BYTES}-byte OPML size limit"
)));
}
let content = String::from_utf8(bytes).map_err(|e| {
unreadable(format!(
"OPML file '{}' is not valid UTF-8: {e}",
path.display()
))
})?;
let invalid = |reason: String| RssError::InvalidConfig { reason };
let mut reader = Reader::from_str(&content);
let mut subs: Vec<(String, Option<String>)> = Vec::new();
loop {
let event = reader.read_event().map_err(|e| {
invalid(format!(
"OPML file '{}' is not well-formed XML: {e}",
path.display()
))
})?;
let tag = match event {
Event::Eof => break,
Event::Start(tag) | Event::Empty(tag) if tag.name().as_ref() == b"outline" => tag,
_ => continue,
};
let decoder = reader.decoder();
let mut xml_url = None;
let mut text_attr = None;
let mut title_attr = None;
for attr in tag.attributes() {
let attr = attr.map_err(|e| {
invalid(format!(
"OPML file '{}' has a malformed attribute: {e}",
path.display()
))
})?;
let decode = || -> Result<String, RssError> {
attr.decoded_and_normalized_value(XmlVersion::Implicit1_0, decoder)
.map(|v| v.into_owned())
.map_err(|e| {
invalid(format!(
"OPML file '{}' has a malformed attribute value: {e}",
path.display()
))
})
};
match attr.key.as_ref() {
b"xmlUrl" | b"xmlurl" => xml_url = Some(decode()?),
b"text" => text_attr = Some(decode()?),
b"title" => title_attr = Some(decode()?),
_ => {}
}
}
if let Some(url) = xml_url {
subs.push((url, text_attr.or(title_attr)));
}
}
Ok(subs)
}
fn read_bytes_bounded(path: &Path, timeout: Duration) -> Result<Vec<u8>, String> {
let (tx, rx) = mpsc::channel();
let path: PathBuf = path.to_path_buf();
thread::spawn(move || {
let result = (|| {
let file = File::open(&path).map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
file.take(MAX_OPML_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
Ok(bytes)
})();
let _ = tx.send(result);
});
match rx.recv_timeout(timeout) {
Ok(result) => result,
Err(_) => Err(format!(
"did not finish reading within {}s (OPML read timeout)",
timeout.as_secs()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sources::providers::rss::FeedSubscription;
use crate::sources::providers::rss::config::inline_config;
#[cfg(unix)]
use std::process::Command;
#[cfg(unix)]
use std::time::Instant;
#[test]
fn opml_outlines_resolve_to_subscriptions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("subs.opml");
std::fs::write(
&path,
r#"<?xml version="1.0"?>
<opml version="2.0">
<head><title>subs</title></head>
<body>
<outline text="Tech">
<outline type="rss" text="Rust Blog" xmlUrl="https://blog.rust-lang.org/feed.xml" htmlUrl="https://blog.rust-lang.org/"/>
<outline title="TWiR" xmlUrl="https://this-week-in-rust.org/rss.xml"/>
</outline>
<outline xmlUrl="https://example.com/no-name.xml"/>
</body>
</opml>"#,
)
.unwrap();
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let subs = resolve_subscriptions("news", &config).unwrap();
assert_eq!(subs.len(), 3);
assert_eq!(
subs[0],
ResolvedSubscription {
name: "Rust Blog".into(),
url: "https://blog.rust-lang.org/feed.xml".into()
}
);
assert_eq!(subs[1].name, "TWiR"); assert_eq!(subs[2].name, "https://example.com/no-name.xml"); }
#[test]
fn literal_tabs_and_newlines_in_attribute_values_collapse_to_spaces() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("subs.opml");
std::fs::write(
&path,
"<opml version=\"2.0\"><body>\
<outline text=\"a\tb\nc\" xmlUrl=\"https://e.com/f\tx\"/>\
</body></opml>",
)
.unwrap();
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let subs = resolve_subscriptions("news", &config).unwrap();
assert_eq!(subs[0].name, "a b c");
assert_eq!(subs[0].url, "https://e.com/f x");
}
#[test]
fn inline_feeds_resolve_without_io() {
let config = inline_config(vec![
FeedSubscription {
url: "https://blog.rust-lang.org/feed.xml".to_string(),
name: Some("rust-blog".to_string()),
},
FeedSubscription {
url: "https://this-week-in-rust.org/rss.xml".to_string(),
name: None,
},
]);
let subs = resolve_subscriptions("news", &config).unwrap();
assert_eq!(
subs,
vec![
ResolvedSubscription {
name: "rust-blog".into(),
url: "https://blog.rust-lang.org/feed.xml".into()
},
ResolvedSubscription {
name: "https://this-week-in-rust.org/rss.xml".into(),
url: "https://this-week-in-rust.org/rss.xml".into()
},
]
);
}
#[test]
fn oversized_opml_is_rejected_without_reading_it_all() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge.opml");
std::fs::write(&path, vec![b' '; (MAX_OPML_BYTES + 1) as usize]).unwrap();
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let err = resolve_subscriptions("news", &config).unwrap_err();
match err {
RssError::OpmlUnreadable { reason, .. } => assert!(
reason.contains("OPML size limit"),
"reason should name the size limit: {reason}"
),
other => panic!("expected OpmlUnreadable, got {other:?}"),
}
}
#[test]
fn oversized_opml_ending_mid_utf8_still_reports_the_size_limit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge-utf8.opml");
let bytes = "é".repeat((MAX_OPML_BYTES as usize / 2) + 1).into_bytes();
assert!(bytes.len() as u64 > MAX_OPML_BYTES);
std::fs::write(&path, bytes).unwrap();
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let err = resolve_subscriptions("news", &config).unwrap_err();
match err {
RssError::OpmlUnreadable { reason, .. } => assert!(
reason.contains("OPML size limit"),
"over-size file must report the size limit, not a UTF-8 error: {reason}"
),
other => panic!("expected OpmlUnreadable, got {other:?}"),
}
}
#[test]
fn opml_exactly_at_the_size_cap_is_accepted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("at-cap.opml");
let body = r#"<opml version="2.0"><body><outline text="A" xmlUrl="https://a.example/f.xml"/></body></opml>"#;
let mut content = body.to_string();
content.push_str(&" ".repeat(MAX_OPML_BYTES as usize - body.len()));
assert_eq!(content.len() as u64, MAX_OPML_BYTES);
std::fs::write(&path, content).unwrap();
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let subs = resolve_subscriptions("news", &config).unwrap();
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].name, "A");
}
#[cfg(unix)]
#[test]
fn non_regular_file_is_rejected_before_any_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("subs.fifo");
let status = Command::new("mkfifo")
.arg(&path)
.status()
.expect("run mkfifo");
assert!(status.success(), "mkfifo failed");
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let start = Instant::now();
let err = resolve_subscriptions("news", &config).unwrap_err();
assert!(
start.elapsed() < OPML_READ_TIMEOUT,
"rejection must come from the metadata check, not the read timeout"
);
match err {
RssError::OpmlUnreadable { reason, .. } => assert!(
reason.contains("not a regular file"),
"reason should name the file-type refusal: {reason}"
),
other => panic!("expected OpmlUnreadable, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn device_file_is_rejected_before_any_read() {
let config = RssConfig {
opml: Some("/dev/zero".into()),
..inline_config(vec![])
};
let err = resolve_subscriptions("news", &config).unwrap_err();
match err {
RssError::OpmlUnreadable { reason, .. } => assert!(
reason.contains("not a regular file"),
"reason should name the file-type refusal: {reason}"
),
other => panic!("expected OpmlUnreadable, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn stalled_read_hits_the_wall_clock_timeout() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stalled.fifo");
let status = Command::new("mkfifo")
.arg(&path)
.status()
.expect("run mkfifo");
assert!(status.success(), "mkfifo failed");
let err = read_bytes_bounded(&path, Duration::from_millis(50)).unwrap_err();
assert!(
err.contains("OPML read timeout"),
"error should name the timeout: {err}"
);
}
#[test]
fn missing_opml_file_is_opml_unreadable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.opml");
let config = RssConfig {
opml: Some(path.clone()),
..inline_config(vec![])
};
let err = resolve_subscriptions("news", &config).unwrap_err();
match err {
RssError::OpmlUnreadable { path: p, .. } => assert_eq!(
p,
path.display().to_string(),
"error should name the unreadable path"
),
other => panic!("expected OpmlUnreadable, got {other:?}"),
}
}
#[test]
fn malformed_opml_is_invalid_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.opml");
std::fs::write(
&path,
r#"<opml><body><outline xmlUrl="https://a.example/f.xml"></body>"#,
)
.unwrap();
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let err = resolve_subscriptions("news", &config).unwrap_err();
match err {
RssError::InvalidConfig { reason, .. } => assert!(
reason.contains("well-formed XML"),
"reason should flag the XML syntax error: {reason}"
),
other => panic!("expected InvalidConfig, got {other:?}"),
}
}
#[test]
fn opml_without_any_xmlurl_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.opml");
std::fs::write(
&path,
r#"<?xml version="1.0"?>
<opml version="2.0">
<head><title>subs</title></head>
<body>
<outline text="Tech"/>
</body>
</opml>"#,
)
.unwrap();
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let err = resolve_subscriptions("news", &config).unwrap_err();
match err {
RssError::InvalidConfig { reason, .. } => assert!(
reason.contains("at least one subscription"),
"reason should require at least one subscription: {reason}"
),
other => panic!("expected InvalidConfig, got {other:?}"),
}
}
#[test]
fn inline_empty_feed_list_is_rejected_by_resolve_too() {
let config = inline_config(vec![]);
let err = resolve_subscriptions("news", &config).unwrap_err();
match err {
RssError::InvalidConfig { reason, .. } => assert!(
reason.contains("at least one subscription"),
"reason should require at least one subscription: {reason}"
),
other => panic!("expected InvalidConfig, got {other:?}"),
}
}
#[test]
fn duplicate_names_across_opml_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dup.opml");
std::fs::write(
&path,
r#"<?xml version="1.0"?>
<opml version="2.0">
<head><title>subs</title></head>
<body>
<outline text="Same Name" xmlUrl="https://a.example/feed.xml"/>
<outline text="Same Name" xmlUrl="https://b.example/feed.xml"/>
</body>
</opml>"#,
)
.unwrap();
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let err = resolve_subscriptions("news", &config).unwrap_err();
match err {
RssError::InvalidConfig { reason, .. } => assert!(
reason.contains("duplicate subscription name"),
"reason should flag the duplicate name: {reason}"
),
other => panic!("expected InvalidConfig, got {other:?}"),
}
}
#[test]
fn opml_bad_scheme_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("badscheme.opml");
std::fs::write(
&path,
r#"<?xml version="1.0"?>
<opml version="2.0">
<head><title>subs</title></head>
<body>
<outline text="FTP Feed" xmlUrl="ftp://example.com/feed.xml"/>
</body>
</opml>"#,
)
.unwrap();
let config = RssConfig {
opml: Some(path),
..inline_config(vec![])
};
let err = resolve_subscriptions("news", &config).unwrap_err();
match err {
RssError::InvalidConfig { reason, .. } => assert!(
reason.contains("http or https"),
"reason should flag the bad scheme: {reason}"
),
other => panic!("expected InvalidConfig, got {other:?}"),
}
}
}