1use std::fs::{self, OpenOptions};
18use std::io::{self, Read, Write};
19use std::net::{SocketAddr, TcpListener, TcpStream};
20use std::path::PathBuf;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender};
23use std::sync::{Arc, OnceLock};
24use std::thread::{self, JoinHandle};
25use std::time::Duration;
26
27use serde::Serialize;
28use serde_json::{Value, json};
29
30const PROTOCOL_VERSION: u32 = 1;
31const MAX_REQUEST_BYTES: usize = 1024 * 1024;
32const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
33
34#[derive(Debug, Clone)]
36pub struct ServerConfig {
37 pub bind_address: SocketAddr,
39 pub descriptor_path: PathBuf,
41 pub renderer_revision: String,
43}
44
45#[derive(Clone, Default)]
58pub struct ServiceWaker(Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>);
59
60impl ServiceWaker {
61 pub fn set(&self, wake: impl Fn() + Send + Sync + 'static) {
63 let _ = self.0.set(Box::new(wake));
64 }
65
66 fn wake(&self) {
67 if let Some(wake) = self.0.get() {
68 wake();
69 }
70 }
71}
72
73impl std::fmt::Debug for ServiceWaker {
74 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 formatter
76 .debug_struct("ServiceWaker")
77 .field("installed", &self.0.get().is_some())
78 .finish()
79 }
80}
81
82#[derive(Debug)]
84pub struct ControlRequest {
85 pub method: String,
86 pub path: String,
87 pub body: Value,
88 reply: SyncSender<ControlResponse>,
89}
90
91impl ControlRequest {
92 pub fn respond(self, response: ControlResponse) -> Result<(), ControlResponse> {
94 self.reply.send(response).map_err(|error| error.0)
95 }
96}
97
98#[derive(Debug)]
100pub enum ControlResponse {
101 Success(Value),
102 Error {
103 error: String,
104 message: String,
105 stacktrace: String,
106 },
107}
108
109impl ControlResponse {
110 pub fn unsupported(message: impl Into<String>) -> Self {
111 Self::Error {
112 error: "unsupported operation".into(),
113 message: message.into(),
114 stacktrace: String::new(),
115 }
116 }
117}
118
119#[derive(Debug, Serialize)]
120#[serde(rename_all = "camelCase")]
121struct Descriptor<'a> {
122 pid: u32,
123 address: String,
124 token: &'a str,
125 protocol_version: u32,
126 renderer: &'static str,
127 renderer_revision: &'a str,
128}
129
130pub struct DebugServer {
132 address: SocketAddr,
133 token: String,
134 descriptor_path: PathBuf,
135 shutdown: Arc<AtomicBool>,
136 waker: ServiceWaker,
137 thread: Option<JoinHandle<()>>,
138}
139
140impl DebugServer {
141 pub fn start(config: ServerConfig) -> io::Result<(Self, Receiver<ControlRequest>)> {
143 if !config.bind_address.ip().is_loopback() {
144 return Err(io::Error::new(
145 io::ErrorKind::InvalidInput,
146 "debug control must bind to a loopback address",
147 ));
148 }
149 let listener = TcpListener::bind(config.bind_address)?;
150 let address = listener.local_addr()?;
151 let token = random_hex(32)?;
152 let (command_tx, command_rx) = mpsc::channel();
157 let shutdown = Arc::new(AtomicBool::new(false));
158 let waker = ServiceWaker::default();
159
160 let thread_shutdown = Arc::clone(&shutdown);
161 let thread_token = token.clone();
162 let thread_waker = waker.clone();
163 let thread = thread::Builder::new()
164 .name("blitz-debug-control".into())
165 .spawn(move || {
166 server_loop(
167 listener,
168 &thread_token,
169 command_tx,
170 &thread_waker,
171 thread_shutdown,
172 )
173 })?;
174
175 if let Err(error) = write_descriptor(&config, address, &token) {
176 shutdown.store(true, Ordering::Release);
177 let _ = TcpStream::connect(address);
178 let _ = thread.join();
179 return Err(error);
180 }
181
182 Ok((
183 Self {
184 address,
185 token,
186 descriptor_path: config.descriptor_path,
187 shutdown,
188 waker,
189 thread: Some(thread),
190 },
191 command_rx,
192 ))
193 }
194
195 pub fn waker(&self) -> ServiceWaker {
198 self.waker.clone()
199 }
200
201 pub fn address(&self) -> SocketAddr {
202 self.address
203 }
204
205 pub fn token(&self) -> &str {
206 &self.token
207 }
208
209 pub fn shutdown(mut self) {
210 self.stop();
211 }
212
213 fn stop(&mut self) {
214 self.shutdown.store(true, Ordering::Release);
215 let _ = TcpStream::connect(self.address);
216 if let Some(thread) = self.thread.take() {
217 let _ = thread.join();
218 }
219 let _ = fs::remove_file(&self.descriptor_path);
220 }
221}
222
223impl Drop for DebugServer {
224 fn drop(&mut self) {
225 self.stop();
226 }
227}
228
229fn random_hex(byte_len: usize) -> io::Result<String> {
230 let mut bytes = vec![0; byte_len];
231 getrandom::fill(&mut bytes).map_err(io::Error::other)?;
232 let mut output = String::with_capacity(byte_len * 2);
233 for byte in bytes {
234 use std::fmt::Write as _;
235 write!(output, "{byte:02x}").unwrap();
236 }
237 Ok(output)
238}
239
240fn write_descriptor(config: &ServerConfig, address: SocketAddr, token: &str) -> io::Result<()> {
241 let descriptor = Descriptor {
242 pid: std::process::id(),
243 address: address.to_string(),
244 token,
245 protocol_version: PROTOCOL_VERSION,
246 renderer: "blitz",
247 renderer_revision: &config.renderer_revision,
248 };
249 let bytes = serde_json::to_vec_pretty(&descriptor).map_err(io::Error::other)?;
250 if let Some(parent) = config.descriptor_path.parent() {
251 fs::create_dir_all(parent)?;
252 }
253 let temporary = config
254 .descriptor_path
255 .with_extension(format!("tmp-{}", random_hex(8)?));
256 let result = (|| {
257 let mut options = OpenOptions::new();
258 options.write(true).create_new(true);
259 #[cfg(unix)]
260 {
261 use std::os::unix::fs::OpenOptionsExt;
262 options.mode(0o600);
263 }
264 let mut file = options.open(&temporary)?;
265 file.write_all(&bytes)?;
266 file.sync_all()?;
267 fs::rename(&temporary, &config.descriptor_path)
268 })();
269 if result.is_err() {
270 let _ = fs::remove_file(&temporary);
271 }
272 result
273}
274
275fn server_loop(
276 listener: TcpListener,
277 token: &str,
278 command_tx: Sender<ControlRequest>,
279 waker: &ServiceWaker,
280 shutdown: Arc<AtomicBool>,
281) {
282 let mut active_session: Option<String> = None;
283 for connection in listener.incoming() {
284 if shutdown.load(Ordering::Acquire) {
285 break;
286 }
287 match connection {
288 Ok(mut stream) => {
289 let _ = stream.set_read_timeout(Some(COMMAND_TIMEOUT));
290 let _ = stream.set_write_timeout(Some(COMMAND_TIMEOUT));
291 let response = match read_request(&mut stream) {
292 Ok(request) => route(request, token, &mut active_session, &command_tx, waker),
293 Err(error) => webdriver_error("invalid argument", error.to_string()),
294 };
295 let _ = write_response(&mut stream, response);
296 }
297 Err(_) if shutdown.load(Ordering::Acquire) => break,
298 Err(_) => continue,
299 }
300 }
301}
302
303#[derive(Debug)]
304struct HttpRequest {
305 method: String,
306 path: String,
307 body: Value,
308}
309
310fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
311 let mut bytes = Vec::with_capacity(4096);
312 let header_end = loop {
313 if bytes.len() >= MAX_REQUEST_BYTES {
314 return Err(io::Error::new(
315 io::ErrorKind::InvalidData,
316 "request is too large",
317 ));
318 }
319 let mut chunk = [0; 4096];
320 let count = stream.read(&mut chunk)?;
321 if count == 0 {
322 return Err(io::Error::new(
323 io::ErrorKind::UnexpectedEof,
324 "connection closed before headers",
325 ));
326 }
327 bytes.extend_from_slice(&chunk[..count]);
328 if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
329 break index + 4;
330 }
331 };
332
333 let headers = std::str::from_utf8(&bytes[..header_end])
334 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
335 let mut lines = headers.split("\r\n");
336 let mut request_line = lines.next().unwrap_or_default().split_whitespace();
337 let method = request_line.next().unwrap_or_default().to_string();
338 let path = request_line.next().unwrap_or_default().to_string();
339 if method.is_empty() || path.is_empty() {
340 return Err(io::Error::new(
341 io::ErrorKind::InvalidData,
342 "invalid request line",
343 ));
344 }
345 let content_length = lines
346 .filter_map(|line| line.split_once(':'))
347 .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
348 .map(|(_, value)| value.trim().parse::<usize>())
349 .transpose()
350 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
351 .unwrap_or(0);
352 if header_end + content_length > MAX_REQUEST_BYTES {
353 return Err(io::Error::new(
354 io::ErrorKind::InvalidData,
355 "request is too large",
356 ));
357 }
358 while bytes.len() < header_end + content_length {
359 let remaining = header_end + content_length - bytes.len();
360 let mut chunk = vec![0; remaining.min(4096)];
361 let count = stream.read(&mut chunk)?;
362 if count == 0 {
363 return Err(io::Error::new(
364 io::ErrorKind::UnexpectedEof,
365 "connection closed before body",
366 ));
367 }
368 bytes.extend_from_slice(&chunk[..count]);
369 }
370 let body = if content_length == 0 {
371 Value::Null
372 } else {
373 serde_json::from_slice(&bytes[header_end..header_end + content_length])
374 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
375 };
376 Ok(HttpRequest { method, path, body })
377}
378
379fn route(
380 request: HttpRequest,
381 token: &str,
382 active_session: &mut Option<String>,
383 command_tx: &Sender<ControlRequest>,
384 waker: &ServiceWaker,
385) -> Value {
386 if request.method == "GET" && request.path == "/status" {
387 return json!({"value": {
388 "ready": true,
389 "message": "Blitz debug control is ready",
390 "protocolVersion": PROTOCOL_VERSION,
391 }});
392 }
393
394 if request.method == "POST" && request.path == "/session" {
395 if active_session.is_some() {
396 return webdriver_error("session not created", "only one session is supported");
397 }
398 let supplied_token = request
399 .body
400 .pointer("/capabilities/alwaysMatch/blitz:token")
401 .and_then(Value::as_str);
402 if !token_matches(supplied_token, token) {
403 return webdriver_error("invalid argument", "invalid blitz:token capability");
404 }
405 let session_id = match random_hex(16) {
406 Ok(value) => value,
407 Err(error) => return webdriver_error("unknown error", error.to_string()),
408 };
409 *active_session = Some(session_id.clone());
410 return json!({"value": {
411 "sessionId": session_id,
412 "capabilities": {
413 "browserName": "blitz",
414 "blitz:protocolVersion": PROTOCOL_VERSION,
415 }
416 }});
417 }
418
419 let Some((session_id, command_path)) = session_path(&request.path) else {
420 return webdriver_error("unknown command", "unknown debug-control route");
421 };
422 if active_session.as_deref() != Some(session_id) {
423 return webdriver_error("invalid session id", "session is not active");
424 }
425 if request.method == "DELETE" && command_path.is_empty() {
426 *active_session = None;
427 return json!({"value": null});
428 }
429
430 let (reply_tx, reply_rx) = mpsc::sync_channel(1);
431 let control_request = ControlRequest {
432 method: request.method,
433 path: command_path.to_string(),
434 body: request.body,
435 reply: reply_tx,
436 };
437 if command_tx.send(control_request).is_err() {
438 return webdriver_error("unknown error", "renderer command channel is closed");
439 }
440 waker.wake();
443 match reply_rx.recv_timeout(COMMAND_TIMEOUT) {
444 Ok(ControlResponse::Success(value)) => json!({"value": value}),
445 Ok(ControlResponse::Error {
446 error,
447 message,
448 stacktrace,
449 }) => json!({"value": {
450 "error": error,
451 "message": message,
452 "stacktrace": stacktrace,
453 }}),
454 Err(RecvTimeoutError::Timeout) => webdriver_error("timeout", "renderer command timed out"),
455 Err(RecvTimeoutError::Disconnected) => {
456 webdriver_error("unknown error", "renderer response channel is closed")
457 }
458 }
459}
460
461fn token_matches(supplied: Option<&str>, expected: &str) -> bool {
464 let supplied = supplied.unwrap_or_default().as_bytes();
465 let expected = expected.as_bytes();
466 let mut difference = supplied.len() ^ expected.len();
467 for (index, expected_byte) in expected.iter().enumerate() {
468 difference |= usize::from(*expected_byte ^ supplied.get(index).copied().unwrap_or(0));
469 }
470 difference == 0
471}
472
473fn session_path(path: &str) -> Option<(&str, &str)> {
474 let remainder = path.strip_prefix("/session/")?;
475 let (session_id, command) = remainder.split_once('/').unwrap_or((remainder, ""));
476 Some((session_id, command))
477}
478
479fn webdriver_error(error: &str, message: impl Into<String>) -> Value {
480 json!({"value": {
481 "error": error,
482 "message": message.into(),
483 "stacktrace": "",
484 }})
485}
486
487fn write_response(stream: &mut TcpStream, body: Value) -> io::Result<()> {
488 let bytes = serde_json::to_vec(&body).map_err(io::Error::other)?;
489 write!(
490 stream,
491 "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
492 bytes.len()
493 )?;
494 stream.write_all(&bytes)
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use std::sync::Mutex;
501 use std::time::{SystemTime, UNIX_EPOCH};
502
503 #[test]
504 fn token_comparison_rejects_prefixes_suffixes_and_missing_values() {
505 assert!(token_matches(Some("012345"), "012345"));
506 assert!(!token_matches(Some("01234x"), "012345"));
507 assert!(!token_matches(Some("0123456"), "012345"));
508 assert!(!token_matches(Some("01234"), "012345"));
509 assert!(!token_matches(None, "012345"));
510 }
511
512 fn descriptor_path() -> PathBuf {
513 let nonce = SystemTime::now()
514 .duration_since(UNIX_EPOCH)
515 .unwrap()
516 .as_nanos();
517 std::env::temp_dir().join(format!("blitz-debug-{nonce}.json"))
518 }
519
520 fn request(address: SocketAddr, method: &str, path: &str, body: Value) -> Value {
521 let body = if body.is_null() {
522 Vec::new()
523 } else {
524 serde_json::to_vec(&body).unwrap()
525 };
526 let mut stream = TcpStream::connect(address).unwrap();
527 write!(
528 stream,
529 "{method} {path} HTTP/1.1\r\nHost: {address}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
530 body.len()
531 )
532 .unwrap();
533 stream.write_all(&body).unwrap();
534 let mut response = Vec::new();
535 stream.read_to_end(&mut response).unwrap();
536 let body_start = response
537 .windows(4)
538 .position(|window| window == b"\r\n\r\n")
539 .unwrap()
540 + 4;
541 serde_json::from_slice(&response[body_start..]).unwrap()
542 }
543
544 fn create_session(address: SocketAddr, token: &str) -> String {
545 request(
546 address,
547 "POST",
548 "/session",
549 json!({"capabilities": {"alwaysMatch": {"blitz:token": token}}}),
550 )["value"]["sessionId"]
551 .as_str()
552 .unwrap()
553 .to_string()
554 }
555
556 #[test]
557 fn status_auth_session_command_and_reconnect() {
558 let descriptor = descriptor_path();
559 let (server, commands) = DebugServer::start(ServerConfig {
560 bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(),
561 descriptor_path: descriptor.clone(),
562 renderer_revision: "test-revision".into(),
563 })
564 .unwrap();
565
566 let status = request(server.address(), "GET", "/status", Value::Null);
567 assert_eq!(status["value"]["ready"], true);
568 assert!(descriptor.exists());
569 #[cfg(unix)]
570 {
571 use std::os::unix::fs::PermissionsExt;
572 assert_eq!(
573 fs::metadata(&descriptor).unwrap().permissions().mode() & 0o777,
574 0o600
575 );
576 }
577
578 let rejected = request(
579 server.address(),
580 "POST",
581 "/session",
582 json!({"capabilities": {"alwaysMatch": {"blitz:token": "wrong"}}}),
583 );
584 assert_eq!(rejected["value"]["error"], "invalid argument");
585
586 let session = create_session(server.address(), server.token());
587 let address = server.address();
588 let command_path = format!("/session/{session}/blitz/getDomSnapshot");
589 let client = thread::spawn(move || request(address, "GET", &command_path, Value::Null));
590 let command = commands.recv_timeout(COMMAND_TIMEOUT).unwrap();
591 assert_eq!(command.method, "GET");
592 assert_eq!(command.path, "blitz/getDomSnapshot");
593 command
594 .respond(ControlResponse::Success(json!({"documentRevision": 7})))
595 .unwrap();
596 let response = client.join().unwrap();
597 assert_eq!(response["value"]["documentRevision"], 7);
598
599 let deleted = request(
600 server.address(),
601 "DELETE",
602 &format!("/session/{session}"),
603 Value::Null,
604 );
605 assert!(deleted["value"].is_null());
606 let second_session = create_session(server.address(), server.token());
607 assert_ne!(second_session, session);
608
609 server.shutdown();
610 assert!(!descriptor.exists());
611 }
612
613 #[test]
614 fn rejects_non_loopback_bind_address() {
615 let result = DebugServer::start(ServerConfig {
616 bind_address: ([0, 0, 0, 0], 0).into(),
617 descriptor_path: descriptor_path(),
618 renderer_revision: "test-revision".into(),
619 });
620 assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::InvalidInput));
621 }
622
623 fn snapshot_request() -> HttpRequest {
624 HttpRequest {
625 method: "GET".into(),
626 path: "/session/test-session/blitz/getDomSnapshot".into(),
627 body: Value::Null,
628 }
629 }
630
631 #[test]
639 fn wakes_the_servicer_with_the_request_already_queued() {
640 let (command_tx, command_rx) = mpsc::channel::<ControlRequest>();
641 let waker = ServiceWaker::default();
642 let queue = Mutex::new(command_rx);
643 waker.set(move || {
644 let queued = queue.lock().unwrap().try_recv().unwrap();
645 assert_eq!(queued.path, "blitz/getDomSnapshot");
646 queued
647 .respond(ControlResponse::Success(json!({"serviced": true})))
648 .unwrap();
649 });
650
651 let mut active_session = Some("test-session".to_string());
652 let response = route(
653 snapshot_request(),
654 "token",
655 &mut active_session,
656 &command_tx,
657 &waker,
658 );
659
660 assert_eq!(response["value"]["serviced"], true);
661 }
662
663 #[test]
669 fn a_second_request_queues_behind_an_unserviced_one() {
670 let (command_tx, command_rx) = mpsc::channel::<ControlRequest>();
671 let (reply_tx, _reply_rx) = mpsc::sync_channel(1);
672 command_tx
673 .send(ControlRequest {
674 method: "GET".into(),
675 path: "occupied".into(),
676 body: Value::Null,
677 reply: reply_tx,
678 })
679 .unwrap();
680
681 let waker = ServiceWaker::default();
682 let queue = Mutex::new(command_rx);
683 waker.set(move || {
684 let queue = queue.lock().unwrap();
685 assert_eq!(queue.try_recv().unwrap().path, "occupied");
686 queue
687 .try_recv()
688 .unwrap()
689 .respond(ControlResponse::Success(json!({"serviced": true})))
690 .unwrap();
691 });
692
693 let mut active_session = Some("test-session".to_string());
694 let response = route(
695 snapshot_request(),
696 "token",
697 &mut active_session,
698 &command_tx,
699 &waker,
700 );
701
702 assert_eq!(response["value"]["serviced"], true);
703 }
704
705 #[test]
708 fn queues_requests_while_no_waker_is_installed() {
709 let (command_tx, command_rx) = mpsc::channel::<ControlRequest>();
710 let waker = ServiceWaker::default();
711 let mut active_session = Some("test-session".to_string());
712
713 let sender = command_tx.clone();
714 let servicer = thread::spawn(move || {
715 let queued = command_rx.recv_timeout(COMMAND_TIMEOUT).unwrap();
716 drop(sender);
717 queued
718 .respond(ControlResponse::Success(json!({"serviced": true})))
719 .unwrap();
720 });
721
722 let response = route(
723 snapshot_request(),
724 "token",
725 &mut active_session,
726 &command_tx,
727 &waker,
728 );
729
730 servicer.join().unwrap();
731 assert_eq!(response["value"]["serviced"], true);
732 }
733}