1use 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 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 pub fn header_map(&self) -> Result<HeaderMap> {
56 let mut map = HeaderMap::new();
57 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 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 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 .referer(false)
106 .connect_timeout(cfg.timeout)
107 .pool_idle_timeout(Duration::from_secs(90))
108 .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#[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 pub content_encoding: Option<String>,
133}
134
135impl RemoteInfo {
136 pub fn supports_parallel(&self) -> bool {
138 self.accept_ranges && self.size.is_some_and(|s| s > 0)
139 }
140
141 pub fn validator(&self) -> Option<String> {
143 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
158pub struct Primed {
160 pub info: RemoteInfo,
161 pub body: Option<Response>,
165 pub body_len: u64,
169}
170
171pub 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 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 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.accept_ranges = info.accept_ranges && len.is_some_and(|l| l > 0);
252 return Ok(Primed {
253 info,
254 body: Some(resp),
255 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
278pub 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 info.accept_ranges = true;
303 return Ok(info);
304 }
305 None => {
306 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 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 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 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
346async 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
379pub 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 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
478pub 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 if end >= t {
496 return None;
497 }
498 }
499 Some((start, end, total))
500}
501
502pub 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
537pub 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 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 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 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}