arch-toolkit 0.3.0

Complete Rust toolkit for Arch Linux package management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
//! Security advisory Atom feed fetching and parsing (security.archlinux.org).

use crate::error::Result;
use crate::types::news::{AdvisorySeverity, SecurityAdvisory};

use super::arch::{extract_between, unescape_xml};
use super::date::normalize_feed_date;

/// URL of the official Arch Linux security advisory Atom feed.
pub const ADVISORY_FEED_URL: &str = "https://security.archlinux.org/advisory/feed.atom";

/// What: Parse security advisory Atom content into advisories.
///
/// Inputs:
/// - `body`: Raw Atom feed XML.
/// - `limit`: Maximum number of advisories to return (best-effort).
/// - `cutoff_date`: Optional `YYYY-MM-DD` date; parsing stops at the first
///   entry older than this (feeds are newest-first).
///
/// Output:
/// - Parsed advisories with normalized dates, newest first.
///
/// Details:
/// - Iteratively scans `<entry>` blocks extracting `<title>`, link `href`,
///   `<updated>`/`<published>`, and `<summary>`, ported from Pacsea's
///   `fetch_security_advisories()`.
/// - Package names and severity are extracted from the advisory title
///   best-effort (Pacsea left both empty/Unknown); titles look like
///   `[ASA-202607-1] openssl: multiple issues` or `ASA-202607-1: openssl: ...`.
/// - Pure function: unit-testable without network access.
///
/// # Example
///
/// ```
/// use arch_toolkit::news::parse_advisories_atom;
///
/// let atom = r#"<entry><title>[ASA-202607-1] openssl: multiple issues</title>
/// <link href="https://security.archlinux.org/ASA-202607-1"/>
/// <updated>2026-07-01T12:00:00Z</updated></entry>"#;
/// let advisories = parse_advisories_atom(atom, 10, None);
/// assert_eq!(advisories.len(), 1);
/// assert_eq!(advisories[0].packages, vec!["openssl".to_string()]);
/// ```
#[must_use]
pub fn parse_advisories_atom(
    body: &str,
    limit: usize,
    cutoff_date: Option<&str>,
) -> Vec<SecurityAdvisory> {
    let mut items: Vec<SecurityAdvisory> = Vec::new();
    let mut pos = 0;
    while items.len() < limit {
        let Some(start) = body[pos..].find("<entry>") else {
            break;
        };
        let s = pos + start;
        let end = body[s..].find("</entry>").map_or(body.len(), |e| s + e + 8);
        let chunk = &body[s..end];

        let title = extract_between(chunk, "<title>", "</title>")
            .map(|t| unescape_xml(&t))
            .unwrap_or_default();
        let link = extract_link_href(chunk).unwrap_or_default();
        let raw_date = extract_between(chunk, "<updated>", "</updated>")
            .or_else(|| extract_between(chunk, "<published>", "</published>"))
            .unwrap_or_default();
        let date = normalize_feed_date(&raw_date);
        // Early date filtering: stop when entries become older than the cutoff
        if let Some(cutoff) = cutoff_date
            && date.as_str() < cutoff
        {
            break;
        }
        let summary = extract_between(chunk, "<summary>", "</summary>")
            .map(|t| unescape_xml(&t))
            .filter(|t| !t.is_empty());
        let entry_id = extract_between(chunk, "<id>", "</id>")
            .map(|t| t.trim().to_string())
            .filter(|t| !t.is_empty());
        let id = entry_id.clone().unwrap_or_else(|| {
            if link.is_empty() {
                if title.is_empty() {
                    raw_date.clone()
                } else {
                    title.clone()
                }
            } else {
                link.clone()
            }
        });
        let url = if link.is_empty() {
            entry_id
        } else {
            Some(link)
        };

        // The feed's <content> block carries structured "Severity:" and
        // "Package :" fields; fall back to title heuristics when absent.
        let (content_severity, content_packages) = extract_content(chunk).map_or_else(
            || (AdvisorySeverity::Unknown, Vec::new()),
            |c| parse_content_fields(&c),
        );
        let severity = if content_severity == AdvisorySeverity::Unknown {
            extract_severity(&title, summary.as_deref())
        } else {
            content_severity
        };
        let packages = if content_packages.is_empty() {
            extract_packages(&title)
        } else {
            content_packages
        };

        items.push(SecurityAdvisory {
            id,
            date,
            severity,
            packages,
            title: if title.is_empty() {
                "Advisory".to_string()
            } else {
                title
            },
            summary,
            url,
        });
        pos = end;
    }
    items
}

/// What: Fetch recent security advisories from the official Atom feed URL.
///
/// Inputs:
/// - `client`: Caller-provided HTTP client controlling transport policy.
/// - `limit`: Maximum number of advisories to return (best-effort).
/// - `cutoff_date`: Optional `YYYY-MM-DD` date for early filtering.
///
/// Output:
/// - `Ok(Vec<SecurityAdvisory>)` with normalized dates, newest first.
///
/// Details:
/// - Delegates to [`fetch_security_advisories_from`] with
///   [`ADVISORY_FEED_URL`].
/// - No cache is used unless callers opt into
///   [`fetch_security_advisories_cached`].
///
/// # Errors
///
/// Returns an error for transport, response-status, response-bound, or UTF-8
/// failures.
pub async fn fetch_security_advisories(
    client: &reqwest::Client,
    limit: usize,
    cutoff_date: Option<&str>,
) -> Result<Vec<SecurityAdvisory>> {
    fetch_security_advisories_from(client, ADVISORY_FEED_URL, limit, cutoff_date).await
}

/// What: Fetch and parse advisories from a caller-specified Atom URL.
///
/// Inputs:
/// - `client`: Caller-provided HTTP client controlling transport policy.
/// - `feed_url`: Absolute HTTP(S) Atom URL, useful for proxies and fixtures.
/// - `limit`: Maximum number of advisories to return (best-effort).
/// - `cutoff_date`: Optional `YYYY-MM-DD` date for early filtering.
///
/// Output:
/// - Parsed advisory values from the successful bounded feed response.
///
/// Details:
/// - Uses the same bounded response policy as RSS news while preserving the
///   existing parse and identifier semantics.
///
/// # Errors
///
/// Returns an error for invalid URLs, failed requests, non-success statuses,
/// oversized bodies, or invalid UTF-8.
pub async fn fetch_security_advisories_from(
    client: &reqwest::Client,
    feed_url: &str,
    limit: usize,
    cutoff_date: Option<&str>,
) -> Result<Vec<SecurityAdvisory>> {
    let body = super::article::fetch_bounded_text(
        client,
        feed_url,
        super::arch::MAX_FEED_RESPONSE_BYTES,
        "advisory feed",
    )
    .await?;
    tracing::debug!(bytes = body.len(), "fetched security advisories");
    Ok(parse_advisories_atom(&body, limit, cutoff_date))
}

/// What: Fetch official security advisories with an optional generic feed cache.
///
/// Inputs:
/// - `client`: Caller-provided HTTP client controlling transport policy.
/// - `limit`: Maximum number of advisories to return (best-effort).
/// - `cutoff_date`: Optional `YYYY-MM-DD` date for early filtering.
/// - `cache`: Optional generic feed cache; `None` always fetches fresh content.
///
/// Output:
/// - Parsed advisories from a cache hit or successful bounded HTTP response.
///
/// Details:
/// - Delegates to [`fetch_security_advisories_cached_from`] using the official
///   advisory feed URL and never uses AUR cache internals.
///
/// # Errors
///
/// Returns requested cache, transport, status, bound, or UTF-8 errors.
pub async fn fetch_security_advisories_cached(
    client: &reqwest::Client,
    limit: usize,
    cutoff_date: Option<&str>,
    cache: Option<&dyn super::FeedCache>,
) -> Result<Vec<SecurityAdvisory>> {
    fetch_security_advisories_cached_from(client, ADVISORY_FEED_URL, limit, cutoff_date, cache)
        .await
}

/// What: Fetch caller-specified advisories with an optional generic feed cache.
///
/// Inputs:
/// - `client`: Caller-provided HTTP client controlling transport policy.
/// - `feed_url`: Absolute HTTP(S) Atom URL, useful for proxies and fixtures.
/// - `limit`: Maximum number of advisories to return (best-effort).
/// - `cutoff_date`: Optional `YYYY-MM-DD` date for early filtering.
/// - `cache`: Optional generic feed cache; `None` always fetches fresh content.
///
/// Output:
/// - Parsed advisories from a cache hit or newly stored successful response.
///
/// Details:
/// - Uses the `security-advisory` namespace, so a shared cache cannot confuse
///   Atom advisory payloads with Arch news RSS payloads at the same URL.
///
/// # Errors
///
/// Returns requested cache, transport, status, bound, or UTF-8 errors.
pub async fn fetch_security_advisories_cached_from(
    client: &reqwest::Client,
    feed_url: &str,
    limit: usize,
    cutoff_date: Option<&str>,
    cache: Option<&dyn super::FeedCache>,
) -> Result<Vec<SecurityAdvisory>> {
    let body = super::arch::fetch_cached_feed_text(
        client,
        feed_url,
        "security-advisory",
        "advisory feed",
        cache,
    )
    .await?;
    Ok(parse_advisories_atom(&body, limit, cutoff_date))
}

/// What: Extract the href attribute of the first `<link>` tag in an entry.
///
/// Inputs:
/// - `s`: Atom entry XML chunk.
///
/// Output:
/// - `Some(href)` when found, `None` otherwise.
///
/// Details:
/// - Ported from Pacsea's `extract_link_href()`.
fn extract_link_href(s: &str) -> Option<String> {
    let link_pos = s.find("<link")?;
    let rest = &s[link_pos..];
    let href_pos = rest.find("href=\"")?;
    let after = &rest[href_pos + 6..];
    let end = after.find('"')?;
    Some(after[..end].to_string())
}

/// What: Extract and unescape the `<content>` block from an Atom entry.
///
/// Inputs:
/// - `chunk`: Atom entry XML chunk.
///
/// Output:
/// - `Some(text)` with entities decoded when a content block exists.
///
/// Details:
/// - The tag carries attributes (`<content type="html">`), so the opening
///   tag is scanned to its closing `>` before extracting the body.
fn extract_content(chunk: &str) -> Option<String> {
    let start_tag = chunk.find("<content")?;
    let rest = &chunk[start_tag..];
    let open_end = rest.find('>')? + 1;
    let end = rest.find("</content>")?;
    if open_end >= end {
        return None;
    }
    Some(unescape_xml(&rest[open_end..end]))
}

/// What: Parse severity and package fields from advisory content text.
///
/// Inputs:
/// - `content`: Unescaped advisory content with `<br/>`-separated lines
///   (format: `Severity: High`, `Package : nodejs-lts-jod`).
///
/// Output:
/// - Parsed severity (Unknown when absent) and package names.
///
/// Details:
/// - The security.archlinux.org feed embeds the full advisory text in
///   `<content>`; its key-value header is the authoritative severity source.
fn parse_content_fields(content: &str) -> (AdvisorySeverity, Vec<String>) {
    let mut severity = AdvisorySeverity::Unknown;
    let mut packages: Vec<String> = Vec::new();
    for line in content
        .split("<br/>")
        .flat_map(|part| part.split("<br>"))
        .flat_map(str::lines)
    {
        let Some((key, value)) = line.split_once(':') else {
            continue;
        };
        let value = value.trim().trim_end_matches("</pre>").trim();
        match key.trim().to_ascii_lowercase().as_str() {
            "severity" => severity = AdvisorySeverity::parse(value),
            "package" | "packages" => {
                packages = value
                    .split_whitespace()
                    .map(str::trim)
                    .filter(|p| !p.is_empty())
                    .map(ToString::to_string)
                    .collect();
            }
            _ => {}
        }
        if severity != AdvisorySeverity::Unknown && !packages.is_empty() {
            break;
        }
    }
    (severity, packages)
}

/// What: Extract affected package names from an advisory title.
///
/// Inputs:
/// - `title`: Advisory title like `[ASA-202607-1] openssl: multiple issues`
///   or `ASA-202607-1: chromium: arbitrary code execution`.
///
/// Output:
/// - Package names (comma-separated lists are split), empty when the title
///   does not match the expected shape.
///
/// Details:
/// - Best-effort improvement over Pacsea, which always returned an empty list.
fn extract_packages(title: &str) -> Vec<String> {
    // Strip a leading "[ASA-...]" or "ASA-...:" identifier
    let rest = title.strip_prefix('[').map_or_else(
        || {
            if title.starts_with("ASA-") || title.starts_with("AVG-") {
                title.split_once(':').map_or("", |(_, rest)| rest)
            } else {
                title
            }
        },
        |after| after.split_once(']').map_or("", |(_, rest)| rest),
    );
    // The package segment is everything before the next ':'
    let Some((pkg_part, _issue)) = rest.split_once(':') else {
        return Vec::new();
    };
    pkg_part
        .split(',')
        .map(str::trim)
        .filter(|p| {
            !p.is_empty()
                && p.bytes().all(|b| {
                    b.is_ascii_lowercase()
                        || b.is_ascii_digit()
                        || matches!(b, b'@' | b'.' | b'_' | b'+' | b'-')
                })
        })
        .map(ToString::to_string)
        .collect()
}

/// What: Extract a severity classification from advisory title or summary.
///
/// Inputs:
/// - `title`: Advisory title.
/// - `summary`: Optional advisory summary.
///
/// Output:
/// - Parsed severity; `Unknown` when neither text states one.
///
/// Details:
/// - Looks for "(critical)"-style markers and "severity: high"-style phrases.
/// - The Atom feed usually omits severity, so `Unknown` is the common case
///   (matching Pacsea's behavior).
fn extract_severity(title: &str, summary: Option<&str>) -> AdvisorySeverity {
    for text in [Some(title), summary].into_iter().flatten() {
        let lower = text.to_ascii_lowercase();
        for candidate in ["critical", "high", "medium", "low"] {
            if lower.contains(&format!("({candidate})"))
                || lower.contains(&format!("severity: {candidate}"))
            {
                return AdvisorySeverity::parse(candidate);
            }
        }
    }
    AdvisorySeverity::Unknown
}

#[cfg(test)]
mod tests {
    use super::*;

    const SAMPLE_ATOM: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
  <title>[ASA-202607-1] openssl: multiple issues</title>
  <link href="https://security.archlinux.org/ASA-202607-1"/>
  <updated>2026-07-01T12:00:00Z</updated>
  <summary>Multiple issues have been found (critical)</summary>
</entry>
<entry>
  <title>ASA-202606-9: chromium,electron32: arbitrary code execution</title>
  <link href="https://security.archlinux.org/ASA-202606-9"/>
  <published>2026-06-20T08:30:00Z</published>
</entry>
<entry>
  <title>Old advisory</title>
  <link href="https://security.archlinux.org/ASA-202501-1"/>
  <updated>2026-01-01T00:00:00Z</updated>
</entry>
</feed>"#;

    #[test]
    /// What: Verify Atom entries parse with dates, links, and summaries.
    ///
    /// Inputs:
    /// - Sample feed with `updated` and `published` date variants.
    ///
    /// Output:
    /// - Three advisories with normalized dates and correct URLs.
    ///
    /// Details:
    /// - `published` must be used when `updated` is absent.
    fn parses_entries() {
        let advisories = parse_advisories_atom(SAMPLE_ATOM, 10, None);
        assert_eq!(advisories.len(), 3);
        assert_eq!(advisories[0].date, "2026-07-01");
        assert_eq!(
            advisories[0].url.as_deref(),
            Some("https://security.archlinux.org/ASA-202607-1")
        );
        assert_eq!(
            advisories[0].summary.as_deref(),
            Some("Multiple issues have been found (critical)")
        );
        assert_eq!(advisories[1].date, "2026-06-20");
        assert!(advisories[2].summary.is_none());
    }

    #[test]
    /// What: Verify package extraction from bracketed and colon title forms.
    ///
    /// Inputs:
    /// - Sample feed titles in `[ASA-...] pkg:` and `ASA-...: pkg,pkg2:` forms.
    ///
    /// Output:
    /// - Single and comma-separated package lists extracted.
    ///
    /// Details:
    /// - Improvement over Pacsea (which returned empty lists).
    fn extracts_packages() {
        let advisories = parse_advisories_atom(SAMPLE_ATOM, 10, None);
        assert_eq!(advisories[0].packages, ["openssl"]);
        assert_eq!(advisories[1].packages, ["chromium", "electron32"]);
        assert!(advisories[2].packages.is_empty());
    }

    #[test]
    /// What: Verify severity extraction from summary markers.
    ///
    /// Inputs:
    /// - Entry with "(critical)" in the summary; entries without markers.
    ///
    /// Output:
    /// - Critical for the first, Unknown for the rest.
    ///
    /// Details:
    /// - The feed usually omits severity, so Unknown is the default.
    fn extracts_severity() {
        let advisories = parse_advisories_atom(SAMPLE_ATOM, 10, None);
        assert_eq!(advisories[0].severity, AdvisorySeverity::Critical);
        assert_eq!(advisories[1].severity, AdvisorySeverity::Unknown);
    }

    #[test]
    /// What: Verify limit and cutoff-date filtering.
    ///
    /// Inputs:
    /// - Sample feed with limit 1 and a cutoff between entries.
    ///
    /// Output:
    /// - Truncated result sets respecting both bounds.
    ///
    /// Details:
    /// - Cutoff stops at the first entry older than the given date.
    fn respects_limit_and_cutoff() {
        assert_eq!(parse_advisories_atom(SAMPLE_ATOM, 1, None).len(), 1);
        let filtered = parse_advisories_atom(SAMPLE_ATOM, 10, Some("2026-06-01"));
        assert_eq!(filtered.len(), 2);
    }

    #[test]
    /// What: Verify identifier fallback (URL → title → raw date).
    ///
    /// Inputs:
    /// - Entries with and without links.
    ///
    /// Output:
    /// - URL used as id when present; title used otherwise.
    ///
    /// Details:
    /// - Matches Pacsea's id fallback chain.
    fn id_fallback() {
        let advisories = parse_advisories_atom(SAMPLE_ATOM, 10, None);
        assert_eq!(
            advisories[0].id,
            "https://security.archlinux.org/ASA-202607-1"
        );

        let no_link =
            "<entry><title>Some advisory</title><updated>2026-07-01T00:00:00Z</updated></entry>";
        let items = parse_advisories_atom(no_link, 10, None);
        assert_eq!(items[0].id, "Some advisory");
    }

    #[test]
    /// What: Verify severity and packages parse from the live feed's content block.
    ///
    /// Inputs:
    /// - Entry shaped like the real security.archlinux.org feed: entity-encoded
    ///   HTML content with `Severity:` and `Package :` fields, plus an `<id>`.
    ///
    /// Output:
    /// - Severity High, package from the content header, id from `<id>`.
    ///
    /// Details:
    /// - The content header is authoritative and overrides title heuristics.
    fn parses_live_feed_content_block() {
        let atom = r#"<entry>
    <id>https://security.archlinux.org/ASA-202505-7</id>
    <title>[ASA-202505-7] nodejs-lts-jod: denial of service</title>
    <updated>2025-05-18T23:32:35.759771+00:00</updated>
    <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>
  </entry>"#;
        let advisories = parse_advisories_atom(atom, 10, None);
        assert_eq!(advisories.len(), 1);
        let advisory = &advisories[0];
        assert_eq!(advisory.severity, AdvisorySeverity::High);
        assert_eq!(advisory.packages, ["nodejs-lts-jod"]);
        assert_eq!(advisory.id, "https://security.archlinux.org/ASA-202505-7");
        assert_eq!(
            advisory.url.as_deref(),
            Some("https://security.archlinux.org/ASA-202505-7")
        );
        assert_eq!(advisory.date, "2025-05-18");
    }

    #[test]
    /// What: Verify empty and malformed feeds yield no advisories.
    ///
    /// Inputs:
    /// - Empty string and non-Atom text.
    ///
    /// Output:
    /// - Empty vectors, no panic.
    ///
    /// Details:
    /// - Parser must degrade gracefully on unexpected content.
    fn handles_garbage() {
        assert!(parse_advisories_atom("", 10, None).is_empty());
        assert!(parse_advisories_atom("no entries here", 10, None).is_empty());
    }
}