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
7const READ_SLICE: Duration = Duration::from_millis(200);
8const CONNECTION_CLOSED: &str = "websocket connection closed\n";
9
10pub struct HttpResponse {
11    pub body: Vec<u8>,
12}
13
14pub struct ProbeResponse {
15    pub status: u16,
16    pub headers: Vec<(String, String)>,
17    pub body: Vec<u8>,
18}
19
20impl ProbeResponse {
21    pub fn content_type(&self) -> &str {
22        self.headers
23            .iter()
24            .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
25            .map(|(_, value)| value.as_str())
26            .unwrap_or("")
27    }
28}
29
30pub struct Ws {
31    stream: Box<dyn ReadWrite>,
32    buf: Vec<u8>,
33    fragments: Vec<u8>,
34    kind: u8,
35    closed: Option<String>,
36}
37
38trait ReadWrite: Read + Write {
39    fn set_nonblocking(&mut self, on: bool) -> std::io::Result<()>;
40    fn read_timeout(&self) -> std::io::Result<Option<Duration>>;
41    fn set_read_timeout(&mut self, timeout: Option<Duration>) -> std::io::Result<()>;
42}
43
44impl ReadWrite for TcpStream {
45    fn set_nonblocking(&mut self, on: bool) -> std::io::Result<()> {
46        TcpStream::set_nonblocking(self, on)
47    }
48
49    fn read_timeout(&self) -> std::io::Result<Option<Duration>> {
50        TcpStream::read_timeout(self)
51    }
52
53    fn set_read_timeout(&mut self, timeout: Option<Duration>) -> std::io::Result<()> {
54        TcpStream::set_read_timeout(self, timeout)
55    }
56}
57
58#[cfg(not(target_os = "wasi"))]
59impl ReadWrite for native_tls::TlsStream<TcpStream> {
60    fn set_nonblocking(&mut self, on: bool) -> std::io::Result<()> {
61        self.get_mut().set_nonblocking(on)
62    }
63
64    fn read_timeout(&self) -> std::io::Result<Option<Duration>> {
65        self.get_ref().read_timeout()
66    }
67
68    fn set_read_timeout(&mut self, timeout: Option<Duration>) -> std::io::Result<()> {
69        self.get_ref().set_read_timeout(timeout)
70    }
71}
72
73struct Parts {
74    scheme: String,
75    host: String,
76    port: u16,
77    path: String,
78}
79
80pub struct EndpointUrl {
81    pub scheme: String,
82    pub host: String,
83    pub port: u16,
84}
85
86pub fn parse_endpoint(url: &str) -> Result<EndpointUrl, String> {
87    let parts = parse_url(url)?;
88    Ok(EndpointUrl {
89        scheme: parts.scheme,
90        host: parts.host,
91        port: parts.port,
92    })
93}
94
95pub fn http_get(url: &str) -> Result<HttpResponse, String> {
96    let parts = parse_url(url)?;
97    if parts.scheme != "http" && parts.scheme != "https" {
98        return Err(format!("discovery URL must be http or https: {url}\n"));
99    }
100    let mut stream = dial(&parts)?;
101    let host_header = host_header(&parts);
102    let request = format!(
103        "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept: application/json\r\n\r\n",
104        parts.path, host_header
105    );
106    stream
107        .write_all(request.as_bytes())
108        .map_err(|err| format!("CDP discovery failed for {url}: {err}\n"))?;
109    let (status, _headers, body, _rest) = read_http(stream.as_mut())?;
110    if !(200..300).contains(&status) {
111        return Err(format_http_error(status, &body));
112    }
113    let _ = status;
114    Ok(HttpResponse { body })
115}
116
117const PROBE_MAX_BODY: usize = 1024 * 1024;
118
119#[cfg(not(target_os = "wasi"))]
120pub fn http_probe(url: &str, accept: &str) -> Result<ProbeResponse, String> {
121    probe_request("GET", url, &format!("Accept: {accept}\r\n"), "")
122}
123
124#[cfg(not(target_os = "wasi"))]
125pub fn http_post_json(url: &str, body: &str) -> Result<ProbeResponse, String> {
126    let headers = format!(
127        "Content-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {}\r\n",
128        body.len()
129    );
130    probe_request("POST", url, &headers, body)
131}
132
133#[cfg(not(target_os = "wasi"))]
134fn probe_request(method: &str, url: &str, headers: &str, body: &str) -> Result<ProbeResponse, String> {
135    exchange(
136        Exchange {
137            label: "probe",
138            method,
139            headers,
140            body,
141            cap: PROBE_MAX_BODY,
142            connect: Duration::from_secs(3),
143            read: Duration::from_secs(1),
144            total: Duration::from_secs(5),
145        },
146        url,
147    )
148}
149
150#[cfg(not(target_os = "wasi"))]
151pub fn http_fetch(url: &str, headers: &[(&str, &str)], cap: usize, total: Duration) -> Result<ProbeResponse, String> {
152    let mut lines = String::new();
153    for (name, value) in headers {
154        if [name, value].iter().any(|text| text.contains(['\r', '\n'])) {
155            return Err(format!("bad header {name}\n"));
156        }
157        lines.push_str(&format!("{name}: {value}\r\n"));
158    }
159    let response = exchange(
160        Exchange {
161            label: "download",
162            method: "GET",
163            headers: &lines,
164            body: "",
165            cap: cap.saturating_add(1),
166            connect: Duration::from_secs(10),
167            read: Duration::from_secs(30),
168            total,
169        },
170        url,
171    )?;
172    if response.body.len() > cap {
173        return Err(format!("{url} is larger than {cap} bytes\n"));
174    }
175    Ok(response)
176}
177
178#[cfg(not(target_os = "wasi"))]
179struct Exchange<'a> {
180    label: &'a str,
181    method: &'a str,
182    headers: &'a str,
183    body: &'a str,
184    cap: usize,
185    connect: Duration,
186    read: Duration,
187    total: Duration,
188}
189
190#[cfg(not(target_os = "wasi"))]
191fn exchange(spec: Exchange<'_>, url: &str) -> Result<ProbeResponse, String> {
192    let Exchange { label, method, headers, body, cap, connect, read, total } = spec;
193    let parts = parse_url(url)?;
194    if parts.scheme != "http" && parts.scheme != "https" {
195        return Err(format!("{label} URL must be http or https: {url}\n"));
196    }
197    refuse_real_debug_port(&parts);
198    let addrs = candidates(&parts.host, parts.port)?;
199    let deadline = Instant::now() + total;
200    let tcp = connect_any(&addrs, connect, Some(deadline))
201        .map_err(|err| format!("{label} connect failed for {url}: {err}\n"))?;
202    tcp.set_read_timeout(Some(read))
203        .map_err(|err| format!("{label} timeout setup failed: {err}\n"))?;
204    tcp.set_nodelay(true).ok();
205    let mut stream: Box<dyn ReadWrite> = if parts.scheme == "https" {
206        wrap_tls(&parts.host, tcp)?
207    } else {
208        Box::new(tcp)
209    };
210    let host_hdr = host_header(&parts);
211    let request = format!(
212        "{method} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n{headers}\r\n{body}",
213        parts.path, host_hdr
214    );
215    stream
216        .write_all(request.as_bytes())
217        .map_err(|err| format!("{label} write failed for {url}: {err}\n"))?;
218    if Instant::now() >= deadline {
219        return Err(format!("{label} deadline exceeded for {url}\n"));
220    }
221    let remaining = deadline.saturating_duration_since(Instant::now());
222    tcp_set_read_deadline(stream.as_mut(), remaining);
223    let (status, headers, body, _rest) = read_http_capped(stream.as_mut(), Some(cap), Some(deadline))?;
224    Ok(ProbeResponse {
225        status,
226        headers,
227        body,
228    })
229}
230
231#[cfg(target_os = "wasi")]
232pub fn http_fetch(_url: &str, _headers: &[(&str, &str)], _cap: usize, _total: Duration) -> Result<ProbeResponse, String> {
233    Err("http_fetch not available on wasi\n".to_string())
234}
235
236#[cfg(target_os = "wasi")]
237pub fn http_probe(_url: &str, _accept: &str) -> Result<ProbeResponse, String> {
238    Err("probes are not available in this build\n".to_string())
239}
240
241#[cfg(target_os = "wasi")]
242pub fn http_post_json(_url: &str, _body: &str) -> Result<ProbeResponse, String> {
243    Err("http_post_json not available on wasi\n".to_string())
244}
245
246pub fn ws_connect(url: &str) -> Result<Ws, String> {
247    let parts = parse_url(url)?;
248    if parts.scheme != "ws" && parts.scheme != "wss" {
249        return Err(format!("WebSocket URL must be ws or wss: {url}\n"));
250    }
251    let mut stream = dial(&parts)?;
252    let key = ws_key();
253    let host_header = host_header(&parts);
254    let request = format!(
255        "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",
256        parts.path, host_header, key
257    );
258    stream
259        .write_all(request.as_bytes())
260        .map_err(|err| connect_error(&parts, &err.to_string()))?;
261    let (status, headers, body, rest) = read_http(stream.as_mut())?;
262    if status != 101 {
263        return Err(format_http_error(status, &body));
264    }
265    let accept = headers
266        .iter()
267        .find(|(name, _)| name.eq_ignore_ascii_case("upgrade"))
268        .map(|(_, value)| value.clone())
269        .unwrap_or_default();
270    if !accept.to_ascii_lowercase().contains("websocket") && !accept.is_empty() {
271        return Err(format_http_error(status, &body));
272    }
273    Ok(Ws {
274        stream,
275        buf: rest,
276        fragments: Vec::new(),
277        kind: 0,
278        closed: None,
279    })
280}
281
282impl Ws {
283    pub fn send_text(&mut self, text: &str) -> Result<(), String> {
284        self.note_server_close()?;
285        let frame = encode_client_frame(0x1, text.as_bytes(), mask_key(text.len()));
286        self.stream
287            .write_all(&frame)
288            .map_err(|err| format!("websocket write failed: {err}\n"))?;
289        self.stream
290            .flush()
291            .map_err(|err| format!("websocket write failed: {err}\n"))?;
292        Ok(())
293    }
294
295    fn latch(&mut self, message: String) -> String {
296        if connection_closed(&message) {
297            self.closed.get_or_insert(message).clone()
298        } else {
299            message
300        }
301    }
302
303    fn note_server_close(&mut self) -> Result<(), String> {
304        if let Some(closed) = &self.closed {
305            return Err(closed.clone());
306        }
307        let drained = self.drain_server_close();
308        drained.map_err(|message| self.latch(message))
309    }
310
311    fn drain_server_close(&mut self) -> Result<(), String> {
312        if let Some(message) = buffered_close(&self.buf) {
313            return Err(message);
314        }
315        let timeout = self.stream.read_timeout()
316            .map_err(|err| format!("websocket timeout setup failed: {err}\n"))?;
317        self.stream.set_nonblocking(true)
318            .map_err(|err| format!("websocket nonblocking setup failed: {err}\n"))?;
319        if let Err(err) = self.stream.set_read_timeout(None) {
320            let _ = self.stream.set_nonblocking(false);
321            return Err(format!("websocket timeout setup failed: {err}\n"));
322        }
323        let deadline = Instant::now() + Duration::from_millis(2);
324        let mut drained = 0;
325        let mut tmp = [0u8; 8192];
326        let mut failed = None;
327        while drained < 65536 && Instant::now() < deadline {
328            match self.stream.read(&mut tmp) {
329                Ok(0) => {
330                    failed = Some(
331                        buffered_close(&self.buf)
332                            .unwrap_or_else(|| CONNECTION_CLOSED.to_string()),
333                    );
334                    break;
335                }
336                Ok(n) => {
337                    self.buf.extend_from_slice(&tmp[..n]);
338                    drained += n;
339                    if let Some(message) = buffered_close(&self.buf) {
340                        failed = Some(message);
341                        break;
342                    }
343                }
344                Err(err)
345                    if err.kind() == std::io::ErrorKind::WouldBlock
346                        || err.kind() == std::io::ErrorKind::TimedOut
347                        || err.kind() == std::io::ErrorKind::Interrupted =>
348                {
349                    break;
350                }
351                Err(err) => {
352                    failed = Some(
353                        buffered_close(&self.buf)
354                            .unwrap_or_else(|| format!("websocket read failed: {err}\n")),
355                    );
356                    break;
357                }
358            }
359        }
360        let restored_timeout = self.stream.set_read_timeout(timeout);
361        let restored_blocking = self.stream.set_nonblocking(false);
362        restored_timeout.map_err(|err| format!("websocket timeout restore failed: {err}\n"))?;
363        restored_blocking.map_err(|err| format!("websocket nonblocking restore failed: {err}\n"))?;
364        if let Some(message) = failed {
365            return Err(message);
366        }
367        if let Some(message) = buffered_close(&self.buf) {
368            return Err(message);
369        }
370        Ok(())
371    }
372
373    pub fn recv_text(&mut self, deadline: std::time::Instant) -> Result<Option<String>, String> {
374        if let Some(closed) = &self.closed {
375            return Err(closed.clone());
376        }
377        let received = self.receive_text(deadline);
378        received.map_err(|message| self.latch(message))
379    }
380
381    fn receive_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
382        loop {
383            let remaining = deadline.saturating_duration_since(Instant::now());
384            if remaining.is_zero() {
385                return Ok(None);
386            }
387            self.stream
388                .set_read_timeout(Some(remaining.clamp(Duration::from_millis(1), READ_SLICE)))
389                .map_err(|err| format!("websocket timeout setup failed: {err}\n"))?;
390            let frame = match next_frame(&mut self.buf, self.stream.as_mut())? {
391                Some(frame) => frame,
392                None => continue,
393            };
394            match frame.opcode {
395                0x1 | 0x2 => {
396                    self.kind = frame.opcode;
397                    if frame.fin {
398                        if frame.opcode == 0x2 {
399                            return Err("unexpected binary websocket frame\n".to_string());
400                        }
401                        return Ok(Some(
402                            String::from_utf8(frame.payload)
403                                .map_err(|err| format!("websocket text: {err}\n"))?,
404                        ));
405                    }
406                    self.fragments = frame.payload;
407                }
408                0x0 => {
409                    self.fragments.extend_from_slice(&frame.payload);
410                    if frame.fin {
411                        if self.kind == 0x2 {
412                            return Err("unexpected binary websocket frame\n".to_string());
413                        }
414                        return Ok(Some(
415                            String::from_utf8(std::mem::take(&mut self.fragments))
416                                .map_err(|err| format!("websocket text: {err}\n"))?,
417                        ));
418                    }
419                }
420                0x8 => return Err(close_message(&frame.payload)),
421                0x9 => {
422                    self.note_server_close()?;
423                    let pong = encode_client_frame(0xA, &frame.payload, mask_key(frame.payload.len()));
424                    self.stream
425                        .write_all(&pong)
426                        .map_err(|err| format!("websocket pong failed: {err}\n"))?;
427                }
428                0xA => {}
429                other => return Err(format!("unsupported websocket opcode {other}\n")),
430            }
431        }
432    }
433}
434
435struct Frame {
436    opcode: u8,
437    fin: bool,
438    payload: Vec<u8>,
439}
440
441fn next_frame(buf: &mut Vec<u8>, stream: &mut dyn ReadWrite) -> Result<Option<Frame>, String> {
442    if let Some((consumed, frame)) = try_decode(buf)? {
443        buf.drain(..consumed);
444        return Ok(Some(frame));
445    }
446    let mut tmp = [0u8; 8192];
447    match stream.read(&mut tmp) {
448        Ok(0) => Err(CONNECTION_CLOSED.to_string()),
449        Ok(n) => {
450            buf.extend_from_slice(&tmp[..n]);
451            if let Some((consumed, frame)) = try_decode(buf)? {
452                buf.drain(..consumed);
453                Ok(Some(frame))
454            } else {
455                Ok(None)
456            }
457        }
458        Err(err)
459            if err.kind() == std::io::ErrorKind::TimedOut
460                || err.kind() == std::io::ErrorKind::WouldBlock =>
461        {
462            Ok(None)
463        }
464        Err(err) => Err(format!("websocket read failed: {err}\n")),
465    }
466}
467
468fn try_decode(buf: &[u8]) -> Result<Option<(usize, Frame)>, String> {
469    if buf.len() < 2 {
470        return Ok(None);
471    }
472    let b0 = buf[0];
473    let b1 = buf[1];
474    let fin = b0 & 0x80 != 0;
475    let opcode = b0 & 0x0f;
476    let masked = b1 & 0x80 != 0;
477    let mut len = (b1 & 0x7f) as u64;
478    let mut offset = 2usize;
479    if len == 126 {
480        if buf.len() < 4 {
481            return Ok(None);
482        }
483        len = u16::from_be_bytes([buf[2], buf[3]]) as u64;
484        offset = 4;
485    } else if len == 127 {
486        if buf.len() < 10 {
487            return Ok(None);
488        }
489        len = u64::from_be_bytes(buf[2..10].try_into().unwrap());
490        offset = 10;
491    }
492    let mask = if masked {
493        if buf.len() < offset + 4 {
494            return Ok(None);
495        }
496        let mask = [buf[offset], buf[offset + 1], buf[offset + 2], buf[offset + 3]];
497        offset += 4;
498        Some(mask)
499    } else {
500        None
501    };
502    let len_usize = usize::try_from(len).map_err(|_| "websocket frame too large\n".to_string())?;
503    if buf.len() < offset + len_usize {
504        return Ok(None);
505    }
506    let mut payload = buf[offset..offset + len_usize].to_vec();
507    if let Some(mask) = mask {
508        for (i, byte) in payload.iter_mut().enumerate() {
509            *byte ^= mask[i % 4];
510        }
511    }
512    Ok(Some((
513        offset + len_usize,
514        Frame {
515            opcode,
516            fin,
517            payload,
518        },
519    )))
520}
521
522pub fn encode_client_frame(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
523    let mut out = Vec::new();
524    out.push(0x80 | opcode);
525    let len = payload.len();
526    if len < 126 {
527        out.push(0x80 | len as u8);
528    } else if len <= u16::MAX as usize {
529        out.push(0x80 | 126);
530        out.extend_from_slice(&(len as u16).to_be_bytes());
531    } else {
532        out.push(0x80 | 127);
533        out.extend_from_slice(&(len as u64).to_be_bytes());
534    }
535    out.extend_from_slice(&mask);
536    for (i, byte) in payload.iter().enumerate() {
537        out.push(byte ^ mask[i % 4]);
538    }
539    out
540}
541
542pub(crate) fn connection_closed(message: &str) -> bool {
543    message == CONNECTION_CLOSED || message.starts_with("websocket closed")
544}
545
546fn buffered_close(buf: &[u8]) -> Option<String> {
547    let mut offset = 0;
548    while offset < buf.len() {
549        match try_decode(&buf[offset..]) {
550            Ok(Some((consumed, frame))) => {
551                if frame.opcode == 0x8 {
552                    return Some(close_message(&frame.payload));
553                }
554                offset += consumed;
555            }
556            _ => return None,
557        }
558    }
559    None
560}
561
562fn close_message(payload: &[u8]) -> String {
563    if payload.len() < 2 {
564        return "websocket closed\n".to_string();
565    }
566    let code = u16::from_be_bytes([payload[0], payload[1]]);
567    let reason = String::from_utf8_lossy(&payload[2..]);
568    if reason.is_empty() {
569        format!("websocket closed {code}\n")
570    } else {
571        format!("websocket closed {code} {reason}\n")
572    }
573}
574
575fn mask_key(seed: usize) -> [u8; 4] {
576    let n = seed as u32 ^ 0xA5A5_5A5A;
577    n.to_le_bytes()
578}
579
580fn ws_key() -> String {
581    let ticks = std::time::SystemTime::now()
582        .duration_since(std::time::UNIX_EPOCH)
583        .map(|d| d.as_nanos())
584        .unwrap_or(1);
585    let mut bytes = [0u8; 16];
586    let mut x = ticks ^ 0x9E37_79B9_7F4A_7C15;
587    for (i, byte) in bytes.iter_mut().enumerate() {
588        x = x
589            .wrapping_mul(0x6C62_272E_07BB_0142)
590            .wrapping_add(i as u128 + 1);
591        *byte = (x >> 11) as u8;
592    }
593    base64_encode(&bytes)
594}
595
596pub fn base64_encode(data: &[u8]) -> String {
597    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
598    let mut out = String::new();
599    let mut i = 0;
600    while i + 3 <= data.len() {
601        let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | data[i + 2] as u32;
602        out.push(TABLE[((n >> 18) & 63) as usize] as char);
603        out.push(TABLE[((n >> 12) & 63) as usize] as char);
604        out.push(TABLE[((n >> 6) & 63) as usize] as char);
605        out.push(TABLE[(n & 63) as usize] as char);
606        i += 3;
607    }
608    if i < data.len() {
609        let rest = data.len() - i;
610        let mut n = (data[i] as u32) << 16;
611        if rest == 2 {
612            n |= (data[i + 1] as u32) << 8;
613        }
614        out.push(TABLE[((n >> 18) & 63) as usize] as char);
615        out.push(TABLE[((n >> 12) & 63) as usize] as char);
616        if rest == 2 {
617            out.push(TABLE[((n >> 6) & 63) as usize] as char);
618            out.push('=');
619        } else {
620            out.push('=');
621            out.push('=');
622        }
623    }
624    out
625}
626
627pub fn base64_decode(data: &str) -> Result<Vec<u8>, String> {
628    fn val(byte: u8) -> Result<u8, String> {
629        match byte {
630            b'A'..=b'Z' => Ok(byte - b'A'),
631            b'a'..=b'z' => Ok(byte - b'a' + 26),
632            b'0'..=b'9' => Ok(byte - b'0' + 52),
633            b'+' => Ok(62),
634            b'/' => Ok(63),
635            _ => Err("invalid base64\n".to_string()),
636        }
637    }
638    let bytes: Vec<u8> = data
639        .bytes()
640        .filter(|b| !b.is_ascii_whitespace())
641        .collect();
642    if bytes.len() % 4 != 0 {
643        return Err("invalid base64\n".to_string());
644    }
645    let mut out = Vec::new();
646    for chunk in bytes.chunks(4) {
647        let pad = chunk.iter().filter(|b| **b == b'=').count();
648        let a = val(chunk[0])?;
649        let b = val(chunk[1])?;
650        let c = if chunk[2] == b'=' { 0 } else { val(chunk[2])? };
651        let d = if chunk[3] == b'=' { 0 } else { val(chunk[3])? };
652        let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | d as u32;
653        out.push((n >> 16) as u8);
654        if pad < 2 {
655            out.push((n >> 8) as u8);
656        }
657        if pad < 1 {
658            out.push(n as u8);
659        }
660    }
661    Ok(out)
662}
663
664fn parse_url(url: &str) -> Result<Parts, String> {
665    let (scheme, rest) = url
666        .split_once("://")
667        .ok_or_else(|| format!("unsupported CDP URL \"{url}\"\n"))?;
668    let scheme = scheme.to_ascii_lowercase();
669    let cut = rest.find(['/', '?', '#']).unwrap_or(rest.len());
670    let authority = &rest[..cut];
671    let path: String = if cut == rest.len() || rest.as_bytes()[cut] == b'#' {
672        "/".to_string()
673    } else if rest.as_bytes()[cut] == b'/' {
674        rest[cut..].to_string()
675    } else {
676        format!("/{}", &rest[cut..])
677    };
678    let hostport = match authority.rsplit_once('@') {
679        Some((_, host)) => host,
680        None => authority,
681    };
682    if hostport.is_empty() {
683        return Err(format!("bad CDP URL \"{url}\"\n"));
684    }
685    let (host, port_text) = if let Some(host) = hostport.strip_prefix('[') {
686        let end = host
687            .find(']')
688            .ok_or_else(|| format!("bad CDP URL \"{url}\"\n"))?;
689        let host = &host[..end];
690        let after = &hostport[end + 2..];
691        if !after.is_empty() && !after.starts_with(':') {
692            return Err(format!("bad CDP URL \"{url}\"\n"));
693        }
694        let port = after.strip_prefix(':');
695        (host.to_string(), port.map(str::to_string))
696    } else if let Some((host, port)) = hostport.rsplit_once(':') {
697        if host.contains(':') {
698            (hostport.to_string(), None)
699        } else if port.is_empty() {
700            return Err(format!("bad port in CDP URL \"{url}\"\n"));
701        } else {
702            (host.to_string(), Some(port.to_string()))
703        }
704    } else {
705        (hostport.to_string(), None)
706    };
707    let default_port = match scheme.as_str() {
708        "https" | "wss" => 443,
709        _ => 80,
710    };
711    let port = match port_text {
712        Some(text) => text
713            .parse::<u16>()
714            .map_err(|_| format!("bad port in CDP URL \"{url}\"\n"))?,
715        None => default_port,
716    };
717    if host.is_empty() {
718        return Err(format!("bad CDP URL \"{url}\"\n"));
719    }
720    let path = if path.is_empty() { "/".to_string() } else { path };
721    Ok(Parts {
722        scheme,
723        host,
724        port,
725        path,
726    })
727}
728
729fn host_header(parts: &Parts) -> String {
730    let default = matches!(
731        (parts.scheme.as_str(), parts.port),
732        ("http" | "ws", 80) | ("https" | "wss", 443)
733    );
734    if default {
735        parts.host.clone()
736    } else if parts.host.contains(':') {
737        format!("[{}]:{}", parts.host, parts.port)
738    } else {
739        format!("{}:{}", parts.host, parts.port)
740    }
741}
742
743fn connect_error(parts: &Parts, err: &str) -> String {
744    format!(
745        "CDP connection to {}:{} failed: {err}. This CLI does not launch Chrome. Pass --cdp or set SLICC_CDP_URL.\n",
746        parts.host, parts.port
747    )
748}
749
750fn dial(parts: &Parts) -> Result<Box<dyn ReadWrite>, String> {
751    refuse_real_debug_port(parts);
752    let addrs = candidates(&parts.host, parts.port)?;
753    let tcp = connect_any(&addrs, Duration::from_secs(10), None)
754        .map_err(|err| connect_error(parts, &err.to_string()))?;
755    tcp.set_read_timeout(Some(READ_SLICE))
756        .map_err(|err| connect_error(parts, &err.to_string()))?;
757    tcp.set_nodelay(true).ok();
758    if parts.scheme == "https" || parts.scheme == "wss" {
759        wrap_tls(&parts.host, tcp)
760    } else {
761        Ok(Box::new(tcp))
762    }
763}
764
765fn refuse_real_debug_port(parts: &Parts) {
766    if (cfg!(test) || crate::ports::testing()) && crate::ports::real_debug_port(&parts.host, parts.port) {
767        panic!("test dialed {}:{}", parts.host, parts.port);
768    }
769}
770
771fn refuse_real_debug_addr(addr: SocketAddr) {
772    if (cfg!(test) || crate::ports::testing()) && crate::ports::real_debug_port(&addr.ip().to_string(), addr.port()) {
773        panic!("test dialed {addr}");
774    }
775}
776
777fn tcp_set_read_deadline(_stream: &mut dyn ReadWrite, _remaining: Duration) {}
778
779const LOOPBACK_V6: std::net::IpAddr = std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST);
780const LOOPBACK_V4: std::net::IpAddr = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
781const PER_ADDRESS: Duration = Duration::from_secs(2);
782
783fn candidates(host: &str, port: u16) -> Result<Vec<SocketAddr>, String> {
784    let resolved = wasix_net::resolve(host, port);
785    let addrs = expand_loopback(host, port, resolved.as_deref().unwrap_or(&[]));
786    for addr in &addrs {
787        refuse_real_debug_addr(*addr);
788    }
789    if addrs.is_empty() {
790        let reason = resolved.err().map(|err| format!(": {err}")).unwrap_or_default();
791        return Err(format!("could not resolve {host}{reason}\n"));
792    }
793    Ok(addrs)
794}
795
796fn loopback_alias(host: &str) -> bool {
797    let host = host.trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase();
798    let host = host.strip_suffix('.').unwrap_or(&host);
799    matches!(host, "localhost" | "127.0.0.1" | "::1" | "0.0.0.0" | "::")
800}
801
802fn expand_loopback(host: &str, port: u16, resolved: &[SocketAddr]) -> Vec<SocketAddr> {
803    let alias = loopback_alias(host);
804    let mut out: Vec<SocketAddr> = Vec::new();
805    let mut push = |addr: SocketAddr| {
806        if !out.contains(&addr) {
807            out.push(addr);
808        }
809    };
810    for addr in resolved {
811        let ip = match addr.ip() {
812            std::net::IpAddr::V4(ip) if alias && ip.is_unspecified() => LOOPBACK_V4,
813            std::net::IpAddr::V6(ip) if alias && ip.is_unspecified() => LOOPBACK_V6,
814            ip => ip,
815        };
816        push(SocketAddr::new(ip, addr.port()));
817    }
818    if alias {
819        push(SocketAddr::new(LOOPBACK_V6, port));
820        push(SocketAddr::new(LOOPBACK_V4, port));
821    }
822    out
823}
824
825fn connect_any(
826    addrs: &[SocketAddr],
827    single: Duration,
828    deadline: Option<Instant>,
829) -> std::io::Result<TcpStream> {
830    let mut last = None;
831    for (index, addr) in addrs.iter().enumerate() {
832        let each = if index + 1 < addrs.len() { PER_ADDRESS.min(single) } else { single };
833        let timeout = match deadline {
834            Some(deadline) => each.min(deadline.saturating_duration_since(Instant::now())),
835            None => each,
836        };
837        if timeout.is_zero() {
838            break;
839        }
840        match TcpStream::connect_timeout(addr, timeout) {
841            Ok(tcp) => return Ok(tcp),
842            Err(err) => last = Some(err),
843        }
844    }
845    Err(last.unwrap_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "connect deadline exceeded")))
846}
847
848#[cfg(not(target_os = "wasi"))]
849fn wrap_tls(host: &str, tcp: TcpStream) -> Result<Box<dyn ReadWrite>, String> {
850    let connector = native_tls::TlsConnector::new()
851        .map_err(|err| format!("TLS setup failed: {err}\n"))?;
852    let stream = connector
853        .connect(host, tcp)
854        .map_err(|err| format!("TLS handshake with {host} failed: {err}\n"))?;
855    Ok(Box::new(stream))
856}
857
858#[cfg(target_os = "wasi")]
859fn wrap_tls(_host: &str, _tcp: TcpStream) -> Result<Box<dyn ReadWrite>, String> {
860    Err("wss and https are not available in this build\n".to_string())
861}
862
863fn read_http(stream: &mut dyn ReadWrite) -> Result<(u16, Vec<(String, String)>, Vec<u8>, Vec<u8>), String> {
864    read_http_capped(stream, None, None)
865}
866
867fn read_http_capped(
868    stream: &mut dyn ReadWrite,
869    body_cap: Option<usize>,
870    deadline: Option<Instant>,
871) -> Result<(u16, Vec<(String, String)>, Vec<u8>, Vec<u8>), String> {
872    let mut buf = Vec::new();
873    let header_end = loop {
874        if deadline.is_some_and(|d| Instant::now() >= d) {
875            return Err("probe deadline exceeded\n".to_string());
876        }
877        if let Some(pos) = find_header_end(&buf) {
878            break pos;
879        }
880        let mut tmp = [0u8; 4096];
881        match stream.read(&mut tmp) {
882            Ok(0) => {
883                return Err("HTTP connection closed before headers\n".to_string());
884            }
885            Ok(n) => buf.extend_from_slice(&tmp[..n]),
886            Err(err)
887                if err.kind() == std::io::ErrorKind::TimedOut
888                    || err.kind() == std::io::ErrorKind::WouldBlock =>
889            {
890                if buf.is_empty() {
891                    continue;
892                }
893                return Err("timed out reading HTTP headers\n".to_string());
894            }
895            Err(err) => return Err(format!("HTTP read failed: {err}\n")),
896        }
897        if buf.len() > 1024 * 1024 {
898            return Err("HTTP headers too large\n".to_string());
899        }
900    };
901    let header_bytes = buf[..header_end].to_vec();
902    let mut rest = buf[header_end + 4..].to_vec();
903    let header_text = String::from_utf8_lossy(&header_bytes);
904    let mut lines = header_text.split("\r\n");
905    let status_line = lines.next().unwrap_or("");
906    let status = status_line
907        .split_whitespace()
908        .nth(1)
909        .and_then(|text| text.parse::<u16>().ok())
910        .ok_or_else(|| format!("bad HTTP status line: {status_line}\n"))?;
911    let mut headers = Vec::new();
912    for line in lines {
913        if let Some((name, value)) = line.split_once(':') {
914            headers.push((name.trim().to_string(), value.trim().to_string()));
915        }
916    }
917    let content_length = headers
918        .iter()
919        .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
920        .and_then(|(_, value)| value.parse::<usize>().ok());
921    let chunked = headers.iter().any(|(name, value)| {
922        name.eq_ignore_ascii_case("transfer-encoding") && value.to_ascii_lowercase().contains("chunked")
923    });
924    let close = headers.iter().any(|(name, value)| {
925        name.eq_ignore_ascii_case("connection") && value.eq_ignore_ascii_case("close")
926    });
927    if status == 101 && !chunked && content_length.is_none() {
928        return Ok((status, headers, Vec::new(), rest));
929    }
930    let body = if chunked {
931        read_chunked_capped(&mut rest, stream, body_cap, deadline)?
932    } else if let Some(len) = content_length {
933        if let Some(cap) = body_cap {
934            if len > cap {
935                return Err("HTTP body exceeds cap\n".to_string());
936            }
937        }
938        let limit = body_cap.map(|c| c.min(len)).unwrap_or(len);
939        while rest.len() < limit {
940            if deadline.is_some_and(|d| Instant::now() >= d) {
941                return Err("probe deadline exceeded\n".to_string());
942            }
943            let mut tmp = [0u8; 4096];
944            match stream.read(&mut tmp) {
945                Ok(0) => break,
946                Ok(n) => rest.extend_from_slice(&tmp[..n]),
947                Err(err)
948                    if err.kind() == std::io::ErrorKind::TimedOut
949                        || err.kind() == std::io::ErrorKind::WouldBlock =>
950                {
951                    if deadline.is_some_and(|d| Instant::now() >= d) {
952                        return Err("probe deadline exceeded\n".to_string());
953                    }
954                    continue;
955                }
956                Err(err) => return Err(format!("HTTP body read failed: {err}\n")),
957            }
958        }
959        let body = rest[..limit.min(rest.len())].to_vec();
960        let extra = if status == 101 {
961            rest[limit.min(rest.len())..].to_vec()
962        } else {
963            Vec::new()
964        };
965        return Ok((status, headers, body, extra));
966    } else {
967        let close_cap = body_cap.unwrap_or(8 * 1024 * 1024);
968        loop {
969            if deadline.is_some_and(|d| Instant::now() >= d) {
970                break;
971            }
972            let mut tmp = [0u8; 4096];
973            match stream.read(&mut tmp) {
974                Ok(0) => break,
975                Ok(n) => rest.extend_from_slice(&tmp[..n]),
976                Err(err)
977                    if err.kind() == std::io::ErrorKind::TimedOut
978                        || err.kind() == std::io::ErrorKind::WouldBlock =>
979                {
980                    break;
981                }
982                Err(err) => return Err(format!("HTTP body read failed: {err}\n")),
983            }
984            if rest.len() > close_cap {
985                break;
986            }
987        }
988        rest
989    };
990    let _ = close;
991    let body = if let Some(cap) = body_cap {
992        if body.len() > cap { body[..cap].to_vec() } else { body }
993    } else {
994        body
995    };
996    Ok((status, headers, body, Vec::new()))
997}
998
999#[allow(dead_code)]
1000fn read_chunked(rest: &mut Vec<u8>, stream: &mut dyn ReadWrite) -> Result<Vec<u8>, String> {
1001    read_chunked_capped(rest, stream, None, None)
1002}
1003
1004fn read_chunked_capped(
1005    rest: &mut Vec<u8>,
1006    stream: &mut dyn ReadWrite,
1007    cap: Option<usize>,
1008    deadline: Option<Instant>,
1009) -> Result<Vec<u8>, String> {
1010    let mut body = Vec::new();
1011    loop {
1012        if deadline.is_some_and(|d| Instant::now() >= d) {
1013            return Ok(body);
1014        }
1015        let line_end = loop {
1016            if let Some(pos) = rest.windows(2).position(|w| w == b"\r\n") {
1017                break pos;
1018            }
1019            let mut tmp = [0u8; 2048];
1020            match stream.read(&mut tmp) {
1021                Ok(0) => return Err("chunked body ended early\n".to_string()),
1022                Ok(n) => rest.extend_from_slice(&tmp[..n]),
1023                Err(err)
1024                    if err.kind() == std::io::ErrorKind::TimedOut
1025                        || err.kind() == std::io::ErrorKind::WouldBlock =>
1026                {
1027                    if deadline.is_some_and(|d| Instant::now() >= d) {
1028                        return Ok(body);
1029                    }
1030                    continue;
1031                }
1032                Err(err) => return Err(format!("chunked read failed: {err}\n")),
1033            }
1034        };
1035        let line = String::from_utf8_lossy(&rest[..line_end]).to_string();
1036        rest.drain(..line_end + 2);
1037        let size_text = line.split(';').next().unwrap_or("").trim();
1038        let size = usize::from_str_radix(size_text, 16)
1039            .map_err(|_| format!("bad chunk size {size_text}\n"))?;
1040        if size == 0 {
1041            return Ok(body);
1042        }
1043        while rest.len() < size + 2 {
1044            let mut tmp = [0u8; 4096];
1045            match stream.read(&mut tmp) {
1046                Ok(0) => return Err("chunked body ended early\n".to_string()),
1047                Ok(n) => rest.extend_from_slice(&tmp[..n]),
1048                Err(err)
1049                    if err.kind() == std::io::ErrorKind::TimedOut
1050                        || err.kind() == std::io::ErrorKind::WouldBlock =>
1051                {
1052                    if deadline.is_some_and(|d| Instant::now() >= d) {
1053                        return Ok(body);
1054                    }
1055                    continue;
1056                }
1057                Err(err) => return Err(format!("chunked read failed: {err}\n")),
1058            }
1059        }
1060        body.extend_from_slice(&rest[..size]);
1061        rest.drain(..size + 2);
1062        if let Some(limit) = cap {
1063            if body.len() > limit {
1064                body.truncate(limit);
1065                return Ok(body);
1066            }
1067        }
1068    }
1069}
1070
1071fn find_header_end(buf: &[u8]) -> Option<usize> {
1072    buf.windows(4).position(|w| w == b"\r\n\r\n")
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078
1079    struct ReadyStream {
1080        state: std::rc::Rc<std::cell::RefCell<(usize, bool, Option<Duration>)>>,
1081        slow: bool,
1082        broken: bool,
1083    }
1084
1085    impl Read for ReadyStream {
1086        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1087            let mut state = self.state.borrow_mut();
1088            assert!(state.1);
1089            assert_eq!(state.2, None);
1090            state.0 += 1;
1091            if self.broken { return Err(std::io::ErrorKind::BrokenPipe.into()); }
1092            let n = if self.slow {
1093                std::thread::sleep(Duration::from_millis(1));
1094                4
1095            } else { buf.len() };
1096            for chunk in buf[..n].chunks_exact_mut(4) { chunk.copy_from_slice(b"\x81\x02{}"); }
1097            Ok(n)
1098        }
1099    }
1100
1101    impl Write for ReadyStream {
1102        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1103            let state = self.state.borrow();
1104            assert!(!state.1);
1105            assert_eq!(state.2, Some(Duration::from_millis(200)));
1106            Ok(buf.len())
1107        }
1108        fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
1109    }
1110
1111    impl ReadWrite for ReadyStream {
1112        fn set_nonblocking(&mut self, on: bool) -> std::io::Result<()> { self.state.borrow_mut().1 = on; Ok(()) }
1113        fn read_timeout(&self) -> std::io::Result<Option<Duration>> { Ok(self.state.borrow().2) }
1114        fn set_read_timeout(&mut self, timeout: Option<Duration>) -> std::io::Result<()> { self.state.borrow_mut().2 = timeout; Ok(()) }
1115    }
1116
1117    #[test]
1118    fn pre_send_draining_is_bounded_and_restores_socket_options() {
1119        for (slow, broken) in [(false, false), (true, false), (false, true)] {
1120            let state = std::rc::Rc::new(std::cell::RefCell::new((0, false, Some(Duration::from_millis(200)))));
1121            let mut ws = Ws { stream: Box::new(ReadyStream { state: state.clone(), slow, broken }), buf: Vec::new(), fragments: Vec::new(), kind: 0, closed: None };
1122            let result = ws.send_text("{}");
1123            assert_eq!(result.is_err(), broken);
1124            let state = state.borrow();
1125            assert!(state.0 > 0);
1126            assert!(state.0 <= if slow { 2 } else { 8 });
1127            assert!(!state.1);
1128            assert_eq!(state.2, Some(Duration::from_millis(200)));
1129            assert!(ws.buf.len() <= 65536);
1130        }
1131    }
1132
1133    struct SilentStream {
1134        timeout: Option<Duration>,
1135    }
1136
1137    impl Read for SilentStream {
1138        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1139            std::thread::sleep(self.timeout.unwrap_or(Duration::from_secs(5)));
1140            Err(std::io::ErrorKind::TimedOut.into())
1141        }
1142    }
1143
1144    impl Write for SilentStream {
1145        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { Ok(buf.len()) }
1146        fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
1147    }
1148
1149    impl ReadWrite for SilentStream {
1150        fn set_nonblocking(&mut self, _on: bool) -> std::io::Result<()> { Ok(()) }
1151        fn read_timeout(&self) -> std::io::Result<Option<Duration>> { Ok(self.timeout) }
1152        fn set_read_timeout(&mut self, timeout: Option<Duration>) -> std::io::Result<()> {
1153            if timeout == Some(Duration::ZERO) { return Err(std::io::ErrorKind::InvalidInput.into()); }
1154            self.timeout = timeout;
1155            Ok(())
1156        }
1157    }
1158
1159    #[test]
1160    fn short_receive_deadline_is_not_stretched_by_the_socket_timeout() {
1161        let stream = SilentStream { timeout: Some(READ_SLICE) };
1162        let mut ws = Ws { stream: Box::new(stream), buf: Vec::new(), fragments: Vec::new(), kind: 0, closed: None };
1163        let started = Instant::now();
1164        assert_eq!(ws.recv_text(started + Duration::from_millis(5)).unwrap(), None);
1165        assert!(started.elapsed() < Duration::from_millis(100), "{:?}", started.elapsed());
1166        let started = Instant::now();
1167        assert_eq!(ws.recv_text(started + Duration::from_millis(450)).unwrap(), None);
1168        let spent = started.elapsed();
1169        assert!(spent >= Duration::from_millis(450) && spent < Duration::from_millis(700), "{spent:?}");
1170    }
1171
1172    struct Scripted {
1173        reads: std::collections::VecDeque<std::io::Result<Vec<u8>>>,
1174        written: std::rc::Rc<std::cell::RefCell<Vec<u8>>>,
1175    }
1176
1177    impl Read for Scripted {
1178        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1179            match self.reads.pop_front() {
1180                Some(Ok(bytes)) => {
1181                    buf[..bytes.len()].copy_from_slice(&bytes);
1182                    Ok(bytes.len())
1183                }
1184                Some(Err(err)) => Err(err),
1185                None => Err(std::io::ErrorKind::WouldBlock.into()),
1186            }
1187        }
1188    }
1189
1190    impl Write for Scripted {
1191        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1192            self.written.borrow_mut().extend_from_slice(buf);
1193            Ok(buf.len())
1194        }
1195        fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
1196    }
1197
1198    impl ReadWrite for Scripted {
1199        fn set_nonblocking(&mut self, _on: bool) -> std::io::Result<()> { Ok(()) }
1200        fn read_timeout(&self) -> std::io::Result<Option<Duration>> { Ok(Some(READ_SLICE)) }
1201        fn set_read_timeout(&mut self, _timeout: Option<Duration>) -> std::io::Result<()> { Ok(()) }
1202    }
1203
1204    fn scripted(reads: Vec<std::io::Result<Vec<u8>>>) -> (Ws, std::rc::Rc<std::cell::RefCell<Vec<u8>>>) {
1205        let written = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1206        let stream = Scripted { reads: reads.into(), written: written.clone() };
1207        (Ws { stream: Box::new(stream), buf: Vec::new(), fragments: Vec::new(), kind: 0, closed: None }, written)
1208    }
1209
1210    fn close_frame(code: u16, reason: &str) -> Vec<u8> {
1211        let mut payload = code.to_be_bytes().to_vec();
1212        payload.extend_from_slice(reason.as_bytes());
1213        let mut frame = vec![0x88, payload.len() as u8];
1214        frame.extend(payload);
1215        frame
1216    }
1217
1218    const GONE: &str = "websocket closed 1011 the CDP host is gone\n";
1219
1220    fn soon() -> Instant {
1221        Instant::now() + Duration::from_millis(20)
1222    }
1223
1224    #[test]
1225    fn a_close_frame_read_by_recv_is_latched_for_every_later_send_and_receive() {
1226        let (mut ws, written) = scripted(vec![Ok(close_frame(1011, "the CDP host is gone")), Ok(Vec::new()), Ok(b"\x81\x02{}".to_vec())]);
1227        assert_eq!(ws.recv_text(soon()).unwrap_err(), GONE);
1228        assert_eq!(ws.send_text("{}").unwrap_err(), GONE);
1229        assert_eq!(ws.recv_text(soon()).unwrap_err(), GONE);
1230        assert_eq!(ws.send_text("{}").unwrap_err(), GONE);
1231        assert!(written.borrow().is_empty());
1232    }
1233
1234    #[test]
1235    fn a_close_drained_before_a_send_is_latched_ahead_of_frames_buffered_before_it() {
1236        let mut bytes = b"\x81\x02{}".to_vec();
1237        bytes.extend(close_frame(1011, "the CDP host is gone"));
1238        let (mut ws, written) = scripted(vec![Ok(bytes), Ok(Vec::new())]);
1239        assert_eq!(ws.send_text("{}").unwrap_err(), GONE);
1240        assert_eq!(ws.recv_text(soon()).unwrap_err(), GONE);
1241        assert_eq!(ws.send_text("{}").unwrap_err(), GONE);
1242        assert!(written.borrow().is_empty());
1243        let (mut ws, _) = scripted(Vec::new());
1244        ws.buf = close_frame(1000, "");
1245        assert_eq!(ws.send_text("{}").unwrap_err(), "websocket closed 1000\n");
1246        assert_eq!(ws.recv_text(soon()).unwrap_err(), "websocket closed 1000\n");
1247    }
1248
1249    #[test]
1250    fn an_end_of_stream_without_a_close_frame_is_latched_too() {
1251        let (mut ws, written) = scripted(vec![Ok(Vec::new()), Ok(b"\x81\x02{}".to_vec())]);
1252        assert_eq!(ws.recv_text(soon()).unwrap_err(), CONNECTION_CLOSED);
1253        assert_eq!(ws.send_text("{}").unwrap_err(), CONNECTION_CLOSED);
1254        assert_eq!(ws.recv_text(soon()).unwrap_err(), CONNECTION_CLOSED);
1255        assert!(written.borrow().is_empty());
1256        let (mut ws, written) = scripted(vec![Ok(Vec::new()), Ok(b"\x81\x02{}".to_vec())]);
1257        assert_eq!(ws.send_text("{}").unwrap_err(), CONNECTION_CLOSED);
1258        assert_eq!(ws.recv_text(soon()).unwrap_err(), CONNECTION_CLOSED);
1259        assert!(written.borrow().is_empty());
1260    }
1261
1262    #[test]
1263    fn a_ping_ahead_of_a_buffered_close_is_not_answered() {
1264        let mut bytes = vec![0x89, 0x00];
1265        bytes.extend(close_frame(1011, "the CDP host is gone"));
1266        let (mut ws, written) = scripted(vec![Ok(bytes)]);
1267        assert_eq!(ws.recv_text(soon()).unwrap_err(), GONE);
1268        assert!(written.borrow().is_empty());
1269        assert_eq!(ws.send_text("{}").unwrap_err(), GONE);
1270    }
1271
1272    #[test]
1273    fn errors_that_are_not_a_close_are_not_latched() {
1274        let (mut ws, written) = scripted(vec![Err(std::io::ErrorKind::ConnectionReset.into()), Ok(b"\x81\x02{}".to_vec())]);
1275        assert!(ws.recv_text(soon()).unwrap_err().starts_with("websocket read failed: "));
1276        assert_eq!(ws.recv_text(soon()).unwrap().as_deref(), Some("{}"));
1277        ws.send_text("{}").unwrap();
1278        assert!(!written.borrow().is_empty());
1279        assert!(ws.closed.is_none());
1280        assert!(connection_closed("websocket closed\n"));
1281        assert!(connection_closed(CONNECTION_CLOSED));
1282        assert!(!connection_closed("websocket read failed: reset\n"));
1283        assert!(!connection_closed("CDP response was not JSON: x\n"));
1284    }
1285
1286    #[test]
1287    fn decodes_unmasked_text_frame() {
1288        let payload = b"hello";
1289        let mut frame = vec![0x81, payload.len() as u8];
1290        frame.extend_from_slice(payload);
1291        let parsed = try_decode(&frame).unwrap().unwrap();
1292        assert_eq!(parsed.1.payload, b"hello");
1293        assert!(parsed.1.fin);
1294        assert_eq!(parsed.1.opcode, 0x1);
1295    }
1296
1297    #[test]
1298    fn client_frame_round_trip_mask() {
1299        let encoded = encode_client_frame(0x1, b"{}", [1, 2, 3, 4]);
1300        assert_eq!(encoded[0], 0x81);
1301        assert_eq!(encoded[1] & 0x80, 0x80);
1302        let parsed = try_decode(&encoded).unwrap().unwrap();
1303        assert_eq!(parsed.1.payload, b"{}");
1304    }
1305
1306    #[test]
1307    fn base64_round_trip() {
1308        assert_eq!(base64_decode(&base64_encode(b"hi")).unwrap(), b"hi");
1309        assert_eq!(base64_decode(&base64_encode(&[0, 1, 2, 3, 4])).unwrap(), [0, 1, 2, 3, 4]);
1310    }
1311
1312    #[test]
1313    fn close_frame_reports_code_and_reason() {
1314        assert_eq!(
1315            close_message(b"\x03\xf3the CDP host is gone"),
1316            "websocket closed 1011 the CDP host is gone\n"
1317        );
1318        assert_eq!(
1319            close_message(b"\x03\xf1a message is over 268435456 bytes"),
1320            "websocket closed 1009 a message is over 268435456 bytes\n"
1321        );
1322        assert_eq!(close_message(&[]), "websocket closed\n");
1323        assert_eq!(close_message(&[0x03, 0xf3]), "websocket closed 1011\n");
1324    }
1325
1326    #[test]
1327    fn tests_do_not_dial_reserved_debug_ports() {
1328        let err = std::panic::catch_unwind(|| {
1329            let _ = http_get("http://127.0.0.1:9222/json/version");
1330        });
1331        assert!(err.is_err());
1332        let err = std::panic::catch_unwind(|| {
1333            let _ = ws_connect("ws://localhost:9229/devtools/browser/abc");
1334        });
1335        assert!(err.is_err());
1336        for url in [
1337            "http://127.1:9223/json/version",
1338            "http://2130706433:9224/json/version",
1339            "http://[::ffff:127.0.0.1]:9225/json/version",
1340            "http://0.0.0.0:9226/json/version",
1341        ] {
1342            assert!(std::panic::catch_unwind(|| http_get(url)).is_err(), "{url}");
1343        }
1344    }
1345
1346    #[test]
1347    fn parses_opaque_browser_path() {
1348        let parts = parse_url("ws://127.0.0.1:9222/devtools/browser/abc-def?x=1").unwrap();
1349        assert_eq!(parts.host, "127.0.0.1");
1350        assert_eq!(parts.port, 9222);
1351        assert_eq!(parts.path, "/devtools/browser/abc-def?x=1");
1352    }
1353
1354    #[cfg(not(target_os = "wasi"))]
1355    #[test]
1356    fn every_resolved_address_is_tried_in_order() {
1357        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1358        let port = listener.local_addr().unwrap().port();
1359        let v6: SocketAddr = format!("[::1]:{port}").parse().unwrap();
1360        let v4: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
1361        let tcp = connect_any(&[v6, v4], Duration::from_secs(10), None).unwrap();
1362        assert_eq!(tcp.peer_addr().unwrap(), v4);
1363        let late = Instant::now() - Duration::from_secs(1);
1364        let err = connect_any(&[v6, v4], Duration::from_secs(10), Some(late)).unwrap_err();
1365        assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
1366        drop(listener);
1367        assert!(connect_any(&[v4], Duration::from_secs(10), None).is_err());
1368    }
1369
1370    #[test]
1371    fn loopback_spellings_fall_back_to_both_families() {
1372        let at = |text: &str| -> SocketAddr { text.parse().unwrap() };
1373        assert_eq!(
1374            expand_loopback("localhost", 9444, &[at("[::1]:9444")]),
1375            vec![at("[::1]:9444"), at("127.0.0.1:9444")]
1376        );
1377        assert_eq!(
1378            expand_loopback("127.0.0.1", 9444, &[at("127.0.0.1:9444")]),
1379            vec![at("127.0.0.1:9444"), at("[::1]:9444")]
1380        );
1381        assert_eq!(
1382            expand_loopback("[::1]", 9444, &[at("[::1]:9444")]),
1383            vec![at("[::1]:9444"), at("127.0.0.1:9444")]
1384        );
1385        assert_eq!(
1386            expand_loopback("0.0.0.0", 9444, &[at("0.0.0.0:9444")]),
1387            vec![at("127.0.0.1:9444"), at("[::1]:9444")]
1388        );
1389        assert_eq!(
1390            expand_loopback("::", 9444, &[at("[::]:9444")]),
1391            vec![at("[::1]:9444"), at("127.0.0.1:9444")]
1392        );
1393        assert_eq!(
1394            expand_loopback("LocalHost.", 9444, &[]),
1395            vec![at("[::1]:9444"), at("127.0.0.1:9444")]
1396        );
1397        assert_eq!(
1398            expand_loopback("example.test", 9444, &[at("[2001:db8::1]:9444"), at("192.0.2.1:9444")]),
1399            vec![at("[2001:db8::1]:9444"), at("192.0.2.1:9444")]
1400        );
1401        assert_eq!(
1402            expand_loopback("127.0.0.2", 9444, &[at("127.0.0.2:9444")]),
1403            vec![at("127.0.0.2:9444")]
1404        );
1405        let local = candidates("localhost", 9444).unwrap();
1406        assert!(local.contains(&at("127.0.0.1:9444")) && local.contains(&at("[::1]:9444")));
1407        assert!(candidates("no-such-host.invalid", 9444).is_err());
1408    }
1409
1410    #[test]
1411    fn userinfo_is_not_part_of_the_dial_host() {
1412        let same = parse_url("http://evil.example@127.0.0.1:9222/json/version").unwrap();
1413        assert_eq!(same.scheme, "http");
1414        assert_eq!(same.host, "127.0.0.1");
1415        assert_eq!(same.port, 9222);
1416        assert_eq!(same.path, "/json/version");
1417        let other = parse_url("http://127.0.0.1:9222@evil.example/json/version").unwrap();
1418        assert_eq!(other.host, "evil.example");
1419        assert_eq!(other.port, 80);
1420        assert_eq!(other.path, "/json/version");
1421        let v6 = parse_url("http://user@[::1]:9333/json/version").unwrap();
1422        assert_eq!(v6.host, "::1");
1423        assert_eq!(v6.port, 9333);
1424        let query = parse_url("http://127.0.0.1:9333?x=1").unwrap();
1425        assert_eq!(query.host, "127.0.0.1");
1426        assert_eq!(query.port, 9333);
1427        assert_eq!(query.path, "/?x=1");
1428        let published = parse_endpoint("HTTP://evil.example@127.0.0.1:9222").unwrap();
1429        assert_eq!(published.scheme, "http");
1430        assert_eq!(published.host, "127.0.0.1");
1431        assert_eq!(published.port, 9222);
1432    }
1433
1434    #[cfg(not(target_os = "wasi"))]
1435    fn accept_headers(listener: std::net::TcpListener) -> std::net::TcpStream {
1436        let (mut sock, _) = listener.accept().unwrap();
1437        let mut buf = Vec::new();
1438        let mut tmp = [0u8; 1024];
1439        loop {
1440            let n = sock.read(&mut tmp).unwrap();
1441            buf.extend_from_slice(&tmp[..n]);
1442            if buf.windows(4).any(|w| w == b"\r\n\r\n") {
1443                break;
1444            }
1445        }
1446        sock
1447    }
1448
1449    #[cfg(not(target_os = "wasi"))]
1450    #[test]
1451    fn http_get_uses_the_stream() {
1452        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1453        let addr = listener.local_addr().unwrap();
1454        let server = std::thread::spawn(move || {
1455            let mut sock = accept_headers(listener);
1456            sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
1457                .unwrap();
1458        });
1459        let body = http_get(&format!("http://{addr}/json/version")).unwrap();
1460        assert_eq!(body.body, b"{}");
1461        server.join().unwrap();
1462    }
1463
1464    #[cfg(not(target_os = "wasi"))]
1465    #[test]
1466    fn websocket_uses_the_stream() {
1467        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1468        let addr = listener.local_addr().unwrap();
1469        let server = std::thread::spawn(move || {
1470            let mut sock = accept_headers(listener);
1471            sock.write_all(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
1472                .unwrap();
1473            sock.write_all(&[0x81, 5, b'h', b'e', b'l', b'l', b'o']).unwrap();
1474        });
1475        let mut ws = ws_connect(&format!("ws://{addr}/devtools/browser/abc")).unwrap();
1476        let text = ws
1477            .recv_text(std::time::Instant::now() + Duration::from_secs(2))
1478            .unwrap();
1479        assert_eq!(text.as_deref(), Some("hello"));
1480        server.join().unwrap();
1481    }
1482
1483    #[cfg(not(target_os = "wasi"))]
1484    #[test]
1485    fn silent_websocket_returns_at_a_short_deadline() {
1486        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1487        let addr = listener.local_addr().unwrap();
1488        let (done, wait) = std::sync::mpsc::channel::<()>();
1489        let server = std::thread::spawn(move || {
1490            let mut sock = accept_headers(listener);
1491            sock.write_all(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
1492                .unwrap();
1493            let _ = wait.recv();
1494        });
1495        let mut ws = ws_connect(&format!("ws://{addr}/devtools/browser/abc")).unwrap();
1496        for _ in 0..3 {
1497            let started = Instant::now();
1498            assert_eq!(ws.recv_text(started + Duration::from_millis(5)).unwrap(), None);
1499            assert!(started.elapsed() < Duration::from_millis(100), "{:?}", started.elapsed());
1500        }
1501        done.send(()).unwrap();
1502        server.join().unwrap();
1503    }
1504
1505    #[cfg(not(target_os = "wasi"))]
1506    #[test]
1507    fn a_message_split_across_receive_deadlines_is_kept_whole() {
1508        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1509        let addr = listener.local_addr().unwrap();
1510        let (next, wait) = std::sync::mpsc::channel::<()>();
1511        let server = std::thread::spawn(move || {
1512            let mut sock = accept_headers(listener);
1513            sock.write_all(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
1514                .unwrap();
1515            sock.write_all(&[0x01, 3, b'{', b'"', b'a']).unwrap();
1516            wait.recv().unwrap();
1517            sock.write_all(&[0x80, 4, b'"', b':', b'1', b'}']).unwrap();
1518            wait.recv().unwrap();
1519        });
1520        let mut ws = ws_connect(&format!("ws://{addr}/devtools/browser/abc")).unwrap();
1521        assert_eq!(ws.recv_text(Instant::now() + Duration::from_millis(50)).unwrap(), None);
1522        next.send(()).unwrap();
1523        let text = ws.recv_text(Instant::now() + Duration::from_secs(2)).unwrap();
1524        assert_eq!(text.as_deref(), Some("{\"a\":1}"));
1525        next.send(()).unwrap();
1526        server.join().unwrap();
1527    }
1528
1529    #[cfg(not(target_os = "wasi"))]
1530    #[test]
1531    fn server_close_is_reported_and_not_answered() {
1532        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1533        let addr = listener.local_addr().unwrap();
1534        let server = std::thread::spawn(move || {
1535            let mut sock = accept_headers(listener);
1536            let reason = b"the CDP host is gone";
1537            let mut message = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec();
1538            message.extend_from_slice(&[0x81, 5, b'h', b'e', b'l', b'l', b'o']);
1539            message.extend_from_slice(&[0x88, (2 + reason.len()) as u8, 0x03, 0xf3]);
1540            message.extend_from_slice(reason);
1541            sock.write_all(&message).unwrap();
1542            sock.set_read_timeout(Some(Duration::from_millis(500))).unwrap();
1543            let mut tmp = [0u8; 64];
1544            match sock.read(&mut tmp) {
1545                Ok(n) => n,
1546                Err(err)
1547                    if err.kind() == std::io::ErrorKind::WouldBlock
1548                        || err.kind() == std::io::ErrorKind::TimedOut =>
1549                {
1550                    0
1551                }
1552                Err(_) => 0,
1553            }
1554        });
1555        let mut ws = ws_connect(&format!("ws://{addr}/devtools/browser/abc")).unwrap();
1556        let text = ws
1557            .recv_text(std::time::Instant::now() + Duration::from_secs(2))
1558            .unwrap();
1559        assert_eq!(text.as_deref(), Some("hello"));
1560        let err = ws.send_text("{}").unwrap_err();
1561        assert_eq!(err, "websocket closed 1011 the CDP host is gone\n");
1562        assert_eq!(server.join().unwrap(), 0);
1563    }
1564
1565    #[test]
1566    fn probe_response_content_type() {
1567        let resp = ProbeResponse {
1568            status: 200,
1569            headers: vec![
1570                ("Content-Type".into(), "application/json; charset=utf-8".into()),
1571            ],
1572            body: b"{}".to_vec(),
1573        };
1574        assert_eq!(resp.content_type(), "application/json; charset=utf-8");
1575    }
1576
1577    #[test]
1578    fn probe_response_missing_content_type() {
1579        let resp = ProbeResponse {
1580            status: 200,
1581            headers: vec![],
1582            body: b"{}".to_vec(),
1583        };
1584        assert_eq!(resp.content_type(), "");
1585    }
1586
1587    #[test]
1588    fn probe_html_200_never_matches_ard() {
1589        let resp = ProbeResponse {
1590            status: 200,
1591            headers: vec![("Content-Type".into(), "text/html".into())],
1592            body: b"<html>".to_vec(),
1593        };
1594        assert_eq!(resp.status, 200);
1595        assert_eq!(resp.content_type(), "text/html");
1596    }
1597
1598    #[test]
1599    fn probe_404_never_matches_ard() {
1600        let resp = ProbeResponse {
1601            status: 404,
1602            headers: vec![("Content-Type".into(), "application/json".into())],
1603            body: b"{}".to_vec(),
1604        };
1605        assert_ne!(resp.status, 200);
1606    }
1607
1608    #[test]
1609    fn probe_301_never_matches_ard() {
1610        let resp = ProbeResponse {
1611            status: 301,
1612            headers: vec![
1613                ("Location".into(), "https://example.com/.well-known/ard.json".into()),
1614                ("Content-Type".into(), "application/json".into()),
1615            ],
1616            body: Vec::new(),
1617        };
1618        assert_ne!(resp.status, 200);
1619    }
1620
1621    #[test]
1622    fn probe_body_cap_constant() {
1623        assert_eq!(PROBE_MAX_BODY, 1024 * 1024);
1624    }
1625
1626    #[test]
1627    fn probe_does_not_dial_reserved_ports() {
1628        let err = std::panic::catch_unwind(|| {
1629            let _ = http_probe("http://127.0.0.1:9222/.well-known/ard.json", "application/json");
1630        });
1631        assert!(err.is_err(), "probe must not dial 9222");
1632    }
1633
1634    #[cfg(not(target_os = "wasi"))]
1635    #[test]
1636    fn body_cap_content_length_truncates() {
1637        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1638        let addr = listener.local_addr().unwrap();
1639        let server = std::thread::spawn(move || {
1640            let mut sock = accept_headers(listener);
1641            let big = vec![b'X'; 5 * 1024 * 1024];
1642            sock.write_all(
1643                format!(
1644                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1645                    big.len()
1646                )
1647                .as_bytes(),
1648            )
1649            .unwrap();
1650            let _ = sock.write_all(&big);
1651        });
1652        let resp = http_probe(
1653            &format!("http://{addr}/.well-known/ard.json"),
1654            "application/json",
1655        );
1656        server.join().unwrap();
1657        match resp {
1658            Ok(r) => assert!(
1659                r.body.len() <= PROBE_MAX_BODY,
1660                "body should be capped at {PROBE_MAX_BODY}, got {}",
1661                r.body.len()
1662            ),
1663            Err(e) => assert!(
1664                e.contains("cap") || e.contains("exceeds"),
1665                "expected cap error, got: {e}"
1666            ),
1667        }
1668    }
1669
1670    #[cfg(not(target_os = "wasi"))]
1671    #[test]
1672    fn body_cap_chunked_truncates() {
1673        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1674        let addr = listener.local_addr().unwrap();
1675        let server = std::thread::spawn(move || {
1676            let mut sock = accept_headers(listener);
1677            sock.write_all(
1678                b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n",
1679            )
1680            .unwrap();
1681            let chunk = vec![b'Y'; 512 * 1024];
1682            for _ in 0..10 {
1683                let _ = sock.write_all(format!("{:x}\r\n", chunk.len()).as_bytes());
1684                let _ = sock.write_all(&chunk);
1685                let _ = sock.write_all(b"\r\n");
1686            }
1687            let _ = sock.write_all(b"0\r\n\r\n");
1688        });
1689        let resp = http_probe(
1690            &format!("http://{addr}/.well-known/ard.json"),
1691            "application/json",
1692        )
1693        .unwrap();
1694        assert!(
1695            resp.body.len() <= PROBE_MAX_BODY,
1696            "chunked body must be capped, got {}",
1697            resp.body.len()
1698        );
1699        server.join().unwrap();
1700    }
1701
1702    #[cfg(not(target_os = "wasi"))]
1703    #[test]
1704    fn a_json_post_sends_its_body_through_the_probe_path() {
1705        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1706        let addr = listener.local_addr().unwrap();
1707        let server = std::thread::spawn(move || {
1708            let (mut sock, _) = listener.accept().unwrap();
1709            sock.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
1710            let mut request = Vec::new();
1711            let mut tmp = [0u8; 1024];
1712            while !String::from_utf8_lossy(&request).ends_with("{\"a\":1}") {
1713                let n = sock.read(&mut tmp).unwrap();
1714                assert!(n > 0);
1715                request.extend_from_slice(&tmp[..n]);
1716            }
1717            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();
1718            String::from_utf8_lossy(&request).into_owned()
1719        });
1720        let resp = http_post_json(&format!("http://{addr}/mcp"), "{\"a\":1}").unwrap();
1721        assert_eq!(resp.status, 200);
1722        assert_eq!(resp.body, b"{}");
1723        let request = server.join().unwrap();
1724        assert!(request.starts_with("POST /mcp HTTP/1.1\r\n"), "{request}");
1725        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}");
1726    }
1727
1728    #[cfg(not(target_os = "wasi"))]
1729    #[test]
1730    fn a_chunked_reply_left_open_returns_what_arrived_by_the_deadline() {
1731        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1732        let addr = listener.local_addr().unwrap();
1733        let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
1734        let server = std::thread::spawn(move || {
1735            let mut sock = accept_headers(listener);
1736            let event = b"data: {}\n\n";
1737            sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n").unwrap();
1738            sock.write_all(format!("{:x}\r\n", event.len()).as_bytes()).unwrap();
1739            sock.write_all(event).unwrap();
1740            sock.write_all(b"\r\n").unwrap();
1741            let _ = done_rx.recv_timeout(Duration::from_secs(20));
1742        });
1743        let started = Instant::now();
1744        let resp = http_post_json(&format!("http://{addr}/mcp"), "{}").unwrap();
1745        assert!(started.elapsed() < Duration::from_secs(8));
1746        assert_eq!(resp.body, b"data: {}\n\n");
1747        done_tx.send(()).unwrap();
1748        server.join().unwrap();
1749    }
1750
1751    #[cfg(not(target_os = "wasi"))]
1752    #[test]
1753    fn a_chunk_cut_short_and_left_open_returns_by_the_deadline() {
1754        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1755        let addr = listener.local_addr().unwrap();
1756        let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
1757        let server = std::thread::spawn(move || {
1758            let mut sock = accept_headers(listener);
1759            sock.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n10\r\nabc").unwrap();
1760            let _ = done_rx.recv_timeout(Duration::from_secs(20));
1761        });
1762        let started = Instant::now();
1763        let resp = http_probe(&format!("http://{addr}/x"), "*/*").unwrap();
1764        assert!(started.elapsed() < Duration::from_secs(8));
1765        assert!(resp.body.is_empty());
1766        done_tx.send(()).unwrap();
1767        server.join().unwrap();
1768    }
1769
1770    #[cfg(not(target_os = "wasi"))]
1771    #[test]
1772    fn a_json_post_refuses_non_http_urls() {
1773        assert!(http_post_json("ftp://example.com/mcp", "{}").is_err());
1774    }
1775
1776    #[cfg(not(target_os = "wasi"))]
1777    #[test]
1778    fn body_cap_close_delimited_truncates() {
1779        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1780        let addr = listener.local_addr().unwrap();
1781        let server = std::thread::spawn(move || {
1782            let mut sock = accept_headers(listener);
1783            sock.write_all(
1784                b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n",
1785            )
1786            .unwrap();
1787            let chunk = vec![b'Z'; 256 * 1024];
1788            for _ in 0..20 {
1789                if sock.write_all(&chunk).is_err() {
1790                    break;
1791                }
1792            }
1793        });
1794        let resp = http_probe(
1795            &format!("http://{addr}/.well-known/ard.json"),
1796            "application/json",
1797        )
1798        .unwrap();
1799        assert!(
1800            resp.body.len() <= PROBE_MAX_BODY,
1801            "close-delimited body must be capped, got {}",
1802            resp.body.len()
1803        );
1804        server.join().unwrap();
1805    }
1806
1807    #[cfg(not(target_os = "wasi"))]
1808    #[test]
1809    fn stalled_body_probe_returns_within_6s() {
1810        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1811        let addr = listener.local_addr().unwrap();
1812        let server = std::thread::spawn(move || {
1813            let mut sock = accept_headers(listener);
1814            sock.write_all(
1815                b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1000\r\nConnection: close\r\n\r\n",
1816            )
1817            .unwrap();
1818            let _ = sock.write_all(&[b'A'; 10]);
1819            std::thread::sleep(Duration::from_secs(30));
1820        });
1821        let start = std::time::Instant::now();
1822        let _ = http_probe(
1823            &format!("http://{addr}/.well-known/ard.json"),
1824            "application/json",
1825        );
1826        let elapsed = start.elapsed();
1827        assert!(
1828            elapsed < Duration::from_secs(7),
1829            "stalled probe must return within 7s, took {elapsed:?}"
1830        );
1831        drop(server);
1832    }
1833}