feather_reader/feed.rs
1//! Feed fetch → parse → sanitize → store pipeline.
2//!
3//! This is the module that turns a feed URL into rows in the [`store`]. It is
4//! deliberately conservative on three axes, because a feed reader ingests
5//! **hostile, arbitrary web input**:
6//!
7//! 1. **Politeness** — fetches use a **conditional GET** (`If-None-Match` /
8//! `If-Modified-Since` from the stored `ETag` / `Last-Modified`), an
9//! identifiable [`crate::USER_AGENT`], a request timeout, and a simple
10//! exponential backoff hint on error. A `304 Not Modified` is a no-op:
11//! the feed is untouched apart from bumping its next-poll time.
12//! 2. **Safety** — every entry's HTML is run through [`ammonia`] before it is
13//! ever stored (and therefore before it is ever rendered). Scripts, event
14//! handlers, `javascript:` URLs, tracking pixels' dangerous attributes, and
15//! other XSS vectors are stripped. Feeds carrying `<script>` is not
16//! hypothetical; treat all feed HTML as untrusted.
17//! 3. **Robustness** — a malformed feed is **logged and skipped**, never a
18//! panic. One bad publisher must not take down the poller. All non-test
19//! paths use `Result`/`anyhow`; there are no `unwrap`/`expect`s.
20//!
21//! The normalized shape written to the store is the store's own
22//! [`store::NewFeed`] / [`store::NewEntry`]; dedup is by feed-native GUID via
23//! [`store::insert_entries`]'s `ON CONFLICT (feed_id, guid)` upsert.
24
25use std::time::Duration;
26
27use anyhow::{Context, Result};
28use chrono::{DateTime, SecondsFormat, Utc};
29use feed_rs::model::{Entry as RawEntry, Feed as RawFeed, Text};
30use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
31use reqwest::{Client, StatusCode};
32use sqlx::SqlitePool;
33use url::Url;
34
35use crate::store::{self, Feed, NewEntry, NewFeed};
36
37/// The privacy classification of a feed URL — the output of
38/// [`classify_feed_privacy`].
39///
40/// A **private** feed carries a secret (a token / key / auth credential) *in the
41/// URL itself* — a Substack `…/feed/private/<token>`, a Patreon `?auth=…` feed,
42/// a Ghost members `?uuid=` feed, a private-podcast token feed (Supercast,
43/// Supporting Cast, tokened Megaphone/Acast+), and so on. FeatherReader stores a
44/// user's subscriptions as records in their **public PDS** (unauthenticated
45/// `getRecord` / `listRecords` + the firehose, retained even after delete), so
46/// writing such a URL anywhere — the PDS *or* the server's own store — would risk
47/// leaking paid / members-only access.
48///
49/// **Decision (stopgap until atproto permissioned data ships): FeatherReader
50/// supports PUBLIC feeds only.** A feed classified [`FeedPrivacy::Private`] is
51/// *refused* at the add / import boundary — never fetched, never stored, never
52/// written to the PDS. There is no local-secret fallback and no override: the
53/// server holds NO private secret, ever, which keeps "your data lives in your
54/// public PDS" 100% honest.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum FeedPrivacy {
57 /// No secret detected in the URL; safe to add as a public feed.
58 Public,
59 /// A secret was detected in the URL. The `String` is a short, human-readable
60 /// reason (for logging / the skip report), e.g. `"substack private feed
61 /// path"`. The feed is refused — not fetched, stored, or written anywhere.
62 Private(String),
63}
64
65impl FeedPrivacy {
66 /// Whether this classification is [`FeedPrivacy::Private`].
67 pub fn is_private(&self) -> bool {
68 matches!(self, FeedPrivacy::Private(_))
69 }
70}
71
72/// Query-parameter *keys* that, when present with a long/opaque value, mark a URL
73/// as carrying a secret. Conservative and lowercase-compared; matched as a whole
74/// key (case-insensitive) so a benign `keyword=` does NOT trip `key`. This is the
75/// generic, provider-agnostic credential-in-query defence — it catches paid
76/// feeds from providers we've never heard of. Covers Patreon (`auth`), Ghost
77/// members (`uuid`), token-in-query feeds (`token`/`key`/`k`/`sig`/`hash`), and
78/// the long tail (`access`/`apikey`/`private`/`password`/`u`/`s`/`p`/…).
79const SECRET_QUERY_KEYS: &[&str] = &[
80 "token", "key", "auth", "secret", "k", "sig", "hash", "access", "apikey", "api_key", "uuid",
81 "id", "u", "s", "p", "private", "password", "pw",
82];
83
84/// Path *segments* / fragments that mark a private-feed URL shape. Matched as a
85/// case-insensitive substring of the (lowercased) path so `/feed/private/<tok>`,
86/// `/members/…`, `/subscriber/…` etc. all trip regardless of the token that
87/// follows. Provider-agnostic: many paid providers expose members-only feeds
88/// under one of these path conventions.
89const PRIVATE_PATH_MARKERS: &[&str] = &[
90 "/private/",
91 "/feed/private/",
92 "/rss/private/",
93 "/private-feed/",
94 "/members/",
95 "/member/",
96 "/subscriber/",
97];
98
99/// A KNOWN paid/private feed provider, matched by host substring + (optionally) a
100/// path/query marker specific to that provider. This is the **secondary**,
101/// precision layer on top of the generic heuristic — it names providers so the
102/// skip report can say *why* and so we catch provider-specific shapes that the
103/// generic pass might rate as borderline. Data-driven and easy to extend: add a
104/// row, don't touch the matcher.
105struct KnownProvider {
106 /// Substring that must appear in the URL host (lowercased), e.g.
107 /// `substack.com`.
108 host_contains: &'static str,
109 /// Optional lowercased substring that must appear in the path-or-query for a
110 /// match (a provider's private-feed marker). `None` = the host alone is
111 /// enough (used for hosts that ONLY serve private/tokened feeds).
112 marker: Option<&'static str>,
113 /// Human-readable reason for the skip report.
114 reason: &'static str,
115}
116
117/// The known-provider table. Covers paid NEWSLETTERS and private PODCASTS — an
118/// RSS reader ingests both. Kept intentionally verbose/commented so it's obvious
119/// what each row targets and safe to extend.
120const KNOWN_PROVIDERS: &[KnownProvider] = &[
121 // --- Paid newsletters -------------------------------------------------
122 // Substack private feed: author.substack.com/feed/private/<token>.
123 KnownProvider {
124 host_contains: "substack.com",
125 marker: Some("/feed/private/"),
126 reason: "Substack private feed",
127 },
128 // Patreon RSS carries the member token as ?auth=.
129 KnownProvider {
130 host_contains: "patreon.com",
131 marker: Some("auth="),
132 reason: "Patreon member feed",
133 },
134 // Ghost members feed: ?uuid=<member-uuid> (or a members token path).
135 KnownProvider {
136 host_contains: "ghost.io",
137 marker: Some("uuid="),
138 reason: "Ghost members feed",
139 },
140 // Buttondown paid RSS uses a per-subscriber token in the path/query.
141 KnownProvider {
142 host_contains: "buttondown.email",
143 marker: Some("token"),
144 reason: "Buttondown premium feed",
145 },
146 KnownProvider {
147 host_contains: "buttondown.com",
148 marker: Some("token"),
149 reason: "Buttondown premium feed",
150 },
151 // Beehiiv premium RSS carries a subscriber token.
152 KnownProvider {
153 host_contains: "beehiiv.com",
154 marker: Some("token"),
155 reason: "Beehiiv premium feed",
156 },
157 // Memberful-gated feeds (host or ?auth token).
158 KnownProvider {
159 host_contains: "memberful.com",
160 marker: None,
161 reason: "Memberful members feed",
162 },
163 // Pico / Steady member feeds.
164 KnownProvider {
165 host_contains: "pico.link",
166 marker: None,
167 reason: "Pico member feed",
168 },
169 KnownProvider {
170 host_contains: "steadyhq.com",
171 marker: None,
172 reason: "Steady member feed",
173 },
174 // --- Private podcasts -------------------------------------------------
175 // Supercast private podcast feeds (host serves tokened member feeds only).
176 KnownProvider {
177 host_contains: "supercast.com",
178 marker: None,
179 reason: "Supercast private podcast",
180 },
181 KnownProvider {
182 host_contains: "supercast.tech",
183 marker: None,
184 reason: "Supercast private podcast",
185 },
186 // Supporting Cast private podcast feeds (supportingcast.fm).
187 KnownProvider {
188 host_contains: "supportingcast.fm",
189 marker: None,
190 reason: "Supporting Cast private podcast",
191 },
192 // RedCircle private/exclusive feeds.
193 KnownProvider {
194 host_contains: "redcircle.com",
195 marker: Some("private"),
196 reason: "RedCircle private podcast",
197 },
198 // Private/tokened Megaphone, Acast+, and Omny feeds carry an access token.
199 KnownProvider {
200 host_contains: "megaphone.fm",
201 marker: Some("token"),
202 reason: "Megaphone private podcast",
203 },
204 KnownProvider {
205 host_contains: "acast.com",
206 marker: Some("token"),
207 reason: "Acast+ private podcast",
208 },
209 KnownProvider {
210 host_contains: "omny.fm",
211 marker: Some("token"),
212 reason: "Omny private podcast",
213 },
214 // Apple / Spotify subscriber podcast feeds carry a per-listener token.
215 KnownProvider {
216 host_contains: "podcasts.apple.com",
217 marker: Some("token"),
218 reason: "Apple subscriber podcast",
219 },
220 KnownProvider {
221 host_contains: "spotify.com",
222 marker: Some("token"),
223 reason: "Spotify subscriber podcast",
224 },
225];
226
227/// Classify whether a feed URL carries a secret credential in the URL itself.
228///
229/// Returns [`FeedPrivacy::Private`] (with a reason) when the URL looks like it
230/// embeds a token / key / auth credential, else [`FeedPrivacy::Public`].
231///
232/// **Design — provider-agnostic first.** The primary defence is a generic
233/// credential-in-URL heuristic that catches paid feeds from *any* provider, not
234/// just the ones we've named; a secondary known-provider table adds precision
235/// (and a nicer reason) for the common paid newsletters and private podcasts. We
236/// deliberately **bias toward flagging**: a false-positive block of a public feed
237/// is low-harm (the user just can't add that one feed yet), whereas a false
238/// negative would leak a paid secret onto the public network — high-harm.
239///
240/// Detection (any one is sufficient):
241/// 1. **Userinfo** — `https://user:pass@host/…` embeds credentials directly.
242/// 2. **Known private-feed path markers** — [`PRIVATE_PATH_MARKERS`]
243/// (`/feed/private/`, `/members/`, `/subscriber/`, …).
244/// 3. **Credential query parameters** — a query key in [`SECRET_QUERY_KEYS`] with
245/// a long/opaque value (Patreon `?auth=`, Ghost `?uuid=`, `?token=`, …).
246/// 4. **High-entropy opaque token segments** — a long opaque blob (hex ≥ 16,
247/// base64url ≥ 16, or a UUID) anywhere in the path or a query value, even
248/// without a telltale name.
249/// 5. **Known providers** — [`KNOWN_PROVIDERS`] host (+ optional marker) match.
250///
251/// An unparseable URL is treated as [`FeedPrivacy::Public`]: the add path rejects
252/// a malformed URL downstream anyway, and we don't want a parse quirk to
253/// misclassify.
254pub fn classify_feed_privacy(url: &str) -> FeedPrivacy {
255 let parsed = match Url::parse(url) {
256 Ok(u) => u,
257 // Can't parse => the add path will reject it as a malformed URL regardless.
258 Err(_) => return FeedPrivacy::Public,
259 };
260
261 // (1) Userinfo (`https://user:pass@host/…`) — credentials in the authority.
262 if !parsed.username().is_empty() || parsed.password().is_some() {
263 return FeedPrivacy::Private("credentials in URL userinfo".to_string());
264 }
265
266 let path_lower = parsed.path().to_ascii_lowercase();
267 let query_lower = parsed.query().unwrap_or("").to_ascii_lowercase();
268 let host_lower = parsed.host_str().unwrap_or("").to_ascii_lowercase();
269
270 // (0) Public-feed allowlist. A handful of large, fully-public feed shapes
271 // carry a high-entropy-looking id in the query that would otherwise trip the
272 // generic entropy heuristic. YouTube channel/playlist RSS
273 // (`youtube.com/feeds/videos.xml?channel_id=UC…` / `?playlist_id=PL…`) is the
274 // canonical way any reader subscribes to a channel — the id is a PUBLIC
275 // handle, not a secret. Allowlist it before the generic checks so we don't
276 // false-block it. (Userinfo / known-provider markers are checked below and
277 // still apply, so this can't be used to smuggle a credential.)
278 if is_public_youtube_feed(&host_lower, &path_lower, &parsed) {
279 return FeedPrivacy::Public;
280 }
281
282 // (5) Known-provider precision layer (checked early so its specific reason
283 // wins over a generic one). Host substring + optional path/query marker.
284 for kp in KNOWN_PROVIDERS {
285 if host_lower.contains(kp.host_contains) {
286 let marker_ok = match kp.marker {
287 None => true,
288 Some(m) => {
289 let m = m.to_ascii_lowercase();
290 path_lower.contains(&m) || query_lower.contains(&m)
291 }
292 };
293 if marker_ok {
294 return FeedPrivacy::Private(kp.reason.to_string());
295 }
296 }
297 }
298
299 // (2) Known private-feed path markers.
300 for marker in PRIVATE_PATH_MARKERS {
301 if path_lower.contains(marker) {
302 return FeedPrivacy::Private(format!("private feed path `{marker}`"));
303 }
304 }
305
306 // (3) Credential query parameters with a long/opaque value.
307 for (k, v) in parsed.query_pairs() {
308 let key = k.as_ref().to_ascii_lowercase();
309 if SECRET_QUERY_KEYS.iter().any(|sk| *sk == key) && value_is_opaque(v.as_ref()) {
310 return FeedPrivacy::Private(format!("credential query parameter `{key}`"));
311 }
312 }
313
314 // (4) High-entropy opaque token segments (an embedded key/token with no
315 // telltale name): hex ≥ 16, base64url ≥ 16, or a UUID, in path or query.
316 // The dominant real-world private-podcast shape delivers the token as a
317 // *filename* (`<token>.rss` / `<token>.xml`) or affixed inside a larger
318 // segment (`feed-<uuid>`), so [`segment_hides_secret`] strips a trailing feed
319 // extension AND scans dot/underscore/hyphen-delimited sub-parts, not just the
320 // whole segment.
321 for seg in parsed.path().split('/').filter(|s| !s.is_empty()) {
322 if segment_hides_secret(seg) {
323 return FeedPrivacy::Private("high-entropy token in path".to_string());
324 }
325 }
326 for (_, v) in parsed.query_pairs() {
327 if looks_like_embedded_secret(v.as_ref()) {
328 return FeedPrivacy::Private("high-entropy token in query".to_string());
329 }
330 }
331
332 FeedPrivacy::Public
333}
334
335/// Whether a *named* credential query value (`?token=<v>`) is long/opaque enough
336/// to count as a secret. A short value (e.g. an enum like `?token=none`) is not.
337/// We treat a UUID, or anything ≥ 8 chars that isn't an obvious plain word, as
338/// opaque — named credential keys already signal intent, so the length bar is
339/// low.
340fn value_is_opaque(v: &str) -> bool {
341 if v.is_empty() {
342 return false;
343 }
344 if is_uuid(v) {
345 return true;
346 }
347 v.len() >= 8
348}
349
350/// Heuristic: does `s` look like an embedded secret (an opaque high-entropy
351/// token), as opposed to an ordinary slug or word? Matches a UUID, a hex string
352/// ≥ 16 chars, or a base64url-ish blob ≥ 16 chars that mixes letters and digits
353/// and isn't a hyphen/dot slug. Deliberately strict so it only fires on things
354/// that really look like keys — the named-marker and known-provider checks cover
355/// the rest.
356fn looks_like_embedded_secret(s: &str) -> bool {
357 if is_uuid(s) {
358 return true;
359 }
360 // Hex string ≥ 16 chars (e.g. a 32-char MD5-ish token).
361 if s.len() >= 16 && s.chars().all(|c| c.is_ascii_hexdigit()) {
362 return true;
363 }
364 // base64url-ish opaque blob ≥ 16 chars.
365 if s.len() < 16 {
366 return false;
367 }
368 // Hyphen/dot-heavy slugs (`this-is-a-normal-post-title`) are not secrets.
369 let separators = s
370 .bytes()
371 .filter(|b| *b == b'-' || *b == b'.' || *b == b' ')
372 .count();
373 if separators >= 3 {
374 return false;
375 }
376 // Must be plausibly token-charset: base64url alphabet only. `=` is accepted
377 // as base64 padding (it only ever appears trailing on a real blob, so a
378 // padded base64url token like `…dnc=` still counts).
379 let token_chars = s
380 .chars()
381 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '='))
382 .count();
383 if token_chars < s.chars().count() {
384 return false;
385 }
386 let has_alpha = s.chars().any(|c| c.is_ascii_alphabetic());
387 let has_digit = s.chars().any(|c| c.is_ascii_digit());
388 if !(has_alpha && has_digit) {
389 // A token almost always mixes letters and digits; a pure-alpha long
390 // segment is far more likely to be a normal (if long) slug/word.
391 return false;
392 }
393 // Distinct-character ratio: real tokens use most of the alphabet, words
394 // repeat a small set. Require >= 10 distinct chars for a 16+ char blob.
395 let mut seen = std::collections::HashSet::new();
396 for c in s.chars() {
397 seen.insert(c.to_ascii_lowercase());
398 }
399 seen.len() >= 10
400}
401
402/// Known feed/file extensions a token filename may wear (`<token>.rss`,
403/// `<token>.xml`, …). Stripped before the whole-segment secret test so a
404/// tokened *filename* — the dominant private-podcast URL shape — is still caught.
405const FEED_EXTENSIONS: &[&str] = &["rss", "xml", "atom", "json", "rss20"];
406
407/// Whether a single path segment hides an embedded secret. Beyond the plain
408/// whole-segment [`looks_like_embedded_secret`] test, this also catches the two
409/// real-world shapes that wrap a token so the whole segment is no longer a clean
410/// blob:
411///
412/// 1. **Token-as-filename** — `<token>.rss` / `<token>.xml`: strip a trailing
413/// feed extension and re-test the stem.
414/// 2. **Token affixed inside a larger segment** — `feed-<uuid>`, `<token>.xml`,
415/// `pod_<hex32>`: split on `.`/`_`/`-` and test each sub-part, so a
416/// high-entropy blob delimited by an affix is still found.
417fn segment_hides_secret(seg: &str) -> bool {
418 if looks_like_embedded_secret(seg) {
419 return true;
420 }
421 // (1) Strip a trailing known feed extension and re-test the stem.
422 if let Some((stem, ext)) = seg.rsplit_once('.') {
423 if FEED_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
424 && looks_like_embedded_secret(stem)
425 {
426 return true;
427 }
428 }
429 // (2) A UUID embedded with an affix (`feed-<uuid>`, `<uuid>-audio`) — the
430 // `-` delimiters inside the UUID mean a naive split can't see it, so scan for
431 // a canonical UUID substring directly.
432 if contains_uuid(seg) {
433 return true;
434 }
435 // (3) Scan `.`/`_`/`-`-delimited sub-parts for a high-entropy blob affixed to
436 // an ordinary word (`pod_<hex32>`, `<hex32>.mp3`). Only fires on multi-part
437 // segments (a single-part segment was already covered by the whole-segment
438 // test above), so a plain `my-normal-post-slug` — whose parts are short
439 // dictionary words — can't trip it.
440 if seg.contains(['.', '_', '-']) {
441 for part in seg.split(['.', '_', '-']).filter(|p| !p.is_empty()) {
442 if looks_like_embedded_secret(part) {
443 return true;
444 }
445 }
446 }
447 false
448}
449
450/// Whether `s` contains a canonical 8-4-4-4-12 UUID as a substring (allowing an
451/// affix on either side, e.g. `feed-<uuid>` or `<uuid>-audio`). Slides a 36-char
452/// window over the string and tests each with [`is_uuid`].
453fn contains_uuid(s: &str) -> bool {
454 const UUID_LEN: usize = 36; // 8+4+4+4+12 + 4 hyphens.
455 let bytes = s.as_bytes();
456 if bytes.len() < UUID_LEN {
457 return false;
458 }
459 // ASCII-only window: a UUID is pure ASCII hex/hyphen, so byte indexing is
460 // safe here (a multi-byte char in the window just fails is_uuid).
461 (0..=bytes.len() - UUID_LEN).any(|i| s.get(i..i + UUID_LEN).map(is_uuid).unwrap_or(false))
462}
463
464/// Whether this is a PUBLIC YouTube channel/playlist RSS feed
465/// (`www.youtube.com/feeds/videos.xml?channel_id=UC…` or `?playlist_id=PL…`).
466/// The channel/playlist id is a public handle, not a credential, so these feeds
467/// must NOT be flagged by the generic entropy heuristic. We require the exact
468/// public host + feeds path + one of the two public id keys, so this narrow
469/// allowlist can't be abused to smuggle a `?token=` past classification.
470fn is_public_youtube_feed(host_lower: &str, path_lower: &str, parsed: &Url) -> bool {
471 let host_ok = host_lower == "youtube.com"
472 || host_lower == "www.youtube.com"
473 || host_lower.ends_with(".youtube.com");
474 if !host_ok || !path_lower.starts_with("/feeds/videos.xml") {
475 return false;
476 }
477 // Only the public id keys may appear; a `token`/`auth`/… key means treat it
478 // as a normal (potentially private) URL and let the checks below run.
479 parsed.query_pairs().all(|(k, _)| {
480 let k = k.as_ref().to_ascii_lowercase();
481 k == "channel_id" || k == "playlist_id" || k == "user"
482 })
483}
484
485/// Whether `s` is a canonical 8-4-4-4-12 hyphenated UUID (any hex case).
486fn is_uuid(s: &str) -> bool {
487 let groups = [8usize, 4, 4, 4, 12];
488 let parts: Vec<&str> = s.split('-').collect();
489 if parts.len() != groups.len() {
490 return false;
491 }
492 parts
493 .iter()
494 .zip(groups.iter())
495 .all(|(p, &n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()))
496}
497
498/// How long a single feed fetch may take before we give up.
499const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
500
501/// Per-read idle timeout: cap the wait for the *next* body chunk, so a server
502/// that trickles bytes forever can't tie up a fetch under the total timeout.
503const READ_TIMEOUT: Duration = Duration::from_secs(15);
504
505/// Base backoff applied after a failed poll; the caller multiplies this by the
506/// feed's consecutive-error count (with a ceiling) to space out retries.
507const BACKOFF_BASE: Duration = Duration::from_secs(300);
508
509/// Ceiling on backoff so a persistently broken feed still gets retried daily.
510const BACKOFF_MAX: Duration = Duration::from_secs(24 * 3600);
511
512/// The outcome of polling a single feed. Lets the scheduler decide how to
513/// reschedule (and lets tests assert what happened) without inspecting the DB.
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub enum PollOutcome {
516 /// The feed was fetched, parsed, and stored. `new_entries` is the number of
517 /// entries inserted or updated by this poll.
518 Updated { new_entries: u64 },
519 /// The server returned `304 Not Modified` — nothing changed, nothing stored.
520 NotModified,
521 /// The fetch or parse failed; the feed was left intact and skipped. Carries
522 /// the suggested backoff before the next attempt. Never a panic.
523 Failed { backoff: Duration },
524}
525
526/// Build a `reqwest::Client` configured for polite **and safe** feed fetching.
527///
528/// Callers should build this **once** and share it (connection pooling), then
529/// hand a reference to [`poll_feed`]. Kept here so the fetch policy (UA,
530/// timeout, redirect behaviour) lives with the code that depends on it.
531///
532/// Auto-redirect is **disabled** on purpose: feed URLs are untrusted, so
533/// redirects are followed manually by [`crate::net::guarded_get`], which
534/// re-validates the scheme + resolved IP of every hop (SSRF defence). A client
535/// that silently followed redirects could be bounced onto `169.254.169.254` or
536/// `127.0.0.1` between the guard's check and the connect.
537pub fn build_client() -> Result<Client> {
538 Client::builder()
539 .user_agent(crate::USER_AGENT)
540 .timeout(FETCH_TIMEOUT)
541 .read_timeout(READ_TIMEOUT)
542 // No auto-redirect: net::guarded_get follows + re-validates each hop.
543 .redirect(reqwest::redirect::Policy::none())
544 .build()
545 .context("failed to build feed HTTP client")
546}
547
548/// Compute the backoff for the `n`th consecutive failure (1-based), clamped to
549/// [`BACKOFF_MAX`]. Exponential in the error count so transient blips retry soon
550/// while a durably-broken feed backs off toward daily.
551///
552/// The scheduler passes the feed's persisted `consecutive_errors` count (see
553/// [`crate::store::bump_feed_errors`]) so a feed that keeps failing actually
554/// climbs toward [`BACKOFF_MAX`] instead of retrying at the floor forever.
555pub fn backoff_for(consecutive_errors: u32) -> Duration {
556 let n = consecutive_errors.max(1);
557 // Saturating shift: base * 2^(n-1), capped. Avoids overflow for large n.
558 let factor = 1u64.checked_shl(n.saturating_sub(1)).unwrap_or(u64::MAX);
559 let secs = BACKOFF_BASE
560 .as_secs()
561 .saturating_mul(factor)
562 .min(BACKOFF_MAX.as_secs());
563 Duration::from_secs(secs)
564}
565
566/// Fetch, parse, sanitize, normalize, and store a single feed.
567///
568/// Performs a conditional GET using the feed's stored `ETag` / `Last-Modified`.
569/// On `304` it returns [`PollOutcome::NotModified`] without touching entries. On
570/// `200` it parses with `feed-rs`, sanitizes every entry's HTML with `ammonia`,
571/// upserts the feed row (carrying the fresh validators) and inserts new entries
572/// (deduped by GUID). Any fetch/parse error is logged and returned as
573/// [`PollOutcome::Failed`] — it never panics and never propagates as `Err` for
574/// a merely-broken feed, so one bad publisher can't stall the scheduler.
575///
576/// `Err` is reserved for *store* failures (a broken local DB is a real error the
577/// caller should see), not for feed misbehaviour.
578///
579/// `max_entries_per_feed` caps how many entries this feed retains after insert
580/// (newest N by published date); `<= 0` disables the per-feed trim.
581pub async fn poll_feed(
582 pool: &SqlitePool,
583 client: &Client,
584 feed: &Feed,
585 max_entries_per_feed: i64,
586) -> Result<PollOutcome> {
587 // --- conditional GET (through the SSRF guard) ----------------------------
588 // The guard re-validates the scheme + resolved IP of the target and of every
589 // redirect hop, so a subscribed feed can't bounce the poller onto an
590 // internal address (cloud metadata / loopback). Conditional-GET validators
591 // ride along as extra headers.
592 let mut extra: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)> = Vec::new();
593 if let Some(etag) = feed.etag.as_deref() {
594 if let Ok(v) = reqwest::header::HeaderValue::from_str(etag) {
595 extra.push((IF_NONE_MATCH, v));
596 }
597 }
598 if let Some(lm) = feed.last_modified.as_deref() {
599 if let Ok(v) = reqwest::header::HeaderValue::from_str(lm) {
600 extra.push((IF_MODIFIED_SINCE, v));
601 }
602 }
603
604 let resp = match crate::net::guarded_get(client, &feed.url, &extra).await {
605 Ok(r) => r,
606 Err(e) => {
607 tracing::warn!(feed = %feed.url, error = %e, "feed fetch failed (or blocked by SSRF guard)");
608 return Ok(PollOutcome::Failed {
609 backoff: backoff_for(1),
610 });
611 }
612 };
613
614 let status = resp.status();
615 if status == StatusCode::NOT_MODIFIED {
616 tracing::debug!(feed = %feed.url, "feed not modified (304)");
617 // Bump last_polled/next_poll only; leave validators + entries untouched.
618 touch_polled(pool, &feed.url, None, None)
619 .await
620 .with_context(|| format!("touch_polled after 304 for {}", feed.url))?;
621 return Ok(PollOutcome::NotModified);
622 }
623 if !status.is_success() {
624 tracing::warn!(feed = %feed.url, %status, "feed returned non-success status");
625 return Ok(PollOutcome::Failed {
626 backoff: backoff_for(1),
627 });
628 }
629
630 // Capture validators for the *next* conditional GET before consuming body.
631 let new_etag = header_str(resp.headers().get(ETAG));
632 let new_last_modified = header_str(resp.headers().get(LAST_MODIFIED));
633
634 // Stream the body with a hard byte cap, aborting mid-stream if it exceeds
635 // it. We never trust Content-Length: reqwest's gzip layer strips it, so a
636 // small gzip bomb could otherwise inflate to GBs before any size check.
637 let body = match crate::net::read_capped(resp).await {
638 Ok(b) => b,
639 Err(e) => {
640 tracing::warn!(feed = %feed.url, error = %e, "feed body rejected (too large / read error)");
641 return Ok(PollOutcome::Failed {
642 backoff: backoff_for(1),
643 });
644 }
645 };
646
647 // --- parse (malformed feed => log + skip, never panic) -------------------
648 let parsed = match feed_rs::parser::parse(&body[..]) {
649 Ok(f) => f,
650 Err(e) => {
651 tracing::warn!(feed = %feed.url, error = %e, "malformed feed; skipping");
652 return Ok(PollOutcome::Failed {
653 backoff: backoff_for(1),
654 });
655 }
656 };
657
658 // --- normalize + sanitize ------------------------------------------------
659 let (title, site_url) = feed_metadata(&parsed);
660 let new_feed = NewFeed {
661 url: feed.url.clone(),
662 title,
663 site_url,
664 etag: new_etag,
665 last_modified: new_last_modified,
666 last_polled: Some(now_rfc3339()),
667 next_poll: None, // the scheduler owns cadence; leave it to set next_poll.
668 };
669
670 let entries: Vec<NewEntry> = parsed.entries.iter().map(normalize_entry).collect();
671
672 // --- store (a store failure IS a real error) -----------------------------
673 let feed_id = store::upsert_feed(pool, &new_feed)
674 .await
675 .with_context(|| format!("upsert_feed for {}", feed.url))?;
676 let n = store::insert_entries(pool, feed_id, &entries, max_entries_per_feed)
677 .await
678 .with_context(|| format!("insert_entries for {}", feed.url))?;
679
680 tracing::info!(feed = %feed.url, entries = n, "feed polled");
681 Ok(PollOutcome::Updated { new_entries: n })
682}
683
684/// Bump `last_polled` (and optionally validators) without changing entries —
685/// used on the `304 Not Modified` path.
686async fn touch_polled(
687 pool: &SqlitePool,
688 url: &str,
689 etag: Option<String>,
690 last_modified: Option<String>,
691) -> Result<()> {
692 // upsert_feed's ON CONFLICT overwrites etag/last_modified unconditionally,
693 // so on a 304 we re-supply the existing validators (fetched from the row) to
694 // avoid clobbering them. The caller passes `None` to mean "keep current".
695 let existing = store::get_feed_by_url(pool, url).await?;
696 let (etag, last_modified) = match existing {
697 Some(f) => (etag.or(f.etag), last_modified.or(f.last_modified)),
698 None => (etag, last_modified),
699 };
700 let nf = NewFeed {
701 url: url.to_string(),
702 etag,
703 last_modified,
704 last_polled: Some(now_rfc3339()),
705 ..Default::default()
706 };
707 store::upsert_feed(pool, &nf).await?;
708 Ok(())
709}
710
711/// Extract `(title, site_url)` from a parsed feed. `site_url` prefers an
712/// `alternate`/no-rel HTML link over the feed's self link.
713fn feed_metadata(parsed: &RawFeed) -> (Option<String>, Option<String>) {
714 let title = parsed.title.as_ref().map(text_plain);
715 let site_url = parsed
716 .links
717 .iter()
718 // Prefer an explicit human-facing page: rel="alternate" or no rel at all.
719 .find(|l| {
720 l.rel.as_deref() == Some("alternate")
721 || (l.rel.is_none()
722 && l.media_type.as_deref() != Some("application/rss+xml")
723 && l.media_type.as_deref() != Some("application/atom+xml"))
724 })
725 .or_else(|| {
726 parsed
727 .links
728 .iter()
729 .find(|l| l.rel.as_deref() != Some("self"))
730 })
731 .or_else(|| parsed.links.first())
732 .map(|l| l.href.clone());
733 (title, site_url)
734}
735
736/// Turn a parsed [`RawEntry`] into the store's [`NewEntry`], sanitizing HTML.
737///
738/// Content preference: full `content` body, else `summary`. Whichever is chosen
739/// is **always** passed through [`sanitize_html`] before storage. GUID falls
740/// back to the entry link, then to a stable hash of title+link, so an entry
741/// missing an `id` still deduplicates instead of being re-inserted forever.
742fn normalize_entry(e: &RawEntry) -> NewEntry {
743 let url = entry_link(e);
744 let content_html = e
745 .content
746 .as_ref()
747 .and_then(|c| c.body.as_deref())
748 .or_else(|| e.summary.as_ref().map(|t| t.content.as_str()))
749 .map(sanitize_html);
750
751 // GUID may use the raw link (dedup key only, never rendered), so prefer the
752 // entry's first raw link for identity even when it's not a safe href.
753 let guid = if !e.id.trim().is_empty() {
754 e.id.trim().to_string()
755 } else if let Some(link) = raw_entry_link(e) {
756 link
757 } else {
758 // Last resort: derive a stable id so re-fetches dedup rather than dupe.
759 stable_guid(e)
760 };
761
762 NewEntry {
763 guid,
764 url,
765 title: e.title.as_ref().map(text_plain),
766 author: entry_author(e),
767 published: entry_time(e),
768 content_html,
769 fetched_at: None, // store defaults to "now".
770 }
771}
772
773/// The raw best-permalink URL for an entry (no scheme filtering) — used only as
774/// a dedup GUID, never rendered as an href.
775fn raw_entry_link(e: &RawEntry) -> Option<String> {
776 e.links
777 .iter()
778 .find(|l| l.rel.as_deref() == Some("alternate") || l.rel.is_none())
779 .or_else(|| e.links.first())
780 .map(|l| l.href.clone())
781}
782
783/// The best display/permalink URL for an entry, **scheme-allow-listed** so it is
784/// safe to render as an `href`: prefer `rel="alternate"` or a no-rel link, else
785/// the first link — but only if it is an `http`/`https` URL. A `javascript:` or
786/// `data:` permalink (a stored-XSS vector that survives HTML escaping, since it
787/// carries no HTML-special characters) is dropped here at ingest, before it can
788/// ever reach the store or a template.
789fn entry_link(e: &RawEntry) -> Option<String> {
790 raw_entry_link(e).and_then(|href| crate::net::safe_link(&href))
791}
792
793/// First author name, if any.
794fn entry_author(e: &RawEntry) -> Option<String> {
795 e.authors.first().map(|p| p.name.clone())
796}
797
798/// Best publication time (published, else updated) as an RFC3339 string.
799fn entry_time(e: &RawEntry) -> Option<String> {
800 e.published.or(e.updated).map(fmt_time)
801}
802
803/// Extract the plain string content of a feed [`Text`] node.
804fn text_plain(t: &Text) -> String {
805 t.content.trim().to_string()
806}
807
808/// Sanitize hostile feed HTML with ammonia's whitelist cleaner. Safe on plain
809/// text too (it will simply escape/strip as needed), so it's applied to *all*
810/// entry bodies unconditionally.
811fn sanitize_html(raw: &str) -> String {
812 ammonia::clean(raw)
813}
814
815/// Format a chrono timestamp as RFC3339 (UTC, seconds precision) to match the
816/// store's string columns.
817fn fmt_time(dt: DateTime<Utc>) -> String {
818 dt.to_rfc3339_opts(SecondsFormat::Secs, true)
819}
820
821/// "Now" in the store's RFC3339 shape.
822fn now_rfc3339() -> String {
823 Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
824}
825
826/// A stable GUID derived from an entry's title + first link, for feeds that
827/// supply neither an id nor a usable link id. Deterministic so re-fetches dedup.
828fn stable_guid(e: &RawEntry) -> String {
829 use std::hash::{Hash, Hasher};
830 let mut h = std::collections::hash_map::DefaultHasher::new();
831 e.title.as_ref().map(|t| t.content.as_str()).hash(&mut h);
832 e.links.first().map(|l| l.href.as_str()).hash(&mut h);
833 e.summary.as_ref().map(|s| s.content.as_str()).hash(&mut h);
834 format!("featherreader:synthetic:{:016x}", h.finish())
835}
836
837/// Decode an HTTP header value to an owned `String`, dropping non-UTF-8 values.
838fn header_str(v: Option<&reqwest::header::HeaderValue>) -> Option<String> {
839 v.and_then(|h| h.to_str().ok()).map(str::to_string)
840}
841
842/// Discover a feed URL from a site's HTML via
843/// `<link rel="alternate" type="application/rss+xml|atom+xml" href="…">`.
844///
845/// Returns the first RSS/Atom autodiscovery link found, resolved against the
846/// page URL if the `href` is relative. This is what lets a user paste a *site*
847/// URL and have FeatherReader find the actual feed ("subscribe by URL").
848/// Returns `None` if the HTML carries no autodiscovery link.
849///
850/// The `base` is the URL the HTML was fetched from, used to resolve relative
851/// `href`s. Pass `None` to only accept absolute hrefs.
852pub fn discover_feed(site_html: &str, base: Option<&Url>) -> Option<Url> {
853 // Parse the HTML with html5ever (via ammonia's dependency graph is separate;
854 // use a light hand-rolled scan over <link> tags to avoid a new dependency).
855 // We look for <link ...> elements whose rel contains "alternate" and whose
856 // type is an RSS/Atom feed media type, and take the href.
857 for tag in link_tags(site_html) {
858 let rel = attr(&tag, "rel").unwrap_or_default().to_ascii_lowercase();
859 let typ = attr(&tag, "type").unwrap_or_default().to_ascii_lowercase();
860 let is_feed_type = typ.contains("application/rss+xml")
861 || typ.contains("application/atom+xml")
862 || typ.contains("application/feed+json")
863 || typ.contains("application/json");
864 // rel="alternate" is the standard; be lenient and also accept a bare
865 // feed type with any rel, but require the feed media type either way.
866 let rel_ok = rel.split_whitespace().any(|r| r == "alternate") || rel.is_empty();
867 if is_feed_type && rel_ok {
868 if let Some(href) = attr(&tag, "href") {
869 let href = href.trim();
870 if href.is_empty() {
871 continue;
872 }
873 // Absolute URL wins directly; otherwise resolve against `base`.
874 if let Ok(u) = Url::parse(href) {
875 return Some(u);
876 }
877 if let Some(b) = base {
878 if let Ok(u) = b.join(href) {
879 return Some(u);
880 }
881 }
882 }
883 }
884 }
885 None
886}
887
888/// Extract the raw text of every `<link ...>` tag (self-closing or not) from an
889/// HTML string. A deliberately small, allocation-light scan — feed
890/// autodiscovery does not need a full DOM, and avoiding one keeps the dependency
891/// surface minimal (design bias: boring, small-dependency).
892fn link_tags(html: &str) -> Vec<String> {
893 let mut out = Vec::new();
894 let bytes = html.as_bytes();
895 let lower = html.to_ascii_lowercase();
896 let mut search_from = 0usize;
897 while let Some(rel_idx) = lower[search_from..].find("<link") {
898 let start = search_from + rel_idx;
899 // Ensure it's a tag boundary ("<link" followed by whitespace, '>' or '/').
900 let after = bytes.get(start + 5).copied();
901 let boundary = matches!(after, Some(b) if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>' || b == b'/');
902 if !boundary {
903 search_from = start + 5;
904 continue;
905 }
906 // Find the closing '>' for this tag.
907 if let Some(end_rel) = html[start..].find('>') {
908 let end = start + end_rel;
909 out.push(html[start..=end].to_string());
910 search_from = end + 1;
911 } else {
912 break;
913 }
914 }
915 out
916}
917
918/// Read an attribute value from a single tag string, handling both single- and
919/// double-quoted values. Case-insensitive attribute name match.
920fn attr(tag: &str, name: &str) -> Option<String> {
921 let lower = tag.to_ascii_lowercase();
922 let needle = format!("{name}=");
923 let mut from = 0usize;
924 while let Some(rel) = lower[from..].find(&needle) {
925 let name_start = from + rel;
926 // Guard against matching a suffix of a longer attribute name
927 // (e.g. matching "type=" inside "mytype="): the char before must be a
928 // tag/whitespace boundary.
929 let ok_prefix = name_start == 0
930 || matches!(
931 tag.as_bytes().get(name_start - 1),
932 Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') | Some(b'<')
933 );
934 let val_start = name_start + needle.len();
935 if !ok_prefix {
936 from = val_start;
937 continue;
938 }
939 let rest = &tag[val_start..];
940 let quote = rest.chars().next();
941 let value = match quote {
942 Some('"') => rest[1..].split('"').next(),
943 Some('\'') => rest[1..].split('\'').next(),
944 // Unquoted: read up to whitespace, '>' or '/'.
945 _ => rest
946 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
947 .next(),
948 };
949 return value.map(str::to_string);
950 }
951 None
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957
958 const RSS_SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
959<rss version="2.0">
960 <channel>
961 <title>Example RSS Feed</title>
962 <link>https://example.com/</link>
963 <description>An example feed for tests</description>
964 <item>
965 <title>First post</title>
966 <link>https://example.com/first</link>
967 <guid>https://example.com/first</guid>
968 <author>alice@example.com (Alice)</author>
969 <pubDate>Fri, 10 Jul 2026 08:00:00 GMT</pubDate>
970 <description><![CDATA[<p>Hello <b>world</b>.</p><script>alert('xss')</script><img src="x" onerror="alert(1)">]]></description>
971 </item>
972 <item>
973 <title>Second post</title>
974 <link>https://example.com/second</link>
975 <guid>guid-second</guid>
976 <pubDate>Sat, 11 Jul 2026 08:00:00 GMT</pubDate>
977 <description><![CDATA[<a href="javascript:alert(1)">click</a><a href="https://ok.example/">ok</a>]]></description>
978 </item>
979 </channel>
980</rss>"#;
981
982 const ATOM_SAMPLE: &str = r#"<?xml version="1.0" encoding="utf-8"?>
983<feed xmlns="http://www.w3.org/2005/Atom">
984 <title>Example Atom Feed</title>
985 <link rel="alternate" href="https://atom.example.com/"/>
986 <link rel="self" href="https://atom.example.com/feed.xml"/>
987 <id>urn:uuid:feed-1</id>
988 <updated>2026-07-11T08:00:00Z</updated>
989 <entry>
990 <title>Atom entry</title>
991 <id>urn:uuid:entry-1</id>
992 <link rel="alternate" href="https://atom.example.com/a"/>
993 <author><name>Bob</name></author>
994 <updated>2026-07-11T08:00:00Z</updated>
995 <content type="html"><![CDATA[<p>Safe <em>text</em>.</p><script>steal()</script><iframe src="evil"></iframe>]]></content>
996 </entry>
997</feed>"#;
998
999 /// Parse a static RSS sample through feed-rs + our normalize/sanitize path
1000 /// (no network) and assert the entries come out sanitized and well-shaped.
1001 #[test]
1002 fn rss_parses_and_sanitizes() {
1003 let parsed = feed_rs::parser::parse(RSS_SAMPLE.as_bytes()).expect("RSS should parse");
1004 assert_eq!(
1005 parsed.title.as_ref().map(text_plain).as_deref(),
1006 Some("Example RSS Feed")
1007 );
1008 assert_eq!(parsed.entries.len(), 2);
1009
1010 let (title, site) = feed_metadata(&parsed);
1011 assert_eq!(title.as_deref(), Some("Example RSS Feed"));
1012 assert_eq!(site.as_deref(), Some("https://example.com/"));
1013
1014 let e0 = normalize_entry(&parsed.entries[0]);
1015 assert_eq!(e0.guid, "https://example.com/first");
1016 assert_eq!(e0.title.as_deref(), Some("First post"));
1017 assert_eq!(e0.url.as_deref(), Some("https://example.com/first"));
1018 assert!(e0.published.is_some());
1019 let html0 = e0.content_html.expect("content present");
1020 // Sanitized: benign markup kept, script + onerror stripped.
1021 assert!(html0.contains("Hello"));
1022 assert!(html0.contains("<b>world</b>") || html0.contains("<b>"));
1023 assert!(!html0.to_ascii_lowercase().contains("<script"));
1024 assert!(!html0.to_ascii_lowercase().contains("onerror"));
1025 assert!(!html0.to_ascii_lowercase().contains("alert"));
1026
1027 // Second entry: javascript: URL scrubbed, safe link kept.
1028 let e1 = normalize_entry(&parsed.entries[1]);
1029 assert_eq!(e1.guid, "guid-second");
1030 let html1 = e1.content_html.expect("content present");
1031 assert!(!html1.to_ascii_lowercase().contains("javascript:"));
1032 assert!(html1.contains("https://ok.example/"));
1033 }
1034
1035 /// Same, for an Atom sample: alternate link is the site URL, dangerous
1036 /// elements are stripped from entry content.
1037 #[test]
1038 fn atom_parses_and_sanitizes() {
1039 let parsed = feed_rs::parser::parse(ATOM_SAMPLE.as_bytes()).expect("Atom should parse");
1040 let (title, site) = feed_metadata(&parsed);
1041 assert_eq!(title.as_deref(), Some("Example Atom Feed"));
1042 // alternate link preferred over rel="self".
1043 assert_eq!(site.as_deref(), Some("https://atom.example.com/"));
1044
1045 assert_eq!(parsed.entries.len(), 1);
1046 let e = normalize_entry(&parsed.entries[0]);
1047 assert_eq!(e.guid, "urn:uuid:entry-1");
1048 assert_eq!(e.title.as_deref(), Some("Atom entry"));
1049 assert_eq!(e.author.as_deref(), Some("Bob"));
1050 assert_eq!(e.url.as_deref(), Some("https://atom.example.com/a"));
1051 let html = e.content_html.expect("content present");
1052 assert!(html.contains("Safe"));
1053 assert!(!html.to_ascii_lowercase().contains("<script"));
1054 assert!(!html.to_ascii_lowercase().contains("<iframe"));
1055 }
1056
1057 #[test]
1058 fn discover_finds_rss_link() {
1059 let html = r#"<!doctype html><html><head>
1060 <title>Blog</title>
1061 <link rel="stylesheet" href="/style.css">
1062 <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed.xml">
1063 </head><body>hi</body></html>"#;
1064 let base = Url::parse("https://blog.example.com/").unwrap();
1065 let found = discover_feed(html, Some(&base)).expect("should discover feed");
1066 assert_eq!(found.as_str(), "https://blog.example.com/feed.xml");
1067 }
1068
1069 #[test]
1070 fn discover_finds_atom_absolute_link() {
1071 let html = r#"<head><link rel="alternate" type="application/atom+xml" href="https://x.example/atom"></head>"#;
1072 let found = discover_feed(html, None).expect("should discover absolute feed");
1073 assert_eq!(found.as_str(), "https://x.example/atom");
1074 }
1075
1076 #[test]
1077 fn discover_returns_none_without_feed_link() {
1078 let html =
1079 r#"<head><link rel="stylesheet" href="/s.css"><link rel="icon" href="/f.ico"></head>"#;
1080 assert!(discover_feed(html, None).is_none());
1081 }
1082
1083 #[test]
1084 fn synthetic_guid_is_stable_and_dedups() {
1085 // An item with neither guid nor link: feed-rs will hash the link (absent)
1086 // to a UUID id, but to exercise *our* synthetic fallback we clear the id
1087 // on the parsed entry and confirm normalize yields a deterministic guid.
1088 let xml = r#"<?xml version="1.0"?><rss version="2.0"><channel>
1089 <title>t</title>
1090 <item><title>only a title</title><description>body</description></item>
1091 </channel></rss>"#;
1092 let mut parsed = feed_rs::parser::parse(xml.as_bytes()).expect("parse");
1093 parsed.entries[0].id.clear();
1094 parsed.entries[0].links.clear();
1095 let g1 = normalize_entry(&parsed.entries[0]).guid;
1096 let g2 = normalize_entry(&parsed.entries[0]).guid;
1097 assert_eq!(g1, g2);
1098 assert!(g1.starts_with("featherreader:synthetic:"));
1099 }
1100
1101 #[test]
1102 fn entry_link_scheme_allowlist_neutralizes_javascript() {
1103 // An entry whose only link is a javascript: URL must yield no href.
1104 let xml = r#"<?xml version="1.0"?><rss version="2.0"><channel>
1105 <title>t</title>
1106 <item>
1107 <title>evil</title>
1108 <link>javascript:alert(document.domain)</link>
1109 <guid>evil-1</guid>
1110 </item>
1111 </channel></rss>"#;
1112 let parsed = feed_rs::parser::parse(xml.as_bytes()).expect("parse");
1113 let e = normalize_entry(&parsed.entries[0]);
1114 // url is dropped (not a safe http(s) link)…
1115 assert_eq!(e.url, None);
1116 // …but the entry still dedups (guid preserved from <guid>).
1117 assert_eq!(e.guid, "evil-1");
1118
1119 // A data: URL is likewise dropped.
1120 let xml2 = r#"<?xml version="1.0"?><rss version="2.0"><channel>
1121 <title>t</title>
1122 <item><title>d</title><link>data:text/html,<script>1</script></link><guid>d1</guid></item>
1123 </channel></rss>"#;
1124 let parsed2 = feed_rs::parser::parse(xml2.as_bytes()).expect("parse");
1125 let e2 = normalize_entry(&parsed2.entries[0]);
1126 assert_eq!(e2.url, None);
1127
1128 // A normal https link survives.
1129 let xml3 = r#"<?xml version="1.0"?><rss version="2.0"><channel>
1130 <title>t</title>
1131 <item><title>ok</title><link>https://ok.example/post</link><guid>ok1</guid></item>
1132 </channel></rss>"#;
1133 let parsed3 = feed_rs::parser::parse(xml3.as_bytes()).expect("parse");
1134 let e3 = normalize_entry(&parsed3.entries[0]);
1135 assert_eq!(e3.url.as_deref(), Some("https://ok.example/post"));
1136 }
1137
1138 #[test]
1139 fn classify_privacy_flags_secret_urls_across_providers() {
1140 // --- Known providers: newsletters ---
1141 // Substack private feed path.
1142 assert!(
1143 classify_feed_privacy("https://author.substack.com/feed/private/deadbeefcafe1234")
1144 .is_private()
1145 );
1146 // Patreon ?auth= member feed.
1147 assert!(classify_feed_privacy(
1148 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4"
1149 )
1150 .is_private());
1151 // Ghost members feed via ?uuid=.
1152 assert!(classify_feed_privacy(
1153 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567"
1154 )
1155 .is_private());
1156
1157 // --- Known providers: private podcasts ---
1158 // Supporting Cast tokened podcast feed.
1159 assert!(classify_feed_privacy(
1160 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01"
1161 )
1162 .is_private());
1163 // Supercast private podcast (host alone is enough).
1164 assert!(classify_feed_privacy("https://feeds.supercast.com/12345/rss").is_private());
1165
1166 // --- Generic, provider-agnostic heuristic ---
1167 // Named credential query params with an opaque value.
1168 assert!(
1169 classify_feed_privacy("https://example.com/feed?token=Zm9vYmFyc2VjcmV0").is_private()
1170 );
1171 assert!(
1172 classify_feed_privacy("https://example.com/feed?key=Zm9vYmFyc2VjcmV0").is_private()
1173 );
1174 assert!(
1175 classify_feed_privacy("https://example.com/feed?secret=Zm9vYmFyc2VjcmV0").is_private()
1176 );
1177 // Userinfo credentials in the authority.
1178 assert!(classify_feed_privacy("https://user:pass@example.com/feed").is_private());
1179 // A `/private/` path segment on an unknown host.
1180 assert!(classify_feed_privacy("https://blog.example.com/private/rss").is_private());
1181 // `/members/` path convention.
1182 assert!(classify_feed_privacy("https://news.example.com/members/feed.xml").is_private());
1183 // A high-entropy opaque token embedded in the path with no telltale name.
1184 assert!(
1185 classify_feed_privacy("https://feeds.example.com/aB3xK9zQ7mP2rT5wL8nD4vF6")
1186 .is_private()
1187 );
1188 // A bare UUID path segment (many tokened feeds).
1189 assert!(classify_feed_privacy(
1190 "https://feeds.example.com/1f2e3d4c-5b6a-7089-90ab-cdef01234567"
1191 )
1192 .is_private());
1193 }
1194
1195 /// The dominant real-world private-podcast shape delivers the token as a
1196 /// FILENAME (`<token>.rss` / `<token>.xml`) or affixed inside a larger
1197 /// segment (`feed-<uuid>`). Named providers are caught by their host rule;
1198 /// these are UNKNOWN-provider CDNs that must still be caught by the generic
1199 /// backstop, so the secret is never fetched or stored.
1200 #[test]
1201 fn classify_privacy_catches_tokened_filenames_on_unknown_hosts() {
1202 // hex-32 token as an .xml filename.
1203 assert!(classify_feed_privacy(
1204 "https://cdn.somepod.io/f/a1b2c3d4e5f60718293a4b5c6d7e8f90.xml"
1205 )
1206 .is_private());
1207 // hex-32 token as a .rss filename on an unknown CDN.
1208 assert!(classify_feed_privacy(
1209 "https://dcs.megaphone.example/network/a1b2c3d4e5f60718293a4b5c6d7e8f90.rss"
1210 )
1211 .is_private());
1212 // UUID + .xml filename.
1213 assert!(classify_feed_privacy(
1214 "https://brandnew.example/feed/1f2e3d4c-5b6a-7089-90ab-cdef01234567.xml"
1215 )
1216 .is_private());
1217 // UUID affixed with a prefix (`feed-<uuid>`) — split can't see it, the
1218 // UUID-substring scan must.
1219 assert!(classify_feed_privacy(
1220 "https://x.example/feed-1f2e3d4c-5b6a-7089-90ab-cdef01234567"
1221 )
1222 .is_private());
1223 // UUID + .rss suffix.
1224 assert!(classify_feed_privacy(
1225 "https://x.example/1f2e3d4c-5b6a-7089-90ab-cdef01234567.rss"
1226 )
1227 .is_private());
1228 // hex-16 token as an .xml filename.
1229 assert!(classify_feed_privacy("https://x.example/feed/9f8e7d6c5b4a3928.xml").is_private());
1230 // A base64url token with `=` padding as a clean path segment.
1231 assert!(
1232 classify_feed_privacy("https://cdn.pod.io/f/YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnc=")
1233 .is_private()
1234 );
1235 }
1236
1237 /// YouTube channel/playlist RSS feeds are FULLY PUBLIC (the id is a public
1238 /// handle, not a secret) and are the standard way to subscribe to a channel —
1239 /// they must NOT be false-blocked by the generic entropy heuristic.
1240 #[test]
1241 fn classify_privacy_allows_public_youtube_feeds() {
1242 assert_eq!(
1243 classify_feed_privacy(
1244 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw"
1245 ),
1246 FeedPrivacy::Public
1247 );
1248 assert_eq!(
1249 classify_feed_privacy(
1250 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1hCaGSI"
1251 ),
1252 FeedPrivacy::Public
1253 );
1254 // Bare host form too.
1255 assert_eq!(
1256 classify_feed_privacy(
1257 "https://youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw"
1258 ),
1259 FeedPrivacy::Public
1260 );
1261 // The allowlist is narrow: a `token=` on the YouTube feeds path still
1262 // classifies private (can't smuggle a credential through the allowlist).
1263 assert!(classify_feed_privacy(
1264 "https://www.youtube.com/feeds/videos.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4"
1265 )
1266 .is_private());
1267 }
1268
1269 #[test]
1270 fn classify_privacy_leaves_normal_public_feeds_public() {
1271 // Plain feed documents.
1272 assert_eq!(
1273 classify_feed_privacy("https://example.com/feed.xml"),
1274 FeedPrivacy::Public
1275 );
1276 assert_eq!(
1277 classify_feed_privacy("https://blog.example.com/rss"),
1278 FeedPrivacy::Public
1279 );
1280 assert_eq!(
1281 classify_feed_privacy("https://blog.example.com/rss.xml"),
1282 FeedPrivacy::Public
1283 );
1284 // A Substack PUBLIC feed (`/feed`, not `/feed/private/`) stays public.
1285 assert_eq!(
1286 classify_feed_privacy("https://author.substack.com/feed"),
1287 FeedPrivacy::Public
1288 );
1289 // A WordPress `/feed` endpoint.
1290 assert_eq!(
1291 classify_feed_privacy("https://wordpress.example.com/feed/"),
1292 FeedPrivacy::Public
1293 );
1294 // A plain Atom feed.
1295 assert_eq!(
1296 classify_feed_privacy("https://example.org/atom.xml"),
1297 FeedPrivacy::Public
1298 );
1299 // A long, hyphenated slug must NOT be mistaken for an embedded secret.
1300 assert_eq!(
1301 classify_feed_privacy("https://example.com/2026/07/my-first-long-blog-post-title/feed"),
1302 FeedPrivacy::Public
1303 );
1304 // A benign query key that merely contains "key" as a substring is fine.
1305 assert_eq!(
1306 classify_feed_privacy("https://example.com/feed?keyword=rust"),
1307 FeedPrivacy::Public
1308 );
1309 // A short, non-opaque value on a named key (e.g. an enum) is not a secret.
1310 assert_eq!(
1311 classify_feed_privacy("https://example.com/feed?p=2"),
1312 FeedPrivacy::Public
1313 );
1314 // An empty credential value is not a secret.
1315 assert_eq!(
1316 classify_feed_privacy("https://example.com/feed?token="),
1317 FeedPrivacy::Public
1318 );
1319 // A hyphenated slug ending in a feed extension must NOT be seen as a
1320 // tokened filename (the stem is short dictionary words, not a blob).
1321 assert_eq!(
1322 classify_feed_privacy("https://example.com/my-first-long-blog-post.xml"),
1323 FeedPrivacy::Public
1324 );
1325 // A short hex episode id in an .xml filename (< 16 chars) is not a secret.
1326 assert_eq!(
1327 classify_feed_privacy("https://example.com/episodes/ab12cd.xml"),
1328 FeedPrivacy::Public
1329 );
1330 // A dotted host-style filename slug stays public.
1331 assert_eq!(
1332 classify_feed_privacy("https://example.com/category/tech-news/feed.xml"),
1333 FeedPrivacy::Public
1334 );
1335 // Unparseable URL: treated as Public (add path rejects it downstream).
1336 assert_eq!(classify_feed_privacy("not a url"), FeedPrivacy::Public);
1337 }
1338
1339 #[test]
1340 fn backoff_grows_and_caps() {
1341 assert_eq!(backoff_for(1), BACKOFF_BASE);
1342 assert!(backoff_for(2) > backoff_for(1));
1343 assert_eq!(backoff_for(100), BACKOFF_MAX);
1344 }
1345}