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