Skip to main content

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}