Skip to main content

ssg/audit/gates/
feeds.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! RSS / Atom feed structural validation gate.
5//!
6//! Walks the site root for `*.xml` files (typically `rss.xml`,
7//! `feed.xml`, `atom.xml`), discriminates RSS 2.0 vs Atom 1.0 by the
8//! root element name, and asserts the required-field set for each
9//! variant.
10//!
11//! RSS 2.0 required fields: `<channel>` containing `<title>`,
12//! `<link>`, `<description>`. At least one `<item>` is expected when
13//! the feed exists.
14//!
15//! Atom 1.0 required fields: `<feed>` containing `<title>`, `<id>`,
16//! `<updated>`. At least one `<entry>` is expected.
17
18use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
19use crate::walk::walk_files;
20
21const NAME: &str = "feeds";
22
23/// RSS/Atom feed structural validation gate.
24///
25/// # Examples
26///
27/// ```
28/// use ssg::audit::AuditGate;
29/// use ssg::audit::gates::feeds::FeedsGate;
30/// assert_eq!(FeedsGate.name(), "feeds");
31/// ```
32#[derive(Debug, Clone, Copy)]
33pub struct FeedsGate;
34
35impl AuditGate for FeedsGate {
36    fn name(&self) -> &'static str {
37        NAME
38    }
39
40    fn explain(&self) -> &'static str {
41        "Walks the site root for *.xml files, classifies each as RSS \
42         2.0 or Atom 1.0 by root element, and asserts the required \
43         fields per variant. RSS 2.0 demands <channel> with <title>, \
44         <link>, <description>; Atom 1.0 demands <feed> with <title>, \
45         <id>, <updated>. Empty feeds raise a warning."
46    }
47
48    fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
49        let mut findings = Vec::new();
50        let xml_files = walk_files(&site.root, "xml").unwrap_or_default();
51        if xml_files.is_empty() {
52            return findings;
53        }
54        for path in &xml_files {
55            let Ok(text) = std::fs::read_to_string(path) else {
56                continue;
57            };
58            let rel = site.rel(path);
59            if let Ok(doc) = oxml::parse(&text) {
60                match classify_doc(&doc) {
61                    FeedKind::Rss2 => {
62                        check_rss2_doc(&doc, &rel, &mut findings);
63                    }
64                    FeedKind::Atom1 => {
65                        check_atom1_doc(&doc, &rel, &mut findings);
66                    }
67                    FeedKind::Sitemap | FeedKind::Unknown => {}
68                }
69                continue;
70            }
71            let kind = classify(&text);
72            match kind {
73                FeedKind::Rss2 => check_rss2(&text, &rel, &mut findings),
74                FeedKind::Atom1 => check_atom1(&text, &rel, &mut findings),
75                FeedKind::Sitemap => {
76                    // Sitemaps are validated by their own pipeline, not this gate.
77                }
78                FeedKind::Unknown => {
79                    // Quietly ignore unknown XML — many SSGs emit
80                    // OPML, GraphQL schema, etc.
81                }
82            }
83        }
84        findings
85    }
86}
87
88enum FeedKind {
89    Rss2,
90    Atom1,
91    Sitemap,
92    Unknown,
93}
94
95fn classify_doc(doc: &oxml::Document) -> FeedKind {
96    let Some(root) = doc.root_element() else {
97        return FeedKind::Unknown;
98    };
99    let Some(name) = doc.element_name(root) else {
100        return FeedKind::Unknown;
101    };
102    let local = name.local.to_ascii_lowercase();
103    match local.as_str() {
104        "rss" => FeedKind::Rss2,
105        "feed"
106            if name
107                .namespace
108                .as_deref()
109                .is_some_and(|ns| ns.to_ascii_lowercase().contains("atom")) =>
110        {
111            FeedKind::Atom1
112        }
113        "urlset" | "sitemapindex" => FeedKind::Sitemap,
114        _ => FeedKind::Unknown,
115    }
116}
117
118fn check_rss2_doc(
119    doc: &oxml::Document,
120    rel: &str,
121    findings: &mut Vec<Finding>,
122) {
123    let Some(root) = doc.root_element() else {
124        return;
125    };
126    let channel = doc.children(root).iter().copied().find(|&child| {
127        doc.element_name(child)
128            .is_some_and(|n| n.local.eq_ignore_ascii_case("channel"))
129    });
130    let Some(channel) = channel else {
131        findings.push(
132            Finding::new(NAME, Severity::Error, "RSS feed missing <channel>")
133                .with_code("RSS-CHANNEL")
134                .with_path(rel.to_string()),
135        );
136        return;
137    };
138    for required in ["title", "link", "description"] {
139        let present = doc.children(channel).iter().any(|&child| {
140            doc.element_name(child)
141                .is_some_and(|n| n.local.eq_ignore_ascii_case(required))
142        });
143        if !present {
144            findings.push(
145                Finding::new(
146                    NAME,
147                    Severity::Error,
148                    format!("RSS <channel> missing <{required}>"),
149                )
150                .with_code(format!("RSS-{}", required.to_uppercase()))
151                .with_path(rel.to_string()),
152            );
153        }
154    }
155    let has_item = doc.children(channel).iter().any(|&child| {
156        doc.element_name(child)
157            .is_some_and(|n| n.local.eq_ignore_ascii_case("item"))
158    });
159    if !has_item {
160        findings.push(
161            Finding::new(
162                NAME,
163                Severity::Warn,
164                "RSS feed contains zero <item> entries",
165            )
166            .with_code("RSS-EMPTY")
167            .with_path(rel.to_string()),
168        );
169    }
170}
171
172fn check_atom1_doc(
173    doc: &oxml::Document,
174    rel: &str,
175    findings: &mut Vec<Finding>,
176) {
177    let Some(root) = doc.root_element() else {
178        return;
179    };
180    for required in ["title", "id", "updated"] {
181        let present = doc.children(root).iter().any(|&child| {
182            doc.element_name(child)
183                .is_some_and(|n| n.local.eq_ignore_ascii_case(required))
184        });
185        if !present {
186            findings.push(
187                Finding::new(
188                    NAME,
189                    Severity::Error,
190                    format!("Atom feed missing <{required}>"),
191                )
192                .with_code(format!("ATOM-{}", required.to_uppercase()))
193                .with_path(rel.to_string()),
194            );
195        }
196    }
197    let has_entry = doc.children(root).iter().any(|&child| {
198        doc.element_name(child)
199            .is_some_and(|n| n.local.eq_ignore_ascii_case("entry"))
200    });
201    if !has_entry {
202        findings.push(
203            Finding::new(
204                NAME,
205                Severity::Warn,
206                "Atom feed contains zero <entry> elements",
207            )
208            .with_code("ATOM-EMPTY")
209            .with_path(rel.to_string()),
210        );
211    }
212}
213
214fn classify(text: &str) -> FeedKind {
215    let lower = text.to_lowercase();
216    if lower.contains("<rss") {
217        FeedKind::Rss2
218    } else if lower.contains("<feed") && lower.contains("atom") {
219        FeedKind::Atom1
220    } else if lower.contains("<urlset") || lower.contains("<sitemapindex") {
221        FeedKind::Sitemap
222    } else {
223        FeedKind::Unknown
224    }
225}
226
227fn check_rss2(text: &str, rel: &str, findings: &mut Vec<Finding>) {
228    let lower = text.to_lowercase();
229    if !lower.contains("<channel") {
230        findings.push(
231            Finding::new(NAME, Severity::Error, "RSS feed missing <channel>")
232                .with_code("RSS-CHANNEL")
233                .with_path(rel.to_string()),
234        );
235        return;
236    }
237    for required in ["<title", "<link", "<description"] {
238        if !lower.contains(required) {
239            findings.push(
240                Finding::new(
241                    NAME,
242                    Severity::Error,
243                    format!("RSS <channel> missing {required}>"),
244                )
245                .with_code(format!(
246                    "RSS-{}",
247                    required.trim_start_matches('<').to_uppercase()
248                ))
249                .with_path(rel.to_string()),
250            );
251        }
252    }
253    if !lower.contains("<item") {
254        findings.push(
255            Finding::new(
256                NAME,
257                Severity::Warn,
258                "RSS feed contains zero <item> entries",
259            )
260            .with_code("RSS-EMPTY")
261            .with_path(rel.to_string()),
262        );
263    }
264}
265
266fn check_atom1(text: &str, rel: &str, findings: &mut Vec<Finding>) {
267    let lower = text.to_lowercase();
268    for required in ["<title", "<id", "<updated"] {
269        if !lower.contains(required) {
270            findings.push(
271                Finding::new(
272                    NAME,
273                    Severity::Error,
274                    format!("Atom feed missing {required}>"),
275                )
276                .with_code(format!(
277                    "ATOM-{}",
278                    required.trim_start_matches('<').to_uppercase()
279                ))
280                .with_path(rel.to_string()),
281            );
282        }
283    }
284    if !lower.contains("<entry") {
285        findings.push(
286            Finding::new(
287                NAME,
288                Severity::Warn,
289                "Atom feed contains zero <entry> elements",
290            )
291            .with_code("ATOM-EMPTY")
292            .with_path(rel.to_string()),
293        );
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn site_with_files(files: &[(&str, &str)]) -> Site {
302        let tmp = tempfile::tempdir().unwrap();
303        let root = tmp.path().to_path_buf();
304        for (name, body) in files {
305            let p = root.join(name);
306            // root.join(name) always has a parent directory.
307            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
308            std::fs::write(&p, body).unwrap();
309        }
310        std::mem::forget(tmp);
311        Site {
312            root,
313            html_files: Vec::new(),
314        }
315    }
316
317    #[test]
318    fn valid_rss_passes() {
319        let body = r#"<?xml version="1.0"?>
320<rss version="2.0"><channel>
321  <title>x</title><link>https://x</link><description>d</description>
322  <item><title>i</title></item>
323</channel></rss>"#;
324        let s = site_with_files(&[("rss.xml", body)]);
325        let f = FeedsGate.run(&s, &AuditOptions::default());
326        assert!(f.is_empty(), "got {f:?}");
327    }
328
329    #[test]
330    fn rss_missing_channel_fields_flagged() {
331        let body = r#"<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>"#;
332        let s = site_with_files(&[("rss.xml", body)]);
333        let f = FeedsGate.run(&s, &AuditOptions::default());
334        let codes: Vec<_> =
335            f.iter().filter_map(|x| x.code.as_deref()).collect();
336        assert!(codes.contains(&"RSS-TITLE"));
337        assert!(codes.contains(&"RSS-LINK"));
338        assert!(codes.contains(&"RSS-DESCRIPTION"));
339    }
340
341    #[test]
342    fn rss_missing_channel_short_circuits() {
343        let body = r#"<?xml version="1.0"?><rss version="2.0"></rss>"#;
344        let s = site_with_files(&[("rss.xml", body)]);
345        let f = FeedsGate.run(&s, &AuditOptions::default());
346        let codes: Vec<_> =
347            f.iter().filter_map(|x| x.code.as_deref()).collect();
348        assert_eq!(codes, vec!["RSS-CHANNEL"]);
349    }
350
351    #[test]
352    fn rss_empty_item_warns() {
353        let body = r#"<?xml version="1.0"?>
354<rss version="2.0"><channel>
355  <title>x</title><link>https://x</link><description>d</description>
356</channel></rss>"#;
357        let s = site_with_files(&[("rss.xml", body)]);
358        let f = FeedsGate.run(&s, &AuditOptions::default());
359        assert!(
360            f.iter()
361                .filter_map(|x| x.code.as_deref())
362                .any(|c| c == "RSS-EMPTY"),
363            "got {f:?}"
364        );
365        assert!(
366            f.iter().any(|x| matches!(x.severity, Severity::Warn)),
367            "got {f:?}"
368        );
369    }
370
371    #[test]
372    fn valid_atom_passes() {
373        let body = r#"<?xml version="1.0"?>
374<feed xmlns="http://www.w3.org/2005/Atom">
375  <title>x</title><id>urn:x</id><updated>2026-01-01</updated>
376  <entry><title>e</title></entry>
377</feed>"#;
378        let s = site_with_files(&[("atom.xml", body)]);
379        let f = FeedsGate.run(&s, &AuditOptions::default());
380        assert!(f.is_empty(), "got {f:?}");
381    }
382
383    #[test]
384    fn atom_missing_fields_flagged() {
385        let body = r#"<?xml version="1.0"?>
386<feed xmlns="http://www.w3.org/2005/Atom"></feed>"#;
387        let s = site_with_files(&[("atom.xml", body)]);
388        let f = FeedsGate.run(&s, &AuditOptions::default());
389        let codes: Vec<_> =
390            f.iter().filter_map(|x| x.code.as_deref()).collect();
391        assert!(codes.contains(&"ATOM-TITLE"));
392        assert!(codes.contains(&"ATOM-ID"));
393        assert!(codes.contains(&"ATOM-UPDATED"));
394        assert!(codes.contains(&"ATOM-EMPTY"));
395    }
396
397    #[test]
398    fn sitemap_xml_ignored() {
399        let body = r#"<?xml version="1.0"?>
400<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
401  <url><loc>https://x</loc></url>
402</urlset>"#;
403        let s = site_with_files(&[("sitemap.xml", body)]);
404        let f = FeedsGate.run(&s, &AuditOptions::default());
405        assert!(f.is_empty(), "{:?}", f);
406    }
407
408    #[test]
409    fn unknown_xml_ignored() {
410        let body = r#"<?xml version="1.0"?><opml version="2.0"></opml>"#;
411        let s = site_with_files(&[("opml.xml", body)]);
412        let f = FeedsGate.run(&s, &AuditOptions::default());
413        assert!(f.is_empty(), "{:?}", f);
414    }
415
416    #[test]
417    fn no_xml_files_returns_empty() {
418        let s = site_with_files(&[("index.html", "<html></html>")]);
419        let f = FeedsGate.run(&s, &AuditOptions::default());
420        assert!(f.is_empty(), "{:?}", f);
421    }
422
423    #[test]
424    fn unreadable_xml_skipped() {
425        // Create a directory with .xml extension so read_to_string errors.
426        let tmp = tempfile::tempdir().unwrap();
427        let root = tmp.path().to_path_buf();
428        std::fs::create_dir_all(root.join("malformed.xml")).unwrap();
429        let s = Site {
430            root: root.clone(),
431            html_files: Vec::new(),
432        };
433        // Should not panic — the gate `continue`s on read error.
434        let _ = FeedsGate.run(&s, &AuditOptions::default());
435        std::mem::forget(tmp);
436    }
437
438    #[test]
439    fn non_utf8_xml_file_is_skipped() {
440        // Invalid UTF-8 makes read_to_string fail on a real file that
441        // walk_files does return, driving the per-file `continue`.
442        let s = site_with_files(&[("good.xml", "<urlset></urlset>")]);
443        std::fs::write(s.root.join("bad.xml"), [0xFFu8, 0xFE, 0x00, 0x9F])
444            .unwrap();
445        let f = FeedsGate.run(&s, &AuditOptions::default());
446        assert!(f.is_empty(), "non-UTF8 xml must be skipped: {f:?}");
447    }
448
449    #[test]
450    fn metadata_methods_exposed() {
451        let g = FeedsGate;
452        assert_eq!(g.name(), "feeds");
453        assert!(g.explain().contains("RSS"));
454        // Debug + Clone + Copy traits
455        let _: FeedsGate = g;
456        let _clone = g;
457        let dbg = format!("{g:?}");
458        assert!(dbg.contains("FeedsGate"));
459    }
460}