use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SeerError};
use crate::http::{GuardedFetcher, SendError};
use crate::validation::normalize_host;
const MAX_BODY: usize = 32 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SecurityTxtIssue {
NotFound,
NotPlainText,
MissingContact,
MissingExpires,
InvalidExpires,
MultipleExpires,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityTxtReport {
pub url: String,
pub found: bool,
pub contact: Vec<String>,
pub expires: Option<DateTime<Utc>>,
pub expired: bool,
pub signed: bool,
pub issues: Vec<SecurityTxtIssue>,
}
impl SecurityTxtReport {
pub fn is_valid(&self) -> bool {
self.found && self.issues.is_empty()
}
fn missing(url: &str, issue: SecurityTxtIssue) -> Self {
Self {
url: url.to_string(),
found: false,
contact: Vec::new(),
expires: None,
expired: false,
signed: false,
issues: vec![issue],
}
}
}
pub async fn fetch_security_txt(host: &str, timeout: Duration) -> Result<SecurityTxtReport> {
let host = normalize_host(host)?;
let fetcher = GuardedFetcher::new().with_timeout(timeout);
let url = format!("https://{host}/.well-known/security.txt");
Ok(fetch_url(&fetcher, &url, Utc::now()).await?)
}
pub(crate) async fn fetch_url(
fetcher: &GuardedFetcher,
url: &str,
now: DateTime<Utc>,
) -> std::result::Result<SecurityTxtReport, SendError> {
let fetcher = fetcher.clone().with_max_body(MAX_BODY);
let response = fetcher.get_traced(url).await.result?;
match response.head.status {
200 => {}
404 | 410 => return Ok(SecurityTxtReport::missing(url, SecurityTxtIssue::NotFound)),
status => {
return Err(SendError::Other(SeerError::HttpError(format!(
"security.txt fetch answered HTTP {status}"
))))
}
}
let plain = response.head.header("content-type").is_some_and(|ct| {
ct.trim_start()
.to_ascii_lowercase()
.starts_with("text/plain")
});
let head = response.body.trim_start().to_ascii_lowercase();
if !plain || head.starts_with("<!doctype") || head.starts_with("<html") {
return Ok(SecurityTxtReport::missing(
url,
SecurityTxtIssue::NotPlainText,
));
}
Ok(parse(url, &response.body, now))
}
pub(crate) fn parse(url: &str, body: &str, now: DateTime<Utc>) -> SecurityTxtReport {
let signed = body
.trim_start()
.starts_with("-----BEGIN PGP SIGNED MESSAGE-----");
let mut contact = Vec::new();
let mut expires_raw: Vec<&str> = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("-----") {
continue;
}
let Some((name, value)) = line.split_once(':') else {
continue;
};
match name.trim().to_ascii_lowercase().as_str() {
"contact" if !value.trim().is_empty() => contact.push(value.trim().to_string()),
"expires" => expires_raw.push(value.trim()),
_ => {}
}
}
let mut issues = Vec::new();
if contact.is_empty() {
issues.push(SecurityTxtIssue::MissingContact);
}
let expires = match expires_raw.as_slice() {
[] => {
issues.push(SecurityTxtIssue::MissingExpires);
None
}
[one, rest @ ..] => {
if !rest.is_empty() {
issues.push(SecurityTxtIssue::MultipleExpires);
}
match DateTime::parse_from_rfc3339(one) {
Ok(t) => Some(t.with_timezone(&Utc)),
Err(_) => {
issues.push(SecurityTxtIssue::InvalidExpires);
None
}
}
}
};
SecurityTxtReport {
url: url.to_string(),
found: true,
contact,
expired: expires.is_some_and(|t| t < now),
expires,
signed,
issues,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 10, 22, 0, 0, 0).unwrap()
}
const URL: &str = "https://example.com/.well-known/security.txt";
#[test]
fn parses_required_fields() {
let r = parse(
URL,
"# comment\nContact: mailto:security@example.com\nContact: https://example.com/vdp\nExpires: 2027-01-01T00:00:00Z\n",
now(),
);
assert!(r.found && r.is_valid() && !r.expired && !r.signed);
assert_eq!(r.contact.len(), 2);
}
#[test]
fn field_names_are_case_insensitive() {
let r = parse(
URL,
"contact: mailto:a@b.test\nEXPIRES: 2027-01-01T00:00:00Z\n",
now(),
);
assert!(r.is_valid(), "{:?}", r.issues);
}
#[test]
fn missing_fields_and_expiry_are_issues() {
let r = parse(URL, "Expires: 2020-01-01T00:00:00Z\n", now());
assert!(r.expired);
assert!(r.issues.contains(&SecurityTxtIssue::MissingContact));
let r = parse(URL, "Contact: mailto:a@b.test\nExpires: soon\n", now());
assert!(r.issues.contains(&SecurityTxtIssue::InvalidExpires));
let r = parse(URL, "Contact: mailto:a@b.test\n", now());
assert!(r.issues.contains(&SecurityTxtIssue::MissingExpires));
let r = parse(
URL,
"Contact: mailto:a@b.test\nExpires: 2027-01-01T00:00:00Z\nExpires: 2028-01-01T00:00:00Z\n",
now(),
);
assert!(r.issues.contains(&SecurityTxtIssue::MultipleExpires));
}
#[test]
fn an_empty_contact_is_not_a_contact() {
let r = parse(
URL,
"Contact:\nContact: \nExpires: 2027-01-01T00:00:00Z\n",
now(),
);
assert!(r.contact.is_empty());
assert_eq!(r.issues, vec![SecurityTxtIssue::MissingContact]);
}
#[test]
fn detects_a_pgp_signature_wrapper() {
let body = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nContact: mailto:a@b.test\nExpires: 2027-01-01T00:00:00Z\n-----BEGIN PGP SIGNATURE-----\nxyz\n-----END PGP SIGNATURE-----\n";
let r = parse(URL, body, now());
assert!(r.signed && r.is_valid(), "{:?}", r.issues);
}
#[tokio::test]
async fn html_soft_404_is_not_a_security_txt() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/security.txt"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/html")
.set_body_string("<!DOCTYPE html><html>Not found</html>"),
)
.mount(&server)
.await;
let fetcher = GuardedFetcher::new().allowing_private_hosts();
let url = format!("{}/.well-known/security.txt", server.uri());
let r = fetch_url(&fetcher, &url, now()).await.unwrap();
assert!(!r.found);
assert_eq!(r.issues, vec![SecurityTxtIssue::NotPlainText]);
}
#[tokio::test]
async fn a_404_is_not_found() {
let server = MockServer::start().await;
let fetcher = GuardedFetcher::new().allowing_private_hosts();
let url = format!("{}/.well-known/security.txt", server.uri());
let r = fetch_url(&fetcher, &url, now()).await.unwrap();
assert!(!r.found);
assert_eq!(r.issues, vec![SecurityTxtIssue::NotFound]);
}
async fn fetch_status(status: u16) -> std::result::Result<SecurityTxtReport, SendError> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/security.txt"))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
let fetcher = GuardedFetcher::new().allowing_private_hosts();
let url = format!("{}/.well-known/security.txt", server.uri());
fetch_url(&fetcher, &url, now()).await
}
#[tokio::test]
async fn only_404_and_410_mean_not_found() {
for status in [404, 410] {
let r = fetch_status(status).await.unwrap();
assert_eq!(r.issues, vec![SecurityTxtIssue::NotFound], "{status}");
}
}
#[tokio::test]
async fn blocked_throttled_or_failing_answers_are_unknown() {
for status in [401, 403, 429, 500, 503, 302] {
let r = fetch_status(status).await;
assert!(matches!(r, Err(SendError::Other(_))), "{status}: {r:?}");
}
}
#[tokio::test]
async fn a_plain_text_file_is_parsed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/security.txt"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/plain; charset=utf-8")
.set_body_string("Contact: mailto:a@b.test\nExpires: 2027-01-01T00:00:00Z\n"),
)
.mount(&server)
.await;
let fetcher = GuardedFetcher::new().allowing_private_hosts();
let url = format!("{}/.well-known/security.txt", server.uri());
let r = fetch_url(&fetcher, &url, now()).await.unwrap();
assert!(r.is_valid(), "{r:?}");
}
}