use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::PathBuf;
use url::Url;
use super::ResolvedSubscription;
use super::error::RssError;
const DEFAULT_TTL_SECONDS: u64 = 900;
const DEFAULT_MAX_CONCURRENT: usize = 6;
const DEFAULT_REQUEST_TIMEOUT_SECONDS: u64 = 10;
const DEFAULT_SCAN_TIMEOUT_SECONDS: u64 = 60;
pub(crate) const DEFAULT_MAX_RESPONSE_BYTES: u64 = 5_242_880;
fn default_ttl_seconds() -> u64 {
DEFAULT_TTL_SECONDS
}
fn default_max_concurrent() -> usize {
DEFAULT_MAX_CONCURRENT
}
fn default_request_timeout_seconds() -> u64 {
DEFAULT_REQUEST_TIMEOUT_SECONDS
}
fn default_scan_timeout_seconds() -> u64 {
DEFAULT_SCAN_TIMEOUT_SECONDS
}
fn default_max_response_bytes() -> u64 {
DEFAULT_MAX_RESPONSE_BYTES
}
fn default_user_agent() -> String {
format!(
"skardi-rss/{} (+https://github.com/SkardiLabs/skardi)",
env!("CARGO_PKG_VERSION")
)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RssConfig {
#[serde(default)]
pub feeds: Option<Vec<FeedSubscription>>,
#[serde(default)]
pub opml: Option<PathBuf>,
#[serde(default = "default_ttl_seconds")]
pub ttl_seconds: u64,
#[serde(default = "default_max_concurrent")]
pub max_concurrent: usize,
#[serde(default = "default_request_timeout_seconds")]
pub request_timeout_seconds: u64,
#[serde(default = "default_scan_timeout_seconds")]
pub scan_timeout_seconds: u64,
#[serde(default = "default_max_response_bytes")]
pub max_response_bytes: u64,
#[serde(default = "default_user_agent")]
pub user_agent: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FeedSubscription {
pub url: String,
#[serde(default)]
pub name: Option<String>,
}
impl RssConfig {
pub fn validate(&self) -> Result<(), RssError> {
match (&self.feeds, &self.opml) {
(Some(_), Some(_)) => {
return Err(invalid_config("`feeds` and `opml` are mutually exclusive"));
}
(None, None) => {
return Err(invalid_config(
"exactly one of `feeds` or `opml` must be set",
));
}
_ => {}
}
if let Some(feeds) = &self.feeds {
let raw = feeds
.iter()
.map(|feed| (feed.url.clone(), feed.name.clone()))
.collect();
finalize(raw)?;
}
if self.max_concurrent == 0 {
return Err(invalid_config("max_concurrent must be at least 1"));
}
if self.request_timeout_seconds == 0 {
return Err(invalid_config("request_timeout_seconds must be at least 1"));
}
if self.scan_timeout_seconds == 0 {
return Err(invalid_config("scan_timeout_seconds must be at least 1"));
}
if self.max_response_bytes == 0 {
return Err(invalid_config("max_response_bytes must be at least 1"));
}
if self.user_agent.trim().is_empty() {
return Err(invalid_config("user_agent must not be empty"));
}
if reqwest::header::HeaderValue::from_str(&self.user_agent).is_err() {
return Err(invalid_config(
"user_agent is not a valid HTTP header value (control characters and other illegal bytes are refused)",
));
}
Ok(())
}
}
fn invalid_config(reason: impl Into<String>) -> RssError {
RssError::InvalidConfig {
reason: reason.into(),
}
}
pub(crate) fn redact_url(parsed: &Url) -> String {
let mut stripped = parsed.clone();
let _ = stripped.set_username("");
let _ = stripped.set_password(None);
stripped.set_query(None);
stripped.set_fragment(None);
String::from(stripped)
}
fn default_name(parsed: &Url) -> String {
redact_url(parsed)
}
pub(crate) fn finalize(
raw: Vec<(String, Option<String>)>,
) -> Result<Vec<ResolvedSubscription>, RssError> {
if raw.is_empty() {
return Err(invalid_config("at least one subscription is required"));
}
let mut seen_names = HashSet::with_capacity(raw.len());
let mut resolved = Vec::with_capacity(raw.len());
for (url, sub_name) in raw {
let parsed = Url::parse(&url)
.map_err(|e| invalid_config(format!("invalid subscription URL '{url}': {e}")))?;
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err(invalid_config(format!(
"subscription URL '{url}' must use http or https"
)));
}
let (effective_name, name_was_derived) =
match sub_name.filter(|name| !name.trim().is_empty()) {
Some(name) => (name, false),
None => (default_name(&parsed), true),
};
if !seen_names.insert(effective_name.clone()) {
let hint = if name_was_derived {
" (an unnamed subscription is named by its URL stripped of \
credentials, query, and fragment; set an explicit `name:` \
to keep both)"
} else {
""
};
return Err(invalid_config(format!(
"duplicate subscription name '{effective_name}'{hint}"
)));
}
resolved.push(ResolvedSubscription {
name: effective_name,
url,
});
}
Ok(resolved)
}
#[cfg(test)]
pub(crate) fn inline_config(feeds: Vec<FeedSubscription>) -> RssConfig {
RssConfig {
feeds: Some(feeds),
opml: None,
ttl_seconds: default_ttl_seconds(),
max_concurrent: default_max_concurrent(),
request_timeout_seconds: default_request_timeout_seconds(),
scan_timeout_seconds: default_scan_timeout_seconds(),
max_response_bytes: default_max_response_bytes(),
user_agent: default_user_agent(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minimal_inline_config_parses_with_spec_defaults() {
let yaml = r#"
feeds:
- url: https://blog.rust-lang.org/feed.xml
name: rust-blog
- url: https://this-week-in-rust.org/rss.xml
"#;
let config: RssConfig = serde_yaml::from_str(yaml).unwrap();
config.validate().unwrap();
assert_eq!(config.ttl_seconds, 900);
assert_eq!(config.max_concurrent, 6);
assert_eq!(config.request_timeout_seconds, 10);
assert_eq!(config.scan_timeout_seconds, 60);
assert_eq!(config.max_response_bytes, 5_242_880);
assert_eq!(
config.user_agent,
format!(
"skardi-rss/{} (+https://github.com/SkardiLabs/skardi)",
env!("CARGO_PKG_VERSION")
)
);
assert_eq!(config.feeds.as_ref().unwrap().len(), 2);
assert_eq!(
config.feeds.as_ref().unwrap()[0].name.as_deref(),
Some("rust-blog")
);
}
#[test]
fn feeds_and_opml_are_mutually_exclusive() {
let yaml = "feeds:\n - url: https://a.example/f.xml\nopml: subs.opml\n";
let config: RssConfig = serde_yaml::from_str(yaml).unwrap();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("mutually exclusive"), "{err}");
}
#[test]
fn neither_feeds_nor_opml_is_rejected() {
let config: RssConfig = serde_yaml::from_str("{}\n").unwrap();
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("one of `feeds` or `opml`"),
"{err}"
);
}
#[test]
fn empty_inline_feed_list_is_rejected() {
let config: RssConfig = serde_yaml::from_str("feeds: []\n").unwrap();
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("at least one subscription"),
"{err}"
);
}
#[test]
fn non_http_scheme_is_rejected() {
let yaml = "feeds:\n - url: file:///etc/passwd\n";
let config: RssConfig = serde_yaml::from_str(yaml).unwrap();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("http or https"), "{err}");
}
#[test]
fn malformed_url_is_rejected() {
let yaml = "feeds:\n - url: \"not a url\"\n";
let config: RssConfig = serde_yaml::from_str(yaml).unwrap();
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("invalid subscription URL"),
"{err}"
);
}
#[test]
fn duplicate_subscription_names_are_rejected() {
let yaml = "feeds:\n - url: https://a.example/f.xml\n name: dup\n - url: https://b.example/f.xml\n name: dup\n";
let config: RssConfig = serde_yaml::from_str(yaml).unwrap();
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("duplicate subscription name"),
"{err}"
);
let yaml = "feeds:\n - url: https://c.example/other.xml\n name: https://dup.example/feed.xml\n - url: https://dup.example/feed.xml\n";
let config: RssConfig = serde_yaml::from_str(yaml).unwrap();
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("duplicate subscription name"),
"{err}"
);
}
#[test]
fn an_unnamed_subscription_is_named_by_its_stripped_url() {
let subs = finalize(vec![(
"https://user:pass@news.example/feed.xml?token=secret#frag".to_string(),
None,
)])
.unwrap();
assert_eq!(subs[0].name, "https://news.example/feed.xml");
assert!(subs[0].url.contains("token=secret"));
}
#[test]
fn unnamed_subscriptions_differing_only_by_query_collide_with_a_hint() {
let err = finalize(vec![
(
"https://news.example/feed.xml?token=alice".to_string(),
None,
),
("https://news.example/feed.xml?token=bob".to_string(), None),
])
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("duplicate subscription name"), "{msg}");
assert!(msg.contains("explicit `name:`"), "{msg}");
}
#[test]
fn blank_names_fall_back_to_the_url() {
let resolved = finalize(vec![
("https://a.example/f.xml".to_string(), Some(String::new())),
("https://b.example/f.xml".to_string(), Some(String::new())),
])
.expect("blank names must fall back to distinct URLs");
assert_eq!(resolved[0].name, "https://a.example/f.xml");
assert_eq!(resolved[1].name, "https://b.example/f.xml");
let resolved = finalize(vec![(
"https://a.example/f.xml".to_string(),
Some(" ".to_string()),
)])
.expect("a whitespace-only name must fall back to the URL");
assert_eq!(resolved[0].name, "https://a.example/f.xml");
let resolved = finalize(vec![
(
"https://a.example/f.xml".to_string(),
Some("a ".to_string()),
),
("https://b.example/f.xml".to_string(), Some("a".to_string())),
])
.expect("non-blank names are not trimmed into collision");
assert_eq!(resolved[0].name, "a ");
assert_eq!(resolved[1].name, "a");
}
#[test]
fn zero_bounds_are_rejected_except_ttl() {
let base = inline_config(vec![FeedSubscription {
url: "https://a.example/f.xml".to_string(),
name: None,
}]);
let mut config = base.clone();
config.max_concurrent = 0;
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("max_concurrent"), "{err}");
let mut config = base.clone();
config.request_timeout_seconds = 0;
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("request_timeout_seconds"), "{err}");
let mut config = base.clone();
config.scan_timeout_seconds = 0;
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("scan_timeout_seconds"), "{err}");
let mut config = base.clone();
config.max_response_bytes = 0;
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("max_response_bytes"), "{err}");
let mut config = base;
config.ttl_seconds = 0;
config
.validate()
.expect("ttl_seconds: 0 (always-live) is legal");
}
#[test]
fn empty_user_agent_is_rejected() {
let mut config = inline_config(vec![FeedSubscription {
url: "https://a.example/f.xml".to_string(),
name: None,
}]);
config.user_agent = String::new();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("user_agent"), "{err}");
}
#[test]
fn user_agent_with_a_control_char_is_rejected_at_config_load() {
let mut config = inline_config(vec![FeedSubscription {
url: "https://a.example/f.xml".to_string(),
name: None,
}]);
config.user_agent = "bad\nua".to_string();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("user_agent"), "{err}");
}
#[test]
fn unknown_fields_are_rejected() {
let err = serde_yaml::from_str::<RssConfig>("feeds: []\nbogus: 1\n").unwrap_err();
assert!(err.to_string().contains("bogus"), "{err}");
let err = serde_yaml::from_str::<RssConfig>(
"feeds:\n - url: https://a.example/f.xml\n bogus: 1\n",
)
.unwrap_err();
assert!(err.to_string().contains("bogus"), "{err}");
}
#[test]
fn opml_only_config_validates_without_reading_the_file() {
let config: RssConfig =
serde_yaml::from_str("opml: does-not-exist.opml\n").expect("opml-only config parses");
config
.validate()
.expect("opml-only config validates without touching the path");
}
}