1use std::ffi::OsString;
25use std::time::Duration;
26
27pub const USER_AGENT: &str = concat!("release-kit/", env!("CARGO_PKG_VERSION"));
29
30pub const ORIGIN_VAR: &str = "RK_HTTP_ORIGIN";
32
33pub const ORIGINAL_HOST_HEADER: &str = "X-Rk-Original-Host";
35
36#[derive(Debug, Clone)]
39pub struct Response {
40 pub status: u16,
42 pub body: Vec<u8>,
44 pub final_url: String,
46}
47
48impl Response {
49 #[must_use]
51 pub const fn ok(&self) -> bool {
52 self.status >= 200 && self.status < 300
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Method {
59 Get,
61 Post,
63}
64
65#[derive(Debug, Clone)]
67pub struct Request<'a> {
68 pub method: Method,
70 pub url: &'a str,
72 pub headers: &'a [(&'a str, &'a str)],
74 pub body: &'a [u8],
76 pub timeout: Duration,
78 pub follow: bool,
81}
82
83const MAX_REDIRECTS: usize = 10;
85
86impl<'a> Request<'a> {
87 #[must_use]
89 pub const fn get(url: &'a str, timeout: Duration) -> Self {
90 Self {
91 method: Method::Get,
92 url,
93 headers: &[],
94 body: &[],
95 timeout,
96 follow: false,
97 }
98 }
99
100 #[must_use]
102 pub const fn following(self) -> Self {
103 Self {
104 follow: true,
105 ..self
106 }
107 }
108}
109
110pub fn send(request: &Request<'_>) -> Result<Response, String> {
120 send_with(request, &|name| std::env::var_os(name))
121}
122
123pub fn send_with(
130 request: &Request<'_>,
131 env: &dyn Fn(&str) -> Option<OsString>,
132) -> Result<Response, String> {
133 let deadline = std::time::Instant::now() + request.timeout;
134 let roots = roots(env);
135 let seam = seam(env)?;
136 let mut url = request.url.to_owned();
137 let mut method = request.method;
138 for _ in 0..=MAX_REDIRECTS {
139 let remaining = deadline
140 .checked_duration_since(std::time::Instant::now())
141 .filter(|left| !left.is_zero())
142 .ok_or_else(|| "the request timed out".to_owned())?;
143 let (sent_to, original_host) = match &seam {
144 Some(origin) => {
145 let (to, host) = through_seam(origin, &url)?;
146 (to, Some(host))
147 }
148 None => (url.clone(), None),
149 };
150 let proxy = if seam.is_some() {
152 None
153 } else {
154 proxy_for(&url, env)?
155 };
156 let agent: ureq::Agent = ureq::Agent::config_builder()
157 .http_status_as_error(false)
158 .max_redirects(0)
159 .timeout_global(Some(remaining))
160 .user_agent(USER_AGENT)
161 .proxy(proxy)
162 .tls_config(
163 ureq::tls::TlsConfig::builder()
164 .root_certs(ureq::tls::RootCerts::new_with_certs(&roots))
165 .unversioned_rustls_crypto_provider(std::sync::Arc::new(
166 rustls::crypto::ring::default_provider(),
167 ))
168 .build(),
169 )
170 .build()
171 .into();
172 let mut headers: Vec<(&str, &str)> = request.headers.to_vec();
173 if let Some(host) = original_host.as_deref() {
174 headers.push((ORIGINAL_HOST_HEADER, host));
175 }
176 let answered = match method {
177 Method::Get => {
178 let mut builder = agent.get(&sent_to);
179 for (name, value) in &headers {
180 builder = builder.header(*name, *value);
181 }
182 builder.call()
183 }
184 Method::Post => {
185 let mut builder = agent.post(&sent_to);
186 for (name, value) in &headers {
187 builder = builder.header(*name, *value);
188 }
189 builder.send(request.body)
190 }
191 };
192 let mut response = answered.map_err(|error| describe(&error))?;
193 let status = response.status().as_u16();
194 let location = response
195 .headers()
196 .get("location")
197 .and_then(|value| value.to_str().ok())
198 .map(str::to_owned);
199 if request.follow
200 && matches!(status, 301 | 302 | 303 | 307 | 308)
201 && let Some(location) = location
202 {
203 url = join(&url, &location)?;
204 if status == 303 || (matches!(status, 301 | 302) && method == Method::Post) {
205 method = Method::Get;
206 }
207 continue;
208 }
209 let body = response
210 .body_mut()
211 .with_config()
212 .limit(64 * 1024 * 1024)
213 .read_to_vec()
214 .map_err(|error| describe(&error))?;
215 return Ok(Response {
216 status,
217 body,
218 final_url: url,
219 });
220 }
221 Err(format!(
222 "{} redirected more than {MAX_REDIRECTS} times",
223 request.url
224 ))
225}
226
227fn join(base: &str, location: &str) -> Result<String, String> {
233 let base = UriParts::parse(base);
234 let Some(scheme) = base.scheme else {
235 return Err("the redirecting URL names no scheme".to_owned());
236 };
237 let reference = UriParts::parse(location);
238 let (scheme, authority, path, query) = match (reference.scheme, reference.authority) {
239 (Some(own), authority) => (
240 own,
241 authority,
242 remove_dot_segments(reference.path),
243 reference.query,
244 ),
245 (None, Some(authority)) => (
246 scheme,
247 Some(authority),
248 remove_dot_segments(reference.path),
249 reference.query,
250 ),
251 (None, None) => resolve_relative(scheme, &base, &reference),
252 };
253 let mut out = format!("{scheme}:");
254 if let Some(authority) = authority {
255 out.push_str("//");
256 out.push_str(authority);
257 }
258 out.push_str(&path);
259 if let Some(query) = query {
260 out.push('?');
261 out.push_str(query);
262 }
263 Ok(out)
264}
265
266fn resolve_relative<'a>(
269 scheme: &'a str,
270 base: &UriParts<'a>,
271 reference: &UriParts<'a>,
272) -> (&'a str, Option<&'a str>, String, Option<&'a str>) {
273 if reference.path.is_empty() {
274 return (
275 scheme,
276 base.authority,
277 base.path.to_owned(),
278 reference.query.or(base.query),
279 );
280 }
281 let path = if reference.path.starts_with('/') {
282 reference.path.to_owned()
283 } else if base.authority.is_some() && base.path.is_empty() {
284 format!("/{}", reference.path)
285 } else {
286 let dir = base.path.rfind('/').map_or("", |at| &base.path[..=at]);
287 format!("{dir}{}", reference.path)
288 };
289 (
290 scheme,
291 base.authority,
292 remove_dot_segments(&path),
293 reference.query,
294 )
295}
296
297struct UriParts<'a> {
300 scheme: Option<&'a str>,
301 authority: Option<&'a str>,
302 path: &'a str,
303 query: Option<&'a str>,
304}
305
306impl<'a> UriParts<'a> {
307 fn parse(text: &'a str) -> Self {
308 let text = text.split_once('#').map_or(text, |(before, _)| before);
309 let (scheme, rest) = match text.find([':', '/', '?']) {
312 Some(at) if text.as_bytes()[at] == b':' && at > 0 => {
313 (Some(&text[..at]), &text[at + 1..])
314 }
315 _ => (None, text),
316 };
317 let (authority, rest) = rest.strip_prefix("//").map_or((None, rest), |after| {
318 let end = after.find(['/', '?']).unwrap_or(after.len());
319 (Some(&after[..end]), &after[end..])
320 });
321 let (path, query) = match rest.split_once('?') {
322 Some((path, query)) => (path, Some(query)),
323 None => (rest, None),
324 };
325 Self {
326 scheme,
327 authority,
328 path,
329 query,
330 }
331 }
332}
333
334fn remove_dot_segments(path: &str) -> String {
337 let mut input = path.to_owned();
338 let mut output = String::new();
339 while !input.is_empty() {
340 if input.starts_with("../") {
341 input.replace_range(..3, "");
342 } else if input.starts_with("./") {
343 input.replace_range(..2, "");
344 } else if input.starts_with("/./") {
345 input.replace_range(..3, "/");
346 } else if input == "/." {
347 input.replace_range(.., "/");
348 } else if input.starts_with("/../") {
349 input.replace_range(..4, "/");
350 output.truncate(output.rfind('/').unwrap_or(0));
351 } else if input == "/.." {
352 input.replace_range(.., "/");
353 output.truncate(output.rfind('/').unwrap_or(0));
354 } else if input == "." || input == ".." {
355 input.clear();
356 } else {
357 let start = usize::from(input.starts_with('/'));
358 let end = input[start..]
359 .find('/')
360 .map_or(input.len(), |at| at + start);
361 output.push_str(&input[..end]);
362 input.replace_range(..end, "");
363 }
364 }
365 output
366}
367
368fn describe(error: &ureq::Error) -> String {
371 match error {
372 ureq::Error::Timeout(_) => "the request timed out".to_owned(),
373 other => other.to_string(),
374 }
375}
376
377fn seam(env: &dyn Fn(&str) -> Option<OsString>) -> Result<Option<String>, String> {
379 let Some(origin) = env(ORIGIN_VAR).filter(|value| !value.is_empty()) else {
380 return Ok(None);
381 };
382 let origin = origin.to_string_lossy().trim_end_matches('/').to_owned();
383 if !local_origin(&origin) {
384 return Err(format!(
385 "{ORIGIN_VAR} names {origin}, which is not an http:// origin on 127.0.0.1, ::1, or localhost; it is a test seam and never sends a request elsewhere"
386 ));
387 }
388 Ok(Some(origin))
389}
390
391fn through_seam(origin: &str, url: &str) -> Result<(String, String), String> {
393 let (_, rest) = url
394 .split_once("://")
395 .ok_or_else(|| format!("{url} names no scheme"))?;
396 let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
397 Ok((format!("{origin}/{path}"), host.to_owned()))
398}
399
400#[cfg(test)]
403fn route(
404 url: &str,
405 env: &dyn Fn(&str) -> Option<OsString>,
406) -> Result<(String, Option<String>), String> {
407 seam(env)?.map_or_else(
408 || Ok((url.to_owned(), None)),
409 |origin| through_seam(&origin, url).map(|(to, host)| (to, Some(host))),
410 )
411}
412
413fn local_origin(origin: &str) -> bool {
416 let Some(authority) = origin.strip_prefix("http://") else {
417 return false;
418 };
419 if authority.contains(['/', '@', '?', '#']) {
420 return false;
421 }
422 let host = if let Some(bracketed) = authority.strip_prefix('[') {
423 let Some((host, rest)) = bracketed.split_once(']') else {
424 return false;
425 };
426 if !(rest.is_empty() || port_only(rest)) {
427 return false;
428 }
429 host
430 } else {
431 match authority.split_once(':') {
432 Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) && !port.is_empty() => {
433 host
434 }
435 Some(_) => return false,
436 None => authority,
437 }
438 };
439 matches!(host, "127.0.0.1" | "::1" | "localhost")
440}
441
442fn port_only(rest: &str) -> bool {
444 rest.strip_prefix(':')
445 .is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()))
446}
447
448fn proxy_for(
454 url: &str,
455 env: &dyn Fn(&str) -> Option<OsString>,
456) -> Result<Option<ureq::Proxy>, String> {
457 let set = |name: &str| {
458 env(name)
459 .map(|value| value.to_string_lossy().into_owned())
460 .filter(|value| !value.is_empty())
461 };
462 let (scheme, rest) = url.split_once("://").unwrap_or(("http", url));
463 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
464 let host = authority
465 .rsplit_once('@')
466 .map_or(authority, |(_, host)| host);
467 let host = host.strip_prefix('[').map_or_else(
468 || host.split(':').next().unwrap_or_default(),
469 |bracketed| bracketed.split(']').next().unwrap_or_default(),
470 );
471 if let Some(list) = set("no_proxy").or_else(|| set("NO_PROXY"))
472 && bypasses(&list, host)
473 {
474 return Ok(None);
475 }
476 let names: &[&str] = if scheme == "https" {
477 &["https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"]
478 } else {
479 &["http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY"]
480 };
481 let Some(proxy) = names.iter().find_map(|name| set(name)) else {
482 return Ok(None);
483 };
484 ureq::Proxy::new(&proxy)
485 .map(Some)
486 .map_err(|error| format!("the proxy the environment names does not parse: {error}"))
487}
488
489fn bypasses(list: &str, host: &str) -> bool {
495 let host = host.to_ascii_lowercase();
496 let address: Option<std::net::IpAddr> = host.parse().ok();
497 list.split(',')
498 .map(|entry| entry.trim().to_ascii_lowercase())
499 .filter(|entry| !entry.is_empty())
500 .any(|entry| {
501 if entry == "*" {
502 return true;
503 }
504 let entry = entry.trim_start_matches('[').replace(']', "");
505 if let Some(address) = address {
506 return in_range(&entry, address);
507 }
508 let entry = entry.trim_start_matches('.');
509 host == entry || host.ends_with(&format!(".{entry}"))
510 })
511}
512
513fn in_range(entry: &str, address: std::net::IpAddr) -> bool {
516 let (base, bits) = match entry.split_once('/') {
517 Some((base, bits)) => match bits.parse::<u32>() {
518 Ok(bits) => (base, Some(bits)),
519 Err(_) => return false,
520 },
521 None => (entry, None),
522 };
523 let Ok(base) = base.parse::<std::net::IpAddr>() else {
524 return false;
525 };
526 match (base, address) {
527 (std::net::IpAddr::V4(base), std::net::IpAddr::V4(address)) => {
528 let bits = bits.unwrap_or(32);
529 if bits > 32 {
530 return false;
531 }
532 let mask = u32::MAX.checked_shl(32 - bits).unwrap_or(0);
533 u32::from(base) & mask == u32::from(address) & mask
534 }
535 (std::net::IpAddr::V6(base), std::net::IpAddr::V6(address)) => {
536 let bits = bits.unwrap_or(128);
537 if bits > 128 {
538 return false;
539 }
540 let mask = u128::MAX.checked_shl(128 - bits).unwrap_or(0);
541 u128::from(base) & mask == u128::from(address) & mask
542 }
543 _ => false,
544 }
545}
546
547fn roots(env: &dyn Fn(&str) -> Option<OsString>) -> Vec<ureq::tls::Certificate<'static>> {
551 let set = |name: &str| env(name).filter(|value| !value.is_empty());
552 let mut certs = platform_store();
553 if let Some(bundle) = ["SSL_CERT_FILE", "NIX_SSL_CERT_FILE", "CURL_CA_BUNDLE"]
554 .iter()
555 .find_map(|name| set(name))
556 {
557 certs.extend(
558 rustls_native_certs::load_certs_from_paths(Some(std::path::Path::new(&bundle)), None)
559 .certs,
560 );
561 }
562 if let Some(dirs) = set("SSL_CERT_DIR") {
563 for dir in std::env::split_paths(&dirs).filter(|path| !path.as_os_str().is_empty()) {
564 certs.extend(rustls_native_certs::load_certs_from_paths(None, Some(&dir)).certs);
565 }
566 }
567 let mut seen = std::collections::HashSet::new();
568 certs
569 .iter()
570 .filter(|der| seen.insert(der.as_ref().to_vec()))
571 .map(|der| ureq::tls::Certificate::from_der(der.as_ref()).to_owned())
572 .collect()
573}
574
575#[cfg(all(unix, not(target_os = "macos")))]
579fn platform_store() -> Vec<rustls_pki_types::CertificateDer<'static>> {
580 openssl_probe::candidate_cert_dirs()
581 .flat_map(|dir| rustls_native_certs::load_certs_from_paths(None, Some(dir)).certs)
582 .collect()
583}
584
585#[cfg(not(all(unix, not(target_os = "macos"))))]
589fn platform_store() -> Vec<rustls_pki_types::CertificateDer<'static>> {
590 rustls_native_certs::load_native_certs().certs
591}
592
593#[cfg(test)]
594mod tests {
595 use super::{Request, bypasses, local_origin, route, send_with};
596 use std::ffi::OsString;
597 use std::io::{BufRead as _, Write as _};
598 use std::time::Duration;
599
600 fn serve(
603 answer: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
604 ) -> (u16, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
605 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port binds");
606 let port = listener.local_addr().expect("an address").port();
607 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
608 let record = std::sync::Arc::clone(&seen);
609 std::thread::spawn(move || {
610 for stream in listener.incoming() {
611 let Ok(mut stream) = stream else { continue };
612 let mut reader = std::io::BufReader::new(stream.try_clone().expect("clones"));
613 let mut head = String::new();
614 loop {
615 let mut line = String::new();
616 if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
617 break;
618 }
619 head.push_str(&line);
620 }
621 record.lock().expect("locks").push(head.clone());
622 if let Some(reply) = answer(&head) {
623 let _ = stream.write_all(reply.as_bytes());
624 }
625 }
626 });
627 (port, seen)
628 }
629
630 fn reply(status: &str, extra: &str, body: &str) -> String {
631 format!(
632 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{extra}\r\n{body}",
633 body.len()
634 )
635 }
636
637 fn no_env(_: &str) -> Option<OsString> {
638 None
639 }
640
641 #[test]
644 fn a_status_is_an_answer() {
645 let (port, seen) = serve(|head| {
646 Some(if head.starts_with("GET /there ") {
647 reply("200 OK", "", "found")
648 } else {
649 reply("404 Not Found", "", "missing")
650 })
651 });
652 let there = format!("http://127.0.0.1:{port}/there");
653 let answer =
654 send_with(&Request::get(&there, Duration::from_secs(5)), &no_env).expect("an answer");
655 assert_eq!(
656 (answer.status, answer.body.as_slice()),
657 (200, b"found".as_slice())
658 );
659 let gone = format!("http://127.0.0.1:{port}/gone");
660 let answer =
661 send_with(&Request::get(&gone, Duration::from_secs(5)), &no_env).expect("an answer");
662 assert_eq!(answer.status, 404);
663 assert!(
664 seen.lock()
665 .expect("locks")
666 .iter()
667 .all(|head| head.contains("release-kit/")),
668 "every request names the agent"
669 );
670 }
671
672 #[test]
674 fn a_redirect_reports_the_url_that_answered() {
675 let (port, _) = serve(move |head| {
676 Some(if head.starts_with("GET /latest ") {
677 reply("302 Found", "Location: /releases/tag/v1.2.3\r\n", "")
678 } else {
679 reply("200 OK", "", "")
680 })
681 });
682 let url = format!("http://127.0.0.1:{port}/latest");
683 let answer = send_with(
684 &Request::get(&url, Duration::from_secs(5)).following(),
685 &no_env,
686 )
687 .expect("an answer");
688 assert!(
689 answer.final_url.ends_with("/releases/tag/v1.2.3"),
690 "{}",
691 answer.final_url
692 );
693 let answer =
695 send_with(&Request::get(&url, Duration::from_secs(5)), &no_env).expect("an answer");
696 assert_eq!(answer.status, 302);
697 assert_eq!(answer.final_url, url);
698 }
699
700 #[test]
704 fn a_redirect_resolves_as_a_uri_reference() {
705 let base = "http://a/b/c/d;p?q";
706 for (reference, target) in [
707 ("g", "http://a/b/c/g"),
708 ("./g", "http://a/b/c/g"),
709 ("g/", "http://a/b/c/g/"),
710 ("/g", "http://a/g"),
711 ("//g", "http://g"),
712 ("?y", "http://a/b/c/d;p?y"),
713 ("g?y", "http://a/b/c/g?y"),
714 ("#s", "http://a/b/c/d;p?q"),
715 ("", "http://a/b/c/d;p?q"),
716 (".", "http://a/b/c/"),
717 ("..", "http://a/b/"),
718 ("../g", "http://a/b/g"),
719 ("../..", "http://a/"),
720 ("../../../g", "http://a/g"),
721 ("/./g", "http://a/g"),
722 ("g;x=1/../y", "http://a/b/c/y"),
723 ("https://other/x?y", "https://other/x?y"),
724 (
725 "/next?return=http://127.0.0.1:9/x",
726 "http://a/next?return=http://127.0.0.1:9/x",
727 ),
728 ] {
729 assert_eq!(
730 super::join(base, reference).as_deref(),
731 Ok(target),
732 "{reference}"
733 );
734 }
735 }
736
737 #[test]
740 fn a_redirect_reaches_the_resource_it_names() {
741 let (port, seen) = serve(|head| {
742 Some(if head.starts_with("GET /a/b/item?old=1 ") {
743 reply("302 Found", "Location: ?new=2\r\n", "")
744 } else if head.starts_with("GET /a/b/item?new=2 ") {
745 reply("302 Found", "Location: ../release\r\n", "")
746 } else if head.starts_with("GET /a/release ") {
747 reply(
748 "302 Found",
749 "Location: /next?return=http://127.0.0.1:9/x\r\n",
750 "",
751 )
752 } else if head.starts_with("GET /next?return=http://127.0.0.1:9/x ") {
753 reply("200 OK", "", "arrived")
754 } else {
755 reply("404 Not Found", "", "")
756 })
757 });
758 let url = format!("http://127.0.0.1:{port}/a/b/item?old=1");
759 let answer = send_with(
760 &Request::get(&url, Duration::from_secs(5)).following(),
761 &no_env,
762 )
763 .expect("an answer");
764 let heads = seen.lock().expect("locks").clone();
765 assert_eq!(answer.body, b"arrived", "{heads:?}");
766 }
767
768 #[test]
772 fn the_seam_keeps_every_hop_on_the_loopback() {
773 let (elsewhere, elsewhere_seen) = serve(|_| Some(reply("200 OK", "", "escaped")));
774 let (proxy, proxy_seen) = serve(|_| Some(reply("200 OK", "", "proxied")));
775 let (origin, origin_seen) = serve(move |head| {
776 Some(if head.starts_with("GET /start ") {
777 reply(
778 "302 Found",
779 &format!("Location: http://127.0.0.1:{elsewhere}/leak\r\n"),
780 "",
781 )
782 } else {
783 reply("200 OK", "", "stayed")
784 })
785 });
786 let seam = format!("http://127.0.0.1:{origin}");
787 let proxy = format!("http://127.0.0.1:{proxy}");
788 let env = |name: &str| match name {
789 "RK_HTTP_ORIGIN" => Some(OsString::from(&seam)),
790 "https_proxy" | "HTTPS_PROXY" | "http_proxy" | "ALL_PROXY" => {
791 Some(OsString::from(&proxy))
792 }
793 _ => None,
794 };
795 let answer = send_with(
796 &Request::get("https://api.github.com/start", Duration::from_secs(5)).following(),
797 &env,
798 )
799 .expect("an answer");
800 assert_eq!(answer.body, b"stayed");
801 assert!(
802 elsewhere_seen.lock().expect("locks").is_empty(),
803 "no escape"
804 );
805 assert!(proxy_seen.lock().expect("locks").is_empty(), "no proxy");
806 let heads = origin_seen.lock().expect("locks").clone();
807 assert!(
808 heads.iter().any(|head| head.starts_with("GET /leak ")
809 && head.contains(&format!("127.0.0.1:{elsewhere}"))),
810 "the redirect came back to the seam, naming its host: {heads:?}"
811 );
812 }
813
814 #[test]
817 fn each_redirect_takes_its_own_proxy() {
818 let (proxy, proxy_seen) = serve(|_| Some(reply("403 Forbidden", "", "")));
819 let (origin, _) = serve(|_| {
820 Some(reply(
821 "302 Found",
822 "Location: http://example.invalid/next\r\n",
823 "",
824 ))
825 });
826 let proxy = format!("http://127.0.0.1:{proxy}");
827 let env = |name: &str| match name {
828 "http_proxy" => Some(OsString::from(&proxy)),
829 "no_proxy" => Some(OsString::from("127.0.0.0/8")),
830 _ => None,
831 };
832 let url = format!("http://127.0.0.1:{origin}/start");
833 let _ = send_with(
834 &Request::get(&url, Duration::from_secs(5)).following(),
835 &env,
836 );
837 let heads = proxy_seen.lock().expect("locks").clone();
838 assert_eq!(heads.len(), 1, "{heads:?}");
839 assert!(
840 heads[0].starts_with("CONNECT example.invalid:80 "),
841 "{heads:?}"
842 );
843 }
844
845 #[test]
848 fn http_proxy_is_read_in_either_case() {
849 let (upper, upper_seen) = serve(|_| Some(reply("403 Forbidden", "", "")));
850 let (lower, lower_seen) = serve(|_| Some(reply("403 Forbidden", "", "")));
851 let upper = format!("http://127.0.0.1:{upper}");
852 let lower = format!("http://127.0.0.1:{lower}");
853 let only_upper = |name: &str| (name == "HTTP_PROXY").then(|| OsString::from(&upper));
854 let _ = send_with(
855 &Request::get("http://example.invalid/a", Duration::from_secs(5)),
856 &only_upper,
857 );
858 assert_eq!(upper_seen.lock().expect("locks").len(), 1);
859 let both = |name: &str| match name {
860 "HTTP_PROXY" => Some(OsString::from(&upper)),
861 "http_proxy" => Some(OsString::from(&lower)),
862 _ => None,
863 };
864 let _ = send_with(
865 &Request::get("http://example.invalid/b", Duration::from_secs(5)),
866 &both,
867 );
868 assert_eq!(upper_seen.lock().expect("locks").len(), 1);
869 assert_eq!(lower_seen.lock().expect("locks").len(), 1);
870 }
871
872 #[test]
876 fn no_answer_is_an_error_with_a_reason() {
877 let refused = {
878 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port binds");
879 listener.local_addr().expect("an address").port()
880 };
881 let url = format!("http://127.0.0.1:{refused}/");
882 assert!(send_with(&Request::get(&url, Duration::from_secs(5)), &no_env).is_err());
883 let (port, _) = serve(|_| None);
884 let url = format!("http://127.0.0.1:{port}/");
885 assert!(send_with(&Request::get(&url, Duration::from_secs(5)), &no_env).is_err());
886 let (port, _) = serve(|_| {
887 std::thread::sleep(Duration::from_secs(3));
888 Some(reply("200 OK", "", "late"))
889 });
890 let url = format!("http://127.0.0.1:{port}/");
891 let err = send_with(&Request::get(&url, Duration::from_secs(1)), &no_env)
892 .expect_err("the answer comes past the timeout");
893 assert!(err.contains("timed out"), "{err}");
894 }
895
896 #[test]
900 fn the_client_honors_https_proxy() {
901 let (port, seen) = serve(|_| Some(reply("403 Forbidden", "", "")));
902 let proxy = format!("http://127.0.0.1:{port}");
903 let env = |name: &str| {
904 (name == "http_proxy" || name == "HTTPS_PROXY").then(|| OsString::from(&proxy))
905 };
906 let err = send_with(
907 &Request::get("http://example.invalid/x", Duration::from_secs(5)),
908 &env,
909 )
910 .expect_err("the proxy refuses the tunnel");
911 assert!(err.contains("proxy"), "{err}");
912 let _ = send_with(
913 &Request::get("https://example.invalid/y", Duration::from_secs(5)),
914 &env,
915 );
916 let lines = seen.lock().expect("locks").clone();
917 assert!(
918 lines
919 .iter()
920 .any(|head| head.starts_with("CONNECT example.invalid:80 ")),
921 "{lines:?}"
922 );
923 assert!(
924 lines
925 .iter()
926 .any(|head| head.starts_with("CONNECT example.invalid:443 ")),
927 "{lines:?}"
928 );
929 let bypass = |name: &str| match name {
930 "http_proxy" => Some(OsString::from(&proxy)),
931 "NO_PROXY" => Some(OsString::from("example.invalid")),
932 _ => None,
933 };
934 let before = seen.lock().expect("locks").len();
935 let _ = send_with(
936 &Request::get("http://example.invalid/z", Duration::from_secs(2)),
937 &bypass,
938 );
939 assert_eq!(
940 seen.lock().expect("locks").len(),
941 before,
942 "NO_PROXY took the host out"
943 );
944 }
945
946 fn tls_server() -> (String, u16) {
949 let ca_key = rcgen::KeyPair::generate().expect("a CA key");
950 let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).expect("CA params");
951 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
952 let ca = rcgen::CertifiedIssuer::self_signed(ca_params, ca_key).expect("a CA");
953 let server_key = rcgen::KeyPair::generate().expect("a server key");
954 let server = rcgen::CertificateParams::new(vec!["localhost".to_owned()])
955 .expect("server params")
956 .signed_by(&server_key, &ca)
957 .expect("a server certificate");
958 let config = std::sync::Arc::new(
959 rustls::ServerConfig::builder_with_provider(std::sync::Arc::new(
960 rustls::crypto::ring::default_provider(),
961 ))
962 .with_safe_default_protocol_versions()
963 .expect("protocols")
964 .with_no_client_auth()
965 .with_single_cert(
966 vec![server.der().clone()],
967 rustls_pki_types::PrivateKeyDer::Pkcs8(server_key.serialize_der().into()),
968 )
969 .expect("a server config"),
970 );
971 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port binds");
972 let port = listener.local_addr().expect("an address").port();
973 std::thread::spawn(move || {
974 for stream in listener.incoming() {
975 let Ok(mut stream) = stream else { continue };
976 let Ok(mut connection) =
977 rustls::ServerConnection::new(std::sync::Arc::clone(&config))
978 else {
979 continue;
980 };
981 let mut tls = rustls::Stream::new(&mut connection, &mut stream);
982 let mut reader = std::io::BufReader::new(&mut tls);
983 let mut line = String::new();
984 while reader.read_line(&mut line).unwrap_or(0) > 0 && line != "\r\n" {
985 line.clear();
986 }
987 let _ = tls.write_all(reply("200 OK", "", "trusted").as_bytes());
988 let _ = tls.flush();
989 }
990 });
991 (ca.pem(), port)
992 }
993
994 fn paths_env<'a>(
996 pairs: Vec<(&'static str, &'a std::path::Path)>,
997 ) -> impl Fn(&str) -> Option<OsString> + 'a {
998 move |asked| {
999 pairs
1000 .iter()
1001 .find(|(name, _)| *name == asked)
1002 .map(|(_, value)| value.as_os_str().to_owned())
1003 }
1004 }
1005
1006 fn other_ca() -> String {
1008 let key = rcgen::KeyPair::generate().expect("a key");
1009 let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).expect("params");
1010 params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
1011 rcgen::CertifiedIssuer::self_signed(params, key)
1012 .expect("a CA")
1013 .pem()
1014 }
1015
1016 #[test]
1020 fn the_client_trusts_ssl_cert_file() {
1021 let (ca, port) = tls_server();
1022 let dir = tempfile::tempdir().expect("a scratch dir");
1023 let bundle = dir.path().join("ca.pem");
1024 std::fs::write(&bundle, &ca).expect("the bundle writes");
1025 let certs = dir.path().join("certs");
1026 std::fs::create_dir(&certs).expect("the dir creates");
1027 std::fs::write(certs.join("ca.pem"), &ca).expect("the cert writes");
1028 let url = format!("https://localhost:{port}/");
1029 let request = Request::get(&url, Duration::from_secs(5));
1030 assert!(
1031 send_with(&request, &no_env).is_err(),
1032 "the test CA is trusted with no variable"
1033 );
1034 for (name, value) in [
1035 ("SSL_CERT_FILE", bundle.as_os_str()),
1036 ("SSL_CERT_DIR", certs.as_os_str()),
1037 ("NIX_SSL_CERT_FILE", bundle.as_os_str()),
1038 ("CURL_CA_BUNDLE", bundle.as_os_str()),
1039 ] {
1040 let env = |asked: &str| (asked == name).then(|| value.to_owned());
1041 let answer = send_with(&request, &env).unwrap_or_else(|err| panic!("{name}: {err}"));
1042 assert_eq!(answer.body, b"trusted", "{name}");
1043 }
1044 }
1045
1046 #[test]
1050 fn the_trust_variables_combine() {
1051 let (ca, port) = tls_server();
1052 let dir = tempfile::tempdir().expect("a scratch dir");
1053 let bundle = dir.path().join("ca.pem");
1054 std::fs::write(&bundle, &ca).expect("the bundle writes");
1055 let certs = dir.path().join("certs");
1056 std::fs::create_dir(&certs).expect("the dir creates");
1057 std::fs::write(certs.join("ca.pem"), &ca).expect("the cert writes");
1058 let other = dir.path().join("other.pem");
1059 std::fs::write(&other, other_ca()).expect("the bundle writes");
1060 let empty = dir.path().join("empty");
1061 std::fs::create_dir(&empty).expect("the dir creates");
1062 let url = format!("https://localhost:{port}/");
1063 let request = Request::get(&url, Duration::from_secs(5));
1064 for (label, pairs, trusted) in [
1065 (
1066 "an empty directory beside the Nix bundle",
1067 vec![
1068 ("NIX_SSL_CERT_FILE", bundle.as_path()),
1069 ("SSL_CERT_DIR", empty.as_path()),
1070 ],
1071 true,
1072 ),
1073 (
1074 "an empty directory beside the curl bundle",
1075 vec![
1076 ("CURL_CA_BUNDLE", bundle.as_path()),
1077 ("SSL_CERT_DIR", empty.as_path()),
1078 ],
1079 true,
1080 ),
1081 (
1082 "SSL_CERT_FILE first, naming another CA",
1083 vec![
1084 ("SSL_CERT_FILE", other.as_path()),
1085 ("NIX_SSL_CERT_FILE", bundle.as_path()),
1086 ],
1087 false,
1088 ),
1089 (
1090 "a directory adds to another bundle",
1091 vec![
1092 ("SSL_CERT_FILE", other.as_path()),
1093 ("SSL_CERT_DIR", certs.as_path()),
1094 ],
1095 true,
1096 ),
1097 ] {
1098 let env = paths_env(pairs);
1099 assert_eq!(send_with(&request, &env).is_ok(), trusted, "{label}");
1100 }
1101 let platform = super::platform_store().len();
1103 let env = paths_env(vec![("SSL_CERT_FILE", bundle.as_path())]);
1104 assert!(super::roots(&env).len() > platform || platform == 0);
1105 assert!(super::roots(&env).len() >= platform);
1106 }
1107
1108 fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
1109 move |name| {
1110 pairs
1111 .iter()
1112 .find(|(key, _)| *key == name)
1113 .map(|(_, value)| OsString::from(value))
1114 }
1115 }
1116
1117 #[test]
1120 fn the_seam_takes_a_loopback_http_origin_alone() {
1121 for good in [
1122 "http://127.0.0.1:8080",
1123 "http://localhost:1",
1124 "http://[::1]:9",
1125 "http://127.0.0.1",
1126 ] {
1127 assert!(local_origin(good), "{good}");
1128 }
1129 for bad in [
1130 "https://127.0.0.1:8080",
1131 "http://example.com",
1132 "http://127.0.0.1.example.com",
1133 "http://user@127.0.0.1",
1134 "http://127.0.0.1:80/path",
1135 "http://localhost:x",
1136 "http://[::2]:9",
1137 ] {
1138 assert!(!local_origin(bad), "{bad}");
1139 }
1140 let env = env_of(&[("RK_HTTP_ORIGIN", "http://evil.example")]);
1141 let err = route("https://api.github.com/app", &env).expect_err("a remote origin refuses");
1142 assert!(err.contains("RK_HTTP_ORIGIN"), "{err}");
1143 let env = env_of(&[("RK_HTTP_ORIGIN", "http://127.0.0.1:4000/")]);
1144 let (url, host) = route("https://api.github.com/app/installations?x=1", &env)
1145 .expect("a local origin routes");
1146 assert_eq!(url, "http://127.0.0.1:4000/app/installations?x=1");
1147 assert_eq!(host.as_deref(), Some("api.github.com"));
1148 }
1149
1150 #[test]
1152 fn no_proxy_names_a_host_and_its_subdomains() {
1153 assert!(bypasses("127.0.0.0/8", "127.0.0.1"));
1154 assert!(bypasses("10.1.0.0/16, example.com", "10.1.2.3"));
1155 assert!(!bypasses("10.1.0.0/16", "10.2.0.1"));
1156 assert!(bypasses("127.0.0.1", "127.0.0.1"));
1157 assert!(bypasses("::1", "::1"));
1158 assert!(bypasses("[fd00::]/8", "fd12::1"));
1159 assert!(!bypasses("example.com", "127.0.0.1"));
1160 assert!(bypasses("example.com", "example.com"));
1161 assert!(bypasses(".example.com", "api.example.com"));
1162 assert!(bypasses("foo, example.com", "a.b.example.com"));
1163 assert!(bypasses("*", "anything"));
1164 assert!(!bypasses("example.com", "notexample.com"));
1165 assert!(!bypasses("", "example.com"));
1166 }
1167
1168 #[test]
1170 fn the_lowercase_no_proxy_wins() {
1171 let env = env_of(&[
1172 ("http_proxy", "http://127.0.0.1:9"),
1173 ("no_proxy", "example.invalid"),
1174 ("NO_PROXY", "other.invalid"),
1175 ]);
1176 assert!(
1177 super::proxy_for("http://example.invalid/", &env)
1178 .expect("parses")
1179 .is_none()
1180 );
1181 assert!(
1182 super::proxy_for("http://other.invalid/", &env)
1183 .expect("parses")
1184 .is_some()
1185 );
1186 }
1187}