feather_reader/vetted.rs
1//! Record types that cannot exist unvetted.
2//!
3//! **This is its own module because of the private field.** A private field is
4//! private to the MODULE, and `repo.rs` is where every record write is
5//! assembled — so a type declared there could be constructed beside its own
6//! constructor, and the guarantee would be a convention again. `safe_link.rs`
7//! exists for exactly this reason, after a review demonstrated five separate
8//! ways to rebuild the hole it was meant to close.
9//!
10//! The guarantee here is narrow and worth stating precisely: **holding one of
11//! these means the vet ran**. It does not mean the value is safe to render, and
12//! it says nothing about records read back from the PDS — that is the read-side
13//! guard on `lexicon::Subscription::site_url`.
14
15use anyhow::{bail, Result};
16use serde::Serialize;
17
18use crate::lexicon::{Folder, ReadState, Saved, Subscription};
19
20/// What a record-write primitive will accept.
21///
22/// **The bound that makes vetting unskippable.** The write primitives were
23/// generic over `T: Serialize`, and `lexicon::Subscription` derives
24/// `Serialize` — so `create_record(nsid::SUBSCRIPTION, &raw_subscription)`
25/// compiled and wrote an unvetted record. On `oauth::xrpc::Repo` those
26/// primitives are `pub` (an example drives them), so that was reachable from
27/// any handler in this crate.
28///
29/// The obvious fix — dropping `Serialize` from `Subscription` — is the one
30/// `repo.rs` recorded as blocked: [`VettedSubscription`] is
31/// `#[serde(transparent)]` over it, so removing the derive forces a
32/// hand-written impl, and a record-shape slip there would silently migrate
33/// every reader's repo. A marker trait gets the same guarantee and cannot
34/// drift, because the wire format keeps coming from one derive.
35///
36/// **Sealed**: implementing it requires `sealed::Sealed`, which is private to
37/// this module, so the list below is the whole list — nothing elsewhere in the
38/// crate can add itself. `Subscription` and [`Saved`] are deliberately absent;
39/// their vetted wrappers are here instead.
40///
41/// A vetted record is accepted:
42///
43/// ```
44/// use feather_reader::lexicon::Subscription;
45/// use feather_reader::vetted::{VettedSubscription, WritableRecord};
46/// fn writes<T: WritableRecord>(_record: &T) {}
47///
48/// let sub = Subscription::new("https://example.com/feed.xml", "2026-01-01T00:00:00.000Z");
49/// writes(&VettedSubscription::new(&sub));
50/// ```
51///
52/// A raw one does not compile:
53///
54/// ```compile_fail
55/// use feather_reader::lexicon::Subscription;
56/// use feather_reader::vetted::WritableRecord;
57/// fn writes<T: WritableRecord>(_record: &T) {}
58///
59/// let sub = Subscription::new("https://example.com/feed.xml", "2026-01-01T00:00:00.000Z");
60/// writes(&sub);
61/// ```
62pub trait WritableRecord: Serialize + sealed::Sealed {}
63
64mod sealed {
65 /// Private to this module, so [`super::WritableRecord`] can only be
66 /// implemented here.
67 pub trait Sealed {}
68}
69
70macro_rules! writable {
71 ($($t:ty),+ $(,)?) => {$(
72 impl sealed::Sealed for $t {}
73 impl WritableRecord for $t {}
74 )+};
75}
76
77// The vetted wrappers, plus the two lexicon records that carry nothing to vet:
78// `Folder` is a name and a sort position, `ReadState` a feed URL the reader
79// already holds and two id lists. Neither has a field rendered as an href,
80// which is what `vet` exists to check.
81writable!(
82 VettedSubscription,
83 VettedSaved,
84 Folder,
85 ReadState,
86 SpikeRecord
87);
88
89/// Arbitrary JSON for `examples/oauth_spike.rs`, which round-trips a record
90/// through a scratch collection to prove the OAuth write path works.
91///
92/// **Not for the reader itself.** It exists because that example is a separate
93/// crate target driving the `pub` primitives, and the alternative was leaving
94/// them generic over every `Serialize` — which is the hole this trait closes.
95/// Anything the reader actually stores has a lexicon type and a vetted wrapper.
96#[doc(hidden)]
97#[derive(Serialize, Clone, Debug)]
98#[serde(transparent)]
99pub struct SpikeRecord(pub serde_json::Value);
100
101/// A [`Subscription`] whose `siteUrl` has been scheme-checked.
102///
103/// `#[serde(transparent)]` so the bytes on the wire are byte-identical to
104/// serializing the inner record. This type changes what the compiler permits,
105/// not what the PDS receives — a record-shape change here would be a silent
106/// migration of every reader's repo.
107#[derive(Serialize, Clone, Debug)]
108#[serde(transparent)]
109pub struct VettedSubscription(Subscription);
110
111impl VettedSubscription {
112 /// Scheme-check `siteUrl` and hand back a record the writers will accept.
113 ///
114 /// A rejected URL becomes `None` rather than dropping the subscription: the
115 /// feed is what the reader asked for, the site link is decoration. That is
116 /// the same call the previous free-function `vet` made, and the reasoning is
117 /// unchanged — what changes is that skipping it no longer type-checks.
118 pub fn new(sub: &Subscription) -> Self {
119 let mut out = sub.clone();
120 out.site_url = out.site_url.as_deref().and_then(crate::net::safe_link);
121 Self(out)
122 }
123
124 /// Vet a batch — the OPML-import path.
125 pub fn all(subs: &[Subscription]) -> Vec<Self> {
126 subs.iter().map(Self::new).collect()
127 }
128}
129
130/// A [`Saved`] record whose `url` has been scheme-checked.
131///
132/// **Fallible, unlike [`VettedSubscription`], because `url` is required.** The
133/// subscription's `siteUrl` is an `Option`, so a rejected value has an obvious
134/// resting place. A saved record exists to point at something, so there is no
135/// honest way to publish one whose URL we refused — an empty string would be a
136/// malformed record, and dropping the field would not deserialize.
137///
138/// Refusing is already a handled outcome at the only call site: the star
139/// handler logs a failed PDS write and keeps the local star, so the reader
140/// still sees the entry starred in this reader while nothing hostile is
141/// published under their authorship.
142#[derive(Serialize, Clone, Debug)]
143#[serde(transparent)]
144pub struct VettedSaved(Saved);
145
146impl VettedSaved {
147 pub fn new(saved: &Saved) -> Result<Self> {
148 let Some(url) = crate::net::safe_link(&saved.url) else {
149 bail!(
150 "refusing to publish a saved record whose url is not http(s); \
151 the entry stays starred locally"
152 )
153 };
154 let mut out = saved.clone();
155 out.url = url;
156 Ok(Self(out))
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn a_hostile_site_url_becomes_none_and_the_rest_survives() {
166 let mut sub = Subscription::new("https://example.com/feed.xml", "2026-01-01T00:00:00.000Z");
167 sub.site_url = Some("javascript:alert(1)".into());
168 sub.title = Some("Kept".into());
169
170 let vetted = VettedSubscription::new(&sub);
171 let rendered = serde_json::to_value(&vetted).unwrap();
172 assert!(
173 rendered.get("siteUrl").is_none(),
174 "a rejected siteUrl must be omitted, not emptied: {rendered}"
175 );
176 assert_eq!(rendered["title"], "Kept", "the rest of the record was lost");
177 assert_eq!(rendered["url"], "https://example.com/feed.xml");
178 }
179
180 #[test]
181 fn a_clean_record_serializes_byte_identically_to_the_inner_one() {
182 // The newtype changes what compiles, not what the PDS receives.
183 let mut sub = Subscription::new("https://example.com/feed.xml", "2026-01-01T00:00:00.000Z");
184 sub.site_url = Some("https://example.com/blog".into());
185 assert_eq!(
186 serde_json::to_string(&VettedSubscription::new(&sub)).unwrap(),
187 serde_json::to_string(&sub).unwrap(),
188 "the wrapper changed the wire format — that would silently migrate \
189 every reader's repo"
190 );
191 }
192
193 /// Ported from `repo::tests::vet_rejects_by_scheme_and_keeps_everything_else`
194 /// when the free function became this type. The cases are the reasoning, so
195 /// they move with it rather than being dropped.
196 #[test]
197 fn whitespace_is_normalised_and_absent_stays_absent() {
198 let mut padded =
199 Subscription::new("https://example.com/feed.xml", "2026-01-01T00:00:00.000Z");
200 padded.site_url = Some(" https://example.com/blog ".into());
201 let rendered = serde_json::to_value(VettedSubscription::new(&padded)).unwrap();
202 assert_eq!(
203 rendered["siteUrl"], "https://example.com/blog",
204 "whitespace should normalise rather than reject, as it does for entry links"
205 );
206
207 // Absent stays absent — no empty string is invented.
208 let bare = Subscription::new("https://example.com/feed.xml", "2026-01-01T00:00:00.000Z");
209 let rendered = serde_json::to_value(VettedSubscription::new(&bare)).unwrap();
210 assert!(rendered.get("siteUrl").is_none());
211 }
212
213 /// Ported from `repo::tests::vet_all_cleans_one_bad_record_without_touching_the_rest`.
214 #[test]
215 fn one_bad_record_in_a_batch_is_cleaned_not_dropped() {
216 let mk = |site: &str| {
217 let mut s =
218 Subscription::new("https://example.com/feed.xml", "2026-01-01T00:00:00.000Z");
219 s.site_url = Some(site.into());
220 s
221 };
222 let vetted =
223 VettedSubscription::all(&[mk("https://a.example/"), mk("javascript:alert(1)")]);
224 assert_eq!(vetted.len(), 2, "the batch lost a record");
225 let first = serde_json::to_value(&vetted[0]).unwrap();
226 let second = serde_json::to_value(&vetted[1]).unwrap();
227 assert_eq!(first["siteUrl"], "https://a.example/");
228 assert!(
229 second.get("siteUrl").is_none(),
230 "one bad record in a batch must be cleaned, not the whole batch dropped"
231 );
232 }
233
234 #[test]
235 fn a_saved_record_with_a_hostile_url_cannot_be_built() {
236 for hostile in [
237 "javascript:alert(1)",
238 "data:text/html;base64,PHNjcmlwdD4=",
239 "vbscript:msgbox(1)",
240 "not a url",
241 ] {
242 let saved = Saved::new(hostile, "2026-01-01T00:00:00.000Z");
243 assert!(
244 VettedSaved::new(&saved).is_err(),
245 "{hostile:?} produced a publishable saved record"
246 );
247 }
248 }
249
250 #[test]
251 fn a_legitimate_saved_record_survives_and_is_normalised() {
252 let saved = Saved::new(" https://example.com/post ", "2026-01-01T00:00:00.000Z");
253 let vetted = VettedSaved::new(&saved).expect("a plain https url is publishable");
254 let rendered = serde_json::to_value(&vetted).unwrap();
255 assert_eq!(rendered["url"], "https://example.com/post");
256 }
257}