use std::collections::BTreeSet;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Egress {
hosts: BTreeSet<String>,
}
impl Egress {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn allow(mut self, host: impl AsRef<str>) -> Self {
self.hosts.insert(host.as_ref().to_ascii_lowercase());
self
}
#[must_use]
pub fn len(&self) -> usize {
self.hosts.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.hosts.is_empty()
}
pub fn hosts(&self) -> impl Iterator<Item = &str> {
self.hosts.iter().map(String::as_str)
}
pub fn permits(&self, host: Option<&str>) -> Result<(), EgressError> {
let Some(host) = host else {
return Err(EgressError::NoHost);
};
if self.hosts.contains(&host.to_ascii_lowercase()) {
return Ok(());
}
Err(EgressError::NotGranted {
host: host.to_owned(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EgressError {
#[error(
"'{host}' is not a granted destination; a host reachable without being \
listed is a self-service egress channel"
)]
NotGranted { host: String },
#[error("the destination has no host, so it cannot be checked against the allowlist")]
NoHost,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_allowlist_permits_nothing() {
let e = Egress::new();
assert!(e.permits(Some("api.anthropic.com")).is_err());
assert!(e.is_empty());
}
#[test]
fn a_granted_host_is_permitted() {
let e = Egress::new().allow("api.anthropic.com");
assert!(e.permits(Some("api.anthropic.com")).is_ok());
}
#[test]
fn an_ungranted_host_is_refused_and_named() {
let e = Egress::new().allow("api.anthropic.com");
let err = e.permits(Some("evil.example")).expect_err("not granted");
assert_eq!(
err,
EgressError::NotGranted {
host: "evil.example".to_owned()
}
);
assert!(err.to_string().contains("evil.example"));
}
#[test]
fn matching_is_case_insensitive() {
let e = Egress::new().allow("API.Anthropic.COM");
assert!(e.permits(Some("api.anthropic.com")).is_ok());
assert!(e.permits(Some("API.ANTHROPIC.COM")).is_ok());
}
#[test]
fn a_grant_does_not_extend_to_subdomains() {
let e = Egress::new().allow("example.com");
assert!(e.permits(Some("evil.example.com")).is_err());
assert!(e.permits(Some("example.com.evil.test")).is_err());
assert!(
e.permits(Some("notexample.com")).is_err(),
"a suffix comparison would have let this through"
);
}
#[test]
fn a_destination_with_no_host_is_refused() {
let e = Egress::new().allow("example.com");
assert_eq!(e.permits(None), Err(EgressError::NoHost));
}
#[test]
fn grants_are_listable_for_an_operator() {
let e = Egress::new().allow("b.example").allow("a.example");
assert_eq!(
e.hosts().collect::<Vec<_>>(),
vec!["a.example", "b.example"]
);
assert_eq!(e.len(), 2);
}
}