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"><pre>Arch Linux Security Advisory ASA-202505-7<br/>Severity: High<br/>Date : 2025-05-18<br/>Package : nodejs-lts-jod<br/>Type : denial of service</pre></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}