slicc-cdp-client 1.41.21

Shared CDP connection for slicc-cdp CLIs
Documentation
use super::Cdp;
use crate::fetch::Paused;
use crate::net::base64_decode;
use crate::protected::Protected;
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;

const ARM: Duration = Duration::from_secs(10);
const FETCH_LIKE: [&str; 3] = ["XHR", "Fetch", "EventSource"];
const NAMED: [&str; 12] = ["Document", "Stylesheet", "Image", "Media", "Font", "Script", "XHR", "Fetch", "EventSource", "Prefetch", "Ping", "CSPViolationReport"];

#[derive(Default)]
pub(super) struct Issued {
    client: BTreeMap<String, Value>,
    armed: BTreeSet<String>,
    chains: BTreeSet<(String, String)>,
    urls: BTreeMap<(String, String), String>,
    auth: BTreeSet<(String, String)>,
    responses: BTreeMap<(String, String), (u64, Vec<String>)>,
    arming: BTreeSet<String>,
    deferred: BTreeMap<String, Vec<Paused>>,
}

impl Issued {
    pub(super) fn forget(&mut self, session: &str) {
        self.client.remove(session);
        self.armed.remove(session);
        self.arming.remove(session);
        self.deferred.remove(session);
        self.chains.retain(|(owner, _)| owner != session);
        self.urls.retain(|(owner, _), _| owner != session);
        self.auth.retain(|(owner, _)| owner != session);
        self.responses.retain(|(owner, _), _| owner != session);
    }
}

enum Token {
    Any,
    One,
    Char(char),
}

fn wildcard(pattern: &str, text: &str) -> bool {
    let mut tokens = Vec::new();
    let mut chars = pattern.chars();
    while let Some(ch) = chars.next() {
        tokens.push(match ch {
            '*' => Token::Any,
            '?' => Token::One,
            '\\' => Token::Char(chars.next().unwrap_or('\\')),
            ch => Token::Char(ch),
        });
    }
    let text: Vec<char> = text.chars().collect();
    let (mut at, mut from, mut star) = (0, 0, None);
    while from < text.len() {
        match tokens.get(at) {
            Some(Token::Any) => {
                star = Some((at, from));
                at += 1;
            }
            Some(Token::One) => {
                at += 1;
                from += 1;
            }
            Some(Token::Char(ch)) if *ch == text[from] => {
                at += 1;
                from += 1;
            }
            _ => {
                let Some((mark, seen)) = star else { return false };
                star = Some((mark, seen + 1));
                at = mark + 1;
                from = seen + 1;
            }
        }
    }
    tokens[at.min(tokens.len())..].iter().all(|token| matches!(token, Token::Any))
}

fn kind_matches(pattern: &str, kind: &str) -> bool {
    pattern == kind
        || FETCH_LIKE.contains(&pattern) && FETCH_LIKE.contains(&kind)
        || pattern == "Fetch" && kind == "Prefetch"
        || pattern == "Other" && !NAMED.contains(&kind)
}

fn refusal_text(guard: &Protected, source: &str, target: &str) -> String {
    let resolved = url::Url::parse(source).ok().and_then(|base| base.join(target.trim()).ok()).map(|url| url.to_string()).filter(|url| guard.url(url));
    format!("{}\n", guard.refusal_for_url(resolved.as_deref().unwrap_or(target)))
}

pub(crate) fn target_refusal(guard: &Protected, source: &str, target: &str) -> Result<String, String> {
    let targets = Cdp::carrier_targets(guard, target, source).ok_or_else(|| refusal_text(guard, source, target))?;
    let url = &targets[0].url;
    if target.trim().is_empty() || targets.iter().any(|target| target.protected)
        || !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() {
        return Err(refusal_text(guard, source, target));
    }
    Ok(url.to_string())
}

fn header_locations(fields: Option<&Value>, binary: Option<&Value>) -> Vec<String> {
    let mut found: Vec<String> = fields.and_then(Value::as_array).into_iter().flatten()
        .filter(|field| field.get("name").and_then(Value::as_str).is_some_and(|name| name.trim().eq_ignore_ascii_case("location")))
        .map(|field| field.get("value").and_then(Value::as_str).unwrap_or("").to_string()).collect();
    if let Some(text) = binary.and_then(Value::as_str) {
        let bytes = base64_decode(text).unwrap_or_else(|_| b"location:".to_vec());
        found.extend(raw_locations(&String::from_utf8_lossy(&bytes), &['\0', '\n']));
    }
    found
}

fn raw_locations(text: &str, separators: &[char]) -> Vec<String> {
    text.split(separators).filter_map(|line| line.split_once(':')).filter(|(name, _)| name.trim().eq_ignore_ascii_case("location")).map(|(_, value)| value.trim().to_string()).collect()
}

fn redirect_answer(method: &str, params: &Value) -> (u64, Vec<String>) {
    if method == "Network.continueInterceptedRequest" {
        let Some(raw) = params.get("rawResponse").and_then(Value::as_str) else { return (0, Vec::new()) };
        let text = String::from_utf8_lossy(&base64_decode(raw).unwrap_or_default()).to_string();
        let head = text.split("\r\n\r\n").next().unwrap_or("").split("\n\n").next().unwrap_or("");
        let status = head.split_whitespace().nth(1).and_then(|code| code.parse().ok()).unwrap_or(300);
        return (status, raw_locations(head, &['\n']));
    }
    let status = params.get("responseCode").and_then(Value::as_u64).unwrap_or(0);
    (status, header_locations(params.get("responseHeaders"), params.get("binaryResponseHeaders")))
}

impl Cdp {
    pub(super) fn hop_refusal(&self, source: &str, target: &str) -> Option<String> {
        match target_refusal(&self.guard, source, target) {
            Ok(url) => self.redirector_protected(&url).then(|| format!("{}\n", self.guard.refusal_for_url(&url))),
            Err(reason) => Some(reason),
        }
    }

    pub(super) fn client_issued(&self, method: &str, params: &Value, session: Option<&str>) -> Result<bool, String> {
        let id = crate::shield::request_key(params).unwrap_or_default();
        let key = session.map(|session| (session.to_string(), id));
        let source = key.as_ref().and_then(|key| self.issued.urls.get(key)).cloned();
        match method {
            "Fetch.continueRequest" | "Network.continueInterceptedRequest" if params.get("url").is_some_and(|url| !url.is_null()) => {
                let url = params["url"].as_str().ok_or_else(|| self.refusal())?;
                match self.hop_refusal(url, url) {
                    Some(reason) => Err(reason),
                    None => Ok(true),
                }
            }
            "Fetch.continueWithAuth" => Ok(true),
            "Fetch.fulfillRequest" | "Fetch.continueResponse" | "Network.continueInterceptedRequest" => {
                let (mut status, mut locations) = redirect_answer(method, params);
                if method == "Fetch.continueResponse" {
                    if !["responseCode", "responseHeaders", "binaryResponseHeaders"].iter().any(|name| params.get(name).is_some()) { return Ok(false) }
                    let original = key.as_ref().and_then(|key| self.issued.responses.get(key)).ok_or_else(|| self.refusal())?;
                    if params.get("responseCode").is_none() { status = original.0; }
                    if params.get("responseHeaders").is_none() && params.get("binaryResponseHeaders").is_none() { locations = original.1.clone(); }
                }
                if !(300..400).contains(&status) || locations.is_empty() { return Ok(false) }
                let source = source.ok_or_else(|| self.refusal())?;
                match locations.iter().find_map(|location| self.hop_refusal(&source, location)) {
                    Some(reason) => Err(reason),
                    None => Ok(true),
                }
            }
            _ => Ok(false),
        }
    }

    fn armed_params(&self, session: &str) -> Value {
        let client = self.issued.client.get(session);
        let mut patterns = match client.and_then(|params| params.get("patterns")) {
            Some(Value::Array(list)) => list.clone(),
            None | Some(Value::Null) if client.is_some() => vec![json!({ "urlPattern": "*", "requestStage": "Request" })],
            _ => Vec::new(),
        };
        patterns.push(json!({ "urlPattern": "*", "requestStage": "Request" }));
        let auth = client.and_then(|params| params.get("handleAuthRequests")).and_then(Value::as_bool).unwrap_or(false);
        json!({ "patterns": patterns, "handleAuthRequests": auth })
    }

    fn arm_fetch(&mut self, session: &str) -> Result<(), String> {
        if self.issued.armed.contains(session) { return Ok(()) }
        let params = self.armed_params(session);
        self.issued.arming.insert(session.to_string());
        let result = self.raw_call("Fetch.enable", params, Some(session), ARM).map_err(|_| self.refusal());
        self.issued.arming.remove(session);
        if result.is_ok() { self.issued.armed.insert(session.to_string()); }
        let deferred = self.issued.deferred.remove(session).unwrap_or_default();
        for paused in deferred {
            if result.is_ok() { self.decide_inline(paused); }
            else { self.continue_plain(&paused.session_id, &paused.request_id); }
        }
        result.map(|_| ())
    }

    pub(super) fn defer_issued(&mut self, paused: &Paused) -> bool {
        if !self.issued.arming.contains(&paused.session_id) { return false }
        self.issued.deferred.entry(paused.session_id.clone()).or_default().push(paused.clone());
        true
    }

    pub(super) fn track_issued(&mut self, session: &str, id: &str) {
        let mut ids = self.request_aliases.get(id).cloned().unwrap_or_default();
        ids.insert(id.to_string());
        for id in ids { self.issued.chains.insert((session.to_string(), id)); }
    }

    pub(super) fn issue(&mut self, params: &Value, session: &str) -> Result<(), String> {
        self.arm_fetch(session)?;
        self.track_issued(session, &crate::shield::request_key(params).unwrap_or_default());
        Ok(())
    }

    fn client_matches(&self, session: &str, url: &str, kind: &str) -> bool {
        match self.issued.client.get(session).and_then(|params| params.get("patterns")) {
            _ if !self.issued.client.contains_key(session) => false,
            None | Some(Value::Null) => true,
            Some(Value::Array(list)) => list.iter().any(|pattern| {
                pattern.get("requestStage").and_then(Value::as_str).unwrap_or("Request") == "Request"
                    && pattern.get("resourceType").and_then(Value::as_str).filter(|wanted| !wanted.is_empty()).is_none_or(|wanted| kind_matches(wanted, kind))
                    && wildcard(pattern.get("urlPattern").and_then(Value::as_str).unwrap_or("*"), url)
            }),
            Some(_) => false,
        }
    }

    pub(super) fn guard_only(&self, paused: &Paused) -> bool {
        self.issued.armed.contains(&paused.session_id) && !paused.is_response() && !self.client_matches(&paused.session_id, &paused.url, &paused.resource_type)
    }

    pub(super) fn guard_auth(&mut self, value: &Value) -> bool {
        let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("").to_string();
        let params = &value["params"];
        let id = params.get("requestId").and_then(Value::as_str).unwrap_or("").to_string();
        if let Some(url) = params.pointer("/request/url").and_then(Value::as_str) {
            self.issued.urls.entry((session.clone(), id.clone())).or_insert_with(|| url.to_string());
        }
        self.issued.auth.insert((session.clone(), id.clone()));
        let wanted = self.issued.client.get(&session).and_then(|params| params.get("handleAuthRequests")).and_then(Value::as_bool) == Some(true)
            && self.client_matches(&session, params.pointer("/request/url").and_then(Value::as_str).unwrap_or(""), params.get("resourceType").and_then(Value::as_str).unwrap_or(""));
        if !self.issued.armed.contains(&session) || wanted { return false }
        let _ = self.send("Fetch.continueWithAuth", json!({ "requestId": id, "authChallengeResponse": { "response": "Default" } }), Some(&session));
        true
    }

    pub(super) fn issued_hop(&mut self, value: &Value, paused: &Paused) -> bool {
        let session = paused.session_id.clone();
        let key = |id: &str| (session.clone(), id.to_string());
        self.issued.urls.insert(key(&paused.request_id), paused.url.clone());
        self.issued.auth.remove(&key(&paused.request_id));
        if let Some(status) = paused.status {
            let locations = paused.response_headers.iter().flatten().filter(|(name, _)| name.trim().eq_ignore_ascii_case("location")).map(|(_, value)| value.clone()).collect();
            self.issued.responses.insert(key(&paused.request_id), (u64::from(status), locations));
        }
        let redirected = value.pointer("/params/redirectedRequestId").and_then(Value::as_str);
        let tracked = [Some(paused.request_id.as_str()), paused.network_id.as_deref(), redirected].into_iter().flatten().any(|id| self.issued.chains.contains(&key(id)));
        if !tracked { return false }
        let refusal = if paused.is_response() {
            let locations: Vec<String> = paused.response_headers.iter().flatten().filter(|(name, _)| name.trim().eq_ignore_ascii_case("location")).map(|(_, value)| value.clone()).collect();
            paused.status.filter(|status| (300..400).contains(status)).and_then(|_| locations.iter().find_map(|location| self.hop_refusal(&paused.url, location)))
        } else {
            self.hop_refusal(&paused.full_url(), &paused.full_url())
        };
        let ids: Vec<String> = [Some(paused.request_id.clone()), paused.network_id.clone()].into_iter().flatten().collect();
        if refusal.is_none() {
            for id in &ids { self.track_issued(&session, id); }
            return false;
        }
        for id in ids { self.requests.insert(id, true); }
        self.fail_seen_network(value);
        self.settle(&session, &paused.request_id);
        let _ = self.send("Fetch.failRequest", json!({ "requestId": paused.request_id, "errorReason": "BlockedByClient" }), Some(&session));
        true
    }

    pub(super) fn fetch_command(&mut self, method: &str, params: Value, session: Option<&str>) -> (String, Value) {
        let Some(session) = session else { return (method.to_string(), params) };
        let armed = self.issued.armed.contains(session);
        match method {
            "Fetch.enable" => {
                self.issued.client.insert(session.to_string(), params.clone());
                if armed { return (method.to_string(), self.armed_params(session)) }
            }
            "Fetch.disable" => {
                self.issued.client.remove(session);
                if armed && !self.blocked.contains(session) {
                    self.release_handed(session);
                    return ("Fetch.enable".to_string(), self.armed_params(session));
                }
                self.issued.armed.remove(session);
            }
            _ => {}
        }
        (method.to_string(), params)
    }

    fn release_handed(&mut self, session: &str) {
        self.paused.retain(|paused| paused.session_id != session);
        let held: Vec<String> = self.handed.iter().filter(|(peer, _)| peer == session).map(|(_, id)| id.clone()).collect();
        for id in held {
            if self.issued.auth.contains(&(session.to_string(), id.clone())) {
                self.settle(session, &id);
                let _ = self.send("Fetch.continueWithAuth", json!({ "requestId": id, "authChallengeResponse": { "response": "Default" } }), Some(session));
            } else {
                self.continue_plain(session, &id);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Instant;

    include!("client_issued_test.rs");

    #[test]
    fn wildcards_follow_chrome_match_pattern() {
        assert!(wildcard("*", ""));
        assert!(wildcard("http://a.test/*", "http://a.test/x?y"));
        assert!(wildcard("http://a.test/?", "http://a.test/x"));
        assert!(!wildcard("http://a.test/?", "http://a.test/"));
        assert!(wildcard("*b*", "abc"));
        assert!(!wildcard("*b", "abc"));
        assert!(wildcard("a\\*c", "a*c"));
        assert!(!wildcard("a\\*c", "abc"));
        assert!(wildcard("a\\", "a\\"));
        assert!(!wildcard("abc", "ab"));
        assert!(!wildcard("ab", "abc"));
    }

    #[test]
    fn resource_kinds_group_like_chrome() {
        assert!(kind_matches("Document", "Document"));
        assert!(kind_matches("XHR", "Fetch"));
        assert!(kind_matches("Fetch", "Prefetch"));
        assert!(kind_matches("Other", "WebSocket"));
        assert!(!kind_matches("Other", "Script"));
        assert!(!kind_matches("Image", "Script"));
    }

    #[test]
    fn redirect_answers_read_every_header_form() {
        let fields = json!([{ "name": "Location", "value": "/a" }, { "name": "x", "value": "y" }]);
        let binary = crate::net::base64_encode(b"Location: /b\0X: y");
        assert_eq!(redirect_answer("Fetch.fulfillRequest", &json!({ "responseCode": 302, "responseHeaders": fields, "binaryResponseHeaders": binary })), (302, vec!["/a".to_string(), "/b".to_string()]));
        assert_eq!(redirect_answer("Fetch.fulfillRequest", &json!({ "binaryResponseHeaders": "!" })).1, vec![String::new()]);
        let raw = crate::net::base64_encode(b"HTTP/1.1 307 Temporary\r\nLocation: /c\r\n\r\nbody: x");
        assert_eq!(redirect_answer("Network.continueInterceptedRequest", &json!({ "rawResponse": raw })), (307, vec!["/c".to_string()]));
        assert_eq!(redirect_answer("Network.continueInterceptedRequest", &json!({})), (0, Vec::new()));
        assert_eq!(redirect_answer("Network.continueInterceptedRequest", &json!({ "rawResponse": crate::net::base64_encode(b"junk") })).0, 300);
    }
}