use serde::{Deserialize, Serialize};
use crate::error::SandboxError;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HttpRule {
pub method: String,
pub host: String,
pub path: String,
}
impl HttpRule {
pub fn parse(s: &str) -> Result<Self, SandboxError> {
let s = s.trim();
let (method, rest) = s
.split_once(char::is_whitespace)
.ok_or_else(|| SandboxError::Invalid(format!("invalid http rule: {}", s)))?;
let rest = rest.trim();
if rest.is_empty() {
return Err(SandboxError::Invalid(format!("invalid http rule: {}", s)));
}
let (host, path) = if let Some(pos) = rest.find('/') {
let (h, p) = rest.split_at(pos);
let has_wildcard = p.ends_with('*');
let mut normalized = normalize_path(p);
if has_wildcard && !normalized.ends_with('*') {
normalized.push('*');
}
(h.to_string(), normalized)
} else {
(rest.to_string(), "/*".to_string())
};
Ok(HttpRule {
method: method.to_uppercase(),
host,
path,
})
}
pub fn matches(&self, method: &str, host: &str, path: &str) -> bool {
if self.method != "*" && !self.method.eq_ignore_ascii_case(method) {
return false;
}
if self.host != "*" && !self.host.eq_ignore_ascii_case(host) {
return false;
}
let normalized = normalize_path(path);
prefix_or_exact_match(&self.path, &normalized)
}
}
pub fn normalize_path(path: &str) -> String {
let mut decoded = String::with_capacity(path.len());
let mut chars = path.bytes();
while let Some(b) = chars.next() {
if b == b'%' {
let hi = chars.next();
let lo = chars.next();
if let (Some(h), Some(l)) = (hi, lo) {
let hex = [h, l];
if let Ok(s) = std::str::from_utf8(&hex) {
if let Ok(val) = u8::from_str_radix(s, 16) {
decoded.push(val as char);
continue;
}
}
decoded.push(b as char);
decoded.push(h as char);
decoded.push(l as char);
} else {
decoded.push(b as char);
}
} else {
decoded.push(b as char);
}
}
let mut segments: Vec<&str> = Vec::new();
for seg in decoded.split('/') {
match seg {
"" | "." => {}
".." => {
segments.pop();
}
s => segments.push(s),
}
}
let mut result = String::with_capacity(decoded.len());
result.push('/');
result.push_str(&segments.join("/"));
result
}
pub fn prefix_or_exact_match(pattern: &str, value: &str) -> bool {
if pattern == "/*" || pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
value.starts_with(prefix)
} else {
pattern == value
}
}
pub fn http_acl_check(
allow: &[HttpRule],
deny: &[HttpRule],
method: &str,
host: &str,
path: &str,
) -> bool {
for rule in deny {
if rule.matches(method, host, path) {
return false;
}
}
if allow.is_empty() && deny.is_empty() {
return true; }
if allow.is_empty() {
return true;
}
for rule in allow {
if rule.matches(method, host, path) {
return true;
}
}
false }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_get() {
let rule = HttpRule::parse("GET api.example.com/v1/*").unwrap();
assert_eq!(rule.method, "GET");
assert_eq!(rule.host, "api.example.com");
assert_eq!(rule.path, "/v1/*");
}
#[test]
fn parse_wildcard_method_and_host() {
let rule = HttpRule::parse("* */admin/*").unwrap();
assert_eq!(rule.method, "*");
assert_eq!(rule.host, "*");
assert_eq!(rule.path, "/admin/*");
}
#[test]
fn parse_post_with_exact_path() {
let rule = HttpRule::parse("POST example.com/upload").unwrap();
assert_eq!(rule.method, "POST");
assert_eq!(rule.host, "example.com");
assert_eq!(rule.path, "/upload");
}
#[test]
fn parse_no_path_defaults_to_wildcard() {
let rule = HttpRule::parse("GET example.com").unwrap();
assert_eq!(rule.method, "GET");
assert_eq!(rule.host, "example.com");
assert_eq!(rule.path, "/*");
}
#[test]
fn parse_method_uppercased() {
let rule = HttpRule::parse("get example.com/foo").unwrap();
assert_eq!(rule.method, "GET");
}
#[test]
fn parse_error_no_space() {
assert!(HttpRule::parse("GETexample.com").is_err());
}
#[test]
fn parse_error_empty_host() {
assert!(HttpRule::parse("GET ").is_err());
}
#[test]
fn prefix_or_exact_match_wildcard_all() {
assert!(prefix_or_exact_match("/*", "/anything"));
assert!(prefix_or_exact_match("*", "/anything"));
assert!(prefix_or_exact_match("/*", "/"));
}
#[test]
fn prefix_or_exact_match_prefix() {
assert!(prefix_or_exact_match("/v1/*", "/v1/foo"));
assert!(prefix_or_exact_match("/v1/*", "/v1/foo/bar"));
assert!(prefix_or_exact_match("/v1/*", "/v1/"));
assert!(!prefix_or_exact_match("/v1/*", "/v2/foo"));
}
#[test]
fn prefix_or_exact_match_exact() {
assert!(prefix_or_exact_match("/v1/models", "/v1/models"));
assert!(!prefix_or_exact_match("/v1/models", "/v1/models/extra"));
assert!(!prefix_or_exact_match("/v1/models", "/v1/model"));
}
#[test]
fn matches_exact() {
let rule = HttpRule::parse("GET api.example.com/v1/models").unwrap();
assert!(rule.matches("GET", "api.example.com", "/v1/models"));
assert!(!rule.matches("POST", "api.example.com", "/v1/models"));
assert!(!rule.matches("GET", "other.com", "/v1/models"));
assert!(!rule.matches("GET", "api.example.com", "/v1/other"));
}
#[test]
fn matches_wildcard_method() {
let rule = HttpRule::parse("* api.example.com/v1/*").unwrap();
assert!(rule.matches("GET", "api.example.com", "/v1/foo"));
assert!(rule.matches("POST", "api.example.com", "/v1/bar"));
}
#[test]
fn matches_wildcard_host() {
let rule = HttpRule::parse("GET */v1/*").unwrap();
assert!(rule.matches("GET", "any.host.com", "/v1/foo"));
}
#[test]
fn matches_case_insensitive_method() {
let rule = HttpRule::parse("GET example.com/foo").unwrap();
assert!(rule.matches("get", "example.com", "/foo"));
assert!(rule.matches("Get", "example.com", "/foo"));
}
#[test]
fn matches_case_insensitive_host() {
let rule = HttpRule::parse("GET Example.COM/foo").unwrap();
assert!(rule.matches("GET", "example.com", "/foo"));
}
#[test]
fn acl_no_rules_allows_all() {
assert!(http_acl_check(&[], &[], "GET", "example.com", "/foo"));
}
#[test]
fn acl_allow_only_permits_matching() {
let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()];
assert!(http_acl_check(&allow, &[], "GET", "api.example.com", "/v1/foo"));
assert!(!http_acl_check(&allow, &[], "POST", "api.example.com", "/v1/foo"));
assert!(!http_acl_check(&allow, &[], "GET", "other.com", "/v1/foo"));
}
#[test]
fn acl_deny_only_blocks_matching() {
let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings"));
assert!(http_acl_check(&[], &deny, "GET", "example.com", "/public/page"));
}
#[test]
fn acl_deny_takes_precedence_over_allow() {
let allow = vec![HttpRule::parse("* example.com/*").unwrap()];
let deny = vec![HttpRule::parse("* example.com/admin/*").unwrap()];
assert!(http_acl_check(&allow, &deny, "GET", "example.com", "/public"));
assert!(!http_acl_check(&allow, &deny, "GET", "example.com", "/admin/settings"));
}
#[test]
fn acl_allow_deny_by_default_when_no_match() {
let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()];
assert!(!http_acl_check(&allow, &[], "GET", "evil.com", "/v1/foo"));
}
#[test]
fn normalize_path_basic() {
assert_eq!(normalize_path("/foo/bar"), "/foo/bar");
assert_eq!(normalize_path("/"), "/");
}
#[test]
fn normalize_path_double_slashes() {
assert_eq!(normalize_path("/foo//bar"), "/foo/bar");
assert_eq!(normalize_path("//foo///bar//"), "/foo/bar");
}
#[test]
fn normalize_path_dot_segments() {
assert_eq!(normalize_path("/foo/./bar"), "/foo/bar");
assert_eq!(normalize_path("/foo/../bar"), "/bar");
assert_eq!(normalize_path("/foo/bar/../../baz"), "/baz");
}
#[test]
fn normalize_path_dotdot_at_root() {
assert_eq!(normalize_path("/../foo"), "/foo");
assert_eq!(normalize_path("/../../foo"), "/foo");
}
#[test]
fn normalize_path_percent_encoding() {
assert_eq!(normalize_path("/foo%2Fbar"), "/foo/bar");
assert_eq!(normalize_path("/%61dmin/settings"), "/admin/settings");
}
#[test]
fn normalize_path_mixed_bypass_attempts() {
assert_eq!(normalize_path("/v1/./admin/settings"), "/v1/admin/settings");
assert_eq!(normalize_path("/v1/../admin/settings"), "/admin/settings");
assert_eq!(normalize_path("/v1//admin/settings"), "/v1/admin/settings");
assert_eq!(normalize_path("/v1/%2e%2e/admin"), "/admin");
}
#[test]
fn acl_deny_prevents_double_slash_bypass() {
let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings"));
assert!(!http_acl_check(&[], &deny, "GET", "example.com", "//admin/settings"));
assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin//settings"));
}
#[test]
fn acl_deny_prevents_dot_segment_bypass() {
let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/./admin/settings"));
assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/public/../admin/settings"));
}
#[test]
fn acl_deny_prevents_percent_encoding_bypass() {
let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/%61dmin/settings"));
}
#[test]
fn acl_allow_normalized_path_still_works() {
let allow = vec![HttpRule::parse("GET example.com/v1/models").unwrap()];
assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/models"));
assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/./models"));
assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1//models"));
assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v1/models/extra"));
assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v2/models"));
}
#[test]
fn parse_normalizes_rule_path() {
let rule = HttpRule::parse("GET example.com/v1/./models/*").unwrap();
assert_eq!(rule.path, "/v1/models/*");
let rule = HttpRule::parse("GET example.com/v1//models").unwrap();
assert_eq!(rule.path, "/v1/models");
}
}