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
10#[path = "webmcp_guard.rs"]
11mod webmcp_guard;
12#[path = "page_scope.rs"]
13mod page_scope;
14#[path = "document_order.rs"]
15mod document_order;
16#[path = "issued.rs"]
17mod issued;
18pub(crate) use issued::target_refusal;
19
20pub trait Transport {
21    fn send_text(&mut self, text: &str) -> Result<(), String>;
22    fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String>;
23}
24
25impl Transport for Ws {
26    fn send_text(&mut self, text: &str) -> Result<(), String> {
27        Ws::send_text(self, text)
28    }
29
30    fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
31        Ws::recv_text(self, deadline)
32    }
33}
34
35pub type Decider = Box<dyn FnMut(&Paused) -> Decision>;
36
37pub const UNSEEN: &str = "slicc: refused: slicc hasn't seen this request, so it could be seven's\n";
38pub const UNGUARDED: &str = "slicc: refused: slicc can't keep seven's origin out of this target\n";
39pub const RENDERER_CRASHED: &str = "slicc: refused: renderer crashed\n";
40pub const GUARD_SETUP: Duration = Duration::from_secs(30);
41const OPTIONAL_STEP: Duration = Duration::from_secs(10);
42const DOCUMENT_CHECK: Duration = Duration::from_secs(2);
43const WAITER_SLICE: Duration = Duration::from_millis(5);
44const AUTO_ATTACH: &str = "slicc: refused: auto-attach is not supported by slicc-cdp\n";
45const FLATTEN: &str = "slicc: refused: attach with flatten: true\n";
46const PERSISTENT: &str = "slicc: refused: persistent page capabilities are not supported by slicc-cdp\n";
47pub const COOKIES: &str = "slicc: refused: cookies on seven's origin\n";
48pub const STREAM: &str = "slicc: refused: slicc didn't hand out this stream, so it could be seven's\n";
49
50struct CarrierTarget {
51    origin: Option<String>,
52    protected: bool,
53    url: url::Url,
54}
55
56#[derive(Clone, Debug)]
57struct DocumentState {
58    frames: BTreeMap<String, Vec<String>>,
59    worker: Option<(String, String)>,
60    scope: Option<String>,
61    epoch: u64,
62    page_scope: Option<(String, u64, u64)>,
63    runtime_scope_epoch: Option<u64>,
64}
65
66impl PartialEq for DocumentState {
67    fn eq(&self, other: &Self) -> bool {
68        self.scope == other.scope && self.epoch == other.epoch && self.worker == other.worker && self.frames.len() == other.frames.len()
69            && self.frames.iter().all(|(id, frame)| other.frames.get(id).is_some_and(|current| {
70                frame[0] == current[0] && frame[2..] == current[2..] && (!frame[0].is_empty() || frame[1] == current[1])
71            }))
72    }
73}
74
75#[derive(Clone)]
76struct ContextProof {
77    id: i64,
78    frame: String,
79    origin: String,
80    name: String,
81    loader: Option<String>,
82    default: bool,
83}
84
85struct Navigation {
86    params: Value,
87    session: Option<String>,
88    reply: Option<Value>,
89    detached: bool,
90}
91
92struct MainDocument {
93    frame_id: String,
94    url: String,
95    origin: String,
96    unreachable_url: String,
97    navigating: bool,
98    navigation_url: String,
99    previous: bool,
100    probed: bool,
101}
102
103pub type Waiter = Box<dyn FnMut(&Cdp)>;
104
105pub struct Cdp {
106    ws: Box<dyn Transport>,
107    next_id: i64,
108    detaching: BTreeMap<i64, String>,
109    detached_ordinary: BTreeSet<String>,
110    navigations: BTreeMap<i64, Navigation>,
111    events: Vec<Value>,
112    dialog_sessions: BTreeSet<String>,
113    dialogs: BTreeMap<String, Dialog>,
114    dialog_documents: BTreeMap<String, DialogDocument>,
115    dialog_epoch: u64,
116    trace_cookies: Vec<Value>,
117    guard: Protected,
118    paused: VecDeque<Paused>,
119    deciders: BTreeMap<String, Decider>,
120    decided: BTreeMap<i64, (String, String)>,
121    fetch_errors: Vec<String>,
122    blocked: BTreeSet<String>,
123    crashed_sessions: BTreeSet<String>,
124    crashed_targets: BTreeSet<String>,
125    session_targets: BTreeMap<String, String>,
126    protected_targets: BTreeSet<String>,
127    guards: BTreeMap<String, String>,
128    page_scope_sessions: BTreeMap<String, String>,
129    checking_page_events: bool,
130    pending_page_events: VecDeque<Value>,
131    runtime_scope_epochs: BTreeMap<String, u64>,
132    page_scope_owners: BTreeMap<String, String>,
133    page_scope_epochs: BTreeMap<String, u64>,
134    page_scope_unknown: u64,
135    guard_sessions: BTreeSet<String>,
136    guarding: BTreeSet<String>,
137    probing: BTreeSet<String>,
138    abandoned: BTreeSet<String>,
139    unguarded_optional: BTreeMap<String, BTreeSet<String>>,
140    notices: Vec<String>,
141    discovering: bool,
142    requests: BTreeMap<String, bool>,
143    redirects: BTreeSet<String>,
144    protected_body_requests: BTreeSet<String>,
145    seen_network: BTreeMap<(String, String), String>,
146    terminal_network: BTreeSet<(String, String)>,
147    client_navigation_urls: BTreeMap<String, String>,
148    request_aliases: BTreeMap<String, BTreeSet<String>>,
149    request_resources: BTreeMap<String, BTreeSet<(String, String)>>,
150    protected_request_pages: BTreeSet<(String, String)>,
151    protected_resources: BTreeSet<(String, String)>,
152    protected_target_resources: BTreeSet<(String, String)>,
153    source_resources: BTreeMap<(String, String), String>,
154    handed: BTreeSet<(String, String)>,
155    answering: BTreeMap<i64, (String, String)>,
156    held: BTreeMap<String, Vec<Value>>,
157    streams: BTreeMap<String, (Option<String>, Option<DocumentState>)>,
158    document_epochs: BTreeMap<String, u64>,
159    unchecked_documents: BTreeMap<(String, String, String), document_order::ObservedDocument>,
160    removed_frames: BTreeMap<(String, String), (String, String)>,
161    document_versions: BTreeMap<(String, String), String>,
162    created_blanks: BTreeMap<String, (String, String, i64)>,
163    navigated_blanks: BTreeMap<String, (String, String, String)>,
164    context_frames: BTreeMap<(String, String), String>,
165    context_proofs: BTreeMap<(String, String), ContextProof>,
166    runtime_sessions: BTreeMap<String, String>,
167    created_contexts: BTreeSet<(String, i64)>,
168    cached_documents: BTreeMap<String, DocumentState>,
169    paused_documents: BTreeSet<(String, String)>,
170    executing_sessions: BTreeSet<String>,
171    contexts: BTreeSet<String>,
172    targets: BTreeMap<String, Value>,
173    worlds: Worlds,
174    frames: BTreeMap<String, Vec<String>>,
175    frame_parents: BTreeMap<String, String>,
176    frame_decisions: BTreeMap<String, bool>,
177    frame_sessions: BTreeMap<String, BTreeSet<String>>,
178    frame_epoch: u64,
179    frame_versions: BTreeMap<String, u64>,
180    documents: BTreeMap<String, MainDocument>,
181    unobserved: BTreeSet<String>,
182    webmcp: webmcp_guard::Webmcp,
183    waiting_calls: BTreeSet<i64>,
184    call_replies: BTreeMap<i64, Value>,
185    issued: issued::Issued,
186    waiter: Option<Waiter>,
187    waiter_due: Instant,
188    #[cfg(test)]
189    runtime_preparing: bool,
190    #[cfg(test)]
191    runtime_probe: Vec<Value>,
192    #[cfg(test)]
193    pub(crate) paused_trail: Vec<(String, String, &'static str)>,
194}
195
196impl Drop for Cdp {
197    fn drop(&mut self) {
198        self.retire_private_sessions(Instant::now() + DOCUMENT_CHECK);
199    }
200}
201
202impl Cdp {
203    fn retire_private_sessions(&mut self, deadline: Instant) {
204        self.waiter = None;
205        let sessions = self.guard_sessions.clone();
206        for session in sessions {
207            if !self.guard_sessions.contains(&session) { continue }
208            if self.guards.values().any(|guard| guard == &session) {
209                let left = deadline.saturating_duration_since(Instant::now());
210                if left.is_zero() { let _ = self.send("Fetch.disable", json!({}), Some(&session)); }
211                else { let _ = self.raw_call("Fetch.disable", json!({}), Some(&session), left); }
212            }
213            let left = deadline.saturating_duration_since(Instant::now());
214            if left.is_zero() { let _ = self.send("Target.detachFromTarget", json!({"sessionId":session}), None); }
215            else { let _ = self.raw_call("Target.detachFromTarget", json!({"sessionId":session}), None, left); }
216        }
217    }
218
219    pub fn connect(url: &str) -> Result<Self, String> {
220        Ok(Self::from_transport(Box::new(crate::net::ws_connect(url)?)))
221    }
222
223    pub fn from_transport(ws: Box<dyn Transport>) -> Self {
224        Self {
225            ws,
226            next_id: 0,
227            detaching: BTreeMap::new(),
228            detached_ordinary: BTreeSet::new(),
229            navigations: BTreeMap::new(),
230            events: Vec::new(),
231            dialog_sessions: BTreeSet::new(),
232            dialogs: BTreeMap::new(),
233            dialog_documents: BTreeMap::new(),
234            dialog_epoch: 0,
235            trace_cookies: Vec::new(),
236            guard: Protected::default(),
237            paused: VecDeque::new(),
238            deciders: BTreeMap::new(),
239            decided: BTreeMap::new(),
240            fetch_errors: Vec::new(),
241            blocked: BTreeSet::new(),
242            crashed_sessions: BTreeSet::new(),
243            crashed_targets: BTreeSet::new(),
244            session_targets: BTreeMap::new(),
245            protected_targets: BTreeSet::new(),
246            guards: BTreeMap::new(),
247            page_scope_sessions: BTreeMap::new(),
248            checking_page_events: false,
249            pending_page_events: VecDeque::new(),
250            runtime_scope_epochs: BTreeMap::new(),
251            page_scope_owners: BTreeMap::new(),
252            page_scope_epochs: BTreeMap::new(),
253            page_scope_unknown: 0,
254            guard_sessions: BTreeSet::new(),
255            guarding: BTreeSet::new(),
256            probing: BTreeSet::new(),
257            abandoned: BTreeSet::new(),
258            unguarded_optional: BTreeMap::new(),
259            notices: Vec::new(),
260            discovering: false,
261            requests: BTreeMap::new(),
262            redirects: BTreeSet::new(),
263            protected_body_requests: BTreeSet::new(),
264            seen_network: BTreeMap::new(),
265            terminal_network: BTreeSet::new(),
266            client_navigation_urls: BTreeMap::new(),
267            request_aliases: BTreeMap::new(),
268            request_resources: BTreeMap::new(),
269            protected_request_pages: BTreeSet::new(),
270            protected_resources: BTreeSet::new(),
271            protected_target_resources: BTreeSet::new(),
272            source_resources: BTreeMap::new(),
273            handed: BTreeSet::new(),
274            answering: BTreeMap::new(),
275            held: BTreeMap::new(),
276            streams: BTreeMap::new(),
277            document_epochs: BTreeMap::new(),
278            unchecked_documents: BTreeMap::new(),
279            removed_frames: BTreeMap::new(),
280            document_versions: BTreeMap::new(),
281            created_blanks: BTreeMap::new(),
282            navigated_blanks: BTreeMap::new(),
283            context_frames: BTreeMap::new(),
284            context_proofs: BTreeMap::new(),
285            runtime_sessions: BTreeMap::new(),
286            created_contexts: BTreeSet::new(),
287            cached_documents: BTreeMap::new(),
288            paused_documents: BTreeSet::new(),
289            executing_sessions: BTreeSet::new(),
290            contexts: BTreeSet::new(),
291            targets: BTreeMap::new(),
292            worlds: Worlds::default(),
293            frames: BTreeMap::new(),
294            frame_parents: BTreeMap::new(),
295            frame_decisions: BTreeMap::new(),
296            frame_sessions: BTreeMap::new(),
297            frame_epoch: 0,
298            frame_versions: BTreeMap::new(),
299            documents: BTreeMap::new(),
300            unobserved: BTreeSet::new(),
301            webmcp: webmcp_guard::Webmcp::default(),
302            waiting_calls: BTreeSet::new(),
303            call_replies: BTreeMap::new(),
304            issued: issued::Issued::default(),
305            waiter: None,
306            waiter_due: Instant::now(),
307            #[cfg(test)]
308            runtime_preparing: false,
309            #[cfg(test)]
310            runtime_probe: Vec::new(),
311            #[cfg(test)]
312            paused_trail: Vec::new(),
313        }
314    }
315
316    #[cfg(test)]
317    pub(crate) fn fixture_session(&mut self, session: &str) {
318        struct Fixture {
319            inner: Box<dyn Transport>,
320            inbox: VecDeque<String>,
321            worlds: BTreeMap<String, Vec<Value>>,
322            pending_worlds: BTreeMap<i64, (String, Value)>,
323            target_infos: Vec<Value>,
324        }
325        impl Transport for Fixture {
326            fn send_text(&mut self, text: &str) -> Result<(), String> {
327                let call: Value = serde_json::from_str(text).unwrap();
328                if call["method"] == "Target.getTargets" && call["params"]["filter"] == json!([{}]) {
329                    self.inbox.push_back(json!({"id":call["id"],"result":{"targetInfos":self.target_infos}}).to_string());
330                    Ok(())
331                } else if call["method"] == "Page.getFrameTree" && call["sessionId"].as_str().is_some_and(|session| session.starts_with("fixture-guard:") || session == "S" || session == "OTHER") {
332                    self.inbox.push_back(json!({"id":call["id"],"result":{"frameTree":{"frame":{"id":"F","loaderId":"L","url":"https://example.test/","securityOrigin":"https://example.test"}}}}).to_string());
333                    Ok(())
334                } else if call["sessionId"].as_str().is_some_and(|session| session.starts_with("fixture-guard:")) && matches!(call["method"].as_str(), Some("Runtime.disable" | "Runtime.enable")) {
335                    if call["method"] == "Runtime.enable" {
336                        self.inbox.push_back(json!({"method":"Runtime.executionContextCreated","sessionId":call["sessionId"],"params":{"context":{"id":1,"uniqueId":"fixture-context","origin":"https://example.test","name":"","auxData":{"frameId":"F","isDefault":true}}}}).to_string());
337                        let owner = call["sessionId"].as_str().unwrap().strip_prefix("fixture-guard:").unwrap();
338                        for context in self.worlds.get(owner).into_iter().flatten() {
339                            self.inbox.push_back(json!({"method":"Runtime.executionContextCreated","sessionId":call["sessionId"],"params":{"context":context}}).to_string());
340                        }
341                    }
342                    self.inbox.push_back(json!({"id":call["id"],"result":{}}).to_string());
343                    Ok(())
344                } else {
345                    if call["method"] == "Page.createIsolatedWorld" {
346                        self.pending_worlds.insert(call["id"].as_i64().unwrap(), (call["sessionId"].as_str().unwrap().to_string(), call["params"].clone()));
347                    }
348                    self.inner.send_text(text)
349                }
350            }
351            fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
352                let text = match self.inbox.pop_front() { Some(text) => Some(text), None => self.inner.recv_text(deadline)? };
353                if let Some(text) = &text {
354                    let reply: Value = serde_json::from_str(text).unwrap();
355                    if let Some((owner, params)) = reply["id"].as_i64().and_then(|id| self.pending_worlds.remove(&id)) {
356                        if let Some(id) = reply.pointer("/result/executionContextId").and_then(Value::as_i64) {
357                            self.worlds.entry(owner).or_default().push(json!({"id":id,"uniqueId":format!("fixture-world:{id}"),"origin":"","name":params["worldName"],"auxData":{"frameId":params["frameId"],"isDefault":false}}));
358                        }
359                    }
360                }
361                Ok(text)
362            }
363        }
364        struct Empty;
365        impl Transport for Empty {
366            fn send_text(&mut self, _: &str) -> Result<(), String> { Ok(()) }
367            fn recv_text(&mut self, _: Instant) -> Result<Option<String>, String> { Ok(None) }
368        }
369        let target = format!("fixture:{session}");
370        let guard = format!("fixture-guard:{session}");
371        self.targets.insert(target.clone(), json!({"targetId":target,"type":"page","url":"https://example.test/"}));
372        self.session_targets.insert(session.into(), target.clone());
373        self.guards.insert(target.clone(), guard.clone());
374        self.guard_sessions.insert(guard);
375        self.unobserved.insert(target);
376        let inner = std::mem::replace(&mut self.ws, Box::new(Empty));
377        self.ws = Box::new(Fixture { inner, inbox:VecDeque::new(), worlds:BTreeMap::new(), pending_worlds:BTreeMap::new(), target_infos:self.targets.values().cloned().collect() });
378    }
379
380    pub fn events(&self) -> &[Value] {
381        &self.events
382    }
383
384    pub fn guard(&self) -> &Protected {
385        &self.guard
386    }
387
388    pub fn protect_origins(&mut self, extra_origins: &[&str]) {
389        self.guard = Protected::new(extra_origins);
390    }
391
392    pub fn take_notices(&mut self) -> Vec<String> {
393        std::mem::take(&mut self.notices)
394    }
395
396    pub fn unguarded_optional(&self, target_id: &str) -> Vec<String> {
397        self.unguarded_optional
398            .get(target_id)
399            .map(|steps| steps.iter().cloned().collect())
400            .unwrap_or_default()
401    }
402
403    pub fn session_blocked(&self, session_id: &str) -> bool {
404        self.blocked.contains(session_id)
405    }
406
407    pub fn content_blocked(&self, session_id: &str) -> bool {
408        self.blocked.contains(session_id) || self.guard_sessions.contains(session_id)
409            || self.session_target(session_id).and_then(|target| self.documents.get(&target)).is_some_and(|doc| self.sealed(doc))
410    }
411
412    #[cfg(test)]
413    pub(crate) fn document_evidence(&self, target_id: &str) -> String {
414        self.documents.get(target_id).map_or_else(|| "none".to_string(), |doc| {
415            format!("url={} unreachable={} navigating={} previous={} probed={}", doc.url, doc.unreachable_url, doc.navigating, doc.previous, doc.probed)
416        })
417    }
418
419    pub fn is_protected_target(&self, target_id: &str) -> bool {
420        self.protected_targets.contains(target_id)
421            || self
422                .session_targets
423                .iter()
424                .any(|(session, target)| target == target_id && self.blocked.contains(session))
425    }
426
427    pub fn set_decider(&mut self, session_id: &str, decider: Option<Decider>) {
428        match decider {
429            Some(decider) => {
430                self.deciders.insert(session_id.to_string(), decider);
431                let queued: Vec<Paused> = self.paused.drain(..).collect();
432                for paused in queued {
433                    if paused.session_id == session_id {
434                        self.decide_inline(paused);
435                    } else {
436                        self.paused.push_back(paused);
437                    }
438                }
439            }
440            None => {
441                self.deciders.remove(session_id);
442            }
443        }
444    }
445
446    pub fn has_decider(&self, session_id: &str) -> bool {
447        self.deciders.contains_key(session_id)
448    }
449
450    #[cfg(test)]
451    fn runtime_stage(&mut self, method: &str, stage: &str) {
452        if method == "Runtime.evaluate" { self.runtime_probe.push(json!({"stage":stage})); }
453    }
454
455    #[cfg(test)]
456    fn runtime_contexts(&mut self, method: &str, stage: &str, guard: &str, target: &str, state: &DocumentState, selected: Option<&str>) {
457        if method != "Runtime.evaluate" { return }
458        let contexts: Vec<Value> = self.context_proofs.iter().filter(|((owner, _), _)| owner == guard).map(|((_, unique), proof)| {
459            let frame = state.frames.get(&proof.frame);
460            json!({"default":proof.default,"frame_known":frame.is_some(),
461                "main_frame":frame.is_some_and(|frame| frame[4].is_empty()),
462                "origin_known":url::Url::parse(&proof.origin).is_ok(),
463                "origin_protected":self.guard.url(&proof.origin),
464                "origin_opaque":matches!(proof.origin.as_str(), "" | "://" | "null"),
465                "origin_matches_frame":frame.is_some_and(|frame| frame[2] == proof.origin),
466                "loader_known":proof.loader.as_ref().is_some_and(|loader| !loader.is_empty()),
467                "loader_matches_frame":proof.loader.as_ref().zip(frame).is_some_and(|(loader, frame)| loader == &frame[0]),
468                "selected":selected == Some(unique.as_str())})
469        }).collect();
470        self.runtime_probe.push(json!({"stage":stage,"private_contexts":contexts,
471            "frame_count":state.frames.len(),"scope_known":state.scope.is_some(),
472            "scope_default":state.scope.as_deref() == Some(""),
473            "page_scope_known":state.page_scope.is_some(),"worker":state.worker.is_some(),
474            "owned_blank":state.frames.iter().any(|(id, frame)| frame[1] == "about:blank" && self.owns_blank(target, id, &frame[0]))}));
475    }
476
477    #[cfg(test)]
478    fn runtime_prepare_probe(&mut self, category: &str, details: Value) {
479        if self.runtime_preparing { self.runtime_probe.push(json!({"stage":"prepare-check","category":category,"details":details})); }
480    }
481
482    #[cfg(test)]
483    fn runtime_prepare_frames(&mut self, tree: &Value, frames: &BTreeMap<String, Vec<String>>) {
484        let mut pending = vec![(tree, None)];
485        while let Some((node, parent)) = pending.pop() {
486            let Some(frame) = node.get("frame") else {
487                self.runtime_prepare_probe("frame-placement", json!({"frame_present":false}));
488                return;
489            };
490            let id = frame.get("id").and_then(Value::as_str).unwrap_or("");
491            if !frames.contains_key(id) {
492                let url = frame.get("url").and_then(Value::as_str).unwrap_or("");
493                let origin = self.page_scope_origin(frame).or_else(|| frame.get("securityOrigin").and_then(Value::as_str)).unwrap_or("");
494                self.runtime_prepare_probe("frame-evidence", json!({"id_known":frame_text_known(id),"url_known":frame_text_known(url),
495                    "parent_matches":frame.get("parentId").is_none_or(|id| id.as_str() == parent),
496                    "loader_known":frame.get("loaderId").and_then(Value::as_str).is_some_and(|id| frame_text_known(id)),
497                    "initial_blank":url == "about:blank" && frame.get("loaderId").and_then(Value::as_str) == Some(""),
498                    "origin_empty":origin.is_empty(),"origin_null":origin == "null","origin_opaque":origin == "://",
499                    "origin_protected":self.guard.url(origin),"url_protected":self.guard.url(url),
500                    "error_unverified":self.unverified_error(url, frame.get("unreachableUrl").and_then(Value::as_str).unwrap_or(""))}));
501                return;
502            }
503            if let Some(children) = node.get("childFrames") {
504                let Some(children) = children.as_array() else {
505                    self.runtime_prepare_probe("frame-placement", json!({"children_valid":false}));
506                    return;
507                };
508                for child in children.iter().rev() { pending.push((child, Some(id))); }
509            }
510        }
511        self.runtime_prepare_probe("frame-placement", json!({"collection_complete":false}));
512    }
513
514    #[cfg(test)]
515    pub(crate) fn runtime_diagnostics(&self) -> &[Value] { &self.runtime_probe }
516
517    pub fn take_fetch_errors(&mut self) -> Vec<String> {
518        std::mem::take(&mut self.fetch_errors)
519    }
520
521    pub fn call(
522        &mut self,
523        method: &str,
524        params: Value,
525        session_id: Option<&str>,
526    ) -> Result<Value, String> {
527        let result = self.call_for(method, params, session_id, Duration::from_secs(30));
528        #[cfg(test)]
529        if result.is_err() && method == "Runtime.evaluate" && std::env::var_os("CI").is_some() {
530            eprintln!("document call refused: stages={:?}", self.runtime_diagnostics().iter().filter_map(|probe| probe.get("stage").and_then(Value::as_str)).collect::<Vec<_>>());
531        }
532        result
533    }
534
535    pub fn call_for(
536        &mut self,
537        method: &str,
538        params: Value,
539        session_id: Option<&str>,
540        timeout: Duration,
541    ) -> Result<Value, String> {
542        let session_id = session_id.filter(|id| !id.is_empty());
543        #[cfg(test)]
544        if method == "Runtime.evaluate" { self.runtime_probe.clear(); }
545        #[cfg(test)]
546        self.runtime_stage(method, "admit");
547        self.admit(method, &params, session_id)?;
548        if method == "Page.navigate" { self.remember_navigation(&params, session_id); }
549        if method == "Target.createTarget" {
550            return self.create_target(params, timeout);
551        }
552        #[cfg(test)]
553        self.runtime_stage(method, "prepare");
554        let mut checks = self.prepare(method, &params, session_id)?;
555        #[cfg(test)]
556        self.runtime_stage(method, "pin");
557        let pinned = self.pin_execution(method, &params, session_id, &mut checks)?;
558        let object_pin = method == "Runtime.callFunctionOn" && params.get("objectId").is_some() && !checks.is_empty();
559        let executed = pinned.is_some() && method != "Runtime.compileScript" || object_pin;
560        let mut sent = params.clone();
561        if let Some(unique) = &pinned {
562            sent.as_object_mut().ok_or_else(|| self.refusal())?.remove("contextId");
563            sent.as_object_mut().unwrap().remove("executionContextId");
564            sent.as_object_mut().unwrap().remove("executionContextUniqueId");
565            sent.as_object_mut().unwrap().remove("executionContextName");
566            if method == "Runtime.compileScript" {
567                let target = session_id.and_then(|session| self.session_target(session)).ok_or_else(|| self.refusal())?;
568                let guard = self.guards.get(&target).or_else(|| self.runtime_sessions.get(&target)).ok_or_else(|| self.refusal())?;
569                let proof = self.context_proofs.get(&(guard.clone(), unique.clone())).ok_or_else(|| self.refusal())?;
570                sent["executionContextId"] = json!(proof.id);
571            } else { sent["uniqueContextId"] = json!(unique); }
572        }
573        let (forwarded, sent) = self.fetch_command(method, sent, session_id);
574        let compile_session = if method == "Runtime.compileScript" {
575            session_id.and_then(|session| self.session_target(session)).and_then(|target| self.guards.get(&target).or_else(|| self.runtime_sessions.get(&target))).cloned()
576        } else { None };
577        let frame_epoch = self.frame_epoch;
578        #[cfg(test)]
579        self.runtime_stage(method, "forward");
580        let result = match self.raw_call_execution(&forwarded, sent, compile_session.as_deref().or(session_id), timeout, executed) {
581            Ok(result) => result,
582            Err(message) => {
583                #[cfg(test)]
584                if method == "Runtime.evaluate" {
585                    let text = message.to_lowercase();
586                    self.runtime_probe.push(json!({"stage":"backend-error","refusal":message.starts_with("slicc: refused:"),
587                        "context_missing":text.contains("cannot find context") || text.contains("cannot find execution context"),
588                        "context_destroyed":text.contains("context was destroyed"),"transport":transport_error(&message)}));
589                }
590                if executed && !message.starts_with("slicc: refused:") && !transport_error(&message) {
591                    #[cfg(test)]
592                    self.runtime_stage(method, "error-page-proof");
593                    self.verify_checks(&checks, false, true)?;
594                }
595                return Err(message);
596            }
597        };
598        #[cfg(test)]
599        self.runtime_stage(method, "reply-proof");
600        if content_command(method, &params) == Some(true) && !executed {
601            self.verify_checks(&checks, matches!(method, "IO.read" | "IO.close"), !method.starts_with("Input."))?;
602        }
603        if executed { self.verify_execution(&checks)?; }
604        if let (true, Some(session), Some(id)) = (
605            shield::answers_interception(method),
606            session_id,
607            shield::request_key(&params),
608        ) {
609            self.settle(session, &id);
610        }
611        if let Some(session) = session_id {
612            if self.blocked.contains(session) && !harmless(method, &params) && !executed {
613                return Err(self.refusal());
614            }
615        }
616        if let Some(frame) = params.get("frameId").and_then(Value::as_str) {
617            if self.frame_refused(frame, session_id)? {
618                return Err(self.refusal());
619            }
620        }
621        if crosses_frames(method, &params) && self.frame_tree_refused(session_id)? {
622            return Err(self.refusal());
623        }
624        if (params.get("frameId").and_then(Value::as_str).is_some() || crosses_frames(method, &params)) && frame_epoch != self.frame_epoch {
625            return Err(self.refusal());
626        }
627        #[cfg(test)]
628        self.runtime_stage(method, "reply-admission");
629        let result = self.after(method, &params, session_id, result)?;
630        self.bind_streams(method, session_id, &result, &checks);
631        #[cfg(test)]
632        self.runtime_stage(method, "complete");
633        Ok(result)
634    }
635
636    pub fn start_navigation(&mut self, params: Value, session: Option<&str>) -> Result<i64, String> {
637        let session = session.filter(|session| !session.is_empty());
638        self.admit("Page.navigate", &params, session)?;
639        self.remember_navigation(&params, session);
640        let id = self.send("Page.navigate", params.clone(), session)?;
641        self.navigations.insert(id, Navigation { params, session: session.map(str::to_string), reply: None, detached: false });
642        Ok(id)
643    }
644
645    pub fn poll_navigation(&mut self, id: i64) -> Option<Result<Value, String>> {
646        let navigation = self.navigations.get(&id)?;
647        if self.renderer_crashed(navigation.session.as_deref(), None) {
648            self.navigations.remove(&id);
649            return Some(Err(RENDERER_CRASHED.to_string()));
650        }
651        let refused = navigation.detached || navigation.session.as_deref().is_some_and(|session| self.blocked.contains(session) && !harmless("Page.navigate", &navigation.params));
652        if refused {
653            self.navigations.remove(&id);
654            return Some(Err(self.refusal()));
655        }
656        navigation.reply.as_ref()?;
657        let navigation = self.navigations.remove(&id).unwrap();
658        let reply = navigation.reply.unwrap();
659        if let Some(error) = reply.get("error") {
660            let (message, _) = shield::redact_text(&self.guard, error.get("message").and_then(Value::as_str).unwrap_or("CDP error"));
661            return Some(Err(format!("Page.navigate: {message}\n")));
662        }
663        Some(self.after("Page.navigate", &navigation.params, navigation.session.as_deref(), reply.get("result").cloned().unwrap_or(Value::Null)))
664    }
665
666    pub fn cancel_navigation(&mut self, id: i64) {
667        self.navigations.remove(&id);
668    }
669
670    pub fn notify(
671        &mut self,
672        method: &str,
673        params: Value,
674        session_id: Option<&str>,
675    ) -> Result<(), String> {
676        let session_id = session_id.filter(|id| !id.is_empty());
677        self.admit(method, &params, session_id)?;
678        if matches!(method, "Target.createTarget" | "Target.attachToTarget") {
679            return Err(format!("{method} must be a call, not a notify\n"));
680        }
681        if content_command(method, &params) == Some(true) {
682            self.call(method, params, session_id)?;
683            return Ok(());
684        }
685        self.prepare(method, &params, session_id)?;
686        let answered = shield::answers_interception(method)
687            .then(|| shield::request_key(&params))
688            .flatten()
689            .zip(session_id.map(str::to_string));
690        let (forwarded, params) = self.fetch_command(method, params, session_id);
691        let sent = self.send(&forwarded, params, session_id)?;
692        if let Some((id, session)) = answered {
693            self.answering.insert(sent, (session, id));
694        }
695        Ok(())
696    }
697
698    fn refusal(&self) -> String {
699        format!("{}\n", self.guard.refusal())
700    }
701
702    fn admit(&mut self, method: &str, params: &Value, session_id: Option<&str>) -> Result<(), String> {
703        if self.renderer_crashed(session_id, params.get("targetId").and_then(Value::as_str)) { return Err(RENDERER_CRASHED.to_string()) }
704        if method == "Page.handleJavaScriptDialog" {
705            if params.get("accept").and_then(Value::as_bool).is_none()
706                || params.get("promptText").is_some_and(|text| !text.is_string())
707                || !params.as_object().is_some_and(|object| object.keys().all(|key| matches!(key.as_str(), "accept" | "promptText"))) {
708                return Err(self.refusal());
709            }
710            return self.admit_dialog(session_id.ok_or_else(|| self.refusal())?);
711        }
712        if session_id.is_some_and(|session| self.dialog_sessions.contains(session)) { return Err(self.refusal()) }
713        if let Some(session) = session_id {
714            if self.blocked.contains(session) && !harmless(method, params) {
715                return Err(self.refusal());
716            }
717            if self.guard_sessions.contains(session) {
718                return Err(self.refusal());
719            }
720            if self.session_target(session).is_some_and(|target| !self.refresh(&target)) {
721                return Err(self.refusal());
722            }
723            if !recovery(method, params) && self.session_target(session).and_then(|target| self.documents.get(&target)).is_some_and(|doc| self.sealed(doc)) {
724                return Err(self.refusal());
725            }
726            if !harmless(method, params) {
727                if let Some(target) = self.session_target(session) {
728                    if self.documents.get(&target).is_some_and(|doc| doc.navigating || self.guard.error_page_url(&doc.url) || !doc.unreachable_url.is_empty()) {
729                        if self.target_protected(&target, false)? {
730                            return Err(self.refusal());
731                        }
732                    } else if self.targets.get(&target).is_some_and(|info| self.info_protected(info, &[])) {
733                        if self.target_protected(&target, false)? {
734                            return Err(self.refusal());
735                        }
736                    }
737                }
738            }
739        }
740        if matches!(method, "Network.loadNetworkResource" | "Page.captureSnapshot") {
741            return Err(self.refusal());
742        }
743        if !shield::body_method(method) && self.content_refused(method, params, session_id.unwrap_or("")) {
744            return Err(self.refusal());
745        }
746        if crosses_frames(method, params) && self.frame_tree_refused(session_id)? {
747            return Err(self.refusal());
748        }
749        if Worlds::selectors(params) || Worlds::sensitive(method) {
750            let session = session_id.unwrap_or("");
751            if !self.worlds.enabled(session) && (Worlds::sensitive(method) || self.worlds.reason(session, method, params).is_some()) {
752                self.raw_call("Runtime.enable", json!({}), session_id, Duration::from_secs(10))?;
753                self.worlds.enable(session);
754            }
755            if let Some(reason) = self.worlds.reason(session, method, params) {
756                return Err(reason.into());
757            }
758        }
759        if params
760            .get("targetId")
761            .and_then(|v| v.as_str())
762            .is_some_and(|target| self.abandoned.contains(target))
763        {
764            return Err(UNGUARDED.to_string());
765        }
766        if let Some(reason) = shield::browser_wide(method, params, session_id, &self.contexts) {
767            return Err(reason);
768        }
769        if method == "Browser.setWindowBounds" {
770            self.admit_window(params, session_id)?;
771        } else if shield::persistent_capability(method) && !self.network_state_admitted(method, params, session_id) {
772            return Err(PERSISTENT.into());
773        }
774        if method == "Target.autoAttachRelated" || method == "Target.setAutoAttach"
775            && params.get("autoAttach").and_then(|v| v.as_bool()) == Some(true)
776        {
777            return Err(AUTO_ATTACH.into());
778        }
779        if matches!(method, "Target.attachToTarget" | "Target.setAutoAttach")
780            && params.get("flatten").and_then(|v| v.as_bool()) != Some(true)
781        {
782            return Err(FLATTEN.into());
783        }
784        if let Some(reason) = shield::command_url_refusal(&self.guard, params, method) {
785            if reason == self.guard.refusal() && shield::cookie_method(method) {
786                return Err(COOKIES.into());
787            }
788            return Err(format!("{reason}\n"));
789        }
790        if method.starts_with("PWA.") {
791            for key in ["manifestId", "installUrlOrBundleUrl"] {
792                if let Some(url) = params.get(key).and_then(Value::as_str) {
793                    if self.guard.url(url) {
794                        return Err(format!("{}\n", self.guard.refusal_for_url(url)));
795                    }
796                }
797            }
798        }
799        if shield::cookie_method(method) && shield::cookie_refused(&self.guard, params) {
800            return Err(COOKIES.into());
801        }
802        if shield::names_protected_storage(&self.guard, params) {
803            return Err(self.refusal());
804        }
805        if method == "Page.navigateToHistoryEntry" {
806            let entry = params.get("entryId").and_then(|v| v.as_i64()).ok_or_else(|| self.refusal())?;
807            let history = self.raw_call(
808                "Page.getNavigationHistory",
809                json!({}),
810                session_id,
811                DOCUMENT_CHECK,
812            ).map_err(|_| self.refusal())?;
813            let url = history
814                .get("entries")
815                .and_then(|v| v.as_array())
816                .and_then(|entries| {
817                    entries
818                        .iter()
819                        .find(|item| item.get("id").and_then(|v| v.as_i64()) == Some(entry))
820                })
821                .and_then(|item| item.get("url").and_then(|v| v.as_str()))
822                .filter(|url| !url.is_empty())
823                .ok_or_else(|| self.refusal())?
824                .to_string();
825            if self.guard.url(&url) {
826                return Err(format!("{}\n", self.guard.refusal_for_url(&url)));
827            }
828        }
829        if let Some(frame) = params.get("frameId").and_then(Value::as_str) {
830            if self.frame_refused(frame, session_id)? {
831                let url = self.frames.get(frame).and_then(|known| known.first()).map(String::as_str).unwrap_or("");
832                return Err(format!("{}\n", self.guard.refusal_for_url(url)));
833            }
834        }
835        if !matches!(method, "Target.getTargetInfo" | "Target.detachFromTarget") {
836            for key in ["targetId"] {
837                if let Some(target) = params.get(key).and_then(|v| v.as_str()) {
838                    let target = target.to_string();
839                    if self.target_protected(&target, false)? {
840                        let url = self.targets.get(&target).and_then(|info| info.get("url")).and_then(Value::as_str).unwrap_or("");
841                        return Err(format!("{}\n", self.guard.refusal_for_url(url)));
842                    }
843                }
844            }
845        }
846        let issued = self.client_issued(method, params, session_id)?;
847        if shield::interception_method(method) {
848            let id = shield::request_key(params).unwrap_or_default();
849            let handed = session_id
850                .is_some_and(|session| self.handed.contains(&(session.to_string(), id.clone())));
851            if !handed || self.requests.get(&id) == Some(&true) {
852                return Err(UNSEEN.into());
853            }
854        }
855        if let Some(session) = session_id.filter(|_| issued) { self.issue(params, session)?; }
856        if shield::body_method(method) {
857            let id = shield::request_key(params);
858            match id.as_deref().and_then(|id| self.request_state(id)) {
859                Some(true) => return Err(self.refusal()),
860                Some(false) => {}
861                None => {
862                    let deadline = Instant::now() + Duration::from_secs(2);
863                    let mut known = None;
864                    while known.is_none() && Instant::now() < deadline {
865                        if !self.pump(deadline)? {
866                            break;
867                        }
868                        known = id.as_deref().and_then(|id| self.request_state(id));
869                    }
870                    match known {
871                        Some(false) => {}
872                        Some(true) => return Err(self.refusal()),
873                        None => return Err(UNSEEN.into()),
874                    }
875                }
876            }
877        }
878        if method == "Network.replayXHR" { return Err(self.refusal()) }
879        if method == "IO.read" {
880            let handle = params.get("handle").and_then(|v| v.as_str()).unwrap_or("");
881            if !self.streams.contains_key(handle) {
882                return Err(STREAM.into());
883            }
884        }
885        Ok(())
886    }
887
888    pub fn settled_refusal(&self, method: &str, params: &Value, session_id: Option<&str>) -> Option<String> {
889        let session_id = session_id.filter(|id| !id.is_empty());
890        if self.renderer_crashed(session_id, params.get("targetId").and_then(Value::as_str)) { return Some(RENDERER_CRASHED.to_string()) }
891        if method == "Page.handleJavaScriptDialog" || harmless(method, params) { return None }
892        if session_id.is_some_and(|session| self.blocked.contains(session)) { return Some(self.refusal()) }
893        let reason = if matches!(method, "Network.loadNetworkResource" | "Page.captureSnapshot") {
894            self.refusal()
895        } else if shield::persistent_capability(method) && method != "Browser.setWindowBounds" && (session_id.is_none() || !shield::network_state(method, params)) {
896            PERSISTENT.to_string()
897        } else if method == "Target.autoAttachRelated" || method == "Target.setAutoAttach" && params.get("autoAttach").and_then(Value::as_bool) == Some(true) {
898            AUTO_ATTACH.to_string()
899        } else if matches!(method, "Target.attachToTarget" | "Target.setAutoAttach") && params.get("flatten").and_then(Value::as_bool) != Some(true) {
900            FLATTEN.to_string()
901        } else {
902            return None;
903        };
904        if session_id.is_some_and(|session| self.dialog_sessions.contains(session) || self.guard_sessions.contains(session))
905            || self.content_refused(method, params, session_id.unwrap_or("")) {
906            return Some(self.refusal());
907        }
908        if reason == self.refusal() { return Some(reason) }
909        if Worlds::selectors(params) || Worlds::sensitive(method) { return None }
910        if params.get("targetId").and_then(Value::as_str).is_some_and(|target| self.abandoned.contains(target)) { return Some(UNGUARDED.to_string()) }
911        Some(shield::browser_wide(method, params, session_id, &self.contexts).unwrap_or(reason))
912    }
913
914    fn network_state_admitted(&self, method: &str, params: &Value, session_id: Option<&str>) -> bool {
915        let Some(session) = session_id else { return false };
916        shield::network_state(method, params)
917            && !self.guard_sessions.contains(session)
918            && !self.dialog_sessions.contains(session)
919            && !self.blocked.contains(session)
920            && self.session_target(session).is_some_and(|target| {
921                !self.protected_targets.contains(&target)
922                    && !self.abandoned.contains(&target)
923                    && self.targets.get(&target).is_some_and(|info| {
924                        info.get("type").and_then(Value::as_str) == Some("page") && !self.info_protected(info, &[])
925                    })
926            })
927    }
928
929    fn prepare(&mut self, method: &str, params: &Value, session: Option<&str>) -> Result<Vec<(String, DocumentState)>, String> {
930        #[cfg(test)]
931        let previous = self.runtime_preparing;
932        #[cfg(test)]
933        { self.runtime_preparing = method == "Runtime.evaluate"; }
934        let result = self.prepare_state(method, params, session);
935        #[cfg(test)]
936        { self.runtime_preparing = previous; }
937        result
938    }
939
940    fn prepare_state(&mut self, method: &str, params: &Value, session: Option<&str>) -> Result<Vec<(String, DocumentState)>, String> {
941        #[cfg(test)]
942        self.runtime_prepare_probe("command-classification", json!({}));
943        let content = content_command(method, params).ok_or_else(|| self.refusal())?;
944        let mut checks = Vec::new();
945        if !content { return Ok(checks) }
946        let runtime = method.starts_with("Runtime.");
947        let page_scope = if page_scope::content(method) {
948            Some(if method == "Runtime.evaluate" && !Worlds::selectors(params) { self.verify_runtime_page_scope_state(session)? }
949                else { self.verify_page_scope_state(session)? })
950        } else { None };
951        if storage_content(method) && method != "Storage.getStorageKeyForFrame" {
952            if !self.storage_scope_verified(params) { return Err(self.refusal()) }
953            return Ok(checks);
954        }
955        if matches!(method, "Fetch.getResponseBody" | "Network.getResponseBody" | "Network.getRequestPostData")
956            && session.and_then(|session| self.session_target(session)).is_some_and(|target| self.document_paused(&target))
957            && shield::request_key(params).is_some_and(|id| self.request_state(&id) == Some(false)) { return Ok(checks) }
958        if matches!(method, "IO.read" | "IO.close") {
959            let saved = params.get("handle").and_then(Value::as_str).and_then(|handle| self.streams.get(handle)).cloned();
960            let Some((Some(target), Some(expected))) = saved else { return Err(self.refusal()) };
961            let mut current = self.document_state_for(&target, true, expected.scope.as_deref()).ok_or_else(|| self.refusal())?;
962            if let Some(proof) = &expected.page_scope {
963                let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
964                if &self.verify_page_scope(Some(&guard))? != proof { return Err(self.refusal()) }
965                current.page_scope = Some(proof.clone());
966            }
967            if current != expected { return Err(self.refusal()) }
968            checks.push((target, current));
969        }
970        if let Some(session) = session {
971            #[cfg(test)]
972            self.runtime_prepare_probe("selected-session", json!({"target_known":self.session_target(session).is_some(),"blocked":self.blocked.contains(session)}));
973            let target = self.session_target(session).ok_or_else(|| self.refusal())?;
974            if !checks.iter().any(|(known, _)| known == &target) {
975                let selected_frame = params.get("frameId").and_then(Value::as_str).map(str::to_string).or_else(|| {
976                    ["contextId", "executionContextId", "uniqueContextId", "executionContextUniqueId", "executionContextName"].into_iter().find_map(|key| {
977                        let value = params.get(key)?;
978                        let label = if matches!(key, "contextId" | "executionContextId") { "id" } else if key == "executionContextName" { "name" } else { "unique" };
979                        let value = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
980                        self.context_frames.get(&(session.to_string(), format!("{label}:{value}"))).cloned()
981                    })
982                });
983                let scope = if crosses_frames(method, params) || page_scope::content(method) && !runtime { None } else { Some(selected_frame.as_deref().unwrap_or("")) };
984                let mut state = if self.document_paused(&target) {
985                    #[cfg(test)]
986                    self.runtime_prepare_probe("paused-document-provenance", json!({"cached_state":self.cached_documents.contains_key(&target),
987                        "guard_present":self.guards.contains_key(&target),"document_known":self.documents.contains_key(&target),
988                        "previous_protected":self.documents.get(&target).is_some_and(|doc| doc.previous),"target_protected":self.protected_targets.contains(&target)}));
989                    if !matches!(method, "Runtime.evaluate" | "Runtime.callFunctionOn") { return Err(self.refusal()) }
990                    self.cached_documents.get(&target).cloned().ok_or_else(|| self.refusal())?
991                } else if let Some((_, state)) = page_scope.as_ref().filter(|(proof, state)| proof.0 == target && (!runtime || Self::runtime_page_reusable(state))) {
992                    #[cfg(test)]
993                    self.runtime_prepare_probe("selected-frame-placement", json!({"source":"fresh-page-proof","explicit_frame":selected_frame.is_some()}));
994                    if runtime { Self::scoped_page_state(state, scope.unwrap_or("")).ok_or_else(|| self.refusal())? } else { state.clone() }
995                } else {
996                    #[cfg(test)]
997                    self.runtime_prepare_probe("selected-document-state", json!({"source":"private-document-proof","guard_present":self.guards.contains_key(&target)}));
998                    self.document_state_for(&target, true, scope).ok_or_else(|| self.refusal())?
999                };
1000                #[cfg(test)]
1001                self.runtime_prepare_probe("blocking", json!({"blocked":self.blocked.contains(session)}));
1002                if self.blocked.contains(session) { return Err(self.refusal()) }
1003                for key in ["frameId", "contextId", "executionContextId", "uniqueContextId", "executionContextUniqueId", "executionContextName"] {
1004                    if let Some(value) = params.get(key) {
1005                        let frame = if key == "frameId" {
1006                            value.as_str().map(str::to_string)
1007                        } else {
1008                            let label = if matches!(key, "contextId" | "executionContextId") { "id" } else if key == "executionContextName" { "name" } else { "unique" };
1009                            let value = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
1010                            self.context_frames.get(&(session.to_string(), format!("{label}:{value}"))).cloned()
1011                        };
1012                        #[cfg(test)]
1013                        self.runtime_prepare_probe("selector-placement", json!({"selector_known":frame.is_some(),"frame_present":frame.as_ref().is_some_and(|frame| state.frames.contains_key(frame))}));
1014                        if frame.is_none_or(|frame| !state.frames.contains_key(&frame)) { return Err(self.refusal()) }
1015                    }
1016                }
1017                state.page_scope = page_scope.map(|(proof, _)| proof);
1018                state.runtime_scope_epoch = state.page_scope.as_ref().filter(|_| runtime).map(|proof| self.runtime_scope_epochs.get(&proof.0).copied().unwrap_or(0));
1019                checks.push((target, state));
1020            }
1021        }
1022        #[cfg(test)]
1023        self.runtime_prepare_probe("selected-state-complete", json!({"checks_present":!checks.is_empty()}));
1024        if checks.is_empty() { return Err(self.refusal()) }
1025        Ok(checks)
1026    }
1027
1028    fn scoped_page_state(state: &DocumentState, scope: &str) -> Option<DocumentState> {
1029        let mut current = if scope.is_empty() { state.frames.iter().find(|(_, frame)| frame[4].is_empty())?.0.as_str() } else { scope };
1030        let mut selected = state.clone();
1031        selected.frames.clear();
1032        loop {
1033            let frame = state.frames.get(current)?;
1034            if selected.frames.insert(current.to_string(), frame.clone()).is_some() { return None }
1035            if frame[4].is_empty() { break }
1036            current = &frame[4];
1037        }
1038        selected.scope = Some(scope.to_string());
1039        Some(selected)
1040    }
1041
1042    fn runtime_page_reusable(state: &DocumentState) -> bool {
1043        state.frames.values().find(|frame| frame[4].is_empty()).is_some_and(|frame| {
1044            url::Url::parse(&frame[1]).is_ok_and(|url| matches!(url.scheme(), "http" | "https"))
1045        })
1046    }
1047
1048    fn document_paused(&self, target: &str) -> bool {
1049        self.paused_documents.iter().any(|(session, _)| self.session_target(session).as_deref() == Some(target))
1050    }
1051
1052    fn pin_execution(&mut self, method: &str, params: &Value, session: Option<&str>, checks: &mut Vec<(String, DocumentState)>) -> Result<Option<String>, String> {
1053        if !matches!(method, "Runtime.evaluate" | "Runtime.callFunctionOn" | "Runtime.compileScript") || checks.is_empty() { return Ok(None) }
1054        if method == "Runtime.callFunctionOn" && params.get("objectId").is_some() { return Ok(None) }
1055        let session = session.ok_or_else(|| self.refusal())?;
1056        let target = self.session_target(session).ok_or_else(|| self.refusal())?;
1057        let mut state = checks.iter().find(|(id, _)| id == &target).map(|(_, state)| state.clone()).ok_or_else(|| self.refusal())?;
1058        let guard = if let Some(guard) = self.guards.get(&target).or_else(|| self.runtime_sessions.get(&target)) {
1059            guard.clone()
1060        } else {
1061            if state.worker.is_none() { return Err(self.refusal()) }
1062            let result = self.raw_call("Target.attachToTarget", json!({"targetId":target,"flatten":true}), None, DOCUMENT_CHECK)?;
1063            let guard = result.get("sessionId").and_then(Value::as_str).ok_or_else(|| self.refusal())?.to_string();
1064            self.guard_sessions.insert(guard.clone());
1065            self.session_targets.insert(guard.clone(), target.clone());
1066            self.runtime_sessions.insert(target.clone(), guard.clone());
1067            guard
1068        };
1069        let choose = |proofs: &BTreeMap<(String, String), ContextProof>, state: &DocumentState| {
1070            proofs.iter().find(|((owner, unique), proof)| {
1071                owner == &guard && if let Some(id) = params.get("contextId").or_else(|| params.get("executionContextId")).and_then(Value::as_i64) {
1072                    proof.id == id
1073                } else if let Some(id) = params.get("uniqueContextId").or_else(|| params.get("executionContextUniqueId")).and_then(Value::as_str) {
1074                    unique == id
1075                } else if let Some(name) = params.get("executionContextName").and_then(Value::as_str) {
1076                    proof.name == name
1077                } else { proof.default && (state.worker.is_some() || state.scope.as_deref() == Some(proof.frame.as_str()) || state.frames.get(&proof.frame).is_some_and(|evidence| evidence[4].is_empty())) }
1078            }).map(|((_, unique), proof)| (unique.clone(), proof.clone()))
1079        };
1080        let mut selected = choose(&self.context_proofs, &state);
1081        #[cfg(test)]
1082        self.runtime_contexts(method, "private-context-initial", &guard, &target, &state, selected.as_ref().map(|(unique, _)| unique.as_str()));
1083        let paused = self.document_paused(&target);
1084        let stable_context = selected.as_ref().is_some_and(|(_, proof)| state.worker.is_some()
1085            || state.frames.get(&proof.frame).is_some_and(|evidence| proof.loader.as_deref() == Some(evidence[0].as_str())));
1086        if !paused && !stable_context {
1087            let deadline = Instant::now() + DOCUMENT_CHECK;
1088            for attempt in 0..2 {
1089                self.context_proofs.retain(|(owner, _), _| owner != &guard);
1090                let left = deadline.saturating_duration_since(Instant::now());
1091                if left.is_zero() { return Err(self.refusal()) }
1092                self.raw_call("Runtime.disable", json!({}), Some(&guard), left)?;
1093                let left = deadline.saturating_duration_since(Instant::now());
1094                if left.is_zero() { return Err(self.refusal()) }
1095                self.raw_call("Runtime.enable", json!({}), Some(&guard), left)?;
1096                #[cfg(test)]
1097                self.runtime_contexts(method, "private-context-replayed", &guard, &target, &state, selected.as_ref().map(|(unique, _)| unique.as_str()));
1098                let current = self.document_state_for(&target, true, state.scope.as_deref()).ok_or_else(|| self.refusal())?;
1099                #[cfg(test)]
1100                self.runtime_contexts(method, "private-context-current", &guard, &target, &current, selected.as_ref().map(|(unique, _)| unique.as_str()));
1101                let same = if state.worker.is_some() { state.worker == current.worker } else {
1102                    state.frames.iter().filter(|(_, evidence)| evidence[4].is_empty()).all(|(id, evidence)| current.frames.get(id).is_some_and(|now| evidence[0] == now[0] && evidence[2] == now[2]))
1103                };
1104                if self.blocked.contains(session) { return Err(self.refusal()) }
1105                if !same {
1106                    let ordinary_default_commit = state.worker.is_none() && state.scope.as_deref() == Some("") && state.frames.len() == 1
1107                        && !Worlds::selectors(params) && self.guards.get(&target) == Some(&guard)
1108                        && state.page_scope.as_ref().is_some_and(|proof| proof.0 == target)
1109                        && state.frames.iter().all(|(id, before)| current.frames.get(id).is_some_and(|after| before[0] != after[0]
1110                            && Self::runtime_page_reusable(&current)));
1111                    if attempt != 0 || !ordinary_default_commit { return Err(self.refusal()) }
1112                    #[cfg(test)]
1113                    self.runtime_stage(method, "private-context-readmission");
1114                    *checks = self.prepare(method, params, Some(session))?;
1115                    state = checks.iter().find(|(id, _)| id == &target).map(|(_, state)| state.clone()).ok_or_else(|| self.refusal())?;
1116                    continue;
1117                }
1118                selected = choose(&self.context_proofs, &state);
1119                if selected.is_some() { break }
1120            }
1121        }
1122        #[cfg(test)]
1123        self.runtime_contexts(method, "private-context-validation", &guard, &target, &state, selected.as_ref().map(|(unique, _)| unique.as_str()));
1124        let (unique, proof) = selected.ok_or_else(|| self.refusal())?;
1125        let owned_world = !proof.default && self.created_contexts.contains(&(session.to_string(), proof.id));
1126        let owned_blank = proof.default && state.frames.get(&proof.frame).is_some_and(|evidence| evidence[1] == "about:blank"
1127            && self.owns_blank(&target, &proof.frame, &evidence[0]));
1128        if unique.is_empty() || proof.origin == "null" && !owned_blank || proof.origin.is_empty() && !owned_world && !owned_blank || self.guard.url(&proof.origin) { return Err(self.refusal()) }
1129        if state.worker.is_none() {
1130            let evidence = state.frames.get(&proof.frame).ok_or_else(|| self.refusal())?;
1131            if proof.loader.as_deref() != Some(evidence[0].as_str()) { return Err(self.refusal()) }
1132        }
1133        Ok(Some(unique))
1134    }
1135
1136    fn storage_scope_verified(&self, params: &Value) -> bool {
1137        let mut scopes = Vec::new();
1138        fn collect(value: &Value, scopes: &mut Vec<String>) -> bool {
1139            let Some(items) = value.as_object() else { return false };
1140            for (key, value) in items {
1141                if matches!(key.as_str(), "origin" | "securityOrigin" | "storageKey" | "cacheId") {
1142                    let Some(text) = value.as_str() else { return false };
1143                    scopes.push(if key == "cacheId" { text.split('|').next().unwrap_or("").to_string() } else { text.to_string() });
1144                } else if value.is_object() && !collect(value, scopes) { return false }
1145            }
1146            true
1147        }
1148        collect(params, &mut scopes) && !scopes.is_empty() && scopes.iter().all(|scope| {
1149            let mut parts = scope.split('^');
1150            let origin = parts.next().unwrap_or("");
1151            let valid = |text: &str| url::Url::parse(text).ok().is_some_and(|url| url.origin().ascii_serialization() != "null" && !self.guard.url(text));
1152            valid(origin) && !self.guard.storage_key(scope)
1153                && parts.filter_map(|part| part.strip_prefix('0')).all(valid)
1154        })
1155    }
1156
1157    fn verify_checks(&mut self, checks: &[(String, DocumentState)], same_document: bool, stable_page: bool) -> Result<(), String> {
1158        for (target, expected) in checks {
1159            let current = if let Some(proof) = &expected.page_scope {
1160                let session = self.guards.get(&proof.0).cloned().ok_or_else(|| self.refusal())?;
1161                let (current, state) = self.verify_page_scope_state(Some(&session))?;
1162                if stable_page && &current != proof { return Err(self.refusal()) }
1163                if current.0 == *target && (expected.runtime_scope_epoch.is_none() || Self::runtime_page_reusable(&state)) {
1164                    Some(if expected.runtime_scope_epoch.is_some() { Self::scoped_page_state(&state, expected.scope.as_deref().unwrap_or("")).ok_or_else(|| self.refusal())? } else { state })
1165                } else { None }
1166            } else { None };
1167            let current = match current {
1168                Some(state) => state,
1169                None => self.document_state_for(target, true, expected.scope.as_deref()).ok_or_else(|| self.refusal())?,
1170            };
1171            if same_document && &current != expected { return Err(self.refusal()) }
1172        }
1173        Ok(())
1174    }
1175
1176    fn verify_execution(&mut self, checks: &[(String, DocumentState)]) -> Result<(), String> {
1177        for (_, state) in checks {
1178            if let Some(proof) = &state.page_scope {
1179                let session = self.guards.get(&proof.0).cloned().ok_or_else(|| self.refusal())?;
1180                if self.session_target(&session).as_deref() != Some(&proof.0) { return Err(self.refusal()) }
1181                if self.page_scope_unknown != proof.2 || state.runtime_scope_epoch != Some(self.runtime_scope_epochs.get(&proof.0).copied().unwrap_or(0)) {
1182                    if self.verify_page_scope(Some(&session))?.0 != proof.0 { return Err(self.refusal()) }
1183                }
1184            }
1185        }
1186        for (target, state) in checks {
1187            let owner = state.worker.as_ref().map_or(target.as_str(), |(_, owner)| owner.as_str());
1188            if self.unchecked_document(owner) {
1189                let guard = self.guards.get(owner).cloned().ok_or_else(|| self.refusal())?;
1190                self.verify_page_scope_state(Some(&guard))?;
1191            }
1192        }
1193        let sealed = checks.iter().any(|(target, state)| {
1194            let owner = state.worker.as_ref().map_or(target.as_str(), |(_, owner)| owner.as_str());
1195            self.unchecked_document(owner) || self.documents.get(owner).is_some_and(|doc| self.unverified_error(&doc.url, &doc.unreachable_url))
1196        });
1197        if sealed { Err(self.refusal()) } else { Ok(()) }
1198    }
1199
1200    fn unverified_error(&self, url: &str, unreachable: &str) -> bool {
1201        let error = self.guard.error_page_url(url) || !unreachable.is_empty()
1202            || url.get(..13).is_some_and(|scheme| scheme.eq_ignore_ascii_case("chrome-error:"));
1203        error && (url::Url::parse(unreachable).is_err() || self.guard.url(unreachable))
1204    }
1205
1206    fn bind_streams(&mut self, method: &str, session: Option<&str>, result: &Value, checks: &[(String, DocumentState)]) {
1207        let target = session.and_then(|session| self.session_target(session));
1208        let document = target.as_ref().and_then(|target| checks.iter().find(|(known, _)| known == target)).map(|(_, state)| state.clone());
1209        for stream in [result.get("stream"), result.pointer("/resource/stream")].into_iter().flatten().filter_map(Value::as_str) {
1210            self.streams.insert(stream.to_string(), (target.clone(), document.clone()));
1211        }
1212        if method == "IO.resolveBlob" {
1213            if let Some(uuid) = result.get("uuid").and_then(Value::as_str) {
1214                self.streams.insert(format!("blob:{uuid}"), (target, document));
1215            }
1216        }
1217    }
1218
1219    fn request_state(&self, id: &str) -> Option<bool> {
1220        if self.redirects.contains(id) { Some(true) } else { self.requests.get(id).copied() }
1221    }
1222
1223    fn after(
1224        &mut self,
1225        method: &str,
1226        params: &Value,
1227        session_id: Option<&str>,
1228        mut result: Value,
1229    ) -> Result<Value, String> {
1230        if self.content_refused(method, params, session_id.unwrap_or("")) {
1231            return Err(self.refusal());
1232        }
1233        if Worlds::sensitive(method) {
1234            if let Some(reason) = self.worlds.reason(session_id.unwrap_or(""), method, params) {
1235                return Err(reason.into());
1236            }
1237        }
1238        if method == "Runtime.enable" {
1239            self.worlds.enable(session_id.unwrap_or(""));
1240        } else if method == "Runtime.disable" {
1241            self.worlds.disable(session_id.unwrap_or(""));
1242        }
1243        self.worlds.reply(session_id.unwrap_or(""), method, &result);
1244        if method == "Page.createIsolatedWorld" {
1245            self.worlds.created(session_id.unwrap_or(""), &result);
1246            if let (Some(frame), Some(id)) = (params.get("frameId").and_then(Value::as_str), result.get("executionContextId")) {
1247                if let Some(id) = id.as_i64() { self.created_contexts.insert((session_id.unwrap_or("").to_string(), id)); }
1248                self.context_frames.insert((session_id.unwrap_or("").to_string(), format!("id:{id}")), frame.to_string());
1249            }
1250        }
1251        if method == "Runtime.compileScript" {
1252            self.worlds.script(session_id.unwrap_or(""), &result);
1253        }
1254        match method {
1255            "Page.navigate" if params.get("url").and_then(Value::as_str) == Some("about:blank")
1256                && params.get("frameId").is_none() && result.get("errorText").is_none()
1257                && result.get("isDownload").is_none_or(|download| download == &json!(false)) => {
1258                if let (Some(target), Some(frame), Some(loader)) = (
1259                    session_id.and_then(|session| self.session_target(session)),
1260                    result.get("frameId").and_then(Value::as_str).filter(|id| frame_text_known(id)),
1261                    result.get("loaderId").and_then(Value::as_str).filter(|id| frame_text_known(id)),
1262                ) {
1263                    if let Some(guard) = self.guards.get(&target).filter(|guard| !self.blocked.contains(*guard)) {
1264                        if !self.protected_targets.contains(&target) && !self.abandoned.contains(&target)
1265                            && self.documents.get(&target).is_some_and(|doc| !doc.previous && doc.frame_id == frame)
1266                            && self.targets.get(&target).and_then(|info| info.get("type")).and_then(Value::as_str) == Some("page") {
1267                            self.navigated_blanks.insert(target, (guard.clone(), frame.to_string(), loader.to_string()));
1268                        }
1269                    }
1270                }
1271            }
1272            "Target.attachToTarget" => {
1273                let session = result
1274                    .get("sessionId")
1275                    .and_then(|v| v.as_str())
1276                    .unwrap_or("")
1277                    .to_string();
1278                let target = params
1279                    .get("targetId")
1280                    .and_then(|v| v.as_str())
1281                    .unwrap_or("")
1282                    .to_string();
1283                if !session.is_empty() {
1284                    self.session_targets.insert(session.clone(), target.clone());
1285                    if let Err(message) = self.touch(&target) {
1286                        if transport_error(&message) {
1287                            self.session_targets.remove(&session);
1288                            return Err(message);
1289                        }
1290                        let detached = self.raw_call(
1291                            "Target.detachFromTarget",
1292                            json!({ "sessionId": session }),
1293                            None,
1294                            Duration::from_secs(5),
1295                        );
1296                        self.session_targets.remove(&session);
1297                        if let Err(closed) = detached {
1298                            if transport_error(&closed) {
1299                                return Err(closed);
1300                            }
1301                        }
1302                        return Err(message);
1303                    }
1304                    if self.protected_targets.contains(&target) {
1305                        self.block(&session);
1306                        return Err(self.refusal());
1307                    }
1308                }
1309            }
1310            "Target.createBrowserContext" => {
1311                if let Some(id) = result.get("browserContextId").and_then(|v| v.as_str()) {
1312                    self.contexts.insert(id.to_string());
1313                }
1314            }
1315            "Target.getTargets" => {
1316                let infos = result
1317                    .get("targetInfos")
1318                    .and_then(|v| v.as_array())
1319                    .cloned()
1320                    .unwrap_or_default();
1321                for info in &infos {
1322                    self.probe_error(info);
1323                }
1324                for info in &infos {
1325                    if let Some(id) = info.get("targetId").and_then(|v| v.as_str()) {
1326                        self.targets.insert(id.to_string(), info.clone());
1327                    }
1328                }
1329                let mut out = Vec::new();
1330                for info in &infos {
1331                    let redacted = self.redact(info, &infos);
1332                    let url = info.get("url").and_then(Value::as_str).unwrap_or("");
1333                    if !(self.guard.target_type(info.get("type").and_then(Value::as_str).unwrap_or(""))
1334                        || self.guard.unparseable(url) && self.guard.url(url)) {
1335                        out.push(redacted);
1336                    }
1337                }
1338                result["targetInfos"] = Value::Array(out);
1339            }
1340            "Target.getTargetInfo" => {
1341                if let Some(info) = result.get("targetInfo").cloned() {
1342                    let target = info.get("targetId").and_then(|v| v.as_str()).unwrap_or("").to_string();
1343                    self.targets.insert(target.clone(), info.clone());
1344                    self.probe_error(&info);
1345                    let protected = self.info_protected(&info, &[]);
1346                    if protected && !self.documents.contains_key(&target) {
1347                        self.protect_target(&target);
1348                    }
1349                    if protected || self.discovery_redacted(&info) {
1350                        result["targetInfo"] = shield::redacted(&info);
1351                    }
1352                }
1353            }
1354            _ if shield::cookie_reader(method) => {
1355                let mut cookies = result.get("cookies").and_then(Value::as_array).cloned().unwrap_or_default();
1356                cookies.extend(self.trace_cookies.iter().cloned());
1357                shield::redact_cookie_events(&self.guard, &mut self.events, &json!({ "cookies": cookies }));
1358                shield::drop_protected_cookies(&self.guard, &mut result);
1359            }
1360            _ => {}
1361        }
1362        shield::redact_strings(&self.guard, &mut result);
1363        Ok(result)
1364    }
1365
1366    fn discovery_redacted(&self, info: &Value) -> bool {
1367        self.guard.error_redaction()
1368            && self.guard.url(info.get("url").and_then(Value::as_str).unwrap_or(""))
1369    }
1370
1371    fn redact(&mut self, info: &Value, all: &[Value]) -> Value {
1372        let protected = self.info_protected(info, all);
1373        if protected {
1374            if let Some(target) = info.get("targetId").and_then(Value::as_str) {
1375                if !self.documents.contains_key(target) {
1376                    self.protect_target(target);
1377                }
1378            }
1379        }
1380        if protected || self.discovery_redacted(info) {
1381            shield::redacted(info)
1382        } else {
1383            info.clone()
1384        }
1385    }
1386
1387    fn info_protected(&self, info: &Value, all: &[Value]) -> bool {
1388        if self.guard.target_type(info.get("type").and_then(Value::as_str).unwrap_or("")) {
1389            return true;
1390        }
1391        let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("");
1392        let id = info.get("targetId").and_then(|v| v.as_str()).unwrap_or("");
1393        let previous = self.protected_targets.contains(id);
1394        if let Some(doc) = self.documents.get(id) {
1395            if doc.navigating {
1396                return doc.previous || self.guard.url(url) || doc.navigation_url.is_empty() || self.guard.url(&doc.navigation_url);
1397            }
1398            if (self.guard.error_page_url(&doc.url) || !doc.unreachable_url.is_empty()) && !self.masked_error(doc, url) {
1399                if !self.guard.error_document(&doc.url, &doc.origin)
1400                    || !(self.reported_unreachable(&doc.unreachable_url, url) || self.guard.error_page_url(url) && doc.url == url && !doc.unreachable_url.is_empty()) {
1401                    return true;
1402                }
1403                return self.guard.target_with_document(&doc.unreachable_url, doc.previous, Opener::None,
1404                    Some(&doc.url), Some(&doc.unreachable_url), doc.navigating);
1405            }
1406            if doc.previous {
1407                return true;
1408            }
1409        }
1410        let opener = match info
1411            .get("openerId")
1412            .and_then(|v| v.as_str())
1413            .filter(|v| !v.is_empty())
1414        {
1415            None => Opener::None,
1416            Some(opener) => match all
1417                .iter()
1418                .find(|item| item.get("targetId").and_then(|v| v.as_str()) == Some(opener))
1419            {
1420                Some(found) if found.get("targetId") == info.get("targetId") => Opener::Unknown,
1421                Some(found) if self.info_protected(found, &[]) => Opener::Protected,
1422                Some(_) => Opener::Unprotected,
1423                None if self.protected_targets.contains(opener) => Opener::Protected,
1424                None => match self.targets.get(opener) {
1425                    Some(found) if found.get("targetId") != info.get("targetId") => {
1426                        if self.info_protected(found, &[]) {
1427                            Opener::Protected
1428                        } else {
1429                            Opener::Unprotected
1430                        }
1431                    }
1432                    _ => Opener::Unknown,
1433                },
1434            },
1435        };
1436        self.guard.target(url, previous, opener)
1437    }
1438
1439    pub fn target_protected(&mut self, target_id: &str, frame: bool) -> Result<bool, String> {
1440        self.judge_target(target_id, frame, true)
1441    }
1442
1443    fn judge_target(&mut self, target_id: &str, frame: bool, refusal_protects: bool) -> Result<bool, String> {
1444        if self.protected_targets.contains(target_id) {
1445            return Ok(true);
1446        }
1447        let info = match self.raw_call(
1448            "Target.getTargetInfo",
1449            json!({ "targetId": target_id }),
1450            None,
1451            Duration::from_secs(30),
1452        ) {
1453            Ok(info) => info.get("targetInfo").cloned().unwrap_or(Value::Null),
1454            Err(message) if refusal_protects && message.starts_with("Target.getTargetInfo: slicc: refused:") => {
1455                self.protected_targets.insert(target_id.to_string());
1456                return Ok(true);
1457            }
1458            Err(_) if frame => return Ok(false),
1459            Err(message) => return Err(message),
1460        };
1461        if info.is_object() {
1462            self.targets.insert(target_id.to_string(), info.clone());
1463            if self.documents.get(target_id).is_some_and(|doc| doc.probed) {
1464                self.documents.remove(target_id);
1465            } else if !self.refresh(target_id) {
1466                return Ok(true);
1467            }
1468            self.probe_error(&info);
1469        }
1470        let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
1471        let opener = match info
1472            .get("openerId")
1473            .and_then(|v| v.as_str())
1474            .filter(|id| !id.is_empty())
1475            .map(str::to_string)
1476        {
1477            None => Opener::None,
1478            Some(id) if self.protected_targets.contains(&id) => Opener::Protected,
1479            Some(id) => match self.raw_call(
1480                "Target.getTargetInfo",
1481                json!({ "targetId": id }),
1482                None,
1483                Duration::from_secs(30),
1484            ) {
1485                Ok(found) => {
1486                    let opener_info = found.get("targetInfo").unwrap_or(&Value::Null);
1487                    let opener_url = opener_info.get("url").and_then(Value::as_str).unwrap_or("");
1488                    if self.guard.target_type(opener_info.get("type").and_then(Value::as_str).unwrap_or(""))
1489                        || self.guard.target(opener_url, false, Opener::Unknown)
1490                    {
1491                        Opener::Protected
1492                    } else {
1493                        Opener::Unprotected
1494                    }
1495                }
1496                Err(_) => Opener::Unknown,
1497            },
1498        };
1499        let protected = if self.documents.contains_key(target_id) {
1500            self.info_protected(&info, &[])
1501        } else {
1502            self.guard.target_type(info.get("type").and_then(Value::as_str).unwrap_or(""))
1503                || self.guard.target(&url, false, opener)
1504        };
1505        if protected && !self.documents.contains_key(target_id) {
1506            self.protect_target(target_id);
1507        }
1508        Ok(protected)
1509    }
1510
1511    fn create_target(&mut self, params: Value, timeout: Duration) -> Result<Value, String> {
1512        let url = params
1513            .get("url")
1514            .and_then(|v| v.as_str())
1515            .unwrap_or("about:blank")
1516            .to_string();
1517        let mut blank = params.clone();
1518        blank["url"] = json!("about:blank");
1519        let mark = self.events.len();
1520        let result = self.raw_call("Target.createTarget", blank, None, timeout)?;
1521        let target = result
1522            .get("targetId")
1523            .and_then(|v| v.as_str())
1524            .unwrap_or("")
1525            .to_string();
1526        if let Err(message) = self.touch(&target) {
1527            let closed = self
1528                .raw_call(
1529                    "Target.closeTarget",
1530                    json!({ "targetId": target }),
1531                    None,
1532                    Duration::from_secs(5),
1533                )
1534                .and_then(|result| {
1535                    if result.get("success").and_then(Value::as_bool) != Some(true) {
1536                        return Err("Target.closeTarget did not confirm success\n".into());
1537                    }
1538                    self.wait_destroyed(&target, mark, Duration::from_secs(2))
1539                });
1540            return Err(match closed {
1541                Ok(()) => message,
1542                Err(error) => format!("{message}slicc: refused: cleanup: {}\n", error.trim()),
1543            });
1544        }
1545        self.record_initial_blank(&target);
1546        if !url.eq_ignore_ascii_case("about:blank") {
1547            let guard = self.guards.get(&target).cloned();
1548            self.send("Page.navigate", json!({ "url": url }), guard.as_deref())?;
1549        }
1550        Ok(result)
1551    }
1552
1553    fn record_initial_blank(&mut self, target: &str) {
1554        if let Some(recorded) = self.initial_blank_evidence(target, true) {
1555            self.created_blanks.insert(target.to_string(), recorded);
1556            let _ = self.document_state_for(target, true, Some(""));
1557        }
1558    }
1559
1560    pub fn created_blank(&self, target: &str) -> Option<Value> {
1561        self.created_blanks.get(target).map(|(frame, loader, entry)| json!([frame, loader, entry]))
1562    }
1563
1564    pub fn restore_created_blank(&mut self, target: &str, proof: &Value) -> bool {
1565        let expected = proof.as_array().filter(|values| values.len() == 3).and_then(|values| {
1566            Some((values[0].as_str()?.to_string(), values[1].as_str()?.to_string(), values[2].as_i64()?))
1567        });
1568        let Some(expected) = expected else { return false };
1569        if self.initial_blank_evidence(target, false).as_ref() != Some(&expected) { return false }
1570        self.created_blanks.insert(target.to_string(), expected);
1571        true
1572    }
1573
1574    fn owns_blank(&self, target: &str, frame: &str, loader: &str) -> bool {
1575        self.created_blanks.get(target).is_some_and(|(id, version, _)| id == frame && version == loader)
1576            || self.navigated_blanks.get(target).is_some_and(|(guard, id, version)| self.guards.get(target) == Some(guard) && id == frame && version == loader)
1577    }
1578
1579    fn initial_blank_evidence(&mut self, target: &str, empty: bool) -> Option<(String, String, i64)> {
1580        let guard = self.guards.get(target)?.clone();
1581        let result = self.raw_call("Target.getTargetInfo", json!({"targetId":target}), None, DOCUMENT_CHECK).ok()?;
1582        let info = result.get("targetInfo")?;
1583        if info.get("targetId").and_then(Value::as_str) != Some(target)
1584            || info.get("type").and_then(Value::as_str) != Some("page")
1585            || info.get("url").and_then(Value::as_str) != Some("about:blank")
1586            || info.get("openerId").is_some() || info.get("openerFrameId").is_some() { return None }
1587        let history = self.raw_call("Page.getNavigationHistory", json!({}), Some(&guard), DOCUMENT_CHECK).ok()?;
1588        let entries = history.get("entries")?.as_array()?;
1589        if history.get("currentIndex").and_then(Value::as_i64) != Some(0) || entries.len() != 1
1590            || entries[0].get("url").and_then(Value::as_str) != Some("about:blank") { return None }
1591        let entry = entries[0].get("id")?.as_i64()?;
1592        let state = self.document_state(target, false)?;
1593        if empty && state.frames.len() != 1 { return None }
1594        let (id, evidence) = state.frames.iter().find(|(_, evidence)| evidence[4].is_empty())?;
1595        if evidence[1] != "about:blank" || !evidence[3].is_empty()
1596            || matches!(evidence[2].as_str(), "" | "null") || self.guard.url(&evidence[2]) { return None }
1597        Some((id.clone(), evidence[0].clone(), entry))
1598    }
1599
1600    fn wait_destroyed(&mut self, target: &str, mark: usize, timeout: Duration) -> Result<(), String> {
1601        let deadline = Instant::now() + timeout;
1602        let destroyed = |events: &[Value]| {
1603            events[mark..].iter().any(|event| {
1604                event.get("method").and_then(Value::as_str) == Some("Target.targetDestroyed")
1605                    && event.pointer("/params/targetId").and_then(Value::as_str) == Some(target)
1606            })
1607        };
1608        loop {
1609            if destroyed(&self.events) {
1610                return Ok(());
1611            }
1612            let left = deadline.saturating_duration_since(Instant::now());
1613            if left.is_zero() {
1614                return Err(
1615                    "Target.targetDestroyed timed out and Target.getTargets did not confirm closure\n".into()
1616                );
1617            }
1618            let listed = self.raw_call("Target.getTargets", json!({}), None, left);
1619            let absent = listed
1620                .as_ref()
1621                .ok()
1622                .and_then(|result| result.get("targetInfos"))
1623                .and_then(Value::as_array)
1624                .is_some_and(|infos| {
1625                    infos.iter().all(|info| info.get("targetId").and_then(Value::as_str).is_some())
1626                        && !infos.iter().any(|info| info.get("targetId").and_then(Value::as_str) == Some(target))
1627                });
1628            if destroyed(&self.events) || absent {
1629                return Ok(());
1630            }
1631            let left = deadline.saturating_duration_since(Instant::now());
1632            self.pump(Instant::now() + left.min(Duration::from_millis(100)))?;
1633        }
1634    }
1635
1636    fn touch(&mut self, target_id: &str) -> Result<(), String> {
1637        if target_id.is_empty() || self.guards.contains_key(target_id) {
1638            return Ok(());
1639        }
1640        let kind = match self.targets.get(target_id) {
1641            Some(info) => info.get("type").and_then(|v| v.as_str()).map(str::to_string),
1642            None => self
1643                .raw_call(
1644                    "Target.getTargetInfo",
1645                    json!({ "targetId": target_id }),
1646                    None,
1647                    Duration::from_secs(10),
1648                )
1649                .ok()
1650                .and_then(|info| {
1651                    info.pointer("/targetInfo/type")
1652                        .and_then(|v| v.as_str())
1653                        .map(str::to_string)
1654                }),
1655        };
1656        if kind
1657            .as_deref()
1658            .is_some_and(|kind| !matches!(kind, "page" | "iframe" | "background_page" | "webview" | "other"))
1659        {
1660            return Ok(());
1661        }
1662        let budget = Instant::now() + GUARD_SETUP;
1663        if !self.discovering {
1664            self.raw_call(
1665                "Target.setDiscoverTargets",
1666                json!({ "discover": true }),
1667                None,
1668                GUARD_SETUP,
1669            )
1670            .map_err(unguarded)?;
1671            self.discovering = true;
1672        }
1673        self.guarding.insert(target_id.to_string());
1674        let left = budget.saturating_duration_since(Instant::now());
1675        let attached = if left.is_zero() {
1676            Err("Target.attachToTarget timed out\n".to_string())
1677        } else {
1678            self.raw_call(
1679                "Target.attachToTarget",
1680                json!({ "targetId": target_id, "flatten": true }),
1681                None,
1682                left,
1683            )
1684        };
1685        self.guarding.remove(target_id);
1686        let session = match attached
1687            .map(|value| value.get("sessionId").and_then(|v| v.as_str()).map(str::to_string))
1688        {
1689            Ok(Some(session)) => session,
1690            Ok(None) => {
1691                self.abandon(target_id);
1692                return Err(UNGUARDED.to_string());
1693            }
1694            Err(message) => {
1695                self.abandon(target_id);
1696                return Err(unguarded(message));
1697            }
1698        };
1699        self.guard_sessions.insert(session.clone());
1700        self.guards.insert(target_id.to_string(), session.clone());
1701        let left = budget.saturating_duration_since(Instant::now());
1702        let inspected = if left.is_zero() { Err("Inspector.enable timed out\n".to_string()) }
1703            else { self.raw_call("Inspector.enable", json!({}), Some(&session), left) };
1704        if let Err(message) = inspected {
1705            return Err(self.guard_failure(target_id, &session, message));
1706        }
1707        let setup = [
1708            (
1709                "Fetch.enable",
1710                json!({ "patterns": [{ "urlPattern": "*", "resourceType": "Document", "requestStage": "Request" }] }),
1711            ),
1712            ("Network.enable", json!({})),
1713            ("Network.setBypassServiceWorker", json!({ "bypass": true })),
1714            ("Page.enable", json!({})),
1715            ("Runtime.enable", json!({})),
1716        ];
1717        for (method, params) in setup {
1718            if method == "Runtime.enable" {
1719                let _ = self.document_state_for(target_id, false, Some(""));
1720                let _ = self.document_state_for(target_id, true, Some(""));
1721            }
1722            let optional = matches!(method, "Page.enable" | "Runtime.enable");
1723            let left = budget.saturating_duration_since(Instant::now());
1724            let span = if optional { left.min(OPTIONAL_STEP) } else { left };
1725            let answer = if span.is_zero() {
1726                Err(format!("{method} timed out\n"))
1727            } else {
1728                self.raw_call(method, params, Some(&session), span)
1729            };
1730            if method == "Page.enable" && answer.is_err() {
1731                self.unobserved.insert(target_id.to_string());
1732            }
1733            let failed = match answer {
1734                Ok(_) => None,
1735                Err(message) if optional && message == format!("{method} timed out\n") => {
1736                    if self
1737                        .unguarded_optional
1738                        .entry(target_id.to_string())
1739                        .or_default()
1740                        .insert(method.to_string())
1741                    {
1742                        self.notices
1743                            .push(format!("slicc: guard: {method} timed out; continuing\n"));
1744                    }
1745                    None
1746                }
1747                Err(message) if optional && message.starts_with(&format!("{method}:")) => None,
1748                Err(message) => Some(message),
1749            };
1750            if let Some(message) = failed {
1751                return Err(self.guard_failure(target_id, &session, message));
1752            }
1753        }
1754        if self.documents.get(target_id).is_none_or(|doc| doc.probed) {
1755            let left = budget.saturating_duration_since(Instant::now());
1756            if !left.is_zero() {
1757                let epoch = self.frame_epoch;
1758                if let Ok(tree) = self.raw_exchange("Page.getFrameTree", json!({}), Some(&session), left.min(OPTIONAL_STEP), false) {
1759                    if self.tree_current(&tree, epoch) {
1760                        self.record_frames(&tree, &session);
1761                    }
1762                    if self.documents.get(target_id).is_none_or(|doc| doc.probed) {
1763                        if let Some(frame) = tree.pointer("/frameTree/frame").filter(|frame| self.main_frame_current(frame, epoch)) {
1764                            self.main_commit(&session, frame);
1765                        }
1766                    }
1767                }
1768            }
1769        }
1770        Ok(())
1771    }
1772
1773    fn guard_failure(&mut self, target: &str, session: &str, message: String) -> String {
1774        self.guards.remove(target);
1775        self.abandon(target);
1776        let _ = self.raw_call("Target.detachFromTarget", json!({ "sessionId": session }), None, Duration::from_secs(5));
1777        unguarded(message)
1778    }
1779
1780    fn abandon(&mut self, target_id: &str) {
1781        self.abandoned.insert(target_id.to_string());
1782        self.protect_target(target_id);
1783    }
1784
1785    fn adopt_guard_attach(&mut self, value: &Value) {
1786        if value.get("method").and_then(|v| v.as_str()) != Some("Target.attachedToTarget") {
1787            return;
1788        }
1789        let target = value
1790            .pointer("/params/targetInfo/targetId")
1791            .and_then(|v| v.as_str())
1792            .unwrap_or("");
1793        let Some(session) = value.pointer("/params/sessionId").and_then(|v| v.as_str()) else {
1794            return;
1795        };
1796        if self.guarding.contains(target) {
1797            self.guard_sessions.insert(session.to_string());
1798        } else if self.abandoned.contains(target) {
1799            self.guard_sessions.insert(session.to_string());
1800            let _ = self.send(
1801                "Target.detachFromTarget",
1802                json!({ "sessionId": session }),
1803                None,
1804            );
1805        }
1806    }
1807
1808    fn block(&mut self, session_id: &str) {
1809        let newly_blocked = self.blocked.insert(session_id.to_string());
1810        if self.webmcp_is_paused(session_id) {
1811            self.webmcp.deferred.insert(session_id.to_string());
1812            self.webmcp_revoke(session_id);
1813            return;
1814        }
1815        if !newly_blocked && !self.webmcp.deferred.remove(session_id) {
1816            return;
1817        }
1818        self.webmcp_revoke(session_id);
1819        if let Some(target) = self.session_targets.get(session_id) {
1820            self.protected_targets.insert(target.clone());
1821        }
1822        let queued: Vec<Paused> = self.paused.drain(..).collect();
1823        for paused in queued {
1824            if paused.session_id == session_id {
1825                self.answer_protected(&paused);
1826            } else {
1827                self.paused.push_back(paused);
1828            }
1829        }
1830        if !self.executing_sessions.contains(session_id) { self.detach_blocked(session_id); }
1831    }
1832
1833    fn detach_blocked(&mut self, session_id: &str) {
1834        let _ = self.send("Fetch.disable", json!({}), Some(session_id));
1835        let _ = self.send(
1836            "Target.detachFromTarget",
1837            json!({ "sessionId": session_id }),
1838            None,
1839        );
1840    }
1841
1842    fn session_target(&self, session_id: &str) -> Option<String> {
1843        self.session_targets.get(session_id).cloned().or_else(|| {
1844            self.guards
1845                .iter()
1846                .find(|(_, guard)| guard.as_str() == session_id)
1847                .map(|(target, _)| target.clone())
1848        })
1849    }
1850
1851    fn main_commit(&mut self, session: &str, frame: &Value) {
1852        let Some(target) = self.session_target(session) else { return };
1853        if self.guards.get(&target).is_some_and(|guard| guard != session) && !self.unobserved.contains(&target) {
1854            if self.guard.url(frame.get("url").and_then(Value::as_str).unwrap_or(""))
1855                || self.guard.url(frame.get("securityOrigin").and_then(Value::as_str).unwrap_or(""))
1856            {
1857                self.committed_protected(session);
1858            }
1859            return;
1860        }
1861        let Some(frame_id) = frame.get("id").and_then(Value::as_str) else { return };
1862        if let Some(loader) = frame.get("loaderId").and_then(Value::as_str).filter(|loader| !loader.is_empty()) {
1863            self.document_versions.insert((target.clone(), frame_id.to_string()), loader.to_string());
1864        }
1865        let url = format!("{}{}", frame.get("url").and_then(Value::as_str).unwrap_or(""), frame.get("urlFragment").and_then(Value::as_str).unwrap_or(""));
1866        let origin = frame.get("securityOrigin").and_then(Value::as_str).unwrap_or("").to_string();
1867        let unreachable_url = frame.get("unreachableUrl").and_then(Value::as_str).unwrap_or("").to_string();
1868        let reported = self.targets.get(&target).and_then(|info| info.get("url")).and_then(Value::as_str);
1869        let unknown = unreachable_url.is_empty() && reported.is_none_or(|reported| self.guard.url(reported));
1870        let previous = self.documents.get(&target).map(|doc| doc.previous).unwrap_or_else(|| {
1871            self.protected_targets.contains(&target) || unknown || self.guard.url(&unreachable_url)
1872        });
1873        let error = self.guard.error_page_url(&url) || !unreachable_url.is_empty();
1874        let protected = self.targets.get(&target).is_some_and(|info| self.guard.target_type(info.get("type").and_then(Value::as_str).unwrap_or(""))) || if error {
1875            previous || unknown || !self.guard.error_document(&url, &origin)
1876        } else {
1877            self.guard.target(&url, self.protected_targets.contains(&target), Opener::None) || self.guard.url(&origin)
1878        };
1879        self.documents.insert(target.clone(), MainDocument {
1880            frame_id: frame_id.to_string(), url, origin, unreachable_url, navigating: false, navigation_url: String::new(), previous: protected, probed: false,
1881        });
1882        if protected {
1883            self.protect_target(&target);
1884        } else if !self.abandoned.contains(&target) {
1885            self.protected_targets.remove(&target);
1886        }
1887    }
1888
1889    pub fn recovering(&self, target_id: &str) -> bool {
1890        self.documents.get(target_id).is_some_and(|doc| doc.probed && !doc.previous)
1891    }
1892
1893    fn document_state(&mut self, target: &str, content: bool) -> Option<DocumentState> {
1894        self.document_state_for(target, content, None)
1895    }
1896
1897    fn document_state_for(&mut self, target: &str, content: bool, scope: Option<&str>) -> Option<DocumentState> {
1898        let Some(guard) = self.guards.get(target).cloned() else {
1899            if self.protected_targets.contains(target) { return None }
1900            let result = self.raw_call("Target.getTargetInfo", json!({"targetId":target}), None, DOCUMENT_CHECK).ok()?;
1901            let info = result.get("targetInfo")?;
1902            let text = |key| info.get(key).and_then(Value::as_str).unwrap_or("");
1903            if text("targetId") != target || !matches!(text("type"), "worker" | "service_worker" | "shared_worker")
1904                || url::Url::parse(text("url")).is_err() || self.guard.target(text("url"), false, Opener::Unknown) { return None }
1905            let owner = text("openerId");
1906            if !self.guards.contains_key(owner) { return None }
1907            let mut state = self.document_state(owner, true)?;
1908            state.worker = Some((text("url").to_string(), owner.to_string()));
1909            return Some(state);
1910        };
1911        #[cfg(test)]
1912        self.runtime_prepare_probe("private-document-read", json!({}));
1913        let tree = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), DOCUMENT_CHECK).ok()?;
1914        if content { self.verify_document_order(&tree, Some(&guard)).ok()?; }
1915        #[cfg(test)]
1916        self.runtime_prepare_probe("private-document-stability", json!({"guard_stable":self.guards.get(target) == Some(&guard)}));
1917        if self.guards.get(target) != Some(&guard) { return None }
1918        let epoch = self.document_epochs.get(target).copied().unwrap_or(0);
1919        #[cfg(test)]
1920        self.runtime_prepare_probe("private-selected-placement", json!({"tree_present":tree.get("frameTree").is_some()}));
1921        let node = tree.get("frameTree")?;
1922        let scoped;
1923        let node = if let Some(id) = scope {
1924            scoped = Self::scoped_document_tree(node, id)?;
1925            &scoped
1926        } else { node };
1927        let frame = node.get("frame")?;
1928        let parent = frame.get("parentId").and_then(Value::as_str);
1929        let mut frames = BTreeMap::new();
1930        #[cfg(test)]
1931        self.runtime_prepare_probe("private-parent-placement", json!({"parent_present":parent.is_some()}));
1932        if let Some(parent) = parent {
1933            let info = self.raw_call("Target.getTargetInfo", json!({"targetId":target}), None, DOCUMENT_CHECK).ok()?;
1934            let info = info.get("targetInfo")?;
1935            if info.get("targetId").and_then(Value::as_str) != Some(target) || info.get("type").and_then(Value::as_str) != Some("iframe")
1936                || info.get("parentFrameId").and_then(Value::as_str) != Some(parent) { return None }
1937            let owner = self.guards.keys().find(|owner| owner.as_str() != target && self.targets.get(*owner).and_then(|info| info.get("type")).and_then(Value::as_str) == Some("page")
1938                && self.documents.get(*owner).is_some_and(|doc| doc.frame_id == parent)).cloned()?;
1939            let parent_guard = self.guards.get(&owner)?.clone();
1940            let parent_tree = self.raw_call("Page.getFrameTree", json!({}), Some(&parent_guard), DOCUMENT_CHECK).ok()?;
1941            let parent_frame = parent_tree.pointer("/frameTree/frame")?;
1942            if parent_frame.get("id").and_then(Value::as_str) != Some(parent) || parent_frame.get("parentId").is_some() { return None }
1943            frames = self.document_state_for(&owner, true, Some(parent))?.frames;
1944        }
1945        if !self.collect_document_frames(node, &mut frames, content, parent) {
1946            #[cfg(test)]
1947            self.runtime_prepare_frames(node, &frames);
1948            return None;
1949        }
1950        #[cfg(test)]
1951        self.runtime_prepare_probe("private-loader-provenance", json!({"loader_regressed":content && frames.iter().any(|(id, evidence)| evidence[0].is_empty() && self.document_versions.contains_key(&(target.to_string(), id.clone())))}));
1952        if content && frames.iter().any(|(id, evidence)| evidence[0].is_empty() && self.document_versions.contains_key(&(target.to_string(), id.clone()))) { return None }
1953        if content && self.guard.inheriting(frame.get("url").and_then(Value::as_str).unwrap_or(""))
1954            && frame.get("securityOrigin").and_then(Value::as_str) == Some("://")
1955        {
1956            let id = frame.get("id").and_then(Value::as_str)?;
1957            let loader = frame.get("loaderId").and_then(Value::as_str)?;
1958            #[cfg(test)]
1959            self.runtime_prepare_probe("private-blank-provenance", json!({"blank_url":frame.get("url").and_then(Value::as_str) == Some("about:blank"),"owned_blank":self.owns_blank(target, id, loader)}));
1960            if frame.get("url").and_then(Value::as_str) != Some("about:blank") || !self.owns_blank(target, id, loader) { return None }
1961            if self.navigated_blanks.contains_key(target) {
1962                let info = self.raw_call("Target.getTargetInfo", json!({"targetId":target}), None, DOCUMENT_CHECK).ok()?;
1963                let info = info.get("targetInfo")?;
1964                if info.get("targetId").and_then(Value::as_str) != Some(target) || info.get("type").and_then(Value::as_str) != Some("page")
1965                    || info.get("url").and_then(Value::as_str) != Some("about:blank") || info.get("openerId").is_some() || info.get("openerFrameId").is_some()
1966                    || frame.get("parentId").is_some() || self.guards.get(target) != Some(&guard) || !self.owns_blank(target, id, loader) { return None }
1967            }
1968        }
1969        self.main_commit(&guard, frame);
1970        #[cfg(test)]
1971        self.runtime_prepare_probe("private-document-origin", json!({"target_protected":self.protected_targets.contains(target)}));
1972        if self.protected_targets.contains(target) { return None }
1973        for (id, evidence) in &frames {
1974            self.frames.insert(id.clone(), vec![evidence[1].clone(), evidence[2].clone()]);
1975            self.note_parent(id, &evidence[4]);
1976            if content { self.frame_decisions.insert(id.clone(), false); }
1977            if !evidence[0].is_empty() {
1978                self.document_versions.insert((target.to_string(), id.clone()), evidence[0].clone());
1979            }
1980        }
1981        let scope = if parent.is_some() && scope == Some("") { frame.get("id").and_then(Value::as_str) } else { scope };
1982        let state = DocumentState { frames, worker:None, scope:scope.map(str::to_string), epoch, page_scope:None, runtime_scope_epoch:None };
1983        if content { self.cached_documents.insert(target.to_string(), state.clone()); }
1984        Some(state)
1985    }
1986
1987    fn scoped_document_tree(node: &Value, id: &str) -> Option<Value> {
1988        let frame = node.get("frame")?;
1989        if id.is_empty() || frame.get("id").and_then(Value::as_str) == Some(id) { return Some(json!({"frame":frame})) }
1990        node.get("childFrames")?.as_array()?.iter().find_map(|child| Self::scoped_document_tree(child, id))
1991            .map(|child| json!({"frame":frame,"childFrames":[child]}))
1992    }
1993
1994    fn collect_document_frames(&self, node: &Value, frames: &mut BTreeMap<String, Vec<String>>, content: bool, parent: Option<&str>) -> bool {
1995        let Some(frame) = node.get("frame") else { return false };
1996        let origin = self.page_scope_origin(frame);
1997        let text = |key| if key == "securityOrigin" { origin.unwrap_or_else(|| frame.get(key).and_then(Value::as_str).unwrap_or("")) } else { frame.get(key).and_then(Value::as_str).unwrap_or("") };
1998        let root = parent.is_none();
1999        if ["id", "url"].iter().any(|key| matches!(text(*key), "" | "null" | "://"))
2000            || frame.get("parentId").is_some_and(|id| id.as_str() != parent) { return false }
2001        let initial_blank = text("url") == "about:blank" && frame.get("loaderId").and_then(Value::as_str) == Some("")
2002            && (root || parent.and_then(|id| frames.get(id)).is_some_and(|evidence| !self.guard.url(&evidence[1]) && !self.guard.url(&evidence[2])));
2003        let parent_allowed = !root && parent.and_then(|id| frames.get(id)).is_some_and(|evidence| !self.guard.url(&evidence[1]) && !self.guard.url(&evidence[2]));
2004        let inherited = parent_allowed && self.guard.inheriting(text("url")) && !matches!(text("securityOrigin"), "" | "null" | "://") && self.frame_decisions.get(text("id")) != Some(&true);
2005        let allowed_error = !root && self.guard.error_document(text("url"), text("securityOrigin"))
2006            && !text("unreachableUrl").is_empty() && self.frame_decisions.get(text("id")) == Some(&false);
2007        let data = (root || parent_allowed) && self.guard.data_document(text("url"));
2008        let opaque = self.guard.opaque_document(text("url"), root) || !root && initial_blank || inherited || allowed_error || data;
2009        if content && (text("loaderId") == "null" || text("loaderId").is_empty() && !initial_blank
2010            || matches!(text("securityOrigin"), "" | "null") || text("securityOrigin") == "://" && !opaque
2011            || !root && !allowed_error && (self.guard.url(text("url")) || self.guard.url(text("securityOrigin")))
2012            || self.unverified_error(text("url"), text("unreachableUrl"))) { return false }
2013        let evidence = vec![text("loaderId").to_string(), format!("{}{}", text("url"), text("urlFragment")), text("securityOrigin").to_string(), text("unreachableUrl").to_string(), parent.unwrap_or("").to_string()];
2014        if frames.insert(text("id").to_string(), evidence).is_some() { return false }
2015        match node.get("childFrames") {
2016            None => true,
2017            Some(children) => children.as_array().is_some_and(|children| children.iter().all(|child| self.collect_document_frames(child, frames, content, Some(text("id"))))),
2018        }
2019    }
2020
2021    fn refresh(&mut self, target: &str) -> bool {
2022        let Some(doc) = self.documents.get(target) else { return true };
2023        if self.document_paused(target) { return true }
2024        if doc.navigating || doc.probed || !(self.guard.error_page_url(&doc.url) || !doc.unreachable_url.is_empty()) {
2025            return true;
2026        }
2027        let Some(guard) = self.guards.get(target).cloned() else { return true };
2028        let frame = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), OPTIONAL_STEP).ok().and_then(|tree| tree.pointer("/frameTree/frame").cloned());
2029        match frame {
2030            Some(frame) if self.guards.get(target) == Some(&guard) => {
2031                self.main_commit(&guard, &frame);
2032                true
2033            }
2034            _ => false,
2035        }
2036    }
2037
2038    fn probe_candidate(&self, info: &Value) -> bool {
2039        let text = |key: &str| info.get(key).and_then(Value::as_str).unwrap_or("");
2040        let target = text("targetId");
2041        !target.is_empty() && text("type") == "page" && text("openerId").is_empty() && (self.guard.url(text("url")) || self.guard.error_page_url(text("url"))) && !self.guard.unparseable(text("url"))
2042            && !self.documents.contains_key(target) && !self.abandoned.contains(target) && !self.guards.contains_key(target)
2043    }
2044
2045    fn probe_error(&mut self, info: &Value) {
2046        let text = |value: &Value, key: &str| value.get(key).and_then(Value::as_str).unwrap_or("").to_string();
2047        let target = text(info, "targetId");
2048        let url = text(info, "url");
2049        if !self.probe_candidate(info) || self.protected_targets.contains(&target) {
2050            return;
2051        }
2052        self.probing.insert(target.clone());
2053        self.guarding.insert(target.clone());
2054        let attached = self.raw_call("Target.attachToTarget", json!({ "targetId": target, "flatten": true }), None, OPTIONAL_STEP);
2055        self.guarding.remove(&target);
2056        let Some(session) = attached.ok().map(|value| text(&value, "sessionId")).filter(|session| !session.is_empty()) else {
2057            self.probing.remove(&target);
2058            return;
2059        };
2060        self.guard_sessions.insert(session.clone());
2061        let frame = self.raw_call("Page.getFrameTree", json!({}), Some(&session), OPTIONAL_STEP).ok()
2062            .and_then(|tree| tree.pointer("/frameTree/frame").cloned())
2063            .filter(|frame| self.guard.error_document(&text(frame, "url"), &text(frame, "securityOrigin"))
2064                && (self.reported_unreachable(&text(frame, "unreachableUrl"), &url) || self.guard.error_page_url(&url) && text(frame, "url") == url));
2065        let history = match frame {
2066            Some(_) => self.raw_call("Page.getNavigationHistory", json!({}), Some(&session), OPTIONAL_STEP).ok(),
2067            None => None,
2068        };
2069        let _ = self.raw_call("Target.detachFromTarget", json!({ "sessionId": session }), None, OPTIONAL_STEP);
2070        self.probing.remove(&target);
2071        let (Some(frame), Some(history)) = (frame, history) else { return };
2072        let unreachable_url = text(&frame, "unreachableUrl");
2073        if self.documents.contains_key(&target) || !self.ordinary_history(&history, &unreachable_url) {
2074            return;
2075        }
2076        self.documents.insert(target, MainDocument {
2077            frame_id: text(&frame, "id"), url: text(&frame, "url"), origin: text(&frame, "securityOrigin"), unreachable_url,
2078            navigating: false, navigation_url: String::new(), previous: false, probed: true,
2079        });
2080    }
2081
2082    fn reported_unreachable(&self, unreachable: &str, reported: &str) -> bool {
2083        !unreachable.is_empty()
2084            && (reported == unreachable
2085                || url::Url::parse(unreachable).is_ok_and(|parsed| {
2086                    let origin = parsed.origin().ascii_serialization();
2087                    reported == origin || reported.strip_suffix('/') == Some(origin.as_str())
2088                }))
2089    }
2090
2091    fn ordinary_history(&self, history: &Value, url: &str) -> bool {
2092        let entries = history.get("entries").and_then(Value::as_array).cloned().unwrap_or_default();
2093        let current = history.get("currentIndex").and_then(Value::as_u64).and_then(|index| usize::try_from(index).ok()).unwrap_or(0);
2094        current >= 1
2095            && entries.get(current).is_some_and(|entry| {
2096                self.reported_unreachable(url, entry.get("url").and_then(Value::as_str).unwrap_or("")) && entry.get("transitionType").and_then(Value::as_str) != Some("reload")
2097            })
2098            && entries.iter().enumerate().all(|(index, entry)| {
2099                index == current || ["url", "userTypedURL"].iter().all(|key| !self.guard.url(entry.get(*key).and_then(Value::as_str).unwrap_or("")))
2100            })
2101    }
2102
2103    fn sealed(&self, doc: &MainDocument) -> bool {
2104        (self.guard.error_page_url(&doc.url) || !doc.unreachable_url.is_empty())
2105            && (doc.unreachable_url.is_empty() || self.guard.url(&doc.unreachable_url))
2106    }
2107
2108    fn masked_error(&self, doc: &MainDocument, reported: &str) -> bool {
2109        doc.unreachable_url.is_empty()
2110            && self.guard.error_document(&doc.url, &doc.origin)
2111            && !self.guard.url(reported)
2112    }
2113
2114    fn committed_protected(&mut self, session_id: &str) {
2115        match self.session_target(session_id) {
2116            Some(target) => self.protect_target(&target),
2117            None => self.block(session_id),
2118        }
2119        if !self.guard_sessions.contains(session_id) {
2120            self.block(session_id);
2121        }
2122    }
2123
2124    fn frame_tree_refused(&mut self, session_id: Option<&str>) -> Result<bool, String> {
2125        let Some(session) = session_id else {
2126            return Ok(true);
2127        };
2128        let tree = match self.raw_call("Page.getFrameTree", json!({}), Some(session), Duration::from_secs(10)) {
2129            Ok(tree) => tree,
2130            Err(message) if transport_error(&message) => return Err(message),
2131            Err(_) => return Ok(true),
2132        };
2133        self.verify_page_scope(Some(session))?;
2134        Ok(tree.get("frameTree").is_none_or(|node| self.tree_node_refused(node)))
2135    }
2136
2137    fn tree_node_refused(&self, node: &Value) -> bool {
2138        let frame = node.get("frame").unwrap_or(&Value::Null);
2139        let unknown = ["id", "url", "securityOrigin"].iter().any(|key| {
2140            let text = if *key == "securityOrigin" { self.page_scope_origin(frame).or_else(|| frame.get(*key).and_then(Value::as_str)) } else { frame.get(*key).and_then(Value::as_str) };
2141            text.is_none_or(|text| !frame_text_known(text))
2142        });
2143        unknown || self.frame_protected(frame.get("id").and_then(Value::as_str).unwrap_or(""))
2144            || node.get("childFrames").is_some_and(|children| children.as_array().is_none_or(|children| children.iter().any(|child| self.tree_node_refused(child))))
2145    }
2146
2147    fn frame_refused(&mut self, frame_id: &str, session_id: Option<&str>) -> Result<bool, String> {
2148        if self.frames.get(frame_id).is_some_and(|known| known.first().is_some_and(|url| self.guard.inheriting(url)) && !self.frame_verified(frame_id)) {
2149            self.verify_page_scope(session_id)?;
2150        }
2151        let known = self.frames.contains_key(frame_id);
2152        if self.frame_protected(frame_id) || known && !self.frame_verified(frame_id) {
2153            return Ok(true);
2154        }
2155        let other_target = session_id.and_then(|session| self.session_target(session)).is_some_and(|target| target != frame_id);
2156        let target_allowed = match self.judge_target(frame_id, false, !other_target) {
2157            Ok(true) => return Ok(true),
2158            Ok(false) => self.targets.get(frame_id).is_some_and(|info| info.get("targetId").and_then(Value::as_str) == Some(frame_id)
2159                && info.get("url").and_then(Value::as_str).is_some_and(frame_text_known)),
2160            Err(message) if transport_error(&message) => return Err(message),
2161            Err(_) => false,
2162        };
2163        let Some(session) = session_id else {
2164            return Ok(!target_allowed);
2165        };
2166        match self.raw_call("Page.getFrameTree", json!({}), Some(session), Duration::from_secs(10)) {
2167            Ok(_) => Ok(!self.frame_sessions.get(session).is_some_and(|frames| frames.contains(frame_id)) || !self.frame_verified(frame_id)),
2168            Err(message) if transport_error(&message) => Err(message),
2169            Err(message) if message.contains("slicc: refused:") => Ok(true),
2170            Err(_) => Ok(known || !target_allowed),
2171        }
2172    }
2173
2174    fn frame_verified(&self, frame_id: &str) -> bool {
2175        self.frame_chain_verified(frame_id, 0)
2176    }
2177
2178    fn frame_chain_verified(&self, frame_id: &str, depth: usize) -> bool {
2179        let Some(known) = self.frames.get(frame_id) else { return false };
2180        if depth > self.frames.len() || !frame_text_known(frame_id) || self.protected_targets.contains(frame_id)
2181            || known.len() < 2 || known.iter().any(|text| self.guard.url(text)) { return false }
2182        if known.iter().all(|text| frame_text_known(text)) { return true }
2183        frame_text_known(&known[0]) && self.guard.data_document(&known[0]) && known[1..].iter().all(|text| text == "://" || frame_text_known(text))
2184            && self.frame_parents.get(frame_id).is_none_or(|parent| self.frame_chain_verified(parent, depth + 1))
2185    }
2186
2187    fn tree_current(&self, tree: &Value, epoch: u64) -> bool {
2188        epoch == self.frame_epoch || self.tree_matches_updates(tree, epoch)
2189    }
2190
2191    fn main_frame_current(&self, frame: &Value, epoch: u64) -> bool {
2192        frame.get("id").and_then(Value::as_str).is_some_and(|id| self.frame_versions.get(id).is_none_or(|version| *version <= epoch))
2193    }
2194
2195    fn tree_matches_updates(&self, tree: &Value, epoch: u64) -> bool {
2196        let mut frames = BTreeMap::new();
2197        if let Some(node) = tree.get("frameTree") {
2198            Self::collect_frames(node, &mut frames);
2199        }
2200        let updates = self.frame_versions.iter().filter(|(_, version)| **version > epoch).collect::<Vec<_>>();
2201        !updates.is_empty() && updates.iter().all(|(id, _)| self.frames.get(*id).is_some_and(|known| {
2202            frames.get(*id).is_some_and(|fresh| known.get(..2) == fresh.get(..2)
2203                && known.iter().skip(2).all(|origin| Some(origin) == fresh.get(1)))
2204        }))
2205    }
2206
2207    fn record_frames(&mut self, tree: &Value, session: &str) {
2208        let mut frames = BTreeMap::new();
2209        if let Some(node) = tree.get("frameTree") {
2210            Self::collect_frames(node, &mut frames);
2211            let mut queue = vec![(node, None::<&str>)];
2212            while let Some((node, parent)) = queue.pop() {
2213                if let Some(frame) = node.get("frame") {
2214                    if let Some(id) = frame.get("id").and_then(Value::as_str) {
2215                        if let Some(known) = frames.get_mut(id) {
2216                            if let Some(origin) = self.page_scope_origin(frame) { known[1] = origin.to_string(); }
2217                            if let Some(parent) = parent { if frame.get("parentId").is_none() { known[2] = parent.to_string(); } }
2218                        }
2219                        for child in node.get("childFrames").and_then(Value::as_array).into_iter().flatten() { queue.push((child, Some(id))); }
2220                    }
2221                }
2222            }
2223        }
2224        let ids = frames.keys().cloned().collect::<BTreeSet<_>>();
2225        for id in self.frame_sessions.insert(session.to_string(), ids.clone()).unwrap_or_default().difference(&ids) {
2226            self.frames.remove(id);
2227            self.frame_parents.remove(id);
2228        }
2229        for (id, mut evidence) in frames {
2230            let parent = evidence.pop().unwrap_or_default();
2231            self.note_parent(&id, &parent);
2232            self.frames.insert(id, evidence);
2233        }
2234    }
2235
2236    fn note_parent(&mut self, frame_id: &str, parent: &str) {
2237        if parent.is_empty() { self.frame_parents.remove(frame_id); } else { self.frame_parents.insert(frame_id.to_string(), parent.to_string()); }
2238    }
2239
2240    fn collect_frames(node: &Value, frames: &mut BTreeMap<String, Vec<String>>) {
2241        let frame = node.get("frame").unwrap_or(&Value::Null);
2242        let text = |key: &str| frame.get(key).and_then(Value::as_str).unwrap_or("");
2243        if !text("id").is_empty() {
2244            let url = format!("{}{}", text("url"), text("urlFragment"));
2245            frames.insert(text("id").to_string(), vec![url, text("securityOrigin").to_string(), text("parentId").to_string()]);
2246        }
2247        for child in node.get("childFrames").and_then(Value::as_array).into_iter().flatten() {
2248            Self::collect_frames(child, frames);
2249        }
2250    }
2251
2252    fn frame_protected(&self, frame_id: &str) -> bool {
2253        if frame_id.is_empty() {
2254            return false;
2255        }
2256        self.protected_targets.contains(frame_id)
2257            || self
2258                .frames
2259                .get(frame_id)
2260                .is_some_and(|known| known.iter().any(|url| self.guard.url(url)))
2261    }
2262
2263    fn initiated_by_protected(&self, paused: &Paused) -> bool {
2264        if self.frame_protected(&paused.frame_id) {
2265            return true;
2266        }
2267        if self
2268            .session_target(&paused.session_id)
2269            .is_some_and(|target| self.protected_targets.contains(&target))
2270        {
2271            return true;
2272        }
2273        ["referer", "origin"].iter().any(|name| {
2274            paused
2275                .header(name)
2276                .is_some_and(|value| value != "null" && self.guard.url(value))
2277        })
2278    }
2279
2280    fn protect_target(&mut self, target_id: &str) {
2281        self.protected_targets.insert(target_id.to_string());
2282        if let Some(doc) = self.documents.get_mut(target_id) {
2283            doc.previous = true;
2284        }
2285        let sessions: Vec<String> = self
2286            .session_targets
2287            .iter()
2288            .filter(|(_, have)| have.as_str() == target_id)
2289            .map(|(session, _)| session.clone())
2290            .collect();
2291        for session in sessions {
2292            self.block(&session);
2293        }
2294    }
2295
2296    fn answer_protected(&mut self, paused: &Paused) {
2297        self.fail_seen_network(&json!({"sessionId":paused.session_id,"params":{"networkId":paused.network_id,"requestId":paused.request_id}}));
2298        self.settle(&paused.session_id, &paused.request_id);
2299        if paused.resource_type == "Document" {
2300            let _ = self.send(
2301                "Fetch.failRequest",
2302                json!({ "requestId": paused.request_id, "errorReason": "BlockedByClient" }),
2303                Some(&paused.session_id),
2304            );
2305        } else {
2306            self.continue_plain(&paused.session_id, &paused.request_id);
2307        }
2308    }
2309
2310    fn continue_plain(&mut self, session_id: &str, request_id: &str) {
2311        self.settle(session_id, request_id);
2312        let _ = self.send(
2313            "Fetch.continueRequest",
2314            json!({ "requestId": request_id }),
2315            Some(session_id),
2316        );
2317    }
2318
2319    fn content_refused(&self, method: &str, params: &Value, session: &str) -> bool {
2320        if shield::body_method(method) && shield::request_key(params).is_some_and(|id| self.protected_body_requests.contains(&id)) { return true }
2321        if shield::body_method(method) && shield::request_key(params).is_some_and(|id| self.request_state(&id) == Some(true)) {
2322            return true;
2323        }
2324        if matches!(method, "Page.getResourceContent" | "Page.searchInResource") {
2325            return self.resource_protected(session, params.get("url").and_then(Value::as_str).unwrap_or(""));
2326        }
2327        if matches!(method, "CSS.getStyleSheetText" | "Debugger.getScriptSource" | "Debugger.getWasmBytecode" | "Debugger.searchInContent" | "Debugger.disassembleWasmModule") {
2328            let id = params.get("styleSheetId").or_else(|| params.get("scriptId")).and_then(Value::as_str).unwrap_or("");
2329            return self.source_resources.get(&(session.to_string(), id.to_string())).is_none_or(|url| self.resource_protected(session, url));
2330        }
2331        false
2332    }
2333
2334    fn protect_resource(&mut self, session: &str, url: &str) {
2335        let Some(url) = self.guard.resource_key(url) else { return };
2336        self.protected_resources.insert((session.to_string(), url.clone()));
2337        if let Some(target) = self.session_target(session) {
2338            self.protected_target_resources.insert((target, url));
2339        }
2340    }
2341
2342    fn redirector_protected(&self, url: &str) -> bool {
2343        let Some(key) = self.guard.resource_key(url) else { return true };
2344        self.guard.url(url) || self.protected_resources.iter().any(|(_, resource)| resource == &key)
2345    }
2346
2347    fn resource_protected(&self, session: &str, url: &str) -> bool {
2348        let Some(key) = self.guard.resource_key(url) else { return true };
2349        self.guard.url(url) || self.protected_resources.contains(&(session.to_string(), key.clone()))
2350            || self.session_target(session).is_some_and(|target| self.protected_target_resources.contains(&(target, key)))
2351    }
2352
2353    fn protect_body_request(&mut self, id: &str) {
2354        let mut ids = self.request_aliases.get(id).cloned().unwrap_or_default();
2355        ids.insert(id.to_string());
2356        for id in ids {
2357            self.protected_body_requests.insert(id.clone());
2358            for (peer, url) in self.request_resources.get(&id).cloned().unwrap_or_default() { self.protect_resource(&peer, &url); }
2359        }
2360    }
2361
2362    fn mark_redirect(&mut self, id: &str) {
2363        let mut ids = self.request_aliases.get(id).cloned().unwrap_or_default();
2364        ids.insert(id.to_string());
2365        for id in ids {
2366            self.redirects.insert(id.clone());
2367            for (session, url) in self.request_resources.get(&id).cloned().unwrap_or_default() {
2368                self.protect_resource(&session, &url);
2369            }
2370        }
2371    }
2372
2373    fn watch(&mut self, value: &mut Value) {
2374        let method = value
2375            .get("method")
2376            .and_then(|v| v.as_str())
2377            .unwrap_or("")
2378            .to_string();
2379        let session = value
2380            .get("sessionId")
2381            .and_then(|v| v.as_str())
2382            .map(str::to_string);
2383        let params = value.get("params").cloned().unwrap_or(Value::Null);
2384        self.observe_document_order(&method, &params, session.as_deref());
2385        self.page_scope_changed(&method, &params, session.as_deref());
2386        if let Some(target) = session.as_deref().and_then(|session| self.session_target(session)) {
2387            if method == "Page.frameNavigated" {
2388                let id = params.pointer("/frame/id").and_then(Value::as_str).unwrap_or("").to_string();
2389                let loader = params.pointer("/frame/loaderId").and_then(Value::as_str).unwrap_or("").to_string();
2390                let key = (target.clone(), id);
2391                if loader.is_empty() || self.document_versions.get(&key) != Some(&loader) {
2392                    if !loader.is_empty() { self.document_versions.insert(key, loader); }
2393                    *self.document_epochs.entry(target).or_default() += 1;
2394                }
2395            } else if method == "Page.frameDetached" {
2396                let id = params.get("frameId").and_then(Value::as_str).unwrap_or("").to_string();
2397                if params.get("reason").and_then(Value::as_str) == Some("remove") { self.note_frame_removal(&target, &id); }
2398                self.forget_frame_order(&target, session.as_deref().unwrap_or(""), &id, matches!(params.get("reason").and_then(Value::as_str), Some("remove" | "swap")));
2399                if self.document_versions.remove(&(target.clone(), id)).is_some() {
2400                    *self.document_epochs.entry(target).or_default() += 1;
2401                }
2402            } else if method == "Page.frameAttached" {
2403                if let Some(id) = params.get("frameId").and_then(Value::as_str) { self.removed_frames.remove(&(target, id.to_string())); }
2404            }
2405        }
2406        if matches!(method.as_str(), "Runtime.executionContextDestroyed" | "Runtime.executionContextsCleared") {
2407            if let Some(session) = session.as_ref() {
2408                let target = self.session_target(session);
2409                let id = params.get("executionContextId").and_then(Value::as_i64);
2410                let unique = params.get("executionContextUniqueId").and_then(Value::as_str);
2411                let cleared = method == "Runtime.executionContextsCleared";
2412                let owners: BTreeSet<String> = self.context_proofs.keys().map(|(owner, _)| owner.clone()).chain(self.created_contexts.iter().map(|(owner, _)| owner.clone())).filter(|owner| owner == session || target.is_some() && self.session_target(owner) == target).collect();
2413                self.context_proofs.retain(|(owner, key), proof| !owners.contains(owner) || !(cleared || id == Some(proof.id) || unique == Some(key.as_str())));
2414                self.created_contexts.retain(|(owner, key)| !owners.contains(owner) || !(cleared || id == Some(*key)));
2415            }
2416        }
2417        if method == "Runtime.executionContextCreated" {
2418            if let (Some(session), Some(unique), Some(id)) = (session.as_ref(), params.pointer("/context/uniqueId").and_then(Value::as_str), params.pointer("/context/id").and_then(Value::as_i64)) {
2419                let frame = params.pointer("/context/auxData/frameId").and_then(Value::as_str).unwrap_or("").to_string();
2420                let loader = self.session_target(session).and_then(|target| self.document_versions.get(&(target, frame.clone())).cloned());
2421                self.context_proofs.insert((session.clone(), unique.to_string()), ContextProof {
2422                    id, frame, loader, origin:params.pointer("/context/origin").and_then(Value::as_str).unwrap_or("").to_string(),
2423                    name:params.pointer("/context/name").and_then(Value::as_str).unwrap_or("").to_string(),
2424                    default:params.pointer("/context/auxData/isDefault").and_then(Value::as_bool) == Some(true),
2425                });
2426            }
2427            if let Some(session) = session.as_ref() {
2428                let frame = params.pointer("/context/auxData/frameId").and_then(Value::as_str);
2429                for (label, key) in [("id", "id"), ("unique", "uniqueId"), ("name", "name")] {
2430                    if let Some(id) = params.pointer("/context").and_then(|context| context.get(key)) {
2431                        let id = id.as_str().map(str::to_string).unwrap_or_else(|| id.to_string());
2432                        let key = (session.clone(), format!("{label}:{id}"));
2433                        if let Some(frame) = frame { self.context_frames.insert(key, frame.to_string()); }
2434                        else { self.context_frames.remove(&key); }
2435                    }
2436                }
2437            }
2438        }
2439        if let Some(id) = params.get("requestId").and_then(Value::as_str) {
2440            if self.redirect_page_protected(value) {
2441                self.protected_request_pages.insert((session.clone().unwrap_or_default(), id.to_string()));
2442            }
2443        }
2444        if let Some(id) = params.get("requestId").and_then(|v| v.as_str()) {
2445            if let Some(url) = shield::request_url(&params) {
2446                let resource = (session.clone().unwrap_or_default(), url.clone());
2447                self.request_resources.entry(id.to_string()).or_default().insert(resource.clone());
2448                if self.redirects.contains(id) || self.protected_body_requests.contains(id) || self.guard.url(&url) {
2449                    self.protect_resource(&resource.0, &resource.1);
2450                }
2451                let initiator = ["documentURL"]
2452                    .iter()
2453                    .filter_map(|key| params.get(*key).and_then(|v| v.as_str()))
2454                    .chain(params.pointer("/initiator/url").and_then(|v| v.as_str()))
2455                    .any(|text| self.guard.url(text))
2456                    || params
2457                        .get("frameId")
2458                        .and_then(|v| v.as_str())
2459                        .is_some_and(|frame| self.frame_protected(frame));
2460                let protected = initiator
2461                    || self.guard.url(&url)
2462                    || self.guard.url(&shield::web_socket_as_http(&url));
2463                let entry = self.requests.entry(id.to_string()).or_insert(false);
2464                *entry = *entry || protected;
2465            }
2466        }
2467        if let Some(id) = params.get("requestId").and_then(Value::as_str) {
2468            let authority = if method == "Network.requestWillBeSentExtraInfo" { params.clone() } else { params.get("request").cloned().unwrap_or(Value::Null) };
2469            if shield::scheme_less_protected(&self.guard, &authority) {
2470                self.requests.insert(id.to_string(), true);
2471                for (peer, url) in self.request_resources.get(id).cloned().unwrap_or_default() { self.protect_resource(&peer, &url); }
2472            }
2473            if self.protected_carrier(value, false) { self.protect_body_request(id); }
2474            if self.actual_redirect_carrier(value) { self.mark_redirect(id); }
2475        }
2476        if let Some((id, url)) = match method.as_str() {
2477            "Debugger.scriptParsed" | "Debugger.scriptFailedToParse" => params.get("scriptId").and_then(Value::as_str).zip(params.get("url").and_then(Value::as_str)),
2478            "CSS.styleSheetAdded" => params.pointer("/header/styleSheetId").and_then(Value::as_str).zip(params.pointer("/header/sourceURL").and_then(Value::as_str)),
2479            _ => None,
2480        } {
2481            self.source_resources.insert((session.clone().unwrap_or_default(), id.to_string()), url.to_string());
2482        }
2483        if matches!(method.as_str(), "Page.frameNavigated" | "Page.frameAttached" | "Page.frameDetached" | "Page.navigatedWithinDocument") {
2484            self.frame_epoch += 1;
2485        }
2486        match method.as_str() {
2487            "Page.frameAttached" | "Page.frameDetached" => {
2488                if method == "Page.frameAttached" && session.as_ref().is_some_and(|session| self.guard_sessions.contains(session)) {
2489                    if let (Some(id), Some(parent)) = (params.get("frameId").and_then(Value::as_str), params.get("parentFrameId").and_then(Value::as_str)) {
2490                        if let Some(protected) = self.frame_decisions.get(parent).copied() {
2491                            self.frame_decisions.entry(id.to_string()).or_insert(protected);
2492                        }
2493                    }
2494                }
2495                if let Some(id) = params.get("frameId").and_then(Value::as_str) {
2496                    self.frames.remove(id);
2497                    self.frame_parents.remove(id);
2498                    self.frame_versions.insert(id.to_string(), self.frame_epoch);
2499                    for frames in self.frame_sessions.values_mut() {
2500                        frames.remove(id);
2501                    }
2502                }
2503            }
2504            "Page.navigatedWithinDocument" => {
2505                if let Some(id) = params.get("frameId").and_then(Value::as_str) {
2506                    self.frame_versions.insert(id.to_string(), self.frame_epoch);
2507                    if let Some(known) = self.frames.get_mut(id).and_then(|known| known.first_mut()) {
2508                        *known = params.get("url").and_then(Value::as_str).unwrap_or("").to_string();
2509                    }
2510                }
2511            }
2512            "Page.frameNavigated" => {
2513                let Some(session) = session else { return };
2514                let frame = params.get("frame").cloned().unwrap_or(Value::Null);
2515                let url = format!(
2516                    "{}{}",
2517                    frame.get("url").and_then(|v| v.as_str()).unwrap_or(""),
2518                    frame.get("urlFragment").and_then(|v| v.as_str()).unwrap_or("")
2519                );
2520                let origin = frame
2521                    .get("securityOrigin")
2522                    .and_then(|v| v.as_str())
2523                    .unwrap_or("")
2524                    .to_string();
2525                if let Some(id) = frame.get("id").and_then(|v| v.as_str()) {
2526                    self.frames.insert(id.to_string(), vec![url.clone(), origin.clone()]);
2527                    self.note_parent(id, frame.get("parentId").and_then(Value::as_str).unwrap_or(""));
2528                    if self.guard.url(&url) || self.guard.url(&origin) {
2529                        self.frame_decisions.insert(id.to_string(), true);
2530                    } else if !self.guard.error_page_url(&url) && !matches!(origin.as_str(), "" | "null" | "://") {
2531                        self.frame_decisions.insert(id.to_string(), false);
2532                    }
2533                    self.frame_versions.insert(id.to_string(), self.frame_epoch);
2534                    self.frame_sessions.entry(session.clone()).or_default().insert(id.to_string());
2535                }
2536                if frame.get("parentId").is_none() {
2537                    self.main_commit(&session, &frame);
2538                    if self.session_target(&session).is_none() && (self.guard.url(&url) || self.guard.url(&origin)) {
2539                        self.committed_protected(&session);
2540                    }
2541                }
2542            }
2543            "Page.frameStartedNavigating" => {
2544                if matches!(params.get("navigationType").and_then(Value::as_str), Some("sameDocument" | "historySameDocument")) {
2545                    return;
2546                }
2547                if let Some(target) = session.as_deref().and_then(|session| self.session_target(session)) {
2548                    if self.guards.get(&target).is_some_and(|guard| Some(guard) != session.as_ref()) && !self.unobserved.contains(&target) {
2549                        return;
2550                    }
2551                    if let Some(doc) = self.documents.get_mut(&target) {
2552                        if params.get("frameId").and_then(Value::as_str) == Some(&doc.frame_id) {
2553                            doc.navigating = true;
2554                            doc.navigation_url = params.get("url").and_then(Value::as_str).unwrap_or("").to_string();
2555                        }
2556                    }
2557                }
2558            }
2559            "Runtime.executionContextCreated" => {
2560                let Some(session) = session else { return };
2561                let origin = params
2562                    .pointer("/context/origin")
2563                    .and_then(|v| v.as_str())
2564                    .unwrap_or("");
2565                let default = params
2566                    .pointer("/context/auxData/isDefault")
2567                    .and_then(|v| v.as_bool())
2568                    .unwrap_or(true);
2569                if let Some(frame) = params.pointer("/context/auxData/frameId").and_then(|v| v.as_str()) {
2570                    if default {
2571                        self.frames
2572                            .entry(frame.to_string())
2573                            .or_default()
2574                            .push(origin.to_string());
2575                    }
2576                }
2577                if default && self.guard.url(origin) {
2578                    self.committed_protected(&session);
2579                }
2580            }
2581            "Target.targetCreated" | "Target.targetInfoChanged" => {
2582                let info = params.get("targetInfo").cloned().unwrap_or(Value::Null);
2583                let target = info
2584                    .get("targetId")
2585                    .and_then(|v| v.as_str())
2586                    .unwrap_or("")
2587                    .to_string();
2588                let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("");
2589                if !target.is_empty() {
2590                    self.targets.insert(target.clone(), info.clone());
2591                }
2592                if self.info_protected(&info, &[]) && !self.documents.contains_key(&target) && !self.probing.contains(&target) && !self.probe_candidate(&info) {
2593                    self.protect_target(&target);
2594                }
2595                if self.info_protected(&info, &[]) || self.discovery_redacted(&info) {
2596                    value["params"]["targetInfo"] = shield::redacted(&info);
2597                } else if !self.documents.contains_key(&target) && !self.guard.target(url, false, Opener::Unknown)
2598                    && !self.abandoned.contains(&target)
2599                {
2600                    self.protected_targets.remove(&target);
2601                }
2602            }
2603            "Target.attachedToTarget" => {
2604                if let Some(info) = params.get("targetInfo") {
2605                    value["params"]["targetInfo"] = if info.get("targetId").and_then(Value::as_str).is_some_and(|target| self.probing.contains(target)) {
2606                        shield::redacted(info)
2607                    } else {
2608                        self.redact(info, &[])
2609                    };
2610                }
2611            }
2612            "Target.targetDestroyed" => {
2613                if let Some(target) = params.get("targetId").and_then(|v| v.as_str()) {
2614                    self.guards.remove(target);
2615                    self.targets.remove(target);
2616                    self.created_blanks.remove(target);
2617                    self.navigated_blanks.remove(target);
2618                    self.documents.remove(target);
2619                    self.unchecked_documents.retain(|(owner, _, _), _| owner != target);
2620                    self.removed_frames.retain(|(owner, _), _| owner != target);
2621                    self.unobserved.remove(target);
2622                }
2623            }
2624            "Target.detachedFromTarget" => {
2625                if let Some(child) = params.get("sessionId").and_then(|v| v.as_str()) {
2626                    self.forget_session(child);
2627                }
2628            }
2629            _ => {}
2630        }
2631    }
2632
2633    fn hand(&mut self, session_id: &str, request_id: &str) {
2634        self.handed
2635            .insert((session_id.to_string(), request_id.to_string()));
2636    }
2637
2638    fn settle(&mut self, session_id: &str, request_id: &str) {
2639        self.paused_documents.remove(&(session_id.to_string(), request_id.to_string()));
2640        self.handed
2641            .remove(&(session_id.to_string(), request_id.to_string()));
2642    }
2643
2644    fn challenge_protected(&self, value: &Value) -> bool {
2645        let session = value.get("sessionId").and_then(|v| v.as_str()).unwrap_or("");
2646        let params = value.get("params").cloned().unwrap_or(Value::Null);
2647        let id = params.get("requestId").and_then(|v| v.as_str()).unwrap_or("");
2648        let text = |pointer: &str| params.pointer(pointer).and_then(|v| v.as_str()).unwrap_or("");
2649        self.request_state(id) == Some(true)
2650            || self.blocked.contains(session)
2651            || self.guard_sessions.contains(session)
2652            || self.frame_protected(text("/frameId"))
2653            || self
2654                .session_target(session)
2655                .is_some_and(|target| self.protected_targets.contains(&target))
2656            || ["/request/url", "/authChallenge/origin"]
2657                .iter()
2658                .any(|pointer| self.guard.url(text(pointer)))
2659    }
2660
2661    fn hidden(&self, value: &Value) -> bool {
2662        let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("");
2663        if method == "Target.receivedMessageFromTarget" {
2664            return true;
2665        }
2666        if let Some(session) = value.get("sessionId").and_then(|v| v.as_str()) {
2667            if self.blocked.contains(session) || self.guard_sessions.contains(session) {
2668                return true;
2669            }
2670        }
2671        if method.starts_with("Target.") {
2672            let info = value.pointer("/params/targetInfo");
2673            let reported = info.and_then(|info| info.get("targetId")).and_then(Value::as_str)
2674                .and_then(|target| self.targets.get(target)).or(info);
2675            if let Some(url) = reported.and_then(|info| info.get("url")).and_then(Value::as_str) {
2676                if self.guard.unparseable(url) && self.guard.url(url) {
2677                    return true;
2678                }
2679            }
2680            if let Some(child) = value.pointer("/params/sessionId").and_then(|v| v.as_str()) {
2681                if self.guard_sessions.contains(child) || self.blocked.contains(child)
2682                    && !(method == "Target.detachedFromTarget" && self.detached_ordinary.contains(child)) {
2683                    return true;
2684                }
2685            }
2686            return false;
2687        }
2688        let params = value.get("params").cloned().unwrap_or(Value::Null);
2689        if let Some(id) = params.get("requestId").and_then(|v| v.as_str()) {
2690            if self.requests.get(id) == Some(&true) || self.redirect_page_protected(value) {
2691                return true;
2692            }
2693        }
2694        shield::loaded_urls_protected(&self.guard, &params)
2695    }
2696
2697    fn fail_seen_network(&mut self, value: &Value) {
2698        let Some(id) = value.pointer("/params/networkId").and_then(Value::as_str).or_else(|| value.pointer("/params/requestId").and_then(Value::as_str)) else { return };
2699        let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
2700        if self.guard_sessions.contains(session) && self.request_state(id) != Some(true) { return }
2701        let target = self.session_target(session);
2702        let keys: Vec<_> = self.seen_network.keys().filter(|(peer, request)| request == id && (peer == session || target.is_some() && self.session_target(peer) == target)).cloned().collect();
2703        for key in keys {
2704            let kind = self.seen_network.remove(&key).unwrap();
2705            if self.terminal_network.insert(key.clone()) {
2706                self.events.push(json!({"method":"Network.loadingFailed","sessionId":key.0,"params":{"requestId":key.1,"timestamp":value.pointer("/params/timestamp").and_then(Value::as_f64).unwrap_or(0.0),"type":kind,"errorText":"net::ERR_BLOCKED_BY_CLIENT","canceled":true,"blockedReason":"inspector"}}));
2707            }
2708        }
2709    }
2710
2711    fn visible_network(&mut self, value: &Value) {
2712        let method = value.get("method").and_then(Value::as_str).unwrap_or("");
2713        if !method.starts_with("Network.") { return }
2714        let Some(id) = value.pointer("/params/requestId").and_then(Value::as_str) else { return };
2715        let key = (value.get("sessionId").and_then(Value::as_str).unwrap_or("").to_string(), id.to_string());
2716        if matches!(method, "Network.loadingFinished" | "Network.loadingFailed") {
2717            self.seen_network.remove(&key);
2718            self.terminal_network.insert(key);
2719        } else {
2720            let kind = value.pointer("/params/type").and_then(Value::as_str).filter(|kind| matches!(*kind, "Document" | "Stylesheet" | "Image" | "Media" | "Font" | "Script" | "TextTrack" | "XHR" | "Fetch" | "Prefetch" | "EventSource" | "WebSocket" | "Manifest" | "SignedExchange" | "Ping" | "CSPViolationReport" | "Preflight" | "Other")).unwrap_or("Other");
2721            self.seen_network.entry(key).or_insert_with(|| kind.to_string());
2722        }
2723    }
2724
2725    fn ordinary_redirect_url(&self, url: &str) -> bool {
2726        !self.guard.url(url) && !self.guard.url(&shield::web_socket_as_http(url)) && url::Url::parse(&shield::web_socket_as_http(url)).is_ok_and(|url| matches!(url.scheme(), "http" | "https") && url.has_host())
2727    }
2728
2729    fn remember_navigation(&mut self, params: &Value, session: Option<&str>) {
2730        if let Some((session, url)) = session.zip(params["url"].as_str()).filter(|(_, url)| self.ordinary_redirect_url(url)) {
2731            self.client_navigation_urls.insert(session.to_string(), url.to_string());
2732        }
2733    }
2734
2735    fn ordinary_network_source(&self, session: &str, id: &str) -> Option<String> {
2736        let resources: Vec<_> = self.request_resources.get(id).into_iter().flatten().filter(|(peer, _)| peer == session).map(|(_, url)| url).collect();
2737        if resources.is_empty() || resources.iter().any(|url| !self.ordinary_redirect_url(url)) { return None }
2738        resources.first().map(|url| (*url).clone())
2739    }
2740
2741    fn actual_redirect_carrier(&self, value: &Value) -> bool {
2742        self.protected_carrier(value, true)
2743    }
2744
2745    fn protected_carrier(&self, value: &Value, navigation: bool) -> bool {
2746        let params = &value["params"];
2747        let method = value["method"].as_str().unwrap_or("");
2748        if navigation && method == "Network.requestWillBeSentExtraInfo" { return false }
2749        let (headers, status, source) = match method {
2750            "Network.requestWillBeSent" => (params.pointer("/redirectResponse/headers"), params.pointer("/redirectResponse/status"), params.pointer("/redirectResponse/url").and_then(Value::as_str).map(str::to_string)),
2751            "Network.responseReceived" => (params.pointer("/response/headers"), params.pointer("/response/status"), params.pointer("/response/url").and_then(Value::as_str).map(str::to_string)),
2752            "Fetch.requestPaused" => (params.get("responseHeaders"), params.get("responseStatusCode"), params.pointer("/request/url").and_then(Value::as_str).map(str::to_string)),
2753            "Network.responseReceivedExtraInfo" | "Network.requestWillBeSentExtraInfo" => (params.get("headers"), params.get("statusCode"), self.ordinary_network_source(value["sessionId"].as_str().unwrap_or(""), params["requestId"].as_str().unwrap_or(""))),
2754            _ => return false,
2755        };
2756        let source = source.as_deref().unwrap_or("https://ordinary.invalid/");
2757        let redirect = status.and_then(Value::as_u64).is_some_and(|status| matches!(status, 301 | 302 | 303 | 307 | 308));
2758        let sensitive = |target: &str| Self::carrier_targets(&self.guard, target, source).is_none_or(|targets| targets.iter().any(|target| target.protected && {
2759            let url = &target.url;
2760            !url.path().trim_matches('/').is_empty() || url.query().is_some() || url.fragment().is_some() || !url.username().is_empty() || url.password().is_some()
2761        }));
2762        let protected = |name: &str, text: &str| {
2763            match name.to_ascii_lowercase().as_str() {
2764                "location" if navigation && redirect => Self::carrier_targets(&self.guard, text, source).is_none_or(|targets| targets.iter().any(|target| target.protected)),
2765                "location" | "content-location" if !navigation => sensitive(text),
2766                "refresh" => Self::redirect_header_value(&self.guard, name, text, source).is_none_or(|text| text.split_once(";url=").is_none_or(|(_, url)| self.guard.url(url))),
2767                "link" => text.split([',', '\n']).any(|part| part.trim().strip_prefix('<').and_then(|part| part.split_once('>')).is_some_and(|(target, _)| sensitive(target))),
2768                _ => false,
2769            }
2770        };
2771        let found = match headers {
2772            Some(Value::Object(headers)) => headers.iter().any(|(name, value)| value.as_str().is_some_and(|text| protected(name, text))),
2773            Some(Value::Array(headers)) => headers.iter().any(|field| field["name"].as_str().zip(field["value"].as_str()).is_some_and(|(name, text)| protected(name, text))),
2774            _ => false,
2775        };
2776        found || ["headersText", "requestHeadersText"].iter().filter(|field| !navigation || **field == "headersText").any(|field| params.get(*field).and_then(Value::as_str).is_some_and(|text| text.lines().filter_map(|line| line.split_once(':')).any(|(name, value)| protected(name.trim(), value.trim()))))
2777    }
2778
2779    fn ordinary_late_extra_info(&self, value: &Value) -> bool {
2780        let session = value["sessionId"].as_str().unwrap_or("");
2781        let id = value["params"]["requestId"].as_str().unwrap_or("");
2782        self.requests.get(id) == Some(&false) && !self.redirects.contains(id) && !self.protected_body_requests.contains(id) && !self.guard_sessions.contains(session)
2783            && !self.redirect_page_protected(value) && !shield::loaded_urls_protected(&self.guard, &value["params"])
2784            && self.ordinary_network_source(session, id).is_some()
2785    }
2786
2787    fn redirect_page_protected(&self, value: &Value) -> bool {
2788        let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
2789        let params = &value["params"];
2790        let hop = value["method"] == "Network.requestWillBeSent" && params["type"] == "Document" && params.pointer("/redirectResponse/url").and_then(Value::as_str).is_some_and(|url| self.ordinary_redirect_url(url)) && params.get("documentURL").is_some_and(|url| Some(url) == params.pointer("/request/url"));
2791        self.blocked.contains(session)
2792            || self.protected_request_pages.iter().any(|(peer, id)| id == params["requestId"].as_str().unwrap_or("") && (peer == session || self.session_target(session).is_some_and(|target| self.session_target(peer).as_ref() == Some(&target))))
2793            || self.session_target(session).is_some_and(|target| self.protected_targets.contains(&target) || self.documents.get(&target).is_some_and(|doc| self.guard.url(&doc.url) || self.guard.url(&doc.origin)))
2794            || params.get("frameId").and_then(Value::as_str).is_some_and(|frame| self.frame_protected(frame))
2795            || params.get("initiator").is_some_and(|initiator| shield::loaded_urls_protected(&self.guard, initiator))
2796            || !hop && params.get("documentURL").and_then(Value::as_str).is_some_and(|url| self.guard.url(url))
2797    }
2798
2799    fn refresh_parts(text: &str) -> Option<(&str, Option<&str>)> {
2800        let text = text.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
2801        let bytes = text.as_bytes();
2802        let mut at = bytes.iter().take_while(|byte| byte.is_ascii_digit()).count();
2803        if at == 0 { return None }
2804        if bytes.get(at) == Some(&b'.') {
2805            at += 1;
2806            while bytes.get(at).is_some_and(u8::is_ascii_digit) { at += 1; }
2807        }
2808        let delay = &text[..at];
2809        let tail = text[at..].trim_start_matches(|ch: char| ch.is_ascii_whitespace());
2810        if tail.is_empty() { return Some((delay, None)) }
2811        let mut target = tail.strip_prefix(';').or_else(|| tail.strip_prefix(','))?.trim_matches(|ch: char| ch.is_ascii_whitespace());
2812        if target.get(..3).is_some_and(|key| key.eq_ignore_ascii_case("url")) {
2813            if let Some(value) = target[3..].trim_start_matches(|ch: char| ch.is_ascii_whitespace()).strip_prefix('=') {
2814                target = value.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
2815            }
2816        }
2817        if let Some(quote) = target.chars().next().filter(|ch| matches!(ch, '\'' | '"')) {
2818            target = &target[1..];
2819            target = target.split(quote).next().unwrap_or("");
2820        }
2821        target = target.trim_matches(|ch: char| ch.is_ascii_whitespace());
2822        Some((delay, (!target.is_empty()).then_some(target)))
2823    }
2824
2825    pub fn ordinary_redirect_target(&self, source: &str, target: &str) -> Result<String, String> {
2826        target_refusal(&self.guard, source, target).map_err(|_| self.refusal())
2827    }
2828
2829    fn carrier_targets(guard: &Protected, target: &str, source: &str) -> Option<Vec<CarrierTarget>> {
2830        let base = url::Url::parse(source).ok()?;
2831        let mut target = target.trim().to_string();
2832        if target.chars().any(char::is_control) { return None }
2833        let mut targets = Vec::new();
2834        for _ in 0..9 {
2835            let url = base.join(&target).ok()?;
2836            let keyed = guard.resource_key(&target).and_then(|key| url::Url::parse(&key).ok()).unwrap_or_else(|| url.clone());
2837            let origin = keyed.origin().ascii_serialization();
2838            let web = origin.starts_with("http://") || origin.starts_with("https://");
2839            let protected = if web { guard.url(&origin) } else { guard.url(keyed.as_str()) };
2840            targets.push(CarrierTarget { origin: web.then_some(origin), protected, url });
2841            let decoded = shield::decode_percent(&target);
2842            if decoded == target { return Some(targets) }
2843            target = decoded;
2844        }
2845        None
2846    }
2847
2848    fn redirect_header_value(guard: &Protected, name: &str, text: &str, source: &str) -> Option<String> {
2849        if text.is_empty() || text.chars().any(|ch| ch.is_control() && !(name.eq_ignore_ascii_case("refresh") && ch == '\t')) { return None }
2850        let origin = |target: &str| {
2851            let targets = Self::carrier_targets(guard, target, source)?;
2852            targets.iter().find(|target| target.protected).unwrap_or(&targets[0]).origin.clone()
2853        };
2854        match name.to_ascii_lowercase().as_str() {
2855            "location" | "content-location" => origin(text),
2856            "refresh" => {
2857                let (delay, target) = Self::refresh_parts(text)?;
2858                Some(format!("{delay};url={}", origin(target.unwrap_or(source))?))
2859            }
2860            "link" => text.split(',').map(|part| {
2861                let (target, params) = part.trim().strip_prefix('<')?.split_once('>')?;
2862                let mut link = format!("<{}>", origin(target)?);
2863                for param in params.split(';') {
2864                    let Some((key, value)) = param.trim().split_once('=') else { continue };
2865                    let key = key.to_ascii_lowercase();
2866                    let value = value.trim();
2867                    let tokens = value.trim_matches('"');
2868                    if matches!(key.as_str(), "rel" | "rev") && !tokens.is_empty() && tokens.split_ascii_whitespace().all(|token| matches!(token, "preconnect" | "prefetch" | "preload" | "next" | "prev" | "stylesheet" | "canonical" | "alternate" | "noopener" | "noreferrer" | "icon" | "dns-prefetch" | "upskill" | "https://www.sliccy.ai/rel/upskill")) {
2869                        link.push_str(&format!("; {key}={value}"));
2870                    }
2871                }
2872                Some(link)
2873            }).collect::<Option<Vec<_>>>().map(|links| links.join(", ")),
2874            _ => None,
2875        }
2876    }
2877
2878    pub fn ordinary_response_body(&self, session: &str, request_id: &str) -> bool {
2879        !self.content_blocked(session) && self.request_state(request_id) == Some(false)
2880            && !self.protected_body_requests.contains(request_id) && self.ordinary_network_source(session, request_id).is_some()
2881    }
2882
2883    pub fn redact_redirect_carriers(&self, headers: &mut Value, source: &str) {
2884        let Some(fields) = headers.as_object_mut() else { return };
2885        fields.retain(|name, value| {
2886            let kind = name.to_ascii_lowercase();
2887            if !matches!(kind.as_str(), "location" | "content-location" | "refresh" | "link") { return true }
2888            let Some(text) = value.as_str() else { return false };
2889            let targets = match kind.as_str() {
2890                "refresh" => Self::refresh_parts(text).and_then(|(_, target)| Self::carrier_targets(&self.guard, target.unwrap_or(source), source)),
2891                "link" => text.split(',').map(|part| part.trim().strip_prefix('<').and_then(|part| part.split_once('>')).and_then(|(target, _)| Self::carrier_targets(&self.guard, target, source))).collect::<Option<Vec<_>>>().map(|parts| parts.into_iter().flatten().collect()),
2892                _ => Self::carrier_targets(&self.guard, text, source),
2893            };
2894            if targets.is_some_and(|targets| !targets.is_empty() && targets.iter().all(|target| !target.protected && matches!(target.url.scheme(), "http" | "https") && target.url.has_host())) { return true }
2895            match Self::redirect_header_value(&self.guard, name, text, source) {
2896                Some(safe) => { *value = json!(safe); true }
2897                None => false,
2898            }
2899        });
2900    }
2901
2902    fn redirect_headers(headers: &mut Value, source: &str, guard: &Protected) {
2903        match headers {
2904            Value::Object(fields) => fields.retain(|name, value| {
2905                let safe = value.as_str().and_then(|text| Self::redirect_header_value(guard, name, text, source));
2906                if let Some(safe) = safe { *value = json!(safe); true } else { false }
2907            }),
2908            Value::Array(fields) => {
2909                fields.retain_mut(|field| {
2910                    let safe = field.get("name").and_then(Value::as_str).zip(field.get("value").and_then(Value::as_str)).and_then(|(name, text)| Self::redirect_header_value(guard, name, text, source));
2911                    if let Some(safe) = safe { field["value"] = json!(safe); true } else { false }
2912                });
2913                for field in fields { if let Some(fields) = field.as_object_mut() { fields.retain(|name, _| matches!(name.as_str(), "name" | "value")); } }
2914            }
2915            _ => *headers = json!({}),
2916        }
2917    }
2918
2919    fn visible_redirect(&self, value: &mut Value) -> bool {
2920        let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("").to_string();
2921        if self.guard_sessions.contains(&session) || self.redirect_page_protected(value) { return false }
2922        let id = value.pointer("/params/requestId").and_then(Value::as_str).unwrap_or("").to_string();
2923        let method = value.get("method").and_then(Value::as_str).unwrap_or("").to_string();
2924        let params = &value["params"];
2925        let source = match method.as_str() {
2926            "Network.requestWillBeSent" => params.pointer("/redirectResponse/url").and_then(Value::as_str).map(str::to_string),
2927            "Network.responseReceived" => params.pointer("/response/url").and_then(Value::as_str).map(str::to_string),
2928            "Fetch.requestPaused" => params.pointer("/request/url").and_then(Value::as_str).map(str::to_string),
2929            "Network.responseReceivedExtraInfo" => self.ordinary_network_source(&session, &id),
2930            _ => return false,
2931        };
2932        let Some(source) = source.filter(|url| self.ordinary_redirect_url(url)) else { return false };
2933        let mut loaded = params.clone();
2934        if method == "Network.requestWillBeSent" {
2935            if let Some(fields) = loaded.as_object_mut() { fields.remove("request"); fields.remove("documentURL"); }
2936        }
2937        if shield::loaded_urls_protected(&self.guard, &loaded) { return false }
2938        if method == "Fetch.requestPaused" && Paused::from_event(value).is_none_or(|paused| self.initiated_by_protected(&paused)) { return false }
2939        let mut carriers = match method.as_str() {
2940            "Network.responseReceived" => params.pointer("/response/headers"),
2941            "Network.requestWillBeSent" => params.pointer("/redirectResponse/headers"),
2942            "Network.responseReceivedExtraInfo" => params.get("headers"),
2943            _ => params.get("responseHeaders"),
2944        }.cloned().unwrap_or(Value::Null);
2945        Self::redirect_headers(&mut carriers, &source, &self.guard);
2946        if !shield::redact_strings(&self.guard, &mut carriers) { return false }
2947        if method == "Network.responseReceivedExtraInfo" && !self.requests.contains_key(&id) { return false }
2948        let status = match method.as_str() {
2949            "Network.requestWillBeSent" => value.pointer("/params/redirectResponse/status"),
2950            "Network.responseReceived" => value.pointer("/params/response/status"),
2951            "Network.responseReceivedExtraInfo" => value.pointer("/params/statusCode"),
2952            "Fetch.requestPaused" => value.pointer("/params/responseStatusCode"),
2953            _ => return false,
2954        }.and_then(Value::as_u64);
2955        if !status.is_some_and(|status| matches!(status, 301 | 302 | 303 | 307 | 308)) || method == "Network.requestWillBeSent" && !self.seen_network.contains_key(&(session, id)) {
2956            return false;
2957        }
2958        let mut redacted = value.clone();
2959        if !shield::redact_strings(&self.guard, &mut redacted) { return false }
2960        let params = &mut redacted["params"];
2961        if method == "Network.responseReceivedExtraInfo" {
2962            if let Some(fields) = params.as_object_mut() { fields.retain(|key, _| matches!(key.as_str(), "requestId" | "statusCode" | "headers" | "headersText")); }
2963            Self::redirect_headers(&mut params["headers"], &source, &self.guard);
2964            if params.get("headersText").is_some() {
2965                let headers = params["headers"].as_object().map(|headers| headers.iter().map(|(name, value)| format!("{name}: {}\r\n", value.as_str().unwrap())).collect::<String>()).unwrap_or_default();
2966                params["headersText"] = json!(format!("HTTP/1.1 {}\r\n{headers}\r\n", status.unwrap()));
2967            }
2968        } else {
2969            let request = json!({"url":params.pointer("/request/url").cloned().unwrap_or(Value::Null),"method":params.pointer("/request/method").and_then(Value::as_str).filter(|method| matches!(*method, "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH")).unwrap_or("GET"),"headers":{}});
2970            if method == "Fetch.requestPaused" {
2971                if let Some(fields) = params.as_object_mut() { fields.retain(|key, _| matches!(key.as_str(), "requestId" | "networkId" | "frameId" | "resourceType" | "responseStatusCode" | "responseHeaders")); }
2972                Self::redirect_headers(&mut params["responseHeaders"], &source, &self.guard);
2973                params["responseStatusText"] = json!("");
2974                params["request"] = request;
2975            } else {
2976                let key = if method == "Network.requestWillBeSent" { "redirectResponse" } else { "response" };
2977                let mut response = params[key].clone();
2978                if let Some(fields) = response.as_object_mut() { fields.retain(|key, _| matches!(key.as_str(), "url" | "status" | "headers")); }
2979                response["statusText"] = json!("");
2980                Self::redirect_headers(&mut response["headers"], &source, &self.guard);
2981                if let Some(fields) = params.as_object_mut() { fields.retain(|key, _| matches!(key.as_str(), "requestId" | "loaderId" | "frameId" | "timestamp" | "wallTime" | "type")); }
2982                if method == "Network.requestWillBeSent" { params["request"] = request; }
2983                params[key] = response;
2984            }
2985        }
2986        *value = redacted;
2987        true
2988    }
2989
2990    fn deliver(&mut self, value: Value) {
2991        self.deliver_event(value);
2992        if !self.checking_page_events {
2993            while let Some(event) = self.pending_page_events.pop_front() { self.deliver_event(event); }
2994        }
2995    }
2996
2997    fn deliver_event(&mut self, mut value: Value) {
2998        self.watch(&mut value);
2999        if value.get("method").and_then(Value::as_str) == Some("Page.screencastFrame") {
3000            if let Some(session) = value.get("sessionId").and_then(Value::as_str) {
3001                let _ = self.send("Page.stopScreencast", json!({}), Some(session));
3002            }
3003            return;
3004        }
3005        if value.get("method").and_then(Value::as_str).is_some_and(page_scope::content_event) {
3006            let session = value.get("sessionId").and_then(Value::as_str);
3007            if session.is_some_and(|session| self.guard_sessions.contains(session)) { return }
3008            if self.checking_page_events { self.pending_page_events.push_back(value); return }
3009            self.checking_page_events = true;
3010            let verified = self.verify_page_scope(session).is_ok();
3011            self.checking_page_events = false;
3012            if !verified { return }
3013        }
3014        let key = (value.get("sessionId").and_then(Value::as_str).unwrap_or("").to_string(), value.pointer("/params/requestId").and_then(Value::as_str).unwrap_or("").to_string());
3015        let terminal = self.terminal_network.contains(&key);
3016        let late_ordinary = terminal && matches!(value["method"].as_str(), Some("Network.responseReceivedExtraInfo" | "Network.requestWillBeSentExtraInfo")) && self.ordinary_late_extra_info(&value);
3017        if value.get("method").and_then(Value::as_str).is_some_and(|method| method.starts_with("Network.") && method != "Network.responseReceivedExtraInfo") && terminal && !late_ordinary { return }
3018        let guarded = self.blocked.contains(&key.0) || shield::loaded_urls_protected(&self.guard, &value["params"]) || self.hidden(&value) || self.redirects.contains(&key.1);
3019        if guarded && self.visible_redirect(&mut value) {
3020            let hop = value.get("method").and_then(Value::as_str) == Some("Network.requestWillBeSent");
3021            self.events.push(value.clone());
3022            if hop { self.fail_seen_network(&value); }
3023            return;
3024        }
3025        if terminal && !late_ordinary { return }
3026        if self.worlds.event(&value, &self.guard) {
3027            if value.get("method").and_then(Value::as_str) == Some("Debugger.paused") {
3028                let _ = self.send("Debugger.resume", json!({}), value.get("sessionId").and_then(Value::as_str));
3029            }
3030            return;
3031        }
3032        if guarded && !value.get("method").and_then(Value::as_str).unwrap_or("").starts_with("Target.") {
3033            self.fail_seen_network(&value);
3034            return;
3035        }
3036        self.adopt_guard_attach(&value);
3037        if self.blocked.contains(value.get("sessionId").and_then(Value::as_str).unwrap_or("")) || !value.get("method").and_then(Value::as_str).unwrap_or("").starts_with("Target.") && shield::loaded_urls_protected(&self.guard, &value["params"]) {
3038            self.fail_seen_network(&value);
3039            return;
3040        }
3041        let echoed_url = (value["method"] == "Network.requestWillBeSent" && value["params"]["type"] == "Document" && !guarded
3042            && !self.protected_body_requests.contains(&key.1) && self.client_navigation_urls.get(&key.0).is_some_and(|url| value.pointer("/params/request/url").and_then(Value::as_str) == Some(url.as_str()))).then(|| value["params"]["request"]["url"].clone());
3043        shield::redact_strings(&self.guard, &mut value);
3044        if let Some(url) = echoed_url { value["params"]["request"]["url"] = url; }
3045        if self.hidden(&value) {
3046            self.fail_seen_network(&value);
3047            return;
3048        }
3049        let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("");
3050        self.worlds.visible_event(value.get("sessionId").and_then(Value::as_str).unwrap_or(""), method, &value["params"]);
3051        let id = value
3052            .pointer("/params/requestId")
3053            .and_then(|v| v.as_str())
3054            .map(str::to_string);
3055        let session = value.get("sessionId").and_then(|v| v.as_str());
3056        if let (true, Some(session), Some(id)) = (method.starts_with("Fetch."), session, &id) {
3057            let session = session.to_string();
3058            if value.pointer("/params/resourceType").and_then(Value::as_str) == Some("Document") {
3059                self.paused_documents.insert((session.clone(), id.clone()));
3060            }
3061            self.hand(&session, id);
3062        }
3063        if method.ends_with("ExtraInfo") {
3064            if let Some(id) = id.clone() {
3065                if let Some(cookies) = value.pointer("/params/associatedCookies").and_then(Value::as_array) {
3066                    self.trace_cookies.extend(cookies.iter().filter_map(|item| item.get("cookie").cloned()));
3067                }
3068                shield::strip_protected_associated(&self.guard, &mut value);
3069                if self.requests.get(&id) != Some(&false) {
3070                    self.held.entry(id).or_default().push(value);
3071                    return;
3072                }
3073            }
3074        }
3075        if !terminal { self.visible_network(&value); }
3076        self.events.push(value);
3077        if let Some(id) = id {
3078            if self.requests.get(&id) == Some(&false) {
3079                if let Some(held) = self.held.remove(&id) {
3080                    for event in held { self.deliver(event); }
3081                }
3082            }
3083        }
3084    }
3085
3086    fn renderer_crashed(&self, session: Option<&str>, target: Option<&str>) -> bool {
3087        target.is_some_and(|target| self.crashed_targets.contains(target))
3088            || session.is_some_and(|session| self.crashed_sessions.contains(session)
3089                || self.session_target(session).is_some_and(|target| self.crashed_targets.contains(&target)))
3090    }
3091
3092    fn observe_renderer_crash(&mut self, value: &Value) {
3093        let method = value.get("method").and_then(Value::as_str).unwrap_or("");
3094        let reason = value.pointer("/params/reason").and_then(Value::as_str).unwrap_or("").to_ascii_lowercase();
3095        if method != "Inspector.targetCrashed" && !(matches!(method, "Inspector.detached" | "Target.detachedFromTarget")
3096            && reason.contains("crash")) { return }
3097        let session = if method == "Target.detachedFromTarget" { value.pointer("/params/sessionId") } else { value.get("sessionId") }
3098            .and_then(Value::as_str).filter(|session| !session.is_empty());
3099        if let Some(session) = session {
3100            self.crashed_sessions.insert(session.to_string());
3101            if let Some(target) = self.session_target(session) {
3102                self.crashed_targets.insert(target.clone());
3103                self.crashed_sessions.extend(self.session_targets.iter().filter(|(_, owner)| *owner == &target).map(|(session, _)| session.clone()));
3104            }
3105        }
3106    }
3107
3108    fn raw_call(
3109        &mut self,
3110        method: &str,
3111        params: Value,
3112        session_id: Option<&str>,
3113        timeout: Duration,
3114    ) -> Result<Value, String> {
3115        self.raw_call_execution(method, params, session_id, timeout, false)
3116    }
3117
3118    fn raw_call_execution(
3119        &mut self,
3120        method: &str,
3121        params: Value,
3122        session_id: Option<&str>,
3123        timeout: Duration,
3124        pinned: bool,
3125    ) -> Result<Value, String> {
3126        let active = pinned.then_some(session_id).flatten();
3127        if let Some(session) = active { self.executing_sessions.insert(session.to_string()); }
3128        let result = self.raw_call_wait(method, params, session_id, timeout, pinned);
3129        if let Some(session) = active {
3130            self.executing_sessions.remove(session);
3131            if self.blocked.contains(session) { self.detach_blocked(session); }
3132        }
3133        result
3134    }
3135
3136    fn raw_call_wait(
3137        &mut self,
3138        method: &str,
3139        params: Value,
3140        session_id: Option<&str>,
3141        timeout: Duration,
3142        pinned: bool,
3143    ) -> Result<Value, String> {
3144        let frame_epoch = self.frame_epoch;
3145        let page = session_id.and_then(|session| self.session_target(session)).and_then(|target| self.page_scope_owner(&target))
3146            .map(|page| (page.clone(), self.page_scope_epochs.get(&page).copied().unwrap_or(0), self.page_scope_unknown));
3147        let result = self.raw_exchange(method, params, session_id, timeout, pinned)?;
3148        if method == "Page.getFrameTree" {
3149            let stable_page = page.is_some_and(|(page, epoch, unknown)| self.page_scope_epochs.get(&page).copied().unwrap_or(0) == epoch && self.page_scope_unknown == unknown);
3150            if !stable_page && !self.tree_current(&result, frame_epoch) {
3151                return Err(self.refusal());
3152            }
3153            self.record_frames(&result, session_id.unwrap_or(""));
3154        }
3155        Ok(result)
3156    }
3157
3158    fn raw_exchange(
3159        &mut self,
3160        method: &str,
3161        params: Value,
3162        session_id: Option<&str>,
3163        timeout: Duration,
3164        pinned: bool,
3165    ) -> Result<Value, String> {
3166        if self.renderer_crashed(session_id, None) { return Err(RENDERER_CRASHED.to_string()) }
3167        let id = self.send(method, params, session_id)?;
3168        self.waiting_calls.insert(id);
3169        let result = self.raw_reply(method, id, session_id, timeout, pinned);
3170        self.waiting_calls.remove(&id);
3171        self.call_replies.remove(&id);
3172        if method == "Page.getFrameTree" && session_id.is_some_and(|session| self.guard_sessions.contains(session)) {
3173            if let Ok(tree) = &result { let _ = self.verify_document_order(tree, session_id); }
3174        }
3175        result
3176    }
3177
3178    fn raw_reply(&mut self, method: &str, id: i64, session_id: Option<&str>, timeout: Duration, pinned: bool) -> Result<Value, String> {
3179        let deadline = Instant::now() + timeout;
3180        let was_blocked = session_id.is_some_and(|session| self.blocked.contains(session));
3181        loop {
3182            if self.renderer_crashed(session_id, None) { return Err(RENDERER_CRASHED.to_string()) }
3183            let value = match self.call_replies.remove(&id) {
3184                Some(value) => value,
3185                None => {
3186                    let text = match self.recv(deadline)? {
3187                        Some(text) => text,
3188                        None => return Err(if pinned && session_id.is_some_and(|session| self.blocked.contains(session)) { self.refusal() } else { format!("{method} timed out\n") }),
3189                    };
3190                    serde_json::from_str::<Value>(&text)
3191                        .map_err(|err| format!("CDP response was not JSON: {err}\n"))?
3192                }
3193            };
3194            if value.get("id").and_then(|item| item.as_i64()) == Some(id) {
3195                self.detach_reply(id, &value);
3196                if let Some(error) = value.get("error") {
3197                    if pinned && session_id.is_some_and(|session| self.blocked.contains(session)) { return Err(self.refusal()) }
3198                    let text = error
3199                        .get("message")
3200                        .and_then(|item| item.as_str())
3201                        .unwrap_or("CDP error");
3202                    let (text, _) = shield::redact_text(&self.guard, text);
3203                    return Err(format!("{method}: {text}\n"));
3204                }
3205                if method == "Target.attachToTarget" {
3206                    if let Some(session) = value.pointer("/result/sessionId").and_then(Value::as_str) {
3207                        self.blocked.remove(session);
3208                        self.detached_ordinary.remove(session);
3209                    }
3210                }
3211                return Ok(value.get("result").cloned().unwrap_or(Value::Null));
3212            }
3213            let detached = value.get("method").and_then(|v| v.as_str())
3214                == Some("Target.detachedFromTarget")
3215                && value.pointer("/params/sessionId").and_then(|v| v.as_str()) == session_id;
3216            self.absorb(value);
3217            if self.renderer_crashed(session_id, None) { return Err(RENDERER_CRASHED.to_string()) }
3218            if let Some(session) = session_id {
3219                if detached || (!pinned && !was_blocked && self.blocked.contains(session)) {
3220                    return Err(self.refusal());
3221                }
3222            }
3223        }
3224    }
3225
3226    fn send(&mut self, method: &str, params: Value, session_id: Option<&str>) -> Result<i64, String> {
3227        if self.next_id >= (1 << 30) - 1 { return Err("CDP client message ID range exhausted\n".into()) }
3228        self.webmcp_trace_fetch_command(method, &params, session_id);
3229        self.next_id += 1;
3230        let id = self.next_id;
3231        let mut message = json!({
3232            "id": id,
3233            "method": method,
3234            "params": params,
3235        });
3236        if let Some(session_id) = session_id {
3237            message["sessionId"] = json!(session_id);
3238        }
3239        self.ws.send_text(&message.to_string())?;
3240        if method == "Target.detachFromTarget" {
3241            if let Some(session) = message.pointer("/params/sessionId").and_then(Value::as_str) {
3242                self.detaching.insert(id, session.to_string());
3243            }
3244        }
3245        Ok(id)
3246    }
3247
3248    pub fn set_waiter(&mut self, waiter: Option<Waiter>) {
3249        self.waiter = waiter;
3250    }
3251
3252    fn recv(&mut self, deadline: Instant) -> Result<Option<String>, String> {
3253        loop {
3254            let Some(mut waiter) = self.waiter.take() else { return self.ws.recv_text(deadline) };
3255            let now = Instant::now();
3256            if now >= self.waiter_due {
3257                waiter(self);
3258                self.waiter_due = Instant::now() + WAITER_SLICE;
3259            }
3260            self.waiter = Some(waiter);
3261            let until = deadline.min(self.waiter_due);
3262            if let Some(text) = self.ws.recv_text(until)? { return Ok(Some(text)) }
3263            if until == deadline { return Ok(None) }
3264        }
3265    }
3266
3267    pub fn pump(&mut self, deadline: Instant) -> Result<bool, String> {
3268        self.webmcp_sweep();
3269        let result = match self.recv(deadline)? {
3270            Some(text) => {
3271                if let Ok(value) = serde_json::from_str::<Value>(&text) {
3272                    self.absorb(value);
3273                }
3274                Ok(true)
3275            }
3276            None => Ok(false),
3277        };
3278        self.webmcp_sweep();
3279        result
3280    }
3281
3282    pub fn next_paused(
3283        &mut self,
3284        session_id: Option<&str>,
3285        timeout: Duration,
3286    ) -> Result<Option<Paused>, String> {
3287        let deadline = Instant::now() + timeout;
3288        loop {
3289            let found = self
3290                .paused
3291                .iter()
3292                .position(|paused| session_id.is_none_or(|id| paused.session_id == id));
3293            if let Some(index) = found {
3294                return Ok(self.paused.remove(index));
3295            }
3296            if let Some(session) = session_id {
3297                if self.blocked.contains(session) {
3298                    return Err(self.refusal());
3299                }
3300            }
3301            if Instant::now() >= deadline || !self.pump(deadline)? {
3302                return Ok(None);
3303            }
3304        }
3305    }
3306
3307    pub fn wait_event(
3308        &mut self,
3309        from: usize,
3310        timeout: Duration,
3311        mut matches: impl FnMut(&Value) -> bool,
3312    ) -> Result<Option<Value>, String> {
3313        let deadline = Instant::now() + timeout;
3314        let mut seen = from;
3315        loop {
3316            while seen < self.events.len() {
3317                if matches(&self.events[seen]) {
3318                    return Ok(Some(self.events[seen].clone()));
3319                }
3320                seen += 1;
3321            }
3322            if Instant::now() >= deadline || !self.pump(deadline)? {
3323                return Ok(None);
3324            }
3325        }
3326    }
3327
3328    pub(crate) fn forget_paused(&mut self, session_id: &str) {
3329        self.paused.retain(|paused| paused.session_id != session_id);
3330        self.deciders.remove(session_id);
3331    }
3332
3333    fn detach_reply(&mut self, id: i64, value: &Value) {
3334        if let Some(session) = self.detaching.remove(&id) {
3335            if value.get("error").is_none() { self.forget_session(&session); }
3336        }
3337    }
3338
3339    fn forget_session(&mut self, session: &str) {
3340        self.webmcp_cancel_queued(session);
3341        if !self.blocked.contains(session) && !self.guard_sessions.contains(session) {
3342            self.detached_ordinary.insert(session.to_string());
3343        }
3344        let target = self.session_target(session);
3345        if self.blocked.contains(session) {
3346            if let Some(target) = &target { self.protected_targets.insert(target.clone()); }
3347            self.unchecked_documents.retain(|(_, owner, _), _| owner != session);
3348        }
3349        let guards: Vec<String> = self.guards.iter().filter(|(_, guard)| guard.as_str() == session).map(|(target, _)| target.clone()).collect();
3350        self.session_targets.remove(session);
3351        self.page_scope_sessions.retain(|_, owner| owner != session);
3352        self.guard_sessions.remove(session);
3353        self.blocked.insert(session.to_string());
3354        self.detaching.retain(|_, owner| owner != session);
3355        for target in guards {
3356            self.guards.remove(&target);
3357            self.documents.remove(&target);
3358            self.cached_documents.remove(&target);
3359            self.abandon(&target);
3360        }
3361        self.runtime_sessions.retain(|_, owner| owner != session);
3362        self.frame_sessions.remove(session);
3363        self.context_frames.retain(|(owner, _), _| owner != session);
3364        self.context_proofs.retain(|(owner, _), _| owner != session);
3365        self.created_contexts.retain(|(owner, _)| owner != session);
3366        self.executing_sessions.remove(session);
3367        self.dialog_sessions.remove(session);
3368        self.dialogs.remove(session);
3369        self.dialog_documents.remove(session);
3370        self.forget_paused(session);
3371        self.paused_documents.retain(|(owner, _)| owner != session);
3372        self.handed.retain(|(owner, _)| owner != session);
3373        self.answering.retain(|_, (owner, _)| owner != session);
3374        self.decided.retain(|_, (owner, _)| owner != session);
3375        self.held.remove(session);
3376        self.navigated_blanks.retain(|_, (owner, _, _)| owner != session);
3377        self.seen_network.retain(|(owner, _), _| owner != session);
3378        self.terminal_network.retain(|(owner, _)| owner != session);
3379        self.client_navigation_urls.remove(session);
3380        self.protected_request_pages.retain(|(owner, _)| owner != session);
3381        self.protected_resources.retain(|(owner, _)| owner != session);
3382        self.source_resources.retain(|(owner, _), _| owner != session);
3383        for resources in self.request_resources.values_mut() { resources.retain(|(owner, _)| owner != session); }
3384        for navigation in self.navigations.values_mut() {
3385            if navigation.session.as_deref() == Some(session) { navigation.detached = true; }
3386        }
3387        self.worlds.event(&json!({"method":"Target.detachedFromTarget","params":{"sessionId":session}}), &self.guard);
3388        self.issued.forget(session);
3389        self.webmcp.forget(session);
3390    }
3391
3392    fn absorb(&mut self, mut value: Value) {
3393        if let Some(id) = value.get("id").and_then(|item| item.as_i64()) {
3394            self.detach_reply(id, &value);
3395            if self.waiting_calls.contains(&id) {
3396                self.call_replies.insert(id, value.clone());
3397            }
3398            if let Some(navigation) = self.navigations.get_mut(&id) {
3399                navigation.reply = Some(value);
3400                return;
3401            }
3402            if let Some((session, request)) = self.answering.remove(&id) {
3403                if value.get("error").is_none() {
3404                    self.settle(&session, &request);
3405                }
3406            }
3407            if let Some((session, request)) = self.decided.remove(&id) {
3408                if let Some(error) = value.get("error") {
3409                    let text = error
3410                        .get("message")
3411                        .and_then(|item| item.as_str())
3412                        .unwrap_or("CDP error");
3413                    let (text, _) = shield::redact_text(&self.guard, text);
3414                    self.fetch_errors.push(format!("Fetch: {text}\n"));
3415                    self.continue_plain(&session, &request);
3416                }
3417            }
3418            return;
3419        }
3420        self.observe_renderer_crash(&value);
3421        self.observe_dialog(&value);
3422        if value.get("method").and_then(Value::as_str) == Some("Target.detachedFromTarget") {
3423            let session = value.pointer("/params/sessionId").and_then(Value::as_str);
3424            for navigation in self.navigations.values_mut() {
3425                if navigation.session.as_deref() == session { navigation.detached = true; }
3426            }
3427        }
3428        if self.webmcp_event(&value) {
3429            return;
3430        }
3431        if value.get("method").and_then(|item| item.as_str()) == Some("Fetch.authRequired")
3432            && self.challenge_protected(&value)
3433        {
3434            let id = value.pointer("/params/requestId").cloned().unwrap_or(Value::Null);
3435            if let Some(key) = id.as_str() {
3436                self.requests.insert(key.to_string(), true);
3437            }
3438            let session = value.get("sessionId").and_then(|v| v.as_str()).unwrap_or("").to_string();
3439            let _ = self.send(
3440                "Fetch.continueWithAuth",
3441                json!({ "requestId": id, "authChallengeResponse": { "response": "Default" } }),
3442                Some(&session),
3443            );
3444            return;
3445        }
3446        if value.get("method").and_then(Value::as_str) == Some("Fetch.authRequired") && self.guard_auth(&value) {
3447            return;
3448        }
3449        if value.get("method").and_then(|item| item.as_str()) == Some("Fetch.requestPaused") {
3450            let Some(mut paused) = Paused::from_event(&value) else {
3451                return;
3452            };
3453            if let Some(network) = &paused.network_id {
3454                self.request_aliases.entry(network.clone()).or_default().insert(paused.request_id.clone());
3455                self.request_aliases.entry(paused.request_id.clone()).or_default().insert(network.clone());
3456                if self.redirects.contains(network) {
3457                    self.mark_redirect(network);
3458                }
3459                if self.protected_body_requests.contains(network) { self.protect_body_request(network); }
3460            }
3461            if self.issued_hop(&value, &paused) { return; }
3462            let guard_only = self.guard_only(&paused);
3463            let authority_protected = shield::scheme_less_protected(&self.guard, &value["params"]["request"]);
3464            let redirect = self.actual_redirect_carrier(&value);
3465            if self.protected_carrier(&value, false) {
3466                self.protect_body_request(&paused.request_id);
3467                if let Some(network) = &paused.network_id { self.protect_body_request(network); self.protect_resource(&paused.session_id, &paused.url); }
3468            }
3469            let reaches = self.guard.url(&paused.full_url()) || redirect || authority_protected;
3470            let loaded_protected = self.guard.url(&paused.full_url()) || authority_protected;
3471            let protected = reaches;
3472            if loaded_protected {
3473                self.protect_resource(&paused.session_id, &paused.full_url());
3474            }
3475            if redirect {
3476                self.mark_redirect(&paused.request_id);
3477                if let Some(network) = &paused.network_id {
3478                    self.mark_redirect(network);
3479                    self.protect_resource(&paused.session_id, &paused.url);
3480                }
3481            }
3482            let initiated = self.initiated_by_protected(&paused);
3483            if self.redirect_page_protected(&value) || initiated {
3484                self.protected_request_pages.insert((paused.session_id.clone(), paused.request_id.clone()));
3485                if let Some(network) = &paused.network_id { self.protected_request_pages.insert((paused.session_id.clone(), network.clone())); }
3486            }
3487            #[cfg(test)]
3488            let observed_url = paused.url.clone();
3489            let ordinary_url = (!redirect && !loaded_protected && !initiated && !self.guard_sessions.contains(&paused.session_id) && !self.redirect_page_protected(&value)
3490                && paused.resource_type == "Document" && self.client_navigation_urls.get(&paused.session_id) == Some(&paused.url)
3491                && !self.protected_body_requests.contains(&paused.request_id)
3492                && self.ordinary_redirect_url(&paused.url)).then(|| paused.url.clone());
3493            shield::redact_strings(&self.guard, &mut value);
3494            if let Some(url) = ordinary_url { value["params"]["request"]["url"] = json!(url); }
3495            paused = Paused::from_event(&value).unwrap();
3496            self.requests.insert(paused.request_id.clone(), loaded_protected || initiated);
3497            if let Some(network) = &paused.network_id {
3498                let entry = self.requests.entry(network.clone()).or_insert(false);
3499                *entry = *entry || loaded_protected || initiated;
3500            }
3501            #[cfg(test)]
3502            {
3503                let reason = if self.guard_sessions.contains(&paused.session_id) {
3504                    "guard session"
3505                } else if protected {
3506                    "reaches a protected origin"
3507                } else if self.blocked.contains(&paused.session_id) {
3508                    "blocked session"
3509                } else if initiated {
3510                    "initiated by protected"
3511                } else if self.deciders.contains_key(&paused.session_id) {
3512                    "decider"
3513                } else {
3514                    "queued"
3515                };
3516                self.paused_trail
3517                    .push((paused.session_id.clone(), observed_url, reason));
3518            }
3519            if self.guard_sessions.contains(&paused.session_id) {
3520                if reaches && paused.resource_type == "Document" {
3521                    self.answer_protected(&paused);
3522                } else {
3523                    self.continue_plain(&paused.session_id, &paused.request_id);
3524                }
3525                return;
3526            }
3527            if protected {
3528                if redirect && !authority_protected && self.visible_redirect(&mut value) {
3529                    self.events.push(value);
3530                }
3531                self.answer_protected(&paused);
3532                return;
3533            }
3534            if self.blocked.contains(&paused.session_id) || initiated {
3535                self.fail_seen_network(&value);
3536                self.continue_plain(&paused.session_id, &paused.request_id);
3537                return;
3538            }
3539            if self.redirects.contains(&paused.request_id) && !redirect && !protected {
3540                self.continue_plain(&paused.session_id, &paused.request_id);
3541                return;
3542            }
3543            if guard_only {
3544                self.continue_plain(&paused.session_id, &paused.request_id);
3545                return;
3546            }
3547            if paused.resource_type == "Document" {
3548                self.paused_documents.insert((paused.session_id.clone(), paused.request_id.clone()));
3549            }
3550            self.hand(&paused.session_id, &paused.request_id);
3551            if self.deciders.contains_key(&paused.session_id) {
3552                self.decide_inline(paused);
3553            } else {
3554                self.paused.push_back(paused);
3555            }
3556            return;
3557        }
3558        if value.get("method").is_some() {
3559            self.deliver(value);
3560        }
3561    }
3562
3563    fn decide_inline(&mut self, paused: Paused) {
3564        if self.blocked.contains(&paused.session_id) {
3565            self.answer_protected(&paused);
3566            return;
3567        }
3568        if self.defer_issued(&paused) { return }
3569        let Some(decider) = self.deciders.get_mut(&paused.session_id) else {
3570            self.paused.push_back(paused);
3571            return;
3572        };
3573        let decision = decider(&paused);
3574        let (method, params, issued) = match fetch::command(&self.guard, &paused, &decision)
3575            .and_then(|(method, params)| self.client_issued(method, &params, Some(&paused.session_id)).map(|issued| (method, params, issued))) {
3576            Ok(command) => command,
3577            Err(message) => {
3578                self.fetch_errors.push(message);
3579                let (method, params) = fetch::command(&self.guard, &paused, &Decision::default())
3580                    .expect("a plain continue is always allowed");
3581                (method, params, false)
3582            }
3583        };
3584        if issued {
3585            if let Err(message) = self.issue(&params, &paused.session_id) {
3586                self.fetch_errors.push(message);
3587                self.continue_plain(&paused.session_id, &paused.request_id);
3588                return;
3589            }
3590        }
3591        self.settle(&paused.session_id, &paused.request_id);
3592        if let Ok(id) = self.send(method, params, Some(&paused.session_id)) {
3593            self.decided
3594                .insert(id, (paused.session_id.clone(), paused.request_id.clone()));
3595        }
3596    }
3597
3598    pub fn wait_load(&mut self, session_id: &str) -> Result<(), String> {
3599        if self.events.iter().any(|event| is_load(event, session_id)) {
3600            return Ok(());
3601        }
3602        let probe = self.call_for(
3603            "Runtime.evaluate",
3604            json!({
3605                "expression": "document.readyState",
3606                "returnByValue": true,
3607            }),
3608            Some(session_id),
3609            Duration::from_secs(1),
3610        );
3611        let settled = match &probe {
3612            Ok(result) => probe_is_settled(Ok(result
3613                .pointer("/result/value")
3614                .and_then(|value| value.as_str())))?,
3615            Err(err) => probe_is_settled(Err(err.as_str()))?,
3616        };
3617        if settled {
3618            return Ok(());
3619        }
3620        let deadline = Instant::now() + Duration::from_secs(15);
3621        loop {
3622            if self.blocked.contains(session_id) {
3623                return Err(self.refusal());
3624            }
3625            if self.events.iter().any(|event| is_load(event, session_id)) {
3626                return Ok(());
3627            }
3628            if Instant::now() >= deadline {
3629                return Ok(());
3630            }
3631            if !self.pump(deadline)? {
3632                return Ok(());
3633            }
3634        }
3635    }
3636
3637    pub fn wait_load_since(&mut self, session_id: &str, from: usize) -> Result<(), String> {
3638        let deadline = Instant::now() + Duration::from_secs(15);
3639        loop {
3640            if self.blocked.contains(session_id) {
3641                return Err(self.refusal());
3642            }
3643            if navigation_settled(&self.events, session_id, from) {
3644                return Ok(());
3645            }
3646            if Instant::now() >= deadline {
3647                return Ok(());
3648            }
3649            if !self.pump(deadline)? {
3650                return Ok(());
3651            }
3652        }
3653    }
3654}
3655
3656pub fn transport_error(message: &str) -> bool {
3657    message.starts_with("websocket ") || message.starts_with("CDP response was not JSON")
3658}
3659
3660fn unguarded(message: String) -> String {
3661    if transport_error(&message) || message == RENDERER_CRASHED {
3662        message
3663    } else if let Some(step) = message.strip_suffix(" timed out\n") {
3664        format!("{}: {step} timed out\n", UNGUARDED.trim_end())
3665    } else {
3666        UNGUARDED.to_string()
3667    }
3668}
3669
3670fn frame_text_known(text: &str) -> bool {
3671    !matches!(text, "" | "null" | "://")
3672}
3673
3674fn crosses_frames(method: &str, params: &Value) -> bool {
3675    matches!(method, "DOMSnapshot.captureSnapshot" | "DOMSnapshot.getSnapshot" | "DOM.getFlattenedDocument")
3676        || method.starts_with("DOM.") && (params.get("pierce").and_then(Value::as_bool) == Some(true)
3677            || params.get("depth").and_then(Value::as_i64).is_some_and(|depth| depth < 0 || depth > 1))
3678}
3679
3680fn recovery(method: &str, params: &Value) -> bool {
3681    match method {
3682        "Page.enable" | "Page.disable" | "Page.navigate" | "Page.reload" | "Page.stopLoading" | "Page.navigateToHistoryEntry"
3683        | "Page.getNavigationHistory" | "Page.getFrameTree" | "Page.close" | "Runtime.enable" | "Runtime.disable"
3684        | "Runtime.runIfWaitingForDebugger" | "Network.enable" | "Network.disable" | "Log.enable" | "Log.disable"
3685        | "Fetch.enable" | "Fetch.disable" | "Fetch.continueRequest" | "Fetch.failRequest" | "Fetch.fulfillRequest"
3686        | "Fetch.continueResponse" | "Fetch.continueWithAuth" => true,
3687        "Runtime.evaluate" => {
3688            matches!(params.get("expression").and_then(Value::as_str), Some("document.readyState" | "0"))
3689                && params.as_object().is_some_and(|object| object.keys().all(|key| matches!(key.as_str(), "expression" | "returnByValue")))
3690        }
3691        _ => false,
3692    }
3693}
3694
3695fn content_command(method: &str, params: &Value) -> Option<bool> {
3696    if method == "Runtime.evaluate"
3697        && matches!(params.get("expression").and_then(Value::as_str), Some("document.readyState" | "0" | "document.readyState === 'complete' ? location.href : ''"))
3698        && params.as_object().is_some_and(|object| object.keys().all(|key| matches!(key.as_str(), "expression" | "returnByValue")))
3699    { return Some(false) }
3700    if method == "Runtime.compileScript" && params.get("persistScript").and_then(Value::as_bool) != Some(false) { return None }
3701    if method == "Page.addScriptToEvaluateOnNewDocument" {
3702        return Some(params.get("runImmediately").is_some_and(|value| value != &json!(false)));
3703    }
3704    match method {
3705        "Debugger.setInstrumentationBreakpoint" | "Emulation.setVirtualTimePolicy" | "Overlay.setShowPaintRects"
3706        | "Page.addScriptToEvaluateOnLoad" | "Security.setIgnoreCertificateErrors" | "Inspector.enable" => None,
3707        "Runtime.evaluate" | "Runtime.callFunctionOn" | "Runtime.awaitPromise" | "Runtime.compileScript" | "Runtime.runScript"
3708        | "Runtime.getProperties" | "Runtime.getExceptionDetails" | "Runtime.queryObjects" | "Runtime.globalLexicalScopeNames" | "Runtime.getHeapUsage" | "Runtime.discardConsoleEntries"
3709        | "DOM.collectClassNamesFromSubtree" | "DOM.copyTo" | "DOM.describeNode" | "DOM.focus" | "DOM.getAttributes" | "DOM.getBoxModel" | "DOM.getContentQuads"
3710        | "DOM.getDocument" | "DOM.getFlattenedDocument" | "DOM.getNodesForSubtreeByStyle" | "DOM.getNodeForLocation" | "DOM.getOuterHTML" | "DOM.getRelayoutBoundary" | "DOM.getSearchResults"
3711        | "DOM.hideHighlight" | "DOM.highlightNode" | "DOM.highlightRect" | "DOM.markUndoableState" | "DOM.moveTo" | "DOM.performSearch" | "DOM.pushNodeByPathToFrontend"
3712        | "DOM.pushNodesByBackendIdsToFrontend" | "DOM.querySelector" | "DOM.querySelectorAll" | "DOM.redo" | "DOM.removeAttribute" | "DOM.removeNode" | "DOM.requestChildNodes" | "DOM.requestNode"
3713        | "DOM.resolveNode" | "DOM.setAttributeValue" | "DOM.setAttributesAsText" | "DOM.setFileInputFiles" | "DOM.setNodeName" | "DOM.setNodeValue" | "DOM.setOuterHTML" | "DOM.undo"
3714        | "DOM.getFrameOwner" | "DOM.getNodeStackTraces" | "DOM.getFileInfo" | "DOM.getDetachedDomNodes" | "DOM.getTopLayerElements" | "DOM.discardSearchResults" | "DOM.forceShowPopover"
3715        | "DOMSnapshot.captureSnapshot" | "DOMSnapshot.getSnapshot" | "DOMDebugger.getEventListeners"
3716        | "Input.dispatchDragEvent" | "Input.dispatchKeyEvent" | "Input.dispatchMouseEvent" | "Input.dispatchTouchEvent" | "Input.emulateTouchFromMouseEvent" | "Input.insertText"
3717        | "Input.synthesizePinchGesture" | "Input.synthesizeScrollGesture" | "Input.synthesizeTapGesture" | "Input.cancelDragging" | "Input.imeSetComposition"
3718        | "Accessibility.getFullAXTree" | "Accessibility.getPartialAXTree" | "Accessibility.getRootAXNode" | "Accessibility.getAXNodeAndAncestors" | "Accessibility.getChildAXNodes" | "Accessibility.queryAXTree"
3719        | "Page.captureScreenshot" | "Page.captureSnapshot" | "Page.printToPDF" | "Page.getResourceContent" | "Page.searchInResource" | "Page.getLayoutMetrics" | "Page.getPermissionsPolicyState" | "Page.setDocumentContent"
3720        | "Page.getAppManifest" | "Page.getInstallabilityErrors" | "Page.getAppId" | "Page.startScreencast" | "Page.generateTestReport"
3721        | "Network.getResponseBody" | "Network.getRequestPostData" | "Network.getResponseBodyForInterception" | "Network.searchInResponseBody" | "Network.loadNetworkResource"
3722        | "Network.streamResourceContent" | "Network.takeResponseBodyForInterceptionAsStream" | "Network.replayXHR" | "Audits.getEncodedResponse"
3723        | "Fetch.getResponseBody" | "Fetch.takeResponseBodyAsStream" | "IO.read" | "IO.close" | "IO.resolveBlob"
3724        | "Debugger.evaluateOnCallFrame" | "Debugger.getScriptSource" | "Debugger.searchInContent" | "Debugger.setScriptSource" | "Debugger.setVariableValue" | "Debugger.restartFrame" | "Debugger.stepInto" | "Debugger.stepOut" | "Debugger.stepOver"
3725        | "Debugger.getWasmBytecode" | "Debugger.disassembleWasmModule" | "Debugger.nextWasmDisassemblyChunk" | "Debugger.getPossibleBreakpoints" | "Debugger.pause"
3726        | "HeapProfiler.takeHeapSnapshot" | "HeapProfiler.getHeapObjectId" | "HeapProfiler.getObjectByHeapObjectId" | "HeapProfiler.getSamplingProfile" | "HeapProfiler.stopSampling" | "HeapProfiler.stopTrackingHeapObjects"
3727        | "DOMStorage.getDOMStorageItems" | "DOMStorage.setDOMStorageItem" | "DOMStorage.removeDOMStorageItem" | "DOMStorage.clear"
3728        | "Storage.clearDataForOrigin" | "Storage.clearDataForStorageKey" | "Storage.getUsageAndQuota" | "Storage.getStorageKeyForFrame"
3729        | "CacheStorage.requestCacheNames" | "CacheStorage.requestEntries" | "CacheStorage.requestCachedResponse" | "CacheStorage.deleteCache" | "CacheStorage.deleteEntry"
3730        | "IndexedDB.requestDatabaseNames" | "IndexedDB.requestDatabase" | "IndexedDB.requestData" | "IndexedDB.deleteDatabase" | "IndexedDB.deleteObjectStoreEntries" | "IndexedDB.clearObjectStore" | "IndexedDB.getMetadata"
3731        | "FileSystem.getDirectory"
3732        | "Target.sendMessageToTarget" | "Target.exposeDevToolsProtocol"
3733        | "Profiler.stop" | "Profiler.takePreciseCoverage" | "Profiler.getBestEffortCoverage" | "CSS.getStyleSheetText" | "CSS.getComputedStyleForNode" | "CSS.getMatchedStylesForNode" | "CSS.getInlineStylesForNode"
3734        | "CSS.setStyleSheetText" | "CSS.setStyleTexts" | "CSS.setEffectivePropertyValueForNode" | "CSS.getMediaQueries" | "CSS.getPlatformFontsForNode" => Some(true),
3735        "Page.enable" | "Page.disable" | "Page.navigate" | "Page.reload" | "Page.stopLoading" | "Page.navigateToHistoryEntry" | "Page.close" | "Page.bringToFront"
3736        | "Page.getFrameTree" | "Page.getResourceTree" | "Page.getNavigationHistory" | "Page.handleJavaScriptDialog" | "Page.createIsolatedWorld" | "Page.setLifecycleEventsEnabled" | "Page.removeScriptToEvaluateOnNewDocument"
3737        | "Page.setBypassCSP" | "Page.setWebLifecycleState" | "Page.setInterceptFileChooserDialog" | "Page.stopScreencast" | "Page.screencastFrameAck" | "Page.setDownloadBehavior" | "Page.getCookies" | "Page.deleteCookie"
3738        | "Runtime.enable" | "Runtime.disable" | "Runtime.runIfWaitingForDebugger" | "Runtime.addBinding" | "Runtime.removeBinding" | "Runtime.releaseObject" | "Runtime.releaseObjectGroup" | "Runtime.getIsolateId"
3739        | "Runtime.setAsyncCallStackDepth" | "Runtime.setCustomObjectFormatterEnabled" | "Runtime.setMaxCallStackSizeToCapture" | "Runtime.terminateExecution"
3740        | "DOM.enable" | "DOM.disable" | "DOM.setNodeStackTracesEnabled" | "DOMSnapshot.enable" | "DOMSnapshot.disable" | "Accessibility.enable" | "Accessibility.disable" | "CSS.enable" | "CSS.disable"
3741        | "Input.setIgnoreInputEvents" | "Input.setInterceptDrags" | "Fetch.enable" | "Fetch.disable" | "Fetch.continueRequest" | "Fetch.fulfillRequest" | "Fetch.failRequest" | "Fetch.continueWithAuth" | "Fetch.continueResponse"
3742        | "Network.enable" | "Network.disable" | "Network.setBypassServiceWorker" | "Network.setCacheDisabled" | "Network.setExtraHTTPHeaders" | "Network.setRequestInterception" | "Network.continueInterceptedRequest"
3743        | "Network.setUserAgentOverride" | "Network.setBlockedURLs" | "Network.emulateNetworkConditions" | "Network.emulateNetworkConditionsByRule" | "Network.overrideNetworkState"
3744        | "Network.getCookies" | "Network.getAllCookies" | "Network.setCookie" | "Network.setCookies" | "Network.deleteCookies" | "Network.clearBrowserCookies" | "Network.clearBrowserCache" | "Network.setAcceptedEncodings" | "Network.clearAcceptedEncodingsOverride"
3745        | "Target.attachToTarget" | "Target.attachToBrowserTarget" | "Target.detachFromTarget" | "Target.createTarget" | "Target.closeTarget" | "Target.activateTarget" | "Target.getTargets" | "Target.getTargetInfo"
3746        | "Target.setDiscoverTargets" | "Target.setAutoAttach" | "Target.autoAttachRelated" | "Target.createBrowserContext" | "Target.disposeBrowserContext" | "Target.getBrowserContexts"
3747        | "Browser.getVersion" | "Browser.close" | "Browser.getWindowForTarget" | "Browser.getWindowBounds" | "Browser.setWindowBounds" | "Browser.grantPermissions" | "Browser.resetPermissions" | "Browser.setPermission"
3748        | "Browser.setDownloadBehavior" | "Browser.cancelDownload" | "Browser.getBrowserCommandLine" | "Browser.getHistograms" | "Browser.getHistogram" | "Browser.executeBrowserCommand" | "Browser.setDockTile"
3749        | "Emulation.setCPUThrottlingRate" | "Emulation.setFocusEmulationEnabled" | "Emulation.setDeviceMetricsOverride" | "Emulation.clearDeviceMetricsOverride" | "Emulation.setTouchEmulationEnabled"
3750        | "Emulation.setUserAgentOverride" | "Emulation.setLocaleOverride" | "Emulation.setTimezoneOverride" | "Emulation.setEmulatedMedia" | "Emulation.setGeolocationOverride" | "Emulation.clearGeolocationOverride"
3751        | "Emulation.setScriptExecutionDisabled" | "Emulation.setEmulatedVisionDeficiency" | "Emulation.setEmitTouchEventsForMouse" | "Emulation.setScrollbarsHidden" | "Emulation.setDefaultBackgroundColorOverride"
3752        | "Debugger.enable" | "Debugger.disable" | "Debugger.resume" | "Debugger.setBreakpoint" | "Debugger.setBreakpointByUrl" | "Debugger.removeBreakpoint"
3753        | "Debugger.setBreakpointsActive" | "Debugger.setPauseOnExceptions" | "Debugger.setSkipAllPauses" | "Debugger.setAsyncCallStackDepth" | "Debugger.setBlackboxPatterns" | "Debugger.setBlackboxedRanges"
3754        | "Profiler.enable" | "Profiler.disable" | "Profiler.start" | "Profiler.startPreciseCoverage" | "Profiler.stopPreciseCoverage" | "Profiler.setSamplingInterval"
3755        | "HeapProfiler.enable" | "HeapProfiler.disable" | "HeapProfiler.startSampling" | "HeapProfiler.startTrackingHeapObjects" | "HeapProfiler.collectGarbage"
3756        | "Log.enable" | "Log.disable" | "Log.clear" | "Log.startViolationsReport" | "Log.stopViolationsReport" | "Audits.enable" | "Audits.disable"
3757        | "DOMStorage.enable" | "DOMStorage.disable"
3758        | "Storage.getCookies" | "Storage.setCookies" | "Storage.clearCookies"
3759        | "IndexedDB.enable" | "IndexedDB.disable" | "IndexedDB.releaseObject"
3760        | "ServiceWorker.enable" | "ServiceWorker.disable" | "ServiceWorker.unregister" | "ServiceWorker.startWorker" | "ServiceWorker.stopWorker" | "ServiceWorker.stopAllWorkers" | "ServiceWorker.updateRegistration"
3761        | "PWA.install" | "PWA.launch" | "PWA.uninstall" | "Extensions.loadUnpacked" | "Extensions.uninstall" | "Extensions.getExtensions" | "SystemInfo.getInfo" | "SystemInfo.getProcessInfo"
3762        | "Tracing.start" | "Tracing.end" | "Tracing.getCategories" | "Tracing.recordClockSyncMarker" | "Tracing.requestMemoryDump" | "Slicc.action" => Some(false),
3763        _ => None,
3764    }
3765}
3766
3767fn storage_content(method: &str) -> bool {
3768    matches!(method, "Storage.getStorageKeyForFrame" | "FileSystem.getDirectory")
3769        || ["DOMStorage.", "IndexedDB.", "CacheStorage."].iter().any(|prefix| method.starts_with(prefix))
3770        || matches!(method, "Storage.clearDataForOrigin" | "Storage.clearDataForStorageKey" | "Storage.getUsageAndQuota")
3771}
3772
3773fn harmless(method: &str, params: &Value) -> bool {
3774    match method {
3775        "Fetch.disable" => true,
3776        "Fetch.continueRequest" => params
3777            .as_object()
3778            .is_some_and(|object| object.keys().all(|key| key == "requestId")),
3779        _ => false,
3780    }
3781}
3782
3783fn probe_is_settled(outcome: Result<Option<&str>, &str>) -> Result<bool, String> {
3784    match outcome {
3785        Ok(Some("loading")) | Ok(Some("interactive")) => Ok(false),
3786        Ok(_) => Ok(true),
3787        Err(err)
3788            if err.starts_with("Runtime.evaluate timed out")
3789                || err.starts_with("Runtime.evaluate:") =>
3790        {
3791            Ok(true)
3792        }
3793        Err(err) => Err(err.to_string()),
3794    }
3795}
3796
3797fn navigation_settled(events: &[Value], session_id: &str, from: usize) -> bool {
3798    let mut committed = false;
3799    for event in events.iter().skip(from) {
3800        if !committed && is_commit(event, session_id) {
3801            committed = true;
3802            continue;
3803        }
3804        if committed && is_load(event, session_id) {
3805            return true;
3806        }
3807    }
3808    false
3809}
3810
3811fn is_commit(event: &Value, session_id: &str) -> bool {
3812    if event.get("sessionId").and_then(|value| value.as_str()) != Some(session_id) {
3813        return false;
3814    }
3815    if event.get("method").and_then(|value| value.as_str()) != Some("Page.frameNavigated") {
3816        return false;
3817    }
3818    let url = event
3819        .pointer("/params/frame/url")
3820        .and_then(|value| value.as_str())
3821        .unwrap_or("");
3822    !url.is_empty() && !url.eq_ignore_ascii_case("about:blank")
3823}
3824
3825fn is_load(event: &Value, session_id: &str) -> bool {
3826    if event.get("sessionId").and_then(|v| v.as_str()) != Some(session_id) {
3827        return false;
3828    }
3829    match event.get("method").and_then(|v| v.as_str()) {
3830        Some("Page.loadEventFired") => true,
3831        Some("Page.lifecycleEvent") => {
3832            event.pointer("/params/name").and_then(|v| v.as_str()) == Some("load")
3833        }
3834        _ => false,
3835    }
3836}
3837
3838pub fn attach(cdp: &mut Cdp, target_id: &str) -> Result<String, String> {
3839    let result = cdp.call(
3840        "Target.attachToTarget",
3841        json!({ "targetId": target_id, "flatten": true }),
3842        None,
3843    )?;
3844    result
3845        .get("sessionId")
3846        .and_then(|v| v.as_str())
3847        .map(str::to_string)
3848        .ok_or_else(|| "Target.attachToTarget returned no sessionId\n".to_string())
3849}
3850
3851pub fn detach(cdp: &mut Cdp, session_id: &str) {
3852    let _ = cdp.call(
3853        "Target.detachFromTarget",
3854        json!({ "sessionId": session_id }),
3855        None,
3856    );
3857}
3858
3859#[derive(Clone, Debug, Default)]
3860pub struct ActionNote {
3861    pub kind: String,
3862    pub tab: Option<String>,
3863    pub target: Option<String>,
3864    pub url: Option<String>,
3865    pub key: Option<String>,
3866    pub length: Option<u64>,
3867    pub method: Option<String>,
3868    pub agent: Option<String>,
3869}
3870
3871impl ActionNote {
3872    pub fn start_params(&self) -> Value {
3873        let mut params = serde_json::Map::new();
3874        params.insert("phase".into(), json!("start"));
3875        params.insert("kind".into(), json!(self.kind));
3876        insert_text(&mut params, "tab", self.tab.as_deref());
3877        insert_text(&mut params, "target", self.target.as_deref());
3878        if let Some(url) = self.url.as_deref().and_then(public_url) {
3879            params.insert("url".into(), json!(url));
3880        }
3881        insert_text(&mut params, "key", self.key.as_deref());
3882        if let Some(length) = self.length {
3883            params.insert("length".into(), json!(length));
3884        }
3885        insert_text(&mut params, "method", self.method.as_deref());
3886        insert_text(&mut params, "agent", self.agent.as_deref());
3887        Value::Object(params)
3888    }
3889
3890    pub fn end_params(&self, ok: bool, stderr: &str) -> Value {
3891        let mut params = serde_json::Map::new();
3892        params.insert("phase".into(), json!("end"));
3893        params.insert("ok".into(), json!(ok));
3894        if !ok {
3895            if let Some(error) = action_error(&self.kind, stderr) {
3896                params.insert("error".into(), json!(error));
3897            }
3898        }
3899        insert_text(&mut params, "tab", self.tab.as_deref());
3900        Value::Object(params)
3901    }
3902}
3903
3904fn insert_text(params: &mut serde_json::Map<String, Value>, key: &str, value: Option<&str>) {
3905    if let Some(value) = value.filter(|value| !value.is_empty()) {
3906        params.insert(key.to_string(), json!(value));
3907    }
3908}
3909
3910pub fn first_stderr_line(stderr: &str) -> Option<&str> {
3911    stderr.lines().next().filter(|line| !line.is_empty())
3912}
3913
3914fn public_url(raw: &str) -> Option<String> {
3915    let raw = raw.trim();
3916    if raw.is_empty() {
3917        return None;
3918    }
3919    let without_fragment = raw.split_once('#').map(|(head, _)| head).unwrap_or(raw);
3920    let without_query = without_fragment
3921        .split_once('?')
3922        .map(|(head, _)| head)
3923        .unwrap_or(without_fragment);
3924    if without_query.is_empty() {
3925        return None;
3926    }
3927    if is_about_blank(without_query) {
3928        return Some("about:blank".to_string());
3929    }
3930    if let Some(rest) = without_query.strip_prefix("//") {
3931        let (host, path) = host_and_path(rest)?;
3932        return Some(format!("//{host}{path}"));
3933    }
3934    let (scheme, rest) = without_query.split_once(':')?;
3935    if !is_scheme(scheme) || !scheme_allowed(scheme) {
3936        return None;
3937    }
3938    let scheme = scheme.to_ascii_lowercase();
3939    let Some(authority) = rest.strip_prefix("//") else {
3940        return Some(format!("{scheme}:"));
3941    };
3942    let (host, path) = host_and_path(authority)?;
3943    Some(format!("{scheme}://{host}{path}"))
3944}
3945
3946fn scheme_allowed(scheme: &str) -> bool {
3947    matches!(
3948        scheme.to_ascii_lowercase().as_str(),
3949        "http"
3950            | "https"
3951            | "ws"
3952            | "wss"
3953            | "file"
3954            | "data"
3955            | "javascript"
3956            | "blob"
3957            | "about"
3958            | "chrome"
3959            | "chrome-extension"
3960            | "view-source"
3961            | "ftp"
3962    )
3963}
3964
3965fn is_about_blank(url: &str) -> bool {
3966    let Some((scheme, rest)) = url.split_once(':') else {
3967        return false;
3968    };
3969    scheme.eq_ignore_ascii_case("about") && rest.eq_ignore_ascii_case("blank")
3970}
3971
3972fn is_scheme(scheme: &str) -> bool {
3973    let mut chars = scheme.chars();
3974    match chars.next() {
3975        Some(ch) if ch.is_ascii_alphabetic() => {}
3976        _ => return false,
3977    }
3978    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.')
3979}
3980
3981fn host_and_path(rest: &str) -> Option<(&str, &str)> {
3982    let (authority, path) = match rest.find('/') {
3983        Some(index) => (&rest[..index], &rest[index..]),
3984        None => (rest, ""),
3985    };
3986    let host = match authority.rfind('@') {
3987        Some(index) => &authority[index + 1..],
3988        None => authority,
3989    };
3990    if host.is_empty() { None } else { Some((host, path)) }
3991}
3992
3993fn action_error(kind: &str, stderr: &str) -> Option<&'static str> {
3994    if kind == "eval" {
3995        return None;
3996    }
3997    let line = first_stderr_line(stderr).unwrap_or("").to_ascii_lowercase();
3998    if line.contains("timed out") {
3999        return Some("timeout");
4000    }
4001    if line.contains("websocket closed")
4002        || line.contains("websocket connection closed")
4003        || line.contains("websocket write failed")
4004        || line.contains("websocket read failed")
4005        || line.contains("connection reset")
4006        || line.contains("broken pipe")
4007    {
4008        return Some("connection lost");
4009    }
4010    if line.contains("no snapshot") || line.contains("likely stale") {
4011        return Some("no snapshot");
4012    }
4013    if line.contains("unknown ref")
4014        || line.contains("element not found")
4015        || line.contains("not an element ref")
4016        || line.contains("could not resolve")
4017        || line.contains("no backend node")
4018    {
4019        return Some("element not found");
4020    }
4021    if line.contains("no open tab is on") {
4022        return Some("not allowed");
4023    }
4024    if line.contains("unknown tab")
4025        || line.contains("--tab")
4026        || line.contains("no open tabs")
4027        || line.contains("explicit tab")
4028    {
4029        return Some("unknown tab");
4030    }
4031    if line.starts_with("navigate:") || line.contains("page.navigate") {
4032        return Some("navigation failed");
4033    }
4034    Some("failed")
4035}
4036
4037pub fn slicc_agent() -> Option<String> {
4038    std::env::var("SLICC_AGENT")
4039        .ok()
4040        .filter(|value| !value.is_empty())
4041}
4042
4043pub fn open(start: crate::connect::Start, runtime: Option<&str>) -> Result<Cdp, String> {
4044    let socket = match start {
4045        crate::connect::Start::Direct(url) => url,
4046        crate::connect::Start::Discover(url) => {
4047            let response = match crate::net::http_get(&url) {
4048                Ok(response) => response,
4049                Err(message) => {
4050                    if message.starts_with("HTTP ") || message.contains("does not launch Chrome") {
4051                        return Err(message);
4052                    }
4053                    return Err(format!(
4054                        "{message}This CLI does not launch Chrome. Pass --cdp or set SLICC_CDP_URL.\n"
4055                    ));
4056                }
4057            };
4058            let body = String::from_utf8_lossy(&response.body).to_string();
4059            crate::connect::websocket_url_from_version(&body)?
4060        }
4061    };
4062    let socket = crate::connect::append_runtime(&socket, runtime);
4063    Cdp::connect(&socket)
4064}
4065
4066include!("dialog.rs");
4067include!("front.rs");
4068include!("window.rs");
4069
4070#[cfg(test)]
4071mod tests {
4072    use super::{first_stderr_line, probe_is_settled, ActionNote, Cdp, Transport};
4073    use serde_json::{json, Value};
4074    use std::collections::VecDeque;
4075    use std::time::{Duration, Instant};
4076
4077    include!("persistent_test.rs");
4078    include!("redirect_test.rs");
4079    include!("redirect_matrix_test.rs");
4080    include!("redirect_race_test.rs");
4081    include!("redirect_terminal_test.rs");
4082    include!("redirect_terminal_guard_test.rs");
4083    include!("redirect_c2_carrier_test.rs");
4084    include!("redirect_grok_test.rs");
4085    include!("redirect_scope_review_test.rs");
4086    include!("ordinary_network_review_test.rs");
4087    include!("pause_metadata_recovery_test.rs");
4088    include!("navigation_echo_test.rs");
4089    include!("refresh_review_test.rs");
4090    include!("refresh_forms_review_test.rs");
4091    include!("redirect_target_taint_test.rs");
4092    include!("redirect_resource_control_test.rs");
4093    include!("navigation_queue_test.rs");
4094    include!("redirect_fragment_test.rs");
4095    include!("redirect_review_test.rs");
4096    include!("redirect_relation_test.rs");
4097    include!("redirect_userinfo_test.rs");
4098    include!("guard_attach_tree_test.rs");
4099    include!("front_test.rs");
4100    include!("window_test.rs");
4101    include!("settled_test.rs");
4102    include!("renderer_crash_test.rs");
4103    include!("client_id_test.rs");
4104    include!("late_reply_test.rs");
4105
4106    #[test]
4107    fn queued_raw_replies_keep_pinned_execution_and_guard_denial_semantics() {
4108        let mut cdp = pending(|_| Vec::new());
4109        cdp.blocked.insert("S".into());
4110        cdp.call_replies.insert(700, json!({"id":700,"error":{"message":"Cannot find context with specified id"}}));
4111        assert_eq!(cdp.raw_reply("Runtime.evaluate", 700, Some("S"), Duration::ZERO, true).unwrap_err(), cdp.refusal());
4112        cdp.call_replies.insert(701, json!({"id":701,"result":{"result":{"value":"ordinary-executed-value"}}}));
4113        assert_eq!(cdp.raw_reply("Runtime.evaluate", 701, Some("S"), Duration::ZERO, true).unwrap()["result"]["value"], "ordinary-executed-value");
4114        assert_eq!(cdp.raw_reply("Runtime.evaluate", 702, Some("S"), Duration::ZERO, true).unwrap_err(), cdp.refusal());
4115        assert!(cdp.call_replies.is_empty());
4116    }
4117
4118    struct Pending {
4119        inbox: VecDeque<String>,
4120        answer: fn(&Value) -> Vec<Value>,
4121    }
4122
4123    impl Transport for Pending {
4124        fn send_text(&mut self, text: &str) -> Result<(), String> {
4125            let message: Value = serde_json::from_str(text).unwrap();
4126            for out in (self.answer)(&message) {
4127                self.inbox.push_back(out.to_string());
4128            }
4129            Ok(())
4130        }
4131
4132        fn recv_text(&mut self, _deadline: Instant) -> Result<Option<String>, String> {
4133            Ok(self.inbox.pop_front())
4134        }
4135    }
4136
4137    fn pending(answer: fn(&Value) -> Vec<Value>) -> Cdp {
4138        let mut cdp = Cdp::from_transport(Box::new(Pending {
4139            inbox: VecDeque::new(),
4140            answer,
4141        }));
4142        cdp.fixture_session("S");
4143        cdp
4144    }
4145
4146    struct Cleanup {
4147        inner: Pending,
4148        destroy_after: Option<Duration>,
4149        destroyed_at: Option<Instant>,
4150        closed: bool,
4151    }
4152
4153    impl Transport for Cleanup {
4154        fn send_text(&mut self, text: &str) -> Result<(), String> {
4155            self.inner.send_text(text)?;
4156            let message: Value = serde_json::from_str(text).unwrap();
4157            if message["method"] == "Target.closeTarget" {
4158                self.closed = true;
4159                self.destroyed_at = self.destroy_after.map(|delay| Instant::now() + delay);
4160            }
4161            Ok(())
4162        }
4163
4164        fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
4165            if let Some(message) = self.inner.inbox.pop_front() {
4166                return Ok(Some(message));
4167            }
4168            if let Some(at) = self.destroyed_at.take() {
4169                if at <= deadline {
4170                    std::thread::sleep(at.saturating_duration_since(Instant::now()));
4171                    return Ok(Some(json!({
4172                        "method": "Target.targetDestroyed", "params": { "targetId": "T" }
4173                    }).to_string()));
4174                }
4175                self.destroyed_at = Some(at);
4176            }
4177            if self.closed {
4178                std::thread::sleep(deadline.saturating_duration_since(Instant::now()));
4179            }
4180            Ok(None)
4181        }
4182    }
4183
4184    fn error_page_client() -> Cdp {
4185        error_page_client_url("https://seven.sliccy.ai/path?secret#fragment")
4186    }
4187
4188    fn error_page_client_url(url: &str) -> Cdp {
4189        let mut cdp = url_backend(url);
4190        cdp.session_targets.insert("S".into(), "T".into());
4191        cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://example.test/"}}}));
4192        cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://example.test/","securityOrigin":"https://example.test"}}}));
4193        cdp
4194    }
4195
4196    fn observe_document(cdp: &mut Cdp) {
4197        struct Observed {
4198            inner: Box<dyn Transport>,
4199            frame: Value,
4200            inbox: VecDeque<String>,
4201        }
4202        impl Transport for Observed {
4203            fn send_text(&mut self, text: &str) -> Result<(), String> {
4204                let call: Value = serde_json::from_str(text).unwrap();
4205                if call["method"] == "Target.getTargets" {
4206                    self.inbox.push_back(json!({"id":call["id"],"result":{"targetInfos":[{"targetId":"T","type":"page","url":self.frame["url"]}]}}).to_string());
4207                    Ok(())
4208                } else if call["method"] == "Page.getFrameTree" {
4209                    self.inbox.push_back(json!({"id":call["id"],"result":{"frameTree":{"frame":self.frame}}}).to_string());
4210                    Ok(())
4211                } else if call["method"] == "Runtime.enable" {
4212                    self.inbox.push_back(json!({"method":"Runtime.executionContextCreated","sessionId":call["sessionId"],"params":{"context":{"id":1,"uniqueId":"observed-context","origin":self.frame["securityOrigin"],"name":"","auxData":{"frameId":self.frame["id"],"isDefault":true}}}}).to_string());
4213                    self.inbox.push_back(json!({"id":call["id"],"result":{}}).to_string());
4214                    Ok(())
4215                } else {
4216                    self.inner.send_text(text)
4217                }
4218            }
4219            fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
4220                match self.inbox.pop_front() {
4221                    Some(text) => Ok(Some(text)),
4222                    None => self.inner.recv_text(deadline),
4223                }
4224            }
4225        }
4226        let doc = &cdp.documents["T"];
4227        let frame = json!({"id":doc.frame_id,"loaderId":"L","url":doc.url,"securityOrigin":doc.origin,"unreachableUrl":doc.unreachable_url});
4228        let inner = std::mem::replace(&mut cdp.ws, Box::new(Pending { inbox:VecDeque::new(), answer: |_| Vec::new() }));
4229        cdp.ws = Box::new(Observed { inner, frame, inbox:VecDeque::new() });
4230        cdp.guards.insert("T".into(), "G".into());
4231        cdp.guard_sessions.insert("G".into());
4232    }
4233
4234    fn error_commit(cdp: &mut Cdp, unreachable: &str, origin: &str) {
4235        cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"chrome-error://chromewebdata/","securityOrigin":origin,"unreachableUrl":unreachable}}}));
4236    }
4237
4238    #[test]
4239    fn protected_error_page_retains_allowed_recovery_and_redacts_discovery() {
4240        let url = "https://seven.sliccy.ai/path?secret#fragment";
4241        let mut cdp = error_page_client();
4242        error_commit(&mut cdp, url, "://");
4243        let info = json!({"targetId":"T","type":"page","url":url,"title":"SECRET"});
4244        for method in ["Target.targetCreated", "Target.targetInfoChanged", "Target.attachedToTarget"] {
4245            cdp.absorb(json!({"method":method,"params":{"sessionId":"A","targetInfo":info}}));
4246            let event = cdp.events().last().unwrap();
4247            assert_eq!(event["params"]["targetInfo"]["url"], "https://seven.sliccy.ai", "{method}");
4248            assert_eq!(event["params"]["targetInfo"]["title"], "https://seven.sliccy.ai", "{method}");
4249        }
4250        assert!(!cdp.target_protected("T", false).unwrap());
4251        assert!(!cdp.session_blocked("S"));
4252        let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
4253        assert_eq!(listed["targetInfos"][0]["url"], "https://seven.sliccy.ai");
4254        assert_eq!(listed["targetInfos"][0]["title"], "https://seven.sliccy.ai");
4255        let found = cdp.call("Target.getTargetInfo", json!({"targetId":"T"}), None).unwrap();
4256        assert_eq!(found["targetInfo"]["url"], "https://seven.sliccy.ai");
4257        assert_eq!(found["targetInfo"]["title"], "https://seven.sliccy.ai");
4258        observe_document(&mut cdp);
4259        for method in ["Page.reload", "Page.navigate", "Target.closeTarget"] {
4260            let (params, session) = if method == "Target.closeTarget" {
4261                (json!({"targetId":"T"}), None)
4262            } else {
4263                (json!({"url":"about:blank"}), Some("S"))
4264            };
4265            assert!(cdp.call(method, params, session).is_ok(), "{method}");
4266        }
4267        assert!(cdp.call("Page.navigate", json!({"url":url}), Some("S")).is_err());
4268    }
4269
4270    #[test]
4271    fn protected_error_page_pending_report_does_not_change_previous_decision() {
4272        let mut cdp = error_page_client();
4273        cdp.absorb(json!({"method":"Page.frameStartedNavigating","sessionId":"S","params":{"frameId":"F","url":"https://seven.sliccy.ai/path?secret#fragment"}}));
4274        cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://seven.sliccy.ai/path?secret#fragment"}}}));
4275        assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.title"}), Some("S")).is_err());
4276        error_commit(&mut cdp, "https://seven.sliccy.ai/path?secret#fragment", "://");
4277        assert!(!cdp.target_protected("T", false).unwrap());
4278        observe_document(&mut cdp);
4279        assert!(cdp.call("Page.reload", json!({}), Some("S")).is_ok());
4280    }
4281
4282    #[test]
4283    fn protected_error_page_unknown_mismatch_origin_and_inflight_fail_closed() {
4284        let url = "https://seven.sliccy.ai/path?secret#fragment";
4285        for (unreachable, origin, navigating) in [(url, "://", true), ("", "://", false), ("https://seven.sliccy.ai/other", "://", false), (url, "null", false), (url, "https://example.test", false)] {
4286            let mut cdp = error_page_client();
4287            error_commit(&mut cdp, unreachable, origin);
4288            if navigating {
4289                cdp.absorb(json!({"method":"Page.frameStartedNavigating","sessionId":"S","params":{"frameId":"F","url":url}}));
4290            }
4291            assert!(cdp.target_protected("T", false).unwrap(), "{unreachable} {origin} {navigating}");
4292            assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.title"}), Some("S")).is_err());
4293        }
4294        let mut cdp = url_backend(url);
4295        cdp.session_targets.insert("S".into(), "T".into());
4296        error_commit(&mut cdp, url, "://");
4297        assert!(cdp.target_protected("T", false).unwrap());
4298    }
4299
4300    #[test]
4301    fn protected_document_restore_and_error_page_sticky_lock() {
4302        let mut cdp = error_page_client();
4303        let url = "https://seven.sliccy.ai/path?secret#fragment";
4304        error_commit(&mut cdp, url, "://");
4305        assert!(!cdp.target_protected("T", false).unwrap());
4306        cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"type":"BackForwardCacheRestore","frame":{"id":"F","url":"https://seven.sliccy.ai/real-document","securityOrigin":"https://seven.sliccy.ai"}}}));
4307        assert!(cdp.is_protected_target("T"));
4308        error_commit(&mut cdp, url, "://");
4309        for method in ["Runtime.evaluate", "Page.reload", "Page.navigate", "Target.closeTarget", "Target.attachToTarget"] {
4310            let params = json!({"targetId":"T","url":"about:blank","flatten":true});
4311            assert!(cdp.call(method, params, Some("S")).is_err(), "{method}");
4312        }
4313    }
4314
4315    #[test]
4316    fn protected_error_page_subframes_and_same_document_do_not_replace_evidence() {
4317        let mut cdp = error_page_client();
4318        let url = "https://seven.sliccy.ai/path?secret#fragment";
4319        error_commit(&mut cdp, url, "://");
4320        cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"CHILD","parentId":"F","url":"https://seven.sliccy.ai/child","securityOrigin":"https://seven.sliccy.ai"}}}));
4321        cdp.absorb(json!({"method":"Page.frameStartedNavigating","sessionId":"S","params":{"frameId":"F","navigationType":"sameDocument","url":url}}));
4322        assert!(!cdp.target_protected("T", false).unwrap());
4323        assert!(cdp.frame_protected("CHILD"));
4324        observe_document(&mut cdp);
4325        assert!(cdp.call("Page.reload", json!({}), Some("S")).is_ok());
4326    }
4327
4328    #[test]
4329    fn protected_error_page_real_commit_and_guard_loss_fail_closed() {
4330        let url = "https://seven.sliccy.ai/path?secret#fragment";
4331        for origin in ["https://seven.sliccy.ai", "https://example.test"] {
4332            let mut cdp = error_page_client();
4333            error_commit(&mut cdp, url, "://");
4334            assert!(!cdp.target_protected("T", false).unwrap());
4335            cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":url,"securityOrigin":origin}}}));
4336            assert!(cdp.is_protected_target("T"));
4337            assert!(cdp.session_blocked("S"));
4338        }
4339        let mut cdp = error_page_client();
4340        error_commit(&mut cdp, url, "://");
4341        cdp.guards.insert("T".into(), "G".into());
4342        cdp.guard_sessions.insert("G".into());
4343        cdp.absorb(json!({"method":"Target.detachedFromTarget","params":{"sessionId":"G"}}));
4344        assert!(cdp.target_protected("T", false).unwrap());
4345        assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.title"}), Some("S")).is_err());
4346    }
4347
4348    #[test]
4349    fn protected_error_page_ordinary_navigation_and_fresh_dns_error_remain_drivable() {
4350        let mut cdp = url_backend("https://example.test/next");
4351        cdp.session_targets.insert("S".into(), "T".into());
4352        cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://example.test/","securityOrigin":"https://example.test"}}}));
4353        cdp.absorb(json!({"method":"Page.frameStartedNavigating","sessionId":"S","params":{"frameId":"F","url":"https://example.test/next"}}));
4354        assert!(!cdp.target_protected("T", false).unwrap());
4355        observe_document(&mut cdp);
4356        assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.title"}), Some("S")).is_ok());
4357        let mut cdp = url_backend("http://ordinary.invalid/");
4358        cdp.session_targets.insert("S".into(), "T".into());
4359        error_commit(&mut cdp, "http://ordinary.invalid/", "://");
4360        assert!(!cdp.target_protected("T", false).unwrap());
4361        observe_document(&mut cdp);
4362        assert!(cdp.call("Page.navigate", json!({"url":"about:blank"}), Some("S")).is_ok());
4363    }
4364
4365    #[test]
4366    fn protected_error_page_path_query_and_fragment_redact_url_and_title_everywhere() {
4367        for url in [
4368            "https://seven.sliccy.ai/path-secret",
4369            "https://seven.sliccy.ai/?query-secret=hidden",
4370            "https://seven.sliccy.ai/#fragment-secret-hidden",
4371            "https://seven.sliccy.ai/path-secret?query-secret=hidden#fragment-secret-hidden",
4372        ] {
4373            let mut cdp = error_page_client_url(url);
4374            error_commit(&mut cdp, url, "://");
4375            let info = json!({"targetId":"T","type":"page","url":url,"title":url});
4376            for method in ["Target.targetCreated", "Target.targetInfoChanged", "Target.attachedToTarget"] {
4377                cdp.absorb(json!({"method":method,"params":{"sessionId":"A","targetInfo":info}}));
4378                let event = cdp.events().last().unwrap();
4379                for key in ["url", "title"] {
4380                    assert_eq!(event["params"]["targetInfo"][key], "https://seven.sliccy.ai", "{method} {key} {url}");
4381                }
4382            }
4383            for method in ["Target.getTargets", "Target.getTargetInfo"] {
4384                let out = cdp.call(method, json!({"targetId":"T"}), None).unwrap();
4385                let info = if method == "Target.getTargets" { &out["targetInfos"][0] } else { &out["targetInfo"] };
4386                for key in ["url", "title"] {
4387                    assert_eq!(info[key], "https://seven.sliccy.ai", "{method} {key} {url}");
4388                }
4389            }
4390            assert!(!cdp.target_protected("T", false).unwrap());
4391            assert!(!cdp.session_blocked("S"));
4392        }
4393    }
4394
4395    #[test]
4396    fn protected_seven_fragment_targets_and_url_changes_never_disclose_markers() {
4397        for url in [
4398            "https://seven.sliccy.ai/auth/callback#access_token=MARK",
4399            "https://seven.sliccy.ai/#tailscale=MARK1",
4400            "https://seven.sliccy.ai/#proxy=http%3A%2F%2F127.0.0.1%3A9%2F&key=MARK2",
4401        ] {
4402            for changed in [false, true] {
4403                let mut cdp = url_backend(url);
4404                if changed {
4405                    cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://seven.sliccy.ai/","title":"clean"}}}));
4406                }
4407                let info = json!({"targetId":"T","type":"page","url":url,"title":url});
4408                for method in ["Target.targetCreated", "Target.targetInfoChanged", "Target.attachedToTarget"] {
4409                    let mark = cdp.events().len();
4410                    cdp.absorb(json!({"method":method,"params":{"sessionId":"A","targetInfo":info}}));
4411                    for event in &cdp.events()[mark..] {
4412                        assert!(!event.to_string().contains("MARK"), "{method} {changed} {event}");
4413                        for key in ["url", "title"] {
4414                            assert_eq!(event["params"]["targetInfo"][key], "https://seven.sliccy.ai", "{method} {key} {url}");
4415                        }
4416                    }
4417                }
4418                for method in ["Target.getTargets", "Target.getTargetInfo"] {
4419                    let params = if method == "Target.getTargets" { json!({}) } else { json!({"targetId":"T"}) };
4420                    let out = cdp.call(method, params, None).unwrap();
4421                    assert!(!out.to_string().contains("MARK"), "{method} {out}");
4422                    let info = if method == "Target.getTargets" { &out["targetInfos"][0] } else { &out["targetInfo"] };
4423                    for key in ["url", "title"] {
4424                        assert_eq!(info[key], "https://seven.sliccy.ai", "{method} {key} {url}");
4425                    }
4426                }
4427                cdp.absorb(json!({"method":"Target.detachedFromTarget","params":{"sessionId":"A","targetId":"T"}}));
4428                assert!(!cdp.events().last().unwrap().to_string().contains("MARK"));
4429                assert!(cdp.target_protected("T", false).unwrap());
4430            }
4431        }
4432    }
4433
4434    fn target_fragment_markers() -> [&'static str; 5] {
4435        [
4436            "https://seven.sliccy.ai/#tailscale=MARK1",
4437            "https://seven.sliccy.ai/#proxy=http%3A%2F%2F127.0.0.1%3A9%2F&key=MARK2",
4438            "https://seven.sliccy.ai/auth/callback#access_token=MARK",
4439            "https://seven.sliccy.ai/path-secret?query-secret=MARK",
4440            "https://seven.sliccy.ai/path-secret?query-secret=MARK#access_token=MARK",
4441        ]
4442    }
4443
4444    #[test]
4445    fn target_info_fragment_markers_are_redacted_in_responses_and_events() {
4446        for url in target_fragment_markers() {
4447            for changed in [false, true] {
4448                let mut cdp = url_backend(url);
4449                if changed {
4450                    cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://seven.sliccy.ai/","title":"clean"}}}));
4451                }
4452                let info = json!({"targetId":"T","type":"page","url":url,"title":url,"openerId":"OPENER","openerFrameId":"FRAME"});
4453                for method in ["Target.targetCreated", "Target.targetInfoChanged", "Target.attachedToTarget"] {
4454                    let mark = cdp.events().len();
4455                    cdp.absorb(json!({"method":method,"params":{"sessionId":"A","targetInfo":info}}));
4456                    let event = cdp.events()[mark..].iter().find(|event| event["method"] == method).unwrap();
4457                    for key in ["url", "title"] {
4458                        assert_eq!(event["params"]["targetInfo"][key], "https://seven.sliccy.ai", "{method} {key} {url} {changed}");
4459                    }
4460                    assert_eq!(event["params"]["targetInfo"]["openerId"], "OPENER");
4461                    assert_eq!(event["params"]["targetInfo"]["openerFrameId"], "FRAME");
4462                    assert!(!event.to_string().contains("MARK"));
4463                }
4464                for method in ["Target.getTargets", "Target.getTargetInfo"] {
4465                    let params = if method == "Target.getTargets" { json!({}) } else { json!({"targetId":"T"}) };
4466                    let out = cdp.call(method, params, None).unwrap();
4467                    let info = if method == "Target.getTargets" { &out["targetInfos"][0] } else { &out["targetInfo"] };
4468                    for key in ["url", "title"] {
4469                        assert_eq!(info[key], "https://seven.sliccy.ai", "{method} {key} {url}");
4470                    }
4471                    assert!(!out.to_string().contains("MARK"));
4472                }
4473                for event in [
4474                    json!({"method":"Target.detachedFromTarget","params":{"sessionId":"A","targetId":"T"}}),
4475                    json!({"method":"Target.detachedFromTarget","params":{"sessionId":"B"}}),
4476                    json!({"method":"Page.frameAttached","sessionId":"S","params":{"frameId":"CHILD","parentFrameId":"FRAME"}}),
4477                ] {
4478                    cdp.absorb(event);
4479                    assert!(!cdp.events().last().unwrap().to_string().contains("MARK"));
4480                }
4481            }
4482        }
4483    }
4484
4485    #[test]
4486    fn target_info_nonflattened_and_browser_attach_paths_remain_refused() {
4487        for (method, params) in [
4488            ("Target.attachToTarget", json!({"targetId":"T","flatten":false})),
4489            ("Target.attachToBrowserTarget", json!({})),
4490            ("Target.setAutoAttach", json!({"autoAttach":true,"flatten":true})),
4491            ("Target.autoAttachRelated", json!({"targetId":"T","waitForDebuggerOnStart":false})),
4492            ("Target.sendMessageToTarget", json!({"targetId":"T","message":"MARK"})),
4493        ] {
4494            let mut cdp = url_backend("https://example.test/");
4495            assert!(cdp.call(method, params, None).is_err(), "{method}");
4496        }
4497    }
4498
4499    #[test]
4500    fn target_info_wrapped_nonflattened_events_never_disclose_markers() {
4501        for url in target_fragment_markers() {
4502            let mut cdp = url_backend("https://example.test/");
4503            let mark = cdp.events().len();
4504            let nested = json!({"method":"Target.attachedToTarget","params":{"sessionId":"CHILD","targetInfo":{"targetId":"T","type":"page","url":url,"title":url}}});
4505            cdp.absorb(json!({"method":"Target.receivedMessageFromTarget","params":{"sessionId":"A","targetId":"T","message":nested.to_string()}}));
4506            assert_eq!(cdp.events().len(), mark, "{url}");
4507        }
4508    }
4509
4510    #[test]
4511    fn allowed_blank_commit_refreshes_stale_protected_discovery_before_driving() {
4512        let mut cdp = url_backend("about:blank");
4513        cdp.session_targets.insert("S".into(), "T".into());
4514        cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://example.test/","securityOrigin":"https://example.test"}}}));
4515        error_commit(&mut cdp, "https://seven.sliccy.ai/doc#access_token=MARK", "://");
4516        cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://seven.sliccy.ai/doc#access_token=MARK","title":"MARK"}}}));
4517        cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"about:blank","securityOrigin":"https://example.test"}}}));
4518        observe_document(&mut cdp);
4519        assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.readyState"}), Some("S")).is_ok());
4520        assert!(!cdp.session_blocked("S"));
4521        assert!(!cdp.is_protected_target("T"));
4522    }
4523
4524    #[test]
4525    fn permitted_error_document_redacts_history_and_refuses_runtime_without_losing_ids() {
4526        struct HistoryResponses(UrlBackend);
4527        impl Transport for HistoryResponses {
4528            fn send_text(&mut self, text: &str) -> Result<(), String> {
4529                let call: Value = serde_json::from_str(text).unwrap();
4530                let protected = self.0.url.clone();
4531                let output = match call["method"].as_str().unwrap() {
4532                    "Page.getNavigationHistory" => Some(json!({"currentIndex":1,"entries":[
4533                        {"id":701,"url":"https://ordinary.example/ready","title":"ordinary control","userTypedURL":"https://ordinary.example/ready"},
4534                        {"id":702,"url":protected,"title":protected,"userTypedURL":protected}
4535                    ]})),
4536                    "Runtime.evaluate" => Some(json!({"result":{"type":"object","value":{"url":protected,"text":protected}}})),
4537                    _ => None,
4538                };
4539                if let Some(output) = output {
4540                    self.0.inbox.push_back(json!({"id":call["id"],"result":output}).to_string());
4541                    Ok(())
4542                } else {
4543                    self.0.send_text(text)
4544                }
4545            }
4546            fn recv_text(&mut self, until: Instant) -> Result<Option<String>, String> {
4547                self.0.recv_text(until)
4548            }
4549        }
4550        for attempted in [
4551            "https://seven.sliccy.ai/private/HISTORY_MARK?key=HISTORY_MARK#access_token=HISTORY_MARK",
4552            "https://vault.sliccy.ai/private/HISTORY_MARK?key=HISTORY_MARK#access_token=HISTORY_MARK",
4553        ] {
4554            let mut cdp = Cdp::from_transport(Box::new(HistoryResponses(UrlBackend { url:attempted.into(), inbox:VecDeque::new() })));
4555            cdp.session_targets.insert("S".into(), "T".into());
4556            cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://ordinary.example/ready","securityOrigin":"https://ordinary.example"}}}));
4557            error_commit(&mut cdp, attempted, "://");
4558            assert!(!cdp.target_protected("T", false).unwrap());
4559            assert!(!cdp.session_blocked("S"));
4560            observe_document(&mut cdp);
4561            let origin = url::Url::parse(attempted).unwrap().origin().ascii_serialization();
4562            let history = cdp.call("Page.getNavigationHistory", json!({}), Some("S")).unwrap();
4563            assert_eq!(history["currentIndex"], 1);
4564            assert_eq!(history["entries"][0]["id"], 701);
4565            assert_eq!(history["entries"][0]["url"], "https://ordinary.example/ready");
4566            assert_eq!(history["entries"][0]["title"], "ordinary control");
4567            assert_eq!(history["entries"][1]["id"], 702);
4568            for key in ["url", "title", "userTypedURL"] {
4569                assert_eq!(history["entries"][1][key], origin, "{key}");
4570            }
4571            assert!(!history.to_string().contains("HISTORY_MARK"));
4572            let sent = cdp.next_id;
4573            for expression in ["location.href", "Array.from(document.documentElement.innerHTML, c => c.codePointAt(0))"] {
4574                assert!(cdp.call("Runtime.evaluate", json!({"expression":expression,"returnByValue":true}), Some("S")).is_err(), "{expression}");
4575            }
4576            assert_eq!(cdp.next_id, sent + 2);
4577            assert!(cdp.call("Page.reload", json!({}), Some("S")).is_ok());
4578            assert!(!cdp.session_blocked("S"));
4579        }
4580    }
4581
4582    #[test]
4583    fn internal_guard_loss_refuses_direct_commands_even_with_stale_ordinary_discovery() {
4584        for error in [false, true] {
4585            let mut cdp = error_page_client();
4586            if error {
4587                error_commit(&mut cdp, "https://seven.sliccy.ai/path?secret#fragment", "://");
4588            }
4589            assert_eq!(cdp.targets["T"]["url"], "https://example.test/");
4590            assert!(!cdp.session_blocked("S"));
4591            cdp.guards.insert("T".into(), "G".into());
4592            cdp.guard_sessions.insert("G".into());
4593            cdp.absorb(json!({"method":"Target.detachedFromTarget","params":{"sessionId":"G","targetId":"T"}}));
4594            let sent = cdp.next_id;
4595            let result = cdp.call("Runtime.evaluate", json!({"expression":"location.href = 'https://seven.sliccy.ai/'"}), Some("S"));
4596            assert!(result.is_err(), "guard loss must refuse before any discovery refresh");
4597            assert_eq!(cdp.next_id, sent, "no Runtime or discovery command may reach the backend");
4598            assert!(cdp.session_blocked("S"));
4599            assert!(cdp.abandoned.contains("T"));
4600            assert!(cdp.is_protected_target("T"));
4601            assert!(cdp.call("Target.attachToTarget", json!({"targetId":"T","flatten":true}), None).is_err());
4602        }
4603    }
4604
4605    #[test]
4606    fn caller_and_unknown_detach_do_not_abandon_a_healthy_guard() {
4607        let mut cdp = error_page_client_url("https://example.test/");
4608        cdp.guards.insert("T".into(), "G".into());
4609        cdp.guard_sessions.insert("G".into());
4610        for detached in ["UNKNOWN", "OTHER"] {
4611            cdp.session_targets.insert("OTHER".into(), "T".into());
4612            cdp.absorb(json!({"method":"Target.detachedFromTarget","params":{"sessionId":detached,"targetId":"T"}}));
4613            assert_eq!(cdp.guards["T"], "G");
4614            assert!(cdp.documents.contains_key("T"));
4615            assert!(!cdp.abandoned.contains("T"));
4616            assert!(!cdp.session_blocked("S"));
4617            observe_document(&mut cdp);
4618            assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.readyState"}), Some("S")).is_ok());
4619        }
4620    }
4621
4622    const UNPARSEABLE: &str = "slicc: refused: slicc can't parse this URL, so it could be a protected page\n";
4623
4624    fn malformed_urls() -> Vec<String> {
4625        let mut urls = [
4626            "http://exa%20mple.sliccy.ai/",
4627            "http://exa mple.sliccy.ai/",
4628            "http://\u{200d}.sliccy.ai/",
4629            "https://exa%20mple.example/",
4630            "http://xn--1ug.sliccy.ai/",
4631            "http://xn--a.sliccy.ai/",
4632            "view-source:http://exa%20mple.sliccy.ai/",
4633            "blob:http://exa%20mple.sliccy.ai/id",
4634            "filesystem:http://exa%20mple.sliccy.ai/temporary/x",
4635        ].map(str::to_string).to_vec();
4636        urls.push(format!("{}https://example.test/", "view-source:".repeat(9)));
4637        urls
4638    }
4639
4640    struct UrlBackend {
4641        url: String,
4642        inbox: VecDeque<String>,
4643    }
4644
4645    impl Transport for UrlBackend {
4646        fn send_text(&mut self, text: &str) -> Result<(), String> {
4647            let message: Value = serde_json::from_str(text).unwrap();
4648            let info = json!({"targetId":"T","type":"page","url":self.url,"title":"SECRET"});
4649            let result = match message["method"].as_str().unwrap() {
4650                "Target.getTargetInfo" => json!({"targetInfo":info}),
4651                "Target.getTargets" => json!({"targetInfos":[info]}),
4652                "Target.attachToTarget" => json!({"sessionId":"S"}),
4653                "Target.createTarget" => json!({"targetId":"T"}),
4654                "Page.getNavigationHistory" => json!({"entries":[{"id":1,"url":self.url}]}),
4655                _ => json!({}),
4656            };
4657            self.inbox.push_back(json!({"id":message["id"],"result":result}).to_string());
4658            Ok(())
4659        }
4660
4661        fn recv_text(&mut self, _deadline: Instant) -> Result<Option<String>, String> {
4662            Ok(self.inbox.pop_front())
4663        }
4664    }
4665
4666    fn url_backend(url: &str) -> Cdp {
4667        let mut cdp = Cdp::from_transport(Box::new(UrlBackend { url: url.into(), inbox: VecDeque::new() }));
4668        cdp.fixture_session("S");
4669        cdp
4670    }
4671
4672    #[test]
4673    fn issue_401_command_destinations_fail_closed() {
4674        let mut failures = Vec::new();
4675        for url in malformed_urls() {
4676            for method in ["Target.createTarget", "Page.navigate", "Network.loadNetworkResource", "Fetch.continueRequest", "Network.continueInterceptedRequest", "PWA.install", "PWA.launch"] {
4677                let mut cdp = url_backend("about:blank");
4678                cdp.hand("S", "R");
4679                cdp.requests.insert("R".into(), false);
4680                let params = if method.starts_with("PWA.") {
4681                    json!({"manifestId":url,"installUrlOrBundleUrl":url})
4682                } else {
4683                    json!({"url":url,"requestId":"R","interceptionId":"R"})
4684                };
4685                let result = cdp.call(method, params, Some("S"));
4686                if !result.as_ref().err().is_some_and(|error| error.starts_with("slicc: refused:")) {
4687                    failures.push(format!("{method} {url}: {result:?}"));
4688                }
4689            }
4690        }
4691        assert!(failures.is_empty(), "{}", failures.join("\n"));
4692    }
4693
4694    #[test]
4695    fn issue_401_normalized_protected_hosts_are_guarded() {
4696        for url in [
4697            "http://vault%2Esliccy.ai/",
4698            "http://vau%6Ct.sliccy.ai/",
4699            "http://VAULT.sliccy.ai/",
4700            "http://vault.sliccy.ai./",
4701            "http://seven.sliccy.ai./",
4702        ] {
4703            for (method, params) in [
4704                ("Target.createTarget", json!({"url":url})),
4705                ("Page.navigate", json!({"url":url})),
4706                ("Network.setCookie", json!({"url":url,"name":"x","value":"y"})),
4707                ("Network.deleteCookies", json!({"url":url,"name":"x"})),
4708                ("Network.setCookies", json!({"cookies":[{"url":url,"name":"x","value":"y"}]})),
4709                ("Storage.setCookies", json!({"cookies":[{"url":url,"name":"x","value":"y"}]})),
4710            ] {
4711                assert!(url_backend("about:blank").call(method, params, Some("S")).unwrap_err().starts_with("slicc: refused:"), "{method} {url}");
4712            }
4713            let mut cdp = url_backend(url);
4714            let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
4715            assert_ne!(listed["targetInfos"][0]["url"], url);
4716            assert!(cdp.is_protected_target("T"));
4717            assert!(url_backend(url).call("Target.attachToTarget", json!({"targetId":"T","flatten":true}), None).is_err());
4718        }
4719    }
4720
4721    #[test]
4722    fn issue_401_refusal_text_is_exact() {
4723        for method in ["Page.navigate", "Target.createTarget", "Network.setCookie", "Network.getCookies"] {
4724            let url = "http://exa%20mple.sliccy.ai/";
4725            let params = if method == "Network.getCookies" { json!({"urls":[url]}) } else { json!({"url":url,"name":"x","value":"y"}) };
4726            assert_eq!(url_backend("about:blank").call(method, params, Some("S")).unwrap_err(), UNPARSEABLE, "{method}");
4727        }
4728    }
4729
4730    #[test]
4731    fn issue_401_reported_targets_and_history_fail_closed() {
4732        for url in malformed_urls() {
4733            let mut cdp = url_backend(&url);
4734            let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
4735            assert!(listed["targetInfos"].as_array().unwrap().is_empty(), "{url}");
4736            assert!(cdp.call("Target.attachToTarget", json!({"targetId":"T","flatten":true}), None).unwrap_err().starts_with("slicc: refused:"), "{url}");
4737            assert!(url_backend(&url).call("Target.attachToTarget", json!({"targetId":"T","flatten":true}), None).is_err(), "{url}");
4738            assert!(url_backend(&url).call("Page.navigateToHistoryEntry", json!({"entryId":1}), Some("S")).is_err(), "{url}");
4739        }
4740    }
4741
4742    #[test]
4743    fn cookie_readers_keep_filtering_parseable_mixed_url_lists() {
4744        assert!(url_backend("about:blank").call("Network.getCookies", json!({"urls":["https://seven.sliccy.ai/", "https://example.test/"]}), Some("S")).is_ok());
4745        assert_eq!(url_backend("about:blank").call("Network.getCookies", json!({"urls":["http://exa%20mple.sliccy.ai/", "https://example.test/"]}), Some("S")).unwrap_err(), UNPARSEABLE);
4746    }
4747
4748    #[test]
4749    fn issue_401_cookie_command_urls_fail_closed() {
4750        let mut failures = Vec::new();
4751        for url in malformed_urls() {
4752            for (method, params) in [
4753                ("Network.setCookie", json!({"url":url,"name":"x","value":"y"})),
4754                ("Network.deleteCookies", json!({"url":url,"name":"x"})),
4755                ("Network.getCookies", json!({"urls":["https://example.test/",url]})),
4756                ("Network.setCookies", json!({"cookies":[{"url":url,"name":"x","value":"y"}]})),
4757                ("Storage.setCookies", json!({"cookies":[{"url":url,"name":"x","value":"y"}]})),
4758            ] {
4759                if url_backend("about:blank").call(method, params, Some("S")).is_ok() {
4760                    failures.push(format!("{method} {url}"));
4761                }
4762            }
4763        }
4764        assert!(failures.is_empty(), "{}", failures.join("\n"));
4765    }
4766
4767    #[test]
4768    fn issue_401_loaded_urls_are_never_exposed() {
4769        for url in malformed_urls() {
4770            for event in [
4771                json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://example.test/","securityOrigin":url}}}),
4772                json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":url,"securityOrigin":"null"}}}),
4773                json!({"method":"Runtime.executionContextCreated","sessionId":"S","params":{"context":{"id":1,"origin":url,"auxData":{"isDefault":true}}}}),
4774                json!({"method":"Runtime.executionContextCreated","sessionId":"S","params":{"context":{"id":1,"origin":url,"auxData":{"isDefault":false}}}}),
4775                json!({"method":"Network.requestWillBeSent","sessionId":"S","params":{"requestId":"R","request":{"url":url}}}),
4776                json!({"method":"Network.webSocketCreated","sessionId":"S","params":{"requestId":"R","url":url.replacen("http:","ws:",1)}}),
4777                json!({"method":"ServiceWorker.workerVersionUpdated","params":{"versions":[{"scriptURL":url,"status":"activated"}]}}),
4778            ] {
4779                let mut cdp = url_backend("about:blank");
4780                cdp.absorb(event.clone());
4781                assert!(cdp.events().is_empty(), "{event}: {:?}", cdp.events());
4782                if event["method"] == "Page.frameNavigated" || event.pointer("/params/context/auxData/isDefault") == Some(&json!(true)) {
4783                    assert!(cdp.blocked.contains("S"), "{event}");
4784                }
4785            }
4786            for kind in ["page", "service_worker", "shared_worker", "iframe"] {
4787                let mut cdp = url_backend("about:blank");
4788                cdp.absorb(json!({"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"T","type":kind,"url":url,"title":"SECRET"}}}));
4789                assert!(cdp.is_protected_target("T"), "{kind} {url}");
4790                assert!(cdp.events().is_empty(), "{kind} {url}");
4791            }
4792        }
4793        for origin in ["", "null", "://"] {
4794            let mut cdp = url_backend("about:blank");
4795            cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"about:blank","securityOrigin":origin}}}));
4796            assert!(!cdp.blocked.contains("S"));
4797            assert_eq!(cdp.events().len(), 1);
4798        }
4799    }
4800
4801    #[test]
4802    fn issue_401_exemptions_and_read_only_keys_remain_open() {
4803        for url in ["", "null", "://"] {
4804            assert!(url_backend("about:blank").call("Target.createTarget", json!({"url":url}), None).is_ok(), "{url}");
4805            assert!(url_backend("about:blank").call("Page.navigate", json!({"url":url}), Some("S")).is_ok(), "{url}");
4806        }
4807        for url in malformed_urls() {
4808            let mut cdp = url_backend("about:blank");
4809            assert!(cdp.notify("Slicc.action", json!({"url":url,"phase":"start"}), None).is_ok(), "{url}");
4810            assert!(cdp.call("Storage.getUsageAndQuota", json!({"origin":url}), None).is_err(), "{url}");
4811            assert!(cdp.call("Storage.clearDataForStorageKey", json!({"storageKey":format!("{url}^0{url}")}), None).is_err(), "{url}");
4812        }
4813    }
4814
4815    fn cleanup_answer(message: &Value) -> Vec<Value> {
4816        let result = match message["method"].as_str() {
4817            Some("Target.createTarget") => json!({ "targetId": "T" }),
4818            Some("Target.getTargetInfo") => json!({ "targetInfo": {
4819                "targetId": "T", "type": "page", "url": "about:blank"
4820            } }),
4821            Some("Target.getTargets") => json!({ "targetInfos": [
4822                { "targetId": "T", "type": "page", "url": "about:blank" }
4823            ] }),
4824            Some("Target.attachToTarget") => json!({ "sessionId": "S" }),
4825            Some("Network.setBypassServiceWorker") => return Vec::new(),
4826            Some("Target.closeTarget") => return vec![
4827                json!({ "method": "Target.targetDestroyed", "params": { "targetId": "OTHER" } }),
4828                json!({ "id": message["id"], "result": { "success": true } }),
4829            ],
4830            _ => json!({}),
4831        };
4832        vec![json!({ "id": message["id"], "result": result })]
4833    }
4834
4835    fn cleanup(delay: Option<Duration>, answer: fn(&Value) -> Vec<Value>) -> Cdp {
4836        Cdp::from_transport(Box::new(Cleanup {
4837            inner: Pending { inbox: VecDeque::new(), answer },
4838            destroy_after: delay,
4839            destroyed_at: None,
4840            closed: false,
4841        }))
4842    }
4843
4844    #[test]
4845    fn cleanup_waits_for_the_refused_targets_late_destruction() {
4846        let mut cdp = cleanup(Some(Duration::from_millis(40)), cleanup_answer);
4847        let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4848        assert_eq!(error, "slicc: refused: slicc can't keep seven's origin out of this target: Network.setBypassServiceWorker timed out\n");
4849        assert!(cdp.events().iter().any(|event| event["method"] == "Target.targetDestroyed" && event["params"]["targetId"] == "T"));
4850        assert!(cdp.is_protected_target("T"));
4851    }
4852
4853    #[test]
4854    fn cleanup_confirms_closure_without_browser_events() {
4855        let mut cdp = cleanup(None, |message| {
4856            if message["method"] == "Target.getTargets" {
4857                return vec![json!({ "id": message["id"], "result": { "targetInfos": [
4858                    { "targetId": "OTHER", "type": "page", "url": "about:blank" }
4859                ] } })];
4860            }
4861            cleanup_answer(message).into_iter().filter(|reply| reply.get("method").is_none()).collect()
4862        });
4863        let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4864        assert_eq!(error, "slicc: refused: slicc can't keep seven's origin out of this target: Network.setBypassServiceWorker timed out\n");
4865        assert!(!cdp.events().iter().any(|event| event["method"] == "Target.targetDestroyed"));
4866        assert!(cdp.is_protected_target("T"));
4867    }
4868
4869    #[test]
4870    fn cleanup_reports_a_missing_destruction_event_without_allowing_the_target() {
4871        let mut cdp = cleanup(None, cleanup_answer);
4872        let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4873        assert!(error.starts_with("slicc: refused:"), "{error}");
4874        assert!(error.contains("Network.setBypassServiceWorker timed out"), "{error}");
4875        assert!(error.contains("Target.targetDestroyed timed out"), "{error}");
4876        assert!(cdp.is_protected_target("T"));
4877    }
4878
4879    #[test]
4880    fn cleanup_reports_a_close_that_did_not_succeed() {
4881        let mut cdp = cleanup(None, |message| {
4882            let mut replies = cleanup_answer(message);
4883            if message["method"] == "Target.closeTarget" {
4884                replies.last_mut().unwrap()["result"] = json!({ "success": false });
4885            }
4886            replies
4887        });
4888        let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4889        assert!(error.contains("Network.setBypassServiceWorker timed out"), "{error}");
4890        assert!(error.contains("cleanup: Target.closeTarget did not confirm success"), "{error}");
4891        assert!(cdp.is_protected_target("T"));
4892    }
4893
4894    #[test]
4895    fn cleanup_does_not_accept_a_stale_destruction_event() {
4896        let mut cdp = cleanup(None, cleanup_answer);
4897        cdp.absorb(json!({ "method": "Target.targetDestroyed", "params": { "targetId": "T" } }));
4898        let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4899        assert!(error.contains("Target.targetDestroyed timed out"), "{error}");
4900        assert!(cdp.is_protected_target("T"));
4901    }
4902
4903    #[test]
4904    fn cleanup_accepts_destruction_before_the_close_reply() {
4905        let mut cdp = cleanup(None, |message| {
4906            let mut replies = cleanup_answer(message);
4907            if message["method"] == "Target.closeTarget" {
4908                replies.insert(0, json!({ "method": "Target.targetDestroyed", "params": { "targetId": "T" } }));
4909            }
4910            replies
4911        });
4912        let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4913        assert!(error.contains("Network.setBypassServiceWorker timed out"), "{error}");
4914        assert!(!error.contains("cleanup:"), "{error}");
4915    }
4916
4917    #[test]
4918    fn privileged_target_types_and_extension_workers_are_guarded() {
4919        let mut cdp = pending(|message| {
4920            let info = match message["params"]["targetId"].as_str() {
4921                Some("UI") => json!({"targetId": "UI", "type": "browser_ui", "url": "https://example.test/"}),
4922                Some("W") => json!({"targetId": "W", "type": "service_worker", "url": "chrome-extension://abc/worker.js"}),
4923                Some("B") => json!({"targetId": "B", "type": "page", "url": "about:blank", "openerId": "UI"}),
4924                _ => json!({"targetId": "P", "type": "page", "url": "https://example.test/"}),
4925            };
4926            let result = match message["method"].as_str() {
4927                Some("Target.getTargetInfo") => json!({"targetInfo": info}),
4928                Some("Target.getTargets") => json!({"targetInfos": [
4929                    {"targetId": "UI", "type": "browser_ui", "url": "https://example.test/"},
4930                    {"targetId": "P", "type": "page", "url": "https://example.test/"}
4931                ]}),
4932                _ => json!({}),
4933            };
4934            vec![json!({"id": message["id"], "result": result})]
4935        });
4936        assert!(cdp.target_protected("B", false).unwrap());
4937        assert!(cdp.target_protected("UI", false).unwrap());
4938        assert!(cdp.target_protected("W", false).unwrap());
4939        assert!(!cdp.target_protected("P", false).unwrap());
4940        let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
4941        assert_eq!(listed["targetInfos"].as_array().unwrap().len(), 1);
4942        assert_eq!(listed["targetInfos"][0]["targetId"], "P");
4943        for kind in ["page", "service_worker", "background_page", "other", "iframe"] {
4944            assert!(cdp.info_protected(&json!({
4945                "targetId": "E", "type": kind, "url": "chrome-extension://abc/options.html"
4946            }), &[]));
4947        }
4948        assert!(cdp.info_protected(&json!({
4949            "targetId": "UI", "type": "browser_ui", "url": "about:blank"
4950        }), &[]));
4951    }
4952
4953    fn evaluate(cdp: &mut Cdp) -> (Result<Value, String>, Duration) {
4954        let started = Instant::now();
4955        let result = cdp.call_for(
4956            "Runtime.evaluate",
4957            json!({ "expression": "1" }),
4958            Some("S"),
4959            Duration::from_secs(30),
4960        );
4961        (result, started.elapsed())
4962    }
4963
4964    #[test]
4965    fn a_call_fails_with_the_refusal_once_the_guard_blocks_its_session() {
4966        let mut cdp = pending(|message| match message["method"].as_str() {
4967            Some("Runtime.evaluate") => vec![json!({
4968                "method": "Page.frameNavigated",
4969                "sessionId": "S",
4970                "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/os/", "securityOrigin": "https://seven.sliccy.ai" } }
4971            })],
4972            _ => vec![],
4973        });
4974        let (result, spent) = evaluate(&mut cdp);
4975        assert_eq!(result.unwrap_err(), format!("{}\n", cdp.guard().refusal()));
4976        assert!(spent < Duration::from_secs(5), "{spent:?}");
4977        assert!(cdp.session_blocked("S"));
4978    }
4979
4980    #[test]
4981    fn a_call_fails_with_the_refusal_once_its_session_detaches() {
4982        let mut cdp = pending(|_| {
4983            vec![
4984                json!({ "method": "Target.detachedFromTarget", "params": { "sessionId": "other" } }),
4985                json!({ "method": "Target.detachedFromTarget", "params": { "sessionId": "S" } }),
4986            ]
4987        });
4988        let (result, spent) = evaluate(&mut cdp);
4989        assert_eq!(result.unwrap_err(), format!("{}\n", cdp.guard().refusal()));
4990        assert!(spent < Duration::from_secs(5), "{spent:?}");
4991    }
4992
4993    #[test]
4994    fn unrelated_events_do_not_end_a_call() {
4995        let mut cdp = pending(|message| {
4996            vec![
4997                json!({ "method": "Target.detachedFromTarget", "params": { "sessionId": "other" } }),
4998                json!({ "id": message["id"], "result": { "result": { "value": 1 } } }),
4999            ]
5000        });
5001        let (result, _) = evaluate(&mut cdp);
5002        assert_eq!(result.unwrap(), json!({ "result": { "value": 1 } }));
5003    }
5004
5005    #[test]
5006    fn a_harmless_call_on_an_already_blocked_session_still_waits_for_its_reply() {
5007        let mut cdp = pending(|message| match message["method"].as_str() {
5008            Some("Runtime.evaluate") => vec![json!({
5009                "method": "Page.frameNavigated",
5010                "sessionId": "S",
5011                "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/os/" } }
5012            })],
5013            Some("Fetch.disable") => vec![
5014                json!({ "method": "Page.lifecycleEvent", "sessionId": "S", "params": { "name": "load" } }),
5015                json!({ "id": message["id"], "result": {} }),
5016            ],
5017            _ => vec![],
5018        });
5019        let _ = evaluate(&mut cdp);
5020        assert!(cdp.session_blocked("S"));
5021        let disabled = cdp.call_for("Fetch.disable", json!({}), Some("S"), Duration::from_secs(30));
5022        assert_eq!(disabled.unwrap(), json!({}));
5023    }
5024
5025    #[test]
5026    fn a_finished_document_does_not_wait_for_load() {
5027        assert_eq!(probe_is_settled(Ok(Some("complete"))).unwrap(), true);
5028        assert_eq!(probe_is_settled(Ok(None)).unwrap(), true);
5029        assert_eq!(
5030            probe_is_settled(Err("Runtime.evaluate timed out\n")).unwrap(),
5031            true
5032        );
5033        assert_eq!(
5034            probe_is_settled(Err("Runtime.evaluate: 'Runtime.evaluate' wasn't found\n")).unwrap(),
5035            true
5036        );
5037    }
5038
5039    #[test]
5040    fn a_blank_load_does_not_settle_the_next_navigation() {
5041        let session = "sess";
5042        let blank_load = json!({
5043            "method": "Page.loadEventFired",
5044            "sessionId": session,
5045            "params": {}
5046        });
5047        let blank_frame = json!({
5048            "method": "Page.frameNavigated",
5049            "sessionId": session,
5050            "params": { "frame": { "url": "about:blank" } }
5051        });
5052        let commit = json!({
5053            "method": "Page.frameNavigated",
5054            "sessionId": session,
5055            "params": { "frame": { "url": "http://a.test/boot" } }
5056        });
5057        let load = json!({
5058            "method": "Page.loadEventFired",
5059            "sessionId": session,
5060            "params": {}
5061        });
5062        let raced = vec![blank_frame, blank_load.clone(), commit.clone()];
5063        assert!(!super::navigation_settled(&raced, session, 0));
5064        let ready = vec![blank_load.clone(), commit.clone(), load.clone()];
5065        assert!(super::navigation_settled(&ready, session, 0));
5066        let stale = vec![commit, load, blank_load];
5067        assert!(!super::navigation_settled(&stale, session, 2));
5068    }
5069
5070    #[test]
5071    fn a_loading_document_still_waits() {
5072        assert_eq!(probe_is_settled(Ok(Some("loading"))).unwrap(), false);
5073        assert_eq!(probe_is_settled(Ok(Some("interactive"))).unwrap(), false);
5074    }
5075
5076    #[test]
5077    fn a_closed_socket_is_not_treated_as_loaded() {
5078        for err in [
5079            "websocket closed 1011 the browser went away\n",
5080            "websocket connection closed\n",
5081            "websocket write failed: broken pipe\n",
5082            "websocket read failed: connection reset\n",
5083            "CDP response was not JSON: eof\n",
5084        ] {
5085            let reported = probe_is_settled(Err(err)).unwrap_err();
5086            assert_eq!(reported, err);
5087        }
5088    }
5089
5090    #[test]
5091    fn action_start_and_end_keep_the_panel_fields() {
5092        let click = ActionNote {
5093            kind: "click".to_string(),
5094            tab: Some("1234".to_string()),
5095            target: Some("button \"Sign in\"".to_string()),
5096            agent: Some("cone:cone-1".to_string()),
5097            ..ActionNote::default()
5098        };
5099        assert_eq!(
5100            click.start_params(),
5101            json!({
5102                "phase": "start",
5103                "kind": "click",
5104                "tab": "1234",
5105                "target": "button \"Sign in\"",
5106                "agent": "cone:cone-1"
5107            })
5108        );
5109        let failed = click.end_params(
5110            false,
5111            "No snapshot available. Run \"snapshot\" first.\nignored\n",
5112        );
5113        assert_eq!(
5114            failed,
5115            json!({
5116                "phase": "end",
5117                "ok": false,
5118                "error": "no snapshot",
5119                "tab": "1234"
5120            })
5121        );
5122        assert!(failed.get("agent").is_none());
5123        assert!(failed.get("kind").is_none());
5124        assert!(failed.get("target").is_none());
5125        let open = ActionNote {
5126            kind: "open".to_string(),
5127            url: Some("http://a.test/".to_string()),
5128            ..ActionNote::default()
5129        };
5130        assert!(open.start_params().get("tab").is_none());
5131        assert_eq!(open.start_params()["url"], "http://a.test/");
5132        let mut opened = open.clone();
5133        opened.tab = Some("TAB1".to_string());
5134        assert_eq!(opened.end_params(true, "")["tab"], "TAB1");
5135        assert!(opened.end_params(true, "").get("error").is_none());
5136    }
5137
5138    #[test]
5139    fn action_params_omit_typed_text_eval_source_and_request_secrets() {
5140        let secret = "secret-text";
5141        let fill = ActionNote {
5142            kind: "fill".to_string(),
5143            tab: Some("T".to_string()),
5144            target: Some("textbox \"Email\"".to_string()),
5145            length: Some(secret.chars().count() as u64),
5146            ..ActionNote::default()
5147        };
5148        let fill_params = fill.start_params();
5149        assert_eq!(fill_params["length"], 11);
5150        assert!(!fill_params.to_string().contains(secret));
5151        assert!(fill_params.get("value").is_none());
5152        let typed = "héllo";
5153        let type_note = ActionNote {
5154            kind: "type".to_string(),
5155            length: Some(typed.chars().count() as u64),
5156            ..ActionNote::default()
5157        };
5158        assert_eq!(type_note.start_params()["length"], 5);
5159        assert!(!type_note.start_params().to_string().contains(typed));
5160        let source = "document.cookie";
5161        let eval_note = ActionNote {
5162            kind: "eval".to_string(),
5163            tab: Some("T".to_string()),
5164            ..ActionNote::default()
5165        };
5166        let eval_params = eval_note.start_params();
5167        assert!(!eval_params.to_string().contains(source));
5168        assert!(eval_params.get("expression").is_none());
5169        let request = ActionNote {
5170            kind: "request".to_string(),
5171            tab: Some("T1".to_string()),
5172            method: Some("POST".to_string()),
5173            url: Some("https://app.example/api/me".to_string()),
5174            ..ActionNote::default()
5175        };
5176        let request_params = request.start_params();
5177        assert_eq!(
5178            request_params,
5179            json!({
5180                "phase": "start",
5181                "kind": "request",
5182                "tab": "T1",
5183                "method": "POST",
5184                "url": "https://app.example/api/me"
5185            })
5186        );
5187        assert!(!request_params.to_string().contains("Authorization"));
5188        assert!(!request_params.to_string().contains(secret));
5189        let press = ActionNote {
5190            kind: "press".to_string(),
5191            key: Some("Enter".to_string()),
5192            ..ActionNote::default()
5193        };
5194        assert_eq!(press.start_params()["key"], "Enter");
5195        assert!(ActionNote::default().start_params().get("agent").is_none());
5196        assert_eq!(first_stderr_line("only-line"), Some("only-line"));
5197        assert_eq!(first_stderr_line("\n"), None);
5198    }
5199
5200    #[test]
5201    fn action_urls_and_errors_drop_page_secrets() {
5202        let secret = "https://user:pass@app.example/form?q=QUERYSECRET&tok=x#FRAGSECRET";
5203        let open = ActionNote {
5204            kind: "open".to_string(),
5205            url: Some(secret.to_string()),
5206            ..ActionNote::default()
5207        };
5208        let started = open.start_params();
5209        assert_eq!(started["url"], "https://app.example/form");
5210        assert_clean(&started);
5211        let goto = ActionNote {
5212            kind: "goto".to_string(),
5213            url: Some(secret.to_string()),
5214            ..ActionNote::default()
5215        };
5216        assert_eq!(goto.start_params()["url"], "https://app.example/form");
5217        let posted = ActionNote {
5218            kind: "request".to_string(),
5219            method: Some("POST".to_string()),
5220            url: Some(secret.to_string()),
5221            ..ActionNote::default()
5222        };
5223        let request = posted.start_params();
5224        assert_eq!(request["method"], "POST");
5225        assert_eq!(request["url"], "https://app.example/form");
5226        assert_clean(&request);
5227        let gotten = ActionNote {
5228            kind: "request".to_string(),
5229            method: Some("GET".to_string()),
5230            url: Some("http://user:p%40ss@[::1]:8443/a/b?x=1#y".to_string()),
5231            ..ActionNote::default()
5232        };
5233        assert_eq!(gotten.start_params()["url"], "http://[::1]:8443/a/b");
5234        let relative = ActionNote {
5235            kind: "request".to_string(),
5236            method: Some("GET".to_string()),
5237            url: Some("//user:pass@app.example/path?q=QUERYSECRET#FRAGSECRET".to_string()),
5238            ..ActionNote::default()
5239        };
5240        let relative_params = relative.start_params();
5241        assert_eq!(relative_params["url"], "//app.example/path");
5242        assert_clean(&relative_params);
5243        assert_eq!(
5244            ActionNote {
5245                kind: "open".to_string(),
5246                url: Some("about:blank".to_string()),
5247                ..ActionNote::default()
5248            }
5249            .start_params()["url"],
5250            "about:blank"
5251        );
5252        let opaque = [
5253            (
5254                "goto",
5255                "data:text/html,<title>DATASECRET</title>",
5256                Some("data:"),
5257                "DATASECRET",
5258            ),
5259            (
5260                "open",
5261                "data:text/html,DATASECRET2",
5262                Some("data:"),
5263                "DATASECRET2",
5264            ),
5265            (
5266                "goto",
5267                "javascript:void('JSSECRET')",
5268                Some("javascript:"),
5269                "JSSECRET",
5270            ),
5271            (
5272                "open",
5273                "blob:https://example.com/BLOBSECRET",
5274                Some("blob:"),
5275                "BLOBSECRET",
5276            ),
5277            ("goto", "about:blank?q=BLANKSECRET", Some("about:blank"), "BLANKSECRET"),
5278            ("open", "ABOUT:blank?q=BLANKSECRET", Some("about:blank"), "BLANKSECRET"),
5279            ("goto", "About:blank", Some("about:blank"), "BLANKSECRET"),
5280            (
5281                "open",
5282                "DATA:text/html,DATASECRET",
5283                Some("data:"),
5284                "DATASECRET",
5285            ),
5286            (
5287                "open",
5288                "about:srcdoc,ABOUTSECRET",
5289                Some("about:"),
5290                "ABOUTSECRET",
5291            ),
5292            ("goto", "file:/tmp/PATHSECRET", Some("file:"), "PATHSECRET"),
5293            (
5294                "open",
5295                "https:example.com/PATHSECRET",
5296                Some("https:"),
5297                "PATHSECRET",
5298            ),
5299            ("goto", "example.com/PATHSECRET", None, "PATHSECRET"),
5300            ("open", "not a url PATHSECRET", None, "PATHSECRET"),
5301            ("goto", "HOSTSECRET3:8080/path", None, "HOSTSECRET3"),
5302            ("open", "host:8080/x", None, "host:"),
5303            (
5304                "goto",
5305                "USERSECRET5:PASSSECRET5@127.0.0.1/form",
5306                None,
5307                "USERSECRET5",
5308            ),
5309            (
5310                "open",
5311                "USERSECRET5:PASSSECRET5@127.0.0.1/form",
5312                None,
5313                "PASSSECRET5",
5314            ),
5315            ("goto", "user:pw@host/x", None, "user:"),
5316            ("open", "foo:bar", None, "foo:"),
5317            ("goto", "foo://example.com/FOOSECRET", None, "FOOSECRET"),
5318            (
5319                "open",
5320                "view-source:https://example.com/VIEWSECRET",
5321                Some("view-source:"),
5322                "VIEWSECRET",
5323            ),
5324        ];
5325        for (kind, raw, expect, secret) in opaque {
5326            let note = ActionNote {
5327                kind: kind.to_string(),
5328                url: Some(raw.to_string()),
5329                ..ActionNote::default()
5330            };
5331            let params = note.start_params();
5332            match expect {
5333                Some(url) => assert_eq!(params["url"], url, "{raw}"),
5334                None => assert!(params.get("url").is_none(), "{raw} {params}"),
5335            }
5336            assert!(!params.to_string().contains(secret), "{raw} {params}");
5337        }
5338        let eval_failed = ActionNote {
5339            kind: "eval".to_string(),
5340            tab: Some("T".to_string()),
5341            ..ActionNote::default()
5342        };
5343        let eval_end = eval_failed.end_params(false, "Error: EVALSECRET-in-error\n");
5344        assert_eq!(eval_end["ok"], false);
5345        assert!(eval_end.get("error").is_none());
5346        assert_clean(&eval_end);
5347        let cases = [
5348            (
5349                "click",
5350                "Unknown ref \"e1\". Available: e2\n",
5351                "element not found",
5352            ),
5353            (
5354                "screenshot",
5355                "No snapshot available. Run \"snapshot\" first.\n",
5356                "no snapshot",
5357            ),
5358            (
5359                "screenshot",
5360                "screenshot: could not resolve element e1 to a visible box — the snapshot is likely stale\n",
5361                "no snapshot",
5362            ),
5363            (
5364                "click",
5365                "Error: --tab <targetId> is required. Run 'playwright-cli tab-list' to get tab IDs.\n",
5366                "unknown tab",
5367            ),
5368            (
5369                "goto",
5370                "navigate: refused https://app.example/form?q=QUERYSECRET#FRAGSECRET\n",
5371                "navigation failed",
5372            ),
5373            ("click", "Page.navigate timed out\n", "timeout"),
5374            (
5375                "open",
5376                "websocket closed 1011 the browser went away\n",
5377                "connection lost",
5378            ),
5379            (
5380                "request",
5381                "curlwright: no open tab is on https://app.example — pass --tab.\nhttps://other.example/dash?q=QUERYSECRET\n",
5382                "not allowed",
5383            ),
5384            (
5385                "request",
5386                "curlwright: (22) The requested URL returned error: 500\n",
5387                "failed",
5388            ),
5389        ];
5390        for (kind, stderr, code) in cases {
5391            let note = ActionNote {
5392                kind: kind.to_string(),
5393                ..ActionNote::default()
5394            };
5395            let ended = note.end_params(false, stderr);
5396            assert_eq!(ended["error"], code, "{stderr}");
5397            assert_clean(&ended);
5398        }
5399    }
5400
5401    fn assert_clean(value: &serde_json::Value) {
5402        let rendered = value.to_string();
5403        for secret in [
5404            "QUERYSECRET",
5405            "FRAGSECRET",
5406            "EVALSECRET",
5407            "user:pass",
5408            "tok=x",
5409            "p%40ss",
5410        ] {
5411            assert!(!rendered.contains(secret), "{rendered}");
5412        }
5413    }
5414}