checked_div in the test server's throttle, a slice for the single range of fetch_first, and dead-code allowances for the redaction helpers in a build with neither http nor a cloud store. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
298 lines
10 KiB
Rust
298 lines
10 KiB
Rust
//! Errors of the remote backends.
|
|
|
|
use clawhdf5_format::error::FormatError;
|
|
|
|
/// `url` as it may be shown in an error, a `Debug` output or a log: no
|
|
/// userinfo (`user:password@`), no fragment, and the query string's values
|
|
/// replaced by `REDACTED` (a presigned S3/GCS/Azure URL carries its
|
|
/// signature or token there). Keys are kept when they look like plain
|
|
/// names, so a message still says which kind of URL it was.
|
|
///
|
|
/// ```
|
|
/// assert_eq!(
|
|
/// clawhdf5_remote::redact_url("https://me:pw@host/f.h5?X-Amz-Signature=abc&a=1#x"),
|
|
/// "https://host/f.h5?X-Amz-Signature=REDACTED&a=REDACTED"
|
|
/// );
|
|
/// ```
|
|
pub fn redact_url(url: &str) -> String {
|
|
let (scheme, rest) = match url.split_once("://") {
|
|
Some((s, r)) => (Some(s), r),
|
|
None => (None, url),
|
|
};
|
|
let rest = rest.split('#').next().unwrap_or("");
|
|
let (before_query, query) = match rest.split_once('?') {
|
|
Some((a, q)) => (a, Some(q)),
|
|
None => (rest, None),
|
|
};
|
|
let mut out = String::with_capacity(url.len());
|
|
if let Some(s) = scheme {
|
|
out.push_str(s);
|
|
out.push_str("://");
|
|
let auth_end = before_query.find('/').unwrap_or(before_query.len());
|
|
let (authority, path) = before_query.split_at(auth_end);
|
|
out.push_str(
|
|
authority
|
|
.rsplit_once('@')
|
|
.map_or(authority, |(_, host)| host),
|
|
);
|
|
out.push_str(path);
|
|
} else {
|
|
out.push_str(before_query);
|
|
}
|
|
if let Some(q) = query {
|
|
out.push('?');
|
|
let plain = |k: &str| {
|
|
!k.is_empty()
|
|
&& k.len() <= 64
|
|
&& k.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
|
|
};
|
|
let parts: Vec<String> = q
|
|
.split('&')
|
|
.map(|kv| {
|
|
let k = kv.split('=').next().unwrap_or("");
|
|
if plain(k) {
|
|
format!("{k}=REDACTED")
|
|
} else {
|
|
"REDACTED".to_string()
|
|
}
|
|
})
|
|
.collect();
|
|
out.push_str(&parts.join("&"));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Replaces the secret parts of one URL (its userinfo and query string,
|
|
/// and the URL itself) wherever they appear in a message — such as the
|
|
/// text of an error from the HTTP client.
|
|
#[cfg_attr(
|
|
not(any(feature = "http", feature = "s3", feature = "gcs", feature = "azure")),
|
|
allow(dead_code)
|
|
)]
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct Redactor {
|
|
shown: String,
|
|
secrets: Vec<(String, String)>,
|
|
}
|
|
|
|
#[cfg_attr(
|
|
not(any(feature = "http", feature = "s3", feature = "gcs", feature = "azure")),
|
|
allow(dead_code)
|
|
)]
|
|
impl Redactor {
|
|
pub(crate) fn new(url: &str) -> Redactor {
|
|
let shown = redact_url(url);
|
|
let mut secrets = vec![(url.to_string(), shown.clone())];
|
|
let rest = url.split_once("://").map_or(url, |(_, r)| r);
|
|
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
|
|
if let Some((userinfo, _)) = authority.rsplit_once('@')
|
|
&& !userinfo.is_empty()
|
|
{
|
|
secrets.push((format!("{userinfo}@"), String::new()));
|
|
secrets.push((userinfo.to_string(), "REDACTED".into()));
|
|
}
|
|
if let Some((_, q)) = rest.split('#').next().unwrap_or("").split_once('?')
|
|
&& !q.is_empty()
|
|
{
|
|
let shown_q = shown.split_once('?').map_or("", |(_, q)| q).to_string();
|
|
secrets.push((q.to_string(), shown_q));
|
|
for kv in q.split('&') {
|
|
if let Some((_, v)) = kv.split_once('=')
|
|
&& v.len() >= 4
|
|
{
|
|
secrets.push((v.to_string(), "REDACTED".into()));
|
|
}
|
|
}
|
|
}
|
|
Redactor { shown, secrets }
|
|
}
|
|
|
|
/// The URL, redacted.
|
|
pub(crate) fn shown(&self) -> &str {
|
|
&self.shown
|
|
}
|
|
|
|
/// `msg` with every secret part of the URL replaced.
|
|
pub(crate) fn scrub(&self, msg: &str) -> String {
|
|
let mut m = msg.to_string();
|
|
for (secret, with) in &self.secrets {
|
|
if m.contains(secret.as_str()) {
|
|
m = m.replace(secret.as_str(), with);
|
|
}
|
|
}
|
|
m
|
|
}
|
|
}
|
|
|
|
/// Why a remote file could not be opened or read.
|
|
///
|
|
/// No message carries a URL's credentials: URLs appear as
|
|
/// [`redact_url`] shows them.
|
|
///
|
|
/// Inside a [`clawhdf5::File`] read these arrive as
|
|
/// `clawhdf5::Error::Format(FormatError::Storage(message))`, the message
|
|
/// being this error's `Display`.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
#[non_exhaustive]
|
|
pub enum RemoteError {
|
|
/// The URL is malformed.
|
|
InvalidUrl(String),
|
|
/// The URL's scheme is not supported by this build (for example
|
|
/// `s3://` without the `s3` feature, or `https://` without `https`).
|
|
UnsupportedScheme(String),
|
|
/// The server answered a range request with the whole file (status
|
|
/// 200), i.e. it does not support ranges, and a full download was not
|
|
/// allowed ([`HttpOptions::allow_full_download`](crate::HttpOptions)).
|
|
RangeNotSupported(String),
|
|
/// The file changed since it was opened (a different ETag,
|
|
/// Last-Modified or length, or a failed `If-Match` precondition).
|
|
/// Nothing read after the change is returned.
|
|
FileChanged(String),
|
|
/// The server answered with an unexpected status.
|
|
Status {
|
|
/// The HTTP status code.
|
|
code: u16,
|
|
/// What was being requested.
|
|
what: String,
|
|
},
|
|
/// A response did not carry what was asked for (a wrong
|
|
/// `Content-Range`, a body shorter or longer than announced), after
|
|
/// every retry.
|
|
BadResponse(String),
|
|
/// A network failure (connection, timeout, reset) after every retry.
|
|
Transport(String),
|
|
/// An error from the object store.
|
|
ObjectStore(String),
|
|
/// Called in a way the backend cannot serve.
|
|
Usage(String),
|
|
/// A redirect that is not followed: from `https` to `http`, to
|
|
/// another scheme, or beyond
|
|
/// [`HttpOptions::max_redirects`](crate::HttpOptions).
|
|
Redirect(String),
|
|
/// The file is larger than a download was allowed to be
|
|
/// ([`download`](crate::download)).
|
|
TooLarge {
|
|
/// The file's length, as the server reports it.
|
|
len: u64,
|
|
/// The limit.
|
|
limit: u64,
|
|
},
|
|
/// A read through a backend failed; its error, as text (the form a
|
|
/// [`clawhdf5::File`] read reports it in).
|
|
Backend(String),
|
|
}
|
|
|
|
impl RemoteError {
|
|
/// The error with every secret part of `r`'s URL scrubbed from its text.
|
|
#[cfg_attr(
|
|
not(any(feature = "http", feature = "s3", feature = "gcs", feature = "azure")),
|
|
allow(dead_code)
|
|
)]
|
|
pub(crate) fn scrubbed(self, r: &Redactor) -> RemoteError {
|
|
let f = |s: String| r.scrub(&s);
|
|
match self {
|
|
RemoteError::InvalidUrl(s) => RemoteError::InvalidUrl(f(s)),
|
|
RemoteError::UnsupportedScheme(s) => RemoteError::UnsupportedScheme(f(s)),
|
|
RemoteError::RangeNotSupported(s) => RemoteError::RangeNotSupported(f(s)),
|
|
RemoteError::FileChanged(s) => RemoteError::FileChanged(f(s)),
|
|
RemoteError::Status { code, what } => RemoteError::Status {
|
|
code,
|
|
what: f(what),
|
|
},
|
|
RemoteError::BadResponse(s) => RemoteError::BadResponse(f(s)),
|
|
RemoteError::Transport(s) => RemoteError::Transport(f(s)),
|
|
RemoteError::ObjectStore(s) => RemoteError::ObjectStore(f(s)),
|
|
RemoteError::Usage(s) => RemoteError::Usage(f(s)),
|
|
RemoteError::Redirect(s) => RemoteError::Redirect(f(s)),
|
|
e @ RemoteError::TooLarge { .. } => e,
|
|
RemoteError::Backend(s) => RemoteError::Backend(f(s)),
|
|
}
|
|
}
|
|
|
|
/// Whether retrying the same request may succeed.
|
|
#[cfg_attr(not(feature = "http"), allow(dead_code))]
|
|
pub(crate) fn is_transient(&self) -> bool {
|
|
match self {
|
|
RemoteError::Transport(_) | RemoteError::BadResponse(_) => true,
|
|
RemoteError::Status { code, .. } => {
|
|
matches!(code, 408 | 429 | 500 | 502 | 503 | 504)
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for RemoteError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
RemoteError::InvalidUrl(s) => write!(f, "invalid URL: {s}"),
|
|
RemoteError::UnsupportedScheme(s) => write!(f, "unsupported URL: {s}"),
|
|
RemoteError::RangeNotSupported(s) => {
|
|
write!(f, "the server does not support range requests: {s}")
|
|
}
|
|
RemoteError::FileChanged(s) => write!(f, "the remote file changed while open: {s}"),
|
|
RemoteError::Status { code, what } => write!(f, "HTTP status {code} for {what}"),
|
|
RemoteError::BadResponse(s) => write!(f, "bad response: {s}"),
|
|
RemoteError::Transport(s) => write!(f, "network error: {s}"),
|
|
RemoteError::ObjectStore(s) => write!(f, "object store: {s}"),
|
|
RemoteError::Usage(s) => write!(f, "{s}"),
|
|
RemoteError::Redirect(s) => write!(f, "redirect refused: {s}"),
|
|
RemoteError::TooLarge { len, limit } => write!(
|
|
f,
|
|
"the remote file is {len} bytes, more than the download limit of {limit} bytes"
|
|
),
|
|
RemoteError::Backend(s) => write!(f, "{s}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for RemoteError {}
|
|
|
|
impl From<RemoteError> for FormatError {
|
|
fn from(e: RemoteError) -> Self {
|
|
FormatError::Storage(e.to_string())
|
|
}
|
|
}
|
|
|
|
/// An error of [`open_url`](crate::open_url): the remote side, or the file
|
|
/// itself.
|
|
#[derive(Debug)]
|
|
#[non_exhaustive]
|
|
pub enum Error {
|
|
/// Reaching or reading the remote file failed.
|
|
Remote(RemoteError),
|
|
/// The bytes were read, but they are not an HDF5 file clawhdf5 can open.
|
|
Hdf5(clawhdf5::Error),
|
|
}
|
|
|
|
impl std::fmt::Display for Error {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Error::Remote(e) => e.fmt(f),
|
|
Error::Hdf5(e) => e.fmt(f),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for Error {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Error::Remote(e) => Some(e),
|
|
Error::Hdf5(e) => Some(e),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<RemoteError> for Error {
|
|
fn from(e: RemoteError) -> Self {
|
|
Error::Remote(e)
|
|
}
|
|
}
|
|
|
|
impl From<clawhdf5::Error> for Error {
|
|
fn from(e: clawhdf5::Error) -> Self {
|
|
Error::Hdf5(e)
|
|
}
|
|
}
|