earl-core 0.2.0

Shared types and traits for earl protocol crates
Documentation
use anyhow::{Result, bail};
use url::Url;

use crate::schema::AllowRule;

pub fn ensure_url_allowed(url: &Url, allow_rules: &[AllowRule]) -> Result<()> {
    if allow_rules.is_empty() {
        return Ok(());
    }

    if allow_rules.iter().any(|rule| matches_rule(url, rule)) {
        return Ok(());
    }

    bail!(
        "url `{}` is not allowed by template allowlist policy",
        url.as_str()
    )
}

pub fn matches_rule(url: &Url, rule: &AllowRule) -> bool {
    let scheme_match = url.scheme().eq_ignore_ascii_case(&rule.scheme);
    let host_match = url
        .host_str()
        .map(|host| host.eq_ignore_ascii_case(&rule.host))
        .unwrap_or(false);
    let port_match = url
        .port_or_known_default()
        .map(|port| port == rule.port)
        .unwrap_or(false);
    let path_match = path_prefix_matches(url.path(), &rule.path_prefix);

    scheme_match && host_match && port_match && path_match
}

fn path_prefix_matches(path: &str, prefix: &str) -> bool {
    let normalized_path = normalize_path(path);
    let normalized_prefix = normalize_path(prefix);

    if normalized_prefix == "/" {
        return true;
    }

    normalized_path == normalized_prefix
        || normalized_path.starts_with(&format!("{normalized_prefix}/"))
}

fn normalize_path(path: &str) -> String {
    let trimmed = path.trim();
    let with_leading = if trimmed.starts_with('/') {
        trimmed.to_string()
    } else {
        format!("/{trimmed}")
    };

    if with_leading.len() > 1 {
        with_leading.trim_end_matches('/').to_string()
    } else {
        "/".to_string()
    }
}