slicc-cdp-client 1.10.0

Shared CDP connection for slicc-cdp CLIs
Documentation
use slicc_browsers::ProtectedOrigins;
use std::sync::OnceLock;
use url::Url;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Opener {
    None,
    Protected,
    Unprotected,
    Unknown,
}

#[derive(Clone, Debug)]
pub struct Protected {
    rules: ProtectedOrigins,
    extra: Vec<String>,
    extra_hosts: Vec<String>,
}

fn catalog_rules() -> &'static ProtectedOrigins {
    static RULES: OnceLock<ProtectedOrigins> = OnceLock::new();
    RULES.get_or_init(|| slicc_browsers::catalog().protected_origins)
}

pub fn refusal() -> &'static str {
    &catalog_rules().refusal
}

impl Default for Protected {
    fn default() -> Self {
        Self::new(&[])
    }
}

impl Protected {
    pub fn new(extra_origins: &[&str]) -> Self {
        Self::with_rules(catalog_rules().clone(), extra_origins)
    }

    pub fn with_rules(rules: ProtectedOrigins, extra_origins: &[&str]) -> Self {
        let parsed: Vec<Url> = extra_origins
            .iter()
            .filter_map(|text| parse(text))
            .collect();
        let extra = parsed.iter().filter_map(origin_of).collect();
        let extra_hosts = parsed
            .iter()
            .filter(|url| url.host_str().is_some_and(|host| !host.is_empty()))
            .map(host_of)
            .collect();
        Self {
            rules,
            extra,
            extra_hosts,
        }
    }

    pub fn refusal(&self) -> &str {
        &self.rules.refusal
    }

    pub fn url(&self, text: &str) -> bool {
        let Some(url) = self.unwrap(parse(text)) else {
            return false;
        };
        let scheme = url.scheme();
        if self.rules.any_schemes.iter().any(|item| item == scheme) {
            return true;
        }
        if self.rules.schemes.iter().any(|item| item == scheme) {
            let host = host_of(&url);
            if self.rules.hosts.contains(&host) {
                return true;
            }
            if self.rules.domains.iter().any(|domain| under(&host, domain)) {
                return true;
            }
        }
        origin_of(&url).is_some_and(|origin| self.extra.contains(&origin))
    }

    pub fn target(&self, text: &str, previous: bool, opener: Opener) -> bool {
        if self.inheriting(text) {
            return previous || matches!(opener, Opener::Protected | Opener::Unknown);
        }
        self.url(text)
    }

    pub fn cookie_domain(&self, domain: &str) -> bool {
        let mut domain = domain.to_ascii_lowercase();
        if let Some(rest) = domain.strip_prefix('.') {
            domain = rest.to_string();
        }
        if let Some(rest) = domain.strip_suffix('.') {
            domain = rest.to_string();
        }
        if domain.is_empty() {
            return false;
        }
        if self.rules.domains.iter().any(|item| under(&domain, item)) {
            return true;
        }
        self.rules
            .domains
            .iter()
            .chain(self.rules.hosts.iter())
            .chain(self.extra_hosts.iter())
            .any(|host| under(host, &domain))
    }

    pub fn storage_key(&self, key: &str) -> bool {
        let mut parts = key.split('^');
        let first = parts.next().unwrap_or("");
        if self.url(first) {
            return true;
        }
        parts
            .filter_map(|part| part.strip_prefix('0'))
            .any(|site| self.url(site))
    }

    pub fn pattern(&self, pattern: &str) -> bool {
        let lowered = pattern.to_ascii_lowercase();
        let names = self.rules.domains.iter().chain(self.rules.hosts.iter());
        for name in names {
            if lowered.contains(name.as_str()) {
                return true;
            }
        }
        if self
            .rules
            .any_schemes
            .iter()
            .any(|scheme| lowered.starts_with(&format!("{scheme}:")))
        {
            return true;
        }
        let literal: String = pattern
            .chars()
            .take_while(|ch| !matches!(ch, '*' | '?' | '{' | '}' | '[' | ']' | '\\'))
            .collect();
        literal.contains("://") && self.url(&literal)
    }

    fn inheriting(&self, text: &str) -> bool {
        if text.is_empty() {
            return true;
        }
        let Some(url) = parse(text) else {
            return false;
        };
        url.scheme() == "about"
            && self
                .rules
                .inheriting
                .iter()
                .any(|item| *item == format!("about:{}", url.path().to_ascii_lowercase()))
    }

    fn unwrap(&self, mut url: Option<Url>) -> Option<Url> {
        let mut depth = 0;
        while let Some(current) = url.as_ref() {
            if depth >= 8
                || !self
                    .rules
                    .inner_schemes
                    .iter()
                    .any(|item| item == current.scheme())
            {
                break;
            }
            let rest = current.as_str()[current.scheme().len() + 1..].to_string();
            url = parse(&rest);
            depth += 1;
        }
        url
    }
}

fn parse(text: &str) -> Option<Url> {
    Url::parse(text).ok()
}

fn host_of(url: &Url) -> String {
    let host = url.host_str().unwrap_or("").to_ascii_lowercase();
    match host.strip_suffix('.') {
        Some(stripped) => stripped.to_string(),
        None => host,
    }
}

fn origin_of(url: &Url) -> Option<String> {
    url.host_str().filter(|host| !host.is_empty())?;
    let host = host_of(url);
    Some(match url.port() {
        Some(port) => format!("{}://{host}:{port}", url.scheme()),
        None => format!("{}://{host}", url.scheme()),
    })
}

fn under(host: &str, domain: &str) -> bool {
    host == domain || host.ends_with(&format!(".{domain}"))
}

#[cfg(test)]
mod tests {
    use super::{Opener, Protected};
    use slicc_browsers::protected::{
        vectors, OPENER_PROTECTED, OPENER_UNKNOWN, OPENER_UNPROTECTED,
    };

    fn guard() -> Protected {
        let vectors = vectors();
        let origins: Vec<&str> = vectors.origins.iter().map(String::as_str).collect();
        Protected::new(&origins)
    }

    #[test]
    fn every_url_vector_matches_slicc_browsers() {
        let guard = guard();
        for vector in vectors().urls {
            assert_eq!(guard.url(&vector.url), vector.protected, "{:?}", vector.url);
        }
    }

    #[test]
    fn every_target_vector_matches_slicc_browsers() {
        let guard = guard();
        for vector in vectors().targets {
            let opener = match vector.opener.as_deref() {
                None => Opener::None,
                Some(OPENER_PROTECTED) => Opener::Protected,
                Some(OPENER_UNPROTECTED) => Opener::Unprotected,
                Some(OPENER_UNKNOWN) => Opener::Unknown,
                Some(other) => panic!("unknown opener {other}"),
            };
            assert_eq!(
                guard.target(&vector.url, vector.previous, opener),
                vector.protected,
                "{vector:?}"
            );
        }
    }

    #[test]
    fn every_cookie_and_storage_vector_matches_slicc_browsers() {
        let guard = guard();
        let all = vectors();
        for vector in all.cookie_domains {
            assert_eq!(
                guard.cookie_domain(&vector.domain),
                vector.protected,
                "{vector:?}"
            );
        }
        for vector in all.storage_keys {
            assert_eq!(
                guard.storage_key(&vector.key),
                vector.protected,
                "{vector:?}"
            );
        }
    }

    #[test]
    fn patterns_that_name_seven_or_the_vault_are_protected() {
        let guard = Protected::default();
        for pattern in [
            "https://vault.sliccy.ai/*",
            "**://*.sliccy.ai/**",
            "*sliccy.ai*",
            "https://SEVEN.SLICCY.AI/api/*",
            "https://www.sliccy.ai/auth/callback*",
            "devtools://*",
            "https://sliccy.ai/",
        ] {
            assert!(guard.pattern(pattern), "{pattern}");
        }
        for pattern in [
            "*",
            "**/*",
            "**/api/users",
            "https://example.com/*",
            "https://vault-sliccy.example/*",
        ] {
            assert!(!guard.pattern(pattern), "{pattern}");
        }
        assert_eq!(
            guard.refusal(),
            "slicc: refused: target is on seven's origin"
        );
    }
}