Skip to main content

feather_reader/
standard_site.rs

1//! Reading `standard.site` publications as feeds.
2//!
3//! A publication is not a feed document — it is a record in somebody's atproto
4//! repo, and its "entries" are separate records in the same repo. So this reads
5//! two collections rather than fetching one URL:
6//!
7//! ```text
8//! at://<did>/site.standard.publication/<rkey>
9//!   ├─ resolve <did> → PDS
10//!   ├─ getRecord   site.standard.publication  → name, url
11//!   └─ listRecords site.standard.document     → paged, filtered on `site`
12//! ```
13//!
14//! **Unauthenticated throughout.** This reads *someone else's* repo with no
15//! session, which is why it cannot reuse [`crate::oauth::xrpc::Repo`]: that type
16//! takes its base URL from the session's PDS, hardcodes `repo` to
17//! `session.sub`, and DPoP-signs every send. None of that survives contact with
18//! "read a stranger's repo".
19//!
20//! **Only `textContent` and `description` are read; `content` is ignored.**
21//! `content` is an open union — measured across 449 real documents it carried
22//! six different wrappers and twenty-two block types from five vendor
23//! namespaces, growing with every platform that adopts the lexicon, and it
24//! would drag an HTML-sanitisation surface over foreign input. A document with
25//! neither field still yields an entry: title, date and a link is what an RSS
26//! reader shows for a title-only feed, and is not a failure state.
27
28use serde::Deserialize;
29
30use crate::lexicon::nsid;
31
32/// Documents per `listRecords` page.
33///
34/// Smaller than the protocol default of 100 because a `site.standard.document`
35/// carries the whole article — ~17 KB measured, and the `content` union this
36/// module ignores is still in the wire bytes — while
37/// [`crate::net::read_capped`] bounds a response at 8 MB. 100 long-form
38/// articles per page can exceed that and fail the walk outright.
39const DOCUMENT_PAGE_SIZE: u32 = 25;
40
41/// A parsed `at://` URI: `at://<authority>/<collection>/<rkey>`.
42///
43/// Parsed by hand rather than with `url::Url`, which **cannot read the form that
44/// matters**: `at://did:plc:…/…` fails with *invalid port number*, because the
45/// colons in the DID are taken as a port separator. The handle form parses
46/// fine, which is what makes the failure easy to miss.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct AtUri {
49    pub authority: String,
50    pub collection: String,
51    pub rkey: String,
52}
53
54impl AtUri {
55    /// Parse, or `None` if this is not a well-formed three-segment at-URI.
56    pub fn parse(uri: &str) -> Option<Self> {
57        let rest = uri.strip_prefix(crate::atproto::AT_URI_PREFIX)?;
58        let mut parts = rest.split('/');
59        let (authority, collection, rkey) = (parts.next()?, parts.next()?, parts.next()?);
60        if parts.next().is_some()
61            || authority.is_empty()
62            || collection.is_empty()
63            || rkey.is_empty()
64        {
65            return None;
66        }
67        Some(Self {
68            authority: authority.to_string(),
69            collection: collection.to_string(),
70            rkey: rkey.to_string(),
71        })
72    }
73}
74
75impl std::fmt::Display for AtUri {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        write!(
78            f,
79            "at://{}/{}/{}",
80            self.authority, self.collection, self.rkey
81        )
82    }
83}
84
85/// The publication record — a pointer, not a feed. Supplies the title and the
86/// base URL that document `path`s are joined onto.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Publication {
89    pub name: Option<String>,
90    pub url: String,
91}
92
93/// One document, mapped onto the shape the feed pipeline already stores.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Entry {
96    /// The document's own `at://` URI.
97    ///
98    /// **Not derived from `path`.** `path` is mutable — a publisher who moves an
99    /// article would duplicate their whole archive on the next poll, because
100    /// dedup is `UNIQUE (feed_id, guid)`.
101    pub guid: String,
102    pub title: String,
103    /// `publishedAt` parsed and re-spelled by [`crate::feed::fmt_time`], the
104    /// store's one RFC3339 shape — or `None` when it does not parse. The
105    /// reading order sorts on this column as a string, so a publisher's
106    /// spelling cannot go in verbatim.
107    pub published: Option<String>,
108    /// The joined, **scheme-vetted** permalink — `None` when the document's
109    /// `path` does not resolve to a safe href on the publication's origin.
110    /// `Option` because the guarantee cannot be met unconditionally and the
111    /// store's column is optional too; a title-only entry is not a failure.
112    pub url: Option<String>,
113    /// `description`, else `textContent`, escaped by
114    /// [`crate::feed::plain_text_to_html`] — both are plain text in the
115    /// lexicon, and the column they land in is rendered as HTML.
116    pub summary: Option<String>,
117}
118
119impl From<Entry> for crate::store::NewEntry {
120    /// The shape the poller stores. Kept here, next to the fields it maps,
121    /// so wiring the reader to the scheduler has nothing left to decide.
122    fn from(e: Entry) -> Self {
123        crate::store::NewEntry {
124            guid: e.guid,
125            url: e.url,
126            title: Some(e.title),
127            author: None,
128            published: e.published,
129            content_html: e.summary,
130            fetched_at: None,
131        }
132    }
133}
134
135#[derive(Debug, Deserialize)]
136struct PublicationValue {
137    name: Option<String>,
138    url: String,
139}
140
141#[derive(Debug, Deserialize)]
142struct DocumentValue {
143    title: String,
144    /// Optional so a document without one is an entry with no date, the same
145    /// answer a garbage one gets — the strictness ran the other way, making
146    /// the field this module is willing to DISCARD the one whose absence was
147    /// fatal to the whole record.
148    #[serde(rename = "publishedAt")]
149    published_at: Option<String>,
150    /// Optional for the same reason as `publishedAt`: a document with no
151    /// `path` keeps its title, date and summary rather than vanishing from
152    /// the feed entirely. `Entry.url` is already `Option`.
153    path: Option<String>,
154    /// The at-URI of the publication this document belongs to.
155    ///
156    /// **Load-bearing.** A repo can hold several publications — measured, some
157    /// do — so documents must be filtered by this rather than assumed to belong
158    /// to the one being polled.
159    site: String,
160    #[serde(rename = "textContent")]
161    text_content: Option<String>,
162    description: Option<String>,
163}
164
165/// Find the publication named by `rkey` among a repo's publication records.
166///
167/// Returns its **canonical** at-URI — the one the PDS itself minted — alongside
168/// the record. That canonical URI is what documents reference in their `site`
169/// field, so it is the key the filter uses, rather than the string the reader
170/// subscribed with. Storage is DID-form only (#164), so today the two agree;
171/// taking the PDS's spelling keeps them agreeing if they ever stop.
172pub fn publication_from_records(
173    rkey: &str,
174    records: &[crate::atproto::RecordEntry],
175) -> Option<(String, Publication)> {
176    let entry = records
177        .iter()
178        .find(|r| AtUri::parse(&r.uri).is_some_and(|u| u.rkey == rkey))?;
179    let value: PublicationValue = serde_json::from_value(entry.value.clone()).ok()?;
180    // **The base of every Entry.url, so it is vetted as the href it becomes.**
181    // `net::safe_link` is the same check the RSS entry pipeline applies at
182    // `feed.rs`, and the reason `safe_link.rs` exists as a type at all: the
183    // procedural version of this guarantee was deleted once with a green suite.
184    let url = crate::net::safe_link(&value.url)?;
185    Some((
186        entry.uri.clone(),
187        Publication {
188            name: value.name,
189            url,
190        },
191    ))
192}
193
194/// Map a repo's document records onto entries, keeping only those belonging to
195/// `canonical_site`.
196pub fn entries_from_records(
197    canonical_site: &str,
198    publication: &Publication,
199    records: &[crate::atproto::RecordEntry],
200) -> Vec<Entry> {
201    // **Normalised to a directory.** `Url::join` is RFC-3986: against a base of
202    // `https://example.com/blog`, a relative `posts/a` resolves to
203    // `/posts/a`, silently dropping the subpath every permalink needs. A
204    // trailing slash makes the base a directory, which is what a publication
205    // URL means.
206    let base = url::Url::parse(&publication.url).ok().map(|mut u| {
207        if !u.path().ends_with('/') {
208            u.set_path(&format!("{}/", u.path()));
209        }
210        u
211    });
212    records
213        .iter()
214        .filter_map(|record| {
215            // A document that does not deserialise is SKIPPED, not fatal: one
216            // malformed record must not cost a publisher its whole feed.
217            let doc: DocumentValue = serde_json::from_value(record.value.clone()).ok()?;
218            if doc.site != canonical_site {
219                return None;
220            }
221            Some(Entry {
222                guid: record.uri.clone(),
223                title: doc.title,
224                published: doc
225                    .published_at
226                    .as_deref()
227                    .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok())
228                    .map(|d| crate::feed::fmt_time(d.with_timezone(&chrono::Utc))),
229                // `non_blank` for the same reason the summary uses it: a blank
230                // path joins to the publication's own base, so a handful of
231                // documents with an empty `path` became a handful of entries
232                // all linking to the site root.
233                url: non_blank(doc.path)
234                    .as_deref()
235                    .and_then(|path| join_path(base.as_ref(), path)),
236                // `description` first — the authored summary — but only when it
237                // actually says something: a blank one must not shadow the body.
238                // Then ESCAPED, not sanitised: both fields are plain text.
239                summary: non_blank(doc.description)
240                    .or_else(|| non_blank(doc.text_content))
241                    .map(|raw| crate::feed::plain_text_to_html(&raw)),
242            })
243        })
244        .collect()
245}
246
247fn non_blank(s: Option<String>) -> Option<String> {
248    s.filter(|v| !v.trim().is_empty())
249}
250
251/// Join a document `path` onto the publication's base URL.
252///
253/// **`Url::join`, not concatenation.** Concatenating produced
254/// `https://x.com/https://evil.example/a` for an absolute path and buried the
255/// path inside the query for a base carrying one. `join` also keeps the result
256/// on the publication's own origin for a relative path, which is the only shape
257/// the lexicon describes.
258fn join_path(base: Option<&url::Url>, path: &str) -> Option<String> {
259    // **`safe_link` on the way out, not only on the base.** The scheme
260    // guarantee used to live solely in `publication_from_records`; this
261    // function and `Publication` are both `pub`, so a caller that built a
262    // `Publication` some other way (the step-3 poller, from a stored row) gave
263    // an unparseable base — and the no-base branch then returned the
264    // document's `path` verbatim, putting `javascript:` into an entry link.
265    // **No base, no URL.** This branch used to return `safe_link(path)`, which
266    // vets the scheme but NOT the origin — so a caller holding a `Publication`
267    // it did not build through `publication_from_records` (the step-3 poller,
268    // from a stored row whose `site_url` is NULL or malformed) would publish a
269    // publisher-controlled `https://evil.example/x` as a permalink under that
270    // publication's name. The two branches agree now: off-origin is `None`, and
271    // "no origin to be off" is also `None`.
272    let base = base?;
273    match base.join(path) {
274        // A path that resolves off the publication's origin is not a path, it
275        // is a redirect the publisher smuggled into a field we render as theirs.
276        Ok(joined) if joined.origin() == base.origin() => crate::net::safe_link(joined.as_str()),
277        // **No URL, not the homepage.** Falling back to the base gave every
278        // affected entry the same href pointing at the site root — which is
279        // what a publication on an apex domain whose documents live on `www.`
280        // or a CDN would produce for its whole archive, with nothing to say
281        // anything had been dropped.
282        _ => None,
283    }
284}
285
286/// What the document walk should do with one record.
287///
288/// A pure decision so it can be tested without a PDS: the walk itself is a
289/// closure over the network.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291enum DocumentFate {
292    /// Belongs to the publication being read.
293    Keep,
294    /// Belongs to another publication **in this repo** — normal, and the
295    /// reason the `site` filter exists. Not a signal of anything.
296    Sibling,
297    /// References a publication this repo does not have: a `site` spelling
298    /// nothing can ever match. Indistinguishable from a quiet blog without
299    /// saying so, which is why it is counted.
300    Orphan,
301    /// Not a document this reader understands.
302    Malformed,
303}
304
305fn classify_document(
306    record: &crate::atproto::RecordEntry,
307    canonical_site: &str,
308    known: &std::collections::HashSet<&str>,
309) -> DocumentFate {
310    match serde_json::from_value::<DocumentValue>(record.value.clone()) {
311        Ok(doc) if doc.site == canonical_site => DocumentFate::Keep,
312        Ok(doc) if known.contains(doc.site.as_str()) => DocumentFate::Sibling,
313        Ok(_) => DocumentFate::Orphan,
314        Err(_) => DocumentFate::Malformed,
315    }
316}
317
318/// Read a publication and its documents through the hardened anonymous client.
319///
320/// **Deliberately thin.** Everything that makes this fetch safe already exists
321/// in [`crate::atproto`] and is reused rather than rebuilt:
322///
323/// - [`crate::atproto::resolve_did_to_pds`] runs
324///   [`crate::net::assert_public_target`] on the `serviceEndpoint`, which is a
325///   stranger's string;
326/// - every read goes through [`crate::net::guarded_get_no_privacy`], re-vetting
327///   per hop and pinning the connection, which closes the rebinding window and
328///   supplies the `User-Agent` that 4 of 19 measured endpoints demand;
329/// - [`crate::net::read_capped`] bounds each response;
330/// - [`crate::atproto::PdsClient::list_all_records`] bounds the page count AND
331///   detects a repeated or absent cursor — the trap this module's first draft
332///   walked into, already solved there;
333/// - an XRPC error envelope surfaces as [`crate::atproto::XrpcError`] rather
334///   than deserialising into an empty page.
335///
336/// The first draft of this module reimplemented all of that, worse. The only
337/// logic left here is the part that is genuinely about standard.site.
338pub async fn fetch(
339    http: &reqwest::Client,
340    plc_directory: &str,
341    uri: &AtUri,
342) -> anyhow::Result<(Publication, Vec<Entry>)> {
343    use anyhow::Context;
344
345    // The collection is part of the identity of what was subscribed to, and
346    // this function is `pub`: without the check it lists publications and
347    // matches on rkey alone, so `at://did/app.bsky.feed.post/<rkey>` would be
348    // "read as a publication" whenever a publication shares that rkey.
349    anyhow::ensure!(
350        uri.collection == nsid::STANDARD_PUBLICATION,
351        "{uri} is not a {} URI",
352        nsid::STANDARD_PUBLICATION
353    );
354
355    let pds = crate::atproto::resolve_did_to_pds(http, plc_directory, &uri.authority)
356        .await
357        .with_context(|| format!("resolving the PDS for {}", uri.authority))?;
358    let client = crate::atproto::PdsClient::anonymous(http.clone(), pds, uri.authority.clone());
359
360    let publications = client
361        .list_all_records(nsid::STANDARD_PUBLICATION)
362        .await
363        .with_context(|| format!("listing publications for {}", uri.authority))?;
364    let (canonical_site, publication) = publication_from_records(&uri.rkey, &publications)
365        .with_context(|| format!("{uri} is not a readable site.standard.publication"))?;
366
367    // **Filtered inside the walk, so the cap counts THIS publication's
368    // documents.** A repo-wide cap applied before the filter starves a quiet
369    // publication whose busy sibling fills the window — it returns nothing,
370    // permanently, and worse with every post the sibling makes.
371    //
372    // The page is smaller than the protocol default because a document
373    // carries the whole article (~17 KB measured, and the `content` union this
374    // module ignores is still on the wire): 100 long-form articles per page
375    // can exceed `read_capped`'s 8 MB and fail the walk outright.
376    let known: std::collections::HashSet<&str> =
377        publications.iter().map(|p| p.uri.as_str()).collect();
378    let mut orphaned = 0usize;
379    let documents = client
380        .list_recent_matching(
381            nsid::STANDARD_DOCUMENT,
382            crate::atproto::MAX_LARGE_RECORDS,
383            DOCUMENT_PAGE_SIZE,
384            // Orphans are counted while WALKING, not over the kept window: a
385            // truncated slice would both miss orphans and stay silent in
386            // exactly the case where the feed went empty structurally.
387            |record| match classify_document(record, &canonical_site, &known) {
388                DocumentFate::Keep => true,
389                DocumentFate::Orphan => {
390                    orphaned += 1;
391                    false
392                }
393                DocumentFate::Sibling | DocumentFate::Malformed => false,
394            },
395        )
396        .await
397        .with_context(|| format!("listing documents for {canonical_site}"))?;
398    let entries = entries_from_records(&canonical_site, &publication, &documents.records);
399
400    // **A walk that stopped early is not a short archive.** Reading part of a
401    // publication is acceptable; reporting it as the whole of one is not, and
402    // when the part is empty — a quiet publication whose busy sibling fills
403    // every page this reader will fetch — the feed looks healthy and stays
404    // empty forever.
405    if !documents.complete {
406        tracing::warn!(
407            site = %canonical_site,
408            kept = entries.len(),
409            "stopped reading this publication before its documents ran out"
410        );
411    }
412    // **A spelling mismatch on `site` looks exactly like an empty
413    // publication.** A publication with no documents is normal, so the poller
414    // would call this healthy forever; if the repo HAD documents and none
415    // matched, say so, because that is the shape of a bug rather than of a
416    // quiet blog.
417    if orphaned > 0 {
418        tracing::warn!(
419            site = %canonical_site,
420            orphaned,
421            "documents in this repo reference no publication in it — a `site` spelling nothing matches"
422        );
423    }
424    Ok((publication, entries))
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::atproto::RecordEntry;
431    use serde_json::json;
432
433    const DID: &str = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
434
435    fn rec(collection: &str, rkey: &str, value: serde_json::Value) -> RecordEntry {
436        RecordEntry {
437            uri: format!("at://{DID}/{collection}/{rkey}"),
438            cid: None,
439            value,
440        }
441    }
442
443    fn publication(rkey: &str, url: &str) -> RecordEntry {
444        rec(
445            nsid::STANDARD_PUBLICATION,
446            rkey,
447            json!({ "name": "Scan's Lab", "url": url }),
448        )
449    }
450
451    fn document(rkey: &str, site: &str, title: &str, path: &str) -> RecordEntry {
452        rec(
453            nsid::STANDARD_DOCUMENT,
454            rkey,
455            json!({
456                "title": title,
457                "publishedAt": "2026-07-11T00:00:00Z",
458                "path": path,
459                "site": site,
460                "textContent": "body",
461            }),
462        )
463    }
464
465    fn canonical(rkey: &str) -> String {
466        format!("at://{DID}/{}/{rkey}", nsid::STANDARD_PUBLICATION)
467    }
468
469    /// **The `site` filter keys on the URI the PDS minted, not the string the
470    /// reader subscribed with.** Documents reference their publication by the
471    /// canonical URI, and every measured document does. An earlier draft
472    /// compared against the subscribed string; storage was then meant to admit
473    /// handle-form URIs, and a handle-form subscription found nothing forever
474    /// while the module declared the feed healthy. #164 made storage DID-only,
475    /// so the two strings agree today — the canonical one is still the right
476    /// key, and this pins it.
477    #[test]
478    fn the_site_filter_uses_the_uri_the_pds_minted() {
479        let records = vec![publication("p", "https://scanash.com")];
480        let (site, pubn) = publication_from_records("p", &records).expect("publication not found");
481        assert_eq!(site, canonical("p"), "did not take the PDS's canonical URI");
482
483        let docs = vec![document("d1", &canonical("p"), "Hello", "/hello")];
484        let entries = entries_from_records(&site, &pubn, &docs);
485        assert_eq!(
486            entries.len(),
487            1,
488            "a canonical-site document was not matched"
489        );
490    }
491
492    /// The mapping into the store's row is total and loses nothing the poller
493    /// would need — so wiring the reader has nothing to invent.
494    #[test]
495    fn an_entry_maps_onto_the_stores_row() {
496        let records = vec![publication("p", "https://example.com")];
497        let (site, pubn) = publication_from_records("p", &records).unwrap();
498        let docs = vec![document("rk1", &site, "Hello", "/hello")];
499        let row: crate::store::NewEntry = entries_from_records(&site, &pubn, &docs)
500            .pop()
501            .unwrap()
502            .into();
503        assert_eq!(
504            row.guid,
505            format!("at://{DID}/{}/rk1", nsid::STANDARD_DOCUMENT)
506        );
507        assert_eq!(row.url.as_deref(), Some("https://example.com/hello"));
508        assert_eq!(row.title.as_deref(), Some("Hello"));
509        assert_eq!(row.published.as_deref(), Some("2026-07-11T00:00:00Z"));
510        assert_eq!(row.content_html.as_deref(), Some("body"));
511        assert_eq!(row.author, None);
512        assert_eq!(row.fetched_at, None);
513    }
514
515    /// A repo can hold several publications — measured, some do — and
516    /// `listRecords` cannot filter server-side.
517    #[test]
518    fn documents_are_filtered_by_their_site_field() {
519        let records = vec![publication("mine", "https://example.com")];
520        let (site, pubn) = publication_from_records("mine", &records).unwrap();
521        let docs = vec![
522            document("a", &site, "Mine", "/a"),
523            document("b", &canonical("theirs"), "Theirs", "/b"),
524            document("c", &site, "Mine again", "/c"),
525        ];
526        let titles: Vec<String> = entries_from_records(&site, &pubn, &docs)
527            .into_iter()
528            .map(|e| e.title)
529            .collect();
530        assert_eq!(titles, ["Mine", "Mine again"]);
531    }
532
533    /// **The publication URL is a stranger's string and is vetted as one.**
534    ///
535    /// It becomes the base of every `Entry.url`, which is an href. This repo has
536    /// `safe_link.rs` as a type with a module-private field precisely because
537    /// the procedural version of this guarantee failed; #143 and #138 are this
538    /// same bug class on `siteUrl`.
539    #[test]
540    fn a_publication_with_a_hostile_url_is_refused() {
541        for hostile in [
542            "javascript:alert(1)",
543            "data:text/html,<script>",
544            "file:///etc/passwd",
545            "",
546        ] {
547            let records = vec![publication("p", hostile)];
548            assert!(
549                publication_from_records("p", &records).is_none(),
550                "accepted a publication whose url is {hostile:?}",
551            );
552        }
553    }
554
555    /// **Entry URLs are joined, not concatenated.**
556    ///
557    /// String concatenation produced `https://x.com/https://evil.com/a` for an
558    /// absolute `path`, and put the path inside the query string for a base
559    /// carrying one.
560    #[test]
561    fn entry_urls_are_joined_against_the_publication_base() {
562        let records = vec![publication("p", "https://example.com/blog")];
563        let (site, pubn) = publication_from_records("p", &records).unwrap();
564        let docs = vec![
565            document("a", &site, "Relative", "/a"),
566            document("b", &site, "Absolute-looking", "https://evil.example/x"),
567        ];
568        let urls: Vec<Option<String>> = entries_from_records(&site, &pubn, &docs)
569            .into_iter()
570            .map(|e| e.url)
571            .collect();
572        assert_eq!(urls[0].as_deref(), Some("https://example.com/a"));
573        // Exactly the publication's base, not merely "not evil": a mutation
574        // that returned the raw path, or an empty string, passed the weaker
575        // negative assertion this used to be.
576        // Off-origin is dropped, not rewritten to the base. This assertion has
577        // moved twice: it began as "not evil" (a mutant returning the raw path
578        // passed it), was tightened to the base fallback, and is now `None` —
579        // the base gave every affected entry the same homepage href.
580        assert_eq!(
581            urls[1], None,
582            "a document path that escapes its publication's origin must yield no URL"
583        );
584    }
585
586    /// 8% of measured documents (37 of 449) carry neither summary field.
587    #[test]
588    fn a_document_with_neither_summary_field_still_yields_an_entry() {
589        let records = vec![publication("p", "https://example.com/")];
590        let (site, pubn) = publication_from_records("p", &records).unwrap();
591        let bare = rec(
592            nsid::STANDARD_DOCUMENT,
593            "bare",
594            json!({
595                "title": "Bare",
596                "publishedAt": "2026-07-11T00:00:00Z",
597                "path": "/bare",
598                "site": site,
599            }),
600        );
601        let entries = entries_from_records(&site, &pubn, &[bare]);
602        assert_eq!(entries.len(), 1);
603        assert_eq!(entries[0].summary, None);
604        assert_eq!(entries[0].url.as_deref(), Some("https://example.com/bare"));
605    }
606
607    /// `description` is the authored summary; `textContent` is the whole body.
608    /// An EMPTY description must not shadow a real one.
609    #[test]
610    fn an_empty_description_does_not_shadow_the_body() {
611        let records = vec![publication("p", "https://example.com")];
612        let (site, pubn) = publication_from_records("p", &records).unwrap();
613        let doc = rec(
614            nsid::STANDARD_DOCUMENT,
615            "d",
616            json!({
617                "title": "T",
618                "publishedAt": "2026-07-11T00:00:00Z",
619                "path": "/d",
620                "site": site,
621                "description": "   ",
622                "textContent": "the real body",
623            }),
624        );
625        let entries = entries_from_records(&site, &pubn, &[doc]);
626        assert_eq!(entries[0].summary.as_deref(), Some("the real body"));
627    }
628
629    /// **`publishedAt` is parsed, not passed through.** The store's `published`
630    /// column is the RFC3339 shape `feed::fmt_time` writes, and the reading
631    /// order sorts on it as a string. A publisher's string went in verbatim —
632    /// a garbage value would have sorted arbitrarily among real ones, and a
633    /// valid-but-differently-spelled one (`+00:00`, fractional seconds) would
634    /// not have matched the RSS path's spelling for the same instant.
635    #[test]
636    fn published_at_is_normalised_or_dropped() {
637        let records = vec![publication("p", "https://example.com")];
638        let (site, pubn) = publication_from_records("p", &records).unwrap();
639        let with = |rkey: &str, published_at: serde_json::Value| {
640            rec(
641                nsid::STANDARD_DOCUMENT,
642                rkey,
643                json!({ "title": "T", "publishedAt": published_at, "path": "/x", "site": site }),
644            )
645        };
646        let docs = vec![
647            with("a", json!("2026-07-11T09:30:00.123+02:00")),
648            with("b", json!("yesterday-ish")),
649            with("c", json!("2026-07-11T00:00:00Z")),
650        ];
651        let published: Vec<Option<String>> = entries_from_records(&site, &pubn, &docs)
652            .into_iter()
653            .map(|e| e.published)
654            .collect();
655        assert_eq!(
656            published,
657            vec![
658                Some("2026-07-11T07:30:00Z".to_string()),
659                None,
660                Some("2026-07-11T00:00:00Z".to_string()),
661            ],
662            "publishedAt was not normalised to the store's spelling"
663        );
664    }
665
666    /// **Summaries are plain text and are escaped, not sanitised.**
667    ///
668    /// The lexicon defines `textContent` and `description` as plain text, and
669    /// the store's `content_html` is rendered as HTML, so the text must be
670    /// escaped on the way in. The first version of this ran `ammonia::clean`
671    /// over them — the RSS body function — which parses its input as markup
672    /// and deletes everything after a bare `<`. 321 of 449 measured documents
673    /// use `textContent` as their summary; any post mentioning `Vec<T>` lost
674    /// the rest of its summary, silently.
675    #[test]
676    fn summaries_are_escaped_as_plain_text_not_sanitised_as_markup() {
677        let records = vec![publication("p", "https://example.com")];
678        let (site, pubn) = publication_from_records("p", &records).unwrap();
679        let doc = |rkey: &str, body: &str| {
680            rec(
681                nsid::STANDARD_DOCUMENT,
682                rkey,
683                json!({
684                    "title": "T",
685                    "publishedAt": "2026-07-11T00:00:00Z",
686                    "path": "/d",
687                    "site": site,
688                    "textContent": body,
689                }),
690            )
691        };
692        let summaries: Vec<String> = entries_from_records(
693            &site,
694            &pubn,
695            &[
696                doc("a", "Vec<String> is a type"),
697                doc("b", "<script>alert(1)</script>"),
698            ],
699        )
700        .into_iter()
701        .filter_map(|e| e.summary)
702        .collect();
703        assert_eq!(
704            summaries[0], "Vec&lt;String&gt; is a type",
705            "prose was eaten by an HTML parser"
706        );
707        assert!(
708            !summaries[1].contains("<script"),
709            "escaping failed: {}",
710            summaries[1]
711        );
712    }
713
714    /// **`Entry.url` is a vetted href or nothing.** The scheme guarantee lived
715    /// only inside `publication_from_records`; `entries_from_records` and
716    /// `Publication` are both `pub`, so any other constructor — step 3 building
717    /// one from the stored `feeds` row, say — gave an unparseable base, and the
718    /// no-base branch then emitted the document's `path` verbatim. A
719    /// `javascript:` path became the entry link. This is the class `safe_link`
720    /// exists for.
721    #[test]
722    fn an_entry_url_is_never_an_unvetted_path() {
723        let pubn = Publication {
724            name: None,
725            // What a caller that did not go through `publication_from_records`
726            // can hand this function.
727            url: "not a url".to_string(),
728        };
729        let site = canonical("p");
730        let docs = vec![
731            document("a", &site, "Hostile", "javascript:alert(1)"),
732            document("b", &site, "Fine", "https://example.com/ok"),
733        ];
734        let entries = entries_from_records(&site, &pubn, &docs);
735        assert_eq!(
736            entries[0].url, None,
737            "an unvetted path became an entry link"
738        );
739        // Contract change: with no parseable base there is no origin to check,
740        // so a well-formed absolute URL is refused too. `safe_link` alone vets
741        // the SCHEME; it would have published a publisher-controlled host under
742        // this publication's name. See `no_parseable_base_means_no_url_not_any_url`.
743        assert_eq!(
744            entries[1].url, None,
745            "an off-origin absolute URL was published under the publication's name"
746        );
747    }
748
749    /// A document with no `publishedAt` is an entry with no date — the same
750    /// answer a garbage one gets. The field the module is willing to discard
751    /// must not be the one whose absence is fatal.
752    #[test]
753    fn a_document_without_published_at_is_still_an_entry() {
754        let records = vec![publication("p", "https://example.com")];
755        let (site, pubn) = publication_from_records("p", &records).unwrap();
756        let doc = rec(
757            nsid::STANDARD_DOCUMENT,
758            "d",
759            json!({ "title": "T", "path": "/d", "site": site }),
760        );
761        let entries = entries_from_records(&site, &pubn, &[doc]);
762        assert_eq!(
763            entries.len(),
764            1,
765            "a missing publishedAt dropped the document"
766        );
767        assert_eq!(entries[0].published, None);
768    }
769
770    /// **`fetch` refuses a URI naming another collection, before the network.**
771    /// It lists publications and matches on rkey alone, so without this an
772    /// `app.bsky.feed.post` URI would be "read as a publication" whenever a
773    /// publication in that repo shares the rkey. Storage enforces the
774    /// collection today, but this function is `pub`.
775    #[tokio::test]
776    async fn fetch_refuses_a_uri_for_another_collection() {
777        let uri = AtUri::parse(&format!("at://{DID}/app.bsky.feed.post/3lab")).unwrap();
778        let err = fetch(&reqwest::Client::new(), "https://plc.example", &uri)
779            .await
780            .expect_err("read a feed post as a publication");
781        assert!(
782            format!("{err:#}").contains(nsid::STANDARD_PUBLICATION),
783            "failed for the wrong reason: {err:#}"
784        );
785    }
786
787    /// **A publication on a subpath keeps it.** `Url::join` is RFC-3986, so a
788    /// relative `posts/a` against `https://example.com/blog` resolves to
789    /// `/posts/a` — dropping the subpath every permalink needs, while still
790    /// passing the origin check. The base is normalised to a directory.
791    #[test]
792    fn a_subpath_publication_keeps_its_base_path() {
793        let records = vec![publication("p", "https://example.com/blog")];
794        let (site, pubn) = publication_from_records("p", &records).unwrap();
795        let docs = vec![document("a", &site, "Relative", "posts/a")];
796        let urls: Vec<Option<String>> = entries_from_records(&site, &pubn, &docs)
797            .into_iter()
798            .map(|e| e.url)
799            .collect();
800        assert_eq!(urls[0].as_deref(), Some("https://example.com/blog/posts/a"));
801    }
802
803    /// With no parseable base there is no origin to check, so there is no URL
804    /// — `safe_link` alone vets the scheme and would pass any absolute URL a
805    /// publisher chose, under this publication's name.
806    #[test]
807    fn no_parseable_base_means_no_url_not_any_url() {
808        let pubn = Publication {
809            name: None,
810            url: "not a url".to_string(),
811        };
812        let site = canonical("p");
813        let docs = vec![document("a", &site, "Absolute", "https://evil.example/x")];
814        let entries = entries_from_records(&site, &pubn, &docs);
815        assert_eq!(
816            entries[0].url, None,
817            "an off-origin absolute URL was published"
818        );
819    }
820
821    /// **An off-origin path is dropped, not rewritten to the homepage.**
822    /// Returning the base gave every affected entry the SAME href pointing at
823    /// the site root — realistic whenever a publication's `url` is the apex
824    /// and its documents sit on `www.` or a CDN domain. `None` is the honest
825    /// answer, and the template already has a no-URL branch.
826    #[test]
827    fn an_off_origin_path_yields_no_url_rather_than_the_homepage() {
828        let records = vec![publication("p", "https://example.com/blog")];
829        let (site, pubn) = publication_from_records("p", &records).unwrap();
830        let docs = vec![
831            document("a", &site, "Elsewhere", "https://www.example.com/post"),
832            document("b", &site, "Home", "/ok"),
833        ];
834        let urls: Vec<Option<String>> = entries_from_records(&site, &pubn, &docs)
835            .into_iter()
836            .map(|e| e.url)
837            .collect();
838        assert_eq!(
839            urls[0], None,
840            "an off-origin path was rewritten to the base"
841        );
842        assert_eq!(urls[1].as_deref(), Some("https://example.com/ok"));
843    }
844
845    /// **A blank `path` is no URL, not the homepage** — the same answer the
846    /// off-origin branch now gives, and for the same reason: several
847    /// documents with an empty `path` otherwise became several entries all
848    /// linking to the site root.
849    #[test]
850    fn a_blank_path_yields_no_url() {
851        let records = vec![publication("p", "https://example.com/blog")];
852        let (site, pubn) = publication_from_records("p", &records).unwrap();
853        let docs = vec![
854            document("a", &site, "Blank", ""),
855            document("b", &site, "Spaces", "   "),
856            document("c", &site, "Real", "/real"),
857        ];
858        let urls: Vec<Option<String>> = entries_from_records(&site, &pubn, &docs)
859            .into_iter()
860            .map(|e| e.url)
861            .collect();
862        assert_eq!(urls[0], None, "a blank path became the homepage");
863        assert_eq!(urls[1], None, "a whitespace path became the homepage");
864        assert_eq!(urls[2].as_deref(), Some("https://example.com/real"));
865    }
866
867    /// A document without `path` keeps its title, date and summary — the
868    /// policy `publishedAt` and `Entry.url` already follow.
869    #[test]
870    fn a_document_without_a_path_is_still_an_entry() {
871        let records = vec![publication("p", "https://example.com")];
872        let (site, pubn) = publication_from_records("p", &records).unwrap();
873        let doc = rec(
874            nsid::STANDARD_DOCUMENT,
875            "d",
876            json!({ "title": "T", "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
877        );
878        let entries = entries_from_records(&site, &pubn, &[doc]);
879        assert_eq!(
880            entries.len(),
881            1,
882            "a missing path dropped the whole document"
883        );
884        assert_eq!(entries[0].title, "T");
885        assert_eq!(entries[0].url, None);
886    }
887
888    /// **A sibling is not an orphan.** A repo with an empty publication A and
889    /// a busy publication B is the exact shape the `site` filter exists for;
890    /// treating B's documents as a signal would warn on every poll of A
891    /// forever. The signal is a document referencing a publication this repo
892    /// does not have — a spelling nothing can ever match.
893    #[test]
894    fn documents_are_classified_keep_sibling_or_orphan() {
895        let pubs = [
896            publication("a", "https://example.com"),
897            publication("b", "https://b.example"),
898        ];
899        let known: std::collections::HashSet<&str> = pubs.iter().map(|p| p.uri.as_str()).collect();
900        let mine = canonical("a");
901        let fate = |d: &crate::atproto::RecordEntry| classify_document(d, &mine, &known);
902
903        assert_eq!(
904            fate(&document("d1", &mine, "Mine", "/1")),
905            DocumentFate::Keep
906        );
907        assert_eq!(
908            fate(&document("d2", &canonical("b"), "B's", "/2")),
909            DocumentFate::Sibling,
910            "a sibling publication's document is not an orphan"
911        );
912        assert_eq!(
913            fate(&document(
914                "d3",
915                "at://did:plc:other/site.standard.publication/x",
916                "?",
917                "/3"
918            )),
919            DocumentFate::Orphan
920        );
921        assert_eq!(
922            fate(&rec(
923                nsid::STANDARD_DOCUMENT,
924                "d4",
925                json!({"title": "no rest"})
926            )),
927            DocumentFate::Malformed
928        );
929    }
930
931    /// `AtUri` uses the crate's one spelling of the prefix.
932    #[test]
933    fn at_uri_parsing_uses_the_shared_prefix() {
934        let uri = format!(
935            "{}{DID}/{}/abc",
936            crate::atproto::AT_URI_PREFIX,
937            nsid::STANDARD_PUBLICATION
938        );
939        assert!(AtUri::parse(&uri).is_some());
940    }
941
942    /// The guid is the record's own URI. `path` is mutable; dedup is
943    /// `UNIQUE (feed_id, guid)`.
944    #[test]
945    fn the_guid_is_the_record_uri_not_the_path() {
946        let records = vec![publication("p", "https://example.com")];
947        let (site, pubn) = publication_from_records("p", &records).unwrap();
948        let docs = vec![document("rk1", &site, "T", "/moved")];
949        let entries = entries_from_records(&site, &pubn, &docs);
950        assert_eq!(
951            entries[0].guid,
952            format!("at://{DID}/{}/rk1", nsid::STANDARD_DOCUMENT)
953        );
954    }
955
956    /// One unreadable record must not cost a publisher the whole feed.
957    #[test]
958    fn a_malformed_document_is_skipped_rather_than_fatal() {
959        let records = vec![publication("p", "https://example.com")];
960        let (site, pubn) = publication_from_records("p", &records).unwrap();
961        let docs = vec![
962            rec(
963                nsid::STANDARD_DOCUMENT,
964                "bad",
965                json!({ "title": "no rest" }),
966            ),
967            document("ok", &site, "Good", "/good"),
968        ];
969        let entries = entries_from_records(&site, &pubn, &docs);
970        assert_eq!(entries.len(), 1);
971        assert_eq!(entries[0].title, "Good");
972    }
973
974    /// An rkey that is not in the repo is absent, not an error.
975    #[test]
976    fn a_missing_publication_is_none() {
977        let records = vec![publication("other", "https://example.com")];
978        assert!(publication_from_records("p", &records).is_none());
979    }
980
981    #[test]
982    fn at_uris_parse_in_both_forms_and_reject_malformed_ones() {
983        let did = AtUri::parse(&format!("at://{DID}/site.standard.publication/abc")).unwrap();
984        assert_eq!(did.authority, DID);
985        assert_eq!(did.rkey, "abc");
986        assert_eq!(
987            did.to_string(),
988            format!("at://{DID}/site.standard.publication/abc")
989        );
990        assert!(AtUri::parse("at://alice.example.com/site.standard.publication/abc").is_some());
991        for bad in [
992            "at://",
993            "at://only-authority",
994            "at://authority/collection",
995            "at://authority/collection/",
996            "at:///collection/rkey",
997            "at://authority/collection/rkey/extra",
998            "https://example.com/feed.xml",
999            "at:authority/collection/rkey",
1000        ] {
1001            assert!(AtUri::parse(bad).is_none(), "parsed {bad:?}");
1002        }
1003    }
1004}