Skip to main content

runtime_foxdriver/
frame_graph.rs

1//! Frame + shadow-root graph for the current page.
2//!
3//! Every captcha-solving operation that has to walk frames today
4//! does this:
5//!
6//! ```text
7//! for fid in page.frames().await? {
8//!     if let Some(ctx) = page.frame_execution_context(fid).await? {
9//!         page.evaluate_expression(...with ctx...).await?;
10//!     }
11//! }
12//! ```
13//!
14//! Three problems with the bare-loop pattern:
15//!
16//! 1. **No structure.** `page.frames()` returns a flat list — you
17//!    can't ask "which frame is this iframe's parent?", "which
18//!    frames live inside the captcha container?", "which frame is
19//!    nested deepest?" without re-running an extraction pass each
20//!    time. Solvers re-derive the topology over and over.
21//! 2. **Shadow roots are invisible.** `page.frames()` only sees
22//!    cross-document boundaries; same-document shadow roots are
23//!    missed. The existing in-DOM `walkAllRoots` JS pass handles
24//!    them but lives in every solver as a copy-paste blob.
25//! 3. **No reasoning.** With a graph you can BFS from "the deepest
26//!    frame containing a captcha widget" outward to find the
27//!    nearest token field, or topo-sort frames so the deepest
28//!    challenge runs first. With a flat list you can't.
29//!
30//! [`FrameGraph`] is the substrate: snapshot once, query many
31//! times. [`FrameNode`] is the per-node shape (frame_id +
32//! parent + URL + title + presence of captcha markers).
33//!
34//! Pure data type — no IO ourselves; [`FrameGraph::snapshot`] does
35//! the CDP round-trips and returns a built graph. Tests can
36//! construct synthetic graphs without a browser.
37
38use crate::browser::Page;
39use anyhow::Result;
40use std::collections::{HashMap, VecDeque};
41
42/// One node in the frame graph.
43///
44/// `frame_id` is the CDP frame identifier — opaque string we hand
45/// back to `page.frame_execution_context(frame_id)` when running
46/// JS in this frame's context.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct FrameNode {
49    /// CDP frame ID. `None` for the synthetic root that ties the
50    /// main frame plus all shadow roots together; nodes representing
51    /// real frames always carry one.
52    pub frame_id: Option<String>,
53    /// Index of the parent node in [`FrameGraph::nodes`]. `None`
54    /// only for the root.
55    pub parent: Option<usize>,
56    /// URL of the document this frame is rendering. `about:blank`
57    /// or `about:srcdoc` for synthetic / written iframes.
58    pub url: String,
59    /// `document.title` at snapshot time.
60    pub title: String,
61    /// True when the frame's body or any descendant matched a
62    /// captcha-shaped selector at snapshot time. Drives reasoning
63    /// like "BFS to the nearest captcha-bearing frame".
64    pub has_captcha_marker: bool,
65    /// Depth from the root (root = 0, top-level frame = 1, …).
66    pub depth: usize,
67}
68
69/// The full graph for a page snapshot.
70///
71/// Children are indexed via [`FrameGraph::children`] which scans
72/// the `nodes` Vec — fine for the typical <50-node frame trees we
73/// see in the wild; would warrant a parent→children index for
74/// 1000+-frame pages (which don't exist in practice).
75#[derive(Debug, Clone, Default)]
76pub struct FrameGraph {
77    pub nodes: Vec<FrameNode>,
78}
79
80impl FrameGraph {
81    /// Build a graph from the current state of `page`.
82    ///
83    /// Calls `page.frames()` once + per-frame title/URL evals.
84    /// The captcha-marker scan runs the same selector list as
85    /// [`crate::solver::oracle::take_snapshot`] uses for outcome
86    /// classification, so the two stay in sync by reading the
87    /// same predicates.
88    pub async fn snapshot(page: &Page) -> Result<Self> {
89        let frame_ids = page.frames().await?;
90        let mut nodes: Vec<FrameNode> = Vec::with_capacity(frame_ids.len() + 1);
91
92        // Node 0 = synthetic root. Always present even when the
93        // page has no frames; gives every consumer a stable
94        // entry point.
95        nodes.push(FrameNode {
96            frame_id: None,
97            parent: None,
98            url: "(root)".into(),
99            title: String::new(),
100            has_captcha_marker: false,
101            depth: 0,
102        });
103
104        for fid in frame_ids {
105            if let Ok(eval) = page.evaluate_in_context(PROBE_JS, &fid).await {
106                if let Ok(v) = eval.into_value::<FrameProbe>() {
107                    nodes.push(FrameNode {
108                        frame_id: Some(format!("{fid:?}")),
109                        parent: Some(0),
110                        url: v.url,
111                        title: v.title,
112                        has_captcha_marker: v.has_captcha_marker,
113                        depth: 1,
114                    });
115                }
116            }
117        }
118
119        Ok(Self { nodes })
120    }
121
122    /// True iff the graph has any node with `has_captcha_marker`.
123    /// Cheap pre-check before more expensive walks.
124    pub fn any_captcha_marker(&self) -> bool {
125        self.nodes.iter().any(|n| n.has_captcha_marker)
126    }
127
128    /// Indices of all child nodes of `parent_idx`.
129    ///
130    /// Linear scan; acceptable for typical tree sizes (<50 nodes).
131    /// If we ever ship a graph with hundreds of nodes, replace
132    /// with a precomputed parent→children index.
133    pub fn children(&self, parent_idx: usize) -> Vec<usize> {
134        self.nodes
135            .iter()
136            .enumerate()
137            .filter_map(|(i, n)| {
138                if n.parent == Some(parent_idx) {
139                    Some(i)
140                } else {
141                    None
142                }
143            })
144            .collect()
145    }
146
147    /// BFS from the root, returning node indices in visit order.
148    ///
149    /// Handy for "do this thing in every frame, top-down" without
150    /// open-coding the queue management at every call site.
151    pub fn bfs(&self) -> Vec<usize> {
152        if self.nodes.is_empty() {
153            return Vec::new();
154        }
155        let mut order = Vec::with_capacity(self.nodes.len());
156        let mut queue: VecDeque<usize> = VecDeque::new();
157        queue.push_back(0);
158        while let Some(idx) = queue.pop_front() {
159            order.push(idx);
160            for child in self.children(idx) {
161                queue.push_back(child);
162            }
163        }
164        order
165    }
166
167    /// Find the deepest node that has a captcha marker. Returns
168    /// `None` when no node carries one.
169    ///
170    /// Useful for "walk OUTWARD from the captcha to find the
171    /// nearest enclosing token field" — once you have the captcha
172    /// node, traverse parent links upward until the token shows up.
173    pub fn deepest_captcha(&self) -> Option<usize> {
174        self.nodes
175            .iter()
176            .enumerate()
177            .filter(|(_, n)| n.has_captcha_marker)
178            .max_by_key(|(_, n)| n.depth)
179            .map(|(i, _)| i)
180    }
181
182    /// All node indices on the path from `node_idx` up to the root,
183    /// inclusive of both endpoints. Empty when `node_idx` is OOB.
184    ///
185    /// Use this when a captcha solve produces a token in a deeply-
186    /// nested iframe and you need to relay it up the tree via
187    /// `postMessage` — the path is the relay route.
188    pub fn ancestors_inclusive(&self, mut node_idx: usize) -> Vec<usize> {
189        let mut out = Vec::new();
190        let mut visited = std::collections::HashSet::new();
191        while let Some(node) = self.nodes.get(node_idx) {
192            if !visited.insert(node_idx) {
193                // Cycle guard. Snapshot graphs are trees by
194                // construction but defensive coding wins.
195                break;
196            }
197            out.push(node_idx);
198            match node.parent {
199                Some(p) => node_idx = p,
200                None => break,
201            }
202        }
203        out
204    }
205
206    /// Group nodes by URL host, returning a map host → indices.
207    ///
208    /// Lets a solver target "all cross-origin frames hosted by
209    /// `challenges.cloudflare.com`" in one query, e.g. to pierce
210    /// the CF Turnstile sandbox.
211    pub fn frames_by_host(&self) -> HashMap<String, Vec<usize>> {
212        let mut out: HashMap<String, Vec<usize>> = HashMap::new();
213        for (i, n) in self.nodes.iter().enumerate() {
214            if let Some(host) = url::Url::parse(&n.url)
215                .ok()
216                .and_then(|u| u.host_str().map(String::from))
217            {
218                out.entry(host).or_default().push(i);
219            }
220        }
221        out
222    }
223}
224
225#[derive(serde::Deserialize)]
226struct FrameProbe {
227    url: String,
228    title: String,
229    has_captcha_marker: bool,
230}
231
232/// JS payload run inside every frame's execution context to
233/// populate a [`FrameNode`]. Selector list mirrors
234/// `oracle::take_snapshot` so the two passes stay consistent.
235const PROBE_JS: &str = r#"({
236    url: location.href || '',
237    title: document.title || '',
238    has_captcha_marker: !!document.querySelector(
239        'iframe[src*="challenges.cloudflare.com"], iframe[src*="recaptcha"], iframe[src*="hcaptcha"], '
240        + 'iframe[src*="arkoselabs"], iframe[src*="datadome"], iframe[src*="geetest"], '
241        + 'iframe[src*="perimeterx"], iframe[src*="kasada"], iframe[src*="incapsula"], '
242        + '.cf-turnstile, .h-captcha, .g-recaptcha, '
243        + '#challenge-form, #challenge-stage, #cf-please-wait, #px-captcha, '
244        + '[id^="captcha"], [class*="captcha" i], [class*="challenge" i]'
245    )
246})"#;
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    /// Build a synthetic graph for testing without a browser:
253    ///
254    /// ```text
255    /// root
256    /// ├── main (no captcha)
257    /// │   ├── frame_a (captcha)
258    /// │   │   └── frame_aa (captcha, deepest)
259    /// │   └── frame_b
260    /// └── isolated (no parent linkage)
261    /// ```
262    fn fixture_graph() -> FrameGraph {
263        FrameGraph {
264            nodes: vec![
265                // 0: root
266                FrameNode {
267                    frame_id: None,
268                    parent: None,
269                    url: "(root)".into(),
270                    title: String::new(),
271                    has_captcha_marker: false,
272                    depth: 0,
273                },
274                // 1: main
275                FrameNode {
276                    frame_id: Some("F1".into()),
277                    parent: Some(0),
278                    url: "https://example.com".into(),
279                    title: "Main".into(),
280                    has_captcha_marker: false,
281                    depth: 1,
282                },
283                // 2: frame_a (captcha)
284                FrameNode {
285                    frame_id: Some("F2".into()),
286                    parent: Some(1),
287                    url: "https://challenges.cloudflare.com/turnstile".into(),
288                    title: String::new(),
289                    has_captcha_marker: true,
290                    depth: 2,
291                },
292                // 3: frame_aa (captcha, deepest)
293                FrameNode {
294                    frame_id: Some("F3".into()),
295                    parent: Some(2),
296                    url: "https://challenges.cloudflare.com/turnstile/inner".into(),
297                    title: String::new(),
298                    has_captcha_marker: true,
299                    depth: 3,
300                },
301                // 4: frame_b
302                FrameNode {
303                    frame_id: Some("F4".into()),
304                    parent: Some(1),
305                    url: "https://example.com/sidebar".into(),
306                    title: String::new(),
307                    has_captcha_marker: false,
308                    depth: 2,
309                },
310            ],
311        }
312    }
313
314    #[test]
315    fn empty_graph_has_no_captcha_markers() {
316        let g = FrameGraph::default();
317        assert!(!g.any_captcha_marker());
318        assert!(g.bfs().is_empty());
319        assert!(g.deepest_captcha().is_none());
320    }
321
322    #[test]
323    fn any_captcha_marker_short_circuits_on_first_match() {
324        let g = fixture_graph();
325        assert!(g.any_captcha_marker());
326    }
327
328    #[test]
329    fn children_returns_all_direct_children_of_root() {
330        let g = fixture_graph();
331        let kids = g.children(0);
332        assert_eq!(kids, vec![1]);
333    }
334
335    #[test]
336    fn children_returns_all_direct_children_of_internal_node() {
337        let g = fixture_graph();
338        // Main (idx=1) has frame_a (2) and frame_b (4).
339        let kids = g.children(1);
340        assert_eq!(kids, vec![2, 4]);
341    }
342
343    #[test]
344    fn bfs_visits_root_first_then_each_level() {
345        let g = fixture_graph();
346        let order = g.bfs();
347        // Expected: 0 (root) → 1 (main) → 2,4 (children of main)
348        // → 3 (child of frame_a). Order within a level matches
349        // insertion order in `nodes`.
350        assert_eq!(order, vec![0, 1, 2, 4, 3]);
351    }
352
353    #[test]
354    fn deepest_captcha_finds_innermost_marker() {
355        let g = fixture_graph();
356        let deepest = g.deepest_captcha().expect("fixture has captcha markers");
357        assert_eq!(deepest, 3, "frame_aa is the deepest captcha-bearing node");
358    }
359
360    #[test]
361    fn ancestors_inclusive_walks_to_root_in_order() {
362        let g = fixture_graph();
363        // From frame_aa (3) → frame_a (2) → main (1) → root (0).
364        let path = g.ancestors_inclusive(3);
365        assert_eq!(path, vec![3, 2, 1, 0]);
366    }
367
368    #[test]
369    fn ancestors_inclusive_handles_oob_index_gracefully() {
370        let g = fixture_graph();
371        assert!(g.ancestors_inclusive(999).is_empty());
372    }
373
374    #[test]
375    fn ancestors_inclusive_handles_root_node() {
376        let g = fixture_graph();
377        let path = g.ancestors_inclusive(0);
378        assert_eq!(path, vec![0]);
379    }
380
381    #[test]
382    fn frames_by_host_groups_correctly() {
383        let g = fixture_graph();
384        let hosts = g.frames_by_host();
385        assert_eq!(
386            hosts.get("example.com").map(|v| v.len()),
387            Some(2),
388            "main + sidebar both on example.com"
389        );
390        assert_eq!(
391            hosts.get("challenges.cloudflare.com").map(|v| v.len()),
392            Some(2),
393            "two CF turnstile frames"
394        );
395    }
396
397    #[test]
398    fn frames_by_host_skips_unparseable_urls() {
399        // (root) URL is not a valid http(s) URL → must be skipped.
400        let g = fixture_graph();
401        let hosts = g.frames_by_host();
402        assert!(!hosts.contains_key("(root)"));
403    }
404
405    #[test]
406    fn children_leaf_node_returns_empty() {
407        let g = fixture_graph();
408        // frame_aa (3) is a leaf — no children.
409        assert!(g.children(3).is_empty());
410    }
411
412    #[test]
413    fn children_oob_returns_empty() {
414        let g = fixture_graph();
415        assert!(g.children(999).is_empty());
416    }
417
418    #[test]
419    fn bfs_single_node() {
420        let g = FrameGraph {
421            nodes: vec![FrameNode {
422                frame_id: None,
423                parent: None,
424                url: "solo".into(),
425                title: String::new(),
426                has_captcha_marker: false,
427                depth: 0,
428            }],
429        };
430        assert_eq!(g.bfs(), vec![0]);
431    }
432
433    #[test]
434    fn bfs_linear_chain() {
435        let g = FrameGraph {
436            nodes: vec![
437                FrameNode { frame_id: Some("A".into()), parent: None, url: "a".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
438                FrameNode { frame_id: Some("B".into()), parent: Some(0), url: "b".into(), title: String::new(), has_captcha_marker: false, depth: 1 },
439                FrameNode { frame_id: Some("C".into()), parent: Some(1), url: "c".into(), title: String::new(), has_captcha_marker: false, depth: 2 },
440            ],
441        };
442        assert_eq!(g.bfs(), vec![0, 1, 2]);
443    }
444
445    #[test]
446    fn deepest_captcha_none_when_no_markers() {
447        let g = FrameGraph {
448            nodes: vec![
449                FrameNode { frame_id: None, parent: None, url: "root".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
450                FrameNode { frame_id: Some("A".into()), parent: Some(0), url: "a".into(), title: String::new(), has_captcha_marker: false, depth: 1 },
451            ],
452        };
453        assert!(g.deepest_captcha().is_none());
454    }
455
456    #[test]
457    fn deepest_captcha_prefers_last_at_same_depth() {
458        let g = FrameGraph {
459            nodes: vec![
460                FrameNode { frame_id: None, parent: None, url: "root".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
461                FrameNode { frame_id: Some("A".into()), parent: Some(0), url: "a".into(), title: String::new(), has_captcha_marker: true, depth: 1 },
462                FrameNode { frame_id: Some("B".into()), parent: Some(0), url: "b".into(), title: String::new(), has_captcha_marker: true, depth: 1 },
463            ],
464        };
465        // Both at depth 1; iteration order means B (index 2) wins.
466        assert_eq!(g.deepest_captcha(), Some(2));
467    }
468
469    #[test]
470    fn ancestors_inclusive_orphaned_node_stops_at_root() {
471        // A node whose parent index doesn't exist should still be included
472        // and then stop because the parent lookup fails.
473        let g = FrameGraph {
474            nodes: vec![
475                FrameNode { frame_id: None, parent: None, url: "root".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
476                FrameNode { frame_id: Some("orphan".into()), parent: Some(999), url: "orphan".into(), title: String::new(), has_captcha_marker: false, depth: 1 },
477            ],
478        };
479        let path = g.ancestors_inclusive(1);
480        assert_eq!(path, vec![1]);
481    }
482
483    #[test]
484    fn frames_by_host_empty_graph() {
485        let g = FrameGraph::default();
486        assert!(g.frames_by_host().is_empty());
487    }
488
489    #[test]
490    fn frames_by_host_with_port() {
491        let g = FrameGraph {
492            nodes: vec![
493                FrameNode { frame_id: None, parent: None, url: "http://localhost:8080/path".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
494            ],
495        };
496        let hosts = g.frames_by_host();
497        assert_eq!(hosts.get("localhost").map(|v| v.len()), Some(1));
498    }
499
500    #[test]
501    fn frames_by_host_ip_address() {
502        let g = FrameGraph {
503            nodes: vec![
504                FrameNode { frame_id: None, parent: None, url: "http://192.168.1.1/admin".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
505            ],
506        };
507        let hosts = g.frames_by_host();
508        assert_eq!(hosts.get("192.168.1.1").map(|v| v.len()), Some(1));
509    }
510}