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