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