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