use serde::Serialize;
use crate::user_extension::{academic_repo_hosts, oa_registry_hosts};
use crate::{DenialContext, DenialReason};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RemediationKind {
AdditionalHost,
TrustFlag,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Remediation {
pub kind: RemediationKind,
pub value: String,
pub note: String,
}
#[must_use]
pub fn for_denial(denial: &DenialContext) -> Vec<Remediation> {
let mut out = Vec::new();
if !matches!(
denial.reason,
DenialReason::RedirectNotInAllowlist | DenialReason::HostInBlockList
) {
return out;
}
let Some(host) = denial.attempted.as_deref() else {
return out;
};
for (pattern, why) in widening_suggestions(host) {
out.push(Remediation {
kind: RemediationKind::AdditionalHost,
value: pattern,
note: why.to_string(),
});
}
if let Some((flag, pattern, why)) = trust_flag_for_host(host) {
out.push(Remediation {
kind: RemediationKind::TrustFlag,
value: flag.to_string(),
note: format!("{host} matches {pattern} — {why}"),
});
}
out
}
#[must_use]
pub fn trust_flag_for_host(host: &str) -> Option<(&'static str, String, String)> {
let host_lc = host.to_ascii_lowercase();
for (flag, hosts) in [
("trust_academic_repos", academic_repo_hosts()),
("trust_oa_registries", oa_registry_hosts()),
] {
for h in hosts {
if pattern_matches(&host_lc, h.host.as_str()) {
return Some((
flag,
h.host.as_str().to_string(),
h.note.unwrap_or_else(|| "a curated host class".to_string()),
));
}
}
}
None
}
fn pattern_matches(host_lc: &str, pattern: &str) -> bool {
let pat_lc = pattern.to_ascii_lowercase();
match pat_lc.strip_prefix("*.") {
Some(suffix) => host_lc == suffix || host_lc.ends_with(&format!(".{suffix}")),
None => host_lc == pat_lc,
}
}
#[must_use]
pub fn widening_suggestions(host: &str) -> Vec<(String, &'static str)> {
let mut out = vec![(host.to_string(), "this hop only")];
let labels: Vec<&str> = host.split('.').filter(|l| !l.is_empty()).collect();
if labels.len() < 2 || looks_like_public_suffix(&labels) {
return out;
}
if labels.len() == 2 {
out.push((format!("*.{host}"), "and its subdomains"));
return out;
}
let parent_labels = &labels[1..];
if looks_like_public_suffix(parent_labels) {
return out;
}
let parent = parent_labels.join(".");
out.push((format!("*.{parent}"), "the whole domain"));
out.push((parent, "apex too (a wildcard does not match it)"));
out
}
fn looks_like_public_suffix(labels: &[&str]) -> bool {
match labels {
[_] => true,
[sld, tld] => {
tld.len() == 2
&& matches!(
*sld,
"co" | "com"
| "ne"
| "net"
| "or"
| "org"
| "ac"
| "edu"
| "gov"
| "go"
| "gr"
| "lg"
| "mil"
| "id"
| "in"
)
}
_ => false,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn denial(host: &str) -> DenialContext {
DenialContext {
reason: DenialReason::RedirectNotInAllowlist,
source: Some("oa-publisher".to_string()),
attempted: Some(host.to_string()),
expected: Some(vec!["*.arxiv.org".to_string()]),
hop_index: None,
cap: None,
actual: None,
}
}
#[test]
fn a_university_repository_offers_the_trust_flag_as_well_as_the_host() {
let r = for_denial(&denial("strathprints.strath.ac.uk"));
let hosts: Vec<&str> = r
.iter()
.filter(|x| x.kind == RemediationKind::AdditionalHost)
.map(|x| x.value.as_str())
.collect();
assert_eq!(
hosts,
vec![
"strathprints.strath.ac.uk",
"*.strath.ac.uk",
"strath.ac.uk"
],
"the #443 widening set, unchanged by the move"
);
let flag = r
.iter()
.find(|x| x.kind == RemediationKind::TrustFlag)
.expect("an *.ac.uk host must surface trust_academic_repos");
assert_eq!(flag.value, "trust_academic_repos");
assert!(
flag.note.contains("*.ac.uk"),
"say WHICH curated pattern matched, or the flag looks like a guess: {}",
flag.note
);
}
#[test]
fn a_publisher_host_offers_no_trust_flag() {
let r = for_denial(&denial("pubs.ams.org"));
assert!(
r.iter().all(|x| x.kind == RemediationKind::AdditionalHost),
"no curated class covers ams.org: {r:?}"
);
assert_eq!(
r.iter().map(|x| x.value.as_str()).collect::<Vec<_>>(),
vec!["pubs.ams.org", "*.ams.org", "ams.org"]
);
}
#[test]
fn a_reason_with_no_config_channel_suggests_nothing() {
for reason in [
DenialReason::SizeCapExceeded,
DenialReason::InsecureScheme,
DenialReason::CapabilityNotGranted,
] {
let mut d = denial("example.org");
d.reason = reason;
assert!(
for_denial(&d).is_empty(),
"{reason:?} has no allowlist channel, so it must suggest nothing"
);
}
}
#[test]
fn a_public_suffix_is_never_offered_for_trust() {
for host in ["example.co.uk", "example.ac.jp", "foo.com", "localhost"] {
for (pattern, _) in widening_suggestions(host) {
assert!(
!matches!(pattern.as_str(), "*.co.uk" | "co.uk" | "*.ac.jp" | "ac.jp"),
"{host} must never suggest trusting a public suffix, got {pattern}"
);
}
}
}
}