use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::helpers::sync::Lock;
use crate::helpers::text::Text;
use crate::models::Limits;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HSTSPolicy {
pub max_age: i64,
pub include_subdomains: bool,
pub preload: bool,
}
impl HSTSPolicy {
pub fn append(out: &mut [u8], written: &mut usize, part: &[u8]) {
let end = (*written + part.len()).min(out.len());
out[*written..end].copy_from_slice(&part[..end - *written]);
*written = end;
}
pub fn new(max_age: i64) -> Self {
Self { max_age, include_subdomains: false, preload: false }
}
pub fn build(&self) -> String {
self.value().into_string()
}
pub fn value(&self) -> Text {
let mut out = [0u8; 64];
let mut written = 0;
Self::append(&mut out, &mut written, b"max-age=");
let mut digits = [0u8; 20];
let mut index = digits.len();
let mut age = self.max_age.max(0) as u64;
loop {
index -= 1;
digits[index] = b'0' + (age % 10) as u8;
age /= 10;
if age == 0 {
break;
}
}
Self::append(&mut out, &mut written, &digits[index..]);
if self.include_subdomains {
Self::append(&mut out, &mut written, b"; includeSubDomains");
}
if self.preload {
Self::append(&mut out, &mut written, b"; preload");
}
Text::from_ascii(&out[..written])
}
pub fn parse(value: &str) -> Option<Self> {
let mut policy = Self { max_age: -1, include_subdomains: false, preload: false };
let mut seen = HashSet::new();
for directive in value.split(';') {
let directive = directive.trim();
if directive.is_empty() {
continue;
}
let (name, rest) = match directive.split_once('=') {
Some((name, rest)) => (name.trim(), Some(rest.trim())),
None => (directive, None),
};
let name = name.to_ascii_lowercase();
if !seen.insert(name.clone()) {
return None;
}
match name.as_str() {
"max-age" => {
let digits = rest?.trim_matches('"');
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
policy.max_age = digits.parse().unwrap_or(i64::MAX);
}
"includesubdomains" => policy.include_subdomains = true,
"preload" => policy.preload = true,
_ => {}
}
}
(policy.max_age >= 0).then_some(policy)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HSTSLimits {
pub max_hsts_entries: u32,
}
impl Default for HSTSLimits {
fn default() -> Self {
Self { max_hsts_entries: 4096 }
}
}
impl From<Limits> for HSTSLimits {
fn from(limits: Limits) -> Self {
Self { max_hsts_entries: limits.max_hsts_entries }
}
}
pub struct HSTSStore {
pub entries: Mutex<HashMap<String, (Instant, bool)>>,
pub limits: HSTSLimits,
}
impl HSTSStore {
pub fn new() -> Self {
Self { entries: Mutex::new(HashMap::new()), limits: HSTSLimits::default() }
}
pub fn with_limits(mut self, limits: impl Into<HSTSLimits>) -> Self {
self.limits = limits.into();
self
}
pub fn normalize(host: &str) -> Option<String> {
let host = host.trim().trim_matches(['[', ']']).trim_end_matches('.');
if host.is_empty() || host.parse::<IpAddr>().is_ok() {
return None;
}
Some(host.to_ascii_lowercase())
}
pub fn learn(&self, host: &str, header: &str, secure: bool, now: Instant) {
if !secure {
return;
}
let Some(name) = Self::normalize(host) else {
return;
};
let Some(policy) = HSTSPolicy::parse(header) else {
return;
};
let mut entries = Lock::on(&self.entries);
if policy.max_age <= 0 {
entries.remove(&name);
return;
}
let Some(expiry) = now.checked_add(Duration::from_secs(policy.max_age as u64)) else {
return;
};
entries.retain(|_, (expiry, _)| *expiry > now);
if entries.len() >= self.limits.max_hsts_entries as usize && !entries.contains_key(&name) {
let soonest = entries.iter().min_by_key(|(_, (expiry, _))| *expiry).map(|(host, _)| host.clone());
if let Some(soonest) = soonest {
entries.remove(&soonest);
}
}
entries.insert(name, (expiry, policy.include_subdomains));
}
pub fn secure(&self, host: &str, now: Instant) -> bool {
let Some(name) = Self::normalize(host) else {
return false;
};
let mut entries = Lock::on(&self.entries);
let mut candidate = name.as_str();
loop {
if let Some((expiry, include_subdomains)) = entries.get(candidate).copied() {
if expiry <= now {
entries.remove(candidate);
} else if candidate == name || include_subdomains {
return true;
}
}
match candidate.split_once('.') {
Some((_, parent)) => candidate = parent,
None => return false,
}
}
}
pub fn prune(&self, now: Instant) {
Lock::on(&self.entries).retain(|_, (expiry, _)| *expiry > now);
}
}
impl Default for HSTSStore {
fn default() -> Self {
Self::new()
}
}