Skip to main content

rget/
http.rs

1//! HTTP client, metadata probing and range requests (PRD §6).
2//!
3//! Two rules drive this module:
4//!
5//! 1. **Never trust the server.** Headers are parsed defensively, sizes are
6//!    sanity-checked, `Content-Range` is verified against what we asked for,
7//!    and a server that ignores `Range` is detected rather than believed.
8//! 2. **Never leak credentials.** URLs are redacted before they can reach a log
9//!    line, and `Authorization` is dropped across a cross-host redirect.
10
11use std::time::Duration;
12
13use anyhow::{Context, Result};
14use reqwest::header::{
15    ACCEPT_ENCODING, ACCEPT_RANGES, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH,
16    CONTENT_RANGE, CONTENT_TYPE, ETAG, HeaderMap, HeaderName, HeaderValue, LAST_MODIFIED, RANGE,
17    RETRY_AFTER,
18};
19use reqwest::{Client, Response, StatusCode};
20use url::Url;
21
22use crate::error::TransferError;
23
24pub const DEFAULT_USER_AGENT: &str = concat!("rget/", env!("CARGO_PKG_VERSION"));
25
26#[derive(Debug, Clone)]
27pub struct HttpConfig {
28    pub user_agent: String,
29    /// Applies to connect *and* to the gap between two body reads. It is
30    /// deliberately not a whole-request deadline: a 4 GiB range is allowed to
31    /// take as long as it takes, as long as bytes keep arriving.
32    pub timeout: Duration,
33    pub headers: Vec<(String, String)>,
34    pub proxy: Option<String>,
35    pub max_redirects: usize,
36    pub basic_auth: Option<(String, String)>,
37}
38
39impl Default for HttpConfig {
40    fn default() -> Self {
41        Self {
42            user_agent: DEFAULT_USER_AGENT.to_string(),
43            timeout: Duration::from_secs(30),
44            headers: Vec::new(),
45            proxy: None,
46            max_redirects: 10,
47            basic_auth: None,
48        }
49    }
50}
51
52impl HttpConfig {
53    /// Extra headers as a `HeaderMap`, rejecting anything unparseable rather
54    /// than silently dropping it.
55    pub fn header_map(&self) -> Result<HeaderMap> {
56        let mut map = HeaderMap::new();
57        // Ask for identity so a proxy cannot hand us a gzip stream whose byte
58        // offsets have nothing to do with the file we are reassembling.
59        map.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity"));
60        for (k, v) in &self.headers {
61            let name: HeaderName = k
62                .trim()
63                .parse()
64                .with_context(|| format!("invalid header name `{k}`"))?;
65            let value = HeaderValue::from_str(v.trim())
66                .with_context(|| format!("invalid value for header `{k}`"))?;
67            map.insert(name, value);
68        }
69        if let Some((user, pass)) = &self.basic_auth {
70            let mut value =
71                HeaderValue::from_str(&format!("Basic {}", base64(&format!("{user}:{pass}"))))
72                    .context("invalid basic-auth credentials")?;
73            // Marks the header sensitive so `HeaderMap`'s Debug impl prints
74            // `Sensitive` instead of the credentials (PRD §25).
75            value.set_sensitive(true);
76            map.insert(AUTHORIZATION, value);
77        }
78        Ok(map)
79    }
80}
81
82pub fn build_client(cfg: &HttpConfig) -> Result<Client> {
83    let max = cfg.max_redirects;
84    let policy = reqwest::redirect::Policy::custom(move |attempt| {
85        if attempt.previous().len() >= max {
86            return attempt.error(format!("exceeded {max} redirects"));
87        }
88        let scheme = attempt.url().scheme().to_string();
89        if scheme != "http" && scheme != "https" {
90            return attempt.error(format!("refusing redirect to `{scheme}` scheme"));
91        }
92        // Explicit loop detection: a server can bounce between two URLs and
93        // stay under the hop limit forever.
94        if attempt.previous().iter().any(|p| p == attempt.url()) {
95            return attempt.error("redirect loop");
96        }
97        attempt.follow()
98    });
99
100    let mut builder = Client::builder()
101        .user_agent(&cfg.user_agent)
102        .default_headers(cfg.header_map()?)
103        .redirect(policy)
104        // Drop `Authorization`/`Cookie` when a redirect crosses hosts.
105        .referer(false)
106        .connect_timeout(cfg.timeout)
107        .pool_idle_timeout(Duration::from_secs(90))
108        // A cap on how much header a hostile server can make us buffer.
109        .http1_ignore_invalid_headers_in_responses(false)
110        .tcp_nodelay(true);
111
112    if let Some(proxy) = &cfg.proxy {
113        builder = builder
114            .proxy(reqwest::Proxy::all(proxy).with_context(|| format!("invalid proxy `{proxy}`"))?);
115    }
116
117    builder.build().context("failed to build HTTP client")
118}
119
120/// Everything we learn about the remote resource before transferring it.
121#[derive(Debug, Clone)]
122pub struct RemoteInfo {
123    pub final_url: Url,
124    pub size: Option<u64>,
125    pub accept_ranges: bool,
126    pub etag: Option<String>,
127    pub last_modified: Option<String>,
128    pub content_type: Option<String>,
129    pub content_disposition: Option<String>,
130    /// Set when the server sent a non-identity `Content-Encoding`; the bytes on
131    /// disk will be the encoded form, so we warn rather than pretend.
132    pub content_encoding: Option<String>,
133}
134
135impl RemoteInfo {
136    /// Can we safely split this into parallel ranges?
137    pub fn supports_parallel(&self) -> bool {
138        self.accept_ranges && self.size.is_some_and(|s| s > 0)
139    }
140
141    /// The strongest validator available, for `If-Range` (PRD §13).
142    pub fn validator(&self) -> Option<String> {
143        // A weak ETag (`W/"x"`) does not guarantee byte-for-byte identity, so
144        // it must never be used for If-Range. Fall back to Last-Modified.
145        match &self.etag {
146            Some(tag) if !tag.trim_start().starts_with("W/") => Some(tag.clone()),
147            _ => self.last_modified.clone(),
148        }
149    }
150
151    pub fn has_strong_etag(&self) -> bool {
152        self.etag
153            .as_deref()
154            .is_some_and(|t| !t.trim_start().starts_with("W/"))
155    }
156}
157
158/// What a priming probe learned, plus the response body it opened.
159pub struct Primed {
160    pub info: RemoteInfo,
161    /// A live response whose body starts at byte 0, ready to be transferred
162    /// rather than discarded. `None` when the probe had to fall back and the
163    /// caller should issue ordinary requests for everything.
164    pub body: Option<Response>,
165    /// How many bytes `body` covers, counted from 0. The plan pins its first
166    /// range to exactly this so nothing is wasted and nothing is fetched twice.
167    /// Zero when there is no body.
168    pub body_len: u64,
169}
170
171/// Probe *and* start the download in a single request.
172///
173/// [`probe`] asks for `bytes=0-0`, reads one byte, throws it away, and only then
174/// lets the real work begin — one whole round trip of pure overhead on every
175/// download, which is why rget could never match a single-request client on a
176/// small file. This asks for `bytes=0-` instead: the reply tells us everything
177/// `probe` would have, and its body is the beginning of the file.
178///
179/// The three answers a server can give, all useful:
180///
181/// - `206` with a parseable `Content-Range` — ranges work, size known, and the
182///   first bytes are already streaming.
183/// - `200` — the server ignored `Range` and sent the whole representation. The
184///   body still starts at byte 0, so it still primes the transfer. Whether we
185///   may *also* issue ranged requests is then down to `Accept-Ranges`.
186/// - anything else — fall back to [`plain_probe`] and hand back no body.
187///
188/// `prime` bounds how much of the file the probe asks for.
189///
190/// `None` means `bytes=0-` — the whole file — which is right when one connection
191/// is going to transfer all of it anyway. `Some(n)` asks for the first `n` bytes,
192/// which is what a parallel download wants: the body is then exactly the first
193/// range of the plan (see [`crate::scheduler::plan_primed`]) and nothing the
194/// server sends is discarded. An open-ended prime in a parallel download would
195/// stream the entire file down a connection whose worker stops at the first
196/// chunk boundary, wasting 3–8% of the transfer.
197pub async fn probe_priming(
198    client: &Client,
199    url: &Url,
200    prime: Option<u64>,
201) -> Result<Primed, TransferError> {
202    let spec = match prime {
203        Some(n) if n > 0 => format!("bytes=0-{}", n - 1),
204        _ => "bytes=0-".to_string(),
205    };
206    let resp = client
207        .get(url.clone())
208        .header(RANGE, spec)
209        .send()
210        .await
211        .map_err(|e| TransferError::from_reqwest(&e))?;
212
213    let status = resp.status();
214
215    if status == StatusCode::PARTIAL_CONTENT {
216        match parse_content_range(header(&resp, CONTENT_RANGE).as_deref()) {
217            // The body must actually begin where we asked, or it cannot prime
218            // the transfer no matter how well-formed the header is.
219            Some((0, end, total)) => {
220                let mut info = info_from(&resp, total);
221                info.accept_ranges = true;
222                return Ok(Primed {
223                    info,
224                    body: Some(resp),
225                    // Trust the range the server actually served, not the one we
226                    // asked for: it is free to return fewer bytes.
227                    body_len: end + 1,
228                });
229            }
230            _ => {
231                tracing::warn!(
232                    "server answered our priming range with an unusable Content-Range; \
233                     disabling parallelism"
234                );
235                return Ok(Primed {
236                    info: plain_probe(client, url).await?,
237                    body: None,
238                    body_len: 0,
239                });
240            }
241        }
242    }
243
244    if status.is_success() {
245        let len = header(&resp, CONTENT_LENGTH).and_then(|v| v.parse::<u64>().ok());
246        let mut info = info_from(&resp, len);
247        // `info_from` already read `Accept-Ranges`. Trust it: a server that
248        // advertises ranges but answers a whole-file range with 200 is within
249        // its rights, and its ranged requests may still work. If they do not,
250        // the first ranged worker fails loudly rather than silently corrupting.
251        info.accept_ranges = info.accept_ranges && len.is_some_and(|l| l > 0);
252        return Ok(Primed {
253            info,
254            body: Some(resp),
255            // The server ignored `Range` and sent the whole representation, so
256            // the body covers everything and there is no boundary to pin.
257            body_len: len.unwrap_or(0),
258        });
259    }
260
261    if matches!(
262        status,
263        StatusCode::METHOD_NOT_ALLOWED
264            | StatusCode::NOT_IMPLEMENTED
265            | StatusCode::BAD_REQUEST
266            | StatusCode::RANGE_NOT_SATISFIABLE
267    ) {
268        return Ok(Primed {
269            info: plain_probe(client, url).await?,
270            body: None,
271            body_len: 0,
272        });
273    }
274
275    Err(status_error(&resp))
276}
277
278/// Ask the server what it has, without downloading it.
279///
280/// A one-byte ranged `GET` rather than `HEAD`: plenty of servers and CDNs
281/// answer `HEAD` with different (or absent) headers than they answer `GET`,
282/// and a ranged `GET` tells us in one round trip whether ranges actually work
283/// — as opposed to whether the server merely claims they do.
284///
285/// Prefer [`probe_priming`] for the primary URL; this remains the right call for
286/// mirrors, where we want the metadata and emphatically not the body.
287pub async fn probe(client: &Client, url: &Url) -> Result<RemoteInfo, TransferError> {
288    let resp = client
289        .get(url.clone())
290        .header(RANGE, "bytes=0-0")
291        .send()
292        .await
293        .map_err(|e| TransferError::from_reqwest(&e))?;
294
295    let status = resp.status();
296    if status == StatusCode::PARTIAL_CONTENT {
297        match parse_content_range(header(&resp, CONTENT_RANGE).as_deref()) {
298            Some((_, _, total)) => {
299                let mut info = info_from(&resp, total);
300                // `Accept-Ranges: none` alongside a 206 is contradictory;
301                // believe the 206, which is what we observed working.
302                info.accept_ranges = true;
303                return Ok(info);
304            }
305            None => {
306                // A 206 we cannot interpret means we cannot trust this server's
307                // ranges at all. Downloading it sequentially is still correct,
308                // so fall back rather than fail.
309                tracing::warn!("server sent an unparseable Content-Range; disabling parallelism");
310                return plain_probe(client, url).await;
311            }
312        }
313    }
314
315    if status.is_success() {
316        // Either the server ignores Range, or the resource is a single byte.
317        let len = header(&resp, CONTENT_LENGTH).and_then(|v| v.parse::<u64>().ok());
318        let accepts = header(&resp, ACCEPT_RANGES)
319            .map(|v| v.eq_ignore_ascii_case("bytes"))
320            .unwrap_or(false);
321        let mut info = info_from(&resp, len);
322        // It said 200 to a ranged request: only trust ranges if it also
323        // advertises them *and* the body was too short to be the whole file.
324        info.accept_ranges = accepts && len.is_some_and(|l| l == 1);
325        if !accepts {
326            info.accept_ranges = false;
327        }
328        return Ok(info);
329    }
330
331    // Some origins reject `Range` outright with 400/405/501. Retry plainly so
332    // we can still download sequentially.
333    if matches!(
334        status,
335        StatusCode::METHOD_NOT_ALLOWED
336            | StatusCode::NOT_IMPLEMENTED
337            | StatusCode::BAD_REQUEST
338            | StatusCode::RANGE_NOT_SATISFIABLE
339    ) {
340        return plain_probe(client, url).await;
341    }
342
343    Err(status_error(&resp))
344}
345
346/// Probe without a `Range` header, for servers whose range support is absent or
347/// untrustworthy. Always yields `accept_ranges: false`.
348async fn plain_probe(client: &Client, url: &Url) -> Result<RemoteInfo, TransferError> {
349    let resp = client
350        .get(url.clone())
351        .send()
352        .await
353        .map_err(|e| TransferError::from_reqwest(&e))?;
354    if !resp.status().is_success() {
355        return Err(status_error(&resp));
356    }
357    let len = header(&resp, CONTENT_LENGTH).and_then(|v| v.parse::<u64>().ok());
358    let mut info = info_from(&resp, len);
359    info.accept_ranges = false;
360    Ok(info)
361}
362
363fn info_from(resp: &Response, size: Option<u64>) -> RemoteInfo {
364    RemoteInfo {
365        final_url: resp.url().clone(),
366        size,
367        accept_ranges: header(resp, ACCEPT_RANGES)
368            .map(|v| v.eq_ignore_ascii_case("bytes"))
369            .unwrap_or(false),
370        etag: header(resp, ETAG),
371        last_modified: header(resp, LAST_MODIFIED),
372        content_type: header(resp, CONTENT_TYPE),
373        content_disposition: header(resp, CONTENT_DISPOSITION),
374        content_encoding: header(resp, reqwest::header::CONTENT_ENCODING)
375            .filter(|v| !v.eq_ignore_ascii_case("identity")),
376    }
377}
378
379/// A ranged `GET`, with the response validated against what we asked for.
380///
381/// `validator` is sent as `If-Range`, so a resource that changed since we
382/// started comes back as a `200` full body — which we detect and reject rather
383/// than splicing into the middle of our file (PRD Invariant 4).
384pub async fn get_range(
385    client: &Client,
386    url: &Url,
387    start: u64,
388    end: Option<u64>,
389    validator: Option<&str>,
390    expected_total: Option<u64>,
391) -> Result<Response, TransferError> {
392    let ranged = start > 0 || end.is_some();
393    let mut req = client.get(url.clone());
394    if ranged {
395        let spec = match end {
396            Some(e) => format!("bytes={start}-{e}"),
397            None => format!("bytes={start}-"),
398        };
399        req = req.header(RANGE, spec);
400        if let Some(v) = validator {
401            req = req.header("If-Range", v);
402        }
403    }
404
405    let resp = req
406        .send()
407        .await
408        .map_err(|e| TransferError::from_reqwest(&e))?;
409    let status = resp.status();
410
411    if status == StatusCode::PRECONDITION_FAILED {
412        return Err(TransferError::RemoteChanged(
413            "server rejected our validator (412)".into(),
414        ));
415    }
416    if status == StatusCode::RANGE_NOT_SATISFIABLE {
417        return Err(TransferError::RemoteChanged(format!(
418            "server cannot satisfy bytes={start}- any more (416); the file likely shrank"
419        )));
420    }
421    if !status.is_success() {
422        return Err(status_error(&resp));
423    }
424
425    if !ranged {
426        return Ok(resp);
427    }
428
429    if status == StatusCode::PARTIAL_CONTENT {
430        let (got_start, _got_end, total) =
431            parse_content_range(header(&resp, CONTENT_RANGE).as_deref()).ok_or_else(|| {
432                TransferError::Protocol("206 response with unparseable Content-Range".into())
433            })?;
434        if got_start != start {
435            return Err(TransferError::Protocol(format!(
436                "asked for bytes from {start}, server sent from {got_start}"
437            )));
438        }
439        if let (Some(total), Some(expected)) = (total, expected_total) {
440            if total != expected {
441                return Err(TransferError::RemoteChanged(format!(
442                    "size changed from {expected} to {total} bytes"
443                )));
444            }
445        }
446        return Ok(resp);
447    }
448
449    // 200 to a ranged request. If we sent a validator, this is the RFC 9110
450    // way of saying "it changed, here is the whole thing". Otherwise the server
451    // simply does not implement Range.
452    if validator.is_some() {
453        Err(TransferError::RemoteChanged(
454            "server answered a conditional range with a full body, so the resource changed".into(),
455        ))
456    } else {
457        Err(TransferError::Protocol(
458            "server ignored our Range header and sent the whole body".into(),
459        ))
460    }
461}
462
463fn status_error(resp: &Response) -> TransferError {
464    TransferError::Status {
465        status: resp.status().as_u16(),
466        retry_after: header(resp, RETRY_AFTER).and_then(|v| parse_retry_after(&v)),
467    }
468}
469
470pub fn header(resp: &Response, name: impl reqwest::header::AsHeaderName) -> Option<String> {
471    resp.headers()
472        .get(name)
473        .and_then(|v| v.to_str().ok())
474        .map(|s| s.trim().to_string())
475        .filter(|s| !s.is_empty())
476}
477
478/// `bytes 0-1023/4096` → `(0, 1023, Some(4096))`. `*` total → `None`.
479pub fn parse_content_range(value: Option<&str>) -> Option<(u64, u64, Option<u64>)> {
480    let value = value?.trim();
481    let rest = value.strip_prefix("bytes")?.trim_start();
482    let (span, total) = rest.split_once('/')?;
483    let (start, end) = span.trim().split_once('-')?;
484    let start: u64 = start.trim().parse().ok()?;
485    let end: u64 = end.trim().parse().ok()?;
486    if end < start {
487        return None;
488    }
489    let total = match total.trim() {
490        "*" => None,
491        t => Some(t.parse::<u64>().ok()?),
492    };
493    if let Some(t) = total {
494        // A range that claims to extend past the resource is nonsense.
495        if end >= t {
496            return None;
497        }
498    }
499    Some((start, end, total))
500}
501
502/// `Retry-After` in delta-seconds form. The HTTP-date form is rare in practice
503/// and parsing dates without a date library invites bugs, so we fall back to
504/// our own backoff for it rather than guess.
505pub fn parse_retry_after(value: &str) -> Option<Duration> {
506    let secs: u64 = value.trim().parse().ok()?;
507    Some(Duration::from_secs(secs.min(3600)))
508}
509
510fn base64(input: &str) -> String {
511    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
512    let bytes = input.as_bytes();
513    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
514    for chunk in bytes.chunks(3) {
515        let b = [
516            chunk[0],
517            chunk.get(1).copied().unwrap_or(0),
518            chunk.get(2).copied().unwrap_or(0),
519        ];
520        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
521        out.push(TABLE[(n >> 18) as usize & 63] as char);
522        out.push(TABLE[(n >> 12) as usize & 63] as char);
523        out.push(if chunk.len() > 1 {
524            TABLE[(n >> 6) as usize & 63] as char
525        } else {
526            '='
527        });
528        out.push(if chunk.len() > 2 {
529            TABLE[n as usize & 63] as char
530        } else {
531            '='
532        });
533    }
534    out
535}
536
537/// Strip userinfo and query before a URL can reach a log line (PRD §36).
538pub fn redact(url: &Url) -> String {
539    let mut u = url.clone();
540    let _ = u.set_username("");
541    let _ = u.set_password(None);
542    u.set_query(None);
543    u.set_fragment(None);
544    u.to_string()
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn parses_content_range() {
553        assert_eq!(
554            parse_content_range(Some("bytes 0-1023/4096")),
555            Some((0, 1023, Some(4096)))
556        );
557        assert_eq!(
558            parse_content_range(Some("bytes 500-999/*")),
559            Some((500, 999, None))
560        );
561        // Hostile / malformed forms must not parse into something plausible.
562        assert_eq!(parse_content_range(None), None);
563        assert_eq!(parse_content_range(Some("")), None);
564        assert_eq!(parse_content_range(Some("items 0-1/2")), None);
565        assert_eq!(parse_content_range(Some("bytes 100-50/4096")), None);
566        assert_eq!(parse_content_range(Some("bytes 0-4096/4096")), None);
567        assert_eq!(parse_content_range(Some("bytes abc-def/4096")), None);
568        assert_eq!(parse_content_range(Some("bytes 0-10")), None);
569    }
570
571    #[test]
572    fn parses_retry_after() {
573        assert_eq!(parse_retry_after("7"), Some(Duration::from_secs(7)));
574        assert_eq!(parse_retry_after(" 30 "), Some(Duration::from_secs(30)));
575        // Clamped, so a hostile server cannot park us for a week.
576        assert_eq!(parse_retry_after("999999"), Some(Duration::from_secs(3600)));
577        assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
578    }
579
580    #[test]
581    fn prefers_strong_validators() {
582        let mut info = RemoteInfo {
583            final_url: Url::parse("https://x.example/f").unwrap(),
584            size: Some(10),
585            accept_ranges: true,
586            etag: Some("W/\"weak\"".into()),
587            last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".into()),
588            content_type: None,
589            content_disposition: None,
590            content_encoding: None,
591        };
592        // A weak ETag must not be used as an If-Range validator.
593        assert_eq!(
594            info.validator().as_deref(),
595            Some("Wed, 21 Oct 2015 07:28:00 GMT")
596        );
597        assert!(!info.has_strong_etag());
598
599        info.etag = Some("\"strong\"".into());
600        assert_eq!(info.validator().as_deref(), Some("\"strong\""));
601        assert!(info.has_strong_etag());
602    }
603
604    #[test]
605    fn parallel_requires_size_and_ranges() {
606        let mut info = RemoteInfo {
607            final_url: Url::parse("https://x.example/f").unwrap(),
608            size: Some(1000),
609            accept_ranges: true,
610            etag: None,
611            last_modified: None,
612            content_type: None,
613            content_disposition: None,
614            content_encoding: None,
615        };
616        assert!(info.supports_parallel());
617        info.size = None;
618        assert!(!info.supports_parallel());
619        info.size = Some(1000);
620        info.accept_ranges = false;
621        assert!(!info.supports_parallel());
622    }
623
624    #[test]
625    fn base64_matches_rfc4648() {
626        assert_eq!(base64("user:pass"), "dXNlcjpwYXNz");
627        assert_eq!(base64("a"), "YQ==");
628        assert_eq!(base64("ab"), "YWI=");
629        assert_eq!(base64("abc"), "YWJj");
630    }
631
632    #[test]
633    fn redacts_credentials_and_queries() {
634        let u = Url::parse("https://alice:s3cret@example.com/f.iso?token=abc#frag").unwrap();
635        let out = redact(&u);
636        assert!(!out.contains("s3cret"), "{out}");
637        assert!(!out.contains("token"), "{out}");
638        assert!(out.contains("example.com/f.iso"));
639    }
640
641    #[test]
642    fn basic_auth_header_is_marked_sensitive() {
643        let cfg = HttpConfig {
644            basic_auth: Some(("alice".into(), "s3cret".into())),
645            ..Default::default()
646        };
647        let map = cfg.header_map().unwrap();
648        let value = map.get(AUTHORIZATION).unwrap();
649        assert!(value.is_sensitive());
650        assert!(!format!("{map:?}").contains("s3cret"));
651    }
652
653    #[test]
654    fn rejects_bad_custom_headers() {
655        let cfg = HttpConfig {
656            headers: vec![("X-Bad Name".into(), "v".into())],
657            ..Default::default()
658        };
659        assert!(cfg.header_map().is_err());
660    }
661
662    #[test]
663    fn requests_identity_encoding() {
664        let map = HttpConfig::default().header_map().unwrap();
665        assert_eq!(map.get(ACCEPT_ENCODING).unwrap(), "identity");
666    }
667}