1use std::{
19 io::{BufRead, BufReader, Read, Write},
20 net::{SocketAddr, TcpListener, TcpStream},
21 path::{Path, PathBuf},
22 process::{Child, Command, Stdio},
23 sync::{
24 Arc,
25 atomic::{AtomicBool, AtomicU64, Ordering},
26 },
27 time::{Duration, Instant},
28};
29
30use base64::Engine as _;
31use serde_json::{Value, json};
32
33const REGISTRY: &str = r#"(() => {
36 if (globalThis.__supercovWasm) return;
37 const instances = (globalThis.__supercovWasm = []);
38 const Instance = WebAssembly.Instance;
39 function Recorded(module, imports) {
40 const instance = new Instance(module, imports);
41 instances.push(instance);
42 return instance;
43 }
44 Recorded.prototype = Instance.prototype;
45 WebAssembly.Instance = Recorded;
46 for (const name of ["instantiate", "instantiateStreaming"]) {
47 const original = WebAssembly[name];
48 if (typeof original !== "function") continue;
49 WebAssembly[name] = async function (...args) {
50 const result = await original.apply(WebAssembly, args);
51 instances.push(result instanceof Instance ? result : result.instance);
52 return result;
53 };
54 }
55})();"#;
56
57const TAKE: &str = r#"const out = [];
59for (const instance of globalThis.__supercovWasm ?? []) {
60 const exports = instance.exports;
61 for (const name of Object.keys(exports)) {
62 if (!name.startsWith('__supercov_region_') || !exports.memory) continue;
63 const packed = BigInt.asUintN(64, BigInt(exports[name]()));
64 const at = Number(packed >> 32n), length = Number(packed & 0xffffffffn);
65 if (!length) continue;
66 const bytes = new Uint8Array(exports.memory.buffer, at, length);
67 let text = '';
68 for (let index = 0; index < length; index += 0x8000)
69 text += String.fromCharCode.apply(null, bytes.subarray(index, Math.min(length, index + 0x8000)));
70 out.push([name.slice('__supercov_region_'.length), btoa(text)]);
71 }
72}
73return out;"#;
74
75#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum Browser {
80 Chrome(PathBuf, Vec<String>),
82 Other(String),
84}
85
86const DRIVERS: [&str; 4] = [
87 "geckodriver",
88 "safaridriver",
89 "chromedriver",
90 "msedgedriver",
91];
92
93pub fn browser(environment: &[(String, String)]) -> Option<Browser> {
94 let variable = |name: &str| {
95 environment
96 .iter()
97 .find(|(key, _)| key == name)
98 .map(|(_, value)| value.clone())
99 .or_else(|| std::env::var(name).ok())
100 };
101 let arguments = |driver: &str| {
102 variable(&format!("{}_ARGS", driver.to_uppercase()))
103 .map(|value| value.split_whitespace().map(str::to_owned).collect())
104 .unwrap_or_default()
105 };
106 for driver in DRIVERS {
107 if variable(&format!("{}_REMOTE", driver.to_uppercase())).is_some() {
108 return Some(Browser::Other(format!("a remote {driver}")));
109 }
110 }
111 let pick = |driver: &str, path: PathBuf| {
112 if driver == "chromedriver" {
113 Browser::Chrome(path, arguments(driver))
114 } else {
115 Browser::Other(driver.to_owned())
116 }
117 };
118 for driver in DRIVERS {
119 if let Some(path) = variable(&driver.to_uppercase()) {
120 return Some(pick(driver, PathBuf::from(path)));
121 }
122 }
123 let path = std::env::var_os("PATH")?;
124 for directory in std::env::split_paths(&path) {
125 for driver in DRIVERS {
126 let candidate = directory
127 .join(driver)
128 .with_extension(std::env::consts::EXE_EXTENSION);
129 if candidate.exists() {
130 return Some(pick(driver, candidate));
131 }
132 }
133 }
134 None
135}
136
137#[derive(Debug)]
139pub struct BrowserReader {
140 address: SocketAddr,
141 driver: Child,
142 stop: Arc<AtomicBool>,
143}
144
145impl BrowserReader {
146 pub fn start(chromedriver: &Path, arguments: &[String], token: &str) -> Result<Self, String> {
147 let port = free_port()?;
148 let driver = Command::new(chromedriver)
149 .args(arguments)
150 .arg(format!("--port={port}"))
151 .stdin(Stdio::null())
152 .stdout(Stdio::null())
153 .stderr(Stdio::null())
154 .spawn()
155 .map_err(|error| format!("could not start {}: {error}", chromedriver.display()))?;
156 let upstream = SocketAddr::from(([127, 0, 0, 1], port));
157 let started = Instant::now();
158 while TcpStream::connect_timeout(&upstream, Duration::from_millis(100)).is_err() {
159 if started.elapsed() > Duration::from_secs(20) {
160 return Err(format!("{} never listened", chromedriver.display()));
161 }
162 std::thread::sleep(Duration::from_millis(50));
163 }
164 let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| error.to_string())?;
165 let address = listener.local_addr().map_err(|error| error.to_string())?;
166 listener
167 .set_nonblocking(true)
168 .map_err(|error| error.to_string())?;
169 let stop = Arc::new(AtomicBool::new(false));
170 let flag = Arc::clone(&stop);
171 let token = token.to_owned();
172 std::thread::Builder::new()
173 .name("supercov-browser-proxy".into())
174 .spawn(move || {
175 while !flag.load(Ordering::Acquire) {
176 match listener.accept() {
177 Ok((stream, _)) => {
178 let _ = stream.set_nonblocking(false);
181 let token = token.clone();
182 std::thread::spawn(move || {
183 let _ = relay(stream, upstream, &token);
184 });
185 }
186 Err(_) => std::thread::sleep(Duration::from_millis(10)),
187 }
188 }
189 })
190 .map_err(|error| error.to_string())?;
191 Ok(Self {
192 address,
193 driver,
194 stop,
195 })
196 }
197
198 pub fn remote_url(&self, evidence: &Path) -> String {
201 let hex = evidence
202 .to_string_lossy()
203 .bytes()
204 .map(|byte| format!("{byte:02x}"))
205 .collect::<String>();
206 format!("http://{}/e/{hex}/", self.address)
207 }
208}
209
210impl Drop for BrowserReader {
211 fn drop(&mut self) {
212 self.stop.store(true, Ordering::Release);
213 let _ = self.driver.kill();
214 let _ = self.driver.wait();
215 }
216}
217
218fn free_port() -> Result<u16, String> {
219 let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| error.to_string())?;
220 listener
221 .local_addr()
222 .map(|address| address.port())
223 .map_err(|error| error.to_string())
224}
225
226struct Request {
227 method: String,
228 path: String,
229 body: Vec<u8>,
230}
231
232fn read_request(stream: &mut BufReader<TcpStream>) -> Option<Request> {
233 let mut line = String::new();
234 stream.read_line(&mut line).ok()?;
235 let mut parts = line.split_whitespace();
236 let method = parts.next()?.to_owned();
237 let path = parts.next()?.to_owned();
238 let mut length = 0;
239 let mut chunked = false;
240 loop {
241 let mut header = String::new();
242 stream.read_line(&mut header).ok()?;
243 let header = header.trim_end();
244 if header.is_empty() {
245 break;
246 }
247 if let Some((name, value)) = header.split_once(':') {
248 if name.eq_ignore_ascii_case("content-length") {
249 length = value.trim().parse().ok()?;
250 } else if name.eq_ignore_ascii_case("transfer-encoding")
251 && value.to_ascii_lowercase().contains("chunked")
252 {
253 chunked = true;
254 }
255 }
256 }
257 let mut body = Vec::new();
258 if chunked {
259 loop {
260 let mut size = String::new();
261 stream.read_line(&mut size).ok()?;
262 let size = usize::from_str_radix(size.trim(), 16).ok()?;
263 let mut chunk = vec![0; size + 2];
264 stream.read_exact(&mut chunk).ok()?;
265 if size == 0 {
266 break;
267 }
268 body.extend_from_slice(&chunk[..size]);
269 }
270 } else {
271 body.resize(length, 0);
272 stream.read_exact(&mut body).ok()?;
273 }
274 Some(Request { method, path, body })
275}
276
277fn call(upstream: SocketAddr, method: &str, path: &str, body: &[u8]) -> Option<(u16, Vec<u8>)> {
279 let mut stream = TcpStream::connect(upstream).ok()?;
280 stream
281 .set_read_timeout(Some(Duration::from_secs(120)))
282 .ok()?;
283 write!(
284 stream,
285 "{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",
286 body.len()
287 )
288 .ok()?;
289 stream.write_all(body).ok()?;
290 let mut reader = BufReader::new(stream);
293 let mut line = String::new();
294 reader.read_line(&mut line).ok()?;
295 let status = line.split_whitespace().nth(1)?.parse().ok()?;
296 let mut length = None;
297 let mut chunked = false;
298 loop {
299 let mut header = String::new();
300 reader.read_line(&mut header).ok()?;
301 let header = header.trim_end();
302 if header.is_empty() {
303 break;
304 }
305 if let Some((name, value)) = header.split_once(':') {
306 if name.eq_ignore_ascii_case("content-length") {
307 length = value.trim().parse::<usize>().ok();
308 } else if name.eq_ignore_ascii_case("transfer-encoding")
309 && value.to_ascii_lowercase().contains("chunked")
310 {
311 chunked = true;
312 }
313 }
314 }
315 let mut payload = Vec::new();
316 if chunked {
317 loop {
318 let mut size = String::new();
319 reader.read_line(&mut size).ok()?;
320 let size = usize::from_str_radix(size.trim(), 16).ok()?;
321 let mut chunk = vec![0; size + 2];
322 reader.read_exact(&mut chunk).ok()?;
323 if size == 0 {
324 break;
325 }
326 payload.extend_from_slice(&chunk[..size]);
327 }
328 } else if let Some(length) = length {
329 payload.resize(length, 0);
330 reader.read_exact(&mut payload).ok()?;
331 } else {
332 reader.read_to_end(&mut payload).ok()?;
333 }
334 Some((status, payload))
335}
336
337static REGIONS: AtomicU64 = AtomicU64::new(0);
338
339fn relay(stream: TcpStream, upstream: SocketAddr, token: &str) -> Option<()> {
340 let mut reader = BufReader::new(stream.try_clone().ok()?);
341 let request = read_request(&mut reader)?;
342 let rest = request.path.strip_prefix("/e/")?;
344 let (hex, webdriver) = rest.split_once('/').unwrap_or((rest, ""));
345 let evidence = PathBuf::from(
346 String::from_utf8(
347 (0..hex.len())
348 .step_by(2)
349 .filter_map(|at| u8::from_str_radix(hex.get(at..at + 2)?, 16).ok())
350 .collect(),
351 )
352 .ok()?,
353 );
354 let webdriver = format!("/{webdriver}");
355 let session = webdriver
358 .strip_prefix("/session/")
359 .map(|rest| rest.strip_suffix("/window").unwrap_or(rest))
360 .filter(|id| !id.contains('/'))
361 .map(str::to_owned);
362 if request.method == "DELETE"
363 && let Some(id) = &session
364 {
365 take_regions(upstream, id, &evidence, token);
366 }
367 let (status, body) = call(upstream, &request.method, &webdriver, &request.body)?;
368 if request.method == "POST"
369 && webdriver == "/session"
370 && status == 200
371 && let Ok(created) = serde_json::from_slice::<Value>(&body)
372 && let Some(id) = created["value"]["sessionId"]
375 .as_str()
376 .or_else(|| created["sessionId"].as_str())
377 {
378 let install = json!({
379 "cmd": "Page.addScriptToEvaluateOnNewDocument",
380 "params": {"source": REGISTRY},
381 });
382 let _ = call(
383 upstream,
384 "POST",
385 &format!("/session/{id}/goog/cdp/execute"),
386 install.to_string().as_bytes(),
387 );
388 }
389 let mut stream = stream;
390 let reason = if status == 200 { "OK" } else { "Error" };
391 write!(
392 stream,
393 "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
394 body.len()
395 )
396 .ok()?;
397 stream.write_all(&body).ok()?;
398 Some(())
399}
400
401fn take_regions(upstream: SocketAddr, session: &str, evidence: &Path, token: &str) {
404 let script = json!({"script": TAKE, "args": []});
405 let Some((200, body)) = call(
406 upstream,
407 "POST",
408 &format!("/session/{session}/execute/sync"),
409 script.to_string().as_bytes(),
410 ) else {
411 return;
412 };
413 let Ok(value) = serde_json::from_slice::<Value>(&body) else {
414 return;
415 };
416 for region in value["value"].as_array().into_iter().flatten() {
417 let Some(bytes) = region
418 .get(1)
419 .and_then(Value::as_str)
420 .and_then(|text| base64::engine::general_purpose::STANDARD.decode(text).ok())
421 else {
422 continue;
423 };
424 if crate::rust_worker::region_token(&bytes) != Some(token) {
425 continue;
426 }
427 let _ = std::fs::create_dir_all(evidence);
428 let _ = std::fs::write(
429 evidence.join(format!(
430 "{token}-browser-{}-{}.region",
431 std::process::id(),
432 REGIONS.fetch_add(1, Ordering::Relaxed)
433 )),
434 &bytes,
435 );
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn the_runner_s_browser_is_picked_as_it_picks_it() {
445 let env = |pairs: &[(&str, &str)]| {
446 pairs
447 .iter()
448 .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
449 .collect::<Vec<_>>()
450 };
451 assert_eq!(
452 browser(&env(&[("CHROMEDRIVER", "/c/chromedriver")])),
453 Some(Browser::Chrome(
454 PathBuf::from("/c/chromedriver"),
455 Vec::new()
456 ))
457 );
458 assert_eq!(
459 browser(&env(&[("CHROMEDRIVER", "/c/cd"), ("GECKODRIVER", "/g/gd")])),
460 Some(Browser::Other("geckodriver".into())),
461 "the runner tries geckodriver first"
462 );
463 assert_eq!(
464 browser(&env(&[("CHROMEDRIVER_REMOTE", "http://x/")])),
465 Some(Browser::Other("a remote chromedriver".into()))
466 );
467 }
468}