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