1use crate::connect::format_http_error;
2use std::io::{Read, Write};
3use std::net::SocketAddr;
4use std::time::{Duration, Instant};
5use wasix_net::TcpStream;
6
7pub struct HttpResponse {
8 pub body: Vec<u8>,
9}
10
11pub struct ProbeResponse {
12 pub status: u16,
13 pub headers: Vec<(String, String)>,
14 pub body: Vec<u8>,
15}
16
17impl ProbeResponse {
18 pub fn content_type(&self) -> &str {
19 self.headers
20 .iter()
21 .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
22 .map(|(_, value)| value.as_str())
23 .unwrap_or("")
24 }
25}
26
27pub struct Ws {
28 stream: Box<dyn ReadWrite>,
29 buf: Vec<u8>,
30}
31
32trait ReadWrite: Read + Write {
33 fn set_nonblocking(&mut self, on: bool) -> std::io::Result<()>;
34}
35
36impl ReadWrite for TcpStream {
37 fn set_nonblocking(&mut self, on: bool) -> std::io::Result<()> {
38 TcpStream::set_nonblocking(self, on)
39 }
40}
41
42#[cfg(not(target_os = "wasi"))]
43impl ReadWrite for native_tls::TlsStream<TcpStream> {
44 fn set_nonblocking(&mut self, on: bool) -> std::io::Result<()> {
45 self.get_mut().set_nonblocking(on)
46 }
47}
48
49struct Parts {
50 scheme: String,
51 host: String,
52 port: u16,
53 path: String,
54}
55
56pub struct EndpointUrl {
57 pub scheme: String,
58 pub host: String,
59 pub port: u16,
60}
61
62pub fn parse_endpoint(url: &str) -> Result<EndpointUrl, String> {
63 let parts = parse_url(url)?;
64 Ok(EndpointUrl {
65 scheme: parts.scheme,
66 host: parts.host,
67 port: parts.port,
68 })
69}
70
71pub fn http_get(url: &str) -> Result<HttpResponse, String> {
72 let parts = parse_url(url)?;
73 if parts.scheme != "http" && parts.scheme != "https" {
74 return Err(format!("discovery URL must be http or https: {url}\n"));
75 }
76 let mut stream = dial(&parts)?;
77 let host_header = host_header(&parts);
78 let request = format!(
79 "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept: application/json\r\n\r\n",
80 parts.path, host_header
81 );
82 stream
83 .write_all(request.as_bytes())
84 .map_err(|err| format!("CDP discovery failed for {url}: {err}\n"))?;
85 let (status, _headers, body, _rest) = read_http(stream.as_mut())?;
86 if !(200..300).contains(&status) {
87 return Err(format_http_error(status, &body));
88 }
89 let _ = status;
90 Ok(HttpResponse { body })
91}
92
93const PROBE_MAX_BODY: usize = 1024 * 1024;
94
95#[cfg(not(target_os = "wasi"))]
96pub fn http_probe(url: &str, accept: &str) -> Result<ProbeResponse, String> {
97 probe_request("GET", url, &format!("Accept: {accept}\r\n"), "")
98}
99
100#[cfg(not(target_os = "wasi"))]
101pub fn http_post_json(url: &str, body: &str) -> Result<ProbeResponse, String> {
102 let headers = format!(
103 "Content-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {}\r\n",
104 body.len()
105 );
106 probe_request("POST", url, &headers, body)
107}
108
109#[cfg(not(target_os = "wasi"))]
110fn probe_request(method: &str, url: &str, headers: &str, body: &str) -> Result<ProbeResponse, String> {
111 let parts = parse_url(url)?;
112 if parts.scheme != "http" && parts.scheme != "https" {
113 return Err(format!("probe URL must be http or https: {url}\n"));
114 }
115 #[cfg(test)]
116 if reserved_loopback(&parts) {
117 panic!("test dialed {}:{}", parts.host, parts.port);
118 }
119 let addr = resolve(&parts.host, parts.port)?;
120 if is_reserved_cdp_socket(addr, parts.port) {
121 return Err(format!("probe refused for reserved CDP address: {url}\n"));
122 }
123 let deadline = Instant::now() + Duration::from_secs(5);
124 let connect_timeout = Duration::from_secs(3).min(
125 deadline.saturating_duration_since(Instant::now()),
126 );
127 let tcp = TcpStream::connect_timeout(&addr, connect_timeout)
128 .map_err(|err| format!("probe connect failed for {url}: {err}\n"))?;
129 tcp.set_read_timeout(Some(Duration::from_secs(1)))
130 .map_err(|err| format!("probe timeout setup failed: {err}\n"))?;
131 tcp.set_nodelay(true).ok();
132 let mut stream: Box<dyn ReadWrite> = if parts.scheme == "https" {
133 wrap_tls(&parts.host, tcp)?
134 } else {
135 Box::new(tcp)
136 };
137 let host_hdr = host_header(&parts);
138 let request = format!(
139 "{method} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n{headers}\r\n{body}",
140 parts.path, host_hdr
141 );
142 stream
143 .write_all(request.as_bytes())
144 .map_err(|err| format!("probe write failed for {url}: {err}\n"))?;
145 if Instant::now() >= deadline {
146 return Err(format!("probe deadline exceeded for {url}\n"));
147 }
148 let remaining = deadline.saturating_duration_since(Instant::now());
149 tcp_set_read_deadline(stream.as_mut(), remaining);
150 let (status, headers, body, _rest) = read_http_capped(stream.as_mut(), Some(PROBE_MAX_BODY), Some(deadline))?;
151 Ok(ProbeResponse {
152 status,
153 headers,
154 body,
155 })
156}
157
158#[cfg(target_os = "wasi")]
159pub fn http_probe(_url: &str, _accept: &str) -> Result<ProbeResponse, String> {
160 Err("probes are not available in this build\n".to_string())
161}
162
163#[cfg(target_os = "wasi")]
164pub fn http_post_json(_url: &str, _body: &str) -> Result<ProbeResponse, String> {
165 Err("http_post_json not available on wasi\n".to_string())
166}
167
168pub fn ws_connect(url: &str) -> Result<Ws, String> {
169 let parts = parse_url(url)?;
170 if parts.scheme != "ws" && parts.scheme != "wss" {
171 return Err(format!("WebSocket URL must be ws or wss: {url}\n"));
172 }
173 let mut stream = dial(&parts)?;
174 let key = ws_key();
175 let host_header = host_header(&parts);
176 let request = format!(
177 "GET {} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n",
178 parts.path, host_header, key
179 );
180 stream
181 .write_all(request.as_bytes())
182 .map_err(|err| connect_error(&parts, &err.to_string()))?;
183 let (status, headers, body, rest) = read_http(stream.as_mut())?;
184 if status != 101 {
185 return Err(format_http_error(status, &body));
186 }
187 let accept = headers
188 .iter()
189 .find(|(name, _)| name.eq_ignore_ascii_case("upgrade"))
190 .map(|(_, value)| value.clone())
191 .unwrap_or_default();
192 if !accept.to_ascii_lowercase().contains("websocket") && !accept.is_empty() {
193 return Err(format_http_error(status, &body));
194 }
195 Ok(Ws {
196 stream,
197 buf: rest,
198 })
199}
200
201impl Ws {
202 pub fn send_text(&mut self, text: &str) -> Result<(), String> {
203 self.note_server_close()?;
204 let frame = encode_client_frame(0x1, text.as_bytes(), mask_key(text.len()));
205 self.stream
206 .write_all(&frame)
207 .map_err(|err| format!("websocket write failed: {err}\n"))?;
208 self.stream
209 .flush()
210 .map_err(|err| format!("websocket write failed: {err}\n"))?;
211 Ok(())
212 }
213
214 fn note_server_close(&mut self) -> Result<(), String> {
215 if let Some(message) = buffered_close(&self.buf) {
216 return Err(message);
217 }
218 let _ = self.stream.set_nonblocking(true);
219 let mut tmp = [0u8; 8192];
220 let mut failed = None;
221 loop {
222 match self.stream.read(&mut tmp) {
223 Ok(0) => {
224 failed = Some(
225 buffered_close(&self.buf)
226 .unwrap_or_else(|| "websocket connection closed\n".to_string()),
227 );
228 break;
229 }
230 Ok(n) => self.buf.extend_from_slice(&tmp[..n]),
231 Err(err)
232 if err.kind() == std::io::ErrorKind::WouldBlock
233 || err.kind() == std::io::ErrorKind::TimedOut
234 || err.kind() == std::io::ErrorKind::Interrupted =>
235 {
236 break;
237 }
238 Err(err) => {
239 failed = Some(
240 buffered_close(&self.buf)
241 .unwrap_or_else(|| format!("websocket read failed: {err}\n")),
242 );
243 break;
244 }
245 }
246 }
247 let _ = self.stream.set_nonblocking(false);
248 if let Some(message) = failed {
249 return Err(message);
250 }
251 if let Some(message) = buffered_close(&self.buf) {
252 return Err(message);
253 }
254 Ok(())
255 }
256
257 pub fn recv_text(&mut self, deadline: std::time::Instant) -> Result<Option<String>, String> {
258 let mut fragments: Vec<u8> = Vec::new();
259 let mut kind = 0u8;
260 loop {
261 if std::time::Instant::now() >= deadline {
262 return Ok(None);
263 }
264 let frame = match next_frame(&mut self.buf, self.stream.as_mut())? {
265 Some(frame) => frame,
266 None => continue,
267 };
268 match frame.opcode {
269 0x1 | 0x2 => {
270 kind = frame.opcode;
271 if frame.fin {
272 if frame.opcode == 0x2 {
273 return Err("unexpected binary websocket frame\n".to_string());
274 }
275 return Ok(Some(
276 String::from_utf8(frame.payload)
277 .map_err(|err| format!("websocket text: {err}\n"))?,
278 ));
279 }
280 fragments = frame.payload;
281 }
282 0x0 => {
283 fragments.extend_from_slice(&frame.payload);
284 if frame.fin {
285 if kind == 0x2 {
286 return Err("unexpected binary websocket frame\n".to_string());
287 }
288 return Ok(Some(
289 String::from_utf8(fragments)
290 .map_err(|err| format!("websocket text: {err}\n"))?,
291 ));
292 }
293 }
294 0x8 => return Err(close_message(&frame.payload)),
295 0x9 => {
296 self.note_server_close()?;
297 let pong = encode_client_frame(0xA, &frame.payload, mask_key(frame.payload.len()));
298 self.stream
299 .write_all(&pong)
300 .map_err(|err| format!("websocket pong failed: {err}\n"))?;
301 }
302 0xA => {}
303 other => return Err(format!("unsupported websocket opcode {other}\n")),
304 }
305 }
306 }
307}
308
309struct Frame {
310 opcode: u8,
311 fin: bool,
312 payload: Vec<u8>,
313}
314
315fn next_frame(buf: &mut Vec<u8>, stream: &mut dyn ReadWrite) -> Result<Option<Frame>, String> {
316 if let Some((consumed, frame)) = try_decode(buf)? {
317 buf.drain(..consumed);
318 return Ok(Some(frame));
319 }
320 let mut tmp = [0u8; 8192];
321 match stream.read(&mut tmp) {
322 Ok(0) => Err("websocket connection closed\n".to_string()),
323 Ok(n) => {
324 buf.extend_from_slice(&tmp[..n]);
325 if let Some((consumed, frame)) = try_decode(buf)? {
326 buf.drain(..consumed);
327 Ok(Some(frame))
328 } else {
329 Ok(None)
330 }
331 }
332 Err(err)
333 if err.kind() == std::io::ErrorKind::TimedOut
334 || err.kind() == std::io::ErrorKind::WouldBlock =>
335 {
336 Ok(None)
337 }
338 Err(err) => Err(format!("websocket read failed: {err}\n")),
339 }
340}
341
342fn try_decode(buf: &[u8]) -> Result<Option<(usize, Frame)>, String> {
343 if buf.len() < 2 {
344 return Ok(None);
345 }
346 let b0 = buf[0];
347 let b1 = buf[1];
348 let fin = b0 & 0x80 != 0;
349 let opcode = b0 & 0x0f;
350 let masked = b1 & 0x80 != 0;
351 let mut len = (b1 & 0x7f) as u64;
352 let mut offset = 2usize;
353 if len == 126 {
354 if buf.len() < 4 {
355 return Ok(None);
356 }
357 len = u16::from_be_bytes([buf[2], buf[3]]) as u64;
358 offset = 4;
359 } else if len == 127 {
360 if buf.len() < 10 {
361 return Ok(None);
362 }
363 len = u64::from_be_bytes(buf[2..10].try_into().unwrap());
364 offset = 10;
365 }
366 let mask = if masked {
367 if buf.len() < offset + 4 {
368 return Ok(None);
369 }
370 let mask = [buf[offset], buf[offset + 1], buf[offset + 2], buf[offset + 3]];
371 offset += 4;
372 Some(mask)
373 } else {
374 None
375 };
376 let len_usize = usize::try_from(len).map_err(|_| "websocket frame too large\n".to_string())?;
377 if buf.len() < offset + len_usize {
378 return Ok(None);
379 }
380 let mut payload = buf[offset..offset + len_usize].to_vec();
381 if let Some(mask) = mask {
382 for (i, byte) in payload.iter_mut().enumerate() {
383 *byte ^= mask[i % 4];
384 }
385 }
386 Ok(Some((
387 offset + len_usize,
388 Frame {
389 opcode,
390 fin,
391 payload,
392 },
393 )))
394}
395
396pub fn encode_client_frame(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
397 let mut out = Vec::new();
398 out.push(0x80 | opcode);
399 let len = payload.len();
400 if len < 126 {
401 out.push(0x80 | len as u8);
402 } else if len <= u16::MAX as usize {
403 out.push(0x80 | 126);
404 out.extend_from_slice(&(len as u16).to_be_bytes());
405 } else {
406 out.push(0x80 | 127);
407 out.extend_from_slice(&(len as u64).to_be_bytes());
408 }
409 out.extend_from_slice(&mask);
410 for (i, byte) in payload.iter().enumerate() {
411 out.push(byte ^ mask[i % 4]);
412 }
413 out
414}
415
416fn buffered_close(buf: &[u8]) -> Option<String> {
417 let mut offset = 0;
418 while offset < buf.len() {
419 match try_decode(&buf[offset..]) {
420 Ok(Some((consumed, frame))) => {
421 if frame.opcode == 0x8 {
422 return Some(close_message(&frame.payload));
423 }
424 offset += consumed;
425 }
426 _ => return None,
427 }
428 }
429 None
430}
431
432fn close_message(payload: &[u8]) -> String {
433 if payload.len() < 2 {
434 return "websocket closed\n".to_string();
435 }
436 let code = u16::from_be_bytes([payload[0], payload[1]]);
437 let reason = String::from_utf8_lossy(&payload[2..]);
438 if reason.is_empty() {
439 format!("websocket closed {code}\n")
440 } else {
441 format!("websocket closed {code} {reason}\n")
442 }
443}
444
445fn mask_key(seed: usize) -> [u8; 4] {
446 let n = seed as u32 ^ 0xA5A5_5A5A;
447 n.to_le_bytes()
448}
449
450fn ws_key() -> String {
451 let ticks = std::time::SystemTime::now()
452 .duration_since(std::time::UNIX_EPOCH)
453 .map(|d| d.as_nanos())
454 .unwrap_or(1);
455 let mut bytes = [0u8; 16];
456 let mut x = ticks ^ 0x9E37_79B9_7F4A_7C15;
457 for (i, byte) in bytes.iter_mut().enumerate() {
458 x = x
459 .wrapping_mul(0x6C62_272E_07BB_0142)
460 .wrapping_add(i as u128 + 1);
461 *byte = (x >> 11) as u8;
462 }
463 base64_encode(&bytes)
464}
465
466pub fn base64_encode(data: &[u8]) -> String {
467 const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
468 let mut out = String::new();
469 let mut i = 0;
470 while i + 3 <= data.len() {
471 let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | data[i + 2] as u32;
472 out.push(TABLE[((n >> 18) & 63) as usize] as char);
473 out.push(TABLE[((n >> 12) & 63) as usize] as char);
474 out.push(TABLE[((n >> 6) & 63) as usize] as char);
475 out.push(TABLE[(n & 63) as usize] as char);
476 i += 3;
477 }
478 if i < data.len() {
479 let rest = data.len() - i;
480 let mut n = (data[i] as u32) << 16;
481 if rest == 2 {
482 n |= (data[i + 1] as u32) << 8;
483 }
484 out.push(TABLE[((n >> 18) & 63) as usize] as char);
485 out.push(TABLE[((n >> 12) & 63) as usize] as char);
486 if rest == 2 {
487 out.push(TABLE[((n >> 6) & 63) as usize] as char);
488 out.push('=');
489 } else {
490 out.push('=');
491 out.push('=');
492 }
493 }
494 out
495}
496
497pub fn base64_decode(data: &str) -> Result<Vec<u8>, String> {
498 fn val(byte: u8) -> Result<u8, String> {
499 match byte {
500 b'A'..=b'Z' => Ok(byte - b'A'),
501 b'a'..=b'z' => Ok(byte - b'a' + 26),
502 b'0'..=b'9' => Ok(byte - b'0' + 52),
503 b'+' => Ok(62),
504 b'/' => Ok(63),
505 _ => Err("invalid base64\n".to_string()),
506 }
507 }
508 let bytes: Vec<u8> = data
509 .bytes()
510 .filter(|b| !b.is_ascii_whitespace())
511 .collect();
512 if bytes.len() % 4 != 0 {
513 return Err("invalid base64\n".to_string());
514 }
515 let mut out = Vec::new();
516 for chunk in bytes.chunks(4) {
517 let pad = chunk.iter().filter(|b| **b == b'=').count();
518 let a = val(chunk[0])?;
519 let b = val(chunk[1])?;
520 let c = if chunk[2] == b'=' { 0 } else { val(chunk[2])? };
521 let d = if chunk[3] == b'=' { 0 } else { val(chunk[3])? };
522 let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | d as u32;
523 out.push((n >> 16) as u8);
524 if pad < 2 {
525 out.push((n >> 8) as u8);
526 }
527 if pad < 1 {
528 out.push(n as u8);
529 }
530 }
531 Ok(out)
532}
533
534fn parse_url(url: &str) -> Result<Parts, String> {
535 let (scheme, rest) = url
536 .split_once("://")
537 .ok_or_else(|| format!("unsupported CDP URL \"{url}\"\n"))?;
538 let scheme = scheme.to_ascii_lowercase();
539 let cut = rest.find(['/', '?', '#']).unwrap_or(rest.len());
540 let authority = &rest[..cut];
541 let path: String = if cut == rest.len() || rest.as_bytes()[cut] == b'#' {
542 "/".to_string()
543 } else if rest.as_bytes()[cut] == b'/' {
544 rest[cut..].to_string()
545 } else {
546 format!("/{}", &rest[cut..])
547 };
548 let hostport = match authority.rsplit_once('@') {
549 Some((_, host)) => host,
550 None => authority,
551 };
552 if hostport.is_empty() {
553 return Err(format!("bad CDP URL \"{url}\"\n"));
554 }
555 let (host, port_text) = if let Some(host) = hostport.strip_prefix('[') {
556 let end = host
557 .find(']')
558 .ok_or_else(|| format!("bad CDP URL \"{url}\"\n"))?;
559 let host = &host[..end];
560 let after = &hostport[end + 2..];
561 if !after.is_empty() && !after.starts_with(':') {
562 return Err(format!("bad CDP URL \"{url}\"\n"));
563 }
564 let port = after.strip_prefix(':');
565 (host.to_string(), port.map(str::to_string))
566 } else if let Some((host, port)) = hostport.rsplit_once(':') {
567 if host.contains(':') {
568 (hostport.to_string(), None)
569 } else if port.is_empty() {
570 return Err(format!("bad port in CDP URL \"{url}\"\n"));
571 } else {
572 (host.to_string(), Some(port.to_string()))
573 }
574 } else {
575 (hostport.to_string(), None)
576 };
577 let default_port = match scheme.as_str() {
578 "https" | "wss" => 443,
579 _ => 80,
580 };
581 let port = match port_text {
582 Some(text) => text
583 .parse::<u16>()
584 .map_err(|_| format!("bad port in CDP URL \"{url}\"\n"))?,
585 None => default_port,
586 };
587 if host.is_empty() {
588 return Err(format!("bad CDP URL \"{url}\"\n"));
589 }
590 let path = if path.is_empty() { "/".to_string() } else { path };
591 Ok(Parts {
592 scheme,
593 host,
594 port,
595 path,
596 })
597}
598
599fn host_header(parts: &Parts) -> String {
600 let default = matches!(
601 (parts.scheme.as_str(), parts.port),
602 ("http" | "ws", 80) | ("https" | "wss", 443)
603 );
604 if default {
605 parts.host.clone()
606 } else if parts.host.contains(':') {
607 format!("[{}]:{}", parts.host, parts.port)
608 } else {
609 format!("{}:{}", parts.host, parts.port)
610 }
611}
612
613fn connect_error(parts: &Parts, err: &str) -> String {
614 format!(
615 "CDP connection to {}:{} failed: {err}. This CLI does not launch Chrome. Pass --cdp or set SLICC_CDP_URL.\n",
616 parts.host, parts.port
617 )
618}
619
620fn dial(parts: &Parts) -> Result<Box<dyn ReadWrite>, String> {
621 #[cfg(test)]
622 if reserved_loopback(parts) {
623 panic!("test dialed {}:{}", parts.host, parts.port);
624 }
625 let addr = resolve(&parts.host, parts.port)?;
626 let tcp = TcpStream::connect_timeout(&addr, Duration::from_secs(10)).map_err(|err| {
627 connect_error(parts, &err.to_string())
628 })?;
629 tcp.set_read_timeout(Some(Duration::from_millis(200)))
630 .map_err(|err| connect_error(parts, &err.to_string()))?;
631 tcp.set_nodelay(true).ok();
632 if parts.scheme == "https" || parts.scheme == "wss" {
633 wrap_tls(&parts.host, tcp)
634 } else {
635 Ok(Box::new(tcp))
636 }
637}
638
639#[cfg(test)]
640fn reserved_loopback(parts: &Parts) -> bool {
641 (9222..=9229).contains(&parts.port)
642 && matches!(
643 parts.host.to_ascii_lowercase().as_str(),
644 "127.0.0.1" | "localhost" | "::1"
645 )
646}
647
648pub fn is_reserved_cdp_socket(addr: SocketAddr, port: u16) -> bool {
649 if !(9222..=9229).contains(&port) {
650 return false;
651 }
652 match addr {
653 SocketAddr::V4(v4) => v4.ip().is_loopback() || v4.ip().is_unspecified(),
654 SocketAddr::V6(v6) => {
655 v6.ip().is_loopback()
656 || v6.ip().is_unspecified()
657 || match v6.ip().to_ipv4_mapped() {
658 Some(v4) => v4.is_loopback() || v4.is_unspecified(),
659 None => false,
660 }
661 }
662 }
663}
664
665fn tcp_set_read_deadline(_stream: &mut dyn ReadWrite, _remaining: Duration) {}
666
667fn resolve(host: &str, port: u16) -> Result<SocketAddr, String> {
668 wasix_net::resolve(host, port)
669 .map_err(|err| format!("could not resolve {host}: {err}\n"))?
670 .into_iter()
671 .next()
672 .ok_or_else(|| format!("could not resolve {host}\n"))
673}
674
675#[cfg(not(target_os = "wasi"))]
676fn wrap_tls(host: &str, tcp: TcpStream) -> Result<Box<dyn ReadWrite>, String> {
677 let connector = native_tls::TlsConnector::new()
678 .map_err(|err| format!("TLS setup failed: {err}\n"))?;
679 let stream = connector
680 .connect(host, tcp)
681 .map_err(|err| format!("TLS handshake with {host} failed: {err}\n"))?;
682 Ok(Box::new(stream))
683}
684
685#[cfg(target_os = "wasi")]
686fn wrap_tls(_host: &str, _tcp: TcpStream) -> Result<Box<dyn ReadWrite>, String> {
687 Err("wss and https are not available in this build\n".to_string())
688}
689
690fn read_http(stream: &mut dyn ReadWrite) -> Result<(u16, Vec<(String, String)>, Vec<u8>, Vec<u8>), String> {
691 read_http_capped(stream, None, None)
692}
693
694fn read_http_capped(
695 stream: &mut dyn ReadWrite,
696 body_cap: Option<usize>,
697 deadline: Option<Instant>,
698) -> Result<(u16, Vec<(String, String)>, Vec<u8>, Vec<u8>), String> {
699 let mut buf = Vec::new();
700 let header_end = loop {
701 if deadline.is_some_and(|d| Instant::now() >= d) {
702 return Err("probe deadline exceeded\n".to_string());
703 }
704 if let Some(pos) = find_header_end(&buf) {
705 break pos;
706 }
707 let mut tmp = [0u8; 4096];
708 match stream.read(&mut tmp) {
709 Ok(0) => {
710 return Err("HTTP connection closed before headers\n".to_string());
711 }
712 Ok(n) => buf.extend_from_slice(&tmp[..n]),
713 Err(err)
714 if err.kind() == std::io::ErrorKind::TimedOut
715 || err.kind() == std::io::ErrorKind::WouldBlock =>
716 {
717 if buf.is_empty() {
718 continue;
719 }
720 return Err("timed out reading HTTP headers\n".to_string());
721 }
722 Err(err) => return Err(format!("HTTP read failed: {err}\n")),
723 }
724 if buf.len() > 1024 * 1024 {
725 return Err("HTTP headers too large\n".to_string());
726 }
727 };
728 let header_bytes = buf[..header_end].to_vec();
729 let mut rest = buf[header_end + 4..].to_vec();
730 let header_text = String::from_utf8_lossy(&header_bytes);
731 let mut lines = header_text.split("\r\n");
732 let status_line = lines.next().unwrap_or("");
733 let status = status_line
734 .split_whitespace()
735 .nth(1)
736 .and_then(|text| text.parse::<u16>().ok())
737 .ok_or_else(|| format!("bad HTTP status line: {status_line}\n"))?;
738 let mut headers = Vec::new();
739 for line in lines {
740 if let Some((name, value)) = line.split_once(':') {
741 headers.push((name.trim().to_string(), value.trim().to_string()));
742 }
743 }
744 let content_length = headers
745 .iter()
746 .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
747 .and_then(|(_, value)| value.parse::<usize>().ok());
748 let chunked = headers.iter().any(|(name, value)| {
749 name.eq_ignore_ascii_case("transfer-encoding") && value.to_ascii_lowercase().contains("chunked")
750 });
751 let close = headers.iter().any(|(name, value)| {
752 name.eq_ignore_ascii_case("connection") && value.eq_ignore_ascii_case("close")
753 });
754 if status == 101 && !chunked && content_length.is_none() {
755 return Ok((status, headers, Vec::new(), rest));
756 }
757 let body = if chunked {
758 read_chunked_capped(&mut rest, stream, body_cap, deadline)?
759 } else if let Some(len) = content_length {
760 if let Some(cap) = body_cap {
761 if len > cap {
762 return Err("HTTP body exceeds cap\n".to_string());
763 }
764 }
765 let limit = body_cap.map(|c| c.min(len)).unwrap_or(len);
766 while rest.len() < limit {
767 if deadline.is_some_and(|d| Instant::now() >= d) {
768 return Err("probe deadline exceeded\n".to_string());
769 }
770 let mut tmp = [0u8; 4096];
771 match stream.read(&mut tmp) {
772 Ok(0) => break,
773 Ok(n) => rest.extend_from_slice(&tmp[..n]),
774 Err(err)
775 if err.kind() == std::io::ErrorKind::TimedOut
776 || err.kind() == std::io::ErrorKind::WouldBlock =>
777 {
778 if deadline.is_some_and(|d| Instant::now() >= d) {
779 return Err("probe deadline exceeded\n".to_string());
780 }
781 continue;
782 }
783 Err(err) => return Err(format!("HTTP body read failed: {err}\n")),
784 }
785 }
786 let body = rest[..limit.min(rest.len())].to_vec();
787 let extra = if status == 101 {
788 rest[limit.min(rest.len())..].to_vec()
789 } else {
790 Vec::new()
791 };
792 return Ok((status, headers, body, extra));
793 } else {
794 let close_cap = body_cap.unwrap_or(8 * 1024 * 1024);
795 loop {
796 if deadline.is_some_and(|d| Instant::now() >= d) {
797 break;
798 }
799 let mut tmp = [0u8; 4096];
800 match stream.read(&mut tmp) {
801 Ok(0) => break,
802 Ok(n) => rest.extend_from_slice(&tmp[..n]),
803 Err(err)
804 if err.kind() == std::io::ErrorKind::TimedOut
805 || err.kind() == std::io::ErrorKind::WouldBlock =>
806 {
807 break;
808 }
809 Err(err) => return Err(format!("HTTP body read failed: {err}\n")),
810 }
811 if rest.len() > close_cap {
812 break;
813 }
814 }
815 rest
816 };
817 let _ = close;
818 let body = if let Some(cap) = body_cap {
819 if body.len() > cap { body[..cap].to_vec() } else { body }
820 } else {
821 body
822 };
823 Ok((status, headers, body, Vec::new()))
824}
825
826#[allow(dead_code)]
827fn read_chunked(rest: &mut Vec<u8>, stream: &mut dyn ReadWrite) -> Result<Vec<u8>, String> {
828 read_chunked_capped(rest, stream, None, None)
829}
830
831fn read_chunked_capped(
832 rest: &mut Vec<u8>,
833 stream: &mut dyn ReadWrite,
834 cap: Option<usize>,
835 deadline: Option<Instant>,
836) -> Result<Vec<u8>, String> {
837 let mut body = Vec::new();
838 loop {
839 if deadline.is_some_and(|d| Instant::now() >= d) {
840 return Ok(body);
841 }
842 let line_end = loop {
843 if let Some(pos) = rest.windows(2).position(|w| w == b"\r\n") {
844 break pos;
845 }
846 let mut tmp = [0u8; 2048];
847 match stream.read(&mut tmp) {
848 Ok(0) => return Err("chunked body ended early\n".to_string()),
849 Ok(n) => rest.extend_from_slice(&tmp[..n]),
850 Err(err)
851 if err.kind() == std::io::ErrorKind::TimedOut
852 || err.kind() == std::io::ErrorKind::WouldBlock =>
853 {
854 if deadline.is_some_and(|d| Instant::now() >= d) {
855 return Ok(body);
856 }
857 continue;
858 }
859 Err(err) => return Err(format!("chunked read failed: {err}\n")),
860 }
861 };
862 let line = String::from_utf8_lossy(&rest[..line_end]).to_string();
863 rest.drain(..line_end + 2);
864 let size_text = line.split(';').next().unwrap_or("").trim();
865 let size = usize::from_str_radix(size_text, 16)
866 .map_err(|_| format!("bad chunk size {size_text}\n"))?;
867 if size == 0 {
868 return Ok(body);
869 }
870 while rest.len() < size + 2 {
871 let mut tmp = [0u8; 4096];
872 match stream.read(&mut tmp) {
873 Ok(0) => return Err("chunked body ended early\n".to_string()),
874 Ok(n) => rest.extend_from_slice(&tmp[..n]),
875 Err(err)
876 if err.kind() == std::io::ErrorKind::TimedOut
877 || err.kind() == std::io::ErrorKind::WouldBlock =>
878 {
879 if deadline.is_some_and(|d| Instant::now() >= d) {
880 return Ok(body);
881 }
882 continue;
883 }
884 Err(err) => return Err(format!("chunked read failed: {err}\n")),
885 }
886 }
887 body.extend_from_slice(&rest[..size]);
888 rest.drain(..size + 2);
889 if let Some(limit) = cap {
890 if body.len() > limit {
891 body.truncate(limit);
892 return Ok(body);
893 }
894 }
895 }
896}
897
898fn find_header_end(buf: &[u8]) -> Option<usize> {
899 buf.windows(4).position(|w| w == b"\r\n\r\n")
900}
901
902#[cfg(test)]
903mod tests {
904 use super::*;
905
906 #[test]
907 fn decodes_unmasked_text_frame() {
908 let payload = b"hello";
909 let mut frame = vec![0x81, payload.len() as u8];
910 frame.extend_from_slice(payload);
911 let parsed = try_decode(&frame).unwrap().unwrap();
912 assert_eq!(parsed.1.payload, b"hello");
913 assert!(parsed.1.fin);
914 assert_eq!(parsed.1.opcode, 0x1);
915 }
916
917 #[test]
918 fn client_frame_round_trip_mask() {
919 let encoded = encode_client_frame(0x1, b"{}", [1, 2, 3, 4]);
920 assert_eq!(encoded[0], 0x81);
921 assert_eq!(encoded[1] & 0x80, 0x80);
922 let parsed = try_decode(&encoded).unwrap().unwrap();
923 assert_eq!(parsed.1.payload, b"{}");
924 }
925
926 #[test]
927 fn base64_round_trip() {
928 assert_eq!(base64_decode(&base64_encode(b"hi")).unwrap(), b"hi");
929 assert_eq!(base64_decode(&base64_encode(&[0, 1, 2, 3, 4])).unwrap(), [0, 1, 2, 3, 4]);
930 }
931
932 #[test]
933 fn close_frame_reports_code_and_reason() {
934 assert_eq!(
935 close_message(b"\x03\xf3the CDP host is gone"),
936 "websocket closed 1011 the CDP host is gone\n"
937 );
938 assert_eq!(
939 close_message(b"\x03\xf1a message is over 268435456 bytes"),
940 "websocket closed 1009 a message is over 268435456 bytes\n"
941 );
942 assert_eq!(close_message(&[]), "websocket closed\n");
943 assert_eq!(close_message(&[0x03, 0xf3]), "websocket closed 1011\n");
944 }
945
946 #[test]
947 fn tests_do_not_dial_reserved_debug_ports() {
948 let err = std::panic::catch_unwind(|| {
949 let _ = http_get("http://127.0.0.1:9222/json/version");
950 });
951 assert!(err.is_err());
952 let err = std::panic::catch_unwind(|| {
953 let _ = ws_connect("ws://localhost:9229/devtools/browser/abc");
954 });
955 assert!(err.is_err());
956 }
957
958 #[test]
959 fn parses_opaque_browser_path() {
960 let parts = parse_url("ws://127.0.0.1:9222/devtools/browser/abc-def?x=1").unwrap();
961 assert_eq!(parts.host, "127.0.0.1");
962 assert_eq!(parts.port, 9222);
963 assert_eq!(parts.path, "/devtools/browser/abc-def?x=1");
964 }
965
966 #[test]
967 fn userinfo_is_not_part_of_the_dial_host() {
968 let same = parse_url("http://evil.example@127.0.0.1:9222/json/version").unwrap();
969 assert_eq!(same.scheme, "http");
970 assert_eq!(same.host, "127.0.0.1");
971 assert_eq!(same.port, 9222);
972 assert_eq!(same.path, "/json/version");
973 let other = parse_url("http://127.0.0.1:9222@evil.example/json/version").unwrap();
974 assert_eq!(other.host, "evil.example");
975 assert_eq!(other.port, 80);
976 assert_eq!(other.path, "/json/version");
977 let v6 = parse_url("http://user@[::1]:9333/json/version").unwrap();
978 assert_eq!(v6.host, "::1");
979 assert_eq!(v6.port, 9333);
980 let query = parse_url("http://127.0.0.1:9333?x=1").unwrap();
981 assert_eq!(query.host, "127.0.0.1");
982 assert_eq!(query.port, 9333);
983 assert_eq!(query.path, "/?x=1");
984 let published = parse_endpoint("HTTP://evil.example@127.0.0.1:9222").unwrap();
985 assert_eq!(published.scheme, "http");
986 assert_eq!(published.host, "127.0.0.1");
987 assert_eq!(published.port, 9222);
988 }
989
990 #[cfg(not(target_os = "wasi"))]
991 fn accept_headers(listener: std::net::TcpListener) -> std::net::TcpStream {
992 let (mut sock, _) = listener.accept().unwrap();
993 let mut buf = Vec::new();
994 let mut tmp = [0u8; 1024];
995 loop {
996 let n = sock.read(&mut tmp).unwrap();
997 buf.extend_from_slice(&tmp[..n]);
998 if buf.windows(4).any(|w| w == b"\r\n\r\n") {
999 break;
1000 }
1001 }
1002 sock
1003 }
1004
1005 #[cfg(not(target_os = "wasi"))]
1006 #[test]
1007 fn http_get_uses_the_stream() {
1008 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1009 let addr = listener.local_addr().unwrap();
1010 let server = std::thread::spawn(move || {
1011 let mut sock = accept_headers(listener);
1012 sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
1013 .unwrap();
1014 });
1015 let body = http_get(&format!("http://{addr}/json/version")).unwrap();
1016 assert_eq!(body.body, b"{}");
1017 server.join().unwrap();
1018 }
1019
1020 #[cfg(not(target_os = "wasi"))]
1021 #[test]
1022 fn websocket_uses_the_stream() {
1023 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1024 let addr = listener.local_addr().unwrap();
1025 let server = std::thread::spawn(move || {
1026 let mut sock = accept_headers(listener);
1027 sock.write_all(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
1028 .unwrap();
1029 sock.write_all(&[0x81, 5, b'h', b'e', b'l', b'l', b'o']).unwrap();
1030 });
1031 let mut ws = ws_connect(&format!("ws://{addr}/devtools/browser/abc")).unwrap();
1032 let text = ws
1033 .recv_text(std::time::Instant::now() + Duration::from_secs(2))
1034 .unwrap();
1035 assert_eq!(text.as_deref(), Some("hello"));
1036 server.join().unwrap();
1037 }
1038
1039 #[cfg(not(target_os = "wasi"))]
1040 #[test]
1041 fn server_close_is_reported_and_not_answered() {
1042 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1043 let addr = listener.local_addr().unwrap();
1044 let server = std::thread::spawn(move || {
1045 let mut sock = accept_headers(listener);
1046 let reason = b"the CDP host is gone";
1047 let mut message = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec();
1048 message.extend_from_slice(&[0x81, 5, b'h', b'e', b'l', b'l', b'o']);
1049 message.extend_from_slice(&[0x88, (2 + reason.len()) as u8, 0x03, 0xf3]);
1050 message.extend_from_slice(reason);
1051 sock.write_all(&message).unwrap();
1052 sock.set_read_timeout(Some(Duration::from_millis(500))).unwrap();
1053 let mut tmp = [0u8; 64];
1054 match sock.read(&mut tmp) {
1055 Ok(n) => n,
1056 Err(err)
1057 if err.kind() == std::io::ErrorKind::WouldBlock
1058 || err.kind() == std::io::ErrorKind::TimedOut =>
1059 {
1060 0
1061 }
1062 Err(_) => 0,
1063 }
1064 });
1065 let mut ws = ws_connect(&format!("ws://{addr}/devtools/browser/abc")).unwrap();
1066 let text = ws
1067 .recv_text(std::time::Instant::now() + Duration::from_secs(2))
1068 .unwrap();
1069 assert_eq!(text.as_deref(), Some("hello"));
1070 let err = ws.send_text("{}").unwrap_err();
1071 assert_eq!(err, "websocket closed 1011 the CDP host is gone\n");
1072 assert_eq!(server.join().unwrap(), 0);
1073 }
1074
1075 #[test]
1076 fn probe_response_content_type() {
1077 let resp = ProbeResponse {
1078 status: 200,
1079 headers: vec![
1080 ("Content-Type".into(), "application/json; charset=utf-8".into()),
1081 ],
1082 body: b"{}".to_vec(),
1083 };
1084 assert_eq!(resp.content_type(), "application/json; charset=utf-8");
1085 }
1086
1087 #[test]
1088 fn probe_response_missing_content_type() {
1089 let resp = ProbeResponse {
1090 status: 200,
1091 headers: vec![],
1092 body: b"{}".to_vec(),
1093 };
1094 assert_eq!(resp.content_type(), "");
1095 }
1096
1097 #[test]
1098 fn probe_html_200_never_matches_ard() {
1099 let resp = ProbeResponse {
1100 status: 200,
1101 headers: vec![("Content-Type".into(), "text/html".into())],
1102 body: b"<html>".to_vec(),
1103 };
1104 assert_eq!(resp.status, 200);
1105 assert_eq!(resp.content_type(), "text/html");
1106 }
1107
1108 #[test]
1109 fn probe_404_never_matches_ard() {
1110 let resp = ProbeResponse {
1111 status: 404,
1112 headers: vec![("Content-Type".into(), "application/json".into())],
1113 body: b"{}".to_vec(),
1114 };
1115 assert_ne!(resp.status, 200);
1116 }
1117
1118 #[test]
1119 fn probe_301_never_matches_ard() {
1120 let resp = ProbeResponse {
1121 status: 301,
1122 headers: vec![
1123 ("Location".into(), "https://example.com/.well-known/ard.json".into()),
1124 ("Content-Type".into(), "application/json".into()),
1125 ],
1126 body: Vec::new(),
1127 };
1128 assert_ne!(resp.status, 200);
1129 }
1130
1131 #[test]
1132 fn probe_body_cap_constant() {
1133 assert_eq!(PROBE_MAX_BODY, 1024 * 1024);
1134 }
1135
1136 #[test]
1137 fn probe_does_not_dial_reserved_ports() {
1138 let err = std::panic::catch_unwind(|| {
1139 let _ = http_probe("http://127.0.0.1:9222/.well-known/ard.json", "application/json");
1140 });
1141 assert!(err.is_err(), "probe must not dial 9222");
1142 }
1143
1144 #[test]
1145 fn is_reserved_cdp_socket_loopback_v4() {
1146 let addr: SocketAddr = "127.0.0.1:9222".parse().unwrap();
1147 assert!(is_reserved_cdp_socket(addr, 9222));
1148 }
1149
1150 #[test]
1151 fn is_reserved_cdp_socket_unspec_v4() {
1152 let addr: SocketAddr = "0.0.0.0:9222".parse().unwrap();
1153 assert!(is_reserved_cdp_socket(addr, 9222));
1154 }
1155
1156 #[test]
1157 fn is_reserved_cdp_socket_loopback_v6() {
1158 let addr: SocketAddr = "[::1]:9222".parse().unwrap();
1159 assert!(is_reserved_cdp_socket(addr, 9222));
1160 }
1161
1162 #[test]
1163 fn is_reserved_cdp_socket_unspec_v6() {
1164 let addr: SocketAddr = "[::]:9223".parse().unwrap();
1165 assert!(is_reserved_cdp_socket(addr, 9223));
1166 }
1167
1168 #[test]
1169 fn is_reserved_cdp_socket_mapped_loopback() {
1170 let addr: SocketAddr = "[::ffff:127.0.0.1]:9224".parse().unwrap();
1171 assert!(is_reserved_cdp_socket(addr, 9224));
1172 }
1173
1174 #[test]
1175 fn is_reserved_cdp_socket_mapped_unspec() {
1176 let addr: SocketAddr = "[::ffff:0.0.0.0]:9225".parse().unwrap();
1177 assert!(is_reserved_cdp_socket(addr, 9225));
1178 }
1179
1180 #[test]
1181 fn is_reserved_cdp_socket_non_reserved() {
1182 let addr: SocketAddr = "93.184.216.34:9222".parse().unwrap();
1183 assert!(!is_reserved_cdp_socket(addr, 9222));
1184 }
1185
1186 #[test]
1187 fn is_reserved_cdp_socket_wrong_port() {
1188 let addr: SocketAddr = "127.0.0.1:9230".parse().unwrap();
1189 assert!(!is_reserved_cdp_socket(addr, 9230));
1190 }
1191
1192 #[test]
1193 fn is_reserved_cdp_socket_127_range() {
1194 let addr: SocketAddr = "127.0.0.2:9222".parse().unwrap();
1195 assert!(is_reserved_cdp_socket(addr, 9222));
1196 let addr: SocketAddr = "127.255.255.254:9229".parse().unwrap();
1197 assert!(is_reserved_cdp_socket(addr, 9229));
1198 }
1199
1200 #[cfg(not(target_os = "wasi"))]
1201 #[test]
1202 fn body_cap_content_length_truncates() {
1203 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1204 let addr = listener.local_addr().unwrap();
1205 let server = std::thread::spawn(move || {
1206 let mut sock = accept_headers(listener);
1207 let big = vec![b'X'; 5 * 1024 * 1024];
1208 sock.write_all(
1209 format!(
1210 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1211 big.len()
1212 )
1213 .as_bytes(),
1214 )
1215 .unwrap();
1216 let _ = sock.write_all(&big);
1217 });
1218 let resp = http_probe(
1219 &format!("http://{addr}/.well-known/ard.json"),
1220 "application/json",
1221 );
1222 server.join().unwrap();
1223 match resp {
1224 Ok(r) => assert!(
1225 r.body.len() <= PROBE_MAX_BODY,
1226 "body should be capped at {PROBE_MAX_BODY}, got {}",
1227 r.body.len()
1228 ),
1229 Err(e) => assert!(
1230 e.contains("cap") || e.contains("exceeds"),
1231 "expected cap error, got: {e}"
1232 ),
1233 }
1234 }
1235
1236 #[cfg(not(target_os = "wasi"))]
1237 #[test]
1238 fn body_cap_chunked_truncates() {
1239 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1240 let addr = listener.local_addr().unwrap();
1241 let server = std::thread::spawn(move || {
1242 let mut sock = accept_headers(listener);
1243 sock.write_all(
1244 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n",
1245 )
1246 .unwrap();
1247 let chunk = vec![b'Y'; 512 * 1024];
1248 for _ in 0..10 {
1249 let _ = sock.write_all(format!("{:x}\r\n", chunk.len()).as_bytes());
1250 let _ = sock.write_all(&chunk);
1251 let _ = sock.write_all(b"\r\n");
1252 }
1253 let _ = sock.write_all(b"0\r\n\r\n");
1254 });
1255 let resp = http_probe(
1256 &format!("http://{addr}/.well-known/ard.json"),
1257 "application/json",
1258 )
1259 .unwrap();
1260 assert!(
1261 resp.body.len() <= PROBE_MAX_BODY,
1262 "chunked body must be capped, got {}",
1263 resp.body.len()
1264 );
1265 server.join().unwrap();
1266 }
1267
1268 #[cfg(not(target_os = "wasi"))]
1269 #[test]
1270 fn a_json_post_sends_its_body_through_the_probe_path() {
1271 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1272 let addr = listener.local_addr().unwrap();
1273 let server = std::thread::spawn(move || {
1274 let (mut sock, _) = listener.accept().unwrap();
1275 sock.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
1276 let mut request = Vec::new();
1277 let mut tmp = [0u8; 1024];
1278 while !String::from_utf8_lossy(&request).ends_with("{\"a\":1}") {
1279 let n = sock.read(&mut tmp).unwrap();
1280 assert!(n > 0);
1281 request.extend_from_slice(&tmp[..n]);
1282 }
1283 sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}").unwrap();
1284 String::from_utf8_lossy(&request).into_owned()
1285 });
1286 let resp = http_post_json(&format!("http://{addr}/mcp"), "{\"a\":1}").unwrap();
1287 assert_eq!(resp.status, 200);
1288 assert_eq!(resp.body, b"{}");
1289 let request = server.join().unwrap();
1290 assert!(request.starts_with("POST /mcp HTTP/1.1\r\n"), "{request}");
1291 assert!(request.contains("\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: 7\r\n\r\n{\"a\":1}"), "{request}");
1292 }
1293
1294 #[cfg(not(target_os = "wasi"))]
1295 #[test]
1296 fn a_chunked_reply_left_open_returns_what_arrived_by_the_deadline() {
1297 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1298 let addr = listener.local_addr().unwrap();
1299 let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
1300 let server = std::thread::spawn(move || {
1301 let mut sock = accept_headers(listener);
1302 let event = b"data: {}\n\n";
1303 sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n").unwrap();
1304 sock.write_all(format!("{:x}\r\n", event.len()).as_bytes()).unwrap();
1305 sock.write_all(event).unwrap();
1306 sock.write_all(b"\r\n").unwrap();
1307 let _ = done_rx.recv_timeout(Duration::from_secs(20));
1308 });
1309 let started = Instant::now();
1310 let resp = http_post_json(&format!("http://{addr}/mcp"), "{}").unwrap();
1311 assert!(started.elapsed() < Duration::from_secs(8));
1312 assert_eq!(resp.body, b"data: {}\n\n");
1313 done_tx.send(()).unwrap();
1314 server.join().unwrap();
1315 }
1316
1317 #[cfg(not(target_os = "wasi"))]
1318 #[test]
1319 fn a_chunk_cut_short_and_left_open_returns_by_the_deadline() {
1320 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1321 let addr = listener.local_addr().unwrap();
1322 let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
1323 let server = std::thread::spawn(move || {
1324 let mut sock = accept_headers(listener);
1325 sock.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n10\r\nabc").unwrap();
1326 let _ = done_rx.recv_timeout(Duration::from_secs(20));
1327 });
1328 let started = Instant::now();
1329 let resp = http_probe(&format!("http://{addr}/x"), "*/*").unwrap();
1330 assert!(started.elapsed() < Duration::from_secs(8));
1331 assert!(resp.body.is_empty());
1332 done_tx.send(()).unwrap();
1333 server.join().unwrap();
1334 }
1335
1336 #[cfg(not(target_os = "wasi"))]
1337 #[test]
1338 fn a_json_post_refuses_non_http_urls() {
1339 assert!(http_post_json("ftp://example.com/mcp", "{}").is_err());
1340 }
1341
1342 #[cfg(not(target_os = "wasi"))]
1343 #[test]
1344 fn body_cap_close_delimited_truncates() {
1345 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1346 let addr = listener.local_addr().unwrap();
1347 let server = std::thread::spawn(move || {
1348 let mut sock = accept_headers(listener);
1349 sock.write_all(
1350 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n",
1351 )
1352 .unwrap();
1353 let chunk = vec![b'Z'; 256 * 1024];
1354 for _ in 0..20 {
1355 if sock.write_all(&chunk).is_err() {
1356 break;
1357 }
1358 }
1359 });
1360 let resp = http_probe(
1361 &format!("http://{addr}/.well-known/ard.json"),
1362 "application/json",
1363 )
1364 .unwrap();
1365 assert!(
1366 resp.body.len() <= PROBE_MAX_BODY,
1367 "close-delimited body must be capped, got {}",
1368 resp.body.len()
1369 );
1370 server.join().unwrap();
1371 }
1372
1373 #[cfg(not(target_os = "wasi"))]
1374 #[test]
1375 fn stalled_body_probe_returns_within_6s() {
1376 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1377 let addr = listener.local_addr().unwrap();
1378 let server = std::thread::spawn(move || {
1379 let mut sock = accept_headers(listener);
1380 sock.write_all(
1381 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1000\r\nConnection: close\r\n\r\n",
1382 )
1383 .unwrap();
1384 let _ = sock.write_all(&[b'A'; 10]);
1385 std::thread::sleep(Duration::from_secs(30));
1386 });
1387 let start = std::time::Instant::now();
1388 let _ = http_probe(
1389 &format!("http://{addr}/.well-known/ard.json"),
1390 "application/json",
1391 );
1392 let elapsed = start.elapsed();
1393 assert!(
1394 elapsed < Duration::from_secs(7),
1395 "stalled probe must return within 7s, took {elapsed:?}"
1396 );
1397 drop(server);
1398 }
1399}