Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions px-camoufox/src/infrastructure/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ pub mod caps;
pub mod fetch_script;
pub mod fetch_strategies;
pub mod proxy_pool;
pub mod sensor_capture;
pub mod session;
pub mod session_pool;
pub mod synthetic_user;

pub use sensor_capture::{CaptureResult, CaptureXhr, capture_sensor};
131 changes: 131 additions & 0 deletions px-camoufox/src/infrastructure/sensor_capture.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Ground-truth XHR + plaintext capture for the px-3 sensor.
//!
//! Spawns a fresh Camoufox session, navigates to `about:blank`, injects
//! a small monkey-patch that records:
//! * every `JSON.stringify(arr)` where `arr` is a `[{t, d}]` shape —
//! i.e. the plaintext sensor events before XOR/base64;
//! * every `XMLHttpRequest.send(body)` whose URL contains `/b/s` —
//! the final wire payload.
//!
//! Then navigates to the target URL and waits `wait_ms` for the
//! runtime to fire. Captures are returned as a `CaptureResult` plus the
//! cookies the session collected.

use crate::domain::config::CamoufoxConfig;
use crate::infrastructure::caps::{build_capabilities, pick_free_port, wait_for_geckodriver};
use fantoccini::ClientBuilder;
use px_errors::AppError;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::process::Command;
use tokio::time::sleep;

const HOOK_SCRIPT: &str = include_str!("sensor_capture_hook.js");

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureXhr {
pub url: String,
pub body: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureResult {
pub plaintext_events: Vec<serde_json::Value>,
pub xhr_sends: Vec<CaptureXhr>,
pub cookies: Vec<(String, String)>,
pub user_agent: String,
}

pub async fn capture_sensor(
config: &CamoufoxConfig,
proxy: Option<&str>,
target_url: &str,
wait_ms: u64,
) -> Result<CaptureResult, AppError> {
let port = pick_free_port().await?;
let mut child = Command::new(&config.geckodriver_bin)
.arg("--port")
.arg(port.to_string())
.arg("--binary")
.arg(&config.camoufox_bin)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()
.map_err(|e| AppError::InternalError(format!("spawn geckodriver: {e}")))?;
wait_for_geckodriver(port, Duration::from_secs(15)).await?;
let caps = build_capabilities(config, proxy);
let endpoint = format!("http://127.0.0.1:{port}");
let client = ClientBuilder::native()
.capabilities(caps)
.connect(&endpoint)
.await
.map_err(|e| AppError::InternalError(format!("webdriver connect: {e}")))?;

let outcome = run_capture(&client, target_url, wait_ms).await;
let _ = client.close().await;
let _ = child.kill().await;
outcome
}

async fn run_capture(
client: &fantoccini::Client,
target_url: &str,
wait_ms: u64,
) -> Result<CaptureResult, AppError> {
if tokio::time::timeout(Duration::from_secs(20), client.goto("about:blank"))
.await
.is_err()
{
return Err(AppError::InternalError("nav about:blank timeout".into()));
}
client
.execute(HOOK_SCRIPT, vec![])
.await
.map_err(|e| AppError::InternalError(format!("inject hook: {e}")))?;

if tokio::time::timeout(Duration::from_secs(60), client.goto(target_url))
.await
.is_err()
{
return Err(AppError::InternalError(format!("nav {target_url} timeout")));
}
sleep(Duration::from_millis(wait_ms)).await;

let dump = client
.execute(
"return JSON.stringify({\
captures: window.__pxCaptures || [],\
xhr: window.__pxXhr || [],\
ua: navigator.userAgent\
});",
vec![],
)
.await
.map_err(|e| AppError::InternalError(format!("read captures: {e}")))?;
let raw = dump.as_str().unwrap_or("");
#[derive(Deserialize)]
struct Raw {
captures: Vec<serde_json::Value>,
xhr: Vec<CaptureXhr>,
ua: String,
}
let parsed: Raw = serde_json::from_str(raw)
.map_err(|e| AppError::InternalError(format!("parse captures: {e}")))?;

let raw_cookies = client
.get_all_cookies()
.await
.map_err(|e| AppError::InternalError(format!("cookies: {e}")))?;
let cookies = raw_cookies
.into_iter()
.map(|c| (c.name().to_string(), c.value().to_string()))
.collect();

Ok(CaptureResult {
plaintext_events: parsed.captures,
xhr_sends: parsed.xhr,
cookies,
user_agent: parsed.ua,
})
}
51 changes: 51 additions & 0 deletions px-camoufox/src/infrastructure/sensor_capture_hook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Monkey-patch hooks injected into a Camoufox session so we can read
// the plaintext sensor events the PX runtime is about to encrypt, plus
// the final wire payload it POSTs to /b/s.
//
// Stores into:
// window.__pxCaptures — array of decoded `[{t, d}, …]` event batches
// window.__pxXhr — array of { url, body } for XHRs that hit /b/s
//
// The hook is idempotent — re-injecting is a no-op.
(function () {
if (window.__pxHooked) return;
window.__pxHooked = true;
window.__pxCaptures = [];
window.__pxXhr = [];

var origStringify = JSON.stringify;
JSON.stringify = function (value) {
try {
if (
Array.isArray(value) &&
value.length > 0 &&
value[0] &&
typeof value[0].t === 'string' &&
value[0].d &&
typeof value[0].d === 'object'
) {
// Snapshot a copy so later mutation doesn't tamper with the record.
window.__pxCaptures.push(JSON.parse(origStringify(value)));
}
} catch (_) {}
return origStringify.apply(JSON, arguments);
};

var origOpen = XMLHttpRequest.prototype.open;
var origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__pxUrl = url;
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
try {
if (this.__pxUrl && String(this.__pxUrl).indexOf('/b/s') !== -1) {
window.__pxXhr.push({
url: this.__pxUrl,
body: typeof body === 'string' ? body : null,
});
}
} catch (_) {}
return origSend.apply(this, arguments);
};
})();
1 change: 1 addition & 0 deletions px-camoufox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pub mod infrastructure;

pub use domain::config::{CamoufoxConfig, CamoufoxConfigError};
pub use infrastructure::camoufox_pool::CamoufoxPool;
pub use infrastructure::sensor_capture::{CaptureResult, CaptureXhr, capture_sensor};
87 changes: 87 additions & 0 deletions px-camoufox/tests/capture_sensor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
//! Live ground-truth capture for the px-3 sensor.
//!
//! Run manually with:
//! CAPTURE_PX=1 \
//! [CAPTURE_URL=https://www.pedidosya.com.ar/] \
//! [CAPTURE_WAIT_MS=12000] \
//! [CAPTURE_OUT=px-research/captures/eT15wiaE/<date>.json] \
//! [PX_PROXIES=socks5://...] \
//! cargo test -q -p pxsolver-camoufox --test capture_sensor -- --ignored --nocapture
//!
//! The output JSON contains:
//! * `plaintext_events`: every `[{t, d}, …]` batch the runtime
//! JSON-stringified during the wait window (= the input to `vP`).
//! * `xhr_sends`: URL + body of every XHR that hit `/b/s` (= the
//! wire-format payload after `vP`).
//! * `cookies`: post-solve cookie jar.
//! * `user_agent`: navigator.userAgent observed in the session.
//!
//! Use the captured batches to calibrate `px-native::events::default_batch`
//! (ADR-0024 N3 follow-up).

use px_camoufox::{CamoufoxConfig, capture_sensor};
use std::path::PathBuf;
use std::time::Duration;

#[tokio::test]
#[ignore]
async fn live_capture_for_calibration() {
if std::env::var("CAPTURE_PX").ok().as_deref() != Some("1") {
eprintln!("set CAPTURE_PX=1 to run");
return;
}
let url =
std::env::var("CAPTURE_URL").unwrap_or_else(|_| "https://www.pedidosya.com.ar/".into());
let wait_ms: u64 = std::env::var("CAPTURE_WAIT_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(12_000);
let out: PathBuf = std::env::var("CAPTURE_OUT")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
PathBuf::from(format!("../px-research/captures/eT15wiaE/{secs}.json"))
});

let mut cfg = CamoufoxConfig::from_env();
cfg.navigate_timeout = Duration::from_secs(60);
eprintln!(
"capturing: url={url} wait_ms={wait_ms} out={}",
out.display()
);

let proxy = std::env::var("PX_PROXIES")
.ok()
.and_then(|s| s.split(',').next().map(|p| p.trim().to_string()));

let result = tokio::time::timeout(
Duration::from_secs(120),
capture_sensor(&cfg, proxy.as_deref(), &url, wait_ms),
)
.await
.expect("capture timeout")
.expect("capture ok");

if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent).expect("create capture dir");
}
let json = serde_json::to_string_pretty(&result).expect("serialize");
std::fs::write(&out, json).expect("write capture");

eprintln!(
"\n=== CAPTURE_PX ok ===\n plaintext_events: {}\n xhr_sends: {}\n cookies: {}\n user_agent: {}\n wrote: {}",
result.plaintext_events.len(),
result.xhr_sends.len(),
result.cookies.len(),
result.user_agent,
out.display(),
);
assert!(
!result.plaintext_events.is_empty() || !result.xhr_sends.is_empty(),
"no PX traffic captured — check CAPTURE_WAIT_MS / proxy / target reachability",
);
}
1 change: 1 addition & 0 deletions px-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ argon2 = { workspace = true }
clap = { workspace = true }
px-auth = { workspace = true }
px-detector = { workspace = true }
px-native = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
11 changes: 11 additions & 0 deletions px-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub enum Cmd {
Serve,
/// Solve a target URL by calling a running px-server's POST /v1/solve.
Solve(SolveArgs),
/// Diff a Camoufox sensor capture against px-native's synthetic batch.
Calibrate(CalibrateArgs),
}

#[derive(Args, Debug)]
Expand Down Expand Up @@ -91,3 +93,12 @@ pub struct SolveArgs {
#[arg(long)]
pub proxy: Option<String>,
}

#[derive(Args, Debug)]
pub struct CalibrateArgs {
/// Path to a capture JSON emitted by `pxsolver-camoufox::capture_sensor`.
pub capture: PathBuf,
/// Print the report as JSON (default: human-readable).
#[arg(long)]
pub json: bool,
}
63 changes: 63 additions & 0 deletions px-cli/src/commands/calibrate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//! `px-cli calibrate` — compare a Camoufox sensor capture against
//! `px-native`'s synthetic baseline batch and print the diff. Drives
//! ADR-0024 v1.8.0 P3 (field grammar calibration).

use std::fs;

use anyhow::{Context, Result};
use px_native::events::{SensorEvent, SyntheticIdentity, calibrate, default_batch};
use serde::Deserialize;

use crate::cli::CalibrateArgs;

#[derive(Deserialize)]
struct Capture {
plaintext_events: Vec<Vec<SensorEvent>>,
}

pub async fn run(args: CalibrateArgs) -> Result<()> {
let raw = fs::read_to_string(&args.capture)
.with_context(|| format!("read capture {}", args.capture.display()))?;
let capture: Capture = serde_json::from_str(&raw)
.with_context(|| format!("parse capture {}", args.capture.display()))?;

let observed: Vec<SensorEvent> = capture.plaintext_events.into_iter().flatten().collect();
let synth = default_batch(&SyntheticIdentity::test_default(), 1_700_000_000_000);
let report = calibrate(&observed, &synth);

if args.json {
println!(
"{}",
serde_json::to_string_pretty(&report).context("serialize report")?
);
return Ok(());
}

println!("Observed events: {}", observed.len());
println!("Synthetic events: {}\n", synth.len());

if !report.missing_tags.is_empty() {
println!("Missing tags (runtime emits, default_batch does not):");
for t in &report.missing_tags {
println!(" - {t}");
}
println!();
}
if !report.extra_tags.is_empty() {
println!("Extra tags (default_batch emits, runtime did not):");
for t in &report.extra_tags {
println!(" - {t}");
}
println!();
}
for (tag, diff) in &report.per_tag {
let obs = diff.observed_keys.len();
let syn = diff.synthesised_keys.len();
let miss = diff.missing_keys.len();
println!("[{tag}] observed={obs} synth={syn} missing={miss}");
for k in &diff.missing_keys {
println!(" + need: {k}");
}
}
Ok(())
}
1 change: 1 addition & 0 deletions px-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod allowlist;
pub mod calibrate;
pub mod detect;
pub mod keys;
pub mod serve;
Expand Down
Loading
Loading