Skip to main content

cdp_client/
fetch.rs

1use crate::cdp::{self, Cdp};
2use crate::net::{base64_decode, base64_encode};
3use crate::protected::Protected;
4use serde_json::{json, Value};
5use std::ops::{Deref, DerefMut};
6use std::time::Duration;
7
8pub const SIGNED_IN_HTML: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>Signed in</title></head><body style=\"font-family:system-ui,sans-serif;margin:4rem auto;max-width:28rem;text-align:center\"><h1>Signed in</h1><p>You can close this tab.</p></body></html>";
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Stage {
12    Request,
13    Response,
14}
15
16impl Stage {
17    fn as_str(self) -> &'static str {
18        match self {
19            Stage::Request => "Request",
20            Stage::Response => "Response",
21        }
22    }
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct Pattern {
27    pub url: String,
28    pub stage: Stage,
29    pub resource_type: Option<String>,
30}
31
32impl Pattern {
33    pub fn request(url: impl Into<String>) -> Self {
34        Self {
35            url: url.into(),
36            stage: Stage::Request,
37            resource_type: None,
38        }
39    }
40
41    pub fn response(url: impl Into<String>) -> Self {
42        Self {
43            url: url.into(),
44            stage: Stage::Response,
45            resource_type: None,
46        }
47    }
48
49    pub fn resource_type(mut self, kind: impl Into<String>) -> Self {
50        self.resource_type = Some(kind.into());
51        self
52    }
53
54    fn to_json(&self) -> Value {
55        let mut value = json!({ "urlPattern": self.url, "requestStage": self.stage.as_str() });
56        if let Some(kind) = &self.resource_type {
57            value["resourceType"] = json!(kind);
58        }
59        value
60    }
61}
62
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
64pub struct Paused {
65    pub session_id: String,
66    pub request_id: String,
67    pub frame_id: String,
68    pub resource_type: String,
69    pub network_id: Option<String>,
70    pub method: String,
71    pub url: String,
72    pub url_fragment: Option<String>,
73    pub headers: Vec<(String, String)>,
74    pub post_data: Option<Vec<u8>>,
75    pub status: Option<u16>,
76    pub status_text: Option<String>,
77    pub response_headers: Option<Vec<(String, String)>>,
78    pub error_reason: Option<String>,
79}
80
81impl Paused {
82    pub fn from_event(event: &Value) -> Option<Self> {
83        if event.get("method").and_then(|v| v.as_str()) != Some("Fetch.requestPaused") {
84            return None;
85        }
86        let params = event.get("params")?;
87        let request = params.get("request")?;
88        let text =
89            |value: &Value, key: &str| value.get(key).and_then(|v| v.as_str()).map(str::to_string);
90        let post_data = match request.get("postData").and_then(|v| v.as_str()) {
91            Some(data) => Some(data.as_bytes().to_vec()),
92            None => request
93                .get("postDataEntries")
94                .and_then(|v| v.as_array())
95                .map(|entries| {
96                    let mut bytes = Vec::new();
97                    for entry in entries {
98                        if let Some(chunk) = entry.get("bytes").and_then(|v| v.as_str()) {
99                            bytes.extend(base64_decode(chunk).unwrap_or_default());
100                        }
101                    }
102                    bytes
103                }),
104        };
105        let status = params
106            .get("responseStatusCode")
107            .and_then(|v| v.as_u64())
108            .and_then(|code| u16::try_from(code).ok());
109        let response_headers = params
110            .get("responseHeaders")
111            .and_then(|v| v.as_array())
112            .map(|items| {
113                items
114                    .iter()
115                    .filter_map(|item| Some((text(item, "name")?, text(item, "value")?)))
116                    .collect()
117            });
118        Some(Self {
119            session_id: text(event, "sessionId").unwrap_or_default(),
120            request_id: text(params, "requestId")?,
121            frame_id: text(params, "frameId").unwrap_or_default(),
122            resource_type: text(params, "resourceType").unwrap_or_default(),
123            network_id: text(params, "networkId"),
124            method: text(request, "method").unwrap_or_else(|| "GET".to_string()),
125            url: text(request, "url")?,
126            url_fragment: text(request, "urlFragment").filter(|fragment| !fragment.is_empty()),
127            headers: request
128                .get("headers")
129                .and_then(|v| v.as_object())
130                .map(|object| {
131                    object
132                        .iter()
133                        .map(|(name, value)| {
134                            (name.clone(), value.as_str().unwrap_or("").to_string())
135                        })
136                        .collect()
137                })
138                .unwrap_or_default(),
139            post_data,
140            status,
141            status_text: text(params, "responseStatusText"),
142            response_headers,
143            error_reason: text(params, "responseErrorReason"),
144        })
145    }
146
147    pub fn is_response(&self) -> bool {
148        self.status.is_some() || self.error_reason.is_some()
149    }
150
151    pub fn full_url(&self) -> String {
152        match &self.url_fragment {
153            Some(fragment) => format!("{}{fragment}", self.url),
154            None => self.url.clone(),
155        }
156    }
157
158    pub fn header(&self, name: &str) -> Option<&str> {
159        find(&self.headers, name)
160    }
161
162    pub fn response_header(&self, name: &str) -> Option<&str> {
163        find(self.response_headers.as_deref()?, name)
164    }
165
166    pub fn location(&self) -> Option<String> {
167        let raw = self.response_header("location")?;
168        let base = url::Url::parse(&self.url).ok()?;
169        Some(
170            base.join(raw)
171                .map(|url| url.to_string())
172                .unwrap_or_else(|_| raw.to_string()),
173        )
174    }
175}
176
177fn find<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
178    headers
179        .iter()
180        .find(|(have, _)| have.eq_ignore_ascii_case(name))
181        .map(|(_, value)| value.as_str())
182}
183
184#[derive(Clone, Debug, Default, PartialEq, Eq)]
185pub struct Overrides {
186    pub url: Option<String>,
187    pub method: Option<String>,
188    pub post_data: Option<Vec<u8>>,
189    pub headers: Option<Vec<(String, String)>>,
190}
191
192#[derive(Clone, Debug, Default, PartialEq, Eq)]
193pub struct ResponseOverrides {
194    pub status: Option<u16>,
195    pub status_text: Option<String>,
196    pub headers: Option<Vec<(String, String)>>,
197}
198
199#[derive(Clone, Debug, PartialEq, Eq)]
200pub struct Fulfill {
201    pub status: u16,
202    pub status_text: Option<String>,
203    pub headers: Vec<(String, String)>,
204    pub body: Vec<u8>,
205}
206
207impl Fulfill {
208    pub fn new(status: u16) -> Self {
209        Self {
210            status,
211            status_text: None,
212            headers: Vec::new(),
213            body: Vec::new(),
214        }
215    }
216
217    pub fn html(status: u16, body: impl Into<Vec<u8>>) -> Self {
218        Self::new(status)
219            .header("Content-Type", "text/html; charset=utf-8")
220            .body(body)
221    }
222
223    pub fn signed_in() -> Self {
224        Self::html(200, SIGNED_IN_HTML).header("Cache-Control", "no-store")
225    }
226
227    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
228        self.headers.push((name.into(), value.into()));
229        self
230    }
231
232    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
233        self.body = body.into();
234        self
235    }
236}
237
238#[derive(Clone, Copy, Debug, PartialEq, Eq)]
239pub enum ErrorReason {
240    Failed,
241    Aborted,
242    TimedOut,
243    AccessDenied,
244    ConnectionClosed,
245    ConnectionReset,
246    ConnectionRefused,
247    ConnectionAborted,
248    ConnectionFailed,
249    NameNotResolved,
250    InternetDisconnected,
251    AddressUnreachable,
252    BlockedByClient,
253    BlockedByResponse,
254}
255
256impl ErrorReason {
257    pub fn as_str(self) -> &'static str {
258        match self {
259            ErrorReason::Failed => "Failed",
260            ErrorReason::Aborted => "Aborted",
261            ErrorReason::TimedOut => "TimedOut",
262            ErrorReason::AccessDenied => "AccessDenied",
263            ErrorReason::ConnectionClosed => "ConnectionClosed",
264            ErrorReason::ConnectionReset => "ConnectionReset",
265            ErrorReason::ConnectionRefused => "ConnectionRefused",
266            ErrorReason::ConnectionAborted => "ConnectionAborted",
267            ErrorReason::ConnectionFailed => "ConnectionFailed",
268            ErrorReason::NameNotResolved => "NameNotResolved",
269            ErrorReason::InternetDisconnected => "InternetDisconnected",
270            ErrorReason::AddressUnreachable => "AddressUnreachable",
271            ErrorReason::BlockedByClient => "BlockedByClient",
272            ErrorReason::BlockedByResponse => "BlockedByResponse",
273        }
274    }
275
276    pub fn parse(text: &str) -> Option<Self> {
277        let all = [
278            ErrorReason::Failed,
279            ErrorReason::Aborted,
280            ErrorReason::TimedOut,
281            ErrorReason::AccessDenied,
282            ErrorReason::ConnectionClosed,
283            ErrorReason::ConnectionReset,
284            ErrorReason::ConnectionRefused,
285            ErrorReason::ConnectionAborted,
286            ErrorReason::ConnectionFailed,
287            ErrorReason::NameNotResolved,
288            ErrorReason::InternetDisconnected,
289            ErrorReason::AddressUnreachable,
290            ErrorReason::BlockedByClient,
291            ErrorReason::BlockedByResponse,
292        ];
293        all.into_iter()
294            .find(|reason| reason.as_str().eq_ignore_ascii_case(text))
295    }
296}
297
298#[derive(Clone, Debug, PartialEq, Eq)]
299pub enum Decision {
300    Continue(Overrides),
301    ContinueResponse(ResponseOverrides),
302    Fulfill(Fulfill),
303    Fail(ErrorReason),
304}
305
306impl Default for Decision {
307    fn default() -> Self {
308        Decision::Continue(Overrides::default())
309    }
310}
311
312#[derive(Clone, Debug, PartialEq, Eq)]
313pub struct Navigation {
314    pub session_id: String,
315    pub frame_id: String,
316    pub main_frame: bool,
317    pub url: String,
318}
319
320pub fn reaches_protected(guard: &Protected, paused: &Paused) -> bool {
321    guard.url(&paused.full_url()) || paused.location().is_some_and(|url| guard.url(&url))
322}
323
324pub fn navigation(guard: &Protected, event: &Value) -> Option<Navigation> {
325    if event.get("method").and_then(|v| v.as_str()) != Some("Page.frameNavigated") {
326        return None;
327    }
328    let frame = event.pointer("/params/frame")?;
329    let base = frame.get("url").and_then(|v| v.as_str())?;
330    let fragment = frame
331        .get("urlFragment")
332        .and_then(|v| v.as_str())
333        .unwrap_or("");
334    let url = format!("{base}{fragment}");
335    if guard.url(&url) || guard.url(base) {
336        return None;
337    }
338    Some(Navigation {
339        session_id: event
340            .get("sessionId")
341            .and_then(|v| v.as_str())
342            .unwrap_or("")
343            .to_string(),
344        frame_id: frame
345            .get("id")
346            .and_then(|v| v.as_str())
347            .unwrap_or("")
348            .to_string(),
349        main_frame: frame.get("parentId").is_none(),
350        url,
351    })
352}
353
354fn header_list(headers: &[(String, String)]) -> Value {
355    Value::Array(
356        headers
357            .iter()
358            .map(|(name, value)| json!({ "name": name, "value": value }))
359            .collect(),
360    )
361}
362
363fn refresh_target(value: &str) -> &str {
364    let rest = value.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
365    let rest = rest.trim_start_matches(|ch: char| ch.is_ascii_digit() || ch == '.');
366    let rest = rest.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
367    let rest = rest
368        .strip_prefix(';')
369        .or_else(|| rest.strip_prefix(','))
370        .unwrap_or(rest);
371    let rest = rest.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
372    let rest = match rest.get(..3) {
373        Some(head) if head.eq_ignore_ascii_case("url") => {
374            let after = rest[3..].trim_start_matches(|ch: char| ch.is_ascii_whitespace());
375            match after.strip_prefix('=') {
376                Some(value) => value.trim_start_matches(|ch: char| ch.is_ascii_whitespace()),
377                None => rest,
378            }
379        }
380        _ => rest,
381    };
382    let rest = rest.trim_end_matches(|ch: char| ch.is_ascii_whitespace());
383    match rest.chars().next() {
384        Some(quote @ ('\'' | '"')) => {
385            let inner = &rest[1..];
386            inner.split(quote).next().unwrap_or(inner)
387        }
388        _ => rest,
389    }
390}
391
392fn redirects_to_protected(guard: &Protected, base: &str, headers: &[(String, String)]) -> bool {
393    let base = url::Url::parse(base).ok();
394    let reaches = |target: &str| {
395        let resolved = base
396            .as_ref()
397            .and_then(|base| base.join(target).ok())
398            .map(|url| url.to_string())
399            .unwrap_or_else(|| target.to_string());
400        guard.url(&resolved) || guard.url(target)
401    };
402    headers.iter().any(|(name, value)| {
403        if name.eq_ignore_ascii_case("location") {
404            reaches(value) || guard.pattern(value)
405        } else if name.eq_ignore_ascii_case("refresh") {
406            reaches(refresh_target(value)) || guard.pattern(value)
407        } else {
408            false
409        }
410    })
411}
412
413pub fn command(
414    guard: &Protected,
415    paused: &Paused,
416    decision: &Decision,
417) -> Result<(&'static str, Value), String> {
418    let id = paused.request_id.as_str();
419    match decision {
420        Decision::Continue(overrides) => {
421            let mut params = json!({ "requestId": id });
422            if let Some(url) = &overrides.url {
423                if guard.url(url) {
424                    return Err(format!("{}\n", guard.refusal()));
425                }
426                params["url"] = json!(url);
427            }
428            if let Some(method) = &overrides.method {
429                params["method"] = json!(method);
430            }
431            if let Some(data) = &overrides.post_data {
432                params["postData"] = json!(base64_encode(data));
433            }
434            if let Some(headers) = &overrides.headers {
435                params["headers"] = header_list(headers);
436            }
437            Ok(("Fetch.continueRequest", params))
438        }
439        Decision::ContinueResponse(overrides) => {
440            if !paused.is_response() {
441                return Err(
442                    "Fetch.continueResponse: the request is not at the response stage\n".into(),
443                );
444            }
445            let mut params = json!({ "requestId": id });
446            if let Some(status) = overrides.status {
447                params["responseCode"] = json!(status);
448            }
449            if let Some(text) = &overrides.status_text {
450                params["responsePhrase"] = json!(text);
451            }
452            if let Some(headers) = &overrides.headers {
453                if redirects_to_protected(guard, &paused.url, headers) {
454                    return Err(format!("{}\n", guard.refusal()));
455                }
456                params["responseHeaders"] = header_list(headers);
457            }
458            Ok(("Fetch.continueResponse", params))
459        }
460        Decision::Fulfill(fulfill) => {
461            if redirects_to_protected(guard, &paused.url, &fulfill.headers) {
462                return Err(format!("{}\n", guard.refusal()));
463            }
464            let mut params = json!({
465                "requestId": id,
466                "responseCode": fulfill.status,
467                "responseHeaders": header_list(&fulfill.headers),
468                "body": base64_encode(&fulfill.body),
469            });
470            if let Some(text) = &fulfill.status_text {
471                params["responsePhrase"] = json!(text);
472            }
473            Ok(("Fetch.fulfillRequest", params))
474        }
475        Decision::Fail(reason) => Ok((
476            "Fetch.failRequest",
477            json!({ "requestId": id, "errorReason": reason.as_str() }),
478        )),
479    }
480}
481
482fn need_session(session_id: &str) -> Result<(), String> {
483    if session_id.is_empty() {
484        return Err("Fetch needs a target session: browser-wide interception is refused\n".into());
485    }
486    Ok(())
487}
488
489pub fn enable(cdp: &mut Cdp, session_id: &str, patterns: &[Pattern]) -> Result<(), String> {
490    need_session(session_id)?;
491    if patterns.is_empty() {
492        return Err("Fetch.enable needs at least one pattern\n".into());
493    }
494    if let Some(pattern) = patterns
495        .iter()
496        .find(|pattern| cdp.guard().pattern(&pattern.url))
497    {
498        return Err(format!("{} ({})\n", cdp.guard().refusal(), pattern.url));
499    }
500    let list: Vec<Value> = patterns.iter().map(Pattern::to_json).collect();
501    cdp.call(
502        "Fetch.enable",
503        json!({ "patterns": list, "handleAuthRequests": false }),
504        Some(session_id),
505    )?;
506    Ok(())
507}
508
509pub fn disable(cdp: &mut Cdp, session_id: &str) {
510    cdp.forget_paused(session_id);
511    if session_id.is_empty() {
512        return;
513    }
514    let _ = cdp.call_for(
515        "Fetch.disable",
516        json!({}),
517        Some(session_id),
518        Duration::from_secs(5),
519    );
520}
521
522pub fn decide(cdp: &mut Cdp, paused: &Paused, decision: &Decision) -> Result<(), String> {
523    let (method, params) = command(cdp.guard(), paused, decision)?;
524    cdp.call(method, params, Some(&paused.session_id))?;
525    Ok(())
526}
527
528pub fn continue_request(
529    cdp: &mut Cdp,
530    paused: &Paused,
531    overrides: Overrides,
532) -> Result<(), String> {
533    decide(cdp, paused, &Decision::Continue(overrides))
534}
535
536pub fn continue_response(
537    cdp: &mut Cdp,
538    paused: &Paused,
539    overrides: ResponseOverrides,
540) -> Result<(), String> {
541    decide(cdp, paused, &Decision::ContinueResponse(overrides))
542}
543
544pub fn fulfill(cdp: &mut Cdp, paused: &Paused, response: Fulfill) -> Result<(), String> {
545    decide(cdp, paused, &Decision::Fulfill(response))
546}
547
548pub fn fail(cdp: &mut Cdp, paused: &Paused, reason: ErrorReason) -> Result<(), String> {
549    decide(cdp, paused, &Decision::Fail(reason))
550}
551
552pub fn response_body(cdp: &mut Cdp, paused: &Paused) -> Result<Vec<u8>, String> {
553    if !paused.is_response() {
554        return Err("Fetch.getResponseBody: the request is not at the response stage\n".into());
555    }
556    let result = cdp.call(
557        "Fetch.getResponseBody",
558        json!({ "requestId": paused.request_id }),
559        Some(&paused.session_id),
560    )?;
561    let body = result.get("body").and_then(|v| v.as_str()).unwrap_or("");
562    if result.get("base64Encoded").and_then(|v| v.as_bool()) == Some(true) {
563        base64_decode(body)
564    } else {
565        Ok(body.as_bytes().to_vec())
566    }
567}
568
569pub fn target_protected(cdp: &mut Cdp, target_id: &str) -> Result<bool, String> {
570    cdp.target_protected(target_id, false)
571}
572
573pub fn refuse_target(cdp: &mut Cdp, target_id: &str) -> Result<(), String> {
574    if target_protected(cdp, target_id)? {
575        return Err(format!("{}\n", cdp.guard().refusal()));
576    }
577    Ok(())
578}
579
580pub struct Interception<'a> {
581    cdp: &'a mut Cdp,
582    session_id: String,
583    owned: bool,
584    done: bool,
585}
586
587impl<'a> Interception<'a> {
588    pub fn attach(cdp: &'a mut Cdp, target_id: &str, patterns: &[Pattern]) -> Result<Self, String> {
589        refuse_target(cdp, target_id)?;
590        let session_id = cdp::attach(cdp, target_id)?;
591        if let Err(message) = enable(cdp, &session_id, patterns) {
592            cdp::detach(cdp, &session_id);
593            return Err(message);
594        }
595        Ok(Self {
596            cdp,
597            session_id,
598            owned: true,
599            done: false,
600        })
601    }
602
603    pub fn on_session(
604        cdp: &'a mut Cdp,
605        target_id: &str,
606        session_id: &str,
607        patterns: &[Pattern],
608    ) -> Result<Self, String> {
609        refuse_target(cdp, target_id)?;
610        enable(cdp, session_id, patterns)?;
611        Ok(Self {
612            cdp,
613            session_id: session_id.to_string(),
614            owned: false,
615            done: false,
616        })
617    }
618
619    pub fn session_id(&self) -> &str {
620        &self.session_id
621    }
622
623    pub fn next(&mut self, timeout: Duration) -> Result<Option<Paused>, String> {
624        let session = self.session_id.clone();
625        self.cdp.next_paused(Some(&session), timeout)
626    }
627
628    pub fn decide(&mut self, paused: &Paused, decision: &Decision) -> Result<(), String> {
629        decide(self.cdp, paused, decision)
630    }
631
632    pub fn response_body(&mut self, paused: &Paused) -> Result<Vec<u8>, String> {
633        response_body(self.cdp, paused)
634    }
635
636    pub fn finish(mut self) {
637        self.close();
638    }
639
640    fn close(&mut self) {
641        if self.done {
642            return;
643        }
644        self.done = true;
645        disable(self.cdp, &self.session_id);
646        if self.owned {
647            cdp::detach(self.cdp, &self.session_id);
648        }
649    }
650}
651
652impl Deref for Interception<'_> {
653    type Target = Cdp;
654
655    fn deref(&self) -> &Cdp {
656        self.cdp
657    }
658}
659
660impl DerefMut for Interception<'_> {
661    fn deref_mut(&mut self) -> &mut Cdp {
662        self.cdp
663    }
664}
665
666impl Drop for Interception<'_> {
667    fn drop(&mut self) {
668        self.close();
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use crate::cdp::Transport;
676    use std::cell::RefCell;
677    use std::collections::VecDeque;
678    use std::rc::Rc;
679    use std::time::Instant;
680
681    type Reply = Box<dyn FnMut(&Value) -> Vec<Value>>;
682
683    struct Fake {
684        log: Rc<RefCell<Vec<Value>>>,
685        inbox: VecDeque<String>,
686        reply: Reply,
687        closed: Option<String>,
688    }
689
690    impl Transport for Fake {
691        fn send_text(&mut self, text: &str) -> Result<(), String> {
692            if let Some(closed) = &self.closed {
693                return Err(closed.clone());
694            }
695            let value: Value = serde_json::from_str(text).unwrap();
696            self.log.borrow_mut().push(value.clone());
697            for out in (self.reply)(&value) {
698                self.inbox.push_back(out.to_string());
699            }
700            Ok(())
701        }
702
703        fn recv_text(&mut self, _deadline: Instant) -> Result<Option<String>, String> {
704            if let Some(closed) = &self.closed {
705                return Err(closed.clone());
706            }
707            let next = self.inbox.pop_front();
708            if let Some(text) = &next {
709                if let Some(reason) = serde_json::from_str::<Value>(text)
710                    .ok()
711                    .and_then(|value| value.get("close").and_then(|v| v.as_str()).map(str::to_string))
712                {
713                    self.closed = Some(reason.clone());
714                    return Err(reason);
715                }
716            }
717            Ok(next)
718        }
719    }
720
721    fn fake(inbox: Vec<Value>, reply: Reply) -> (Cdp, Rc<RefCell<Vec<Value>>>) {
722        let log = Rc::new(RefCell::new(Vec::new()));
723        let transport = Fake {
724            log: log.clone(),
725            inbox: inbox.into_iter().map(|value| value.to_string()).collect(),
726            reply,
727            closed: None,
728        };
729        (Cdp::from_transport(Box::new(transport)), log)
730    }
731
732    fn ok(message: &Value) -> Value {
733        json!({ "id": message["id"], "result": {} })
734    }
735
736    fn methods(log: &Rc<RefCell<Vec<Value>>>) -> Vec<String> {
737        log.borrow()
738            .iter()
739            .map(|item| item["method"].as_str().unwrap_or("").to_string())
740            .collect()
741    }
742
743    fn paused_event(session: &str, id: &str, url: &str) -> Value {
744        json!({
745            "method": "Fetch.requestPaused",
746            "sessionId": session,
747            "params": {
748                "requestId": id,
749                "frameId": "F1",
750                "resourceType": "Document",
751                "networkId": "N1",
752                "request": {
753                    "url": url,
754                    "method": "GET",
755                    "headers": { "Accept": "text/html", "Cookie": "a=b" }
756                }
757            }
758        })
759    }
760
761    #[test]
762    fn a_paused_request_and_response_are_read() {
763        let mut event = paused_event("S", "R1", "https://idp.example/cb?code=abc");
764        event["params"]["request"]["urlFragment"] = json!("#access_token=xyz");
765        event["params"]["request"]["method"] = json!("POST");
766        event["params"]["request"]["postDataEntries"] =
767            json!([{ "bytes": base64_encode(b"a=1&") }, { "bytes": base64_encode(b"b=2") }]);
768        let paused = Paused::from_event(&event).unwrap();
769        assert_eq!(paused.session_id, "S");
770        assert_eq!(paused.request_id, "R1");
771        assert_eq!(paused.method, "POST");
772        assert_eq!(
773            paused.full_url(),
774            "https://idp.example/cb?code=abc#access_token=xyz"
775        );
776        assert_eq!(paused.header("cookie"), Some("a=b"));
777        assert_eq!(paused.post_data.as_deref(), Some(&b"a=1&b=2"[..]));
778        assert!(!paused.is_response());
779        assert_eq!(paused.location(), None);
780        let mut response = paused_event("S", "R2", "https://idp.example/authorize");
781        response["params"]["responseStatusCode"] = json!(302);
782        response["params"]["responseStatusText"] = json!("Found");
783        response["params"]["responseHeaders"] =
784            json!([{ "name": "Location", "value": "/done#id_token=t&state=s" }]);
785        let paused = Paused::from_event(&response).unwrap();
786        assert!(paused.is_response());
787        assert_eq!(paused.status, Some(302));
788        assert_eq!(
789            paused.response_header("location"),
790            Some("/done#id_token=t&state=s")
791        );
792        assert_eq!(
793            paused.location().as_deref(),
794            Some("https://idp.example/done#id_token=t&state=s")
795        );
796        assert!(Paused::from_event(&json!({ "method": "Page.loadEventFired" })).is_none());
797    }
798
799    #[test]
800    fn a_decider_answers_a_pause_that_arrives_inside_a_call() {
801        let navigate = Rc::new(RefCell::new(None));
802        let held = navigate.clone();
803        let (mut cdp, log) = fake(
804            Vec::new(),
805            Box::new(move |message| match message["method"].as_str().unwrap() {
806                "Page.navigate" => {
807                    *held.borrow_mut() = Some(message["id"].clone());
808                    vec![paused_event("S", "R1", "https://app.example/api/users")]
809                }
810                "Fetch.fulfillRequest" => vec![
811                    ok(message),
812                    json!({ "id": held.borrow().clone().unwrap(), "result": { "frameId": "F1", "loaderId": "L" } }),
813                ],
814                _ => vec![ok(message)],
815            }),
816        );
817        cdp.set_decider(
818            "S",
819            Some(Box::new(|paused: &Paused| {
820                assert_eq!(paused.url, "https://app.example/api/users");
821                Decision::Fulfill(
822                    Fulfill::new(201)
823                        .header("Content-Type", "application/json")
824                        .body("[]"),
825                )
826            })),
827        );
828        let result = cdp
829            .call(
830                "Page.navigate",
831                json!({ "url": "https://app.example/" }),
832                Some("S"),
833            )
834            .unwrap();
835        assert_eq!(result["loaderId"], "L");
836        let sent = log.borrow();
837        let fulfil = sent
838            .iter()
839            .find(|item| item["method"] == "Fetch.fulfillRequest")
840            .unwrap();
841        assert_eq!(fulfil["sessionId"], "S");
842        assert_eq!(fulfil["params"]["requestId"], "R1");
843        assert_eq!(fulfil["params"]["responseCode"], 201);
844        assert_eq!(fulfil["params"]["body"], base64_encode(b"[]"));
845        assert_eq!(
846            fulfil["params"]["responseHeaders"],
847            json!([{ "name": "Content-Type", "value": "application/json" }])
848        );
849        assert!(cdp.events().is_empty());
850        assert!(cdp.take_fetch_errors().is_empty());
851    }
852
853    #[test]
854    fn a_failed_decision_falls_back_to_continue() {
855        let (mut cdp, log) = fake(
856            vec![paused_event("S", "R1", "https://app.example/x")],
857            Box::new(|message| match message["method"].as_str().unwrap() {
858                "Fetch.fulfillRequest" => {
859                    vec![json!({ "id": message["id"], "error": { "message": "Invalid header" } })]
860                }
861                _ => vec![ok(message)],
862            }),
863        );
864        cdp.set_decider(
865            "S",
866            Some(Box::new(|_: &Paused| {
867                Decision::Fulfill(Fulfill::new(200).header("Bad\n", "x"))
868            })),
869        );
870        cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
871        assert!(cdp
872            .pump(Instant::now() + Duration::from_millis(10))
873            .unwrap());
874        assert_eq!(
875            methods(&log),
876            [
877                "Runtime.evaluate",
878                "Fetch.fulfillRequest",
879                "Fetch.continueRequest"
880            ]
881        );
882        assert_eq!(cdp.take_fetch_errors(), ["Fetch: Invalid header\n"]);
883    }
884
885    #[test]
886    fn seven_and_vault_requests_are_never_handed_out() {
887        let mut location = paused_event("S", "R3", "https://idp.example/authorize");
888        location["params"]["responseStatusCode"] = json!(302);
889        location["params"]["responseHeaders"] =
890            json!([{ "name": "location", "value": "https://vault.sliccy.ai/cb#token=t" }]);
891        let mut xhr = paused_event("S", "R2", "blob:https://vault.sliccy.ai/0b9e");
892        xhr["params"]["resourceType"] = json!("XHR");
893        let (mut cdp, log) = fake(
894            vec![
895                paused_event("S", "R1", "https://seven.sliccy.ai/api"),
896                xhr,
897                location,
898                paused_event("S", "R4", "https://app.example/ok"),
899            ],
900            Box::new(|message| vec![ok(message)]),
901        );
902        let got = cdp
903            .next_paused(Some("S"), Duration::from_millis(50))
904            .unwrap()
905            .unwrap();
906        assert_eq!(got.request_id, "R4");
907        assert!(cdp
908            .next_paused(Some("S"), Duration::from_millis(10))
909            .unwrap()
910            .is_none());
911        let sent = log.borrow();
912        let continued: Vec<&str> = sent
913            .iter()
914            .filter(|item| item["method"] == "Fetch.continueRequest")
915            .map(|item| item["params"]["requestId"].as_str().unwrap())
916            .collect();
917        assert_eq!(continued, ["R2"]);
918        let failed: Vec<(&str, &str)> = sent
919            .iter()
920            .filter(|item| item["method"] == "Fetch.failRequest")
921            .map(|item| {
922                (
923                    item["params"]["requestId"].as_str().unwrap(),
924                    item["params"]["errorReason"].as_str().unwrap(),
925                )
926            })
927            .collect();
928        assert_eq!(failed, [("R1", "BlockedByClient"), ("R3", "BlockedByClient")]);
929        assert!(sent.iter().all(|item| item["params"].get("url").is_none()));
930        assert!(cdp.events().is_empty());
931    }
932
933    #[test]
934    fn a_decider_never_sees_a_protected_request() {
935        let (mut cdp, log) = fake(
936            vec![paused_event(
937                "S",
938                "R1",
939                "https://www.sliccy.ai/auth/callback?code=c",
940            )],
941            Box::new(|message| vec![ok(message)]),
942        );
943        cdp.set_decider(
944            "S",
945            Some(Box::new(|_: &Paused| {
946                panic!("a protected request reached the decider")
947            })),
948        );
949        cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
950        assert_eq!(methods(&log), ["Runtime.evaluate", "Fetch.failRequest"]);
951    }
952
953    fn auth_event(session: &str, id: &str, url: &str) -> Value {
954        json!({
955            "method": "Fetch.authRequired",
956            "sessionId": session,
957            "params": {
958                "requestId": id,
959                "frameId": "F1",
960                "resourceType": "Fetch",
961                "request": { "url": url, "method": "GET", "headers": {} },
962                "authChallenge": { "source": "Server", "origin": url, "scheme": "basic", "realm": "r" }
963            }
964        })
965    }
966
967    fn refused(result: Result<Value, String>) -> bool {
968        result.is_err_and(|message| message == crate::cdp::UNSEEN)
969    }
970
971    #[test]
972    fn an_issued_id_is_good_on_its_session_until_it_is_answered() {
973        let mut xhr = paused_event("S", "R1", "https://app.example/api");
974        xhr["params"]["resourceType"] = json!("XHR");
975        let mut response = xhr.clone();
976        response["params"]["responseStatusCode"] = json!(200);
977        let (mut cdp, log) = fake(vec![xhr], Box::new(|message| vec![ok(message)]));
978        let got = cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().unwrap();
979        assert_eq!(got.request_id, "R1");
980        assert!(refused(cdp.call("Fetch.fulfillRequest", json!({ "requestId": "R2", "responseCode": 200 }), Some("S"))));
981        assert!(refused(cdp.call("Fetch.continueRequest", json!({ "requestId": "R1" }), Some("T"))));
982        assert!(refused(cdp.call("Fetch.continueRequest", json!({}), Some("S"))));
983        cdp.call("Fetch.getResponseBody", json!({ "requestId": "R1" }), Some("S")).unwrap();
984        cdp.call("Fetch.continueRequest", json!({ "requestId": "R1" }), Some("S")).unwrap();
985        for (method, params) in [
986            ("Fetch.failRequest", json!({ "requestId": "R1", "errorReason": "Failed" })),
987            ("Fetch.fulfillRequest", json!({ "requestId": "R1", "responseCode": 200 })),
988            ("Fetch.continueResponse", json!({ "requestId": "R1" })),
989            ("Fetch.continueWithAuth", json!({ "requestId": "R1", "authChallengeResponse": { "response": "Default" } })),
990        ] {
991            assert!(refused(cdp.call(method, params, Some("S"))), "{method}");
992        }
993        assert!(refused(cdp.notify("Fetch.continueRequest", json!({ "requestId": "R1" }), Some("S")).map(|_| Value::Null)));
994        assert_eq!(methods(&log), ["Fetch.getResponseBody", "Fetch.continueRequest"]);
995        let (mut cdp, log) = fake(vec![response], Box::new(|message| vec![ok(message)]));
996        let again = cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().unwrap();
997        assert!(again.is_response());
998        cdp.call("Fetch.continueResponse", json!({ "requestId": "R1" }), Some("S")).unwrap();
999        assert!(refused(cdp.call("Fetch.continueResponse", json!({ "requestId": "R1" }), Some("S"))));
1000        assert_eq!(methods(&log), ["Fetch.continueResponse"]);
1001    }
1002
1003    #[test]
1004    fn an_answer_chrome_rejects_leaves_the_id_issued() {
1005        let reject = |message: &Value| {
1006            if message["params"]["responseCode"] == 0 {
1007                vec![json!({ "id": message["id"], "error": { "message": "Invalid http status code" } })]
1008            } else {
1009                vec![ok(message)]
1010            }
1011        };
1012        let (mut cdp, log) = fake(
1013            vec![
1014                paused_event("S", "R1", "https://app.example/a"),
1015                paused_event("S", "R2", "https://app.example/b"),
1016            ],
1017            Box::new(reject),
1018        );
1019        while cdp.next_paused(Some("S"), Duration::from_millis(10)).unwrap().is_some() {}
1020        let bad = json!({ "requestId": "R1", "responseCode": 0 });
1021        let good = json!({ "requestId": "R1", "responseCode": 200 });
1022        assert!(cdp.call("Fetch.fulfillRequest", bad.clone(), Some("S")).unwrap_err().contains("Invalid http status"));
1023        cdp.call("Fetch.fulfillRequest", good.clone(), Some("S")).unwrap();
1024        assert!(refused(cdp.call("Fetch.fulfillRequest", good, Some("S"))));
1025        let bad = json!({ "requestId": "R2", "responseCode": 0 });
1026        cdp.notify("Fetch.fulfillRequest", bad, Some("S")).unwrap();
1027        cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
1028        cdp.notify("Fetch.continueRequest", json!({ "requestId": "R2" }), Some("S")).unwrap();
1029        cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
1030        assert!(refused(cdp.call("Fetch.continueRequest", json!({ "requestId": "R2" }), Some("S"))));
1031        assert_eq!(
1032            methods(&log),
1033            [
1034                "Fetch.fulfillRequest",
1035                "Fetch.fulfillRequest",
1036                "Fetch.fulfillRequest",
1037                "Runtime.evaluate",
1038                "Fetch.continueRequest",
1039                "Runtime.evaluate"
1040            ]
1041        );
1042    }
1043
1044    #[test]
1045    fn an_id_a_decider_answered_is_not_issued_again() {
1046        let (mut cdp, log) = fake(
1047            vec![paused_event("S", "R1", "https://app.example/x")],
1048            Box::new(|message| vec![ok(message)]),
1049        );
1050        cdp.set_decider("S", Some(Box::new(|_: &Paused| Decision::default())));
1051        cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
1052        assert!(refused(cdp.call("Fetch.fulfillRequest", json!({ "requestId": "R1", "responseCode": 200 }), Some("S"))));
1053        assert_eq!(methods(&log), ["Runtime.evaluate", "Fetch.continueRequest"]);
1054    }
1055
1056    #[test]
1057    fn a_protected_id_is_never_issued_even_when_guessed() {
1058        let mut xhr = paused_event("S", "R1", "https://seven.sliccy.ai/data");
1059        xhr["params"]["resourceType"] = json!("XHR");
1060        let (mut cdp, log) = fake(vec![xhr], Box::new(|message| vec![ok(message)]));
1061        assert!(cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().is_none());
1062        for method in [
1063            "Fetch.fulfillRequest",
1064            "Fetch.failRequest",
1065            "Fetch.continueRequest",
1066            "Fetch.continueWithAuth",
1067            "Fetch.continueResponse",
1068            "Fetch.getResponseBody",
1069            "Fetch.takeResponseBodyAsStream",
1070            "Network.continueInterceptedRequest",
1071            "Network.getResponseBodyForInterception",
1072        ] {
1073            assert!(refused(cdp.call(method, json!({ "requestId": "R1", "interceptionId": "R1" }), Some("S"))), "{method}");
1074        }
1075        assert_eq!(methods(&log), ["Fetch.continueRequest"]);
1076        let old = cdp
1077            .call("Network.setRequestInterception", json!({ "patterns": [{ "urlPattern": "*" }] }), Some("S"))
1078            .unwrap_err();
1079        assert!(old.starts_with("slicc: refused"), "{old}");
1080        assert_eq!(methods(&log), ["Fetch.continueRequest"]);
1081    }
1082
1083    #[test]
1084    fn a_protected_auth_challenge_gets_the_default_and_its_id_is_refused() {
1085        let (mut cdp, log) = fake(
1086            vec![
1087                auth_event("S", "A1", "https://vault.sliccy.ai/auth"),
1088                auth_event("S", "A2", "https://app.example/auth"),
1089            ],
1090            Box::new(|message| vec![ok(message)]),
1091        );
1092        while cdp.pump(Instant::now() + Duration::from_millis(10)).unwrap() {}
1093        let sent = log.borrow().clone();
1094        assert_eq!(sent.len(), 1);
1095        assert_eq!(sent[0]["method"], "Fetch.continueWithAuth");
1096        assert_eq!(sent[0]["params"]["requestId"], "A1");
1097        assert_eq!(sent[0]["params"]["authChallengeResponse"], json!({ "response": "Default" }));
1098        let delivered: Vec<&Value> = cdp.events().iter().collect();
1099        assert_eq!(delivered.len(), 1);
1100        assert_eq!(delivered[0]["params"]["requestId"], "A2");
1101        let answer = json!({ "response": "ProvideCredentials", "username": "u", "password": "p" });
1102        assert!(refused(cdp.call("Fetch.continueWithAuth", json!({ "requestId": "A1", "authChallengeResponse": answer }), Some("S"))));
1103        cdp.call("Fetch.continueWithAuth", json!({ "requestId": "A2", "authChallengeResponse": answer }), Some("S")).unwrap();
1104        assert!(refused(cdp.call("Fetch.continueWithAuth", json!({ "requestId": "A2", "authChallengeResponse": answer }), Some("S"))));
1105        assert_eq!(methods(&log), ["Fetch.continueWithAuth", "Fetch.continueWithAuth"]);
1106    }
1107
1108    fn panic_decider() -> Option<crate::cdp::Decider> {
1109        Some(Box::new(|paused: &Paused| {
1110            panic!("the agent saw {} paused", paused.url)
1111        }))
1112    }
1113
1114    #[test]
1115    fn a_tab_that_commits_seven_stops_intercepting_and_hands_out_nothing() {
1116        let mut leak = paused_event("S", "R1", "https://api.model.example/v1/chat");
1117        leak["params"]["resourceType"] = json!("Fetch");
1118        let (mut cdp, log) = fake(
1119            vec![
1120                json!({
1121                    "method": "Page.frameNavigated",
1122                    "sessionId": "S",
1123                    "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/os/", "securityOrigin": "https://seven.sliccy.ai" } }
1124                }),
1125                leak,
1126                json!({
1127                    "method": "Network.requestWillBeSent",
1128                    "sessionId": "S",
1129                    "params": { "requestId": "N1", "documentURL": "https://seven.sliccy.ai/os/", "request": { "url": "https://api.model.example/v1/chat", "headers": { "Authorization": "Bearer KEY" } } }
1130                }),
1131            ],
1132            Box::new(|message| vec![ok(message)]),
1133        );
1134        cdp.set_decider("S", panic_decider());
1135        while cdp.pump(Instant::now() + Duration::from_millis(10)).unwrap() {}
1136        assert!(cdp.session_blocked("S"));
1137        assert_eq!(
1138            methods(&log),
1139            ["Fetch.disable", "Target.detachFromTarget", "Fetch.continueRequest"]
1140        );
1141        assert!(log.borrow()[2]["params"].get("headers").is_none());
1142        assert!(cdp.events().is_empty(), "{:?}", cdp.events());
1143        assert!(cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap_err().starts_with("slicc: refused"));
1144    }
1145
1146    #[test]
1147    fn a_worker_attaches_without_a_page_guard_and_a_page_without_page_domains_still_does() {
1148        let (mut cdp, log) = fake(
1149            Vec::new(),
1150            Box::new(|message| match message["method"].as_str().unwrap() {
1151                "Target.getTargetInfo" => {
1152                    let kind = if message["params"]["targetId"] == "W" { "service_worker" } else { "page" };
1153                    vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": message["params"]["targetId"], "type": kind, "url": "https://app.example/sw.js" } } })]
1154                }
1155                "Target.attachToTarget" => {
1156                    vec![json!({ "id": message["id"], "result": { "sessionId": format!("S{}", message["id"]) } })]
1157                }
1158                "Page.enable" | "Runtime.enable" => vec![json!({ "id": message["id"], "error": { "message": "'Page.enable' wasn't found" } })],
1159                _ => vec![ok(message)],
1160            }),
1161        );
1162        crate::cdp::attach(&mut cdp, "W").unwrap();
1163        assert_eq!(methods(&log), ["Target.getTargetInfo", "Target.attachToTarget"]);
1164        crate::cdp::attach(&mut cdp, "P").unwrap();
1165        assert!(methods(&log).contains(&"Network.setBypassServiceWorker".to_string()));
1166    }
1167
1168    #[test]
1169    fn a_takeover_close_frame_wins_over_the_guard_refusal() {
1170        let close = "websocket closed 1011 another page took over slicc-node's /cdp (superseded-by-new-cdp-client)\n";
1171        let (mut cdp, _log) = fake(
1172            Vec::new(),
1173            Box::new(move |message| match message["method"].as_str().unwrap() {
1174                "Target.getTargetInfo" => vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T", "type": "page", "url": "https://app.example/" } } })],
1175                "Target.attachToTarget" => vec![json!({ "id": message["id"], "result": { "sessionId": "S" } })],
1176                "Target.setDiscoverTargets" => vec![json!({ "close": close })],
1177                _ => vec![ok(message)],
1178            }),
1179        );
1180        assert_eq!(crate::cdp::attach(&mut cdp, "T").unwrap_err(), close);
1181        assert_eq!(
1182            cdp.call("Runtime.evaluate", json!({ "expression": "1" }), Some("S")).unwrap_err(),
1183            close
1184        );
1185        let (mut live, _log) = fake(
1186            Vec::new(),
1187            Box::new(|message| match message["method"].as_str().unwrap() {
1188                "Target.getTargetInfo" => vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T", "type": "page", "url": "https://app.example/" } } })],
1189                "Target.attachToTarget" => vec![json!({ "id": message["id"], "result": { "sessionId": format!("S{}", message["id"]) } })],
1190                "Fetch.enable" => vec![json!({ "id": message["id"], "error": { "message": "Fetch is not available" } })],
1191                _ => vec![ok(message)],
1192            }),
1193        );
1194        assert_eq!(crate::cdp::attach(&mut live, "T").unwrap_err(), crate::cdp::UNGUARDED);
1195    }
1196
1197    #[test]
1198    fn a_target_that_landed_on_the_vault_is_reported_protected() {
1199        let (mut cdp, _log) = fake(
1200            Vec::new(),
1201            Box::new(|message| vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T", "type": "page", "url": "https://vault.sliccy.ai/landing" } } })]),
1202        );
1203        assert!(!cdp.is_protected_target("T"));
1204        assert!(cdp.target_protected("T", false).unwrap());
1205        assert!(cdp.is_protected_target("T"));
1206        assert!(cdp.call("Target.attachToTarget", json!({ "targetId": "T", "flatten": true }), None).is_err());
1207    }
1208
1209    #[test]
1210    fn requests_from_a_protected_frame_or_referrer_are_continued_unseen() {
1211        let mut framed = paused_event("S", "R1", "https://api.example/a");
1212        framed["params"]["frameId"] = json!("CHILD");
1213        framed["params"]["resourceType"] = json!("XHR");
1214        let mut referred = paused_event("S", "R2", "https://api.example/b");
1215        referred["params"]["frameId"] = json!("OTHER");
1216        referred["params"]["resourceType"] = json!("XHR");
1217        referred["params"]["request"]["headers"] = json!({ "Referer": "https://vault.sliccy.ai/unlock" });
1218        let mut plain = paused_event("S", "R3", "https://api.example/c");
1219        plain["params"]["frameId"] = json!("OTHER");
1220        plain["params"]["resourceType"] = json!("XHR");
1221        let (mut cdp, log) = fake(
1222            vec![
1223                json!({
1224                    "method": "Page.frameNavigated",
1225                    "sessionId": "S",
1226                    "params": { "frame": { "id": "CHILD", "parentId": "MAIN", "url": "https://vault.sliccy.ai/frame" } }
1227                }),
1228                framed,
1229                referred,
1230                plain,
1231            ],
1232            Box::new(|message| vec![ok(message)]),
1233        );
1234        let got = cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().unwrap();
1235        assert_eq!(got.request_id, "R3");
1236        let continued: Vec<String> = log
1237            .borrow()
1238            .iter()
1239            .filter(|item| item["method"] == "Fetch.continueRequest")
1240            .map(|item| item["params"]["requestId"].as_str().unwrap().to_string())
1241            .collect();
1242        assert_eq!(continued, ["R1", "R2"]);
1243        assert!(cdp.call("Fetch.getResponseBody", json!({ "requestId": "R1" }), Some("S")).is_err());
1244    }
1245
1246    #[test]
1247    fn enable_refuses_protected_patterns_and_browser_wide_interception() {
1248        let (mut cdp, log) = fake(Vec::new(), Box::new(|message| vec![ok(message)]));
1249        let refused = enable(
1250            &mut cdp,
1251            "S",
1252            &[Pattern::request("https://vault.sliccy.ai/*")],
1253        )
1254        .unwrap_err();
1255        assert!(
1256            refused.starts_with("slicc: refused: target is on seven's origin"),
1257            "{refused}"
1258        );
1259        let refused = enable(&mut cdp, "S", &[Pattern::response("*://*.sliccy.ai/*")]).unwrap_err();
1260        assert!(refused.starts_with("slicc: refused"), "{refused}");
1261        assert!(enable(&mut cdp, "", &[Pattern::request("*")])
1262            .unwrap_err()
1263            .contains("browser-wide"));
1264        assert!(enable(&mut cdp, "S", &[]).is_err());
1265        assert!(log.borrow().is_empty());
1266        enable(
1267            &mut cdp,
1268            "S",
1269            &[
1270                Pattern::request("*"),
1271                Pattern::response("https://idp.example/*").resource_type("Document"),
1272            ],
1273        )
1274        .unwrap();
1275        let sent = log.borrow();
1276        assert_eq!(sent[0]["method"], "Fetch.enable");
1277        assert_eq!(sent[0]["sessionId"], "S");
1278        assert_eq!(
1279            sent[0]["params"]["patterns"],
1280            json!([
1281                { "urlPattern": "*", "requestStage": "Request" },
1282                { "urlPattern": "https://idp.example/*", "requestStage": "Response", "resourceType": "Document" }
1283            ])
1284        );
1285    }
1286
1287    #[test]
1288    fn decisions_cannot_send_a_tab_to_seven() {
1289        let guard = Protected::default();
1290        let paused = Paused::from_event(&paused_event("S", "R1", "https://app.example/")).unwrap();
1291        let fulfil =
1292            Decision::Fulfill(Fulfill::new(302).header("Location", "https://seven.sliccy.ai/"));
1293        assert!(command(&guard, &paused, &fulfil)
1294            .unwrap_err()
1295            .starts_with("slicc: refused"));
1296        let refresh = Decision::Fulfill(
1297            Fulfill::html(200, "x").header("Refresh", "0; url=https://vault.sliccy.ai/"),
1298        );
1299        assert!(command(&guard, &paused, &refresh).is_err());
1300        let rewrite = Decision::Continue(Overrides {
1301            url: Some("https://vault.sliccy.ai/".into()),
1302            ..Overrides::default()
1303        });
1304        assert!(command(&guard, &paused, &rewrite).is_err());
1305        let fine = Decision::Fulfill(Fulfill::new(302).header("Location", "/next"));
1306        assert_eq!(
1307            command(&guard, &paused, &fine).unwrap().0,
1308            "Fetch.fulfillRequest"
1309        );
1310        let (method, params) = command(
1311            &guard,
1312            &paused,
1313            &Decision::Fail(ErrorReason::BlockedByClient),
1314        )
1315        .unwrap();
1316        assert_eq!(method, "Fetch.failRequest");
1317        assert_eq!(params["errorReason"], "BlockedByClient");
1318        let (method, params) = command(
1319            &guard,
1320            &paused,
1321            &Decision::Continue(Overrides {
1322                method: Some("POST".into()),
1323                post_data: Some(b"x=1".to_vec()),
1324                headers: Some(vec![("X-A".into(), "1".into())]),
1325                ..Overrides::default()
1326            }),
1327        )
1328        .unwrap();
1329        assert_eq!(method, "Fetch.continueRequest");
1330        assert_eq!(params["postData"], base64_encode(b"x=1"));
1331        assert_eq!(params["headers"], json!([{ "name": "X-A", "value": "1" }]));
1332        assert!(command(
1333            &guard,
1334            &paused,
1335            &Decision::ContinueResponse(ResponseOverrides::default())
1336        )
1337        .is_err());
1338        assert_eq!(ErrorReason::parse("timedout"), Some(ErrorReason::TimedOut));
1339        assert_eq!(ErrorReason::parse("nope"), None);
1340    }
1341
1342    #[test]
1343    fn signed_in_page_is_small_html() {
1344        let page = Fulfill::signed_in();
1345        assert_eq!(page.status, 200);
1346        assert!(page
1347            .headers
1348            .iter()
1349            .any(|(name, value)| name == "Content-Type" && value.starts_with("text/html")));
1350        assert!(String::from_utf8(page.body)
1351            .unwrap()
1352            .contains("You can close this tab"));
1353    }
1354
1355    #[test]
1356    fn an_interception_reads_a_response_and_cleans_up_on_drop() {
1357        let mut response = paused_event("S1", "R1", "https://idp.example/authorize");
1358        response["params"]["responseStatusCode"] = json!(200);
1359        let queued = Rc::new(RefCell::new(Some(response)));
1360        let attaches = Rc::new(RefCell::new(0));
1361        let (mut cdp, log) = fake(
1362            Vec::new(),
1363            Box::new(move |message| match message["method"].as_str().unwrap() {
1364                "Target.getTargetInfo" => vec![
1365                    json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T1", "url": "about:blank" } } }),
1366                ],
1367                "Target.attachToTarget" => {
1368                    *attaches.borrow_mut() += 1;
1369                    let session = if *attaches.borrow() == 1 { "S1" } else { "G1" };
1370                    vec![json!({ "id": message["id"], "result": { "sessionId": session } })]
1371                }
1372                "Fetch.enable" if message["sessionId"] == "S1" => {
1373                    vec![ok(message), queued.borrow_mut().take().unwrap()]
1374                }
1375                "Fetch.getResponseBody" => vec![
1376                    json!({ "id": message["id"], "result": { "body": base64_encode(b"<p>hi</p>"), "base64Encoded": true } }),
1377                ],
1378                _ => vec![ok(message)],
1379            }),
1380        );
1381        {
1382            let mut interception = Interception::attach(
1383                &mut cdp,
1384                "T1",
1385                &[Pattern::response("https://idp.example/*")],
1386            )
1387            .unwrap();
1388            assert_eq!(interception.session_id(), "S1");
1389            let paused = interception
1390                .next(Duration::from_millis(50))
1391                .unwrap()
1392                .unwrap();
1393            assert_eq!(interception.response_body(&paused).unwrap(), b"<p>hi</p>");
1394            interception
1395                .decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1396                .unwrap();
1397        }
1398        assert_eq!(
1399            methods(&log),
1400            [
1401                "Target.getTargetInfo",
1402                "Target.getTargetInfo",
1403                "Target.attachToTarget",
1404                "Target.setDiscoverTargets",
1405                "Target.attachToTarget",
1406                "Fetch.enable",
1407                "Network.enable",
1408                "Network.setBypassServiceWorker",
1409                "Page.enable",
1410                "Runtime.enable",
1411                "Fetch.enable",
1412                "Fetch.getResponseBody",
1413                "Fetch.fulfillRequest",
1414                "Fetch.disable",
1415                "Target.detachFromTarget"
1416            ]
1417        );
1418    }
1419
1420    #[test]
1421    fn an_interception_refuses_a_seven_tab_or_its_blank_popup() {
1422        let (mut cdp, log) = fake(
1423            Vec::new(),
1424            Box::new(|message| {
1425                let url = match message["params"]["targetId"].as_str() {
1426                    Some("SEVEN") => "https://lars.sliccy.ai/",
1427                    Some("POPUP") => "about:blank",
1428                    _ => "",
1429                };
1430                let opener = if message["params"]["targetId"] == "POPUP" {
1431                    json!("SEVEN")
1432                } else {
1433                    Value::Null
1434                };
1435                vec![
1436                    json!({ "id": message["id"], "result": { "targetInfo": { "url": url, "openerId": opener } } }),
1437                ]
1438            }),
1439        );
1440        for target in ["SEVEN", "POPUP"] {
1441            let refused = Interception::attach(&mut cdp, target, &[Pattern::request("*")])
1442                .err()
1443                .unwrap();
1444            assert!(refused.starts_with("slicc: refused"), "{target}: {refused}");
1445        }
1446        assert!(methods(&log)
1447            .iter()
1448            .all(|method| method == "Target.getTargetInfo"));
1449    }
1450
1451    #[test]
1452    fn navigation_keeps_the_fragment_and_hides_seven() {
1453        let guard = Protected::default();
1454        let event = json!({
1455            "method": "Page.frameNavigated",
1456            "sessionId": "S",
1457            "params": { "frame": { "id": "F", "url": "https://app.example/cb", "urlFragment": "#access_token=t" } }
1458        });
1459        let found = navigation(&guard, &event).unwrap();
1460        assert_eq!(found.url, "https://app.example/cb#access_token=t");
1461        assert!(found.main_frame);
1462        let seven = json!({
1463            "method": "Page.frameNavigated",
1464            "sessionId": "S",
1465            "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/auth/callback", "urlFragment": "#t" } }
1466        });
1467        assert!(navigation(&guard, &seven).is_none());
1468    }
1469
1470    fn live(name: &str) -> String {
1471        std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set"))
1472    }
1473
1474    fn encoded(text: &str) -> String {
1475        url::form_urlencoded::byte_serialize(text.as_bytes()).collect()
1476    }
1477
1478    fn evaluate(cdp: &mut Cdp, session: &str, expression: &str) -> Value {
1479        cdp.call(
1480            "Runtime.evaluate",
1481            json!({ "expression": expression, "returnByValue": true }),
1482            Some(session),
1483        )
1484        .unwrap()
1485        .pointer("/result/value")
1486        .cloned()
1487        .unwrap_or(Value::Null)
1488    }
1489
1490    fn poll(cdp: &mut Cdp, session: &str, expression: &str, want: &str) {
1491        let deadline = Instant::now() + Duration::from_secs(10);
1492        let mut last = Value::Null;
1493        while Instant::now() < deadline {
1494            last = evaluate(cdp, session, expression);
1495            if last == json!(want) {
1496                return;
1497            }
1498            let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1499        }
1500        panic!("{expression} stayed {last}, wanted {want}");
1501    }
1502
1503    fn committed(cdp: &mut Cdp, session: &str, url: &str) {
1504        let deadline = Instant::now() + Duration::from_secs(10);
1505        let mut last = String::new();
1506        while Instant::now() < deadline {
1507            last = match cdp.call("Page.getNavigationHistory", json!({}), Some(session)) {
1508                Ok(history) => {
1509                    let index = history["currentIndex"].as_u64().unwrap_or(0) as usize;
1510                    let current = history["entries"][index]["url"].as_str().unwrap_or("");
1511                    if current == url {
1512                        return;
1513                    }
1514                    current.to_string()
1515                }
1516                Err(error) => error.trim().to_string(),
1517            };
1518            let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1519        }
1520        panic!("the browser never committed {url}: {last}");
1521    }
1522
1523    fn new_tab(cdp: &mut Cdp, url: &str) -> String {
1524        cdp.call(
1525            "Target.createTarget",
1526            json!({ "url": url, "background": true }),
1527            None,
1528        )
1529        .unwrap()["targetId"]
1530            .as_str()
1531            .unwrap()
1532            .to_string()
1533    }
1534
1535    fn unpaused(cdp: &mut Cdp, session: &str, app: &str, api: &str) -> String {
1536        let page = cdp
1537            .call_for(
1538                "Runtime.evaluate",
1539                json!({ "expression": "JSON.stringify({ href: location.href, readyState: document.readyState, got: window.__got ?? null })", "returnByValue": true }),
1540                Some(session),
1541                Duration::from_secs(5),
1542            )
1543            .map(|value| value.pointer("/result/value").cloned().unwrap_or(Value::Null).to_string())
1544            .unwrap_or_else(|error| format!("evaluate failed: {}", error.trim()));
1545        let path = url::form_urlencoded::byte_serialize(b"/api/headers").collect::<String>();
1546        let hits = crate::net::http_probe(&format!("{app}/hits?path={path}"), "text/plain")
1547            .map(|response| String::from_utf8_lossy(&response.body).into_owned())
1548            .unwrap_or_else(|error| format!("unknown ({})", error.trim()));
1549        while cdp
1550            .pump(std::time::Instant::now() + Duration::from_millis(200))
1551            .unwrap_or(false)
1552        {}
1553        let trail: Vec<String> = cdp
1554            .paused_trail
1555            .iter()
1556            .filter(|(_, url, _)| url.starts_with(api))
1557            .map(|(id, url, reason)| {
1558                let whose = if id == session { "this session" } else { "another session" };
1559                format!("{url} on {whose}: {reason}")
1560            })
1561            .collect();
1562        format!(
1563            "page {page}; fixture /api/headers hits {hits}; Fetch.requestPaused for it: {}",
1564            if trail.is_empty() { "none".to_string() } else { trail.join(", ") }
1565        )
1566    }
1567
1568    fn intercepting(seen: &mut Interception<'_>, session: &str, api: &str, app: &str) {
1569        let mut active = false;
1570        for probe in 1..=10 {
1571            let url = format!("{api}?probe={probe}");
1572            start_script(seen, session, &format!("fetch({}).catch(() => null)", json!(url)));
1573            let until = Instant::now() + Duration::from_secs(2);
1574            while let Some(paused) = seen
1575                .next(until.saturating_duration_since(Instant::now()))
1576                .unwrap()
1577            {
1578                assert!(paused.url.contains("?probe="), "{} paused before the real request", paused.url);
1579                if paused.is_response() {
1580                    seen.decide(&paused, &Decision::ContinueResponse(ResponseOverrides::default()))
1581                        .unwrap();
1582                    if active {
1583                        break;
1584                    }
1585                } else {
1586                    active = true;
1587                    seen.decide(&paused, &Decision::Continue(Overrides::default()))
1588                        .unwrap();
1589                }
1590            }
1591            if active {
1592                while let Some(paused) = seen.next(Duration::from_millis(300)).unwrap() {
1593                    assert!(paused.url.contains("?probe="), "{} paused before the real request", paused.url);
1594                    let decision = if paused.is_response() {
1595                        Decision::ContinueResponse(ResponseOverrides::default())
1596                    } else {
1597                        Decision::Continue(Overrides::default())
1598                    };
1599                    seen.decide(&paused, &decision).unwrap();
1600                }
1601                return;
1602            }
1603        }
1604        panic!("interception never became active: {}", unpaused(seen, session, app, api));
1605    }
1606
1607    fn start_script(cdp: &mut Cdp, session: &str, expression: &str) {
1608        let _ = cdp.notify(
1609            "Runtime.evaluate",
1610            json!({ "expression": expression, "returnByValue": true }),
1611            Some(session),
1612        );
1613    }
1614
1615    #[test]
1616    #[ignore = "needs SLICC_FETCH_LIVE_WS from test/integration/fetch.test.mjs"]
1617    fn live_chromium_interception() {
1618        let ws = live("SLICC_FETCH_LIVE_WS");
1619        let app = live("SLICC_FETCH_LIVE_APP");
1620        let idp = live("SLICC_FETCH_LIVE_IDP");
1621        let seven = live("SLICC_FETCH_LIVE_SEVEN");
1622        let mut cdp = Cdp::connect(&ws).unwrap();
1623        let tab = new_tab(&mut cdp, "about:blank");
1624
1625        let implicit = format!("{app}/cb#access_token=T&state=S");
1626        {
1627            let mut seen = Interception::attach(
1628                &mut cdp,
1629                &tab,
1630                &[Pattern::response(format!("{idp}/authorize*"))],
1631            )
1632            .unwrap();
1633            let session = seen.session_id().to_string();
1634            seen.call("Page.enable", json!({}), Some(&session)).unwrap();
1635            let start = format!("{idp}/authorize?to={}", encoded(&implicit));
1636            let _ = seen.notify("Page.navigate", json!({ "url": start }), Some(&session));
1637            let paused = seen
1638                .next(Duration::from_secs(10))
1639                .unwrap()
1640                .expect("the redirect paused");
1641            assert!(paused.is_response());
1642            assert_eq!(paused.status, Some(302));
1643            assert_eq!(paused.location().as_deref(), Some(implicit.as_str()));
1644            seen.decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1645                .unwrap();
1646            poll(&mut seen, &session, "document.title", "Signed in");
1647            poll(
1648                &mut seen,
1649                &session,
1650                "location.href",
1651                &format!("{idp}/authorize?to={}", encoded(&implicit)),
1652            );
1653        }
1654
1655        let code = format!("{app}/cb?code=C&state=S");
1656        {
1657            let mut seen =
1658                Interception::attach(&mut cdp, &tab, &[Pattern::request(format!("{app}/cb*"))])
1659                    .unwrap();
1660            let session = seen.session_id().to_string();
1661            seen.call("Page.enable", json!({}), Some(&session)).unwrap();
1662            let start = format!("{idp}/authorize?to={}", encoded(&code));
1663            let _ = seen.notify("Page.navigate", json!({ "url": start }), Some(&session));
1664            let paused = seen
1665                .next(Duration::from_secs(10))
1666                .unwrap()
1667                .expect("the callback paused");
1668            assert!(!paused.is_response());
1669            assert_eq!(paused.url, code);
1670            assert_eq!(paused.method, "GET");
1671            assert_eq!(paused.resource_type, "Document");
1672            seen.decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1673                .unwrap();
1674            poll(&mut seen, &session, "document.title", "Signed in");
1675        }
1676
1677        {
1678            let session = cdp::attach(&mut cdp, &tab).unwrap();
1679            cdp.call("Page.enable", json!({}), Some(&session)).unwrap();
1680            let mark = cdp.events().len();
1681            let landing = format!("{app}/landing#id_token=J");
1682            let start = format!("{idp}/js-redirect?to={}", encoded(&landing));
1683            let _ = cdp.notify("Page.navigate", json!({ "url": start }), Some(&session));
1684            let guard = cdp.guard().clone();
1685            let event = cdp
1686                .wait_event(mark, Duration::from_secs(10), |event| {
1687                    navigation(&guard, event).is_some_and(|found| found.url.contains("/landing"))
1688                })
1689                .unwrap()
1690                .expect("the script redirect navigated");
1691            let found = navigation(&guard, &event).unwrap();
1692            assert_eq!(found.url, landing);
1693            assert!(found.main_frame);
1694            assert_eq!(found.session_id, session);
1695            committed(&mut cdp, &session, &landing);
1696            cdp::detach(&mut cdp, &session);
1697        }
1698
1699        {
1700            let api = format!("{app}/api/headers");
1701            let mut seen = Interception::attach(
1702                &mut cdp,
1703                &tab,
1704                &[
1705                    Pattern::request(format!("{api}*")),
1706                    Pattern::response(format!("{api}*")),
1707                ],
1708            )
1709            .unwrap();
1710            let session = seen.session_id().to_string();
1711            intercepting(&mut seen, &session, &api, &app);
1712            start_script(
1713                &mut seen,
1714                &session,
1715                &format!(
1716                    "fetch({}).then((r) => r.text()).then((t) => {{ window.__got = t; }})",
1717                    json!(api)
1718                ),
1719            );
1720            let request = match seen.next(Duration::from_secs(10)).unwrap() {
1721                Some(request) => request,
1722                None => panic!("the request paused: {}", unpaused(&mut seen, &session, &app, &api)),
1723            };
1724            assert!(!request.is_response());
1725            let mut headers = request.headers.clone();
1726            headers.push(("X-Added".into(), "1".into()));
1727            seen.decide(
1728                &request,
1729                &Decision::Continue(Overrides {
1730                    headers: Some(headers),
1731                    ..Overrides::default()
1732                }),
1733            )
1734            .unwrap();
1735            let response = seen
1736                .next(Duration::from_secs(10))
1737                .unwrap()
1738                .expect("the response paused");
1739            assert_eq!(response.status, Some(200));
1740            let body = String::from_utf8(seen.response_body(&response).unwrap()).unwrap();
1741            assert_eq!(body, "x-added=1 x-drop=none");
1742            seen.decide(
1743                &response,
1744                &Decision::ContinueResponse(ResponseOverrides::default()),
1745            )
1746            .unwrap();
1747            poll(&mut seen, &session, "window.__got || ''", &body);
1748        }
1749
1750        {
1751            let mut seen =
1752                Interception::attach(&mut cdp, &tab, &[Pattern::request("*/blocked*")]).unwrap();
1753            let session = seen.session_id().to_string();
1754            start_script(&mut seen, &session, "fetch('/blocked').then(() => 'ok', () => 'failed').then((t) => { window.__blocked = t; })");
1755            let paused = seen
1756                .next(Duration::from_secs(10))
1757                .unwrap()
1758                .expect("the blocked request paused");
1759            seen.decide(&paused, &Decision::Fail(ErrorReason::BlockedByClient))
1760                .unwrap();
1761            poll(&mut seen, &session, "window.__blocked || ''", "failed");
1762        }
1763
1764        {
1765            let mut seen = Interception::attach(&mut cdp, &tab, &[Pattern::request("*")]).unwrap();
1766            let session = seen.session_id().to_string();
1767            start_script(
1768                &mut seen,
1769                &session,
1770                &format!("fetch({}).then((r) => r.text(), (e) => 'error:' + e).then((t) => {{ window.__seven = t; }})", json!(format!("{seven}/ping"))),
1771            );
1772            assert!(seen.next(Duration::from_secs(2)).unwrap().is_none());
1773            poll(&mut seen, &session, "window.__seven || ''", "pong");
1774            let refused = enable(
1775                &mut seen,
1776                &session,
1777                &[Pattern::request(format!("{seven}/*"))],
1778            )
1779            .unwrap_err();
1780            assert!(refused.starts_with("slicc: refused"), "{refused}");
1781        }
1782
1783        let refused = cdp
1784            .call("Target.createTarget", json!({ "url": format!("{seven}/landing") }), None)
1785            .unwrap_err();
1786        assert!(refused.starts_with("slicc: refused"), "{refused}");
1787        let seven_tab = crate::raw::Raw::open(&ws).create(&format!("{seven}/landing"));
1788        let deadline = Instant::now() + Duration::from_secs(10);
1789        loop {
1790            match Interception::attach(&mut cdp, &seven_tab, &[Pattern::request("*")]) {
1791                Err(refused) => {
1792                    assert!(refused.starts_with("slicc: refused"), "{refused}");
1793                    break;
1794                }
1795                Ok(seen) => {
1796                    seen.finish();
1797                    assert!(Instant::now() < deadline, "a seven tab was intercepted");
1798                    std::thread::sleep(Duration::from_millis(200));
1799                }
1800            }
1801        }
1802        for target in [tab, seven_tab] {
1803            let _ = cdp.call("Target.closeTarget", json!({ "targetId": target }), None);
1804        }
1805    }
1806}