use std::sync::Arc;
use futures::future::BoxFuture;
use serde_json::json;
use zendriver_transport::SessionHandle;
use crate::detection::CaptchaKind;
use crate::error::ImpervaError;
#[derive(Debug, Clone)]
pub struct CaptchaChallenge {
pub kind: CaptchaKind,
pub site_key: Option<String>,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct CaptchaSolution {
pub token: String,
pub form_field: String,
}
pub(crate) type CaptchaSolver = dyn Fn(
CaptchaChallenge,
) -> BoxFuture<'static, Result<CaptchaSolution, Box<dyn std::error::Error + Send + Sync>>>
+ Send
+ Sync;
pub(crate) fn arc_solver<F, Fut>(f: F) -> Arc<CaptchaSolver>
where
F: Fn(CaptchaChallenge) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<
Output = Result<CaptchaSolution, Box<dyn std::error::Error + Send + Sync>>,
> + Send
+ 'static,
{
Arc::new(move |challenge| Box::pin(f(challenge)))
}
pub(crate) async fn extract_captcha_site_key(
session: &SessionHandle,
kind: CaptchaKind,
) -> Result<(Option<String>, String), ImpervaError> {
const PROBE_JS: &str = r#"
(function () {
function findKey(selector, attr) {
var el = document.querySelector(selector);
return el ? el.getAttribute(attr) : null;
}
var hcap =
findKey(".h-captcha", "data-sitekey") ||
findKey("[data-hcaptcha-sitekey]", "data-hcaptcha-sitekey");
var rcap =
findKey(".g-recaptcha", "data-sitekey") ||
findKey("[data-recaptcha-sitekey]", "data-recaptcha-sitekey");
return { hcap: hcap, rcap: rcap, url: location.href };
})()
"#;
let res = session
.call(
"Runtime.evaluate",
json!({
"expression": PROBE_JS,
"returnByValue": true,
}),
)
.await?;
let value = res
.get("result")
.and_then(|r| r.get("value"))
.cloned()
.unwrap_or(serde_json::Value::Null);
#[derive(serde::Deserialize)]
struct Probe {
hcap: Option<String>,
rcap: Option<String>,
url: String,
}
let probe: Probe = serde_json::from_value(value)
.map_err(|e| ImpervaError::JsError(format!("invalid captcha probe payload: {e}")))?;
let site_key = match kind {
CaptchaKind::HCaptcha => probe.hcap,
CaptchaKind::Recaptcha => probe.rcap,
CaptchaKind::ImpervaNative | CaptchaKind::Unknown => None,
};
Ok((site_key, probe.url))
}
pub(crate) async fn inject_captcha_solution(
session: &SessionHandle,
solution: &CaptchaSolution,
) -> Result<(), ImpervaError> {
let name = solution
.form_field
.replace('\\', "\\\\")
.replace('"', "\\\"");
let script = format!(
r#"
(function () {{
var field = document.querySelector('[name="{name}"]')
|| document.getElementById("{name}");
if (!field) {{
var t = document.createElement("textarea");
t.name = "{name}";
t.id = "{name}";
t.style.display = "none";
document.body.appendChild(t);
field = t;
}}
field.value = {token};
field.dispatchEvent(new Event("change", {{ bubbles: true }}));
return true;
}})()
"#,
name = name,
token = serde_json::Value::String(solution.token.clone()),
);
let res = session
.call(
"Runtime.evaluate",
json!({
"expression": script,
"returnByValue": true,
}),
)
.await?;
if let Some(details) = res.get("exceptionDetails") {
let msg = details
.get("exception")
.and_then(|e| e.get("description"))
.and_then(|d| d.as_str())
.unwrap_or("unknown")
.to_string();
return Err(ImpervaError::JsError(msg));
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn extract_returns_none_when_neither_sitekey_attr_present() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let fut = tokio::spawn({
let s = sess.clone();
async move { extract_captcha_site_key(&s, CaptchaKind::HCaptcha).await }
});
let id = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(
id,
json!({
"result": {
"type": "object",
"value": {
"hcap": null,
"rcap": null,
"url": "https://example.com/x",
}
}
}),
)
.await;
let (site_key, url) = fut.await.unwrap().unwrap();
assert!(site_key.is_none(), "no .h-captcha[data-sitekey] → None");
assert_eq!(url, "https://example.com/x");
conn.shutdown();
}
#[tokio::test]
async fn extract_uses_rcap_for_recaptcha_kind() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let fut = tokio::spawn({
let s = sess.clone();
async move { extract_captcha_site_key(&s, CaptchaKind::Recaptcha).await }
});
let id = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(
id,
json!({
"result": {
"type": "object",
"value": {
"hcap": "IGNORED",
"rcap": "RKEY",
"url": "https://x.com/",
}
}
}),
)
.await;
let (site_key, _) = fut.await.unwrap().unwrap();
assert_eq!(site_key.as_deref(), Some("RKEY"));
conn.shutdown();
}
#[tokio::test]
async fn extract_returns_none_for_imperva_native_kind() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let fut = tokio::spawn({
let s = sess.clone();
async move { extract_captcha_site_key(&s, CaptchaKind::ImpervaNative).await }
});
let id = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(
id,
json!({
"result": {
"type": "object",
"value": {
"hcap": "WOULD_IGNORE",
"rcap": "WOULD_IGNORE",
"url": "https://x.com/",
}
}
}),
)
.await;
let (site_key, _) = fut.await.unwrap().unwrap();
assert!(
site_key.is_none(),
"ImpervaNative kind never extracts a site_key"
);
conn.shutdown();
}
#[tokio::test]
async fn inject_escapes_backslash_and_quote_in_form_field() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let solution = CaptchaSolution {
token: "TOK".into(),
form_field: r#"a\b"c"#.into(),
};
let fut = tokio::spawn({
let s = sess.clone();
async move { inject_captcha_solution(&s, &solution).await }
});
let id = mock.expect_cmd("Runtime.evaluate").await;
let sent = mock.last_sent();
let script = sent["params"]["expression"].as_str().unwrap();
assert!(
script.contains(r#"a\\b\"c"#),
"form_field must have \\ and \" both escaped; script was: {script}"
);
mock.reply(
id,
json!({ "result": { "type": "boolean", "value": true } }),
)
.await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn inject_returns_jserror_when_evaluator_raises() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let solution = CaptchaSolution {
token: "TOK".into(),
form_field: "h-captcha-response".into(),
};
let fut = tokio::spawn({
let s = sess.clone();
async move { inject_captcha_solution(&s, &solution).await }
});
let id = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(
id,
json!({
"result": { "type": "undefined" },
"exceptionDetails": {
"exception": { "description": "TypeError: nope" }
}
}),
)
.await;
let err = fut.await.unwrap().unwrap_err();
assert!(matches!(err, ImpervaError::JsError(s) if s.contains("TypeError")));
conn.shutdown();
}
}