1use std::fs::{self, OpenOptions};
7use std::io::{self, Read, Write};
8use std::net::{SocketAddr, TcpListener, TcpStream};
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
13use std::thread::{self, JoinHandle};
14use std::time::Duration;
15
16use serde::Serialize;
17use serde_json::{Value, json};
18
19const PROTOCOL_VERSION: u32 = 1;
20const MAX_REQUEST_BYTES: usize = 1024 * 1024;
21const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
22
23#[derive(Debug, Clone)]
25pub struct ServerConfig {
26 pub bind_address: SocketAddr,
28 pub descriptor_path: PathBuf,
30 pub renderer_revision: String,
32}
33
34#[derive(Debug)]
36pub struct ControlRequest {
37 pub method: String,
38 pub path: String,
39 pub body: Value,
40 reply: SyncSender<ControlResponse>,
41}
42
43impl ControlRequest {
44 pub fn respond(self, response: ControlResponse) -> Result<(), ControlResponse> {
46 self.reply.send(response).map_err(|error| error.0)
47 }
48}
49
50#[derive(Debug)]
52pub enum ControlResponse {
53 Success(Value),
54 Error {
55 error: String,
56 message: String,
57 stacktrace: String,
58 },
59}
60
61impl ControlResponse {
62 pub fn unsupported(message: impl Into<String>) -> Self {
63 Self::Error {
64 error: "unsupported operation".into(),
65 message: message.into(),
66 stacktrace: String::new(),
67 }
68 }
69}
70
71#[derive(Debug, Serialize)]
72#[serde(rename_all = "camelCase")]
73struct Descriptor<'a> {
74 pid: u32,
75 address: String,
76 token: &'a str,
77 protocol_version: u32,
78 renderer: &'static str,
79 renderer_revision: &'a str,
80}
81
82pub struct DebugServer {
84 address: SocketAddr,
85 token: String,
86 descriptor_path: PathBuf,
87 shutdown: Arc<AtomicBool>,
88 thread: Option<JoinHandle<()>>,
89}
90
91impl DebugServer {
92 pub fn start(config: ServerConfig) -> io::Result<(Self, Receiver<ControlRequest>)> {
94 if !config.bind_address.ip().is_loopback() {
95 return Err(io::Error::new(
96 io::ErrorKind::InvalidInput,
97 "debug control must bind to a loopback address",
98 ));
99 }
100 let listener = TcpListener::bind(config.bind_address)?;
101 let address = listener.local_addr()?;
102 let token = random_hex(32)?;
103 let (command_tx, command_rx) = mpsc::sync_channel(1);
104 let shutdown = Arc::new(AtomicBool::new(false));
105
106 let thread_shutdown = Arc::clone(&shutdown);
107 let thread_token = token.clone();
108 let thread = thread::Builder::new()
109 .name("blitz-debug-control".into())
110 .spawn(move || server_loop(listener, &thread_token, command_tx, thread_shutdown))?;
111
112 if let Err(error) = write_descriptor(&config, address, &token) {
113 shutdown.store(true, Ordering::Release);
114 let _ = TcpStream::connect(address);
115 let _ = thread.join();
116 return Err(error);
117 }
118
119 Ok((
120 Self {
121 address,
122 token,
123 descriptor_path: config.descriptor_path,
124 shutdown,
125 thread: Some(thread),
126 },
127 command_rx,
128 ))
129 }
130
131 pub fn address(&self) -> SocketAddr {
132 self.address
133 }
134
135 pub fn token(&self) -> &str {
136 &self.token
137 }
138
139 pub fn shutdown(mut self) {
140 self.stop();
141 }
142
143 fn stop(&mut self) {
144 self.shutdown.store(true, Ordering::Release);
145 let _ = TcpStream::connect(self.address);
146 if let Some(thread) = self.thread.take() {
147 let _ = thread.join();
148 }
149 let _ = fs::remove_file(&self.descriptor_path);
150 }
151}
152
153impl Drop for DebugServer {
154 fn drop(&mut self) {
155 self.stop();
156 }
157}
158
159fn random_hex(byte_len: usize) -> io::Result<String> {
160 let mut bytes = vec![0; byte_len];
161 getrandom::fill(&mut bytes).map_err(io::Error::other)?;
162 let mut output = String::with_capacity(byte_len * 2);
163 for byte in bytes {
164 use std::fmt::Write as _;
165 write!(output, "{byte:02x}").unwrap();
166 }
167 Ok(output)
168}
169
170fn write_descriptor(config: &ServerConfig, address: SocketAddr, token: &str) -> io::Result<()> {
171 let descriptor = Descriptor {
172 pid: std::process::id(),
173 address: address.to_string(),
174 token,
175 protocol_version: PROTOCOL_VERSION,
176 renderer: "blitz",
177 renderer_revision: &config.renderer_revision,
178 };
179 let bytes = serde_json::to_vec_pretty(&descriptor).map_err(io::Error::other)?;
180 if let Some(parent) = config.descriptor_path.parent() {
181 fs::create_dir_all(parent)?;
182 }
183 let temporary = config
184 .descriptor_path
185 .with_extension(format!("tmp-{}", random_hex(8)?));
186 let result = (|| {
187 let mut options = OpenOptions::new();
188 options.write(true).create_new(true);
189 #[cfg(unix)]
190 {
191 use std::os::unix::fs::OpenOptionsExt;
192 options.mode(0o600);
193 }
194 let mut file = options.open(&temporary)?;
195 file.write_all(&bytes)?;
196 file.sync_all()?;
197 fs::rename(&temporary, &config.descriptor_path)
198 })();
199 if result.is_err() {
200 let _ = fs::remove_file(&temporary);
201 }
202 result
203}
204
205fn server_loop(
206 listener: TcpListener,
207 token: &str,
208 command_tx: SyncSender<ControlRequest>,
209 shutdown: Arc<AtomicBool>,
210) {
211 let mut active_session: Option<String> = None;
212 for connection in listener.incoming() {
213 if shutdown.load(Ordering::Acquire) {
214 break;
215 }
216 match connection {
217 Ok(mut stream) => {
218 let _ = stream.set_read_timeout(Some(COMMAND_TIMEOUT));
219 let _ = stream.set_write_timeout(Some(COMMAND_TIMEOUT));
220 let response = match read_request(&mut stream) {
221 Ok(request) => route(request, token, &mut active_session, &command_tx),
222 Err(error) => webdriver_error("invalid argument", error.to_string()),
223 };
224 let _ = write_response(&mut stream, response);
225 }
226 Err(_) if shutdown.load(Ordering::Acquire) => break,
227 Err(_) => continue,
228 }
229 }
230}
231
232#[derive(Debug)]
233struct HttpRequest {
234 method: String,
235 path: String,
236 body: Value,
237}
238
239fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
240 let mut bytes = Vec::with_capacity(4096);
241 let header_end = loop {
242 if bytes.len() >= MAX_REQUEST_BYTES {
243 return Err(io::Error::new(
244 io::ErrorKind::InvalidData,
245 "request is too large",
246 ));
247 }
248 let mut chunk = [0; 4096];
249 let count = stream.read(&mut chunk)?;
250 if count == 0 {
251 return Err(io::Error::new(
252 io::ErrorKind::UnexpectedEof,
253 "connection closed before headers",
254 ));
255 }
256 bytes.extend_from_slice(&chunk[..count]);
257 if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
258 break index + 4;
259 }
260 };
261
262 let headers = std::str::from_utf8(&bytes[..header_end])
263 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
264 let mut lines = headers.split("\r\n");
265 let mut request_line = lines.next().unwrap_or_default().split_whitespace();
266 let method = request_line.next().unwrap_or_default().to_string();
267 let path = request_line.next().unwrap_or_default().to_string();
268 if method.is_empty() || path.is_empty() {
269 return Err(io::Error::new(
270 io::ErrorKind::InvalidData,
271 "invalid request line",
272 ));
273 }
274 let content_length = lines
275 .filter_map(|line| line.split_once(':'))
276 .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
277 .map(|(_, value)| value.trim().parse::<usize>())
278 .transpose()
279 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
280 .unwrap_or(0);
281 if header_end + content_length > MAX_REQUEST_BYTES {
282 return Err(io::Error::new(
283 io::ErrorKind::InvalidData,
284 "request is too large",
285 ));
286 }
287 while bytes.len() < header_end + content_length {
288 let remaining = header_end + content_length - bytes.len();
289 let mut chunk = vec![0; remaining.min(4096)];
290 let count = stream.read(&mut chunk)?;
291 if count == 0 {
292 return Err(io::Error::new(
293 io::ErrorKind::UnexpectedEof,
294 "connection closed before body",
295 ));
296 }
297 bytes.extend_from_slice(&chunk[..count]);
298 }
299 let body = if content_length == 0 {
300 Value::Null
301 } else {
302 serde_json::from_slice(&bytes[header_end..header_end + content_length])
303 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
304 };
305 Ok(HttpRequest { method, path, body })
306}
307
308fn route(
309 request: HttpRequest,
310 token: &str,
311 active_session: &mut Option<String>,
312 command_tx: &SyncSender<ControlRequest>,
313) -> Value {
314 if request.method == "GET" && request.path == "/status" {
315 return json!({"value": {
316 "ready": true,
317 "message": "Blitz debug control is ready",
318 "protocolVersion": PROTOCOL_VERSION,
319 }});
320 }
321
322 if request.method == "POST" && request.path == "/session" {
323 if active_session.is_some() {
324 return webdriver_error("session not created", "only one session is supported");
325 }
326 let supplied_token = request
327 .body
328 .pointer("/capabilities/alwaysMatch/blitz:token")
329 .and_then(Value::as_str);
330 if supplied_token != Some(token) {
331 return webdriver_error("invalid argument", "invalid blitz:token capability");
332 }
333 let session_id = match random_hex(16) {
334 Ok(value) => value,
335 Err(error) => return webdriver_error("unknown error", error.to_string()),
336 };
337 *active_session = Some(session_id.clone());
338 return json!({"value": {
339 "sessionId": session_id,
340 "capabilities": {
341 "browserName": "blitz",
342 "blitz:protocolVersion": PROTOCOL_VERSION,
343 }
344 }});
345 }
346
347 let Some((session_id, command_path)) = session_path(&request.path) else {
348 return webdriver_error("unknown command", "unknown debug-control route");
349 };
350 if active_session.as_deref() != Some(session_id) {
351 return webdriver_error("invalid session id", "session is not active");
352 }
353 if request.method == "DELETE" && command_path.is_empty() {
354 *active_session = None;
355 return json!({"value": null});
356 }
357
358 let (reply_tx, reply_rx) = mpsc::sync_channel(1);
359 let control_request = ControlRequest {
360 method: request.method,
361 path: command_path.to_string(),
362 body: request.body,
363 reply: reply_tx,
364 };
365 match command_tx.try_send(control_request) {
366 Ok(()) => {}
367 Err(TrySendError::Full(_)) => {
368 return webdriver_error("timeout", "renderer command queue is full");
369 }
370 Err(TrySendError::Disconnected(_)) => {
371 return webdriver_error("unknown error", "renderer command channel is closed");
372 }
373 }
374 match reply_rx.recv_timeout(COMMAND_TIMEOUT) {
375 Ok(ControlResponse::Success(value)) => json!({"value": value}),
376 Ok(ControlResponse::Error {
377 error,
378 message,
379 stacktrace,
380 }) => json!({"value": {
381 "error": error,
382 "message": message,
383 "stacktrace": stacktrace,
384 }}),
385 Err(RecvTimeoutError::Timeout) => webdriver_error("timeout", "renderer command timed out"),
386 Err(RecvTimeoutError::Disconnected) => {
387 webdriver_error("unknown error", "renderer response channel is closed")
388 }
389 }
390}
391
392fn session_path(path: &str) -> Option<(&str, &str)> {
393 let remainder = path.strip_prefix("/session/")?;
394 let (session_id, command) = remainder.split_once('/').unwrap_or((remainder, ""));
395 Some((session_id, command))
396}
397
398fn webdriver_error(error: &str, message: impl Into<String>) -> Value {
399 json!({"value": {
400 "error": error,
401 "message": message.into(),
402 "stacktrace": "",
403 }})
404}
405
406fn write_response(stream: &mut TcpStream, body: Value) -> io::Result<()> {
407 let bytes = serde_json::to_vec(&body).map_err(io::Error::other)?;
408 write!(
409 stream,
410 "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
411 bytes.len()
412 )?;
413 stream.write_all(&bytes)
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use std::time::{SystemTime, UNIX_EPOCH};
420
421 fn descriptor_path() -> PathBuf {
422 let nonce = SystemTime::now()
423 .duration_since(UNIX_EPOCH)
424 .unwrap()
425 .as_nanos();
426 std::env::temp_dir().join(format!("blitz-debug-{nonce}.json"))
427 }
428
429 fn request(address: SocketAddr, method: &str, path: &str, body: Value) -> Value {
430 let body = if body.is_null() {
431 Vec::new()
432 } else {
433 serde_json::to_vec(&body).unwrap()
434 };
435 let mut stream = TcpStream::connect(address).unwrap();
436 write!(
437 stream,
438 "{method} {path} HTTP/1.1\r\nHost: {address}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
439 body.len()
440 )
441 .unwrap();
442 stream.write_all(&body).unwrap();
443 let mut response = Vec::new();
444 stream.read_to_end(&mut response).unwrap();
445 let body_start = response
446 .windows(4)
447 .position(|window| window == b"\r\n\r\n")
448 .unwrap()
449 + 4;
450 serde_json::from_slice(&response[body_start..]).unwrap()
451 }
452
453 fn create_session(address: SocketAddr, token: &str) -> String {
454 request(
455 address,
456 "POST",
457 "/session",
458 json!({"capabilities": {"alwaysMatch": {"blitz:token": token}}}),
459 )["value"]["sessionId"]
460 .as_str()
461 .unwrap()
462 .to_string()
463 }
464
465 #[test]
466 fn status_auth_session_command_and_reconnect() {
467 let descriptor = descriptor_path();
468 let (server, commands) = DebugServer::start(ServerConfig {
469 bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(),
470 descriptor_path: descriptor.clone(),
471 renderer_revision: "test-revision".into(),
472 })
473 .unwrap();
474
475 let status = request(server.address(), "GET", "/status", Value::Null);
476 assert_eq!(status["value"]["ready"], true);
477 assert!(descriptor.exists());
478 #[cfg(unix)]
479 {
480 use std::os::unix::fs::PermissionsExt;
481 assert_eq!(
482 fs::metadata(&descriptor).unwrap().permissions().mode() & 0o777,
483 0o600
484 );
485 }
486
487 let rejected = request(
488 server.address(),
489 "POST",
490 "/session",
491 json!({"capabilities": {"alwaysMatch": {"blitz:token": "wrong"}}}),
492 );
493 assert_eq!(rejected["value"]["error"], "invalid argument");
494
495 let session = create_session(server.address(), server.token());
496 let address = server.address();
497 let command_path = format!("/session/{session}/blitz/getDomSnapshot");
498 let client = thread::spawn(move || request(address, "GET", &command_path, Value::Null));
499 let command = commands.recv_timeout(COMMAND_TIMEOUT).unwrap();
500 assert_eq!(command.method, "GET");
501 assert_eq!(command.path, "blitz/getDomSnapshot");
502 command
503 .respond(ControlResponse::Success(json!({"documentRevision": 7})))
504 .unwrap();
505 let response = client.join().unwrap();
506 assert_eq!(response["value"]["documentRevision"], 7);
507
508 let deleted = request(
509 server.address(),
510 "DELETE",
511 &format!("/session/{session}"),
512 Value::Null,
513 );
514 assert!(deleted["value"].is_null());
515 let second_session = create_session(server.address(), server.token());
516 assert_ne!(second_session, session);
517
518 server.shutdown();
519 assert!(!descriptor.exists());
520 }
521
522 #[test]
523 fn rejects_non_loopback_bind_address() {
524 let result = DebugServer::start(ServerConfig {
525 bind_address: ([0, 0, 0, 0], 0).into(),
526 descriptor_path: descriptor_path(),
527 renderer_revision: "test-revision".into(),
528 });
529 assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::InvalidInput));
530 }
531
532 #[test]
533 fn a_full_renderer_queue_fails_without_blocking_the_server() {
534 let (command_tx, command_rx) = mpsc::sync_channel(1);
535 let (reply_tx, _reply_rx) = mpsc::sync_channel(1);
536 command_tx
537 .send(ControlRequest {
538 method: "GET".into(),
539 path: "occupied".into(),
540 body: Value::Null,
541 reply: reply_tx,
542 })
543 .unwrap();
544 let mut active_session = Some("test-session".to_string());
545 let response = route(
546 HttpRequest {
547 method: "GET".into(),
548 path: "/session/test-session/blitz/getDomSnapshot".into(),
549 body: Value::Null,
550 },
551 "token",
552 &mut active_session,
553 &command_tx,
554 );
555 assert_eq!(response["value"]["error"], "timeout");
556 drop(command_rx);
557 }
558}