Skip to main content

cdp_client/
net.rs

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}