use std::collections::BTreeSet;
use crate::localization::{self, LocalizedMessage, keys};
use thiserror::Error;
use url::Url;
use crate::host_pattern::{HostCandidate, HostPattern, HostPatternError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkPolicy {
allowed_schemes: BTreeSet<String>,
allowed_hosts: Option<Vec<HostPattern>>,
blocked_hosts: Vec<HostPattern>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum NetworkPolicyConfigError {
#[error("{message}")]
EmptyScheme {
message: LocalizedMessage,
},
#[error("{message}")]
InvalidScheme {
scheme: String,
message: LocalizedMessage,
},
#[error("{message}")]
EmptyAllowlist {
message: LocalizedMessage,
},
#[error(transparent)]
HostPattern(#[from] HostPatternError),
}
impl NetworkPolicy {
#[must_use]
pub fn https_only() -> Self {
let mut schemes = BTreeSet::new();
schemes.insert(String::from("https"));
Self {
allowed_schemes: schemes,
allowed_hosts: None,
blocked_hosts: Vec::new(),
}
}
pub fn allow_scheme(
mut self,
scheme: impl AsRef<str>,
) -> Result<Self, NetworkPolicyConfigError> {
let candidate = scheme.as_ref();
if candidate.is_empty() {
return Err(NetworkPolicyConfigError::EmptyScheme {
message: localization::message(keys::NETWORK_POLICY_SCHEME_EMPTY),
});
}
let mut chars = candidate.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) {
return Err(NetworkPolicyConfigError::InvalidScheme {
scheme: candidate.to_owned(),
message: localization::message(keys::NETWORK_POLICY_SCHEME_INVALID)
.with_arg("scheme", candidate),
});
}
if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
return Err(NetworkPolicyConfigError::InvalidScheme {
scheme: candidate.to_owned(),
message: localization::message(keys::NETWORK_POLICY_SCHEME_INVALID)
.with_arg("scheme", candidate),
});
}
self.allowed_schemes.insert(candidate.to_ascii_lowercase());
Ok(self)
}
fn extend_allowed_hosts<I>(mut self, patterns_iter: I) -> Result<Self, NetworkPolicyConfigError>
where
I: IntoIterator<Item = HostPattern>,
{
let mut patterns = self.allowed_hosts.take().unwrap_or_default();
patterns.extend(patterns_iter);
if patterns.is_empty() {
return Err(NetworkPolicyConfigError::EmptyAllowlist {
message: localization::message(keys::NETWORK_POLICY_ALLOWLIST_EMPTY),
});
}
self.allowed_hosts = Some(patterns);
Ok(self)
}
pub fn allow_hosts<I, S>(self, hosts: I) -> Result<Self, NetworkPolicyConfigError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let parsed = hosts
.into_iter()
.map(|host| HostPattern::parse(host.as_ref()))
.collect::<Result<Vec<_>, _>>()?;
self.extend_allowed_hosts(parsed)
}
pub fn allow_host_patterns<I>(self, hosts: I) -> Result<Self, NetworkPolicyConfigError>
where
I: IntoIterator<Item = HostPattern>,
{
self.extend_allowed_hosts(hosts)
}
#[must_use]
pub fn deny_all_hosts(mut self) -> Self {
self.allowed_hosts = Some(Vec::new());
self
}
pub fn block_host(mut self, host: impl AsRef<str>) -> Result<Self, NetworkPolicyConfigError> {
let pattern = HostPattern::parse(host.as_ref())?;
self.blocked_hosts.push(pattern);
Ok(self)
}
#[must_use]
pub fn block_host_pattern(mut self, host: HostPattern) -> Self {
self.blocked_hosts.push(host);
self
}
pub fn evaluate(&self, url: &Url) -> Result<(), NetworkPolicyViolation> {
let scheme = url.scheme();
if !self.allowed_schemes.contains(scheme) {
return Err(NetworkPolicyViolation::SchemeNotAllowed {
scheme: scheme.to_owned(),
message: localization::message(keys::NETWORK_POLICY_SCHEME_NOT_ALLOWED)
.with_arg("scheme", scheme),
});
}
let host = url.host_str().filter(|host| !host.is_empty()).ok_or(
NetworkPolicyViolation::MissingHost {
message: localization::message(keys::NETWORK_POLICY_MISSING_HOST),
},
)?;
if self
.blocked_hosts
.iter()
.any(|pattern| pattern.matches(HostCandidate(host)))
{
return Err(NetworkPolicyViolation::HostBlocked {
host: host.to_owned(),
message: localization::message(keys::NETWORK_POLICY_HOST_BLOCKED)
.with_arg("host", host),
});
}
if self.allowed_hosts.as_ref().is_some_and(|allowlist| {
!allowlist
.iter()
.any(|pattern| pattern.matches(HostCandidate(host)))
}) {
return Err(NetworkPolicyViolation::HostNotAllowlisted {
host: host.to_owned(),
message: localization::message(keys::NETWORK_POLICY_HOST_NOT_ALLOWLISTED)
.with_arg("host", host),
});
}
Ok(())
}
}
impl Default for NetworkPolicy {
fn default() -> Self {
Self::https_only()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum NetworkPolicyViolation {
#[error("{message}")]
SchemeNotAllowed {
scheme: String,
message: LocalizedMessage,
},
#[error("{message}")]
MissingHost {
message: LocalizedMessage,
},
#[error("{message}")]
HostNotAllowlisted {
host: String,
message: LocalizedMessage,
},
#[error("{message}")]
HostBlocked {
host: String,
message: LocalizedMessage,
},
}
#[cfg(test)]
mod tests;