Skip to main content

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}