//! 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 = 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 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 for Error { fn from(e: RemoteError) -> Self { Error::Remote(e) } } impl From for Error { fn from(e: clawhdf5::Error) -> Self { Error::Hdf5(e) } }