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}