Skip to main content

arch_toolkit/news/
advisories.rs

1//! Security advisory Atom feed fetching and parsing (security.archlinux.org).
2
3use crate::error::Result;
4use crate::types::news::{AdvisorySeverity, SecurityAdvisory};
5
6use super::arch::{extract_between, unescape_xml};
7use super::date::normalize_feed_date;
8
9/// URL of the official Arch Linux security advisory Atom feed.
10pub const ADVISORY_FEED_URL: &str = "https://security.archlinux.org/advisory/feed.atom";
11
12/// What: Parse security advisory Atom content into advisories.
13///
14/// Inputs:
15/// - `body`: Raw Atom feed XML.
16/// - `limit`: Maximum number of advisories to return (best-effort).
17/// - `cutoff_date`: Optional `YYYY-MM-DD` date; parsing stops at the first
18///   entry older than this (feeds are newest-first).
19///
20/// Output:
21/// - Parsed advisories with normalized dates, newest first.
22///
23/// Details:
24/// - Iteratively scans `<entry>` blocks extracting `<title>`, link `href`,
25///   `<updated>`/`<published>`, and `<summary>`, ported from Pacsea's
26///   `fetch_security_advisories()`.
27/// - Package names and severity are extracted from the advisory title
28///   best-effort (Pacsea left both empty/Unknown); titles look like
29///   `[ASA-202607-1] openssl: multiple issues` or `ASA-202607-1: openssl: ...`.
30/// - Pure function: unit-testable without network access.
31///
32/// # Example
33///
34/// ```
35/// use arch_toolkit::news::parse_advisories_atom;
36///
37/// let atom = r#"<entry><title>[ASA-202607-1] openssl: multiple issues</title>
38/// <link href="https://security.archlinux.org/ASA-202607-1"/>
39/// <updated>2026-07-01T12:00:00Z</updated></entry>"#;
40/// let advisories = parse_advisories_atom(atom, 10, None);
41/// assert_eq!(advisories.len(), 1);
42/// assert_eq!(advisories[0].packages, vec!["openssl".to_string()]);
43/// ```
44#[must_use]
45pub fn parse_advisories_atom(
46    body: &str,
47    limit: usize,
48    cutoff_date: Option<&str>,
49) -> Vec<SecurityAdvisory> {
50    let mut items: Vec<SecurityAdvisory> = Vec::new();
51    let mut pos = 0;
52    while items.len() < limit {
53        let Some(start) = body[pos..].find("<entry>") else {
54            break;
55        };
56        let s = pos + start;
57        let end = body[s..].find("</entry>").map_or(body.len(), |e| s + e + 8);
58        let chunk = &body[s..end];
59
60        let title = extract_between(chunk, "<title>", "</title>")
61            .map(|t| unescape_xml(&t))
62            .unwrap_or_default();
63        let link = extract_link_href(chunk).unwrap_or_default();
64        let raw_date = extract_between(chunk, "<updated>", "</updated>")
65            .or_else(|| extract_between(chunk, "<published>", "</published>"))
66            .unwrap_or_default();
67        let date = normalize_feed_date(&raw_date);
68        // Early date filtering: stop when entries become older than the cutoff
69        if let Some(cutoff) = cutoff_date
70            && date.as_str() < cutoff
71        {
72            break;
73        }
74        let summary = extract_between(chunk, "<summary>", "</summary>")
75            .map(|t| unescape_xml(&t))
76            .filter(|t| !t.is_empty());
77        let entry_id = extract_between(chunk, "<id>", "</id>")
78            .map(|t| t.trim().to_string())
79            .filter(|t| !t.is_empty());
80        let id = entry_id.clone().unwrap_or_else(|| {
81            if link.is_empty() {
82                if title.is_empty() {
83                    raw_date.clone()
84                } else {
85                    title.clone()
86                }
87            } else {
88                link.clone()
89            }
90        });
91        let url = if link.is_empty() {
92            entry_id
93        } else {
94            Some(link)
95        };
96
97        // The feed's <content> block carries structured "Severity:" and
98        // "Package :" fields; fall back to title heuristics when absent.
99        let (content_severity, content_packages) = extract_content(chunk).map_or_else(
100            || (AdvisorySeverity::Unknown, Vec::new()),
101            |c| parse_content_fields(&c),
102        );
103        let severity = if content_severity == AdvisorySeverity::Unknown {
104            extract_severity(&title, summary.as_deref())
105        } else {
106            content_severity
107        };
108        let packages = if content_packages.is_empty() {
109            extract_packages(&title)
110        } else {
111            content_packages
112        };
113
114        items.push(SecurityAdvisory {
115            id,
116            date,
117            severity,
118            packages,
119            title: if title.is_empty() {
120                "Advisory".to_string()
121            } else {
122                title
123            },
124            summary,
125            url,
126        });
127        pos = end;
128    }
129    items
130}
131
132/// What: Fetch recent security advisories from the official Atom feed URL.
133///
134/// Inputs:
135/// - `client`: Caller-provided HTTP client controlling transport policy.
136/// - `limit`: Maximum number of advisories to return (best-effort).
137/// - `cutoff_date`: Optional `YYYY-MM-DD` date for early filtering.
138///
139/// Output:
140/// - `Ok(Vec<SecurityAdvisory>)` with normalized dates, newest first.
141///
142/// Details:
143/// - Delegates to [`fetch_security_advisories_from`] with
144///   [`ADVISORY_FEED_URL`].
145/// - No cache is used unless callers opt into
146///   [`fetch_security_advisories_cached`].
147///
148/// # Errors
149///
150/// Returns an error for transport, response-status, response-bound, or UTF-8
151/// failures.
152pub async fn fetch_security_advisories(
153    client: &reqwest::Client,
154    limit: usize,
155    cutoff_date: Option<&str>,
156) -> Result<Vec<SecurityAdvisory>> {
157    fetch_security_advisories_from(client, ADVISORY_FEED_URL, limit, cutoff_date).await
158}
159
160/// What: Fetch and parse advisories from a caller-specified Atom URL.
161///
162/// Inputs:
163/// - `client`: Caller-provided HTTP client controlling transport policy.
164/// - `feed_url`: Absolute HTTP(S) Atom URL, useful for proxies and fixtures.
165/// - `limit`: Maximum number of advisories to return (best-effort).
166/// - `cutoff_date`: Optional `YYYY-MM-DD` date for early filtering.
167///
168/// Output:
169/// - Parsed advisory values from the successful bounded feed response.
170///
171/// Details:
172/// - Uses the same bounded response policy as RSS news while preserving the
173///   existing parse and identifier semantics.
174///
175/// # Errors
176///
177/// Returns an error for invalid URLs, failed requests, non-success statuses,
178/// oversized bodies, or invalid UTF-8.
179pub async fn fetch_security_advisories_from(
180    client: &reqwest::Client,
181    feed_url: &str,
182    limit: usize,
183    cutoff_date: Option<&str>,
184) -> Result<Vec<SecurityAdvisory>> {
185    let body = super::article::fetch_bounded_text(
186        client,
187        feed_url,
188        super::arch::MAX_FEED_RESPONSE_BYTES,
189        "advisory feed",
190    )
191    .await?;
192    tracing::debug!(bytes = body.len(), "fetched security advisories");
193    Ok(parse_advisories_atom(&body, limit, cutoff_date))
194}
195
196/// What: Fetch official security advisories with an optional generic feed cache.
197///
198/// Inputs:
199/// - `client`: Caller-provided HTTP client controlling transport policy.
200/// - `limit`: Maximum number of advisories to return (best-effort).
201/// - `cutoff_date`: Optional `YYYY-MM-DD` date for early filtering.
202/// - `cache`: Optional generic feed cache; `None` always fetches fresh content.
203///
204/// Output:
205/// - Parsed advisories from a cache hit or successful bounded HTTP response.
206///
207/// Details:
208/// - Delegates to [`fetch_security_advisories_cached_from`] using the official
209///   advisory feed URL and never uses AUR cache internals.
210///
211/// # Errors
212///
213/// Returns requested cache, transport, status, bound, or UTF-8 errors.
214pub async fn fetch_security_advisories_cached(
215    client: &reqwest::Client,
216    limit: usize,
217    cutoff_date: Option<&str>,
218    cache: Option<&dyn super::FeedCache>,
219) -> Result<Vec<SecurityAdvisory>> {
220    fetch_security_advisories_cached_from(client, ADVISORY_FEED_URL, limit, cutoff_date, cache)
221        .await
222}
223
224/// What: Fetch caller-specified advisories with an optional generic feed cache.
225///
226/// Inputs:
227/// - `client`: Caller-provided HTTP client controlling transport policy.
228/// - `feed_url`: Absolute HTTP(S) Atom URL, useful for proxies and fixtures.
229/// - `limit`: Maximum number of advisories to return (best-effort).
230/// - `cutoff_date`: Optional `YYYY-MM-DD` date for early filtering.
231/// - `cache`: Optional generic feed cache; `None` always fetches fresh content.
232///
233/// Output:
234/// - Parsed advisories from a cache hit or newly stored successful response.
235///
236/// Details:
237/// - Uses the `security-advisory` namespace, so a shared cache cannot confuse
238///   Atom advisory payloads with Arch news RSS payloads at the same URL.
239///
240/// # Errors
241///
242/// Returns requested cache, transport, status, bound, or UTF-8 errors.
243pub async fn fetch_security_advisories_cached_from(
244    client: &reqwest::Client,
245    feed_url: &str,
246    limit: usize,
247    cutoff_date: Option<&str>,
248    cache: Option<&dyn super::FeedCache>,
249) -> Result<Vec<SecurityAdvisory>> {
250    let body = super::arch::fetch_cached_feed_text(
251        client,
252        feed_url,
253        "security-advisory",
254        "advisory feed",
255        cache,
256    )
257    .await?;
258    Ok(parse_advisories_atom(&body, limit, cutoff_date))
259}
260
261/// What: Extract the href attribute of the first `<link>` tag in an entry.
262///
263/// Inputs:
264/// - `s`: Atom entry XML chunk.
265///
266/// Output:
267/// - `Some(href)` when found, `None` otherwise.
268///
269/// Details:
270/// - Ported from Pacsea's `extract_link_href()`.
271fn extract_link_href(s: &str) -> Option<String> {
272    let link_pos = s.find("<link")?;
273    let rest = &s[link_pos..];
274    let href_pos = rest.find("href=\"")?;
275    let after = &rest[href_pos + 6..];
276    let end = after.find('"')?;
277    Some(after[..end].to_string())
278}
279
280/// What: Extract and unescape the `<content>` block from an Atom entry.
281///
282/// Inputs:
283/// - `chunk`: Atom entry XML chunk.
284///
285/// Output:
286/// - `Some(text)` with entities decoded when a content block exists.
287///
288/// Details:
289/// - The tag carries attributes (`<content type="html">`), so the opening
290///   tag is scanned to its closing `>` before extracting the body.
291fn extract_content(chunk: &str) -> Option<String> {
292    let start_tag = chunk.find("<content")?;
293    let rest = &chunk[start_tag..];
294    let open_end = rest.find('>')? + 1;
295    let end = rest.find("</content>")?;
296    if open_end >= end {
297        return None;
298    }
299    Some(unescape_xml(&rest[open_end..end]))
300}
301
302/// What: Parse severity and package fields from advisory content text.
303///
304/// Inputs:
305/// - `content`: Unescaped advisory content with `<br/>`-separated lines
306///   (format: `Severity: High`, `Package : nodejs-lts-jod`).
307///
308/// Output:
309/// - Parsed severity (Unknown when absent) and package names.
310///
311/// Details:
312/// - The security.archlinux.org feed embeds the full advisory text in
313///   `<content>`; its key-value header is the authoritative severity source.
314fn parse_content_fields(content: &str) -> (AdvisorySeverity, Vec<String>) {
315    let mut severity = AdvisorySeverity::Unknown;
316    let mut packages: Vec<String> = Vec::new();
317    for line in content
318        .split("<br/>")
319        .flat_map(|part| part.split("<br>"))
320        .flat_map(str::lines)
321    {
322        let Some((key, value)) = line.split_once(':') else {
323            continue;
324        };
325        let value = value.trim().trim_end_matches("</pre>").trim();
326        match key.trim().to_ascii_lowercase().as_str() {
327            "severity" => severity = AdvisorySeverity::parse(value),
328            "package" | "packages" => {
329                packages = value
330                    .split_whitespace()
331                    .map(str::trim)
332                    .filter(|p| !p.is_empty())
333                    .map(ToString::to_string)
334                    .collect();
335            }
336            _ => {}
337        }
338        if severity != AdvisorySeverity::Unknown && !packages.is_empty() {
339            break;
340        }
341    }
342    (severity, packages)
343}
344
345/// What: Extract affected package names from an advisory title.
346///
347/// Inputs:
348/// - `title`: Advisory title like `[ASA-202607-1] openssl: multiple issues`
349///   or `ASA-202607-1: chromium: arbitrary code execution`.
350///
351/// Output:
352/// - Package names (comma-separated lists are split), empty when the title
353///   does not match the expected shape.
354///
355/// Details:
356/// - Best-effort improvement over Pacsea, which always returned an empty list.
357fn extract_packages(title: &str) -> Vec<String> {
358    // Strip a leading "[ASA-...]" or "ASA-...:" identifier
359    let rest = title.strip_prefix('[').map_or_else(
360        || {
361            if title.starts_with("ASA-") || title.starts_with("AVG-") {
362                title.split_once(':').map_or("", |(_, rest)| rest)
363            } else {
364                title
365            }
366        },
367        |after| after.split_once(']').map_or("", |(_, rest)| rest),
368    );
369    // The package segment is everything before the next ':'
370    let Some((pkg_part, _issue)) = rest.split_once(':') else {
371        return Vec::new();
372    };
373    pkg_part
374        .split(',')
375        .map(str::trim)
376        .filter(|p| {
377            !p.is_empty()
378                && p.bytes().all(|b| {
379                    b.is_ascii_lowercase()
380                        || b.is_ascii_digit()
381                        || matches!(b, b'@' | b'.' | b'_' | b'+' | b'-')
382                })
383        })
384        .map(ToString::to_string)
385        .collect()
386}
387
388/// What: Extract a severity classification from advisory title or summary.
389///
390/// Inputs:
391/// - `title`: Advisory title.
392/// - `summary`: Optional advisory summary.
393///
394/// Output:
395/// - Parsed severity; `Unknown` when neither text states one.
396///
397/// Details:
398/// - Looks for "(critical)"-style markers and "severity: high"-style phrases.
399/// - The Atom feed usually omits severity, so `Unknown` is the common case
400///   (matching Pacsea's behavior).
401fn extract_severity(title: &str, summary: Option<&str>) -> AdvisorySeverity {
402    for text in [Some(title), summary].into_iter().flatten() {
403        let lower = text.to_ascii_lowercase();
404        for candidate in ["critical", "high", "medium", "low"] {
405            if lower.contains(&format!("({candidate})"))
406                || lower.contains(&format!("severity: {candidate}"))
407            {
408                return AdvisorySeverity::parse(candidate);
409            }
410        }
411    }
412    AdvisorySeverity::Unknown
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    const SAMPLE_ATOM: &str = r#"<?xml version="1.0" encoding="utf-8"?>
420<feed xmlns="http://www.w3.org/2005/Atom">
421<entry>
422  <title>[ASA-202607-1] openssl: multiple issues</title>
423  <link href="https://security.archlinux.org/ASA-202607-1"/>
424  <updated>2026-07-01T12:00:00Z</updated>
425  <summary>Multiple issues have been found (critical)</summary>
426</entry>
427<entry>
428  <title>ASA-202606-9: chromium,electron32: arbitrary code execution</title>
429  <link href="https://security.archlinux.org/ASA-202606-9"/>
430  <published>2026-06-20T08:30:00Z</published>
431</entry>
432<entry>
433  <title>Old advisory</title>
434  <link href="https://security.archlinux.org/ASA-202501-1"/>
435  <updated>2026-01-01T00:00:00Z</updated>
436</entry>
437</feed>"#;
438
439    #[test]
440    /// What: Verify Atom entries parse with dates, links, and summaries.
441    ///
442    /// Inputs:
443    /// - Sample feed with `updated` and `published` date variants.
444    ///
445    /// Output:
446    /// - Three advisories with normalized dates and correct URLs.
447    ///
448    /// Details:
449    /// - `published` must be used when `updated` is absent.
450    fn parses_entries() {
451        let advisories = parse_advisories_atom(SAMPLE_ATOM, 10, None);
452        assert_eq!(advisories.len(), 3);
453        assert_eq!(advisories[0].date, "2026-07-01");
454        assert_eq!(
455            advisories[0].url.as_deref(),
456            Some("https://security.archlinux.org/ASA-202607-1")
457        );
458        assert_eq!(
459            advisories[0].summary.as_deref(),
460            Some("Multiple issues have been found (critical)")
461        );
462        assert_eq!(advisories[1].date, "2026-06-20");
463        assert!(advisories[2].summary.is_none());
464    }
465
466    #[test]
467    /// What: Verify package extraction from bracketed and colon title forms.
468    ///
469    /// Inputs:
470    /// - Sample feed titles in `[ASA-...] pkg:` and `ASA-...: pkg,pkg2:` forms.
471    ///
472    /// Output:
473    /// - Single and comma-separated package lists extracted.
474    ///
475    /// Details:
476    /// - Improvement over Pacsea (which returned empty lists).
477    fn extracts_packages() {
478        let advisories = parse_advisories_atom(SAMPLE_ATOM, 10, None);
479        assert_eq!(advisories[0].packages, ["openssl"]);
480        assert_eq!(advisories[1].packages, ["chromium", "electron32"]);
481        assert!(advisories[2].packages.is_empty());
482    }
483
484    #[test]
485    /// What: Verify severity extraction from summary markers.
486    ///
487    /// Inputs:
488    /// - Entry with "(critical)" in the summary; entries without markers.
489    ///
490    /// Output:
491    /// - Critical for the first, Unknown for the rest.
492    ///
493    /// Details:
494    /// - The feed usually omits severity, so Unknown is the default.
495    fn extracts_severity() {
496        let advisories = parse_advisories_atom(SAMPLE_ATOM, 10, None);
497        assert_eq!(advisories[0].severity, AdvisorySeverity::Critical);
498        assert_eq!(advisories[1].severity, AdvisorySeverity::Unknown);
499    }
500
501    #[test]
502    /// What: Verify limit and cutoff-date filtering.
503    ///
504    /// Inputs:
505    /// - Sample feed with limit 1 and a cutoff between entries.
506    ///
507    /// Output:
508    /// - Truncated result sets respecting both bounds.
509    ///
510    /// Details:
511    /// - Cutoff stops at the first entry older than the given date.
512    fn respects_limit_and_cutoff() {
513        assert_eq!(parse_advisories_atom(SAMPLE_ATOM, 1, None).len(), 1);
514        let filtered = parse_advisories_atom(SAMPLE_ATOM, 10, Some("2026-06-01"));
515        assert_eq!(filtered.len(), 2);
516    }
517
518    #[test]
519    /// What: Verify identifier fallback (URL → title → raw date).
520    ///
521    /// Inputs:
522    /// - Entries with and without links.
523    ///
524    /// Output:
525    /// - URL used as id when present; title used otherwise.
526    ///
527    /// Details:
528    /// - Matches Pacsea's id fallback chain.
529    fn id_fallback() {
530        let advisories = parse_advisories_atom(SAMPLE_ATOM, 10, None);
531        assert_eq!(
532            advisories[0].id,
533            "https://security.archlinux.org/ASA-202607-1"
534        );
535
536        let no_link =
537            "<entry><title>Some advisory</title><updated>2026-07-01T00:00:00Z</updated></entry>";
538        let items = parse_advisories_atom(no_link, 10, None);
539        assert_eq!(items[0].id, "Some advisory");
540    }
541
542    #[test]
543    /// What: Verify severity and packages parse from the live feed's content block.
544    ///
545    /// Inputs:
546    /// - Entry shaped like the real security.archlinux.org feed: entity-encoded
547    ///   HTML content with `Severity:` and `Package :` fields, plus an `<id>`.
548    ///
549    /// Output:
550    /// - Severity High, package from the content header, id from `<id>`.
551    ///
552    /// Details:
553    /// - The content header is authoritative and overrides title heuristics.
554    fn parses_live_feed_content_block() {
555        let atom = r#"<entry>
556    <id>https://security.archlinux.org/ASA-202505-7</id>
557    <title>[ASA-202505-7] nodejs-lts-jod: denial of service</title>
558    <updated>2025-05-18T23:32:35.759771+00:00</updated>
559    <content type="html">&lt;pre&gt;Arch Linux Security Advisory ASA-202505-7&lt;br/&gt;Severity: High&lt;br/&gt;Date    : 2025-05-18&lt;br/&gt;Package : nodejs-lts-jod&lt;br/&gt;Type    : denial of service&lt;/pre&gt;</content>
560  </entry>"#;
561        let advisories = parse_advisories_atom(atom, 10, None);
562        assert_eq!(advisories.len(), 1);
563        let advisory = &advisories[0];
564        assert_eq!(advisory.severity, AdvisorySeverity::High);
565        assert_eq!(advisory.packages, ["nodejs-lts-jod"]);
566        assert_eq!(advisory.id, "https://security.archlinux.org/ASA-202505-7");
567        assert_eq!(
568            advisory.url.as_deref(),
569            Some("https://security.archlinux.org/ASA-202505-7")
570        );
571        assert_eq!(advisory.date, "2025-05-18");
572    }
573
574    #[test]
575    /// What: Verify empty and malformed feeds yield no advisories.
576    ///
577    /// Inputs:
578    /// - Empty string and non-Atom text.
579    ///
580    /// Output:
581    /// - Empty vectors, no panic.
582    ///
583    /// Details:
584    /// - Parser must degrade gracefully on unexpected content.
585    fn handles_garbage() {
586        assert!(parse_advisories_atom("", 10, None).is_empty());
587        assert!(parse_advisories_atom("no entries here", 10, None).is_empty());
588    }
589}