Skip to main content

cdp_client/
cdp.rs

1use crate::fetch::{self, Decision, Paused};
2use crate::net::Ws;
3use crate::protected::{Opener, Protected};
4use crate::shield;
5use serde_json::{json, Value};
6use std::collections::{BTreeMap, BTreeSet, VecDeque};
7use std::time::{Duration, Instant};
8
9pub trait Transport {
10    fn send_text(&mut self, text: &str) -> Result<(), String>;
11    fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String>;
12}
13
14impl Transport for Ws {
15    fn send_text(&mut self, text: &str) -> Result<(), String> {
16        Ws::send_text(self, text)
17    }
18
19    fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
20        Ws::recv_text(self, deadline)
21    }
22}
23
24pub type Decider = Box<dyn FnMut(&Paused) -> Decision>;
25
26pub const UNSEEN: &str = "slicc: refused: slicc hasn't seen this request, so it could be seven's\n";
27pub const UNGUARDED: &str = "slicc: refused: slicc can't keep seven's origin out of this target\n";
28pub const COOKIES: &str = "slicc: refused: cookies on seven's origin\n";
29pub const STREAM: &str = "slicc: refused: slicc didn't hand out this stream, so it could be seven's\n";
30
31pub struct Cdp {
32    ws: Box<dyn Transport>,
33    next_id: i64,
34    events: Vec<Value>,
35    guard: Protected,
36    paused: VecDeque<Paused>,
37    deciders: BTreeMap<String, Decider>,
38    decided: BTreeMap<i64, (String, String)>,
39    fetch_errors: Vec<String>,
40    blocked: BTreeSet<String>,
41    session_targets: BTreeMap<String, String>,
42    protected_targets: BTreeSet<String>,
43    guards: BTreeMap<String, String>,
44    guard_sessions: BTreeSet<String>,
45    guarding: BTreeSet<String>,
46    abandoned: BTreeSet<String>,
47    discovering: bool,
48    requests: BTreeMap<String, bool>,
49    handed: BTreeSet<(String, String)>,
50    answering: BTreeMap<i64, (String, String)>,
51    held: BTreeMap<String, Vec<Value>>,
52    streams: BTreeSet<String>,
53    contexts: BTreeSet<String>,
54    targets: BTreeMap<String, Value>,
55    frames: BTreeMap<String, Vec<String>>,
56}
57
58impl Cdp {
59    pub fn connect(url: &str) -> Result<Self, String> {
60        Ok(Self::from_transport(Box::new(crate::net::ws_connect(url)?)))
61    }
62
63    pub fn from_transport(ws: Box<dyn Transport>) -> Self {
64        Self {
65            ws,
66            next_id: 0,
67            events: Vec::new(),
68            guard: Protected::default(),
69            paused: VecDeque::new(),
70            deciders: BTreeMap::new(),
71            decided: BTreeMap::new(),
72            fetch_errors: Vec::new(),
73            blocked: BTreeSet::new(),
74            session_targets: BTreeMap::new(),
75            protected_targets: BTreeSet::new(),
76            guards: BTreeMap::new(),
77            guard_sessions: BTreeSet::new(),
78            guarding: BTreeSet::new(),
79            abandoned: BTreeSet::new(),
80            discovering: false,
81            requests: BTreeMap::new(),
82            handed: BTreeSet::new(),
83            answering: BTreeMap::new(),
84            held: BTreeMap::new(),
85            streams: BTreeSet::new(),
86            contexts: BTreeSet::new(),
87            targets: BTreeMap::new(),
88            frames: BTreeMap::new(),
89        }
90    }
91
92    pub fn events(&self) -> &[Value] {
93        &self.events
94    }
95
96    pub fn guard(&self) -> &Protected {
97        &self.guard
98    }
99
100    pub fn protect_origins(&mut self, extra_origins: &[&str]) {
101        self.guard = Protected::new(extra_origins);
102    }
103
104    pub fn session_blocked(&self, session_id: &str) -> bool {
105        self.blocked.contains(session_id)
106    }
107
108    pub fn is_protected_target(&self, target_id: &str) -> bool {
109        self.protected_targets.contains(target_id)
110            || self
111                .session_targets
112                .iter()
113                .any(|(session, target)| target == target_id && self.blocked.contains(session))
114    }
115
116    pub fn set_decider(&mut self, session_id: &str, decider: Option<Decider>) {
117        match decider {
118            Some(decider) => {
119                self.deciders.insert(session_id.to_string(), decider);
120                let queued: Vec<Paused> = self.paused.drain(..).collect();
121                for paused in queued {
122                    if paused.session_id == session_id {
123                        self.decide_inline(paused);
124                    } else {
125                        self.paused.push_back(paused);
126                    }
127                }
128            }
129            None => {
130                self.deciders.remove(session_id);
131            }
132        }
133    }
134
135    pub fn has_decider(&self, session_id: &str) -> bool {
136        self.deciders.contains_key(session_id)
137    }
138
139    pub fn take_fetch_errors(&mut self) -> Vec<String> {
140        std::mem::take(&mut self.fetch_errors)
141    }
142
143    pub fn call(
144        &mut self,
145        method: &str,
146        params: Value,
147        session_id: Option<&str>,
148    ) -> Result<Value, String> {
149        self.call_for(method, params, session_id, Duration::from_secs(30))
150    }
151
152    pub fn call_for(
153        &mut self,
154        method: &str,
155        params: Value,
156        session_id: Option<&str>,
157        timeout: Duration,
158    ) -> Result<Value, String> {
159        let session_id = session_id.filter(|id| !id.is_empty());
160        self.admit(method, &params, session_id)?;
161        if method == "Target.createTarget" {
162            return self.create_target(params, timeout);
163        }
164        let result = self.raw_call(method, params.clone(), session_id, timeout)?;
165        if let (true, Some(session), Some(id)) = (
166            shield::answers_interception(method),
167            session_id,
168            shield::request_key(&params),
169        ) {
170            self.settle(session, &id);
171        }
172        if let Some(session) = session_id {
173            if self.blocked.contains(session) && !harmless(method, &params) {
174                return Err(self.refusal());
175            }
176        }
177        self.after(method, &params, session_id, result)
178    }
179
180    pub fn notify(
181        &mut self,
182        method: &str,
183        params: Value,
184        session_id: Option<&str>,
185    ) -> Result<(), String> {
186        let session_id = session_id.filter(|id| !id.is_empty());
187        self.admit(method, &params, session_id)?;
188        if matches!(method, "Target.createTarget" | "Target.attachToTarget") {
189            return Err(format!("{method} must be a call, not a notify\n"));
190        }
191        let answered = shield::answers_interception(method)
192            .then(|| shield::request_key(&params))
193            .flatten()
194            .zip(session_id.map(str::to_string));
195        let sent = self.send(method, params, session_id)?;
196        if let Some((id, session)) = answered {
197            self.answering.insert(sent, (session, id));
198        }
199        Ok(())
200    }
201
202    fn refusal(&self) -> String {
203        format!("{}\n", self.guard.refusal())
204    }
205
206    fn admit(&mut self, method: &str, params: &Value, session_id: Option<&str>) -> Result<(), String> {
207        if let Some(session) = session_id {
208            if self.blocked.contains(session) && !harmless(method, params) {
209                return Err(self.refusal());
210            }
211            if self.guard_sessions.contains(session) {
212                return Err(self.refusal());
213            }
214        }
215        if params
216            .get("targetId")
217            .and_then(|v| v.as_str())
218            .is_some_and(|target| self.abandoned.contains(target))
219        {
220            return Err(UNGUARDED.to_string());
221        }
222        if let Some(reason) = shield::browser_wide(method, params, session_id, &self.contexts) {
223            return Err(reason);
224        }
225        if method == "Target.setAutoAttach"
226            && params.get("autoAttach").and_then(|v| v.as_bool()) == Some(true)
227        {
228            return Err("slicc: refused: auto-attach is not supported by slicc-cdp\n".into());
229        }
230        if matches!(method, "Target.attachToTarget" | "Target.setAutoAttach")
231            && params.get("flatten").and_then(|v| v.as_bool()) != Some(true)
232        {
233            return Err("slicc: refused: attach with flatten: true\n".into());
234        }
235        if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
236            if shield::cookie_method(method) {
237                if shield::cookie_refused(&self.guard, params) {
238                    return Err(COOKIES.into());
239                }
240            } else if self.guard.url(url) || self.guard.url(&shield::web_socket_as_http(url)) {
241                return Err(self.refusal());
242            }
243        }
244        if shield::cookie_method(method) && shield::cookie_refused(&self.guard, params) {
245            return Err(COOKIES.into());
246        }
247        if shield::names_protected_storage(&self.guard, params) {
248            return Err(self.refusal());
249        }
250        if method == "Page.navigateToHistoryEntry" {
251            let entry = params.get("entryId").and_then(|v| v.as_i64());
252            let history = self.raw_call(
253                "Page.getNavigationHistory",
254                json!({}),
255                session_id,
256                Duration::from_secs(10),
257            )?;
258            let url = history
259                .get("entries")
260                .and_then(|v| v.as_array())
261                .and_then(|entries| {
262                    entries
263                        .iter()
264                        .find(|item| item.get("id").and_then(|v| v.as_i64()) == entry)
265                })
266                .and_then(|item| item.get("url").and_then(|v| v.as_str()))
267                .unwrap_or("")
268                .to_string();
269            if self.guard.url(&url) {
270                return Err(self.refusal());
271            }
272        }
273        if !matches!(method, "Target.getTargetInfo" | "Target.detachFromTarget") {
274            for key in ["targetId", "frameId"] {
275                if let Some(target) = params.get(key).and_then(|v| v.as_str()) {
276                    let target = target.to_string();
277                    if self.target_protected(&target, key == "frameId")? {
278                        return Err(self.refusal());
279                    }
280                }
281            }
282        }
283        if shield::interception_method(method) {
284            let id = shield::request_key(params).unwrap_or_default();
285            let handed = session_id
286                .is_some_and(|session| self.handed.contains(&(session.to_string(), id.clone())));
287            if !handed || self.request_state(&id) == Some(true) {
288                return Err(UNSEEN.into());
289            }
290        }
291        if shield::body_method(method) {
292            let id = shield::request_key(params);
293            match id.as_deref().and_then(|id| self.request_state(id)) {
294                Some(true) => return Err(self.refusal()),
295                Some(false) => {}
296                None => {
297                    let deadline = Instant::now() + Duration::from_secs(2);
298                    let mut known = None;
299                    while known.is_none() && Instant::now() < deadline {
300                        if !self.pump(deadline)? {
301                            break;
302                        }
303                        known = id.as_deref().and_then(|id| self.request_state(id));
304                    }
305                    match known {
306                        Some(false) => {}
307                        Some(true) => return Err(self.refusal()),
308                        None => return Err(UNSEEN.into()),
309                    }
310                }
311            }
312        }
313        if method == "IO.read" {
314            let handle = params.get("handle").and_then(|v| v.as_str()).unwrap_or("");
315            if !self.streams.contains(handle) {
316                return Err(STREAM.into());
317            }
318        }
319        Ok(())
320    }
321
322    fn request_state(&self, id: &str) -> Option<bool> {
323        self.requests.get(id).copied()
324    }
325
326    fn after(
327        &mut self,
328        method: &str,
329        params: &Value,
330        session_id: Option<&str>,
331        mut result: Value,
332    ) -> Result<Value, String> {
333        if let Some(stream) = result.get("stream").and_then(|v| v.as_str()) {
334            self.streams.insert(stream.to_string());
335        }
336        if let Some(stream) = result.pointer("/resource/stream").and_then(|v| v.as_str()) {
337            self.streams.insert(stream.to_string());
338        }
339        if method == "IO.resolveBlob" {
340            if let Some(uuid) = result.get("uuid").and_then(|v| v.as_str()) {
341                self.streams.insert(format!("blob:{uuid}"));
342            }
343        }
344        match method {
345            "Target.attachToTarget" => {
346                let session = result
347                    .get("sessionId")
348                    .and_then(|v| v.as_str())
349                    .unwrap_or("")
350                    .to_string();
351                let target = params
352                    .get("targetId")
353                    .and_then(|v| v.as_str())
354                    .unwrap_or("")
355                    .to_string();
356                if !session.is_empty() {
357                    self.session_targets.insert(session.clone(), target.clone());
358                    if let Err(message) = self.touch(&target) {
359                        if transport_error(&message) {
360                            self.session_targets.remove(&session);
361                            return Err(message);
362                        }
363                        let detached = self.raw_call(
364                            "Target.detachFromTarget",
365                            json!({ "sessionId": session }),
366                            None,
367                            Duration::from_secs(5),
368                        );
369                        self.session_targets.remove(&session);
370                        if let Err(closed) = detached {
371                            if transport_error(&closed) {
372                                return Err(closed);
373                            }
374                        }
375                        return Err(message);
376                    }
377                    if self.protected_targets.contains(&target) {
378                        self.block(&session);
379                        return Err(self.refusal());
380                    }
381                }
382            }
383            "Target.createBrowserContext" => {
384                if let Some(id) = result.get("browserContextId").and_then(|v| v.as_str()) {
385                    self.contexts.insert(id.to_string());
386                }
387            }
388            "Target.getTargets" => {
389                let infos = result
390                    .get("targetInfos")
391                    .and_then(|v| v.as_array())
392                    .cloned()
393                    .unwrap_or_default();
394                for info in &infos {
395                    if let Some(id) = info.get("targetId").and_then(|v| v.as_str()) {
396                        self.targets.insert(id.to_string(), info.clone());
397                    }
398                }
399                let mut out = Vec::new();
400                for info in &infos {
401                    out.push(self.redact(info, &infos));
402                }
403                result["targetInfos"] = Value::Array(out);
404            }
405            "Target.getTargetInfo" => {
406                if let Some(info) = result.get("targetInfo").cloned() {
407                    let target = info.get("targetId").and_then(|v| v.as_str()).unwrap_or("").to_string();
408                    let protected = self.target_protected(&target, false).unwrap_or(true);
409                    if protected {
410                        result["targetInfo"] = shield::redacted(&info);
411                    }
412                }
413            }
414            _ if shield::cookie_reader(method) => {
415                shield::drop_protected_cookies(&self.guard, &mut result);
416            }
417            _ => {}
418        }
419        let _ = session_id;
420        Ok(result)
421    }
422
423    fn redact(&mut self, info: &Value, all: &[Value]) -> Value {
424        if self.info_protected(info, all) {
425            if let Some(id) = info.get("targetId").and_then(|v| v.as_str()) {
426                self.protected_targets.insert(id.to_string());
427            }
428            shield::redacted(info)
429        } else {
430            info.clone()
431        }
432    }
433
434    fn info_protected(&self, info: &Value, all: &[Value]) -> bool {
435        let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("");
436        let id = info.get("targetId").and_then(|v| v.as_str()).unwrap_or("");
437        let previous = self.protected_targets.contains(id);
438        let opener = match info
439            .get("openerId")
440            .and_then(|v| v.as_str())
441            .filter(|v| !v.is_empty())
442        {
443            None => Opener::None,
444            Some(opener) => match all
445                .iter()
446                .find(|item| item.get("targetId").and_then(|v| v.as_str()) == Some(opener))
447            {
448                Some(found) if found.get("targetId") == info.get("targetId") => Opener::Unknown,
449                Some(found) if self.info_protected(found, &[]) => Opener::Protected,
450                Some(_) => Opener::Unprotected,
451                None if self.protected_targets.contains(opener) => Opener::Protected,
452                None => match self.targets.get(opener) {
453                    Some(found) if found.get("targetId") != info.get("targetId") => {
454                        if self.info_protected(found, &[]) {
455                            Opener::Protected
456                        } else {
457                            Opener::Unprotected
458                        }
459                    }
460                    _ => Opener::Unknown,
461                },
462            },
463        };
464        self.guard.target(url, previous, opener)
465    }
466
467    pub fn target_protected(&mut self, target_id: &str, frame: bool) -> Result<bool, String> {
468        if self.protected_targets.contains(target_id) {
469            return Ok(true);
470        }
471        let info = match self.raw_call(
472            "Target.getTargetInfo",
473            json!({ "targetId": target_id }),
474            None,
475            Duration::from_secs(30),
476        ) {
477            Ok(info) => info.get("targetInfo").cloned().unwrap_or(Value::Null),
478            Err(_) if frame => return Ok(false),
479            Err(message) => return Err(message),
480        };
481        if info.is_object() {
482            self.targets.insert(target_id.to_string(), info.clone());
483        }
484        let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
485        let opener = match info
486            .get("openerId")
487            .and_then(|v| v.as_str())
488            .filter(|id| !id.is_empty())
489            .map(str::to_string)
490        {
491            None => Opener::None,
492            Some(id) if self.protected_targets.contains(&id) => Opener::Protected,
493            Some(id) => match self.raw_call(
494                "Target.getTargetInfo",
495                json!({ "targetId": id }),
496                None,
497                Duration::from_secs(30),
498            ) {
499                Ok(found) => {
500                    let opener_url = found
501                        .pointer("/targetInfo/url")
502                        .and_then(|v| v.as_str())
503                        .unwrap_or("");
504                    if self.guard.target(opener_url, false, Opener::Unknown) {
505                        Opener::Protected
506                    } else {
507                        Opener::Unprotected
508                    }
509                }
510                Err(_) => Opener::Unknown,
511            },
512        };
513        let protected = self.guard.target(&url, false, opener);
514        if protected {
515            self.protected_targets.insert(target_id.to_string());
516        }
517        Ok(protected)
518    }
519
520    fn create_target(&mut self, params: Value, timeout: Duration) -> Result<Value, String> {
521        let url = params
522            .get("url")
523            .and_then(|v| v.as_str())
524            .unwrap_or("about:blank")
525            .to_string();
526        let mut blank = params.clone();
527        blank["url"] = json!("about:blank");
528        let result = self.raw_call("Target.createTarget", blank, None, timeout)?;
529        let target = result
530            .get("targetId")
531            .and_then(|v| v.as_str())
532            .unwrap_or("")
533            .to_string();
534        if let Err(message) = self.touch(&target) {
535            let _ = self.raw_call(
536                "Target.closeTarget",
537                json!({ "targetId": target }),
538                None,
539                Duration::from_secs(5),
540            );
541            return Err(message);
542        }
543        if !url.eq_ignore_ascii_case("about:blank") {
544            let guard = self.guards.get(&target).cloned();
545            self.send("Page.navigate", json!({ "url": url }), guard.as_deref())?;
546        }
547        Ok(result)
548    }
549
550    fn touch(&mut self, target_id: &str) -> Result<(), String> {
551        if target_id.is_empty() || self.guards.contains_key(target_id) {
552            return Ok(());
553        }
554        let kind = match self.targets.get(target_id) {
555            Some(info) => info.get("type").and_then(|v| v.as_str()).map(str::to_string),
556            None => self
557                .raw_call(
558                    "Target.getTargetInfo",
559                    json!({ "targetId": target_id }),
560                    None,
561                    Duration::from_secs(10),
562                )
563                .ok()
564                .and_then(|info| {
565                    info.pointer("/targetInfo/type")
566                        .and_then(|v| v.as_str())
567                        .map(str::to_string)
568                }),
569        };
570        if kind
571            .as_deref()
572            .is_some_and(|kind| !matches!(kind, "page" | "iframe" | "background_page" | "webview" | "other"))
573        {
574            return Ok(());
575        }
576        if !self.discovering {
577            self.raw_call(
578                "Target.setDiscoverTargets",
579                json!({ "discover": true }),
580                None,
581                Duration::from_secs(10),
582            )
583            .map_err(unguarded)?;
584            self.discovering = true;
585        }
586        self.guarding.insert(target_id.to_string());
587        let attached = self.raw_call(
588            "Target.attachToTarget",
589            json!({ "targetId": target_id, "flatten": true }),
590            None,
591            Duration::from_secs(10),
592        );
593        self.guarding.remove(target_id);
594        let session = match attached
595            .map(|value| value.get("sessionId").and_then(|v| v.as_str()).map(str::to_string))
596        {
597            Ok(Some(session)) => session,
598            Ok(None) => {
599                self.abandon(target_id);
600                return Err(UNGUARDED.to_string());
601            }
602            Err(message) => {
603                self.abandon(target_id);
604                return Err(unguarded(message));
605            }
606        };
607        self.guard_sessions.insert(session.clone());
608        self.guards.insert(target_id.to_string(), session.clone());
609        let setup = [
610            (
611                "Fetch.enable",
612                json!({ "patterns": [{ "urlPattern": "*", "resourceType": "Document", "requestStage": "Request" }] }),
613            ),
614            ("Network.enable", json!({})),
615            ("Network.setBypassServiceWorker", json!({ "bypass": true })),
616            ("Page.enable", json!({})),
617            ("Runtime.enable", json!({})),
618        ];
619        for (method, params) in setup {
620            let optional = matches!(method, "Page.enable" | "Runtime.enable");
621            let failed = match self.raw_call(method, params, Some(&session), Duration::from_secs(10)) {
622                Ok(_) => None,
623                Err(message) if optional && message.starts_with(&format!("{method}:")) => None,
624                Err(message) => Some(message),
625            };
626            if let Some(message) = failed {
627                self.guards.remove(target_id);
628                self.abandon(target_id);
629                let _ = self.raw_call(
630                    "Target.detachFromTarget",
631                    json!({ "sessionId": session }),
632                    None,
633                    Duration::from_secs(5),
634                );
635                return Err(unguarded(message));
636            }
637        }
638        Ok(())
639    }
640
641    fn abandon(&mut self, target_id: &str) {
642        self.abandoned.insert(target_id.to_string());
643        self.protect_target(target_id);
644    }
645
646    fn adopt_guard_attach(&mut self, value: &Value) {
647        if value.get("method").and_then(|v| v.as_str()) != Some("Target.attachedToTarget") {
648            return;
649        }
650        let target = value
651            .pointer("/params/targetInfo/targetId")
652            .and_then(|v| v.as_str())
653            .unwrap_or("");
654        let Some(session) = value.pointer("/params/sessionId").and_then(|v| v.as_str()) else {
655            return;
656        };
657        if self.guarding.contains(target) {
658            self.guard_sessions.insert(session.to_string());
659        } else if self.abandoned.contains(target) {
660            self.guard_sessions.insert(session.to_string());
661            let _ = self.send(
662                "Target.detachFromTarget",
663                json!({ "sessionId": session }),
664                None,
665            );
666        }
667    }
668
669    fn block(&mut self, session_id: &str) {
670        if !self.blocked.insert(session_id.to_string()) {
671            return;
672        }
673        if let Some(target) = self.session_targets.get(session_id) {
674            self.protected_targets.insert(target.clone());
675        }
676        let queued: Vec<Paused> = self.paused.drain(..).collect();
677        for paused in queued {
678            if paused.session_id == session_id {
679                self.answer_protected(&paused);
680            } else {
681                self.paused.push_back(paused);
682            }
683        }
684        let _ = self.send("Fetch.disable", json!({}), Some(session_id));
685        let _ = self.send(
686            "Target.detachFromTarget",
687            json!({ "sessionId": session_id }),
688            None,
689        );
690    }
691
692    fn session_target(&self, session_id: &str) -> Option<String> {
693        self.session_targets.get(session_id).cloned().or_else(|| {
694            self.guards
695                .iter()
696                .find(|(_, guard)| guard.as_str() == session_id)
697                .map(|(target, _)| target.clone())
698        })
699    }
700
701    fn committed_protected(&mut self, session_id: &str) {
702        match self.session_target(session_id) {
703            Some(target) => self.protect_target(&target),
704            None => self.block(session_id),
705        }
706        if !self.guard_sessions.contains(session_id) {
707            self.block(session_id);
708        }
709    }
710
711    fn frame_protected(&self, frame_id: &str) -> bool {
712        if frame_id.is_empty() {
713            return false;
714        }
715        self.protected_targets.contains(frame_id)
716            || self
717                .frames
718                .get(frame_id)
719                .is_some_and(|known| known.iter().any(|url| self.guard.url(url)))
720    }
721
722    fn initiated_by_protected(&self, paused: &Paused) -> bool {
723        if self.frame_protected(&paused.frame_id) {
724            return true;
725        }
726        if self
727            .session_target(&paused.session_id)
728            .is_some_and(|target| self.protected_targets.contains(&target))
729        {
730            return true;
731        }
732        ["referer", "origin"].iter().any(|name| {
733            paused
734                .header(name)
735                .is_some_and(|value| value != "null" && self.guard.url(value))
736        })
737    }
738
739    fn protect_target(&mut self, target_id: &str) {
740        self.protected_targets.insert(target_id.to_string());
741        let sessions: Vec<String> = self
742            .session_targets
743            .iter()
744            .filter(|(_, have)| have.as_str() == target_id)
745            .map(|(session, _)| session.clone())
746            .collect();
747        for session in sessions {
748            self.block(&session);
749        }
750    }
751
752    fn answer_protected(&mut self, paused: &Paused) {
753        self.settle(&paused.session_id, &paused.request_id);
754        if paused.resource_type == "Document" {
755            let _ = self.send(
756                "Fetch.failRequest",
757                json!({ "requestId": paused.request_id, "errorReason": "BlockedByClient" }),
758                Some(&paused.session_id),
759            );
760        } else {
761            self.continue_plain(&paused.session_id, &paused.request_id);
762        }
763    }
764
765    fn continue_plain(&mut self, session_id: &str, request_id: &str) {
766        self.settle(session_id, request_id);
767        let _ = self.send(
768            "Fetch.continueRequest",
769            json!({ "requestId": request_id }),
770            Some(session_id),
771        );
772    }
773
774    fn watch(&mut self, value: &mut Value) {
775        let method = value
776            .get("method")
777            .and_then(|v| v.as_str())
778            .unwrap_or("")
779            .to_string();
780        let session = value
781            .get("sessionId")
782            .and_then(|v| v.as_str())
783            .map(str::to_string);
784        let params = value.get("params").cloned().unwrap_or(Value::Null);
785        if let Some(id) = params.get("requestId").and_then(|v| v.as_str()) {
786            if let Some(url) = shield::request_url(&params) {
787                let initiator = ["documentURL"]
788                    .iter()
789                    .filter_map(|key| params.get(*key).and_then(|v| v.as_str()))
790                    .chain(params.pointer("/initiator/url").and_then(|v| v.as_str()))
791                    .any(|text| self.guard.url(text))
792                    || params
793                        .get("frameId")
794                        .and_then(|v| v.as_str())
795                        .is_some_and(|frame| self.frame_protected(frame));
796                let protected = initiator
797                    || self.guard.url(&url)
798                    || self.guard.url(&shield::web_socket_as_http(&url));
799                let entry = self.requests.entry(id.to_string()).or_insert(false);
800                *entry = *entry || protected;
801            }
802        }
803        match method.as_str() {
804            "Page.frameNavigated" => {
805                let Some(session) = session else { return };
806                let frame = params.get("frame").cloned().unwrap_or(Value::Null);
807                let url = format!(
808                    "{}{}",
809                    frame.get("url").and_then(|v| v.as_str()).unwrap_or(""),
810                    frame.get("urlFragment").and_then(|v| v.as_str()).unwrap_or("")
811                );
812                let origin = frame
813                    .get("securityOrigin")
814                    .and_then(|v| v.as_str())
815                    .unwrap_or("")
816                    .to_string();
817                if let Some(id) = frame.get("id").and_then(|v| v.as_str()) {
818                    self.frames.insert(id.to_string(), vec![url.clone(), origin.clone()]);
819                }
820                if frame.get("parentId").is_none() && (self.guard.url(&url) || self.guard.url(&origin)) {
821                    self.committed_protected(&session);
822                }
823            }
824            "Runtime.executionContextCreated" => {
825                let Some(session) = session else { return };
826                let origin = params
827                    .pointer("/context/origin")
828                    .and_then(|v| v.as_str())
829                    .unwrap_or("");
830                let default = params
831                    .pointer("/context/auxData/isDefault")
832                    .and_then(|v| v.as_bool())
833                    .unwrap_or(true);
834                if let Some(frame) = params.pointer("/context/auxData/frameId").and_then(|v| v.as_str()) {
835                    if default {
836                        self.frames
837                            .entry(frame.to_string())
838                            .or_default()
839                            .push(origin.to_string());
840                    }
841                }
842                if default && self.guard.url(origin) {
843                    self.committed_protected(&session);
844                }
845            }
846            "Target.targetCreated" | "Target.targetInfoChanged" => {
847                let info = params.get("targetInfo").cloned().unwrap_or(Value::Null);
848                let target = info
849                    .get("targetId")
850                    .and_then(|v| v.as_str())
851                    .unwrap_or("")
852                    .to_string();
853                let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("");
854                if !target.is_empty() {
855                    self.targets.insert(target.clone(), info.clone());
856                }
857                if self.info_protected(&info, &[]) {
858                    self.protect_target(&target);
859                    value["params"]["targetInfo"] = shield::redacted(&info);
860                } else if !self.guard.target(url, false, Opener::Unknown)
861                    && !self.abandoned.contains(&target)
862                {
863                    self.protected_targets.remove(&target);
864                }
865            }
866            "Target.targetDestroyed" => {
867                if let Some(target) = params.get("targetId").and_then(|v| v.as_str()) {
868                    self.guards.remove(target);
869                    self.targets.remove(target);
870                }
871            }
872            "Target.detachedFromTarget" => {
873                if let Some(child) = params.get("sessionId").and_then(|v| v.as_str()) {
874                    self.session_targets.remove(child);
875                    self.guard_sessions.remove(child);
876                    self.guards.retain(|_, session| session != child);
877                }
878            }
879            _ => {}
880        }
881    }
882
883    fn hand(&mut self, session_id: &str, request_id: &str) {
884        self.handed
885            .insert((session_id.to_string(), request_id.to_string()));
886    }
887
888    fn settle(&mut self, session_id: &str, request_id: &str) {
889        self.handed
890            .remove(&(session_id.to_string(), request_id.to_string()));
891    }
892
893    fn challenge_protected(&self, value: &Value) -> bool {
894        let session = value.get("sessionId").and_then(|v| v.as_str()).unwrap_or("");
895        let params = value.get("params").cloned().unwrap_or(Value::Null);
896        let id = params.get("requestId").and_then(|v| v.as_str()).unwrap_or("");
897        let text = |pointer: &str| params.pointer(pointer).and_then(|v| v.as_str()).unwrap_or("");
898        self.requests.get(id) == Some(&true)
899            || self.blocked.contains(session)
900            || self.guard_sessions.contains(session)
901            || self.frame_protected(text("/frameId"))
902            || self
903                .session_target(session)
904                .is_some_and(|target| self.protected_targets.contains(&target))
905            || ["/request/url", "/authChallenge/origin"]
906                .iter()
907                .any(|pointer| self.guard.url(text(pointer)))
908    }
909
910    fn hidden(&self, value: &Value) -> bool {
911        let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("");
912        if let Some(session) = value.get("sessionId").and_then(|v| v.as_str()) {
913            if self.blocked.contains(session) || self.guard_sessions.contains(session) {
914                return true;
915            }
916        }
917        if method.starts_with("Target.") {
918            if let Some(child) = value.pointer("/params/sessionId").and_then(|v| v.as_str()) {
919                if self.guard_sessions.contains(child) || self.blocked.contains(child) {
920                    return true;
921                }
922            }
923            return false;
924        }
925        let params = value.get("params").cloned().unwrap_or(Value::Null);
926        if let Some(id) = params.get("requestId").and_then(|v| v.as_str()) {
927            if self.requests.get(id) == Some(&true) {
928                return true;
929            }
930        }
931        shield::mentions(&self.guard, &params)
932    }
933
934    fn deliver(&mut self, mut value: Value) {
935        self.adopt_guard_attach(&value);
936        self.watch(&mut value);
937        if self.hidden(&value) {
938            return;
939        }
940        let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("");
941        let id = value
942            .pointer("/params/requestId")
943            .and_then(|v| v.as_str())
944            .map(str::to_string);
945        let session = value.get("sessionId").and_then(|v| v.as_str());
946        if let (true, Some(session), Some(id)) = (method.starts_with("Fetch."), session, &id) {
947            let session = session.to_string();
948            self.hand(&session, id);
949        }
950        if method.ends_with("ExtraInfo") {
951            if let Some(id) = id.clone() {
952                shield::strip_protected_associated(&self.guard, &mut value);
953                if self.requests.get(&id) != Some(&false) {
954                    self.held.entry(id).or_default().push(value);
955                    return;
956                }
957            }
958        }
959        self.events.push(value);
960        if let Some(id) = id {
961            if self.requests.get(&id) == Some(&false) {
962                if let Some(held) = self.held.remove(&id) {
963                    self.events.extend(held);
964                }
965            }
966        }
967    }
968
969    fn raw_call(
970        &mut self,
971        method: &str,
972        params: Value,
973        session_id: Option<&str>,
974        timeout: Duration,
975    ) -> Result<Value, String> {
976        let id = self.send(method, params, session_id)?;
977        let deadline = Instant::now() + timeout;
978        let was_blocked = session_id.is_some_and(|session| self.blocked.contains(session));
979        loop {
980            let text = match self.ws.recv_text(deadline)? {
981                Some(text) => text,
982                None => return Err(format!("{method} timed out\n")),
983            };
984            let value: Value = serde_json::from_str(&text)
985                .map_err(|err| format!("CDP response was not JSON: {err}\n"))?;
986            if value.get("id").and_then(|item| item.as_i64()) == Some(id) {
987                if let Some(error) = value.get("error") {
988                    let text = error
989                        .get("message")
990                        .and_then(|item| item.as_str())
991                        .unwrap_or("CDP error");
992                    return Err(format!("{method}: {text}\n"));
993                }
994                return Ok(value.get("result").cloned().unwrap_or(Value::Null));
995            }
996            let detached = value.get("method").and_then(|v| v.as_str())
997                == Some("Target.detachedFromTarget")
998                && value.pointer("/params/sessionId").and_then(|v| v.as_str()) == session_id;
999            self.absorb(value);
1000            if let Some(session) = session_id {
1001                if detached || (!was_blocked && self.blocked.contains(session)) {
1002                    return Err(self.refusal());
1003                }
1004            }
1005        }
1006    }
1007
1008    fn send(&mut self, method: &str, params: Value, session_id: Option<&str>) -> Result<i64, String> {
1009        self.next_id += 1;
1010        let id = self.next_id;
1011        let mut message = json!({
1012            "id": id,
1013            "method": method,
1014            "params": params,
1015        });
1016        if let Some(session_id) = session_id {
1017            message["sessionId"] = json!(session_id);
1018        }
1019        self.ws.send_text(&message.to_string())?;
1020        Ok(id)
1021    }
1022
1023    pub fn pump(&mut self, deadline: Instant) -> Result<bool, String> {
1024        match self.ws.recv_text(deadline)? {
1025            Some(text) => {
1026                if let Ok(value) = serde_json::from_str::<Value>(&text) {
1027                    self.absorb(value);
1028                }
1029                Ok(true)
1030            }
1031            None => Ok(false),
1032        }
1033    }
1034
1035    pub fn next_paused(
1036        &mut self,
1037        session_id: Option<&str>,
1038        timeout: Duration,
1039    ) -> Result<Option<Paused>, String> {
1040        let deadline = Instant::now() + timeout;
1041        loop {
1042            let found = self
1043                .paused
1044                .iter()
1045                .position(|paused| session_id.is_none_or(|id| paused.session_id == id));
1046            if let Some(index) = found {
1047                return Ok(self.paused.remove(index));
1048            }
1049            if let Some(session) = session_id {
1050                if self.blocked.contains(session) {
1051                    return Err(self.refusal());
1052                }
1053            }
1054            if Instant::now() >= deadline || !self.pump(deadline)? {
1055                return Ok(None);
1056            }
1057        }
1058    }
1059
1060    pub fn wait_event(
1061        &mut self,
1062        from: usize,
1063        timeout: Duration,
1064        mut matches: impl FnMut(&Value) -> bool,
1065    ) -> Result<Option<Value>, String> {
1066        let deadline = Instant::now() + timeout;
1067        let mut seen = from;
1068        loop {
1069            while seen < self.events.len() {
1070                if matches(&self.events[seen]) {
1071                    return Ok(Some(self.events[seen].clone()));
1072                }
1073                seen += 1;
1074            }
1075            if Instant::now() >= deadline || !self.pump(deadline)? {
1076                return Ok(None);
1077            }
1078        }
1079    }
1080
1081    pub(crate) fn forget_paused(&mut self, session_id: &str) {
1082        self.paused.retain(|paused| paused.session_id != session_id);
1083        self.deciders.remove(session_id);
1084    }
1085
1086    fn absorb(&mut self, value: Value) {
1087        if let Some(id) = value.get("id").and_then(|item| item.as_i64()) {
1088            if let Some((session, request)) = self.answering.remove(&id) {
1089                if value.get("error").is_none() {
1090                    self.settle(&session, &request);
1091                }
1092            }
1093            if let Some((session, request)) = self.decided.remove(&id) {
1094                if let Some(error) = value.get("error") {
1095                    let text = error
1096                        .get("message")
1097                        .and_then(|item| item.as_str())
1098                        .unwrap_or("CDP error");
1099                    self.fetch_errors.push(format!("Fetch: {text}\n"));
1100                    self.continue_plain(&session, &request);
1101                }
1102            }
1103            return;
1104        }
1105        if value.get("method").and_then(|item| item.as_str()) == Some("Fetch.authRequired")
1106            && self.challenge_protected(&value)
1107        {
1108            let id = value.pointer("/params/requestId").cloned().unwrap_or(Value::Null);
1109            if let Some(key) = id.as_str() {
1110                self.requests.insert(key.to_string(), true);
1111            }
1112            let session = value.get("sessionId").and_then(|v| v.as_str()).unwrap_or("").to_string();
1113            let _ = self.send(
1114                "Fetch.continueWithAuth",
1115                json!({ "requestId": id, "authChallengeResponse": { "response": "Default" } }),
1116                Some(&session),
1117            );
1118            return;
1119        }
1120        if value.get("method").and_then(|item| item.as_str()) == Some("Fetch.requestPaused") {
1121            let Some(paused) = Paused::from_event(&value) else {
1122                return;
1123            };
1124            let protected = fetch::reaches_protected(&self.guard, &paused);
1125            let initiated = self.initiated_by_protected(&paused);
1126            self.requests.insert(paused.request_id.clone(), protected || initiated);
1127            if let Some(network) = &paused.network_id {
1128                let entry = self.requests.entry(network.clone()).or_insert(false);
1129                *entry = *entry || protected || initiated;
1130            }
1131            if self.guard_sessions.contains(&paused.session_id) {
1132                if protected && paused.resource_type == "Document" {
1133                    self.answer_protected(&paused);
1134                } else {
1135                    self.continue_plain(&paused.session_id, &paused.request_id);
1136                }
1137                return;
1138            }
1139            if protected {
1140                self.answer_protected(&paused);
1141                return;
1142            }
1143            if self.blocked.contains(&paused.session_id) || initiated {
1144                self.continue_plain(&paused.session_id, &paused.request_id);
1145                return;
1146            }
1147            self.hand(&paused.session_id, &paused.request_id);
1148            if self.deciders.contains_key(&paused.session_id) {
1149                self.decide_inline(paused);
1150            } else {
1151                self.paused.push_back(paused);
1152            }
1153            return;
1154        }
1155        if value.get("method").is_some() {
1156            self.deliver(value);
1157        }
1158    }
1159
1160    fn decide_inline(&mut self, paused: Paused) {
1161        if self.blocked.contains(&paused.session_id) {
1162            self.answer_protected(&paused);
1163            return;
1164        }
1165        let Some(decider) = self.deciders.get_mut(&paused.session_id) else {
1166            self.paused.push_back(paused);
1167            return;
1168        };
1169        let decision = decider(&paused);
1170        let (method, params) = match fetch::command(&self.guard, &paused, &decision) {
1171            Ok(command) => command,
1172            Err(message) => {
1173                self.fetch_errors.push(message);
1174                fetch::command(&self.guard, &paused, &Decision::default())
1175                    .expect("a plain continue is always allowed")
1176            }
1177        };
1178        self.settle(&paused.session_id, &paused.request_id);
1179        if let Ok(id) = self.send(method, params, Some(&paused.session_id)) {
1180            self.decided
1181                .insert(id, (paused.session_id.clone(), paused.request_id.clone()));
1182        }
1183    }
1184
1185    pub fn wait_load(&mut self, session_id: &str) -> Result<(), String> {
1186        if self.events.iter().any(|event| is_load(event, session_id)) {
1187            return Ok(());
1188        }
1189        let probe = self.call_for(
1190            "Runtime.evaluate",
1191            json!({
1192                "expression": "document.readyState",
1193                "returnByValue": true,
1194            }),
1195            Some(session_id),
1196            Duration::from_secs(1),
1197        );
1198        let settled = match &probe {
1199            Ok(result) => probe_is_settled(Ok(result
1200                .pointer("/result/value")
1201                .and_then(|value| value.as_str())))?,
1202            Err(err) => probe_is_settled(Err(err.as_str()))?,
1203        };
1204        if settled {
1205            return Ok(());
1206        }
1207        let deadline = Instant::now() + Duration::from_secs(15);
1208        loop {
1209            if self.blocked.contains(session_id) {
1210                return Err(self.refusal());
1211            }
1212            if self.events.iter().any(|event| is_load(event, session_id)) {
1213                return Ok(());
1214            }
1215            if Instant::now() >= deadline {
1216                return Ok(());
1217            }
1218            if !self.pump(deadline)? {
1219                return Ok(());
1220            }
1221        }
1222    }
1223
1224    pub fn wait_load_since(&mut self, session_id: &str, from: usize) -> Result<(), String> {
1225        let deadline = Instant::now() + Duration::from_secs(15);
1226        loop {
1227            if self.blocked.contains(session_id) {
1228                return Err(self.refusal());
1229            }
1230            if navigation_settled(&self.events, session_id, from) {
1231                return Ok(());
1232            }
1233            if Instant::now() >= deadline {
1234                return Ok(());
1235            }
1236            if !self.pump(deadline)? {
1237                return Ok(());
1238            }
1239        }
1240    }
1241}
1242
1243pub fn transport_error(message: &str) -> bool {
1244    message.starts_with("websocket ") || message.starts_with("CDP response was not JSON")
1245}
1246
1247fn unguarded(message: String) -> String {
1248    if transport_error(&message) {
1249        message
1250    } else {
1251        UNGUARDED.to_string()
1252    }
1253}
1254
1255fn harmless(method: &str, params: &Value) -> bool {
1256    match method {
1257        "Fetch.disable" => true,
1258        "Fetch.continueRequest" => params
1259            .as_object()
1260            .is_some_and(|object| object.keys().all(|key| key == "requestId")),
1261        _ => false,
1262    }
1263}
1264
1265fn probe_is_settled(outcome: Result<Option<&str>, &str>) -> Result<bool, String> {
1266    match outcome {
1267        Ok(Some("loading")) | Ok(Some("interactive")) => Ok(false),
1268        Ok(_) => Ok(true),
1269        Err(err)
1270            if err.starts_with("Runtime.evaluate timed out")
1271                || err.starts_with("Runtime.evaluate:") =>
1272        {
1273            Ok(true)
1274        }
1275        Err(err) => Err(err.to_string()),
1276    }
1277}
1278
1279fn navigation_settled(events: &[Value], session_id: &str, from: usize) -> bool {
1280    let mut committed = false;
1281    for event in events.iter().skip(from) {
1282        if !committed && is_commit(event, session_id) {
1283            committed = true;
1284            continue;
1285        }
1286        if committed && is_load(event, session_id) {
1287            return true;
1288        }
1289    }
1290    false
1291}
1292
1293fn is_commit(event: &Value, session_id: &str) -> bool {
1294    if event.get("sessionId").and_then(|value| value.as_str()) != Some(session_id) {
1295        return false;
1296    }
1297    if event.get("method").and_then(|value| value.as_str()) != Some("Page.frameNavigated") {
1298        return false;
1299    }
1300    let url = event
1301        .pointer("/params/frame/url")
1302        .and_then(|value| value.as_str())
1303        .unwrap_or("");
1304    !url.is_empty() && !url.eq_ignore_ascii_case("about:blank")
1305}
1306
1307fn is_load(event: &Value, session_id: &str) -> bool {
1308    if event.get("sessionId").and_then(|v| v.as_str()) != Some(session_id) {
1309        return false;
1310    }
1311    match event.get("method").and_then(|v| v.as_str()) {
1312        Some("Page.loadEventFired") => true,
1313        Some("Page.lifecycleEvent") => {
1314            event.pointer("/params/name").and_then(|v| v.as_str()) == Some("load")
1315        }
1316        _ => false,
1317    }
1318}
1319
1320pub fn attach(cdp: &mut Cdp, target_id: &str) -> Result<String, String> {
1321    let result = cdp.call(
1322        "Target.attachToTarget",
1323        json!({ "targetId": target_id, "flatten": true }),
1324        None,
1325    )?;
1326    result
1327        .get("sessionId")
1328        .and_then(|v| v.as_str())
1329        .map(str::to_string)
1330        .ok_or_else(|| "Target.attachToTarget returned no sessionId\n".to_string())
1331}
1332
1333pub fn detach(cdp: &mut Cdp, session_id: &str) {
1334    cdp.forget_paused(session_id);
1335    cdp.blocked.remove(session_id);
1336    cdp.session_targets.remove(session_id);
1337    let _ = cdp.call(
1338        "Target.detachFromTarget",
1339        json!({ "sessionId": session_id }),
1340        None,
1341    );
1342}
1343
1344#[derive(Clone, Debug, Default)]
1345pub struct ActionNote {
1346    pub kind: String,
1347    pub tab: Option<String>,
1348    pub target: Option<String>,
1349    pub url: Option<String>,
1350    pub key: Option<String>,
1351    pub length: Option<u64>,
1352    pub method: Option<String>,
1353    pub agent: Option<String>,
1354}
1355
1356impl ActionNote {
1357    pub fn start_params(&self) -> Value {
1358        let mut params = serde_json::Map::new();
1359        params.insert("phase".into(), json!("start"));
1360        params.insert("kind".into(), json!(self.kind));
1361        insert_text(&mut params, "tab", self.tab.as_deref());
1362        insert_text(&mut params, "target", self.target.as_deref());
1363        if let Some(url) = self.url.as_deref().and_then(public_url) {
1364            params.insert("url".into(), json!(url));
1365        }
1366        insert_text(&mut params, "key", self.key.as_deref());
1367        if let Some(length) = self.length {
1368            params.insert("length".into(), json!(length));
1369        }
1370        insert_text(&mut params, "method", self.method.as_deref());
1371        insert_text(&mut params, "agent", self.agent.as_deref());
1372        Value::Object(params)
1373    }
1374
1375    pub fn end_params(&self, ok: bool, stderr: &str) -> Value {
1376        let mut params = serde_json::Map::new();
1377        params.insert("phase".into(), json!("end"));
1378        params.insert("ok".into(), json!(ok));
1379        if !ok {
1380            if let Some(error) = action_error(&self.kind, stderr) {
1381                params.insert("error".into(), json!(error));
1382            }
1383        }
1384        insert_text(&mut params, "tab", self.tab.as_deref());
1385        Value::Object(params)
1386    }
1387}
1388
1389fn insert_text(params: &mut serde_json::Map<String, Value>, key: &str, value: Option<&str>) {
1390    if let Some(value) = value.filter(|value| !value.is_empty()) {
1391        params.insert(key.to_string(), json!(value));
1392    }
1393}
1394
1395pub fn first_stderr_line(stderr: &str) -> Option<&str> {
1396    stderr.lines().next().filter(|line| !line.is_empty())
1397}
1398
1399fn public_url(raw: &str) -> Option<String> {
1400    let raw = raw.trim();
1401    if raw.is_empty() {
1402        return None;
1403    }
1404    let without_fragment = raw.split_once('#').map(|(head, _)| head).unwrap_or(raw);
1405    let without_query = without_fragment
1406        .split_once('?')
1407        .map(|(head, _)| head)
1408        .unwrap_or(without_fragment);
1409    if without_query.is_empty() {
1410        return None;
1411    }
1412    if is_about_blank(without_query) {
1413        return Some("about:blank".to_string());
1414    }
1415    if let Some(rest) = without_query.strip_prefix("//") {
1416        let (host, path) = host_and_path(rest)?;
1417        return Some(format!("//{host}{path}"));
1418    }
1419    let (scheme, rest) = without_query.split_once(':')?;
1420    if !is_scheme(scheme) || !scheme_allowed(scheme) {
1421        return None;
1422    }
1423    let scheme = scheme.to_ascii_lowercase();
1424    let Some(authority) = rest.strip_prefix("//") else {
1425        return Some(format!("{scheme}:"));
1426    };
1427    let (host, path) = host_and_path(authority)?;
1428    Some(format!("{scheme}://{host}{path}"))
1429}
1430
1431fn scheme_allowed(scheme: &str) -> bool {
1432    matches!(
1433        scheme.to_ascii_lowercase().as_str(),
1434        "http"
1435            | "https"
1436            | "ws"
1437            | "wss"
1438            | "file"
1439            | "data"
1440            | "javascript"
1441            | "blob"
1442            | "about"
1443            | "chrome"
1444            | "chrome-extension"
1445            | "view-source"
1446            | "ftp"
1447    )
1448}
1449
1450fn is_about_blank(url: &str) -> bool {
1451    let Some((scheme, rest)) = url.split_once(':') else {
1452        return false;
1453    };
1454    scheme.eq_ignore_ascii_case("about") && rest.eq_ignore_ascii_case("blank")
1455}
1456
1457fn is_scheme(scheme: &str) -> bool {
1458    let mut chars = scheme.chars();
1459    match chars.next() {
1460        Some(ch) if ch.is_ascii_alphabetic() => {}
1461        _ => return false,
1462    }
1463    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.')
1464}
1465
1466fn host_and_path(rest: &str) -> Option<(&str, &str)> {
1467    let (authority, path) = match rest.find('/') {
1468        Some(index) => (&rest[..index], &rest[index..]),
1469        None => (rest, ""),
1470    };
1471    let host = match authority.rfind('@') {
1472        Some(index) => &authority[index + 1..],
1473        None => authority,
1474    };
1475    if host.is_empty() { None } else { Some((host, path)) }
1476}
1477
1478fn action_error(kind: &str, stderr: &str) -> Option<&'static str> {
1479    if kind == "eval" {
1480        return None;
1481    }
1482    let line = first_stderr_line(stderr).unwrap_or("").to_ascii_lowercase();
1483    if line.contains("timed out") {
1484        return Some("timeout");
1485    }
1486    if line.contains("websocket closed")
1487        || line.contains("websocket connection closed")
1488        || line.contains("websocket write failed")
1489        || line.contains("websocket read failed")
1490        || line.contains("connection reset")
1491        || line.contains("broken pipe")
1492    {
1493        return Some("connection lost");
1494    }
1495    if line.contains("no snapshot") || line.contains("likely stale") {
1496        return Some("no snapshot");
1497    }
1498    if line.contains("unknown ref")
1499        || line.contains("element not found")
1500        || line.contains("not an element ref")
1501        || line.contains("could not resolve")
1502        || line.contains("no backend node")
1503    {
1504        return Some("element not found");
1505    }
1506    if line.contains("no open tab is on") {
1507        return Some("not allowed");
1508    }
1509    if line.contains("unknown tab")
1510        || line.contains("--tab")
1511        || line.contains("no open tabs")
1512        || line.contains("explicit tab")
1513    {
1514        return Some("unknown tab");
1515    }
1516    if line.starts_with("navigate:") || line.contains("page.navigate") {
1517        return Some("navigation failed");
1518    }
1519    Some("failed")
1520}
1521
1522pub fn slicc_agent() -> Option<String> {
1523    std::env::var("SLICC_AGENT")
1524        .ok()
1525        .filter(|value| !value.is_empty())
1526}
1527
1528pub fn open(start: crate::connect::Start, runtime: Option<&str>) -> Result<Cdp, String> {
1529    let socket = match start {
1530        crate::connect::Start::Direct(url) => url,
1531        crate::connect::Start::Discover(url) => {
1532            let response = match crate::net::http_get(&url) {
1533                Ok(response) => response,
1534                Err(message) => {
1535                    if message.starts_with("HTTP ") || message.contains("does not launch Chrome") {
1536                        return Err(message);
1537                    }
1538                    return Err(format!(
1539                        "{message}This CLI does not launch Chrome. Pass --cdp or set SLICC_CDP_URL.\n"
1540                    ));
1541                }
1542            };
1543            let body = String::from_utf8_lossy(&response.body).to_string();
1544            crate::connect::websocket_url_from_version(&body)?
1545        }
1546    };
1547    let socket = crate::connect::append_runtime(&socket, runtime);
1548    Cdp::connect(&socket)
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553    use super::{first_stderr_line, probe_is_settled, ActionNote, Cdp, Transport};
1554    use serde_json::{json, Value};
1555    use std::collections::VecDeque;
1556    use std::time::{Duration, Instant};
1557
1558    struct Pending {
1559        inbox: VecDeque<String>,
1560        answer: fn(&Value) -> Vec<Value>,
1561    }
1562
1563    impl Transport for Pending {
1564        fn send_text(&mut self, text: &str) -> Result<(), String> {
1565            let message: Value = serde_json::from_str(text).unwrap();
1566            for out in (self.answer)(&message) {
1567                self.inbox.push_back(out.to_string());
1568            }
1569            Ok(())
1570        }
1571
1572        fn recv_text(&mut self, _deadline: Instant) -> Result<Option<String>, String> {
1573            Ok(self.inbox.pop_front())
1574        }
1575    }
1576
1577    fn pending(answer: fn(&Value) -> Vec<Value>) -> Cdp {
1578        Cdp::from_transport(Box::new(Pending {
1579            inbox: VecDeque::new(),
1580            answer,
1581        }))
1582    }
1583
1584    fn evaluate(cdp: &mut Cdp) -> (Result<Value, String>, Duration) {
1585        let started = Instant::now();
1586        let result = cdp.call_for(
1587            "Runtime.evaluate",
1588            json!({ "expression": "1" }),
1589            Some("S"),
1590            Duration::from_secs(30),
1591        );
1592        (result, started.elapsed())
1593    }
1594
1595    #[test]
1596    fn a_call_fails_with_the_refusal_once_the_guard_blocks_its_session() {
1597        let mut cdp = pending(|message| match message["method"].as_str() {
1598            Some("Runtime.evaluate") => vec![json!({
1599                "method": "Page.frameNavigated",
1600                "sessionId": "S",
1601                "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/os/", "securityOrigin": "https://seven.sliccy.ai" } }
1602            })],
1603            _ => vec![],
1604        });
1605        let (result, spent) = evaluate(&mut cdp);
1606        assert_eq!(result.unwrap_err(), format!("{}\n", cdp.guard().refusal()));
1607        assert!(spent < Duration::from_secs(5), "{spent:?}");
1608        assert!(cdp.session_blocked("S"));
1609    }
1610
1611    #[test]
1612    fn a_call_fails_with_the_refusal_once_its_session_detaches() {
1613        let mut cdp = pending(|_| {
1614            vec![
1615                json!({ "method": "Target.detachedFromTarget", "params": { "sessionId": "other" } }),
1616                json!({ "method": "Target.detachedFromTarget", "params": { "sessionId": "S" } }),
1617            ]
1618        });
1619        let (result, spent) = evaluate(&mut cdp);
1620        assert_eq!(result.unwrap_err(), format!("{}\n", cdp.guard().refusal()));
1621        assert!(spent < Duration::from_secs(5), "{spent:?}");
1622    }
1623
1624    #[test]
1625    fn unrelated_events_do_not_end_a_call() {
1626        let mut cdp = pending(|message| {
1627            vec![
1628                json!({ "method": "Target.detachedFromTarget", "params": { "sessionId": "other" } }),
1629                json!({ "id": message["id"], "result": { "result": { "value": 1 } } }),
1630            ]
1631        });
1632        let (result, _) = evaluate(&mut cdp);
1633        assert_eq!(result.unwrap(), json!({ "result": { "value": 1 } }));
1634    }
1635
1636    #[test]
1637    fn a_harmless_call_on_an_already_blocked_session_still_waits_for_its_reply() {
1638        let mut cdp = pending(|message| match message["method"].as_str() {
1639            Some("Runtime.evaluate") => vec![json!({
1640                "method": "Page.frameNavigated",
1641                "sessionId": "S",
1642                "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/os/" } }
1643            })],
1644            Some("Fetch.disable") => vec![
1645                json!({ "method": "Page.lifecycleEvent", "sessionId": "S", "params": { "name": "load" } }),
1646                json!({ "id": message["id"], "result": {} }),
1647            ],
1648            _ => vec![],
1649        });
1650        let _ = evaluate(&mut cdp);
1651        assert!(cdp.session_blocked("S"));
1652        let disabled = cdp.call_for("Fetch.disable", json!({}), Some("S"), Duration::from_secs(30));
1653        assert_eq!(disabled.unwrap(), json!({}));
1654    }
1655
1656    #[test]
1657    fn a_finished_document_does_not_wait_for_load() {
1658        assert_eq!(probe_is_settled(Ok(Some("complete"))).unwrap(), true);
1659        assert_eq!(probe_is_settled(Ok(None)).unwrap(), true);
1660        assert_eq!(
1661            probe_is_settled(Err("Runtime.evaluate timed out\n")).unwrap(),
1662            true
1663        );
1664        assert_eq!(
1665            probe_is_settled(Err("Runtime.evaluate: 'Runtime.evaluate' wasn't found\n")).unwrap(),
1666            true
1667        );
1668    }
1669
1670    #[test]
1671    fn a_blank_load_does_not_settle_the_next_navigation() {
1672        let session = "sess";
1673        let blank_load = json!({
1674            "method": "Page.loadEventFired",
1675            "sessionId": session,
1676            "params": {}
1677        });
1678        let blank_frame = json!({
1679            "method": "Page.frameNavigated",
1680            "sessionId": session,
1681            "params": { "frame": { "url": "about:blank" } }
1682        });
1683        let commit = json!({
1684            "method": "Page.frameNavigated",
1685            "sessionId": session,
1686            "params": { "frame": { "url": "http://a.test/boot" } }
1687        });
1688        let load = json!({
1689            "method": "Page.loadEventFired",
1690            "sessionId": session,
1691            "params": {}
1692        });
1693        let raced = vec![blank_frame, blank_load.clone(), commit.clone()];
1694        assert!(!super::navigation_settled(&raced, session, 0));
1695        let ready = vec![blank_load.clone(), commit.clone(), load.clone()];
1696        assert!(super::navigation_settled(&ready, session, 0));
1697        let stale = vec![commit, load, blank_load];
1698        assert!(!super::navigation_settled(&stale, session, 2));
1699    }
1700
1701    #[test]
1702    fn a_loading_document_still_waits() {
1703        assert_eq!(probe_is_settled(Ok(Some("loading"))).unwrap(), false);
1704        assert_eq!(probe_is_settled(Ok(Some("interactive"))).unwrap(), false);
1705    }
1706
1707    #[test]
1708    fn a_closed_socket_is_not_treated_as_loaded() {
1709        for err in [
1710            "websocket closed 1011 the browser went away\n",
1711            "websocket connection closed\n",
1712            "websocket write failed: broken pipe\n",
1713            "websocket read failed: connection reset\n",
1714            "CDP response was not JSON: eof\n",
1715        ] {
1716            let reported = probe_is_settled(Err(err)).unwrap_err();
1717            assert_eq!(reported, err);
1718        }
1719    }
1720
1721    #[test]
1722    fn action_start_and_end_keep_the_panel_fields() {
1723        let click = ActionNote {
1724            kind: "click".to_string(),
1725            tab: Some("1234".to_string()),
1726            target: Some("button \"Sign in\"".to_string()),
1727            agent: Some("cone:cone-1".to_string()),
1728            ..ActionNote::default()
1729        };
1730        assert_eq!(
1731            click.start_params(),
1732            json!({
1733                "phase": "start",
1734                "kind": "click",
1735                "tab": "1234",
1736                "target": "button \"Sign in\"",
1737                "agent": "cone:cone-1"
1738            })
1739        );
1740        let failed = click.end_params(
1741            false,
1742            "No snapshot available. Run \"snapshot\" first.\nignored\n",
1743        );
1744        assert_eq!(
1745            failed,
1746            json!({
1747                "phase": "end",
1748                "ok": false,
1749                "error": "no snapshot",
1750                "tab": "1234"
1751            })
1752        );
1753        assert!(failed.get("agent").is_none());
1754        assert!(failed.get("kind").is_none());
1755        assert!(failed.get("target").is_none());
1756        let open = ActionNote {
1757            kind: "open".to_string(),
1758            url: Some("http://a.test/".to_string()),
1759            ..ActionNote::default()
1760        };
1761        assert!(open.start_params().get("tab").is_none());
1762        assert_eq!(open.start_params()["url"], "http://a.test/");
1763        let mut opened = open.clone();
1764        opened.tab = Some("TAB1".to_string());
1765        assert_eq!(opened.end_params(true, "")["tab"], "TAB1");
1766        assert!(opened.end_params(true, "").get("error").is_none());
1767    }
1768
1769    #[test]
1770    fn action_params_omit_typed_text_eval_source_and_request_secrets() {
1771        let secret = "secret-text";
1772        let fill = ActionNote {
1773            kind: "fill".to_string(),
1774            tab: Some("T".to_string()),
1775            target: Some("textbox \"Email\"".to_string()),
1776            length: Some(secret.chars().count() as u64),
1777            ..ActionNote::default()
1778        };
1779        let fill_params = fill.start_params();
1780        assert_eq!(fill_params["length"], 11);
1781        assert!(!fill_params.to_string().contains(secret));
1782        assert!(fill_params.get("value").is_none());
1783        let typed = "héllo";
1784        let type_note = ActionNote {
1785            kind: "type".to_string(),
1786            length: Some(typed.chars().count() as u64),
1787            ..ActionNote::default()
1788        };
1789        assert_eq!(type_note.start_params()["length"], 5);
1790        assert!(!type_note.start_params().to_string().contains(typed));
1791        let source = "document.cookie";
1792        let eval_note = ActionNote {
1793            kind: "eval".to_string(),
1794            tab: Some("T".to_string()),
1795            ..ActionNote::default()
1796        };
1797        let eval_params = eval_note.start_params();
1798        assert!(!eval_params.to_string().contains(source));
1799        assert!(eval_params.get("expression").is_none());
1800        let request = ActionNote {
1801            kind: "request".to_string(),
1802            tab: Some("T1".to_string()),
1803            method: Some("POST".to_string()),
1804            url: Some("https://app.example/api/me".to_string()),
1805            ..ActionNote::default()
1806        };
1807        let request_params = request.start_params();
1808        assert_eq!(
1809            request_params,
1810            json!({
1811                "phase": "start",
1812                "kind": "request",
1813                "tab": "T1",
1814                "method": "POST",
1815                "url": "https://app.example/api/me"
1816            })
1817        );
1818        assert!(!request_params.to_string().contains("Authorization"));
1819        assert!(!request_params.to_string().contains(secret));
1820        let press = ActionNote {
1821            kind: "press".to_string(),
1822            key: Some("Enter".to_string()),
1823            ..ActionNote::default()
1824        };
1825        assert_eq!(press.start_params()["key"], "Enter");
1826        assert!(ActionNote::default().start_params().get("agent").is_none());
1827        assert_eq!(first_stderr_line("only-line"), Some("only-line"));
1828        assert_eq!(first_stderr_line("\n"), None);
1829    }
1830
1831    #[test]
1832    fn action_urls_and_errors_drop_page_secrets() {
1833        let secret = "https://user:pass@app.example/form?q=QUERYSECRET&tok=x#FRAGSECRET";
1834        let open = ActionNote {
1835            kind: "open".to_string(),
1836            url: Some(secret.to_string()),
1837            ..ActionNote::default()
1838        };
1839        let started = open.start_params();
1840        assert_eq!(started["url"], "https://app.example/form");
1841        assert_clean(&started);
1842        let goto = ActionNote {
1843            kind: "goto".to_string(),
1844            url: Some(secret.to_string()),
1845            ..ActionNote::default()
1846        };
1847        assert_eq!(goto.start_params()["url"], "https://app.example/form");
1848        let posted = ActionNote {
1849            kind: "request".to_string(),
1850            method: Some("POST".to_string()),
1851            url: Some(secret.to_string()),
1852            ..ActionNote::default()
1853        };
1854        let request = posted.start_params();
1855        assert_eq!(request["method"], "POST");
1856        assert_eq!(request["url"], "https://app.example/form");
1857        assert_clean(&request);
1858        let gotten = ActionNote {
1859            kind: "request".to_string(),
1860            method: Some("GET".to_string()),
1861            url: Some("http://user:p%40ss@[::1]:8443/a/b?x=1#y".to_string()),
1862            ..ActionNote::default()
1863        };
1864        assert_eq!(gotten.start_params()["url"], "http://[::1]:8443/a/b");
1865        let relative = ActionNote {
1866            kind: "request".to_string(),
1867            method: Some("GET".to_string()),
1868            url: Some("//user:pass@app.example/path?q=QUERYSECRET#FRAGSECRET".to_string()),
1869            ..ActionNote::default()
1870        };
1871        let relative_params = relative.start_params();
1872        assert_eq!(relative_params["url"], "//app.example/path");
1873        assert_clean(&relative_params);
1874        assert_eq!(
1875            ActionNote {
1876                kind: "open".to_string(),
1877                url: Some("about:blank".to_string()),
1878                ..ActionNote::default()
1879            }
1880            .start_params()["url"],
1881            "about:blank"
1882        );
1883        let opaque = [
1884            (
1885                "goto",
1886                "data:text/html,<title>DATASECRET</title>",
1887                Some("data:"),
1888                "DATASECRET",
1889            ),
1890            (
1891                "open",
1892                "data:text/html,DATASECRET2",
1893                Some("data:"),
1894                "DATASECRET2",
1895            ),
1896            (
1897                "goto",
1898                "javascript:void('JSSECRET')",
1899                Some("javascript:"),
1900                "JSSECRET",
1901            ),
1902            (
1903                "open",
1904                "blob:https://example.com/BLOBSECRET",
1905                Some("blob:"),
1906                "BLOBSECRET",
1907            ),
1908            ("goto", "about:blank?q=BLANKSECRET", Some("about:blank"), "BLANKSECRET"),
1909            ("open", "ABOUT:blank?q=BLANKSECRET", Some("about:blank"), "BLANKSECRET"),
1910            ("goto", "About:blank", Some("about:blank"), "BLANKSECRET"),
1911            (
1912                "open",
1913                "DATA:text/html,DATASECRET",
1914                Some("data:"),
1915                "DATASECRET",
1916            ),
1917            (
1918                "open",
1919                "about:srcdoc,ABOUTSECRET",
1920                Some("about:"),
1921                "ABOUTSECRET",
1922            ),
1923            ("goto", "file:/tmp/PATHSECRET", Some("file:"), "PATHSECRET"),
1924            (
1925                "open",
1926                "https:example.com/PATHSECRET",
1927                Some("https:"),
1928                "PATHSECRET",
1929            ),
1930            ("goto", "example.com/PATHSECRET", None, "PATHSECRET"),
1931            ("open", "not a url PATHSECRET", None, "PATHSECRET"),
1932            ("goto", "HOSTSECRET3:8080/path", None, "HOSTSECRET3"),
1933            ("open", "host:8080/x", None, "host:"),
1934            (
1935                "goto",
1936                "USERSECRET5:PASSSECRET5@127.0.0.1/form",
1937                None,
1938                "USERSECRET5",
1939            ),
1940            (
1941                "open",
1942                "USERSECRET5:PASSSECRET5@127.0.0.1/form",
1943                None,
1944                "PASSSECRET5",
1945            ),
1946            ("goto", "user:pw@host/x", None, "user:"),
1947            ("open", "foo:bar", None, "foo:"),
1948            ("goto", "foo://example.com/FOOSECRET", None, "FOOSECRET"),
1949            (
1950                "open",
1951                "view-source:https://example.com/VIEWSECRET",
1952                Some("view-source:"),
1953                "VIEWSECRET",
1954            ),
1955        ];
1956        for (kind, raw, expect, secret) in opaque {
1957            let note = ActionNote {
1958                kind: kind.to_string(),
1959                url: Some(raw.to_string()),
1960                ..ActionNote::default()
1961            };
1962            let params = note.start_params();
1963            match expect {
1964                Some(url) => assert_eq!(params["url"], url, "{raw}"),
1965                None => assert!(params.get("url").is_none(), "{raw} {params}"),
1966            }
1967            assert!(!params.to_string().contains(secret), "{raw} {params}");
1968        }
1969        let eval_failed = ActionNote {
1970            kind: "eval".to_string(),
1971            tab: Some("T".to_string()),
1972            ..ActionNote::default()
1973        };
1974        let eval_end = eval_failed.end_params(false, "Error: EVALSECRET-in-error\n");
1975        assert_eq!(eval_end["ok"], false);
1976        assert!(eval_end.get("error").is_none());
1977        assert_clean(&eval_end);
1978        let cases = [
1979            (
1980                "click",
1981                "Unknown ref \"e1\". Available: e2\n",
1982                "element not found",
1983            ),
1984            (
1985                "screenshot",
1986                "No snapshot available. Run \"snapshot\" first.\n",
1987                "no snapshot",
1988            ),
1989            (
1990                "screenshot",
1991                "screenshot: could not resolve element e1 to a visible box — the snapshot is likely stale\n",
1992                "no snapshot",
1993            ),
1994            (
1995                "click",
1996                "Error: --tab <targetId> is required. Run 'playwright-cli tab-list' to get tab IDs.\n",
1997                "unknown tab",
1998            ),
1999            (
2000                "goto",
2001                "navigate: refused https://app.example/form?q=QUERYSECRET#FRAGSECRET\n",
2002                "navigation failed",
2003            ),
2004            ("click", "Page.navigate timed out\n", "timeout"),
2005            (
2006                "open",
2007                "websocket closed 1011 the browser went away\n",
2008                "connection lost",
2009            ),
2010            (
2011                "request",
2012                "curlwright: no open tab is on https://app.example — pass --tab.\nhttps://other.example/dash?q=QUERYSECRET\n",
2013                "not allowed",
2014            ),
2015            (
2016                "request",
2017                "curlwright: (22) The requested URL returned error: 500\n",
2018                "failed",
2019            ),
2020        ];
2021        for (kind, stderr, code) in cases {
2022            let note = ActionNote {
2023                kind: kind.to_string(),
2024                ..ActionNote::default()
2025            };
2026            let ended = note.end_params(false, stderr);
2027            assert_eq!(ended["error"], code, "{stderr}");
2028            assert_clean(&ended);
2029        }
2030    }
2031
2032    fn assert_clean(value: &serde_json::Value) {
2033        let rendered = value.to_string();
2034        for secret in [
2035            "QUERYSECRET",
2036            "FRAGSECRET",
2037            "EVALSECRET",
2038            "user:pass",
2039            "tok=x",
2040            "p%40ss",
2041        ] {
2042            assert!(!rendered.contains(secret), "{rendered}");
2043        }
2044    }
2045}