This is an automated email from the ASF dual-hosted git repository.

alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs-object-store.git


The following commit(s) were added to refs/heads/main by this push:
     new c216ede  fix: return errors instead of panicking on invalid requests 
(#894)
c216ede is described below

commit c216edef0ac3d0d39da5bcdfeed3dcd034e26597
Author: Xin Sun <[email protected]>
AuthorDate: Sun Nov 1 20:56:52 2026 +0800

    fix: return errors instead of panicking on invalid requests (#894)
    
    Change-Id: I76f22524f0691db92df4886f43920b763396468f
---
 src/aws/credential.rs         |  31 +++--
 src/azure/builder.rs          |  11 ++
 src/azure/credential.rs       |  42 +++---
 src/client/builder.rs         |  86 ++++++++----
 src/client/http/connection.rs |  16 ++-
 src/client/retry.rs           |  86 ++++++++++--
 src/http/mod.rs               |   7 +
 tests/request_errors.rs       | 316 ++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 520 insertions(+), 75 deletions(-)

diff --git a/src/aws/credential.rs b/src/aws/credential.rs
index 72017fd..8084496 100644
--- a/src/aws/credential.rs
+++ b/src/aws/credential.rs
@@ -20,8 +20,8 @@ use crate::client::builder::HttpRequestBuilder;
 use crate::client::retry::RetryExt;
 use crate::client::token::{TemporaryToken, TokenCache};
 use crate::client::{
-    CryptoProvider, DigestAlgorithm, HttpClient, HttpError, HttpRequest, 
TokenProvider,
-    crypto_provider,
+    CryptoProvider, DigestAlgorithm, HttpClient, HttpError, HttpErrorKind, 
HttpRequest,
+    TokenProvider, crypto_provider,
 };
 use crate::util::{append_strict_query_pairs, hex_digest, hex_encode};
 use crate::{CredentialProvider, Result, RetryConfig};
@@ -218,11 +218,11 @@ impl<'a> AwsAuthorizer<'a> {
         request: &mut HttpRequest,
         pre_calculated_digest: Option<&[u8]>,
     ) -> Result<()> {
+        let url = 
Url::parse(&request.uri().to_string()).map_err(invalid_request)?;
         let crypto = crypto_provider(self.crypto)?;
-        let url = Url::parse(&request.uri().to_string()).unwrap();
 
         if let Some(ref token) = self.credential.token {
-            let token_val = HeaderValue::from_str(token).unwrap();
+            let token_val = 
HeaderValue::from_str(token).map_err(invalid_request)?;
             let header = self.token_header.as_ref().unwrap_or(&TOKEN_HEADER);
             request.headers_mut().insert(header, token_val);
         }
@@ -265,7 +265,7 @@ impl<'a> AwsAuthorizer<'a> {
         request
             .headers_mut()
             .insert(&HOST, HeaderValue::from_str(host).unwrap());
-        let (signed_headers, canonical_headers) = 
canonicalize_headers(request.headers());
+        let (signed_headers, canonical_headers) = 
canonicalize_headers(request.headers())?;
         request.headers_mut().remove(&HOST);
 
         let scope = self.scope(date);
@@ -292,7 +292,7 @@ impl<'a> AwsAuthorizer<'a> {
             ALGORITHM, self.credential.key_id, scope, signed_headers, signature
         );
 
-        let authorization_val = HeaderValue::from_str(&authorisation).unwrap();
+        let authorization_val = 
HeaderValue::from_str(&authorisation).map_err(invalid_request)?;
         request
             .headers_mut()
             .insert(&AUTHORIZATION, authorization_val);
@@ -336,7 +336,7 @@ impl<'a> AwsAuthorizer<'a> {
             headers.append(name.clone(), value.clone());
         }
 
-        let (signed_headers, canonical_headers) = 
canonicalize_headers(&headers);
+        let (signed_headers, canonical_headers) = 
canonicalize_headers(&headers)?;
 
         // 
https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
         url.query_pairs_mut()
@@ -458,7 +458,7 @@ impl CredentialExt for HttpRequestBuilder {
         match authorizer {
             Some(authorizer) => {
                 let (client, request) = self.into_parts();
-                let mut request = request.expect("request valid");
+                let mut request = request.map_err(invalid_request)?;
                 authorizer.try_authorize(&mut request, payload_sha256)?;
 
                 Ok(Self::from_parts(client, request))
@@ -468,6 +468,13 @@ impl CredentialExt for HttpRequestBuilder {
     }
 }
 
+fn invalid_request(error: impl std::error::Error + Send + Sync + 'static) -> 
crate::Error {
+    crate::Error::Generic {
+        store: STORE,
+        source: Box::new(HttpError::new(HttpErrorKind::InvalidRequest, error)),
+    }
+}
+
 /// Canonicalizes query parameters into the AWS canonical form
 ///
 /// Parameters are sorted by encoded name, with ties broken by encoded value, 
as required by the
@@ -519,7 +526,7 @@ fn append_normalized_whitespace_value(headers: &'_ mut 
String, input: &str) {
 /// Canonicalizes headers into the AWS Canonical Form.
 ///
 /// 
<https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html>
-fn canonicalize_headers(header_map: &HeaderMap) -> (String, String) {
+fn canonicalize_headers(header_map: &HeaderMap) -> Result<(String, String)> {
     let mut headers = BTreeMap::<&str, Vec<&str>>::new();
     let mut value_count = 0;
     let mut value_bytes = 0;
@@ -531,7 +538,7 @@ fn canonicalize_headers(header_map: &HeaderMap) -> (String, 
String) {
             continue;
         }
 
-        let value = std::str::from_utf8(value.as_bytes()).unwrap();
+        let value = 
std::str::from_utf8(value.as_bytes()).map_err(invalid_request)?;
         key_bytes += key.len();
         value_bytes += value.len();
         value_count += 1;
@@ -559,7 +566,7 @@ fn canonicalize_headers(header_map: &HeaderMap) -> (String, 
String) {
         canonical_headers.push('\n');
     }
 
-    (signed_headers, canonical_headers)
+    Ok((signed_headers, canonical_headers))
 }
 
 /// Credentials sourced from the instance metadata service
@@ -1772,7 +1779,7 @@ mod tests {
         headers.insert("authorization", "SHOULD_BE_IGNORED".parse().unwrap());
         headers.insert("content-length", "1337".parse().unwrap());
 
-        let (signed_headers, canonical_headers) = 
super::canonicalize_headers(&headers);
+        let (signed_headers, canonical_headers) = 
super::canonicalize_headers(&headers).unwrap();
 
         assert_eq!(
             signed_headers,
diff --git a/src/azure/builder.rs b/src/azure/builder.rs
index d1d68fe..2d1734f 100644
--- a/src/azure/builder.rs
+++ b/src/azure/builder.rs
@@ -64,6 +64,9 @@ enum Error {
         source: url::ParseError,
     },
 
+    #[error("Endpoint URL cannot be used as a base")]
+    InvalidEndpoint,
+
     #[error("Account must be specified")]
     MissingAccount {},
 
@@ -1246,6 +1249,10 @@ impl MicrosoftAzureBuilder {
             // Allow overriding defaults. Values taken from
             // from 
https://docs.rs/azure_storage/0.2.0/src/azure_storage/core/clients/storage_account_client.rs.html#129-141
             let url = url_from_env("AZURITE_BLOB_STORAGE_URL", 
"http://127.0.0.1:10000";)?;
+            if url.cannot_be_a_base() {
+                return Err(Error::InvalidEndpoint.into());
+            }
+
             let credential = if let Some(k) = self.access_key {
                 AzureCredential::AccessKey(AzureAccessKey::try_new(&k)?)
             } else if let Some(bearer_token) = self.bearer_token {
@@ -1277,6 +1284,10 @@ impl MicrosoftAzureBuilder {
                 Error::UnableToParseUrl { url, source }
             })?;
 
+            if url.cannot_be_a_base() {
+                return Err(Error::InvalidEndpoint.into());
+            }
+
             let credential = if let Some(credential) = self.credentials {
                 credential
             } else {
diff --git a/src/azure/credential.rs b/src/azure/credential.rs
index 5537e34..3746c30 100644
--- a/src/azure/credential.rs
+++ b/src/azure/credential.rs
@@ -22,8 +22,8 @@ use crate::client::builder::{HttpRequestBuilder, 
add_query_pairs};
 use crate::client::retry::RetryExt;
 use crate::client::token::{TemporaryToken, TokenCache};
 use crate::client::{
-    CredentialProvider, CryptoProvider, DigestAlgorithm, HttpClient, 
HttpError, HttpRequest,
-    TokenProvider, crypto_provider,
+    CredentialProvider, CryptoProvider, DigestAlgorithm, HttpClient, 
HttpError, HttpErrorKind,
+    HttpRequest, TokenProvider, crypto_provider,
 };
 use async_trait::async_trait;
 use base64::Engine;
@@ -277,8 +277,8 @@ impl<'a> AzureAuthorizer<'a> {
 
         match self.credential {
             AzureCredential::AccessKey(key) => {
+                let url = 
Url::parse(&request.uri().to_string()).map_err(invalid_request)?;
                 let crypto = self.crypto()?;
-                let url = Url::parse(&request.uri().to_string()).unwrap();
                 let signature = generate_authorization(
                     crypto,
                     request.headers(),
@@ -288,21 +288,19 @@ impl<'a> AzureAuthorizer<'a> {
                     key,
                 )?;
 
-                // "signature" is a base 64 encoded string so it should never
-                // contain illegal characters
                 request.headers_mut().append(
                     AUTHORIZATION,
-                    HeaderValue::from_str(signature.as_str()).unwrap(),
+                    
HeaderValue::from_str(&signature).map_err(invalid_request)?,
                 );
             }
             AzureCredential::BearerToken(token) => {
                 request.headers_mut().append(
                     AUTHORIZATION,
-                    HeaderValue::from_str(format!("Bearer 
{token}").as_str()).unwrap(),
+                    HeaderValue::from_str(&format!("Bearer 
{token}")).map_err(invalid_request)?,
                 );
             }
             AzureCredential::SASToken(query_pairs) => {
-                add_query_pairs(request.uri_mut(), query_pairs);
+                add_query_pairs(request.uri_mut(), 
query_pairs).map_err(invalid_request)?;
             }
         }
         Ok(())
@@ -328,7 +326,7 @@ impl CredentialExt for HttpRequestBuilder {
         account: &str,
     ) -> crate::Result<Self> {
         let (client, request) = self.into_parts();
-        let mut request = request.expect("request valid");
+        let mut request = request.map_err(invalid_request)?;
 
         match credential.as_deref() {
             Some(credential) => {
@@ -347,6 +345,13 @@ impl CredentialExt for HttpRequestBuilder {
     }
 }
 
+fn invalid_request(error: impl std::error::Error + Send + Sync + 'static) -> 
crate::Error {
+    crate::Error::Generic {
+        store: STORE,
+        source: Box::new(HttpError::new(HttpErrorKind::InvalidRequest, error)),
+    }
+}
+
 /// Generate signed key for authorization via access keys
 /// 
<https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key>
 fn generate_authorization(
@@ -357,7 +362,7 @@ fn generate_authorization(
     account: &str,
     key: &AzureAccessKey,
 ) -> crate::Result<String> {
-    let str_to_sign = string_to_sign(h, u, method, account);
+    let str_to_sign = string_to_sign(h, u, method, account)?;
     let mut ctx = crypto.hmac(DigestAlgorithm::Sha256, &key.0)?;
     ctx.update(str_to_sign.as_bytes());
     let auth = ctx.finish()?;
@@ -524,7 +529,7 @@ fn string_to_sign_user_delegation_sas(
 }
 
 /// 
<https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key#constructing-the-signature-string>
-fn string_to_sign(h: &HeaderMap, u: &Url, method: &Method, account: &str) -> 
String {
+fn string_to_sign(h: &HeaderMap, u: &Url, method: &Method, account: &str) -> 
crate::Result<String> {
     // content length must only be specified if != 0
     // this is valid from 2015-02-21
     let content_length = h
@@ -535,7 +540,7 @@ fn string_to_sign(h: &HeaderMap, u: &Url, method: &Method, 
account: &str) -> Str
         .flatten()
         .filter(|&v| v != "0")
         .unwrap_or_default();
-    format!(
+    Ok(format!(
         "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}{}",
         method.as_ref(),
         add_if_exists(h, &CONTENT_ENCODING),
@@ -549,19 +554,18 @@ fn string_to_sign(h: &HeaderMap, u: &Url, method: 
&Method, account: &str) -> Str
         add_if_exists(h, &IF_NONE_MATCH),
         add_if_exists(h, &IF_UNMODIFIED_SINCE),
         add_if_exists(h, &RANGE),
-        canonicalize_header(h),
+        canonicalize_header(h)?,
         canonicalize_resource(account, u)
-    )
+    ))
 }
 
 /// 
<https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key#constructing-the-canonicalized-headers-string>
-fn canonicalize_header(headers: &HeaderMap) -> String {
+fn canonicalize_header(headers: &HeaderMap) -> crate::Result<String> {
     let mut names = headers
         .iter()
         .filter(|&(k, _)| k.as_str().starts_with("x-ms"))
-        // TODO remove unwraps
-        .map(|(k, _)| (k.as_str(), headers.get(k).unwrap().to_str().unwrap()))
-        .collect::<Vec<_>>();
+        .map(|(k, v)| Ok((k.as_str(), v.to_str().map_err(invalid_request)?)))
+        .collect::<crate::Result<Vec<_>>>()?;
     names.sort_unstable();
 
     let mut result = String::new();
@@ -571,7 +575,7 @@ fn canonicalize_header(headers: &HeaderMap) -> String {
         result.push_str(value);
         result.push('\n');
     }
-    result
+    Ok(result)
 }
 
 /// 
<https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key#constructing-the-canonicalized-resource-string>
diff --git a/src/client/builder.rs b/src/client/builder.rs
index cca6f93..5f267b4 100644
--- a/src/client/builder.rs
+++ b/src/client/builder.rs
@@ -17,7 +17,7 @@
 
 use crate::client::{HttpClient, HttpError, HttpErrorKind, HttpRequest, 
HttpRequestBody};
 use http::header::{InvalidHeaderName, InvalidHeaderValue};
-use http::uri::InvalidUri;
+use http::uri::{InvalidUri, InvalidUriParts};
 use http::{HeaderName, HeaderValue, Method, Uri};
 
 #[derive(Debug, thiserror::Error)]
@@ -25,6 +25,9 @@ pub(crate) enum RequestBuilderError {
     #[error("Invalid URI")]
     InvalidUri(#[from] InvalidUri),
 
+    #[error("Invalid URI parts: {0}")]
+    InvalidUriParts(#[from] InvalidUriParts),
+
     #[error("Invalid Header Value")]
     InvalidHeaderValue(#[from] InvalidHeaderValue),
 
@@ -40,7 +43,7 @@ pub(crate) enum RequestBuilderError {
 
 impl From<RequestBuilderError> for HttpError {
     fn from(value: RequestBuilderError) -> Self {
-        Self::new(HttpErrorKind::Request, value)
+        Self::new(HttpErrorKind::InvalidRequest, value)
     }
 }
 
@@ -203,29 +206,18 @@ impl HttpRequestBuilder {
         feature = "azure-base"
     ))]
     pub(crate) fn query<T: serde::Serialize + ?Sized>(mut self, query: &T) -> 
Self {
-        let mut error = None;
-        if let Ok(ref mut req) = self.request {
+        self.request = self.request.and_then(|mut req| {
             let mut out = format!("{}?", req.uri().path());
             let start_position = out.len();
             let mut encoder = form_urlencoded::Serializer::for_suffix(&mut 
out, start_position);
             let serializer = serde_urlencoded::Serializer::new(&mut encoder);
+            query.serialize(serializer)?;
 
-            if let Err(err) = query.serialize(serializer) {
-                error = Some(err.into());
-            }
-
-            match http::uri::PathAndQuery::from_maybe_shared(out) {
-                Ok(p) => {
-                    let mut parts = req.uri().clone().into_parts();
-                    parts.path_and_query = Some(p);
-                    *req.uri_mut() = Uri::from_parts(parts).unwrap();
-                }
-                Err(err) => error = Some(err.into()),
-            }
-        }
-        if let Some(err) = error {
-            self.request = Err(err);
-        }
+            let mut parts = req.uri().clone().into_parts();
+            parts.path_and_query = 
Some(http::uri::PathAndQuery::from_maybe_shared(out)?);
+            *req.uri_mut() = Uri::from_parts(parts)?;
+            Ok(req)
+        });
         self
     }
 
@@ -264,7 +256,10 @@ impl HttpRequestBuilder {
 }
 
 #[cfg(any(test, feature = "azure-base"))]
-pub(crate) fn add_query_pairs<I, K, V>(uri: &mut Uri, query_pairs: I)
+pub(crate) fn add_query_pairs<I, K, V>(
+    uri: &mut Uri,
+    query_pairs: I,
+) -> Result<(), RequestBuilderError>
 where
     I: IntoIterator,
     I::Item: std::borrow::Borrow<(K, V)>,
@@ -289,8 +284,9 @@ where
 
     serializer.extend_pairs(query_pairs);
 
-    parts.path_and_query = Some(out.try_into().unwrap());
-    *uri = Uri::from_parts(parts).unwrap();
+    parts.path_and_query = Some(out.try_into()?);
+    *uri = Uri::from_parts(parts)?;
+    Ok(())
 }
 
 #[cfg(test)]
@@ -301,16 +297,16 @@ mod tests {
     fn test_add_query_pairs() {
         let mut uri = Uri::from_static("https://[email protected]/bananas";);
 
-        add_query_pairs(&mut uri, [("foo", "1")]);
+        add_query_pairs(&mut uri, [("foo", "1")]).unwrap();
         assert_eq!(uri.to_string(), "https://[email protected]/bananas?foo=1";);
 
-        add_query_pairs(&mut uri, [("bingo", "foo"), ("auth", "test")]);
+        add_query_pairs(&mut uri, [("bingo", "foo"), ("auth", 
"test")]).unwrap();
         assert_eq!(
             uri.to_string(),
             "https://[email protected]/bananas?foo=1&bingo=foo&auth=test";
         );
 
-        add_query_pairs(&mut uri, [("t1", "funky shenanigans"), ("a", "πŸ˜€")]);
+        add_query_pairs(&mut uri, [("t1", "funky shenanigans"), ("a", 
"πŸ˜€")]).unwrap();
         assert_eq!(
             uri.to_string(),
             
"https://[email protected]/bananas?foo=1&bingo=foo&auth=test&t1=funky+shenanigans&a=%F0%9F%98%80";
@@ -320,7 +316,7 @@ mod tests {
     #[test]
     fn test_add_query_pairs_no_path() {
         let mut uri = Uri::from_static("https://[email protected]";);
-        add_query_pairs(&mut uri, [("foo", "1")]);
+        add_query_pairs(&mut uri, [("foo", "1")]).unwrap();
         assert_eq!(uri.to_string(), "https://[email protected]/?foo=1";);
     }
 
@@ -348,6 +344,42 @@ mod tests {
         );
     }
 
+    #[cfg(feature = "reqwest")]
+    #[test]
+    fn test_query_invalid_uri() {
+        let client = HttpClient::new(reqwest::Client::new());
+        let (_, result) = HttpRequestBuilder::new(client)
+            .uri("example.invalid")
+            .query(&[("list-type", "2")])
+            .header("x-test", "valid")
+            .into_parts();
+        assert!(matches!(
+            result,
+            Err(RequestBuilderError::InvalidUriParts(_))
+        ));
+    }
+
+    #[test]
+    fn test_add_query_pairs_invalid_uri() {
+        let mut uri = Uri::from_static("example.invalid");
+        let original = uri.clone();
+        assert!(add_query_pairs(&mut uri, [("foo", "bar")]).is_err());
+        assert_eq!(uri, original);
+    }
+
+    #[cfg(feature = "reqwest")]
+    #[test]
+    fn test_query_serialization_error() {
+        let client = HttpClient::new(reqwest::Client::new());
+        let (_, result) = HttpRequestBuilder::new(client)
+            .uri("example.invalid")
+            .query(&42)
+            .uri("https://example.invalid";)
+            .query(&[("foo", "bar")])
+            .into_parts();
+        assert!(matches!(result, Err(RequestBuilderError::SerdeUrl(_))));
+    }
+
     fn assert_request_uri(builder: HttpRequestBuilder, expected: &str) {
         assert_eq!(builder.into_parts().1.unwrap().uri().to_string(), expected)
     }
diff --git a/src/client/http/connection.rs b/src/client/http/connection.rs
index 0558a7e..a088d0f 100644
--- a/src/client/http/connection.rs
+++ b/src/client/http/connection.rs
@@ -28,6 +28,8 @@ use std::error::Error;
 use std::sync::Arc;
 #[cfg(all(feature = "reqwest", not(target_arch = "wasm32")))]
 use tokio::runtime::Handle;
+#[cfg(feature = "reqwest")]
+use url::Url;
 
 /// An HTTP protocol error
 ///
@@ -49,6 +51,10 @@ pub struct HttpError {
 #[derive(Debug, Copy, Clone, PartialEq, Eq)]
 #[non_exhaustive]
 pub enum HttpErrorKind {
+    /// Invalid input prevented the request from being built or signed
+    ///
+    /// Retrying the same input cannot succeed. Will not be automatically 
retried
+    InvalidRequest,
     /// An error occurred whilst connecting to the remote
     ///
     /// Will be automatically retried
@@ -97,7 +103,9 @@ impl HttpError {
         #[cfg(target_arch = "wasm32")]
         let is_connect = || false;
 
-        let mut kind = if e.is_timeout() {
+        let mut kind = if e.is_builder() {
+            HttpErrorKind::InvalidRequest
+        } else if e.is_timeout() {
             HttpErrorKind::Timeout
         } else if is_connect() {
             HttpErrorKind::Connect
@@ -221,7 +229,8 @@ impl HttpService for reqwest::Client {
     async fn call(&self, req: HttpRequest) -> Result<HttpResponse, HttpError> {
         let (parts, body) = req.into_parts();
 
-        let url = parts.uri.to_string().parse().unwrap();
+        let url = Url::parse(&parts.uri.to_string())
+            .map_err(|e| HttpError::new(HttpErrorKind::InvalidRequest, e))?;
         let mut req = reqwest::Request::new(parts.method, url);
         *req.headers_mut() = parts.headers;
         *req.body_mut() = Some(body.into_reqwest());
@@ -245,7 +254,8 @@ impl HttpService for reqwest::Client {
         use wasm_bindgen_futures::spawn_local;
 
         let (parts, body) = req.into_parts();
-        let url = parts.uri.to_string().parse().unwrap();
+        let url = Url::parse(&parts.uri.to_string())
+            .map_err(|e| HttpError::new(HttpErrorKind::InvalidRequest, e))?;
         let mut req = reqwest::Request::new(parts.method, url);
         *req.headers_mut() = parts.headers;
         *req.body_mut() = Some(body.into_reqwest());
diff --git a/src/client/retry.rs b/src/client/retry.rs
index c79012a..8c6dd8f 100644
--- a/src/client/retry.rs
+++ b/src/client/retry.rs
@@ -19,7 +19,7 @@
 
 use crate::PutPayload;
 use crate::client::backoff::{Backoff, BackoffConfig};
-use crate::client::builder::HttpRequestBuilder;
+use crate::client::builder::{HttpRequestBuilder, RequestBuilderError};
 use crate::client::{HttpClient, HttpError, HttpErrorKind, HttpRequest, 
HttpResponse};
 use futures_util::future::BoxFuture;
 use http::StatusCode;
@@ -38,7 +38,7 @@ pub struct RetryError(Box<RetryErrorImpl>);
 /// Box error to avoid large error variant
 #[derive(Debug)]
 struct RetryErrorImpl {
-    method: Method,
+    method: Option<Method>,
     uri: Option<Uri>,
     retries: usize,
     max_retries: usize,
@@ -49,10 +49,14 @@ struct RetryErrorImpl {
 
 impl std::fmt::Display for RetryError {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "Error performing {} ", self.0.method)?;
+        match &self.0.method {
+            Some(method) => write!(f, "Error performing {method} ")?,
+            None => write!(f, "Error performing request ")?,
+        }
         match &self.0.uri {
             Some(uri) => write!(f, "{uri} ")?,
-            None => write!(f, "REDACTED ")?,
+            None if self.0.method.is_some() => write!(f, "REDACTED ")?,
+            None => {}
         }
         write!(f, "in {:?}", self.0.elapsed)?;
         if self.0.retries != 0 {
@@ -199,6 +203,7 @@ impl From<RetryError> for std::io::Error {
             }
             _ => match err.inner() {
                 RequestError::Http(h) => match h.kind() {
+                    HttpErrorKind::InvalidRequest => ErrorKind::InvalidInput,
                     HttpErrorKind::Timeout => ErrorKind::TimedOut,
                     HttpErrorKind::Connect => ErrorKind::NotConnected,
                     _ => ErrorKind::Other,
@@ -315,7 +320,7 @@ impl RetryableRequestBuilder {
 /// A retryable request
 pub(crate) struct RetryableRequest {
     client: HttpClient,
-    http: HttpRequest,
+    http: Result<HttpRequest, RequestBuilderError>,
 
     sensitive: bool,
     idempotent: Option<bool>,
@@ -332,9 +337,10 @@ impl RetryableRequest {
     }
 
     fn err(&self, error: RequestError, ctx: &RetryContext) -> RetryError {
+        let request = self.http.as_ref().ok();
         RetryError(Box::new(RetryErrorImpl {
-            uri: (!self.sensitive).then(|| self.http.uri().clone()),
-            method: self.http.method().clone(),
+            uri: request.filter(|_| !self.sensitive).map(|r| r.uri().clone()),
+            method: request.map(|r| r.method().clone()),
             retries: ctx.retries,
             max_retries: ctx.max_retries,
             elapsed: ctx.start.elapsed(),
@@ -344,8 +350,22 @@ impl RetryableRequest {
     }
 
     pub(crate) async fn send(self, ctx: &mut RetryContext) -> 
Result<HttpResponse> {
+        let http = match self.http {
+            Ok(ref http) => http,
+            Err(error) => {
+                return Err(RetryError(Box::new(RetryErrorImpl {
+                    method: None,
+                    uri: None,
+                    retries: ctx.retries,
+                    max_retries: ctx.max_retries,
+                    elapsed: ctx.start.elapsed(),
+                    retry_timeout: ctx.retry_timeout,
+                    inner: RequestError::Http(error.into()),
+                })));
+            }
+        };
         loop {
-            let mut request = self.http.clone();
+            let mut request = http.clone();
 
             if let Some(payload) = &self.payload {
                 *request.body_mut() = payload.clone().into();
@@ -432,14 +452,14 @@ impl RetryableRequest {
                     }
                 }
                 Err(e) => {
-                    let is_idempotent = self
-                        .idempotent
-                        .unwrap_or_else(|| self.http.method().is_safe());
+                    let is_idempotent = self.idempotent.unwrap_or_else(|| 
http.method().is_safe());
 
                     let do_retry = match e.kind() {
                         HttpErrorKind::Connect | HttpErrorKind::Request => 
true, // Request not sent, can retry
                         HttpErrorKind::Timeout | HttpErrorKind::Interrupted => 
is_idempotent,
-                        HttpErrorKind::Unknown | HttpErrorKind::Decode => 
false,
+                        HttpErrorKind::InvalidRequest
+                        | HttpErrorKind::Unknown
+                        | HttpErrorKind::Decode => false,
                     };
 
                     if ctx.exhausted() || !do_retry {
@@ -486,7 +506,6 @@ impl RetryExt for HttpRequestBuilder {
 
     fn retryable_request(self) -> RetryableRequest {
         let (client, request) = self.into_parts();
-        let request = request.expect("request must be valid");
 
         RetryableRequest {
             client,
@@ -511,7 +530,10 @@ mod tests {
     use crate::RetryConfig;
     use crate::client::mock_server::MockServer;
     use crate::client::retry::{RequestError, RetryContext, RetryExt, 
body_contains_error};
-    use crate::client::{HttpClient, HttpError, HttpErrorKind, HttpResponse};
+    use crate::client::{
+        HttpClient, HttpError, HttpErrorKind, HttpRequest, HttpResponse, 
HttpService,
+    };
+    use async_trait::async_trait;
     use http::Method;
     use http::StatusCode;
     use hyper::Response;
@@ -523,10 +545,46 @@ mod tests {
     use reqwest::Client;
     use std::convert::Infallible;
     use std::error::Error;
+    use std::sync::Arc;
+    use std::sync::atomic::{AtomicUsize, Ordering};
     use std::time::Duration;
     use tokio::net::TcpListener;
     use tokio::time::timeout;
 
+    #[tokio::test]
+    async fn test_invalid_request_no_retry() {
+        #[derive(Debug)]
+        struct InvalidRequestService(Arc<AtomicUsize>);
+
+        #[async_trait]
+        impl HttpService for InvalidRequestService {
+            async fn call(&self, _: HttpRequest) -> Result<HttpResponse, 
HttpError> {
+                self.0.fetch_add(1, Ordering::SeqCst);
+                Err(HttpError::new(
+                    HttpErrorKind::InvalidRequest,
+                    std::io::Error::new(std::io::ErrorKind::InvalidInput, 
"invalid request"),
+                ))
+            }
+        }
+
+        let calls = Arc::new(AtomicUsize::new(0));
+        let client = 
HttpClient::new(InvalidRequestService(Arc::clone(&calls)));
+        let config = RetryConfig {
+            max_retries: 2,
+            ..Default::default()
+        };
+        let error = client
+            .request(Method::GET, "https://example.invalid/object";)
+            .send_retry(&config)
+            .await
+            .unwrap_err();
+        assert_eq!(calls.load(Ordering::SeqCst), 1);
+        assert!(matches!(
+            error.inner(),
+            RequestError::Http(error) if error.kind() == 
HttpErrorKind::InvalidRequest
+        ));
+    }
+
     #[test]
     fn test_body_contains_error() {
         // Example error message provided by 
https://repost.aws/knowledge-center/s3-resolve-200-internalerror
diff --git a/src/http/mod.rs b/src/http/mod.rs
index 1c83d45..43b18d0 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -68,6 +68,9 @@ enum Error {
         url: String,
     },
 
+    #[error("Endpoint URL cannot be used as a base")]
+    InvalidEndpoint,
+
     #[error("Unable to extract metadata from headers: {}", source)]
     Metadata {
         source: crate::client::header::Error,
@@ -283,6 +286,10 @@ impl HttpBuilder {
         let url = self.url.ok_or(Error::MissingUrl)?;
         let parsed = Url::parse(&url).map_err(|source| Error::UnableToParseUrl 
{ url, source })?;
 
+        if parsed.cannot_be_a_base() {
+            return Err(Error::InvalidEndpoint.into());
+        }
+
         let client = 
http_connector(self.http_connector)?.connect(&self.client_options)?;
 
         Ok(HttpStore {
diff --git a/tests/request_errors.rs b/tests/request_errors.rs
new file mode 100644
index 0000000..e620e99
--- /dev/null
+++ b/tests/request_errors.rs
@@ -0,0 +1,316 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#![cfg(all(feature = "aws", feature = "azure", feature = "gcp", feature = 
"http"))]
+
+use async_trait::async_trait;
+use object_store::ClientOptions;
+use object_store::client::{
+    HttpClient, HttpConnector, HttpError, HttpErrorKind, HttpRequest, 
HttpResponse, HttpService,
+};
+use std::error::Error;
+use std::sync::{
+    Arc,
+    atomic::{AtomicUsize, Ordering},
+};
+
+fn find_error<'a, T: Error + 'static>(mut error: &'a (dyn Error + 'static)) -> 
Option<&'a T> {
+    loop {
+        if let Some(error) = error.downcast_ref::<T>() {
+            return Some(error);
+        }
+        error = error.source()?;
+    }
+}
+
+fn assert_invalid_request(error: &object_store::Error) {
+    let http_error = find_error::<HttpError>(error).expect("HttpError missing 
from error chain");
+    assert_eq!(http_error.kind(), HttpErrorKind::InvalidRequest, "{error}");
+}
+
+#[derive(Debug, Clone)]
+struct NoNetwork(Arc<AtomicUsize>);
+
+#[async_trait]
+impl HttpService for NoNetwork {
+    async fn call(&self, _: HttpRequest) -> Result<HttpResponse, HttpError> {
+        self.0.fetch_add(1, Ordering::SeqCst);
+        Ok(http::Response::builder()
+            .status(400)
+            .body(String::from("test transport").into())
+            .unwrap())
+    }
+}
+
+impl HttpConnector for NoNetwork {
+    fn connect(&self, _: &ClientOptions) -> object_store::Result<HttpClient> {
+        Ok(HttpClient::new(self.clone()))
+    }
+}
+
+mod requests {
+    use super::*;
+    use futures_util::TryStreamExt;
+    use object_store::{Attribute, Attributes, ObjectStore, ObjectStoreExt, 
PutOptions};
+
+    fn build_store(
+        provider: &str,
+        endpoint: &str,
+        signed: bool,
+        bad_token: bool,
+        transport: NoNetwork,
+    ) -> object_store::Result<Box<dyn ObjectStore>> {
+        let token = if bad_token { "test\ninvalid" } else { "test" };
+        match provider {
+            "s3" => object_store::aws::AmazonS3Builder::new()
+                .with_bucket_name("bucket")
+                .with_region("us-east-1")
+                .with_access_key_id("test")
+                .with_secret_access_key("test")
+                .with_token(token)
+                .with_endpoint(endpoint)
+                .with_allow_http(true)
+                .with_virtual_hosted_style_request(true)
+                .with_skip_signature(!signed)
+                .with_http_connector(transport)
+                .build()
+                .map(|s| Box::new(s) as _),
+            "gcs" => object_store::gcp::GoogleCloudStorageBuilder::new()
+                .with_bucket_name("bucket")
+                .with_base_url(endpoint)
+                .with_bearer_token(token)
+                .with_skip_signature(!signed)
+                
.with_client_options(ClientOptions::new().with_allow_http(true))
+                .with_http_connector(transport)
+                .build()
+                .map(|s| Box::new(s) as _),
+            "azure" => object_store::azure::MicrosoftAzureBuilder::new()
+                .with_account("account")
+                .with_container_name("container")
+                .with_endpoint(endpoint.into())
+                .with_bearer_token_authorization(token)
+                .with_allow_http(true)
+                .with_skip_signature(!signed)
+                .with_http_connector(transport)
+                .build()
+                .map(|s| Box::new(s) as _),
+            "http" => object_store::http::HttpBuilder::new()
+                .with_url(endpoint)
+                
.with_client_options(ClientOptions::new().with_allow_http(true))
+                .with_http_connector(transport)
+                .build()
+                .map(|s| Box::new(s) as _),
+            _ => unreachable!(),
+        }
+    }
+
+    async fn check_request(provider: &str, endpoint: &str, signed: bool, op: 
&str) {
+        let calls = Arc::new(AtomicUsize::new(0));
+        let mut stage = "build";
+        let ret = (async {
+            let store = build_store(
+                provider,
+                endpoint,
+                signed,
+                op == "bad_token",
+                NoNetwork(calls.clone()),
+            )?;
+            stage = "operation";
+            match op {
+                "list" => 
store.list(None).try_collect::<Vec<_>>().await.map(|_| ()),
+                "bad_header" => {
+                    let mut attributes = Attributes::new();
+                    attributes.insert(Attribute::ContentType, 
"test\ninvalid".into());
+                    store
+                        .put_opts(
+                            &"key".into(),
+                            "data".into(),
+                            PutOptions {
+                                attributes,
+                                ..Default::default()
+                            },
+                        )
+                        .await
+                        .map(|_| ())
+                }
+                "get" | "bad_token" => store.get(&"key".into()).await.map(|_| 
()),
+                _ => unreachable!("unknown operation: {op}"),
+            }
+        })
+        .await;
+        let error = ret.expect_err("expected request failure");
+        // The custom transport accepts relative URIs. Signed S3 requests 
reject
+        // them earlier because the authorizer requires an absolute URL.
+        let expected_calls = usize::from(
+            endpoint == "http://bucket.example.invalid"; && 
!op.starts_with("bad_")
+                || provider == "gcs" && endpoint == "/relative"
+                || provider == "s3" && endpoint == "/relative" && !signed,
+        );
+        assert_eq!(
+            calls.load(Ordering::SeqCst),
+            expected_calls,
+            "{provider} {endpoint} {op} {stage}"
+        );
+        if expected_calls == 1 {
+            assert!(
+                error
+                    .to_string()
+                    .contains("400 Bad Request: test transport"),
+                "{error}"
+            );
+            assert!(find_error::<HttpError>(&error).is_none());
+        } else if stage == "operation" {
+            assert_invalid_request(&error);
+        } else {
+            // Invalid endpoints rejected at build time have provider-specific 
errors.
+            assert!(find_error::<HttpError>(&error).is_none());
+        }
+    }
+
+    #[tokio::test]
+    async fn test_invalid_requests() {
+        for provider in ["s3", "gcs", "azure", "http"] {
+            for endpoint in [
+                "bucket.example.invalid",
+                "127.0.0.1:18764",
+                "http://[";,
+                "/relative",
+                "data:text/plain,test",
+                "mailto:[email protected]";,
+                "http://bucket.example.invalid";,
+            ] {
+                for signed in [true, false] {
+                    if provider == "http" && signed {
+                        continue;
+                    }
+                    for op in ["list", "get"] {
+                        check_request(provider, endpoint, signed, op).await;
+                    }
+                }
+            }
+        }
+        for provider in ["s3", "gcs", "azure", "http"] {
+            for signed in [true, false] {
+                if provider == "http" && signed {
+                    continue;
+                }
+                check_request(
+                    provider,
+                    "http://bucket.example.invalid";,
+                    signed,
+                    "bad_header",
+                )
+                .await;
+            }
+            if provider != "http" {
+                check_request(provider, "http://bucket.example.invalid";, true, 
"bad_token").await;
+            }
+        }
+    }
+}
+
+mod signing {
+    use super::*;
+    use object_store::{Attribute, Attributes, ObjectStore, ObjectStoreExt, 
PutOptions};
+
+    async fn error_without_transport<T>(
+        name: &str,
+        calls: &AtomicUsize,
+        future: impl std::future::Future<Output = object_store::Result<T>>,
+    ) -> object_store::Error {
+        let error = future.await.err().expect("expected signing failure");
+        assert_eq!(calls.load(Ordering::SeqCst), 0, "{name}");
+        error
+    }
+
+    #[tokio::test]
+    async fn test_invalid_signing() {
+        let calls = Arc::new(AtomicUsize::new(0));
+        let mut headers = http::HeaderMap::new();
+        headers.insert("x-test", 
http::HeaderValue::from_bytes(&[0xff]).unwrap());
+        let aws = object_store::aws::AmazonS3Builder::new()
+            .with_bucket_name("bucket")
+            .with_region("us-east-1")
+            .with_access_key_id("test")
+            .with_secret_access_key("test")
+            .with_endpoint("http://bucket.example.invalid";)
+            .with_virtual_hosted_style_request(true)
+            .with_client_options(
+                ClientOptions::new()
+                    .with_allow_http(true)
+                    .with_default_headers(headers),
+            )
+            .with_http_connector(NoNetwork(calls.clone()))
+            .build()
+            .unwrap();
+        let error =
+            error_without_transport("s3_default_header_non_utf8", &calls, 
aws.get(&"key".into()))
+                .await;
+        assert_invalid_request(&error);
+        let azure = object_store::azure::MicrosoftAzureBuilder::new()
+            .with_account("account")
+            .with_container_name("container")
+            .with_endpoint("http://bucket.example.invalid".into())
+            .with_access_key("dGVzdA==")
+            .with_allow_http(true)
+            .with_http_connector(NoNetwork(calls.clone()))
+            .build()
+            .unwrap();
+        let mut attributes = Attributes::new();
+        attributes.insert(Attribute::Metadata("label".into()), "δΈ­ζ–‡".into());
+        let error = error_without_transport(
+            "azure_shared_key_unicode_metadata",
+            &calls,
+            azure.put_opts(
+                &"key".into(),
+                "data".into(),
+                PutOptions {
+                    attributes,
+                    ..Default::default()
+                },
+            ),
+        )
+        .await;
+        assert_invalid_request(&error);
+    }
+}
+
+mod transport {
+    use super::*;
+    use futures_util::TryStreamExt;
+    use object_store::{ObjectStore, ObjectStoreExt};
+
+    #[tokio::test]
+    async fn test_reqwest_invalid_uri() {
+        let gcs_relative = object_store::gcp::GoogleCloudStorageBuilder::new()
+            .with_bucket_name("bucket")
+            .with_base_url("/relative")
+            .with_bearer_token("test")
+            .with_skip_signature(true)
+            .with_client_options(ClientOptions::new().with_allow_http(true))
+            .build()
+            .unwrap();
+        let error = gcs_relative.get(&"key".into()).await.unwrap_err();
+        assert_invalid_request(&error);
+        let error = gcs_relative
+            .list(None)
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap_err();
+        assert_invalid_request(&error);
+    }
+}

Reply via email to