use crate::browser::Page;
use anyhow::Result;
use std::collections::{HashMap, VecDeque};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrameNode {
pub frame_id: Option<String>,
pub parent: Option<usize>,
pub url: String,
pub title: String,
pub has_captcha_marker: bool,
pub depth: usize,
}
#[derive(Debug, Clone, Default)]
pub struct FrameGraph {
pub nodes: Vec<FrameNode>,
}
impl FrameGraph {
pub async fn snapshot(page: &Page) -> Result<Self> {
let tree = page.frame_tree().await?;
let mut enriched: Vec<EnrichedFrame> = Vec::with_capacity(tree.len());
for entry in &tree {
let (title, has_captcha_marker) =
match page.evaluate_in_context(PROBE_JS, &entry.id).await {
Ok(eval) => match eval.into_value::<FrameProbe>() {
Ok(v) => (v.title, v.has_captcha_marker),
Err(e) => {
tracing::warn!("frame {} probe decode failed: {e}", entry.url);
(String::new(), false)
}
},
Err(e) => {
tracing::warn!("frame {} probe eval failed: {e}", entry.url);
(String::new(), false)
}
};
enriched.push(EnrichedFrame {
id: entry.id.inner().to_string(),
url: entry.url.clone(),
parent: entry.parent.as_ref().map(|p| p.inner().to_string()),
title,
has_captcha_marker,
});
}
Ok(Self::assemble(&enriched))
}
fn assemble(entries: &[EnrichedFrame]) -> Self {
let mut nodes: Vec<FrameNode> = Vec::with_capacity(entries.len() + 1);
nodes.push(FrameNode {
frame_id: None,
parent: None,
url: "(root)".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
});
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
for e in entries {
let parent_idx = match &e.parent {
None => 0, Some(pid) => match id_to_idx.get(pid) {
Some(&idx) => idx,
None => {
tracing::warn!(
"frame parent {pid} not seen before child {}, attaching to root",
e.id
);
0
}
},
};
let depth = nodes[parent_idx].depth + 1;
id_to_idx.insert(e.id.clone(), nodes.len());
nodes.push(FrameNode {
frame_id: Some(e.id.clone()),
parent: Some(parent_idx),
url: e.url.clone(),
title: e.title.clone(),
has_captcha_marker: e.has_captcha_marker,
depth,
});
}
Self { nodes }
}
pub fn any_captcha_marker(&self) -> bool {
self.nodes.iter().any(|n| n.has_captcha_marker)
}
pub fn children(&self, parent_idx: usize) -> Vec<usize> {
self.nodes
.iter()
.enumerate()
.filter_map(|(i, n)| {
if n.parent == Some(parent_idx) {
Some(i)
} else {
None
}
})
.collect()
}
pub fn bfs(&self) -> Vec<usize> {
if self.nodes.is_empty() {
return Vec::new();
}
let mut order = Vec::with_capacity(self.nodes.len());
let mut queue: VecDeque<usize> = VecDeque::new();
queue.push_back(0);
while let Some(idx) = queue.pop_front() {
order.push(idx);
for child in self.children(idx) {
queue.push_back(child);
}
}
order
}
pub fn deepest_captcha(&self) -> Option<usize> {
self.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.has_captcha_marker)
.max_by_key(|(_, n)| n.depth)
.map(|(i, _)| i)
}
pub fn ancestors_inclusive(&self, mut node_idx: usize) -> Vec<usize> {
let mut out = Vec::new();
let mut visited = std::collections::HashSet::new();
while let Some(node) = self.nodes.get(node_idx) {
if !visited.insert(node_idx) {
break;
}
out.push(node_idx);
match node.parent {
Some(p) => node_idx = p,
None => break,
}
}
out
}
pub fn frames_by_host(&self) -> HashMap<String, Vec<usize>> {
let mut out: HashMap<String, Vec<usize>> = HashMap::new();
for (i, n) in self.nodes.iter().enumerate() {
if let Some(host) = url::Url::parse(&n.url)
.ok()
.and_then(|u| u.host_str().map(String::from))
{
out.entry(host).or_default().push(i);
}
}
out
}
}
#[derive(serde::Deserialize)]
struct FrameProbe {
title: String,
has_captcha_marker: bool,
}
struct EnrichedFrame {
id: String,
url: String,
parent: Option<String>,
title: String,
has_captcha_marker: bool,
}
const PROBE_JS: &str = r#"({
title: document.title || '',
has_captcha_marker: !!document.querySelector(
'iframe[src*="challenges.cloudflare.com"], iframe[src*="recaptcha"], iframe[src*="hcaptcha"], '
+ 'iframe[src*="arkoselabs"], iframe[src*="datadome"], iframe[src*="geetest"], '
+ 'iframe[src*="perimeterx"], iframe[src*="kasada"], iframe[src*="incapsula"], '
+ '.cf-turnstile, .h-captcha, .g-recaptcha, '
+ '#challenge-form, #challenge-stage, #cf-please-wait, #px-captcha, '
+ '[id^="captcha"], [class*="captcha" i], [class*="challenge" i]'
)
})"#;
#[cfg(test)]
mod tests {
use super::*;
fn fixture_graph() -> FrameGraph {
FrameGraph {
nodes: vec![
FrameNode {
frame_id: None,
parent: None,
url: "(root)".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
},
FrameNode {
frame_id: Some("F1".into()),
parent: Some(0),
url: "https://example.com".into(),
title: "Main".into(),
has_captcha_marker: false,
depth: 1,
},
FrameNode {
frame_id: Some("F2".into()),
parent: Some(1),
url: "https://challenges.cloudflare.com/turnstile".into(),
title: String::new(),
has_captcha_marker: true,
depth: 2,
},
FrameNode {
frame_id: Some("F3".into()),
parent: Some(2),
url: "https://challenges.cloudflare.com/turnstile/inner".into(),
title: String::new(),
has_captcha_marker: true,
depth: 3,
},
FrameNode {
frame_id: Some("F4".into()),
parent: Some(1),
url: "https://example.com/sidebar".into(),
title: String::new(),
has_captcha_marker: false,
depth: 2,
},
],
}
}
#[test]
fn empty_graph_has_no_captcha_markers() {
let g = FrameGraph::default();
assert!(!g.any_captcha_marker());
assert!(g.bfs().is_empty());
assert!(g.deepest_captcha().is_none());
}
#[test]
fn any_captcha_marker_short_circuits_on_first_match() {
let g = fixture_graph();
assert!(g.any_captcha_marker());
}
#[test]
fn children_returns_all_direct_children_of_root() {
let g = fixture_graph();
let kids = g.children(0);
assert_eq!(kids, vec![1]);
}
#[test]
fn children_returns_all_direct_children_of_internal_node() {
let g = fixture_graph();
let kids = g.children(1);
assert_eq!(kids, vec![2, 4]);
}
#[test]
fn bfs_visits_root_first_then_each_level() {
let g = fixture_graph();
let order = g.bfs();
assert_eq!(order, vec![0, 1, 2, 4, 3]);
}
#[test]
fn deepest_captcha_finds_innermost_marker() {
let g = fixture_graph();
let deepest = g.deepest_captcha().expect("fixture has captcha markers");
assert_eq!(deepest, 3, "frame_aa is the deepest captcha-bearing node");
}
#[test]
fn ancestors_inclusive_walks_to_root_in_order() {
let g = fixture_graph();
let path = g.ancestors_inclusive(3);
assert_eq!(path, vec![3, 2, 1, 0]);
}
#[test]
fn ancestors_inclusive_handles_oob_index_gracefully() {
let g = fixture_graph();
assert!(g.ancestors_inclusive(999).is_empty());
}
#[test]
fn ancestors_inclusive_handles_root_node() {
let g = fixture_graph();
let path = g.ancestors_inclusive(0);
assert_eq!(path, vec![0]);
}
#[test]
fn frames_by_host_groups_correctly() {
let g = fixture_graph();
let hosts = g.frames_by_host();
assert_eq!(
hosts.get("example.com").map(|v| v.len()),
Some(2),
"main + sidebar both on example.com"
);
assert_eq!(
hosts.get("challenges.cloudflare.com").map(|v| v.len()),
Some(2),
"two CF turnstile frames"
);
}
#[test]
fn frames_by_host_skips_unparseable_urls() {
let g = fixture_graph();
let hosts = g.frames_by_host();
assert!(!hosts.contains_key("(root)"));
}
#[test]
fn children_leaf_node_returns_empty() {
let g = fixture_graph();
assert!(g.children(3).is_empty());
}
#[test]
fn children_oob_returns_empty() {
let g = fixture_graph();
assert!(g.children(999).is_empty());
}
#[test]
fn bfs_single_node() {
let g = FrameGraph {
nodes: vec![FrameNode {
frame_id: None,
parent: None,
url: "solo".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
}],
};
assert_eq!(g.bfs(), vec![0]);
}
#[test]
fn bfs_linear_chain() {
let g = FrameGraph {
nodes: vec![
FrameNode {
frame_id: Some("A".into()),
parent: None,
url: "a".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
},
FrameNode {
frame_id: Some("B".into()),
parent: Some(0),
url: "b".into(),
title: String::new(),
has_captcha_marker: false,
depth: 1,
},
FrameNode {
frame_id: Some("C".into()),
parent: Some(1),
url: "c".into(),
title: String::new(),
has_captcha_marker: false,
depth: 2,
},
],
};
assert_eq!(g.bfs(), vec![0, 1, 2]);
}
#[test]
fn deepest_captcha_none_when_no_markers() {
let g = FrameGraph {
nodes: vec![
FrameNode {
frame_id: None,
parent: None,
url: "root".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
},
FrameNode {
frame_id: Some("A".into()),
parent: Some(0),
url: "a".into(),
title: String::new(),
has_captcha_marker: false,
depth: 1,
},
],
};
assert!(g.deepest_captcha().is_none());
}
#[test]
fn deepest_captcha_prefers_last_at_same_depth() {
let g = FrameGraph {
nodes: vec![
FrameNode {
frame_id: None,
parent: None,
url: "root".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
},
FrameNode {
frame_id: Some("A".into()),
parent: Some(0),
url: "a".into(),
title: String::new(),
has_captcha_marker: true,
depth: 1,
},
FrameNode {
frame_id: Some("B".into()),
parent: Some(0),
url: "b".into(),
title: String::new(),
has_captcha_marker: true,
depth: 1,
},
],
};
assert_eq!(g.deepest_captcha(), Some(2));
}
#[test]
fn ancestors_inclusive_orphaned_node_stops_at_root() {
let g = FrameGraph {
nodes: vec![
FrameNode {
frame_id: None,
parent: None,
url: "root".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
},
FrameNode {
frame_id: Some("orphan".into()),
parent: Some(999),
url: "orphan".into(),
title: String::new(),
has_captcha_marker: false,
depth: 1,
},
],
};
let path = g.ancestors_inclusive(1);
assert_eq!(path, vec![1]);
}
#[test]
fn frames_by_host_empty_graph() {
let g = FrameGraph::default();
assert!(g.frames_by_host().is_empty());
}
#[test]
fn frames_by_host_with_port() {
let g = FrameGraph {
nodes: vec![FrameNode {
frame_id: None,
parent: None,
url: "http://localhost:8080/path".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
}],
};
let hosts = g.frames_by_host();
assert_eq!(hosts.get("localhost").map(|v| v.len()), Some(1));
}
#[test]
fn frames_by_host_ip_address() {
let g = FrameGraph {
nodes: vec![FrameNode {
frame_id: None,
parent: None,
url: "http://192.168.1.1/admin".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
}],
};
let hosts = g.frames_by_host();
assert_eq!(hosts.get("192.168.1.1").map(|v| v.len()), Some(1));
}
fn enriched(id: &str, parent: Option<&str>, url: &str, captcha: bool) -> EnrichedFrame {
EnrichedFrame {
id: id.into(),
url: url.into(),
parent: parent.map(Into::into),
title: String::new(),
has_captcha_marker: captcha,
}
}
#[test]
fn assemble_recovers_nested_recaptcha_depth_not_a_flat_tree() {
let entries = vec![
enriched("MAIN", None, "https://victim.example/login", false),
enriched(
"ANCHOR",
Some("MAIN"),
"https://www.google.com/recaptcha/api2/anchor",
false,
),
enriched(
"BFRAME",
Some("ANCHOR"),
"https://www.google.com/recaptcha/api2/bframe",
true,
),
];
let g = FrameGraph::assemble(&entries);
assert_eq!(g.nodes.len(), 4);
assert_eq!(g.nodes[0].depth, 0, "synthetic root");
assert_eq!(g.nodes[1].depth, 1, "main document under root");
assert_eq!(g.nodes[2].depth, 2, "anchor nested in main");
assert_eq!(g.nodes[3].depth, 3, "bframe nested in anchor");
assert_eq!(g.nodes[1].parent, Some(0));
assert_eq!(g.nodes[2].parent, Some(1));
assert_eq!(g.nodes[3].parent, Some(2));
let deepest = g.deepest_captcha().expect("bframe carries the marker");
assert_eq!(deepest, 3);
assert_eq!(g.ancestors_inclusive(deepest), vec![3, 2, 1, 0]);
assert_eq!(g.nodes[3].frame_id.as_deref(), Some("BFRAME"));
}
#[test]
fn assemble_keeps_true_siblings_at_the_same_depth() {
let entries = vec![
enriched("MAIN", None, "https://site.example/", false),
enriched("ADS", Some("MAIN"), "https://ads.example/slot", false),
enriched("CHAT", Some("MAIN"), "https://chat.example/widget", false),
];
let g = FrameGraph::assemble(&entries);
assert_eq!(
g.children(1),
vec![2, 3],
"both iframes are children of main"
);
assert_eq!(g.nodes[2].depth, 2);
assert_eq!(g.nodes[3].depth, 2);
}
#[test]
fn assemble_attaches_each_top_level_context_to_the_root() {
let entries = vec![
enriched("TAB1", None, "https://a.example/", false),
enriched("TAB2", None, "https://b.example/", false),
];
let g = FrameGraph::assemble(&entries);
assert_eq!(g.children(0), vec![1, 2]);
assert_eq!(g.nodes[1].depth, 1);
assert_eq!(g.nodes[2].depth, 1);
}
#[test]
fn assemble_empty_tree_is_just_the_root() {
let g = FrameGraph::assemble(&[]);
assert_eq!(g.nodes.len(), 1);
assert_eq!(g.nodes[0].frame_id, None);
assert_eq!(g.nodes[0].depth, 0);
}
}