use std::{
io::{BufRead, BufReader, Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::{Duration, Instant},
};
use base64::Engine as _;
use serde_json::{Value, json};
const REGISTRY: &str = r#"(() => {
if (globalThis.__supercovWasm) return;
const instances = (globalThis.__supercovWasm = []);
const Instance = WebAssembly.Instance;
function Recorded(module, imports) {
const instance = new Instance(module, imports);
instances.push(instance);
return instance;
}
Recorded.prototype = Instance.prototype;
WebAssembly.Instance = Recorded;
for (const name of ["instantiate", "instantiateStreaming"]) {
const original = WebAssembly[name];
if (typeof original !== "function") continue;
WebAssembly[name] = async function (...args) {
const result = await original.apply(WebAssembly, args);
instances.push(result instanceof Instance ? result : result.instance);
return result;
};
}
})();"#;
const TAKE: &str = r#"const out = [];
for (const instance of globalThis.__supercovWasm ?? []) {
const exports = instance.exports;
for (const name of Object.keys(exports)) {
if (!name.startsWith('__supercov_region_') || !exports.memory) continue;
const packed = BigInt.asUintN(64, BigInt(exports[name]()));
const at = Number(packed >> 32n), length = Number(packed & 0xffffffffn);
if (!length) continue;
const bytes = new Uint8Array(exports.memory.buffer, at, length);
let text = '';
for (let index = 0; index < length; index += 0x8000)
text += String.fromCharCode.apply(null, bytes.subarray(index, Math.min(length, index + 0x8000)));
out.push([name.slice('__supercov_region_'.length), btoa(text)]);
}
}
return out;"#;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Browser {
Chrome(PathBuf, Vec<String>),
Other(String),
}
const DRIVERS: [&str; 4] = [
"geckodriver",
"safaridriver",
"chromedriver",
"msedgedriver",
];
pub fn browser(environment: &[(String, String)]) -> Option<Browser> {
let variable = |name: &str| {
environment
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.clone())
.or_else(|| std::env::var(name).ok())
};
let arguments = |driver: &str| {
variable(&format!("{}_ARGS", driver.to_uppercase()))
.map(|value| value.split_whitespace().map(str::to_owned).collect())
.unwrap_or_default()
};
for driver in DRIVERS {
if variable(&format!("{}_REMOTE", driver.to_uppercase())).is_some() {
return Some(Browser::Other(format!("a remote {driver}")));
}
}
let pick = |driver: &str, path: PathBuf| {
if driver == "chromedriver" {
Browser::Chrome(path, arguments(driver))
} else {
Browser::Other(driver.to_owned())
}
};
for driver in DRIVERS {
if let Some(path) = variable(&driver.to_uppercase()) {
return Some(pick(driver, PathBuf::from(path)));
}
}
let path = std::env::var_os("PATH")?;
for directory in std::env::split_paths(&path) {
for driver in DRIVERS {
let candidate = directory
.join(driver)
.with_extension(std::env::consts::EXE_EXTENSION);
if candidate.exists() {
return Some(pick(driver, candidate));
}
}
}
None
}
#[derive(Debug)]
pub struct BrowserReader {
address: SocketAddr,
driver: Child,
stop: Arc<AtomicBool>,
}
impl BrowserReader {
pub fn start(chromedriver: &Path, arguments: &[String], token: &str) -> Result<Self, String> {
let port = free_port()?;
let driver = Command::new(chromedriver)
.args(arguments)
.arg(format!("--port={port}"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|error| format!("could not start {}: {error}", chromedriver.display()))?;
let upstream = SocketAddr::from(([127, 0, 0, 1], port));
let started = Instant::now();
while TcpStream::connect_timeout(&upstream, Duration::from_millis(100)).is_err() {
if started.elapsed() > Duration::from_secs(20) {
return Err(format!("{} never listened", chromedriver.display()));
}
std::thread::sleep(Duration::from_millis(50));
}
let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| error.to_string())?;
let address = listener.local_addr().map_err(|error| error.to_string())?;
listener
.set_nonblocking(true)
.map_err(|error| error.to_string())?;
let stop = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&stop);
let token = token.to_owned();
std::thread::Builder::new()
.name("supercov-browser-proxy".into())
.spawn(move || {
while !flag.load(Ordering::Acquire) {
match listener.accept() {
Ok((stream, _)) => {
let _ = stream.set_nonblocking(false);
let token = token.clone();
std::thread::spawn(move || {
let _ = relay(stream, upstream, &token);
});
}
Err(_) => std::thread::sleep(Duration::from_millis(10)),
}
}
})
.map_err(|error| error.to_string())?;
Ok(Self {
address,
driver,
stop,
})
}
pub fn remote_url(&self, evidence: &Path) -> String {
let hex = evidence
.to_string_lossy()
.bytes()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("http://{}/e/{hex}/", self.address)
}
}
impl Drop for BrowserReader {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
let _ = self.driver.kill();
let _ = self.driver.wait();
}
}
fn free_port() -> Result<u16, String> {
let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| error.to_string())?;
listener
.local_addr()
.map(|address| address.port())
.map_err(|error| error.to_string())
}
struct Request {
method: String,
path: String,
body: Vec<u8>,
}
fn read_request(stream: &mut BufReader<TcpStream>) -> Option<Request> {
let mut line = String::new();
stream.read_line(&mut line).ok()?;
let mut parts = line.split_whitespace();
let method = parts.next()?.to_owned();
let path = parts.next()?.to_owned();
let mut length = 0;
let mut chunked = false;
loop {
let mut header = String::new();
stream.read_line(&mut header).ok()?;
let header = header.trim_end();
if header.is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':') {
if name.eq_ignore_ascii_case("content-length") {
length = value.trim().parse().ok()?;
} else if name.eq_ignore_ascii_case("transfer-encoding")
&& value.to_ascii_lowercase().contains("chunked")
{
chunked = true;
}
}
}
let mut body = Vec::new();
if chunked {
loop {
let mut size = String::new();
stream.read_line(&mut size).ok()?;
let size = usize::from_str_radix(size.trim(), 16).ok()?;
let mut chunk = vec![0; size + 2];
stream.read_exact(&mut chunk).ok()?;
if size == 0 {
break;
}
body.extend_from_slice(&chunk[..size]);
}
} else {
body.resize(length, 0);
stream.read_exact(&mut body).ok()?;
}
Some(Request { method, path, body })
}
fn call(upstream: SocketAddr, method: &str, path: &str, body: &[u8]) -> Option<(u16, Vec<u8>)> {
let mut stream = TcpStream::connect(upstream).ok()?;
stream
.set_read_timeout(Some(Duration::from_secs(120)))
.ok()?;
write!(
stream,
"{method} {path} HTTP/1.1\r\nHost: {upstream}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.ok()?;
stream.write_all(body).ok()?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).ok()?;
let status = line.split_whitespace().nth(1)?.parse().ok()?;
let mut length = None;
let mut chunked = false;
loop {
let mut header = String::new();
reader.read_line(&mut header).ok()?;
let header = header.trim_end();
if header.is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':') {
if name.eq_ignore_ascii_case("content-length") {
length = value.trim().parse::<usize>().ok();
} else if name.eq_ignore_ascii_case("transfer-encoding")
&& value.to_ascii_lowercase().contains("chunked")
{
chunked = true;
}
}
}
let mut payload = Vec::new();
if chunked {
loop {
let mut size = String::new();
reader.read_line(&mut size).ok()?;
let size = usize::from_str_radix(size.trim(), 16).ok()?;
let mut chunk = vec![0; size + 2];
reader.read_exact(&mut chunk).ok()?;
if size == 0 {
break;
}
payload.extend_from_slice(&chunk[..size]);
}
} else if let Some(length) = length {
payload.resize(length, 0);
reader.read_exact(&mut payload).ok()?;
} else {
reader.read_to_end(&mut payload).ok()?;
}
Some((status, payload))
}
static REGIONS: AtomicU64 = AtomicU64::new(0);
fn relay(stream: TcpStream, upstream: SocketAddr, token: &str) -> Option<()> {
let mut reader = BufReader::new(stream.try_clone().ok()?);
let request = read_request(&mut reader)?;
let rest = request.path.strip_prefix("/e/")?;
let (hex, webdriver) = rest.split_once('/').unwrap_or((rest, ""));
let evidence = PathBuf::from(
String::from_utf8(
(0..hex.len())
.step_by(2)
.filter_map(|at| u8::from_str_radix(hex.get(at..at + 2)?, 16).ok())
.collect(),
)
.ok()?,
);
let webdriver = format!("/{webdriver}");
let session = webdriver
.strip_prefix("/session/")
.map(|rest| rest.strip_suffix("/window").unwrap_or(rest))
.filter(|id| !id.contains('/'))
.map(str::to_owned);
if request.method == "DELETE"
&& let Some(id) = &session
{
take_regions(upstream, id, &evidence, token);
}
let (status, body) = call(upstream, &request.method, &webdriver, &request.body)?;
if request.method == "POST"
&& webdriver == "/session"
&& status == 200
&& let Ok(created) = serde_json::from_slice::<Value>(&body)
&& let Some(id) = created["value"]["sessionId"]
.as_str()
.or_else(|| created["sessionId"].as_str())
{
let install = json!({
"cmd": "Page.addScriptToEvaluateOnNewDocument",
"params": {"source": REGISTRY},
});
let _ = call(
upstream,
"POST",
&format!("/session/{id}/goog/cdp/execute"),
install.to_string().as_bytes(),
);
}
let mut stream = stream;
let reason = if status == 200 { "OK" } else { "Error" };
write!(
stream,
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.ok()?;
stream.write_all(&body).ok()?;
Some(())
}
fn take_regions(upstream: SocketAddr, session: &str, evidence: &Path, token: &str) {
let script = json!({"script": TAKE, "args": []});
let Some((200, body)) = call(
upstream,
"POST",
&format!("/session/{session}/execute/sync"),
script.to_string().as_bytes(),
) else {
return;
};
let Ok(value) = serde_json::from_slice::<Value>(&body) else {
return;
};
for region in value["value"].as_array().into_iter().flatten() {
let Some(bytes) = region
.get(1)
.and_then(Value::as_str)
.and_then(|text| base64::engine::general_purpose::STANDARD.decode(text).ok())
else {
continue;
};
if crate::rust_worker::region_token(&bytes) != Some(token) {
continue;
}
let _ = std::fs::create_dir_all(evidence);
let _ = std::fs::write(
evidence.join(format!(
"{token}-browser-{}-{}.region",
std::process::id(),
REGIONS.fetch_add(1, Ordering::Relaxed)
)),
&bytes,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_runner_s_browser_is_picked_as_it_picks_it() {
let env = |pairs: &[(&str, &str)]| {
pairs
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
.collect::<Vec<_>>()
};
assert_eq!(
browser(&env(&[("CHROMEDRIVER", "/c/chromedriver")])),
Some(Browser::Chrome(
PathBuf::from("/c/chromedriver"),
Vec::new()
))
);
assert_eq!(
browser(&env(&[("CHROMEDRIVER", "/c/cd"), ("GECKODRIVER", "/g/gd")])),
Some(Browser::Other("geckodriver".into())),
"the runner tries geckodriver first"
);
assert_eq!(
browser(&env(&[("CHROMEDRIVER_REMOTE", "http://x/")])),
Some(Browser::Other("a remote chromedriver".into()))
);
}
}