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