Skip to main content

release_kit/
http.rs

1//! The one owner of the HTTP requests `rk` sends.
2//!
3//! `rk` sends every request in-process, through one blocking client with
4//! a TLS stack that links no system OpenSSL and needs no system library.
5//! No other module builds a client. The client trusts the platform's
6//! certificate store, plus one bundle file, the first that
7//! `SSL_CERT_FILE`, `NIX_SSL_CERT_FILE`, or `CURL_CA_BUNDLE` names, plus
8//! each directory `SSL_CERT_DIR` names. It reads its proxy per request
9//! and per redirect: `https_proxy`, then `HTTPS_PROXY`, for https,
10//! `http_proxy`, then `HTTP_PROXY`, for http, then `all_proxy`, then
11//! `ALL_PROXY`, and none where `no_proxy`, else `NO_PROXY`, names the
12//! host, its domain, or an address range it falls in. Each call carries
13//! its own timeout over the whole request, and states whether it follows
14//! a redirect, as each curl call it replaced did.
15//!
16//! `RK_HTTP_ORIGIN` is the one test seam: an `http://` origin on
17//! `127.0.0.1`, `::1`, or `localhost` that every request and every
18//! redirect is sent to instead, with no proxy, and with the host it named
19//! in the `X-Rk-Original-Host` header. Any other value refuses by name, so
20//! the seam can never carry a token to a remote host.
21//!
22//! SATISFIES tool-requirements:rk-sends-its-own-requests
23
24use std::ffi::OsString;
25use std::time::Duration;
26
27/// The user agent every request names; crates.io requires one.
28pub const USER_AGENT: &str = concat!("release-kit/", env!("CARGO_PKG_VERSION"));
29
30/// The test seam's variable.
31pub const ORIGIN_VAR: &str = "RK_HTTP_ORIGIN";
32
33/// The header that carries the host a request named, under the seam.
34pub const ORIGINAL_HOST_HEADER: &str = "X-Rk-Original-Host";
35
36/// One answer: its status, its body, and the URL that answered after
37/// every redirect.
38#[derive(Debug, Clone)]
39pub struct Response {
40    /// The HTTP status code.
41    pub status: u16,
42    /// The body, whole.
43    pub body: Vec<u8>,
44    /// The URL that answered, after the redirects the client followed.
45    pub final_url: String,
46}
47
48impl Response {
49    /// Whether the status is a success, 2xx.
50    #[must_use]
51    pub const fn ok(&self) -> bool {
52        self.status >= 200 && self.status < 300
53    }
54}
55
56/// One request's method.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Method {
59    /// GET.
60    Get,
61    /// POST, with a body.
62    Post,
63}
64
65/// One request: what a call site asks the client to send.
66#[derive(Debug, Clone)]
67pub struct Request<'a> {
68    /// The method.
69    pub method: Method,
70    /// The URL.
71    pub url: &'a str,
72    /// Extra headers, beside the user agent.
73    pub headers: &'a [(&'a str, &'a str)],
74    /// The body of a POST.
75    pub body: &'a [u8],
76    /// The bound on the whole request, redirects included.
77    pub timeout: Duration,
78    /// Whether a redirect is followed. Where it is not, a 3xx is the
79    /// answer.
80    pub follow: bool,
81}
82
83/// The most redirects one request follows.
84const MAX_REDIRECTS: usize = 10;
85
86impl<'a> Request<'a> {
87    /// A GET with no extra header, which follows no redirect.
88    #[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    /// The same request, following redirects.
101    #[must_use]
102    pub const fn following(self) -> Self {
103        Self {
104            follow: true,
105            ..self
106        }
107    }
108}
109
110/// Send one request and read its whole answer. Every status is an
111/// answer, so a 404 is `Ok`; an `Err` is a request that got no answer,
112/// with the reason.
113///
114/// # Errors
115///
116/// The reason where the request got no answer: an unresolvable host, a
117/// refused or timed-out connection, a TLS failure, or a seam value that
118/// is not a local origin.
119pub fn send(request: &Request<'_>) -> Result<Response, String> {
120    send_with(request, &|name| std::env::var_os(name))
121}
122
123/// [`send`] with the environment it reads given, so a test can name the
124/// certificate and proxy variables without touching the process.
125///
126/// # Errors
127///
128/// As [`send`].
129pub 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        // The seam stays on the loopback: it never takes a proxy.
151        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
227/// The URL a redirect names, resolved against the URL that sent it by
228/// RFC 3986, section 5.2: the reference's own parts where it carries
229/// them, the base's otherwise, a merged path for a relative one, and dot
230/// segments removed. The fragment is dropped, because no request sends
231/// one.
232fn 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
266/// The target of a reference with no scheme and no authority, against
267/// `base`, by RFC 3986, section 5.2.2.
268fn 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
297/// The five parts of a URI reference, split as RFC 3986, appendix B
298/// splits one.
299struct 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        // A scheme is the text before the first `:`, where no `/`, `?`,
310        // or `#` comes before it.
311        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
334/// A path with its `.` and `..` segments removed, by RFC 3986, section
335/// 5.2.4.
336fn 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
368/// A transport error in words, with no header value: a header can carry a
369/// token, and ureq's errors name the URL and the cause alone.
370fn 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
377/// The seam's origin, where `RK_HTTP_ORIGIN` names one, judged once.
378fn 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
391/// `url` sent to the seam's `origin` instead, and the host it named.
392fn 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/// The URL to send to, and the host it named where the test seam
401/// redirects it.
402#[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
413/// Whether `origin` is `http://` on a loopback host, with an optional
414/// port and nothing else.
415fn 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
442/// Whether `rest` is `:<digits>`.
443fn 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
448/// The proxy a request to `url` goes through, read the way curl reads
449/// it, with the lowercase name first: `https_proxy`, `HTTPS_PROXY`, then
450/// `all_proxy`, `ALL_PROXY` for an https URL, `http_proxy`, `HTTP_PROXY`,
451/// then `all_proxy`, `ALL_PROXY` for an http one, and none where
452/// `no_proxy`, else `NO_PROXY`, names the host.
453fn 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
489/// Whether a no-proxy list names `host`, as curl reads one: `*` names
490/// every host, a name entry names the host itself and every host under
491/// it, with or without a leading dot, an address entry names that
492/// address, and an `<address>/<bits>` entry names every address in that
493/// range.
494fn 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
513/// Whether `address` is the address `entry` names, or falls in the
514/// `<address>/<bits>` range it names.
515fn 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
547/// The certificates the client trusts: the platform's store, plus one
548/// bundle file, the first that `SSL_CERT_FILE`, `NIX_SSL_CERT_FILE`, or
549/// `CURL_CA_BUNDLE` names, plus each directory `SSL_CERT_DIR` names.
550fn 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/// The platform's own store, which no trust variable narrows. On a Unix
576/// other than macOS that is every certificate in the system's certificate
577/// directories. Elsewhere the operating system's store answers.
578#[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/// The platform's own store. The operating system's store answers here,
586/// and rustls-native-certs reads `SSL_CERT_FILE` and `SSL_CERT_DIR` in
587/// place of it where this process sets either.
588#[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    /// A one-shot local server: each accepted connection gets `answer`'s
601    /// reply to the request line it read, and the request lines are kept.
602    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    /// Every status is an answer: a success and a not-found each come back
642    /// with their status and body, and every request names rk's agent.
643    #[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    /// A redirect is followed, and the URL that answered is reported.
673    #[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        // A request that follows no redirect answers with the redirect.
694        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    /// A redirect resolves as RFC 3986 resolves a reference: a query
701    /// alone keeps the path, dot segments go, and a `://` inside a query
702    /// names no scheme.
703    #[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    /// The three references a local server sends reach the resource they
738    /// name.
739    #[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    /// Under the seam, a redirect to another host and a proxy the
769    /// environment names both stay off: every hop goes to the seam's
770    /// origin, and the proxy sees nothing.
771    #[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    /// The proxy is chosen for each hop: a bypassed loopback request that
815    /// redirects to another host reaches the proxy for that host.
816    #[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    /// `HTTP_PROXY` carries an http request where `http_proxy` is unset,
846    /// and the lowercase name wins where both are set.
847    #[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    /// A host that gives no answer is an error with a reason: a refused
873    /// connection, a connection closed with no answer, and a server that
874    /// answers past the timeout.
875    #[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    /// A proxy the environment names carries the request: `http_proxy`
897    /// for an http URL, `HTTPS_PROXY` for an https one, each through a
898    /// tunnel the proxy is asked for, and `NO_PROXY` takes a host out.
899    #[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    /// A test CA, as PEM, and a local HTTPS server on `localhost` whose
947    /// certificate it signs, as its port.
948    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    /// An environment that names each path of `pairs` under its variable.
995    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    /// A self-signed CA no server uses, as PEM.
1007    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    /// A local HTTPS server whose certificate a test CA signs: trusted
1017    /// where a certificate variable names the CA, and refused where none
1018    /// does. Each of the four variables is held.
1019    #[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    /// The variables combine: the platform store, one bundle file, the
1047    /// first set, and the directories. A bundle of another CA first hides
1048    /// the later file, and an empty directory hides nothing.
1049    #[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        // The platform store stays beside a named bundle.
1102        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    /// The seam takes a loopback http origin and nothing else, so it
1118    /// can never carry a request to a remote host.
1119    #[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    /// `NO_PROXY` names a host and every host under it.
1151    #[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    /// `no_proxy` wins over `NO_PROXY`, as curl reads them.
1169    #[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}