timeout_global (60 s) covered a whole request, and a request can carry 8 MiB (max_request): below about 140 KB/s every block run timed out, was retried from scratch and failed, so a slow link could not read remote files at all. HttpOptions::timeout (now 30 s) bounds connecting and receiving the response headers; the body gets timeout + its size at the new HttpOptions::min_speed (16 KiB/s by default: 94 s for a 1 MiB block). A slow but moving link is not cut off; a stalled one still fails. (ureq has no idle timeout; its body timeout is a total budget.) The test server can throttle bodies and stall mid-body. Test: a 256 KiB block at 256 KiB/s reads with a 300 ms timeout (it failed before), and a body stalled for 20 s fails in under 5 s. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
795 lines
31 KiB
Rust
795 lines
31 KiB
Rust
//! HTTP(S) range requests: [`HttpStorage`].
|
|
//!
|
|
//! Every read is a `GET` with a `Range: bytes=a-b` header, answered `206
|
|
//! Partial Content`. The file is pinned when it is opened:
|
|
//!
|
|
//! - its length comes from the `Content-Range` of the first request (which
|
|
//! also fetches the first block, so opening costs one request);
|
|
//! - a strong `ETag` is sent back as `If-Match` on every later request, and
|
|
//! compared with the `ETag` of every response; without one, `Last-Modified`
|
|
//! is sent as `If-Unmodified-Since` and compared; the length in every
|
|
//! `Content-Range` must stay the same. A file that changes while it is
|
|
//! open is [`RemoteError::FileChanged`], never a mix of old and new bytes.
|
|
//! (A server that sends neither validator cannot be checked beyond the
|
|
//! length; [`HttpOptions::require_validator`] refuses such servers.)
|
|
//! - a `200` answer to the first request whose body is no longer than the
|
|
//! range asked for is the whole file (a server may answer so when the
|
|
//! range covers it): it is kept and read from memory.
|
|
//! - a server that ignores `Range` and answers `200` with the whole file is
|
|
//! refused with [`RemoteError::RangeNotSupported`], unless
|
|
//! [`HttpOptions::allow_full_download`] is set: then the file is
|
|
//! downloaded once, at open, and read from memory.
|
|
//!
|
|
//! Redirects are followed (at most [`HttpOptions::max_redirects`]), but
|
|
//! never from `https` to `http`, and the custom
|
|
//! [`HttpOptions::headers`] are not sent to another origin.
|
|
//!
|
|
//! Timeouts scale with the request: [`HttpOptions::timeout`] to connect
|
|
//! and to get the response headers, and for the body that plus its size at
|
|
//! [`HttpOptions::min_speed`] — a slow link is not cut off mid-block, a
|
|
//! stalled connection still is.
|
|
//!
|
|
//! Transient failures — connection errors, timeouts, `408`/`429`/`5xx`, and
|
|
//! a body shorter or longer than its `Content-Range` — are retried with
|
|
//! exponential backoff. Responses are requested with
|
|
//! `Accept-Encoding: identity`, since a compressed body cannot be a byte
|
|
//! range of the file.
|
|
//!
|
|
//! `HttpStorage` itself does not cache: each `read_at` is one request. Read
|
|
//! it through [`BlockCache`](crate::BlockCache) (which [`open_url`](crate::open_url)
|
|
//! does); its `read_ranges` fetches the ranges of one call in parallel.
|
|
|
|
use std::borrow::Cow;
|
|
use std::io::Read;
|
|
use std::ops::Range;
|
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use clawhdf5_format::error::FormatError;
|
|
use clawhdf5_format::storage::Storage;
|
|
|
|
use crate::error::{Redactor, RemoteError, redact_url};
|
|
|
|
/// Settings of an [`HttpStorage`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct HttpOptions {
|
|
/// Retries of a request that failed transiently (so up to `retries + 1`
|
|
/// attempts).
|
|
pub retries: u32,
|
|
/// Delay before the first retry; doubled for each further one.
|
|
pub backoff: Duration,
|
|
/// Time allowed to connect, and then to receive the response headers.
|
|
/// The body gets this plus the time it takes at
|
|
/// [`min_speed`](Self::min_speed), so a request's budget grows with its
|
|
/// size: a slow but moving link is not cut off, a stalled one is.
|
|
pub timeout: Duration,
|
|
/// Slowest transfer rate tolerated, in bytes per second: receiving a
|
|
/// body of `n` bytes may take `timeout + n / min_speed` (16 KiB/s by
|
|
/// default: 94 s for a 1 MiB block, 9 min for an 8 MiB request).
|
|
pub min_speed: u64,
|
|
/// Requests of one `read_ranges` call in flight at once.
|
|
pub max_parallel: usize,
|
|
/// Bytes fetched by the first request, from offset 0 (the superblock and
|
|
/// usually the root group's metadata); at least 1.
|
|
pub first_request: u64,
|
|
/// When the server ignores `Range` (answers `200`), download the whole
|
|
/// file once and read it from memory, instead of failing.
|
|
pub allow_full_download: bool,
|
|
/// Largest file [`allow_full_download`](Self::allow_full_download) will
|
|
/// download.
|
|
pub max_full_download: u64,
|
|
/// Refuse a server that sends neither a strong `ETag` nor
|
|
/// `Last-Modified`, since a change of the file could then go unnoticed
|
|
/// (only its length is checked).
|
|
pub require_validator: bool,
|
|
/// Extra headers sent with every request to the URL's own origin (for
|
|
/// example `Authorization` or `X-Api-Key`). They are never sent to
|
|
/// another origin a redirect leads to.
|
|
pub headers: Vec<(String, String)>,
|
|
/// Redirects followed per request (0: none, a redirect is an error).
|
|
/// A redirect from `https` to plain `http` is always refused; once a
|
|
/// redirect leaves the URL's origin (scheme, host and port), the
|
|
/// [`headers`](Self::headers) are no longer sent. Every request of a
|
|
/// file follows the redirects again (the target is not remembered, as
|
|
/// a presigned target may expire).
|
|
pub max_redirects: u32,
|
|
}
|
|
|
|
impl Default for HttpOptions {
|
|
fn default() -> Self {
|
|
HttpOptions {
|
|
retries: 3,
|
|
backoff: Duration::from_millis(200),
|
|
timeout: Duration::from_secs(30),
|
|
min_speed: 16 << 10,
|
|
max_parallel: 8,
|
|
first_request: crate::cache::DEFAULT_BLOCK_SIZE,
|
|
allow_full_download: false,
|
|
max_full_download: 1 << 30,
|
|
require_validator: false,
|
|
headers: Vec::new(),
|
|
max_redirects: 5,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Requests and bytes an [`HttpStorage`] has used.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub struct HttpStats {
|
|
/// HTTP requests sent (retries included).
|
|
pub requests: u64,
|
|
/// Requests that were retries.
|
|
pub retries: u64,
|
|
/// Response body bytes received.
|
|
pub bytes: u64,
|
|
}
|
|
|
|
/// How the file is pinned.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum Validator {
|
|
ETag(String),
|
|
LastModified(String),
|
|
None,
|
|
}
|
|
|
|
/// An HTTP(S) file read by range requests. See the [module
|
|
/// documentation](self).
|
|
pub struct HttpStorage {
|
|
agent: ureq::Agent,
|
|
/// The URL as given, credentials and all: only ever sent to the server.
|
|
url: String,
|
|
/// Shows the URL without its credentials, in every message.
|
|
redactor: Redactor,
|
|
len: u64,
|
|
validator: Validator,
|
|
options: HttpOptions,
|
|
/// The whole file, when the server ignores ranges and a full download
|
|
/// was allowed.
|
|
full: Option<Vec<u8>>,
|
|
requests: AtomicU64,
|
|
retries: AtomicU64,
|
|
bytes: AtomicU64,
|
|
}
|
|
|
|
impl std::fmt::Debug for HttpStorage {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("HttpStorage")
|
|
.field("url", &self.redactor.shown())
|
|
.field("len", &self.len)
|
|
.field("validator", &self.validator)
|
|
.field("full_download", &self.full.is_some())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// A parsed `Content-Range: bytes a-b/total`.
|
|
fn content_range(v: &str) -> Option<(u64, u64, Option<u64>)> {
|
|
let rest = v.trim().strip_prefix("bytes")?.trim_start();
|
|
let (span, total) = rest.split_once('/')?;
|
|
let (a, b) = span.trim().split_once('-')?;
|
|
let a: u64 = a.trim().parse().ok()?;
|
|
let b: u64 = b.trim().parse().ok()?;
|
|
if b < a {
|
|
return None;
|
|
}
|
|
let total = match total.trim() {
|
|
"*" => None,
|
|
t => Some(t.parse().ok()?),
|
|
};
|
|
Some((a, b, total))
|
|
}
|
|
|
|
fn header<'a>(resp: &'a ureq::http::Response<ureq::Body>, name: &str) -> Option<&'a str> {
|
|
resp.headers().get(name).and_then(|v| v.to_str().ok())
|
|
}
|
|
|
|
/// A body with a `Content-Encoding` is not a byte range of the file.
|
|
fn check_identity(url: &str, resp: &ureq::http::Response<ureq::Body>) -> Result<(), RemoteError> {
|
|
match header(resp, "content-encoding") {
|
|
Some(enc) if !enc.trim().eq_ignore_ascii_case("identity") => {
|
|
Err(RemoteError::Usage(format!(
|
|
"{url}: the server sent a {enc}-encoded body despite Accept-Encoding: identity"
|
|
)))
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// Scheme, host (lowercase, no userinfo) and port of an absolute URL.
|
|
fn origin(url: &str) -> Option<(String, String, u16)> {
|
|
let (scheme, rest) = url.split_once("://")?;
|
|
let scheme = scheme.to_ascii_lowercase();
|
|
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
|
|
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
|
let default = match scheme.as_str() {
|
|
"http" => 80,
|
|
"https" => 443,
|
|
_ => return None,
|
|
};
|
|
// "[v6]:port", "host:port", or either without a port.
|
|
let (host, port) = match host_port.rsplit_once(':') {
|
|
Some((h, p)) if !p.contains(']') => (h, p.parse().ok()?),
|
|
_ => (host_port, default),
|
|
};
|
|
Some((scheme, host.to_ascii_lowercase(), port))
|
|
}
|
|
|
|
fn same_origin(a: &str, b: &str) -> bool {
|
|
matches!((origin(a), origin(b)), (Some(x), Some(y)) if x == y)
|
|
}
|
|
|
|
/// The URL a redirect from `base` to `location` goes to, if it may be
|
|
/// followed: `http`/`https` only, and never from `https` to `http`.
|
|
fn redirect_target(base: &str, location: &str) -> Result<String, RemoteError> {
|
|
let location = location.trim();
|
|
let (scheme, rest) = base.split_once("://").unwrap_or(("http", base));
|
|
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
|
let (authority, path) = rest.split_at(authority_end);
|
|
let target = if location.contains("://") {
|
|
location.to_string()
|
|
} else if let Some(r) = location.strip_prefix("//") {
|
|
format!("{scheme}://{r}")
|
|
} else if location.starts_with('/') {
|
|
format!("{scheme}://{authority}{location}")
|
|
} else {
|
|
let path = path.split(['?', '#']).next().unwrap_or("");
|
|
let dir = path.rsplit_once('/').map_or("", |(d, _)| d);
|
|
format!("{scheme}://{authority}{dir}/{location}")
|
|
};
|
|
let Some((to_scheme, _, _)) = origin(&target) else {
|
|
return Err(RemoteError::Redirect(format!(
|
|
"{} redirects to {}, which is not an http(s) URL",
|
|
redact_url(base),
|
|
redact_url(&target)
|
|
)));
|
|
};
|
|
if scheme.eq_ignore_ascii_case("https") && to_scheme != "https" {
|
|
return Err(RemoteError::Redirect(format!(
|
|
"{} redirects to {}: a downgrade from https is refused",
|
|
redact_url(base),
|
|
redact_url(&target)
|
|
)));
|
|
}
|
|
Ok(target)
|
|
}
|
|
|
|
fn transport(e: ureq::Error) -> RemoteError {
|
|
match e {
|
|
ureq::Error::StatusCode(code) => RemoteError::Status {
|
|
code,
|
|
what: "request".into(),
|
|
},
|
|
ureq::Error::BadUri(s) => RemoteError::InvalidUrl(s),
|
|
other => RemoteError::Transport(other.to_string()),
|
|
}
|
|
}
|
|
|
|
impl HttpStorage {
|
|
/// Open `url` (`http://`, or `https://` with the `https` feature):
|
|
/// one ranged `GET` of the first [`HttpOptions::first_request`] bytes,
|
|
/// which gives the file's length and validators. Returns the storage
|
|
/// and the bytes that request fetched (the file's start), for a
|
|
/// [`BlockCache`](crate::BlockCache) to keep.
|
|
pub fn open(url: &str, options: HttpOptions) -> Result<(HttpStorage, Vec<u8>), RemoteError> {
|
|
let redactor = Redactor::new(url);
|
|
HttpStorage::open_inner(url, options, &redactor).map_err(|e| e.scrubbed(&redactor))
|
|
}
|
|
|
|
fn open_inner(
|
|
url: &str,
|
|
options: HttpOptions,
|
|
redactor: &Redactor,
|
|
) -> Result<(HttpStorage, Vec<u8>), RemoteError> {
|
|
let shown = redactor.shown();
|
|
let lower = url.to_ascii_lowercase();
|
|
if lower.starts_with("https://") {
|
|
if !cfg!(feature = "https") {
|
|
return Err(RemoteError::UnsupportedScheme(format!(
|
|
"{shown}: https:// needs the `https` feature of clawhdf5-remote"
|
|
)));
|
|
}
|
|
} else if !lower.starts_with("http://") {
|
|
return Err(RemoteError::UnsupportedScheme(shown.to_string()));
|
|
}
|
|
// Redirects are followed by `call`, which applies our rules.
|
|
let config = ureq::Agent::config_builder()
|
|
.http_status_as_error(false)
|
|
.max_redirects(0)
|
|
.timeout_connect(Some(options.timeout))
|
|
.timeout_recv_response(Some(options.timeout))
|
|
.build();
|
|
let mut storage = HttpStorage {
|
|
agent: ureq::Agent::new_with_config(config),
|
|
url: url.to_string(),
|
|
redactor: redactor.clone(),
|
|
len: 0,
|
|
validator: Validator::None,
|
|
options,
|
|
full: None,
|
|
requests: AtomicU64::new(0),
|
|
retries: AtomicU64::new(0),
|
|
bytes: AtomicU64::new(0),
|
|
};
|
|
let first = storage.with_retries(|| storage.probe())?;
|
|
let (len, validator, bytes, full) = first;
|
|
storage.len = len;
|
|
storage.validator = validator;
|
|
if full {
|
|
storage.full = Some(bytes);
|
|
return Ok((storage, Vec::new()));
|
|
}
|
|
if storage.options.require_validator && storage.validator == Validator::None {
|
|
return Err(RemoteError::Usage(format!(
|
|
"{shown}: the server sends neither a strong ETag nor Last-Modified, so a change \
|
|
of the file could not be detected (HttpOptions::require_validator)"
|
|
)));
|
|
}
|
|
Ok((storage, bytes))
|
|
}
|
|
|
|
/// The URL as given — with any credentials it carries, so do not log
|
|
/// it; [`redact_url`](crate::redact_url) gives a form that can be.
|
|
pub fn url(&self) -> &str {
|
|
&self.url
|
|
}
|
|
|
|
/// Whether the file was downloaded whole, because the server does not
|
|
/// support ranges and [`HttpOptions::allow_full_download`] was set.
|
|
pub fn is_full_download(&self) -> bool {
|
|
self.full.is_some()
|
|
}
|
|
|
|
/// The `ETag` the file is pinned to, if the server sent a strong one.
|
|
pub fn etag(&self) -> Option<&str> {
|
|
match &self.validator {
|
|
Validator::ETag(e) => Some(e),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Requests and bytes so far.
|
|
pub fn stats(&self) -> HttpStats {
|
|
HttpStats {
|
|
requests: self.requests.load(Ordering::Relaxed),
|
|
retries: self.retries.load(Ordering::Relaxed),
|
|
bytes: self.bytes.load(Ordering::Relaxed),
|
|
}
|
|
}
|
|
|
|
fn with_retries<T>(
|
|
&self,
|
|
mut attempt: impl FnMut() -> Result<T, RemoteError>,
|
|
) -> Result<T, RemoteError> {
|
|
let mut delay = self.options.backoff;
|
|
let mut n = 0;
|
|
loop {
|
|
match attempt() {
|
|
Ok(v) => return Ok(v),
|
|
Err(e) if e.is_transient() && n < self.options.retries => {
|
|
n += 1;
|
|
self.retries.fetch_add(1, Ordering::Relaxed);
|
|
std::thread::sleep(delay);
|
|
delay = delay.saturating_mul(2);
|
|
}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One `GET` of `url`. The options' custom headers are sent only to
|
|
/// the URL's own origin (`trusted`).
|
|
fn request(
|
|
&self,
|
|
url: &str,
|
|
range: Option<(u64, u64)>,
|
|
trusted: bool,
|
|
) -> ureq::RequestBuilder<ureq::typestate::WithoutBody> {
|
|
let mut req = self.agent.get(url).header("Accept-Encoding", "identity");
|
|
if let Some((a, b)) = range {
|
|
req = req.header("Range", format!("bytes={a}-{b}"));
|
|
}
|
|
// The body's budget scales with what it may carry: the range, or
|
|
// a whole file the server may send instead.
|
|
let mut body = range.map_or(0, |(a, b)| b.saturating_sub(a).saturating_add(1));
|
|
if self.options.allow_full_download {
|
|
body = body.max(self.options.max_full_download);
|
|
}
|
|
let secs = body as f64 / self.options.min_speed.max(1) as f64;
|
|
let body_timeout = self
|
|
.options
|
|
.timeout
|
|
.saturating_add(Duration::try_from_secs_f64(secs).unwrap_or(Duration::MAX));
|
|
let mut req = req.config().timeout_recv_body(Some(body_timeout)).build();
|
|
match &self.validator {
|
|
Validator::ETag(e) => req = req.header("If-Match", e),
|
|
Validator::LastModified(t) => req = req.header("If-Unmodified-Since", t),
|
|
Validator::None => {}
|
|
}
|
|
if trusted {
|
|
for (k, v) in &self.options.headers {
|
|
req = req.header(k, v);
|
|
}
|
|
}
|
|
req
|
|
}
|
|
|
|
/// Send a ranged `GET`, following redirects by the rules of
|
|
/// [`HttpOptions::max_redirects`]: at most that many, never from
|
|
/// `https` to anything else, and without the custom headers once the
|
|
/// chain has left the URL's origin. Each hop counts as a request.
|
|
fn call(
|
|
&self,
|
|
range: Option<(u64, u64)>,
|
|
) -> Result<ureq::http::Response<ureq::Body>, RemoteError> {
|
|
let mut url = self.url.clone();
|
|
let mut trusted = true;
|
|
let mut hops = 0u32;
|
|
loop {
|
|
if hops > 0 {
|
|
self.requests.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
let resp = self
|
|
.request(&url, range, trusted)
|
|
.call()
|
|
.map_err(|e| transport(e).scrubbed(&Redactor::new(&url)))?;
|
|
if !matches!(resp.status().as_u16(), 301 | 302 | 303 | 307 | 308) {
|
|
return Ok(resp);
|
|
}
|
|
let status = resp.status().as_u16();
|
|
let Some(location) = header(&resp, "location") else {
|
|
return Err(RemoteError::BadResponse(format!(
|
|
"{}: status {status} without a Location",
|
|
redact_url(&url)
|
|
)));
|
|
};
|
|
let next = redirect_target(&url, location)?;
|
|
if hops >= self.options.max_redirects {
|
|
return Err(RemoteError::Redirect(format!(
|
|
"{} redirects to {}: more than HttpOptions::max_redirects ({})",
|
|
redact_url(&url),
|
|
redact_url(&next),
|
|
self.options.max_redirects
|
|
)));
|
|
}
|
|
if !same_origin(&next, &self.url) {
|
|
trusted = false;
|
|
}
|
|
url = next;
|
|
hops += 1;
|
|
}
|
|
}
|
|
|
|
/// Read a body of exactly `want` bytes (or up to `limit` when `want` is
|
|
/// unknown).
|
|
fn body(
|
|
&self,
|
|
resp: ureq::http::Response<ureq::Body>,
|
|
want: Option<u64>,
|
|
limit: u64,
|
|
) -> Result<Vec<u8>, RemoteError> {
|
|
let cap = want.unwrap_or(limit);
|
|
let mut buf = Vec::with_capacity(usize::try_from(cap.min(64 << 20)).unwrap_or(0));
|
|
let reader = resp.into_body().into_reader();
|
|
let got = reader
|
|
.take(cap.saturating_add(1))
|
|
.read_to_end(&mut buf)
|
|
.map_err(|e| {
|
|
RemoteError::Transport(format!("{}: reading the body: {e}", self.redactor.shown()))
|
|
});
|
|
self.bytes.fetch_add(buf.len() as u64, Ordering::Relaxed);
|
|
got?;
|
|
match want {
|
|
Some(n) if buf.len() as u64 != n => Err(RemoteError::BadResponse(format!(
|
|
"{}: body of {} bytes, expected {n}",
|
|
self.redactor.shown(),
|
|
buf.len()
|
|
))),
|
|
None if buf.len() as u64 > limit => Err(RemoteError::Usage(format!(
|
|
"{}: the file is larger than HttpOptions::max_full_download ({limit} bytes)",
|
|
self.redactor.shown()
|
|
))),
|
|
_ => Ok(buf),
|
|
}
|
|
}
|
|
|
|
/// The first request: length, validators and the file's first bytes.
|
|
/// The last field is true when the server ignored the range and sent
|
|
/// the whole file (only kept when a full download is allowed).
|
|
fn probe(&self) -> Result<(u64, Validator, Vec<u8>, bool), RemoteError> {
|
|
let n = self.options.first_request.max(1);
|
|
self.requests.fetch_add(1, Ordering::Relaxed);
|
|
let resp = self.call(Some((0, n - 1)))?;
|
|
let status = resp.status().as_u16();
|
|
check_identity(self.redactor.shown(), &resp)?;
|
|
let validator = match (header(&resp, "etag"), header(&resp, "last-modified")) {
|
|
(Some(e), _) if !e.starts_with("W/") => Validator::ETag(e.to_string()),
|
|
(_, Some(t)) => Validator::LastModified(t.to_string()),
|
|
_ => Validator::None,
|
|
};
|
|
match status {
|
|
206 => {
|
|
let cr = header(&resp, "content-range").ok_or_else(|| {
|
|
RemoteError::BadResponse(format!(
|
|
"{}: 206 without Content-Range",
|
|
self.redactor.shown()
|
|
))
|
|
})?;
|
|
let (a, b, total) = content_range(cr).ok_or_else(|| {
|
|
RemoteError::BadResponse(format!(
|
|
"{}: bad Content-Range {cr:?}",
|
|
self.redactor.shown()
|
|
))
|
|
})?;
|
|
let total = total.ok_or_else(|| {
|
|
RemoteError::BadResponse(format!(
|
|
"{}: the server does not report the file's length (Content-Range {cr:?})",
|
|
self.redactor.shown()
|
|
))
|
|
})?;
|
|
if a != 0 || b >= total || b > n - 1 {
|
|
return Err(RemoteError::BadResponse(format!(
|
|
"{}: asked for bytes 0-{}, got Content-Range {cr:?}",
|
|
self.redactor.shown(),
|
|
n - 1
|
|
)));
|
|
}
|
|
let bytes = self.body(resp, Some(b - a + 1), 0)?;
|
|
Ok((total, validator, bytes, false))
|
|
}
|
|
200 => {
|
|
// RFC 9110 lets a server answer 200 when the range covers
|
|
// the whole file: a body no longer than the range asked
|
|
// for is the whole file, ranges supported or not.
|
|
let want: Option<u64> =
|
|
header(&resp, "content-length").and_then(|v| v.trim().parse().ok());
|
|
let refused = || {
|
|
RemoteError::RangeNotSupported(format!(
|
|
"{} answered a range request with the whole file (status 200); set \
|
|
HttpOptions::allow_full_download to download it",
|
|
self.redactor.shown()
|
|
))
|
|
};
|
|
if !self.options.allow_full_download {
|
|
return match want {
|
|
Some(w) if w <= n => {
|
|
let bytes = self.body(resp, Some(w), 0)?;
|
|
Ok((bytes.len() as u64, validator, bytes, true))
|
|
}
|
|
Some(_) => Err(refused()),
|
|
// No length: read at most the range asked for.
|
|
None => match self.body(resp, None, n) {
|
|
Ok(bytes) => Ok((bytes.len() as u64, validator, bytes, true)),
|
|
Err(RemoteError::Usage(_)) => Err(refused()),
|
|
Err(e) => Err(e),
|
|
},
|
|
};
|
|
}
|
|
if want.is_some_and(|w| w > self.options.max_full_download) {
|
|
return Err(RemoteError::Usage(format!(
|
|
"{}: the file is larger than HttpOptions::max_full_download ({} bytes)",
|
|
self.redactor.shown(),
|
|
self.options.max_full_download
|
|
)));
|
|
}
|
|
let bytes = self.body(resp, want, self.options.max_full_download)?;
|
|
Ok((bytes.len() as u64, validator, bytes, true))
|
|
}
|
|
416 => Err(RemoteError::Usage(format!(
|
|
"{}: status 416 for the first bytes (an empty file?)",
|
|
self.redactor.shown()
|
|
))),
|
|
code => Err(RemoteError::Status {
|
|
code,
|
|
what: self.redactor.shown().to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// One request for `[start, end)` (inside the file, non-empty).
|
|
fn fetch_once(&self, start: u64, end: u64) -> Result<Vec<u8>, RemoteError> {
|
|
self.requests.fetch_add(1, Ordering::Relaxed);
|
|
let resp = self.call(Some((start, end - 1)))?;
|
|
let changed =
|
|
|why: String| RemoteError::FileChanged(format!("{}: {why}", self.redactor.shown()));
|
|
check_identity(self.redactor.shown(), &resp)?;
|
|
match resp.status().as_u16() {
|
|
206 => {}
|
|
200 => {
|
|
return Err(RemoteError::RangeNotSupported(format!(
|
|
"{} answered a range request with the whole file (status 200)",
|
|
self.redactor.shown()
|
|
)));
|
|
}
|
|
412 => {
|
|
return Err(changed(
|
|
"If-Match/If-Unmodified-Since failed (status 412)".into(),
|
|
));
|
|
}
|
|
416 => return Err(changed("range no longer satisfiable (status 416)".into())),
|
|
code => {
|
|
return Err(RemoteError::Status {
|
|
code,
|
|
what: format!("{} bytes {start}-{}", self.redactor.shown(), end - 1),
|
|
});
|
|
}
|
|
}
|
|
match &self.validator {
|
|
Validator::ETag(e) => {
|
|
if let Some(got) = header(&resp, "etag")
|
|
&& got != e
|
|
{
|
|
return Err(changed(format!("ETag {got} instead of {e}")));
|
|
}
|
|
}
|
|
Validator::LastModified(t) => {
|
|
if let Some(got) = header(&resp, "last-modified")
|
|
&& got != t
|
|
{
|
|
return Err(changed(format!("Last-Modified {got} instead of {t}")));
|
|
}
|
|
}
|
|
Validator::None => {}
|
|
}
|
|
let cr = header(&resp, "content-range").ok_or_else(|| {
|
|
RemoteError::BadResponse(format!(
|
|
"{}: 206 without Content-Range",
|
|
self.redactor.shown()
|
|
))
|
|
})?;
|
|
let (a, b, total) = content_range(cr).ok_or_else(|| {
|
|
RemoteError::BadResponse(format!(
|
|
"{}: bad Content-Range {cr:?}",
|
|
self.redactor.shown()
|
|
))
|
|
})?;
|
|
if let Some(total) = total
|
|
&& total != self.len
|
|
{
|
|
return Err(changed(format!("length {total} instead of {}", self.len)));
|
|
}
|
|
if a != start || b != end - 1 {
|
|
return Err(RemoteError::BadResponse(format!(
|
|
"{}: asked for bytes {start}-{}, got Content-Range {cr:?}",
|
|
self.redactor.shown(),
|
|
end - 1
|
|
)));
|
|
}
|
|
self.body(resp, Some(end - start), 0)
|
|
}
|
|
|
|
fn fetch(&self, start: u64, end: u64) -> Result<Vec<u8>, RemoteError> {
|
|
self.with_retries(|| self.fetch_once(start, end))
|
|
.map_err(|e| e.scrubbed(&self.redactor))
|
|
}
|
|
|
|
/// `[offset, offset + len)` clamped to the file, or `None` if empty.
|
|
fn clamp(&self, offset: u64, len: u64) -> Option<(u64, u64)> {
|
|
let end = offset.saturating_add(len).min(self.len);
|
|
(offset < end).then_some((offset, end))
|
|
}
|
|
}
|
|
|
|
impl Storage for HttpStorage {
|
|
fn read_at(&self, offset: u64, len: usize) -> Result<Cow<'_, [u8]>, FormatError> {
|
|
if let Some(all) = &self.full {
|
|
return all.as_slice().read_at(offset, len);
|
|
}
|
|
match self.clamp(offset, len as u64) {
|
|
None => Ok(Cow::Owned(Vec::new())),
|
|
Some((a, b)) => Ok(Cow::Owned(self.fetch(a, b)?)),
|
|
}
|
|
}
|
|
|
|
fn len(&self) -> u64 {
|
|
self.len
|
|
}
|
|
|
|
fn read_ranges(&self, ranges: &[Range<u64>]) -> Result<Vec<Cow<'_, [u8]>>, FormatError> {
|
|
if let Some(all) = &self.full {
|
|
return all.as_slice().read_ranges(ranges);
|
|
}
|
|
let parallel = self.options.max_parallel.clamp(1, ranges.len().max(1));
|
|
if parallel <= 1 {
|
|
return ranges
|
|
.iter()
|
|
.map(|r| self.read_at(r.start, (r.end.saturating_sub(r.start)) as usize))
|
|
.collect();
|
|
}
|
|
let next = AtomicUsize::new(0);
|
|
let failed = std::sync::atomic::AtomicBool::new(false);
|
|
type Slot = Option<Result<Vec<u8>, RemoteError>>;
|
|
let results: std::sync::Mutex<Vec<Slot>> =
|
|
std::sync::Mutex::new((0..ranges.len()).map(|_| None).collect());
|
|
std::thread::scope(|s| {
|
|
for _ in 0..parallel {
|
|
s.spawn(|| {
|
|
loop {
|
|
let i = next.fetch_add(1, Ordering::Relaxed);
|
|
if i >= ranges.len() || failed.load(Ordering::Relaxed) {
|
|
break;
|
|
}
|
|
let r = &ranges[i];
|
|
let got = match self.clamp(r.start, r.end.saturating_sub(r.start)) {
|
|
None => Ok(Vec::new()),
|
|
Some((a, b)) => self.fetch(a, b),
|
|
};
|
|
if got.is_err() {
|
|
failed.store(true, Ordering::Relaxed);
|
|
}
|
|
results
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)[i] = Some(got);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
let results = results
|
|
.into_inner()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let mut out = Vec::with_capacity(ranges.len());
|
|
for r in results {
|
|
match r {
|
|
Some(Ok(v)) => out.push(Cow::Owned(v)),
|
|
Some(Err(e)) => return Err(e.into()),
|
|
// Not fetched because another range failed first.
|
|
None => continue,
|
|
}
|
|
}
|
|
if out.len() != ranges.len() {
|
|
return Err(FormatError::Storage(format!(
|
|
"{}: a parallel range read failed",
|
|
self.redactor.shown()
|
|
)));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn as_contiguous(&self) -> Option<&[u8]> {
|
|
self.full.as_deref()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::content_range;
|
|
|
|
#[test]
|
|
fn content_range_parses() {
|
|
assert_eq!(content_range("bytes 0-99/1000"), Some((0, 99, Some(1000))));
|
|
assert_eq!(content_range("bytes 5-5/*"), Some((5, 5, None)));
|
|
assert_eq!(content_range("bytes 9-5/10"), None);
|
|
assert_eq!(content_range("items 0-1/2"), None);
|
|
assert_eq!(content_range("bytes */1000"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn redirect_targets_and_origins() {
|
|
use super::{redirect_target, same_origin};
|
|
let t = |b: &str, l: &str| redirect_target(b, l).map_err(|e| e.to_string());
|
|
assert_eq!(
|
|
t("http://a:8/d/f.h5?x=1", "g.h5").unwrap(),
|
|
"http://a:8/d/g.h5"
|
|
);
|
|
assert_eq!(t("http://a/d/f.h5", "/g.h5").unwrap(), "http://a/g.h5");
|
|
assert_eq!(t("https://a/d/f.h5", "//b/g.h5").unwrap(), "https://b/g.h5");
|
|
assert_eq!(t("http://a/f", "https://b/g").unwrap(), "https://b/g");
|
|
let e = t("https://a/f.h5", "http://a/f.h5").unwrap_err();
|
|
assert!(e.contains("downgrade"), "{e}");
|
|
assert!(t("HTTPS://a/f.h5", "http://b/f.h5").is_err());
|
|
assert!(t("https://a/f", "//b/g").is_ok());
|
|
assert!(t("http://a/f", "ftp://b/g").is_err());
|
|
let e = t("https://u:pw@a/f?sig=SECRET", "http://b/g?sig=OTHER").unwrap_err();
|
|
assert!(
|
|
!e.contains("SECRET") && !e.contains("OTHER") && !e.contains("pw"),
|
|
"{e}"
|
|
);
|
|
assert!(same_origin("http://a/x", "http://A:80/y"));
|
|
assert!(same_origin("https://u:p@a:443/x", "https://a/y"));
|
|
assert!(!same_origin("http://a/x", "https://a/x"));
|
|
assert!(!same_origin("http://a:1/x", "http://a:2/x"));
|
|
assert!(!same_origin("http://a/x", "http://b/x"));
|
|
assert!(same_origin("http://[::1]:8/x", "http://[::1]:8/y"));
|
|
assert!(!same_origin("http://[::1]/x", "http://[::1]:8/y"));
|
|
}
|
|
}
|