use crate::error::Result;
use crate::types::news::{AdvisorySeverity, SecurityAdvisory};
use super::arch::{extract_between, unescape_xml};
use super::date::normalize_feed_date;
pub const ADVISORY_FEED_URL: &str = "https://security.archlinux.org/advisory/feed.atom";
#[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);
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)
};
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
}
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
}
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))
}
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
}
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))
}
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())
}
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]))
}
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)
}
fn extract_packages(title: &str) -> Vec<String> {
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),
);
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()
}
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]
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]
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]
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]
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]
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]
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"><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>
</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]
fn handles_garbage() {
assert!(parse_advisories_atom("", 10, None).is_empty());
assert!(parse_advisories_atom("no entries here", 10, None).is_empty());
}
}