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