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<String> 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}