1use std::collections::{HashMap, VecDeque};
5use std::io::{self, Read, Write};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use go_lib::context::{with_timeout, Context};
10use go_lib::net::TcpStream;
11use url::Url;
12
13use crate::cookie::{Cookie, CookieJar};
14use crate::error::HttpError;
15use crate::header::Header;
16use crate::parse::response::{read_response, ParsedResponse};
17use crate::parse::transfer::Body;
18use crate::request::Request;
19use crate::response::Response;
20
21pub trait RoundTripper: Send + Sync {
28 fn round_trip(&self, req: Request) -> Result<Response, HttpError>;
29}
30
31struct IdleConn {
37 stream: TcpStream,
38}
39
40pub type ProxyFn = Arc<dyn Fn(&Request) -> Result<Option<Url>, HttpError> + Send + Sync>;
43
44pub struct Transport {
47 pub max_idle_conns_per_host: usize,
48 pub idle_conn_timeout: Option<Duration>,
49 pub dial_timeout: Option<Duration>,
50 pub tls_config: Option<Arc<rustls::ClientConfig>>,
53 pub proxy: Option<ProxyFn>,
55 pool: Mutex<HashMap<String, VecDeque<IdleConn>>>,
58}
59
60impl Transport {
61 pub fn new() -> Self {
62 Self {
63 max_idle_conns_per_host: 10,
64 idle_conn_timeout: Some(Duration::from_secs(90)),
65 dial_timeout: Some(Duration::from_secs(30)),
66 tls_config: None,
67 proxy: None,
68 pool: Mutex::new(HashMap::new()),
69 }
70 }
71
72 fn acquire(&self, host_port: &str) -> io::Result<TcpStream> {
74 if let Some(conn) = self
76 .pool
77 .lock()
78 .unwrap()
79 .get_mut(host_port)
80 .and_then(|q| q.pop_front())
81 {
82 return Ok(conn.stream);
83 }
84 TcpStream::connect(host_port)
86 }
87
88 fn release(&self, host_port: &str, stream: TcpStream) {
90 let mut pool = self.pool.lock().unwrap();
91 let queue = pool.entry(host_port.to_owned()).or_default();
92 if queue.len() < self.max_idle_conns_per_host {
93 queue.push_back(IdleConn { stream });
94 }
95 }
97}
98
99impl Default for Transport {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl RoundTripper for Transport {
106 fn round_trip(&self, mut req: Request) -> Result<Response, HttpError> {
107 let is_https = req.url.scheme() == "https";
108 let host = req.url.host_str().unwrap_or("localhost").to_owned();
109 let port = req.url.port_or_known_default()
110 .unwrap_or(if is_https { 443 } else { 80 });
111 let target_hp = format!("{host}:{port}");
112
113 let proxy_url = match &self.proxy {
115 Some(f) => f(&req)?,
116 None => None,
117 };
118
119 match proxy_url {
120 None => {
121 if is_https {
122 self.https_round_trip(req, &host, &target_hp, None)
123 } else {
124 self.http_round_trip(req, &target_hp, false)
125 }
126 }
127 Some(pu) => {
128 let proxy_hp = proxy_host_port(&pu)?;
129 let auth = proxy_auth_header(&pu);
130 if is_https {
131 self.https_round_trip(req, &host, &target_hp, Some((proxy_hp, auth)))
133 } else {
134 if let Some(a) = auth {
136 req.header.set("Proxy-Authorization", a);
137 }
138 self.http_round_trip(req, &proxy_hp, true)
139 }
140 }
141 }
142 }
143}
144
145impl Transport {
146 fn http_round_trip(
150 &self,
151 mut req: Request,
152 dial_hp: &str,
153 absolute: bool,
154 ) -> Result<Response, HttpError> {
155 let mut stream = self.acquire(dial_hp).map_err(HttpError::Io)?;
156 send_request(&mut stream, &mut req, absolute)?;
157
158 let mut parsed = read_response(
159 stream.try_clone().map_err(HttpError::Io)?,
160 Some(req.method.as_str()),
161 crate::parse::request::DEFAULT_MAX_HEADER_BYTES,
162 )?;
163
164 let keep_alive = is_keep_alive_parsed(&parsed, req.proto_minor);
165
166 if keep_alive {
172 let bytes = parsed.body.read_to_vec().map_err(|_| HttpError::BodyRead)?;
173 parsed.body = Body::Unbounded(Box::new(io::Cursor::new(bytes)));
174 self.release(dial_hp, stream);
175 }
176
177 Ok(parsed_response_to_response(parsed))
178 }
179
180 fn https_round_trip(
185 &self,
186 mut req: Request,
187 sni_host: &str,
188 target_hp: &str,
189 proxy: Option<(String, Option<String>)>,
190 ) -> Result<Response, HttpError> {
191 let stream = match proxy {
192 Some((proxy_hp, auth)) => {
193 let mut s = TcpStream::connect(proxy_hp.as_str()).map_err(HttpError::Io)?;
194 connect_tunnel(&mut s, target_hp, auth.as_deref())?;
195 s
196 }
197 None => TcpStream::connect(target_hp).map_err(HttpError::Io)?,
198 };
199
200 let tls_cfg = match &self.tls_config {
201 Some(c) => Arc::clone(c),
202 None => crate::tls::default_client_config(),
203 };
204 let server_name = rustls::pki_types::ServerName::try_from(sni_host.to_owned())
205 .map_err(|e| HttpError::Tls(e.to_string()))?;
206 let client_conn = rustls::ClientConnection::new(tls_cfg, server_name)
207 .map_err(|e| HttpError::Tls(e.to_string()))?;
208 let mut tls = rustls::StreamOwned::new(client_conn, stream);
209
210 send_request(&mut tls, &mut req, false)?;
212
213 let read_ptr: *mut dyn Read = &mut tls as &mut dyn Read as *mut dyn Read;
217 let parsed = read_response(
218 RawRead(read_ptr),
219 Some(req.method.as_str()),
220 crate::parse::request::DEFAULT_MAX_HEADER_BYTES,
221 )?;
222 Ok(parsed_response_to_response(parsed))
223 }
224}
225
226struct RawRead(*mut dyn Read);
237unsafe impl Send for RawRead {}
238impl Read for RawRead {
239 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
240 unsafe { (*self.0).read(buf) }
241 }
242}
243
244pub enum RedirectPolicy {
250 Follow,
252 UseLastResponse,
255}
256
257pub type CheckRedirect =
265 Arc<dyn Fn(&Request, &[Request]) -> Result<RedirectPolicy, HttpError> + Send + Sync>;
266
267pub struct Client {
273 pub transport: Arc<dyn RoundTripper>,
275 pub timeout: Option<Duration>,
277 pub max_redirects: usize,
280 pub check_redirect: Option<CheckRedirect>,
283 pub jar: Option<Arc<dyn CookieJar>>,
285}
286
287impl Client {
288 pub fn new() -> Self {
290 Self {
291 transport: Arc::new(Transport::new()),
292 timeout: None,
293 max_redirects: 10,
294 check_redirect: None,
295 jar: None,
296 }
297 }
298
299 pub fn get(&self, url: &str) -> Result<Response, HttpError> {
303 let req = Request::new("GET", url, None)?;
304 self.do_request(req)
305 }
306
307 pub fn post(
310 &self,
311 url: &str,
312 content_type: &str,
313 body: Body,
314 ) -> Result<Response, HttpError> {
315 let mut req = Request::new("POST", url, Some(body))?;
316 req.header.set("Content-Type", content_type);
317 self.do_request(req)
318 }
319
320 pub fn post_form(
323 &self,
324 url: &str,
325 values: &[(&str, &str)],
326 ) -> Result<Response, HttpError> {
327 let encoded = url_encode(values);
328 let body = Body::Unbounded(Box::new(io::Cursor::new(encoded.into_bytes())));
329 self.post(url, "application/x-www-form-urlencoded", body)
330 }
331
332 pub fn head(&self, url: &str) -> Result<Response, HttpError> {
334 let req = Request::new("HEAD", url, None)?;
335 self.do_request(req)
336 }
337
338 pub fn do_request(&self, req: Request) -> Result<Response, HttpError> {
343 let (_cancel, ctx) = match self.timeout {
349 Some(d) => {
350 let (c, cancel) = with_timeout(req.context(), d);
351 (Some(cancel), c)
352 }
353 None => (None, req.context().clone()),
354 };
355
356 let mut req = req;
357
358 if let Some(jar) = &self.jar {
360 attach_cookies(&mut req, jar.as_ref());
361 }
362
363 let mut via: Vec<Request> = Vec::new();
366
367 loop {
368 let method = req.method.clone();
369 let url = req.url.clone();
370 let via_entry = snapshot_request(&req);
371
372 let mut resp = self.execute_round_trip(req, &ctx)?;
373 via.push(via_entry);
374
375 if let Some(jar) = &self.jar {
377 store_cookies(&url, &resp.header, jar.as_ref());
378 }
379
380 let status = resp.status;
382 if !is_redirect(status) {
383 return Ok(resp);
384 }
385
386 let location = resp
387 .header
388 .get("Location")
389 .ok_or_else(|| HttpError::InvalidUrl("redirect with no Location".into()))?
390 .to_owned();
391
392 let new_url = resolve_url(&url, &location)?;
394
395 let new_method = match status {
397 301..=303 => {
398 if method == "POST" { "GET".to_owned() } else { method }
399 }
400 _ => method,
401 };
402
403 let mut new_req = Request::new(&new_method, new_url.as_str(), None)?;
406 forward_headers(&mut new_req.header, &resp.header, same_origin(&url, &new_url));
408 if let Some(jar) = &self.jar {
409 attach_cookies(&mut new_req, jar.as_ref());
410 }
411
412 match &self.check_redirect {
414 Some(policy) => match policy(&new_req, &via)? {
415 RedirectPolicy::Follow => {}
416 RedirectPolicy::UseLastResponse => return Ok(resp),
417 },
418 None => {
419 if via.len() > self.max_redirects {
420 return Err(HttpError::TooManyRedirects);
421 }
422 }
423 }
424
425 let _ = resp.body_bytes();
427
428 req = new_req;
429 }
430 }
431
432 fn execute_round_trip(&self, req: Request, ctx: &Context) -> Result<Response, HttpError> {
441 if ctx.deadline().is_none() {
442 return self.transport.round_trip(req);
443 }
444 if ctx.is_done() {
445 return Err(HttpError::Timeout);
446 }
447
448 let (tx, rx) = go_lib::chan::chan::<Result<Response, HttpError>>(1);
449 let transport = Arc::clone(&self.transport);
450 go_lib::go!(move || {
451 let result = transport.round_trip(req);
452 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tx.send(result)));
455 });
456
457 go_lib::select! {
458 recv(ctx.done()) -> _sig => { Err(HttpError::Timeout) }
459 recv(rx) -> result => { result.unwrap_or_else(|| Err(HttpError::Timeout)) }
460 }
461 }
462}
463
464impl Default for Client {
465 fn default() -> Self {
466 Self::new()
467 }
468}
469
470fn default_client() -> &'static Client {
476 use std::sync::OnceLock;
477 static DEFAULT: OnceLock<Client> = OnceLock::new();
478 DEFAULT.get_or_init(Client::new)
479}
480
481pub fn get(url: &str) -> Result<Response, HttpError> {
483 default_client().get(url)
484}
485
486pub fn post(url: &str, content_type: &str, body: Body) -> Result<Response, HttpError> {
488 default_client().post(url, content_type, body)
489}
490
491pub fn post_form(url: &str, values: &[(&str, &str)]) -> Result<Response, HttpError> {
493 default_client().post_form(url, values)
494}
495
496pub fn head(url: &str) -> Result<Response, HttpError> {
498 default_client().head(url)
499}
500
501fn send_request(w: &mut impl Write, req: &mut Request, absolute: bool) -> Result<(), HttpError> {
509 if absolute {
510 req.write_header_absolute_to(w)?;
511 } else {
512 req.write_header_to(w)?;
513 }
514 if let Some(body) = req.body.take() {
516 use std::io::Read;
517 let mut body = body;
518 let mut buf = [0u8; 8192];
519 loop {
520 let n = body.read(&mut buf).map_err(|_| HttpError::BodyRead)?;
521 if n == 0 { break; }
522 w.write_all(&buf[..n])?;
523 }
524 }
525 Ok(())
526}
527
528fn is_keep_alive_parsed(resp: &ParsedResponse, req_minor: u8) -> bool {
530 let conn = resp.header.get("Connection").unwrap_or("").to_ascii_lowercase();
531 if conn.contains("close") { return false; }
532 if req_minor == 0 { conn.contains("keep-alive") } else { true }
533}
534
535fn parsed_response_to_response(p: ParsedResponse) -> Response {
537 Response {
538 status: p.status,
539 status_text: p.status_text,
540 proto: p.proto,
541 proto_major: p.proto_major,
542 proto_minor: p.proto_minor,
543 header: p.header,
544 body: match p.body {
545 Body::Empty => None,
546 other => Some(other),
547 },
548 content_length: p.content_length,
549 transfer_encoding: p.transfer_encoding,
550 trailer: Header::new(),
551 }
552}
553
554pub fn proxy_from_environment() -> ProxyFn {
569 Arc::new(|req: &Request| -> Result<Option<Url>, HttpError> {
570 let is_https = req.url.scheme() == "https";
571 let host = req.url.host_str().unwrap_or("");
572
573 if let Some(no) = env_first(&["NO_PROXY", "no_proxy"])
575 && host_matches_no_proxy(host, &no)
576 {
577 return Ok(None);
578 }
579
580 let names: &[&str] = if is_https {
581 &["HTTPS_PROXY", "https_proxy"]
582 } else {
583 &["HTTP_PROXY", "http_proxy"]
584 };
585 match env_first(names) {
586 None => Ok(None),
587 Some(v) if v.trim().is_empty() => Ok(None),
588 Some(v) => {
589 let raw = v.trim();
590 let normalized = if raw.contains("://") {
592 raw.to_owned()
593 } else {
594 format!("http://{raw}")
595 };
596 let url = Url::parse(&normalized)
597 .map_err(|e| HttpError::Proxy(format!("bad proxy URL {raw:?}: {e}")))?;
598 Ok(Some(url))
599 }
600 }
601 })
602}
603
604fn env_first(names: &[&str]) -> Option<String> {
606 names.iter().find_map(|n| std::env::var(n).ok())
607}
608
609fn host_matches_no_proxy(host: &str, no_proxy: &str) -> bool {
611 let host = host.trim_start_matches('.').to_ascii_lowercase();
612 for entry in no_proxy.split(',') {
613 let e = entry.trim().to_ascii_lowercase();
614 if e.is_empty() {
615 continue;
616 }
617 if e == "*" {
618 return true;
619 }
620 let suffix = e.trim_start_matches('.');
621 if host == suffix || host.ends_with(&format!(".{suffix}")) {
622 return true;
623 }
624 }
625 false
626}
627
628fn proxy_host_port(pu: &Url) -> Result<String, HttpError> {
630 let h = pu
631 .host_str()
632 .ok_or_else(|| HttpError::Proxy("proxy URL has no host".into()))?;
633 let p = pu.port_or_known_default().unwrap_or(80);
634 Ok(format!("{h}:{p}"))
635}
636
637fn proxy_auth_header(pu: &Url) -> Option<String> {
640 let user = pu.username();
641 if user.is_empty() {
642 return None;
643 }
644 let pass = pu.password().unwrap_or("");
645 let creds = format!("{user}:{pass}");
646 let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, creds);
647 Some(format!("Basic {b64}"))
648}
649
650fn connect_tunnel<S: Read + Write>(
653 stream: &mut S,
654 target_hp: &str,
655 auth: Option<&str>,
656) -> Result<(), HttpError> {
657 let mut req = format!("CONNECT {target_hp} HTTP/1.1\r\nHost: {target_hp}\r\n");
658 if let Some(a) = auth {
659 req.push_str(&format!("Proxy-Authorization: {a}\r\n"));
660 }
661 req.push_str("\r\n");
662 stream.write_all(req.as_bytes()).map_err(HttpError::Io)?;
663
664 let status = read_connect_response(stream)?;
665 if !(200..300).contains(&status) {
666 return Err(HttpError::Proxy(format!(
667 "CONNECT to {target_hp} failed with status {status}"
668 )));
669 }
670 Ok(())
671}
672
673fn read_connect_response<R: Read>(stream: &mut R) -> Result<u16, HttpError> {
676 let mut buf = Vec::with_capacity(128);
677 let mut byte = [0u8; 1];
678 loop {
679 let n = stream.read(&mut byte).map_err(HttpError::Io)?;
680 if n == 0 {
681 return Err(HttpError::Proxy("proxy closed before CONNECT response".into()));
682 }
683 buf.push(byte[0]);
684 if buf.ends_with(b"\r\n\r\n") {
685 break;
686 }
687 if buf.len() > 8192 {
688 return Err(HttpError::Proxy("CONNECT response headers too large".into()));
689 }
690 }
691 let text = String::from_utf8_lossy(&buf);
692 let first = text.lines().next().unwrap_or("");
693 first
694 .split_whitespace()
695 .nth(1)
696 .and_then(|s| s.parse::<u16>().ok())
697 .ok_or_else(|| HttpError::Proxy(format!("malformed CONNECT status line: {first:?}")))
698}
699
700fn snapshot_request(r: &Request) -> Request {
703 let mut s = Request::new(&r.method, r.url.as_str(), None)
704 .unwrap_or_else(|_| Request::new("GET", "http://invalid.invalid/", None).unwrap());
705 s.header = r.header.clone();
706 s.host = r.host.clone();
707 s.proto = r.proto.clone();
708 s
709}
710
711fn forward_headers(dst: &mut Header, src: &Header, same_origin: bool) {
714 for (name, values) in src.iter() {
715 let lower = name.to_ascii_lowercase();
717 if matches!(
718 lower.as_str(),
719 "connection" | "keep-alive" | "proxy-authenticate"
720 | "proxy-authorization" | "te" | "trailers"
721 | "transfer-encoding" | "upgrade"
722 ) {
723 continue;
724 }
725 if !same_origin && lower == "authorization" {
727 continue;
728 }
729 for v in values {
730 dst.add(name, v.as_str());
731 }
732 }
733}
734
735fn is_redirect(status: u16) -> bool {
737 matches!(status, 301 | 302 | 303 | 307 | 308)
738}
739
740fn resolve_url(base: &Url, location: &str) -> Result<Url, HttpError> {
742 if location.starts_with("http://") || location.starts_with("https://") {
743 Url::parse(location).map_err(|e| HttpError::InvalidUrl(e.to_string()))
744 } else {
745 base.join(location).map_err(|e| HttpError::InvalidUrl(e.to_string()))
746 }
747}
748
749fn same_origin(a: &Url, b: &Url) -> bool {
751 a.scheme() == b.scheme()
752 && a.host_str() == b.host_str()
753 && a.port() == b.port()
754}
755
756fn attach_cookies(req: &mut Request, jar: &dyn CookieJar) {
758 let cookies = jar.cookies(&req.url);
759 if !cookies.is_empty() {
760 let pairs: Vec<String> = cookies
761 .iter()
762 .map(|c| format!("{}={}", c.name, c.value))
763 .collect();
764 req.header.set("Cookie", pairs.join("; "));
765 }
766}
767
768fn store_cookies(url: &Url, header: &Header, jar: &dyn CookieJar) {
770 let cookies: Vec<Cookie> = header
771 .values("Set-Cookie")
772 .iter()
773 .filter_map(|v| {
774 let eq = v.find('=')?;
775 let name = v[..eq].trim().to_owned();
776 let rest = &v[eq + 1..];
777 let value = rest.split(';').next().unwrap_or("").trim().to_owned();
778 Some(Cookie::new(name, value))
779 })
780 .collect();
781 if !cookies.is_empty() {
782 jar.set_cookies(url, &cookies);
783 }
784}
785
786fn url_encode(values: &[(&str, &str)]) -> String {
788 values
789 .iter()
790 .map(|(k, v)| format!("{}={}", encode_form(k), encode_form(v)))
791 .collect::<Vec<_>>()
792 .join("&")
793}
794
795fn encode_form(s: &str) -> String {
796 let mut out = String::with_capacity(s.len());
797 for b in s.bytes() {
798 match b {
799 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
800 | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
801 b' ' => out.push('+'),
802 _ => {
803 out.push('%');
804 out.push_str(&format!("{b:02X}"));
805 }
806 }
807 }
808 out
809}
810
811#[cfg(test)]
816mod tests {
817 use super::*;
818
819 #[test]
820 fn url_encode_basic() {
821 let pairs = [("q", "hello world"), ("lang", "rust")];
822 assert_eq!(url_encode(&pairs), "q=hello+world&lang=rust");
823 }
824
825 #[test]
826 fn url_encode_special_chars() {
827 let pairs = [("a", "b&c=d")];
828 assert_eq!(url_encode(&pairs), "a=b%26c%3Dd");
829 }
830
831 #[test]
832 fn resolve_url_absolute() {
833 let base = Url::parse("http://example.com/foo").unwrap();
834 let resolved = resolve_url(&base, "http://other.com/bar").unwrap();
835 assert_eq!(resolved.as_str(), "http://other.com/bar");
836 }
837
838 #[test]
839 fn resolve_url_relative() {
840 let base = Url::parse("http://example.com/a/b").unwrap();
841 let resolved = resolve_url(&base, "/c").unwrap();
842 assert_eq!(resolved.as_str(), "http://example.com/c");
843 }
844
845 #[test]
846 fn is_redirect_codes() {
847 for code in [301u16, 302, 303, 307, 308] {
848 assert!(is_redirect(code), "{code} should be redirect");
849 }
850 for code in [200u16, 404, 500] {
851 assert!(!is_redirect(code), "{code} should not be redirect");
852 }
853 }
854
855 #[test]
856 fn same_origin_check() {
857 let a = Url::parse("http://example.com/foo").unwrap();
858 let b = Url::parse("http://example.com/bar").unwrap();
859 let c = Url::parse("https://example.com/foo").unwrap();
860 let d = Url::parse("http://other.com/foo").unwrap();
861 assert!(same_origin(&a, &b));
862 assert!(!same_origin(&a, &c)); assert!(!same_origin(&a, &d)); }
865
866 #[test]
867 fn transport_pool_reuse() {
868 let t = Transport::new();
873 assert_eq!(t.max_idle_conns_per_host, 10);
874 assert!(t.pool.lock().unwrap().is_empty());
876 }
877
878 #[test]
886 fn proxy_auth_header_from_userinfo() {
887 use base64::Engine;
888 let with = Url::parse("http://user:pass@proxy.local:3128").unwrap();
889 let expected = format!(
890 "Basic {}",
891 base64::engine::general_purpose::STANDARD.encode("user:pass")
892 );
893 assert_eq!(proxy_auth_header(&with), Some(expected));
894
895 let without = Url::parse("http://proxy.local:3128").unwrap();
896 assert_eq!(proxy_auth_header(&without), None);
897 }
898
899 #[test]
900 fn proxy_host_port_defaults_port_80() {
901 let u = Url::parse("http://proxy.local").unwrap();
902 assert_eq!(proxy_host_port(&u).unwrap(), "proxy.local:80");
903 let u2 = Url::parse("http://proxy.local:8080").unwrap();
904 assert_eq!(proxy_host_port(&u2).unwrap(), "proxy.local:8080");
905 }
906
907 #[test]
908 fn no_proxy_matching() {
909 assert!(host_matches_no_proxy("example.com", "example.com"));
910 assert!(host_matches_no_proxy("api.example.com", ".example.com"));
911 assert!(host_matches_no_proxy("api.example.com", "example.com"));
912 assert!(host_matches_no_proxy("anything", "*"));
913 assert!(host_matches_no_proxy("b.internal", "foo.com, .internal"));
914 assert!(!host_matches_no_proxy("example.org", "example.com"));
915 assert!(!host_matches_no_proxy("notexample.com", ".example.com"));
916 }
917
918 #[test]
919 fn connect_response_parsing() {
920 use std::io::Cursor;
921 let mut ok = Cursor::new(b"HTTP/1.1 200 Connection established\r\n\r\n".to_vec());
922 assert_eq!(read_connect_response(&mut ok).unwrap(), 200);
923
924 let mut denied = Cursor::new(b"HTTP/1.1 407 Proxy Auth Required\r\n\r\n".to_vec());
925 assert_eq!(read_connect_response(&mut denied).unwrap(), 407);
926 }
927
928 struct MockConn {
930 to_read: std::io::Cursor<Vec<u8>>,
931 written: Vec<u8>,
932 }
933 impl Read for MockConn {
934 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
935 self.to_read.read(buf)
936 }
937 }
938 impl Write for MockConn {
939 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
940 self.written.extend_from_slice(buf);
941 Ok(buf.len())
942 }
943 fn flush(&mut self) -> io::Result<()> {
944 Ok(())
945 }
946 }
947
948 #[test]
949 fn connect_tunnel_sends_request_and_accepts_200() {
950 let mut conn = MockConn {
951 to_read: std::io::Cursor::new(b"HTTP/1.1 200 OK\r\n\r\n".to_vec()),
952 written: Vec::new(),
953 };
954 connect_tunnel(&mut conn, "example.com:443", Some("Basic Zm9v")).unwrap();
955 let sent = String::from_utf8(conn.written.clone()).unwrap();
956 assert!(sent.starts_with("CONNECT example.com:443 HTTP/1.1\r\n"), "got: {sent:?}");
957 assert!(sent.contains("Host: example.com:443\r\n"));
958 assert!(sent.contains("Proxy-Authorization: Basic Zm9v\r\n"));
959 assert!(sent.ends_with("\r\n\r\n"));
960 }
961
962 #[test]
963 fn connect_tunnel_errors_on_non_2xx() {
964 let mut conn = MockConn {
965 to_read: std::io::Cursor::new(b"HTTP/1.1 403 Forbidden\r\n\r\n".to_vec()),
966 written: Vec::new(),
967 };
968 let err = connect_tunnel(&mut conn, "example.com:443", None).unwrap_err();
969 assert!(matches!(err, HttpError::Proxy(_)), "got: {err:?}");
970 }
971}