use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct NetworkPolicy {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowlist: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub denylist: Vec<String>,
}
impl NetworkPolicy {
pub fn unrestricted() -> Self {
Self::default()
}
pub fn is_restricted(&self) -> bool {
!self.allowlist.is_empty() || !self.denylist.is_empty()
}
pub fn is_host_allowed(&self, host: &str) -> bool {
let host = normalize_host(host);
if host.is_empty() {
return false;
}
if self.denylist.iter().any(|p| host_matches_pattern(&host, p)) {
return false;
}
if self.allowlist.is_empty() {
return true;
}
self.allowlist
.iter()
.any(|p| host_matches_pattern(&host, p))
}
pub fn ensure_url_allowed(&self, url: &str) -> Result<(), String> {
if !self.is_restricted() {
return Ok(());
}
let host = host_from_url(url)?;
if self.is_host_allowed(&host) {
Ok(())
} else {
Err(blocked_message(&host, url, self))
}
}
pub fn check_url(&self, url: &str) -> Result<(), String> {
let host = host_from_url(url)?;
if self.is_host_allowed(&host) {
Ok(())
} else {
Err(blocked_message(&host, url, self))
}
}
}
fn blocked_message(host: &str, url: &str, policy: &NetworkPolicy) -> String {
let mut msg = format!("Network policy blocked host `{host}` for URL: {url}");
if !policy.allowlist.is_empty() {
msg.push_str(&format!(
"\nAllowed patterns: {}",
policy.allowlist.join(", ")
));
}
if !policy.denylist.is_empty() {
msg.push_str(&format!(
"\nDenied patterns: {}",
policy.denylist.join(", ")
));
}
msg.push_str(
"\nConfigure `security.network_allowlist` / `security.network_denylist` in config.toml \
(or WHYCODES_NETWORK_ALLOWLIST / WHYCODES_NETWORK_DENYLIST).",
);
msg
}
pub fn normalize_host(host: &str) -> String {
let h = host.trim().trim_end_matches('.').to_ascii_lowercase();
if h.starts_with('[') && h.ends_with(']') && h.len() >= 2 {
h[1..h.len() - 1].to_string()
} else {
h
}
}
pub fn host_from_url(url: &str) -> Result<String, String> {
let url = url.trim();
if url.is_empty() {
return Err("URL is empty".to_string());
}
let rest = if let Some(r) = url.strip_prefix("https://") {
r
} else if let Some(r) = url.strip_prefix("http://") {
r
} else if let Some(scheme_end) = url.find("://") {
let scheme = &url[..scheme_end];
return Err(format!(
"unsupported URL scheme `{scheme}` (only http and https are allowed for network tools)"
));
} else {
return Err(format!(
"URL must start with http:// or https:// (got: {url})"
));
};
if rest.is_empty() {
return Err(format!("could not parse host from URL: {url}"));
}
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
if authority.is_empty() {
return Err(format!("could not parse host from URL: {url}"));
}
let hostport = match authority.rfind('@') {
Some(i) => &authority[i + 1..],
None => authority,
};
let host = if hostport.starts_with('[') {
match hostport.find(']') {
Some(end) => &hostport[1..end],
None => {
return Err(format!("invalid IPv6 host in URL: {url}"));
}
}
} else {
hostport.split(':').next().unwrap_or(hostport)
};
let host = normalize_host(host);
if host.is_empty() {
return Err(format!("could not parse host from URL: {url}"));
}
Ok(host)
}
pub fn host_matches_pattern(host: &str, pattern: &str) -> bool {
let host = normalize_host(host);
let pattern = normalize_host(pattern);
if host.is_empty() || pattern.is_empty() {
return false;
}
if pattern == "*" {
return true;
}
if let Some(suffix) = pattern.strip_prefix("*.") {
!suffix.is_empty()
&& host.len() > suffix.len()
&& host.ends_with(suffix)
&& host.as_bytes().get(host.len() - suffix.len() - 1) == Some(&b'.')
} else {
host == pattern
|| (host.len() > pattern.len()
&& host.ends_with(&pattern)
&& host.as_bytes().get(host.len() - pattern.len() - 1) == Some(&b'.'))
}
}
pub fn parse_domain_list(raw: &str) -> Vec<String> {
raw.split([',', ' ', '\n', '\t'])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_url_and_pattern_helpers() {
let open = NetworkPolicy::unrestricted();
assert!(!open.is_restricted());
assert!(open.ensure_url_allowed("https://example.com").is_ok());
assert!(!open.is_host_allowed(""));
assert!(open.is_host_allowed("example.com"));
let deny = NetworkPolicy {
allowlist: vec!["example.com".into()],
denylist: vec!["blocked.example.com".into()],
};
assert!(deny.is_restricted());
assert!(deny.ensure_url_allowed("https://api.example.com/x").is_ok());
assert!(deny.check_url("https://api.example.com/x").is_ok());
let blocked = deny.check_url("https://blocked.example.com/x").unwrap_err();
assert!(blocked.contains("Denied patterns"));
assert!(blocked.contains("Allowed patterns"));
assert!(deny.ensure_url_allowed("https://evil.test").is_err());
assert_eq!(
host_from_url("https://USER:pw@Example.COM.:443/a?q=1#h").unwrap(),
"example.com"
);
assert_eq!(host_from_url("http://[::1]/x").unwrap(), "::1");
assert!(host_from_url("").is_err());
assert!(host_from_url("ftp://x").is_err());
assert!(host_from_url("example.com").is_err());
assert!(host_from_url("https://").is_err());
assert!(host_from_url("https:///nohost").is_err());
assert!(host_from_url("https://[::1").is_err());
assert_eq!(normalize_host("[::1]"), "::1");
assert!(host_matches_pattern("a.example.com", "*.example.com"));
assert!(!host_matches_pattern("example.com", "*.example.com"));
assert!(host_matches_pattern("a.example.com", "example.com"));
assert!(host_matches_pattern("x", "*"));
assert!(!host_matches_pattern("", "example.com"));
assert_eq!(
parse_domain_list("a.com, b.com\nc.com"),
vec!["a.com", "b.com", "c.com"]
);
}
}