use std::time::Duration;
use anyhow::{Context, Result};
use chrono::{DateTime, SecondsFormat, Utc};
use feed_rs::model::{Entry as RawEntry, Feed as RawFeed, Text};
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
use reqwest::{Client, StatusCode};
use sqlx::SqlitePool;
use url::Url;
use crate::store::{self, Feed, NewEntry, NewFeed};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FeedPrivacy {
Public,
Private(String),
}
impl FeedPrivacy {
pub fn is_private(&self) -> bool {
matches!(self, FeedPrivacy::Private(_))
}
}
const SECRET_QUERY_KEYS: &[&str] = &[
"token", "key", "auth", "secret", "k", "sig", "hash", "access", "apikey", "api_key", "uuid",
"id", "u", "s", "p", "private", "password", "pw",
];
const PRIVATE_PATH_MARKERS: &[&str] = &[
"/private/",
"/feed/private/",
"/rss/private/",
"/private-feed/",
"/members/",
"/member/",
"/subscriber/",
];
struct KnownProvider {
host_contains: &'static str,
marker: Option<&'static str>,
reason: &'static str,
}
const KNOWN_PROVIDERS: &[KnownProvider] = &[
KnownProvider {
host_contains: "substack.com",
marker: Some("/feed/private/"),
reason: "Substack private feed",
},
KnownProvider {
host_contains: "patreon.com",
marker: Some("auth="),
reason: "Patreon member feed",
},
KnownProvider {
host_contains: "ghost.io",
marker: Some("uuid="),
reason: "Ghost members feed",
},
KnownProvider {
host_contains: "buttondown.email",
marker: Some("token"),
reason: "Buttondown premium feed",
},
KnownProvider {
host_contains: "buttondown.com",
marker: Some("token"),
reason: "Buttondown premium feed",
},
KnownProvider {
host_contains: "beehiiv.com",
marker: Some("token"),
reason: "Beehiiv premium feed",
},
KnownProvider {
host_contains: "memberful.com",
marker: None,
reason: "Memberful members feed",
},
KnownProvider {
host_contains: "pico.link",
marker: None,
reason: "Pico member feed",
},
KnownProvider {
host_contains: "steadyhq.com",
marker: None,
reason: "Steady member feed",
},
KnownProvider {
host_contains: "supercast.com",
marker: None,
reason: "Supercast private podcast",
},
KnownProvider {
host_contains: "supercast.tech",
marker: None,
reason: "Supercast private podcast",
},
KnownProvider {
host_contains: "supportingcast.fm",
marker: None,
reason: "Supporting Cast private podcast",
},
KnownProvider {
host_contains: "redcircle.com",
marker: Some("private"),
reason: "RedCircle private podcast",
},
KnownProvider {
host_contains: "megaphone.fm",
marker: Some("token"),
reason: "Megaphone private podcast",
},
KnownProvider {
host_contains: "acast.com",
marker: Some("token"),
reason: "Acast+ private podcast",
},
KnownProvider {
host_contains: "omny.fm",
marker: Some("token"),
reason: "Omny private podcast",
},
KnownProvider {
host_contains: "podcasts.apple.com",
marker: Some("token"),
reason: "Apple subscriber podcast",
},
KnownProvider {
host_contains: "spotify.com",
marker: Some("token"),
reason: "Spotify subscriber podcast",
},
];
pub fn classify_feed_privacy(url: &str) -> FeedPrivacy {
let parsed = match Url::parse(url) {
Ok(u) => u,
Err(_) => return FeedPrivacy::Public,
};
if !parsed.username().is_empty() || parsed.password().is_some() {
return FeedPrivacy::Private("credentials in URL userinfo".to_string());
}
let path_lower = parsed.path().to_ascii_lowercase();
let query_lower = parsed.query().unwrap_or("").to_ascii_lowercase();
let host_lower = parsed.host_str().unwrap_or("").to_ascii_lowercase();
if is_public_youtube_feed(&host_lower, &path_lower, &parsed) {
return FeedPrivacy::Public;
}
for kp in KNOWN_PROVIDERS {
if host_lower.contains(kp.host_contains) {
let marker_ok = match kp.marker {
None => true,
Some(m) => {
let m = m.to_ascii_lowercase();
path_lower.contains(&m) || query_lower.contains(&m)
}
};
if marker_ok {
return FeedPrivacy::Private(kp.reason.to_string());
}
}
}
for marker in PRIVATE_PATH_MARKERS {
if path_lower.contains(marker) {
return FeedPrivacy::Private(format!("private feed path `{marker}`"));
}
}
for (k, v) in parsed.query_pairs() {
let key = k.as_ref().to_ascii_lowercase();
if SECRET_QUERY_KEYS.iter().any(|sk| *sk == key) && value_is_opaque(v.as_ref()) {
return FeedPrivacy::Private(format!("credential query parameter `{key}`"));
}
}
for seg in parsed.path().split('/').filter(|s| !s.is_empty()) {
if segment_hides_secret(seg) {
return FeedPrivacy::Private("high-entropy token in path".to_string());
}
}
for (_, v) in parsed.query_pairs() {
if looks_like_embedded_secret(v.as_ref()) {
return FeedPrivacy::Private("high-entropy token in query".to_string());
}
}
FeedPrivacy::Public
}
fn value_is_opaque(v: &str) -> bool {
if v.is_empty() {
return false;
}
if is_uuid(v) {
return true;
}
v.len() >= 8
}
fn looks_like_embedded_secret(s: &str) -> bool {
if is_uuid(s) {
return true;
}
if s.len() >= 16 && s.chars().all(|c| c.is_ascii_hexdigit()) {
return true;
}
if s.len() < 16 {
return false;
}
let separators = s
.bytes()
.filter(|b| *b == b'-' || *b == b'.' || *b == b' ')
.count();
if separators >= 3 {
return false;
}
let token_chars = s
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '='))
.count();
if token_chars < s.chars().count() {
return false;
}
let has_alpha = s.chars().any(|c| c.is_ascii_alphabetic());
let has_digit = s.chars().any(|c| c.is_ascii_digit());
if !(has_alpha && has_digit) {
return false;
}
let mut seen = std::collections::HashSet::new();
for c in s.chars() {
seen.insert(c.to_ascii_lowercase());
}
seen.len() >= 10
}
const FEED_EXTENSIONS: &[&str] = &["rss", "xml", "atom", "json", "rss20"];
fn segment_hides_secret(seg: &str) -> bool {
if looks_like_embedded_secret(seg) {
return true;
}
if let Some((stem, ext)) = seg.rsplit_once('.') {
if FEED_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
&& looks_like_embedded_secret(stem)
{
return true;
}
}
if contains_uuid(seg) {
return true;
}
if seg.contains(['.', '_', '-']) {
for part in seg.split(['.', '_', '-']).filter(|p| !p.is_empty()) {
if looks_like_embedded_secret(part) {
return true;
}
}
}
false
}
fn contains_uuid(s: &str) -> bool {
const UUID_LEN: usize = 36; let bytes = s.as_bytes();
if bytes.len() < UUID_LEN {
return false;
}
(0..=bytes.len() - UUID_LEN).any(|i| s.get(i..i + UUID_LEN).map(is_uuid).unwrap_or(false))
}
fn is_public_youtube_feed(host_lower: &str, path_lower: &str, parsed: &Url) -> bool {
let host_ok = host_lower == "youtube.com"
|| host_lower == "www.youtube.com"
|| host_lower.ends_with(".youtube.com");
if !host_ok || !path_lower.starts_with("/feeds/videos.xml") {
return false;
}
parsed.query_pairs().all(|(k, _)| {
let k = k.as_ref().to_ascii_lowercase();
k == "channel_id" || k == "playlist_id" || k == "user"
})
}
fn is_uuid(s: &str) -> bool {
let groups = [8usize, 4, 4, 4, 12];
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != groups.len() {
return false;
}
parts
.iter()
.zip(groups.iter())
.all(|(p, &n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()))
}
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
const READ_TIMEOUT: Duration = Duration::from_secs(15);
const BACKOFF_BASE: Duration = Duration::from_secs(300);
const BACKOFF_MAX: Duration = Duration::from_secs(24 * 3600);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PollOutcome {
Updated { new_entries: u64 },
NotModified,
Failed { backoff: Duration },
}
pub fn build_client() -> Result<Client> {
Client::builder()
.user_agent(crate::USER_AGENT)
.timeout(FETCH_TIMEOUT)
.read_timeout(READ_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.context("failed to build feed HTTP client")
}
pub fn backoff_for(consecutive_errors: u32) -> Duration {
let n = consecutive_errors.max(1);
let factor = 1u64.checked_shl(n.saturating_sub(1)).unwrap_or(u64::MAX);
let secs = BACKOFF_BASE
.as_secs()
.saturating_mul(factor)
.min(BACKOFF_MAX.as_secs());
Duration::from_secs(secs)
}
pub async fn poll_feed(
pool: &SqlitePool,
client: &Client,
feed: &Feed,
max_entries_per_feed: i64,
) -> Result<PollOutcome> {
let mut extra: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)> = Vec::new();
if let Some(etag) = feed.etag.as_deref() {
if let Ok(v) = reqwest::header::HeaderValue::from_str(etag) {
extra.push((IF_NONE_MATCH, v));
}
}
if let Some(lm) = feed.last_modified.as_deref() {
if let Ok(v) = reqwest::header::HeaderValue::from_str(lm) {
extra.push((IF_MODIFIED_SINCE, v));
}
}
let resp = match crate::net::guarded_get(client, &feed.url, &extra).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(feed = %feed.url, error = %e, "feed fetch failed (or blocked by SSRF guard)");
return Ok(PollOutcome::Failed {
backoff: backoff_for(1),
});
}
};
let status = resp.status();
if status == StatusCode::NOT_MODIFIED {
tracing::debug!(feed = %feed.url, "feed not modified (304)");
touch_polled(pool, &feed.url, None, None)
.await
.with_context(|| format!("touch_polled after 304 for {}", feed.url))?;
return Ok(PollOutcome::NotModified);
}
if !status.is_success() {
tracing::warn!(feed = %feed.url, %status, "feed returned non-success status");
return Ok(PollOutcome::Failed {
backoff: backoff_for(1),
});
}
let new_etag = header_str(resp.headers().get(ETAG));
let new_last_modified = header_str(resp.headers().get(LAST_MODIFIED));
let body = match crate::net::read_capped(resp).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(feed = %feed.url, error = %e, "feed body rejected (too large / read error)");
return Ok(PollOutcome::Failed {
backoff: backoff_for(1),
});
}
};
let parsed = match feed_rs::parser::parse(&body[..]) {
Ok(f) => f,
Err(e) => {
tracing::warn!(feed = %feed.url, error = %e, "malformed feed; skipping");
return Ok(PollOutcome::Failed {
backoff: backoff_for(1),
});
}
};
let (title, site_url) = feed_metadata(&parsed);
let new_feed = NewFeed {
url: feed.url.clone(),
title,
site_url,
etag: new_etag,
last_modified: new_last_modified,
last_polled: Some(now_rfc3339()),
next_poll: None, };
let entries: Vec<NewEntry> = parsed.entries.iter().map(normalize_entry).collect();
let feed_id = store::upsert_feed(pool, &new_feed)
.await
.with_context(|| format!("upsert_feed for {}", feed.url))?;
let n = store::insert_entries(pool, feed_id, &entries, max_entries_per_feed)
.await
.with_context(|| format!("insert_entries for {}", feed.url))?;
tracing::info!(feed = %feed.url, entries = n, "feed polled");
Ok(PollOutcome::Updated { new_entries: n })
}
async fn touch_polled(
pool: &SqlitePool,
url: &str,
etag: Option<String>,
last_modified: Option<String>,
) -> Result<()> {
let existing = store::get_feed_by_url(pool, url).await?;
let (etag, last_modified) = match existing {
Some(f) => (etag.or(f.etag), last_modified.or(f.last_modified)),
None => (etag, last_modified),
};
let nf = NewFeed {
url: url.to_string(),
etag,
last_modified,
last_polled: Some(now_rfc3339()),
..Default::default()
};
store::upsert_feed(pool, &nf).await?;
Ok(())
}
fn feed_metadata(parsed: &RawFeed) -> (Option<String>, Option<String>) {
let title = parsed.title.as_ref().map(text_plain);
let site_url = parsed
.links
.iter()
.find(|l| {
l.rel.as_deref() == Some("alternate")
|| (l.rel.is_none()
&& l.media_type.as_deref() != Some("application/rss+xml")
&& l.media_type.as_deref() != Some("application/atom+xml"))
})
.or_else(|| {
parsed
.links
.iter()
.find(|l| l.rel.as_deref() != Some("self"))
})
.or_else(|| parsed.links.first())
.map(|l| l.href.clone());
(title, site_url)
}
fn normalize_entry(e: &RawEntry) -> NewEntry {
let url = entry_link(e);
let content_html = e
.content
.as_ref()
.and_then(|c| c.body.as_deref())
.or_else(|| e.summary.as_ref().map(|t| t.content.as_str()))
.map(sanitize_html);
let guid = if !e.id.trim().is_empty() {
e.id.trim().to_string()
} else if let Some(link) = raw_entry_link(e) {
link
} else {
stable_guid(e)
};
NewEntry {
guid,
url,
title: e.title.as_ref().map(text_plain),
author: entry_author(e),
published: entry_time(e),
content_html,
fetched_at: None, }
}
fn raw_entry_link(e: &RawEntry) -> Option<String> {
e.links
.iter()
.find(|l| l.rel.as_deref() == Some("alternate") || l.rel.is_none())
.or_else(|| e.links.first())
.map(|l| l.href.clone())
}
fn entry_link(e: &RawEntry) -> Option<String> {
raw_entry_link(e).and_then(|href| crate::net::safe_link(&href))
}
fn entry_author(e: &RawEntry) -> Option<String> {
e.authors.first().map(|p| p.name.clone())
}
fn entry_time(e: &RawEntry) -> Option<String> {
e.published.or(e.updated).map(fmt_time)
}
fn text_plain(t: &Text) -> String {
t.content.trim().to_string()
}
fn sanitize_html(raw: &str) -> String {
ammonia::clean(raw)
}
fn fmt_time(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn stable_guid(e: &RawEntry) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
e.title.as_ref().map(|t| t.content.as_str()).hash(&mut h);
e.links.first().map(|l| l.href.as_str()).hash(&mut h);
e.summary.as_ref().map(|s| s.content.as_str()).hash(&mut h);
format!("featherreader:synthetic:{:016x}", h.finish())
}
fn header_str(v: Option<&reqwest::header::HeaderValue>) -> Option<String> {
v.and_then(|h| h.to_str().ok()).map(str::to_string)
}
pub fn discover_feed(site_html: &str, base: Option<&Url>) -> Option<Url> {
for tag in link_tags(site_html) {
let rel = attr(&tag, "rel").unwrap_or_default().to_ascii_lowercase();
let typ = attr(&tag, "type").unwrap_or_default().to_ascii_lowercase();
let is_feed_type = typ.contains("application/rss+xml")
|| typ.contains("application/atom+xml")
|| typ.contains("application/feed+json")
|| typ.contains("application/json");
let rel_ok = rel.split_whitespace().any(|r| r == "alternate") || rel.is_empty();
if is_feed_type && rel_ok {
if let Some(href) = attr(&tag, "href") {
let href = href.trim();
if href.is_empty() {
continue;
}
if let Ok(u) = Url::parse(href) {
return Some(u);
}
if let Some(b) = base {
if let Ok(u) = b.join(href) {
return Some(u);
}
}
}
}
}
None
}
fn link_tags(html: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = html.as_bytes();
let lower = html.to_ascii_lowercase();
let mut search_from = 0usize;
while let Some(rel_idx) = lower[search_from..].find("<link") {
let start = search_from + rel_idx;
let after = bytes.get(start + 5).copied();
let boundary = matches!(after, Some(b) if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>' || b == b'/');
if !boundary {
search_from = start + 5;
continue;
}
if let Some(end_rel) = html[start..].find('>') {
let end = start + end_rel;
out.push(html[start..=end].to_string());
search_from = end + 1;
} else {
break;
}
}
out
}
fn attr(tag: &str, name: &str) -> Option<String> {
let lower = tag.to_ascii_lowercase();
let needle = format!("{name}=");
let mut from = 0usize;
while let Some(rel) = lower[from..].find(&needle) {
let name_start = from + rel;
let ok_prefix = name_start == 0
|| matches!(
tag.as_bytes().get(name_start - 1),
Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') | Some(b'<')
);
let val_start = name_start + needle.len();
if !ok_prefix {
from = val_start;
continue;
}
let rest = &tag[val_start..];
let quote = rest.chars().next();
let value = match quote {
Some('"') => rest[1..].split('"').next(),
Some('\'') => rest[1..].split('\'').next(),
_ => rest
.split(|c: char| c.is_whitespace() || c == '>' || c == '/')
.next(),
};
return value.map(str::to_string);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const RSS_SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Example RSS Feed</title>
<link>https://example.com/</link>
<description>An example feed for tests</description>
<item>
<title>First post</title>
<link>https://example.com/first</link>
<guid>https://example.com/first</guid>
<author>alice@example.com (Alice)</author>
<pubDate>Fri, 10 Jul 2026 08:00:00 GMT</pubDate>
<description><![CDATA[<p>Hello <b>world</b>.</p><script>alert('xss')</script><img src="x" onerror="alert(1)">]]></description>
</item>
<item>
<title>Second post</title>
<link>https://example.com/second</link>
<guid>guid-second</guid>
<pubDate>Sat, 11 Jul 2026 08:00:00 GMT</pubDate>
<description><![CDATA[<a href="javascript:alert(1)">click</a><a href="https://ok.example/">ok</a>]]></description>
</item>
</channel>
</rss>"#;
const ATOM_SAMPLE: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Atom Feed</title>
<link rel="alternate" href="https://atom.example.com/"/>
<link rel="self" href="https://atom.example.com/feed.xml"/>
<id>urn:uuid:feed-1</id>
<updated>2026-07-11T08:00:00Z</updated>
<entry>
<title>Atom entry</title>
<id>urn:uuid:entry-1</id>
<link rel="alternate" href="https://atom.example.com/a"/>
<author><name>Bob</name></author>
<updated>2026-07-11T08:00:00Z</updated>
<content type="html"><![CDATA[<p>Safe <em>text</em>.</p><script>steal()</script><iframe src="evil"></iframe>]]></content>
</entry>
</feed>"#;
#[test]
fn rss_parses_and_sanitizes() {
let parsed = feed_rs::parser::parse(RSS_SAMPLE.as_bytes()).expect("RSS should parse");
assert_eq!(
parsed.title.as_ref().map(text_plain).as_deref(),
Some("Example RSS Feed")
);
assert_eq!(parsed.entries.len(), 2);
let (title, site) = feed_metadata(&parsed);
assert_eq!(title.as_deref(), Some("Example RSS Feed"));
assert_eq!(site.as_deref(), Some("https://example.com/"));
let e0 = normalize_entry(&parsed.entries[0]);
assert_eq!(e0.guid, "https://example.com/first");
assert_eq!(e0.title.as_deref(), Some("First post"));
assert_eq!(e0.url.as_deref(), Some("https://example.com/first"));
assert!(e0.published.is_some());
let html0 = e0.content_html.expect("content present");
assert!(html0.contains("Hello"));
assert!(html0.contains("<b>world</b>") || html0.contains("<b>"));
assert!(!html0.to_ascii_lowercase().contains("<script"));
assert!(!html0.to_ascii_lowercase().contains("onerror"));
assert!(!html0.to_ascii_lowercase().contains("alert"));
let e1 = normalize_entry(&parsed.entries[1]);
assert_eq!(e1.guid, "guid-second");
let html1 = e1.content_html.expect("content present");
assert!(!html1.to_ascii_lowercase().contains("javascript:"));
assert!(html1.contains("https://ok.example/"));
}
#[test]
fn atom_parses_and_sanitizes() {
let parsed = feed_rs::parser::parse(ATOM_SAMPLE.as_bytes()).expect("Atom should parse");
let (title, site) = feed_metadata(&parsed);
assert_eq!(title.as_deref(), Some("Example Atom Feed"));
assert_eq!(site.as_deref(), Some("https://atom.example.com/"));
assert_eq!(parsed.entries.len(), 1);
let e = normalize_entry(&parsed.entries[0]);
assert_eq!(e.guid, "urn:uuid:entry-1");
assert_eq!(e.title.as_deref(), Some("Atom entry"));
assert_eq!(e.author.as_deref(), Some("Bob"));
assert_eq!(e.url.as_deref(), Some("https://atom.example.com/a"));
let html = e.content_html.expect("content present");
assert!(html.contains("Safe"));
assert!(!html.to_ascii_lowercase().contains("<script"));
assert!(!html.to_ascii_lowercase().contains("<iframe"));
}
#[test]
fn discover_finds_rss_link() {
let html = r#"<!doctype html><html><head>
<title>Blog</title>
<link rel="stylesheet" href="/style.css">
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed.xml">
</head><body>hi</body></html>"#;
let base = Url::parse("https://blog.example.com/").unwrap();
let found = discover_feed(html, Some(&base)).expect("should discover feed");
assert_eq!(found.as_str(), "https://blog.example.com/feed.xml");
}
#[test]
fn discover_finds_atom_absolute_link() {
let html = r#"<head><link rel="alternate" type="application/atom+xml" href="https://x.example/atom"></head>"#;
let found = discover_feed(html, None).expect("should discover absolute feed");
assert_eq!(found.as_str(), "https://x.example/atom");
}
#[test]
fn discover_returns_none_without_feed_link() {
let html =
r#"<head><link rel="stylesheet" href="/s.css"><link rel="icon" href="/f.ico"></head>"#;
assert!(discover_feed(html, None).is_none());
}
#[test]
fn synthetic_guid_is_stable_and_dedups() {
let xml = r#"<?xml version="1.0"?><rss version="2.0"><channel>
<title>t</title>
<item><title>only a title</title><description>body</description></item>
</channel></rss>"#;
let mut parsed = feed_rs::parser::parse(xml.as_bytes()).expect("parse");
parsed.entries[0].id.clear();
parsed.entries[0].links.clear();
let g1 = normalize_entry(&parsed.entries[0]).guid;
let g2 = normalize_entry(&parsed.entries[0]).guid;
assert_eq!(g1, g2);
assert!(g1.starts_with("featherreader:synthetic:"));
}
#[test]
fn entry_link_scheme_allowlist_neutralizes_javascript() {
let xml = r#"<?xml version="1.0"?><rss version="2.0"><channel>
<title>t</title>
<item>
<title>evil</title>
<link>javascript:alert(document.domain)</link>
<guid>evil-1</guid>
</item>
</channel></rss>"#;
let parsed = feed_rs::parser::parse(xml.as_bytes()).expect("parse");
let e = normalize_entry(&parsed.entries[0]);
assert_eq!(e.url, None);
assert_eq!(e.guid, "evil-1");
let xml2 = r#"<?xml version="1.0"?><rss version="2.0"><channel>
<title>t</title>
<item><title>d</title><link>data:text/html,<script>1</script></link><guid>d1</guid></item>
</channel></rss>"#;
let parsed2 = feed_rs::parser::parse(xml2.as_bytes()).expect("parse");
let e2 = normalize_entry(&parsed2.entries[0]);
assert_eq!(e2.url, None);
let xml3 = r#"<?xml version="1.0"?><rss version="2.0"><channel>
<title>t</title>
<item><title>ok</title><link>https://ok.example/post</link><guid>ok1</guid></item>
</channel></rss>"#;
let parsed3 = feed_rs::parser::parse(xml3.as_bytes()).expect("parse");
let e3 = normalize_entry(&parsed3.entries[0]);
assert_eq!(e3.url.as_deref(), Some("https://ok.example/post"));
}
#[test]
fn classify_privacy_flags_secret_urls_across_providers() {
assert!(
classify_feed_privacy("https://author.substack.com/feed/private/deadbeefcafe1234")
.is_private()
);
assert!(classify_feed_privacy(
"https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4"
)
.is_private());
assert!(classify_feed_privacy(
"https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567"
)
.is_private());
assert!(classify_feed_privacy(
"https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01"
)
.is_private());
assert!(classify_feed_privacy("https://feeds.supercast.com/12345/rss").is_private());
assert!(
classify_feed_privacy("https://example.com/feed?token=Zm9vYmFyc2VjcmV0").is_private()
);
assert!(
classify_feed_privacy("https://example.com/feed?key=Zm9vYmFyc2VjcmV0").is_private()
);
assert!(
classify_feed_privacy("https://example.com/feed?secret=Zm9vYmFyc2VjcmV0").is_private()
);
assert!(classify_feed_privacy("https://user:pass@example.com/feed").is_private());
assert!(classify_feed_privacy("https://blog.example.com/private/rss").is_private());
assert!(classify_feed_privacy("https://news.example.com/members/feed.xml").is_private());
assert!(
classify_feed_privacy("https://feeds.example.com/aB3xK9zQ7mP2rT5wL8nD4vF6")
.is_private()
);
assert!(classify_feed_privacy(
"https://feeds.example.com/1f2e3d4c-5b6a-7089-90ab-cdef01234567"
)
.is_private());
}
#[test]
fn classify_privacy_catches_tokened_filenames_on_unknown_hosts() {
assert!(classify_feed_privacy(
"https://cdn.somepod.io/f/a1b2c3d4e5f60718293a4b5c6d7e8f90.xml"
)
.is_private());
assert!(classify_feed_privacy(
"https://dcs.megaphone.example/network/a1b2c3d4e5f60718293a4b5c6d7e8f90.rss"
)
.is_private());
assert!(classify_feed_privacy(
"https://brandnew.example/feed/1f2e3d4c-5b6a-7089-90ab-cdef01234567.xml"
)
.is_private());
assert!(classify_feed_privacy(
"https://x.example/feed-1f2e3d4c-5b6a-7089-90ab-cdef01234567"
)
.is_private());
assert!(classify_feed_privacy(
"https://x.example/1f2e3d4c-5b6a-7089-90ab-cdef01234567.rss"
)
.is_private());
assert!(classify_feed_privacy("https://x.example/feed/9f8e7d6c5b4a3928.xml").is_private());
assert!(
classify_feed_privacy("https://cdn.pod.io/f/YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnc=")
.is_private()
);
}
#[test]
fn classify_privacy_allows_public_youtube_feeds() {
assert_eq!(
classify_feed_privacy(
"https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw"
),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy(
"https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1hCaGSI"
),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy(
"https://youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw"
),
FeedPrivacy::Public
);
assert!(classify_feed_privacy(
"https://www.youtube.com/feeds/videos.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4"
)
.is_private());
}
#[test]
fn classify_privacy_leaves_normal_public_feeds_public() {
assert_eq!(
classify_feed_privacy("https://example.com/feed.xml"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://blog.example.com/rss"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://blog.example.com/rss.xml"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://author.substack.com/feed"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://wordpress.example.com/feed/"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://example.org/atom.xml"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://example.com/2026/07/my-first-long-blog-post-title/feed"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://example.com/feed?keyword=rust"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://example.com/feed?p=2"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://example.com/feed?token="),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://example.com/my-first-long-blog-post.xml"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://example.com/episodes/ab12cd.xml"),
FeedPrivacy::Public
);
assert_eq!(
classify_feed_privacy("https://example.com/category/tech-news/feed.xml"),
FeedPrivacy::Public
);
assert_eq!(classify_feed_privacy("not a url"), FeedPrivacy::Public);
}
#[test]
fn backoff_grows_and_caps() {
assert_eq!(backoff_for(1), BACKOFF_BASE);
assert!(backoff_for(2) > backoff_for(1));
assert_eq!(backoff_for(100), BACKOFF_MAX);
}
}