Skip to main content

object_store/
util.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Common logic for interacting with remote object stores
19use std::{
20    fmt::Display,
21    ops::{Range, RangeBounds},
22};
23
24use super::Result;
25use bytes::Bytes;
26use futures_util::{Stream, TryStreamExt, stream::StreamExt};
27
28#[cfg(any(feature = "azure-base", feature = "http-base"))]
29pub(crate) static RFC1123_FMT: &str = "%a, %d %h %Y %T GMT";
30
31// deserialize dates according to rfc1123
32#[cfg(any(feature = "azure-base", feature = "http-base"))]
33pub(crate) fn deserialize_rfc1123<'de, D>(
34    deserializer: D,
35) -> Result<chrono::DateTime<chrono::Utc>, D::Error>
36where
37    D: serde::Deserializer<'de>,
38{
39    let s: String = serde::Deserialize::deserialize(deserializer)?;
40    let naive =
41        chrono::NaiveDateTime::parse_from_str(&s, RFC1123_FMT).map_err(serde::de::Error::custom)?;
42    Ok(chrono::TimeZone::from_utc_datetime(&chrono::Utc, &naive))
43}
44
45/// Collect a stream into [`Bytes`] avoiding copying in the event of a single chunk
46pub async fn collect_bytes<S, E>(mut stream: S, size_hint: Option<u64>) -> Result<Bytes, E>
47where
48    E: Send,
49    S: Stream<Item = Result<Bytes, E>> + Send + Unpin,
50{
51    let first = stream.next().await.transpose()?.unwrap_or_default();
52
53    // Avoid copying if single response
54    match stream.next().await.transpose()? {
55        None => Ok(first),
56        Some(second) => {
57            let size_hint = size_hint.unwrap_or_else(|| first.len() as u64 + second.len() as u64);
58
59            let mut buf = Vec::with_capacity(size_hint as usize);
60            buf.extend_from_slice(&first);
61            buf.extend_from_slice(&second);
62            while let Some(maybe_bytes) = stream.next().await {
63                buf.extend_from_slice(&maybe_bytes?);
64            }
65
66            Ok(buf.into())
67        }
68    }
69}
70
71#[cfg(all(feature = "fs", not(target_arch = "wasm32")))]
72/// Takes a function and spawns it to a tokio blocking pool if available
73pub(crate) async fn maybe_spawn_blocking<F, T>(f: F) -> Result<T>
74where
75    F: FnOnce() -> Result<T> + Send + 'static,
76    T: Send + 'static,
77{
78    match tokio::runtime::Handle::try_current() {
79        Ok(runtime) => runtime.spawn_blocking(f).await?,
80        Err(_) => f(),
81    }
82}
83
84/// Range requests with a gap less than or equal to this,
85/// will be coalesced into a single request by [`coalesce_ranges`]
86pub const OBJECT_STORE_COALESCE_DEFAULT: u64 = 1024 * 1024;
87
88/// Up to this number of range requests will be performed in parallel by [`coalesce_ranges`]
89pub(crate) const OBJECT_STORE_COALESCE_PARALLEL: usize = 10;
90
91/// Takes a function `fetch` that can fetch a range of bytes and uses this to
92/// fetch the provided byte `ranges`
93///
94/// To improve performance it will:
95///
96/// * Combine ranges less than `coalesce` bytes apart into a single call to `fetch`
97/// * Make multiple `fetch` requests in parallel (up to maximum of 10)
98///
99pub async fn coalesce_ranges<F, E, Fut>(
100    ranges: &[Range<u64>],
101    fetch: F,
102    coalesce: u64,
103) -> Result<Vec<Bytes>, E>
104where
105    F: Send + FnMut(Range<u64>) -> Fut,
106    E: Send,
107    Fut: std::future::Future<Output = Result<Bytes, E>> + Send,
108{
109    let fetch_ranges = merge_ranges(ranges, coalesce);
110
111    let fetched: Vec<_> = futures_util::stream::iter(fetch_ranges.iter().cloned())
112        .map(fetch)
113        .buffered(OBJECT_STORE_COALESCE_PARALLEL)
114        .try_collect()
115        .await?;
116
117    Ok(ranges
118        .iter()
119        .map(|range| {
120            let idx = fetch_ranges.partition_point(|v| v.start <= range.start) - 1;
121            let fetch_range = &fetch_ranges[idx];
122            let fetch_bytes = &fetched[idx];
123
124            let start = range.start - fetch_range.start;
125            let end = range.end - fetch_range.start;
126            let range = (start as usize)..(end as usize).min(fetch_bytes.len());
127            fetch_bytes.slice(range)
128        })
129        .collect())
130}
131
132/// Returns a sorted list of ranges that cover `ranges`
133pub(crate) fn merge_ranges(ranges: &[Range<u64>], coalesce: u64) -> Vec<Range<u64>> {
134    if ranges.is_empty() {
135        return vec![];
136    }
137
138    let mut ranges = ranges.to_vec();
139    ranges.sort_unstable_by_key(|range| range.start);
140
141    let mut ret = Vec::with_capacity(ranges.len());
142    let mut start_idx = 0;
143    let mut end_idx = 1;
144
145    while start_idx != ranges.len() {
146        let mut range_end = ranges[start_idx].end;
147
148        while end_idx != ranges.len()
149            && ranges[end_idx]
150                .start
151                .checked_sub(range_end)
152                .map(|delta| delta <= coalesce)
153                .unwrap_or(true)
154        {
155            range_end = range_end.max(ranges[end_idx].end);
156            end_idx += 1;
157        }
158
159        let start = ranges[start_idx].start;
160        let end = range_end;
161        ret.push(start..end);
162
163        start_idx = end_idx;
164        end_idx += 1;
165    }
166
167    ret
168}
169
170/// Request only a portion of an object's bytes
171///
172/// These can be created from [usize] ranges, like
173///
174/// ```rust
175/// # use object_store::GetRange;
176/// let range1: GetRange = (50..150).into();
177/// let range2: GetRange = (50..=150).into();
178/// let range3: GetRange = (50..).into();
179/// let range4: GetRange = (..150).into();
180/// ```
181///
182/// Implementations may wish to inspect [`GetResult`] for the exact byte
183/// range returned.
184///
185/// [`GetResult`]: crate::GetResult
186#[derive(Debug, PartialEq, Eq, Clone)]
187pub enum GetRange {
188    /// Request a specific range of bytes
189    ///
190    /// If the given range is zero-length or starts after the end of the object,
191    /// an error will be returned. Additionally, if the range ends after the end
192    /// of the object, the entire remainder of the object will be returned.
193    /// Otherwise, the exact requested range will be returned.
194    ///
195    /// Note that range is u64 (i.e., not usize),
196    /// as `object_store` supports 32-bit architectures such as WASM
197    Bounded(Range<u64>),
198    /// Request all bytes starting from a given byte offset
199    Offset(u64),
200    /// Request up to the last n bytes
201    Suffix(u64),
202}
203
204#[derive(Debug, thiserror::Error)]
205#[non_exhaustive]
206pub enum InvalidGetRange {
207    #[error("Wanted range starting at {requested}, but object was only {length} bytes long")]
208    StartTooLarge { requested: u64, length: u64 },
209
210    #[error("Range started at {start} and ended at {end}")]
211    Inconsistent { start: u64, end: u64 },
212
213    #[error("Range {requested} is larger than system memory limit {max}")]
214    TooLarge { requested: u64, max: u64 },
215}
216
217impl GetRange {
218    /// Check if the range is valid.
219    pub fn is_valid(&self) -> Result<(), InvalidGetRange> {
220        if let Self::Bounded(r) = self {
221            if r.end <= r.start {
222                return Err(InvalidGetRange::Inconsistent {
223                    start: r.start,
224                    end: r.end,
225                });
226            }
227            if (r.end - r.start) > usize::MAX as u64 {
228                return Err(InvalidGetRange::TooLarge {
229                    requested: r.start,
230                    max: usize::MAX as u64,
231                });
232            }
233        }
234        Ok(())
235    }
236
237    /// Convert to a [`Range`] if [valid](Self::is_valid).
238    pub fn as_range(&self, len: u64) -> Result<Range<u64>, InvalidGetRange> {
239        self.is_valid()?;
240        match self {
241            Self::Bounded(r) => {
242                if r.start >= len {
243                    Err(InvalidGetRange::StartTooLarge {
244                        requested: r.start,
245                        length: len,
246                    })
247                } else if r.end > len {
248                    Ok(r.start..len)
249                } else {
250                    Ok(r.clone())
251                }
252            }
253            Self::Offset(o) => {
254                if *o >= len {
255                    Err(InvalidGetRange::StartTooLarge {
256                        requested: *o,
257                        length: len,
258                    })
259                } else {
260                    Ok(*o..len)
261                }
262            }
263            Self::Suffix(n) => Ok(len.saturating_sub(*n)..len),
264        }
265    }
266}
267
268impl Display for GetRange {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match self {
271            Self::Bounded(r) => write!(f, "bytes={}-{}", r.start, r.end - 1),
272            Self::Offset(o) => write!(f, "bytes={o}-"),
273            Self::Suffix(n) => write!(f, "bytes=-{n}"),
274        }
275    }
276}
277
278impl<T: RangeBounds<u64>> From<T> for GetRange {
279    fn from(value: T) -> Self {
280        use std::ops::Bound::*;
281        let first = match value.start_bound() {
282            Included(i) => *i,
283            Excluded(i) => i + 1,
284            Unbounded => 0,
285        };
286        match value.end_bound() {
287            Included(i) => Self::Bounded(first..(i + 1)),
288            Excluded(i) => Self::Bounded(first..*i),
289            Unbounded => Self::Offset(first),
290        }
291    }
292}
293// http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
294//
295// Do not URI-encode any of the unreserved characters that RFC 3986 defines:
296// A-Z, a-z, 0-9, hyphen ( - ), underscore ( _ ), period ( . ), and tilde ( ~ ).
297#[cfg(any(feature = "aws-base", feature = "gcp-base"))]
298pub(crate) const STRICT_ENCODE_SET: percent_encoding::AsciiSet = percent_encoding::NON_ALPHANUMERIC
299    .remove(b'-')
300    .remove(b'.')
301    .remove(b'_')
302    .remove(b'~');
303
304/// Append `pairs` to the query string of `url`, percent-encoding keys and values with
305/// [`STRICT_ENCODE_SET`].
306///
307/// [`url::Url::query_pairs_mut`]'s `append_pair` serializes using
308/// `application/x-www-form-urlencoded`, which encodes spaces as `+`. AWS SigV4 and GCS V4 require
309/// spaces as `%20` and sign the canonicalized query string, so the bytes in the URL must match what
310/// is signed. Encoding the pairs ourselves keeps the URL and the signature consistent regardless of
311/// the characters a caller supplies.
312#[cfg(any(feature = "aws-base", feature = "gcp-base"))]
313pub(crate) fn append_strict_query_pairs(url: &mut url::Url, pairs: &[(String, String)]) {
314    use percent_encoding::utf8_percent_encode;
315    use std::fmt::Write;
316
317    if pairs.is_empty() {
318        return;
319    }
320
321    let mut query = url.query().unwrap_or_default().to_owned();
322    for (key, value) in pairs {
323        if !query.is_empty() {
324            query.push('&');
325        }
326        let _ = write!(
327            query,
328            "{}={}",
329            utf8_percent_encode(key, &STRICT_ENCODE_SET),
330            utf8_percent_encode(value, &STRICT_ENCODE_SET),
331        );
332    }
333    url.set_query(Some(&query));
334}
335
336/// Headers that are controlled by the signer (`host`) or silently dropped during canonicalization
337/// (`authorization`, `content-length`, `user-agent`). Allowing a caller to "sign" these would
338/// either corrupt the signature or be a silent no-op, so they are rejected.
339#[cfg(any(feature = "aws-base", feature = "gcp-base"))]
340const RESERVED_SIGNED_HEADERS: [&str; 4] =
341    ["host", "authorization", "content-length", "user-agent"];
342
343/// Validate the `extra_query` and `signed_headers` from a [`SignedUrlOptions`].
344///
345/// Query parameter names that are reserved for the signing protocol (those starting with
346/// `reserved_query_prefix`, e.g. `x-amz-`/`x-goog-`) are rejected so a caller cannot inject a
347/// duplicate `X-Amz-Signature`, `X-Amz-Expires`, etc. Header names in [`RESERVED_SIGNED_HEADERS`]
348/// are likewise rejected.
349///
350/// Header values must be valid UTF-8. While [`http::HeaderValue`] permits
351/// opaque bytes (0x80–0xFF) that UTF-8 does not, the canonical request is built
352/// as a string, so such values cannot be signed.
353///
354/// [`SignedUrlOptions`]: crate::signer::SignedUrlOptions
355#[cfg(any(feature = "aws-base", feature = "gcp-base"))]
356pub(crate) fn validate_signed_url_extras(
357    store: &'static str,
358    extra_query: &[(String, String)],
359    signed_headers: &http::HeaderMap,
360    reserved_query_prefix: &str,
361) -> Result<()> {
362    let err = |source: String| crate::Error::Generic {
363        store,
364        source: source.into(),
365    };
366
367    for (name, _) in extra_query {
368        if name.is_empty() {
369            return Err(err("query parameter name must not be empty".to_string()));
370        }
371        if name.to_ascii_lowercase().starts_with(reserved_query_prefix) {
372            return Err(err(format!(
373                "query parameter {name:?} is reserved for request signing and cannot be set via SignedUrlOptions"
374            )));
375        }
376    }
377
378    for (name, value) in signed_headers {
379        if RESERVED_SIGNED_HEADERS.contains(&name.as_str()) {
380            return Err(err(format!(
381                "header {name:?} is controlled by the signer and cannot be signed via SignedUrlOptions"
382            )));
383        }
384        if std::str::from_utf8(value.as_bytes()).is_err() {
385            return Err(err(format!(
386                "value of header {name:?} is not valid UTF-8 and cannot be signed via SignedUrlOptions"
387            )));
388        }
389    }
390
391    Ok(())
392}
393
394/// Computes the SHA256 digest of `body` returned as a hex encoded string
395#[cfg(any(feature = "aws-base", feature = "gcp-base"))]
396pub(crate) fn hex_digest(
397    crypto: &dyn crate::client::CryptoProvider,
398    bytes: &[u8],
399) -> Result<String> {
400    let mut ctx = crypto.digest(crate::client::DigestAlgorithm::Sha256)?;
401    ctx.update(bytes);
402    Ok(hex_encode(ctx.finish()?))
403}
404
405/// Returns `bytes` as a lower-case hex encoded string
406#[cfg(any(feature = "aws-base", feature = "gcp-base"))]
407pub(crate) fn hex_encode(bytes: &[u8]) -> String {
408    use std::fmt::Write;
409    let mut out = String::with_capacity(bytes.len() * 2);
410    for byte in bytes {
411        // String writing is infallible
412        let _ = write!(out, "{byte:02x}");
413    }
414    out
415}
416
417#[cfg(test)]
418mod tests {
419    use crate::Error;
420
421    use super::*;
422    use rand::{RngExt, rng};
423    use std::ops::Range;
424
425    /// Calls coalesce_ranges and validates the returned data is correct
426    ///
427    /// Returns the fetched ranges
428    async fn do_fetch(ranges: Vec<Range<u64>>, coalesce: u64) -> Vec<Range<u64>> {
429        let max = ranges.iter().map(|x| x.end).max().unwrap_or(0);
430        let src: Vec<_> = (0..max).map(|x| x as u8).collect();
431
432        let mut fetches = vec![];
433        let coalesced = coalesce_ranges::<_, Error, _>(
434            &ranges,
435            |range| {
436                fetches.push(range.clone());
437                let start = usize::try_from(range.start).unwrap();
438                let end = usize::try_from(range.end).unwrap();
439                futures_util::future::ready(Ok(Bytes::from(src[start..end].to_vec())))
440            },
441            coalesce,
442        )
443        .await
444        .unwrap();
445
446        assert_eq!(ranges.len(), coalesced.len());
447        for (range, bytes) in ranges.iter().zip(coalesced) {
448            assert_eq!(
449                bytes.as_ref(),
450                &src[usize::try_from(range.start).unwrap()..usize::try_from(range.end).unwrap()]
451            );
452        }
453        fetches
454    }
455
456    #[tokio::test]
457    async fn test_coalesce_ranges() {
458        let fetches = do_fetch(vec![], 0).await;
459        assert!(fetches.is_empty());
460
461        let fetches = do_fetch(vec![0..3; 1], 0).await;
462        assert_eq!(fetches, vec![0..3]);
463
464        let fetches = do_fetch(vec![0..2, 3..5], 0).await;
465        assert_eq!(fetches, vec![0..2, 3..5]);
466
467        let fetches = do_fetch(vec![0..1, 1..2], 0).await;
468        assert_eq!(fetches, vec![0..2]);
469
470        let fetches = do_fetch(vec![0..1, 2..72], 1).await;
471        assert_eq!(fetches, vec![0..72]);
472
473        let fetches = do_fetch(vec![0..1, 56..72, 73..75], 1).await;
474        assert_eq!(fetches, vec![0..1, 56..75]);
475
476        let fetches = do_fetch(vec![0..1, 5..6, 7..9, 2..3, 4..6], 1).await;
477        assert_eq!(fetches, vec![0..9]);
478
479        let fetches = do_fetch(vec![0..1, 5..6, 7..9, 2..3, 4..6], 1).await;
480        assert_eq!(fetches, vec![0..9]);
481
482        let fetches = do_fetch(vec![0..1, 6..7, 8..9, 10..14, 9..10], 4).await;
483        assert_eq!(fetches, vec![0..1, 6..14]);
484    }
485
486    #[tokio::test]
487    async fn test_coalesce_fuzz() {
488        let mut rand = rng();
489        for _ in 0..100 {
490            let object_len = rand.random_range(10..250);
491            let range_count = rand.random_range(0..10);
492            let ranges: Vec<_> = (0..range_count)
493                .map(|_| {
494                    let start = rand.random_range(0..object_len);
495                    let max_len = 20.min(object_len - start);
496                    let len = rand.random_range(0..max_len);
497                    start..start + len
498                })
499                .collect();
500
501            let coalesce = rand.random_range(1..5);
502            let fetches = do_fetch(ranges.clone(), coalesce).await;
503
504            for fetch in fetches.windows(2) {
505                assert!(
506                    fetch[0].start <= fetch[1].start,
507                    "fetches should be sorted, {:?} vs {:?}",
508                    fetch[0],
509                    fetch[1]
510                );
511
512                let delta = fetch[1].end - fetch[0].end;
513                assert!(
514                    delta > coalesce,
515                    "fetches should not overlap by {}, {:?} vs {:?} for {:?}",
516                    coalesce,
517                    fetch[0],
518                    fetch[1],
519                    ranges
520                );
521            }
522        }
523    }
524
525    #[test]
526    fn getrange_str() {
527        assert_eq!(GetRange::Offset(0).to_string(), "bytes=0-");
528        assert_eq!(GetRange::Bounded(10..19).to_string(), "bytes=10-18");
529        assert_eq!(GetRange::Suffix(10).to_string(), "bytes=-10");
530    }
531
532    #[test]
533    fn getrange_from() {
534        assert_eq!(Into::<GetRange>::into(10..15), GetRange::Bounded(10..15),);
535        assert_eq!(Into::<GetRange>::into(10..=15), GetRange::Bounded(10..16),);
536        assert_eq!(Into::<GetRange>::into(10..), GetRange::Offset(10),);
537        assert_eq!(Into::<GetRange>::into(..=15), GetRange::Bounded(0..16));
538    }
539
540    #[test]
541    fn test_as_range() {
542        let range = GetRange::Bounded(2..5);
543        assert_eq!(range.as_range(5).unwrap(), 2..5);
544
545        let range = range.as_range(4).unwrap();
546        assert_eq!(range, 2..4);
547
548        let range = GetRange::Bounded(3..3);
549        let err = range.as_range(2).unwrap_err().to_string();
550        assert_eq!(err, "Range started at 3 and ended at 3");
551
552        let range = GetRange::Bounded(2..2);
553        let err = range.as_range(3).unwrap_err().to_string();
554        assert_eq!(err, "Range started at 2 and ended at 2");
555
556        let range = GetRange::Suffix(3);
557        assert_eq!(range.as_range(3).unwrap(), 0..3);
558        assert_eq!(range.as_range(2).unwrap(), 0..2);
559
560        let range = GetRange::Suffix(0);
561        assert_eq!(range.as_range(0).unwrap(), 0..0);
562
563        let range = GetRange::Offset(2);
564        let err = range.as_range(2).unwrap_err().to_string();
565        assert_eq!(
566            err,
567            "Wanted range starting at 2, but object was only 2 bytes long"
568        );
569
570        let err = range.as_range(1).unwrap_err().to_string();
571        assert_eq!(
572            err,
573            "Wanted range starting at 2, but object was only 1 bytes long"
574        );
575
576        let range = GetRange::Offset(1);
577        assert_eq!(range.as_range(2).unwrap(), 1..2);
578    }
579
580    #[cfg(any(feature = "aws-base", feature = "gcp-base"))]
581    fn owned_pairs(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
582        pairs
583            .iter()
584            .map(|(k, v)| (k.to_string(), v.to_string()))
585            .collect()
586    }
587
588    #[cfg(any(feature = "aws-base", feature = "gcp-base"))]
589    #[test]
590    fn append_strict_query_pairs_uses_percent_encoding() {
591        let mut url = url::Url::parse("https://example.com/object").unwrap();
592        append_strict_query_pairs(
593            &mut url,
594            &owned_pairs(&[("a key", "a value"), ("plus", "a+b/c=d")]),
595        );
596        // Spaces are `%20` (not `+`), and reserved characters are percent-encoded.
597        assert_eq!(url.query().unwrap(), "a%20key=a%20value&plus=a%2Bb%2Fc%3Dd");
598        // Appending to an existing query preserves it.
599        append_strict_query_pairs(&mut url, &owned_pairs(&[("x", "y")]));
600        assert!(url.query().unwrap().ends_with("&x=y"));
601        // Empty input is a no-op.
602        let before = url.query().unwrap().to_owned();
603        append_strict_query_pairs(&mut url, &[]);
604        assert_eq!(url.query().unwrap(), before);
605    }
606
607    #[cfg(any(feature = "aws-base", feature = "gcp-base"))]
608    #[test]
609    fn validate_signed_url_extras_accepts_and_rejects() {
610        use http::{HeaderMap, HeaderName, HeaderValue};
611
612        let header = |name: &'static str| {
613            let mut h = HeaderMap::new();
614            h.insert(HeaderName::from_static(name), HeaderValue::from_static("v"));
615            h
616        };
617
618        // Legitimate extras are accepted.
619        validate_signed_url_extras(
620            "S3",
621            &owned_pairs(&[("partNumber", "1"), ("uploadId", "abc")]),
622            &header("content-type"),
623            "x-amz-",
624        )
625        .unwrap();
626
627        // Reserved query parameters are rejected (case-insensitively).
628        for key in ["X-Amz-Signature", "x-amz-expires", "X-Amz-Security-Token"] {
629            let err = validate_signed_url_extras(
630                "S3",
631                &owned_pairs(&[(key, "x")]),
632                &HeaderMap::new(),
633                "x-amz-",
634            )
635            .unwrap_err();
636            assert!(
637                matches!(err, Error::Generic { .. }),
638                "{key} should be rejected"
639            );
640        }
641        // GCS uses a different reserved prefix.
642        assert!(
643            validate_signed_url_extras(
644                "GCS",
645                &owned_pairs(&[("X-Goog-Signature", "x")]),
646                &HeaderMap::new(),
647                "x-goog-"
648            )
649            .is_err()
650        );
651
652        // Empty query parameter names are rejected.
653        assert!(
654            validate_signed_url_extras(
655                "S3",
656                &owned_pairs(&[("", "x")]),
657                &HeaderMap::new(),
658                "x-amz-"
659            )
660            .is_err()
661        );
662
663        // Headers controlled by the signer or dropped during canonicalization are rejected.
664        for name in ["host", "authorization", "content-length", "user-agent"] {
665            let err = validate_signed_url_extras("S3", &[], &header(name), "x-amz-").unwrap_err();
666            assert!(
667                matches!(err, Error::Generic { .. }),
668                "{name} should be rejected"
669            );
670        }
671
672        // Header values that are not valid UTF-8 (which `HeaderValue` permits) are rejected
673        // with an error rather than panicking during canonicalization.
674        let mut non_utf8 = HeaderMap::new();
675        non_utf8.insert(
676            HeaderName::from_static("x-custom"),
677            HeaderValue::from_bytes(&[0xFF]).unwrap(),
678        );
679        let err = validate_signed_url_extras("S3", &[], &non_utf8, "x-amz-").unwrap_err();
680        assert!(
681            matches!(err, Error::Generic { .. }),
682            "non-UTF-8 header value should be rejected"
683        );
684    }
685}