Skip to main content

aws_smithy_runtime_api/http/
headers.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Types for HTTP headers
7
8use crate::http::error::{HttpError, NonUtf8Header};
9use std::borrow::Cow;
10use std::fmt::Debug;
11use std::str::FromStr;
12
13/// Header names whose values must be redacted in Debug output to prevent
14/// credential / session-token / customer-key leakage via tracing.
15const DENYLIST: &[&str] = &[
16    "authorization",
17    "proxy-authorization",
18    "x-amz-security-token",
19    "cookie",
20    "set-cookie",
21    "x-amz-server-side-encryption-customer-key",
22    "x-amz-server-side-encryption-customer-key-md5",
23    "x-amz-copy-source-server-side-encryption-customer-key",
24    "x-amz-copy-source-server-side-encryption-customer-key-md5",
25];
26
27fn is_sensitive(name: &str) -> bool {
28    DENYLIST.iter().any(|d| name.eq_ignore_ascii_case(d))
29}
30
31/// An immutable view of headers
32#[derive(Clone, Default)]
33pub struct Headers {
34    pub(super) headers: http_1x::HeaderMap<HeaderValue>,
35}
36
37impl Debug for Headers {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        let mut map = f.debug_map();
40        for (key, value) in self.headers.iter() {
41            let name = key.as_str();
42            if is_sensitive(name) {
43                map.entry(
44                    &name,
45                    &format_args!("** redacted (length={}) **", value.as_ref().len()),
46                );
47            } else {
48                map.entry(&name, &value.as_ref());
49            }
50        }
51        map.finish()
52    }
53}
54
55impl<'a> IntoIterator for &'a Headers {
56    type Item = (&'a str, &'a str);
57    type IntoIter = HeadersIter<'a>;
58
59    fn into_iter(self) -> Self::IntoIter {
60        HeadersIter {
61            inner: self.headers.iter(),
62        }
63    }
64}
65
66/// An Iterator over headers
67pub struct HeadersIter<'a> {
68    inner: http_1x::header::Iter<'a, HeaderValue>,
69}
70
71impl<'a> Iterator for HeadersIter<'a> {
72    type Item = (&'a str, &'a str);
73
74    fn next(&mut self) -> Option<Self::Item> {
75        self.inner.next().map(|(k, v)| (k.as_str(), v.as_ref()))
76    }
77}
78
79impl Headers {
80    /// Create an empty header map
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    #[cfg(feature = "http-1x")]
86    pub(crate) fn http1_headermap(self) -> http_1x::HeaderMap {
87        let mut headers = http_1x::HeaderMap::new();
88        headers.reserve(self.headers.len());
89        headers.extend(self.headers.into_iter().map(|(k, v)| (k, v.into_http1x())));
90        headers
91    }
92
93    #[cfg(feature = "http-02x")]
94    pub(crate) fn http0_headermap(self) -> http_02x::HeaderMap {
95        let mut headers = http_02x::HeaderMap::new();
96        headers.reserve(self.headers.len());
97        headers.extend(self.headers.into_iter().map(|(k, v)| {
98            (
99                k.map(|n| {
100                    http_02x::HeaderName::from_bytes(n.as_str().as_bytes()).expect("proven valid")
101                }),
102                v.into_http02x(),
103            )
104        }));
105        headers
106    }
107
108    /// Returns the value for a given key
109    ///
110    /// If multiple values are associated, the first value is returned
111    /// See [HeaderMap::get](http_1x::HeaderMap::get)
112    pub fn get(&self, key: impl AsRef<str>) -> Option<&str> {
113        self.headers.get(key.as_ref()).map(|v| v.as_ref())
114    }
115
116    /// Returns all values for a given key
117    pub fn get_all(&self, key: impl AsRef<str>) -> impl Iterator<Item = &str> {
118        self.headers
119            .get_all(key.as_ref())
120            .iter()
121            .map(|v| v.as_ref())
122    }
123
124    /// Returns an iterator over the headers
125    pub fn iter(&self) -> HeadersIter<'_> {
126        HeadersIter {
127            inner: self.headers.iter(),
128        }
129    }
130
131    /// Returns the total number of **values** stored in the map
132    pub fn len(&self) -> usize {
133        self.headers.len()
134    }
135
136    /// Returns true if there are no headers
137    pub fn is_empty(&self) -> bool {
138        self.len() == 0
139    }
140
141    /// Returns true if this header is present
142    pub fn contains_key(&self, key: impl AsRef<str>) -> bool {
143        self.headers.contains_key(key.as_ref())
144    }
145
146    /// Insert a value into the headers structure.
147    ///
148    /// This will *replace* any existing value for this key. Returns the previous associated value if any.
149    ///
150    /// # Panics
151    /// If the key is not valid ASCII, or if the value is not valid UTF-8, this function will panic.
152    pub fn insert(
153        &mut self,
154        key: impl AsHeaderComponent,
155        value: impl AsHeaderComponent,
156    ) -> Option<String> {
157        let key = header_name(key, false).unwrap();
158        let value = header_value(value.into_maybe_static().unwrap(), false).unwrap();
159        self.headers
160            .insert(key, value)
161            .map(|old_value| old_value.into())
162    }
163
164    /// Insert a value into the headers structure.
165    ///
166    /// This will *replace* any existing value for this key. Returns the previous associated value if any.
167    ///
168    /// If the key is not valid ASCII, or if the value is not valid UTF-8, this function will return an error.
169    pub fn try_insert(
170        &mut self,
171        key: impl AsHeaderComponent,
172        value: impl AsHeaderComponent,
173    ) -> Result<Option<String>, HttpError> {
174        let key = header_name(key, true)?;
175        let value = header_value(value.into_maybe_static()?, true)?;
176        Ok(self
177            .headers
178            .insert(key, value)
179            .map(|old_value| old_value.into()))
180    }
181
182    /// Appends a value to a given key
183    ///
184    /// # Panics
185    /// If the key is not valid ASCII, or if the value is not valid UTF-8, this function will panic.
186    pub fn append(&mut self, key: impl AsHeaderComponent, value: impl AsHeaderComponent) -> bool {
187        let key = header_name(key.into_maybe_static().unwrap(), false).unwrap();
188        let value = header_value(value.into_maybe_static().unwrap(), false).unwrap();
189        self.headers.append(key, value)
190    }
191
192    /// Appends a value to a given key
193    ///
194    /// If the key is not valid ASCII, or if the value is not valid UTF-8, this function will return an error.
195    pub fn try_append(
196        &mut self,
197        key: impl AsHeaderComponent,
198        value: impl AsHeaderComponent,
199    ) -> Result<bool, HttpError> {
200        let key = header_name(key.into_maybe_static()?, true)?;
201        let value = header_value(value.into_maybe_static()?, true)?;
202        Ok(self.headers.append(key, value))
203    }
204
205    /// Removes all headers with a given key
206    ///
207    /// If there are multiple entries for this key, the first entry is returned
208    pub fn remove(&mut self, key: impl AsRef<str>) -> Option<String> {
209        self.headers
210            .remove(key.as_ref())
211            .map(|h| h.as_str().to_string())
212    }
213}
214
215#[cfg(feature = "http-02x")]
216impl TryFrom<http_02x::HeaderMap> for Headers {
217    type Error = HttpError;
218
219    fn try_from(value: http_02x::HeaderMap) -> Result<Self, Self::Error> {
220        if let Some(utf8_error) = value.iter().find_map(|(k, v)| {
221            std::str::from_utf8(v.as_bytes())
222                .err()
223                .map(|err| NonUtf8Header::new(k.as_str().to_owned(), v.as_bytes().to_vec(), err))
224        }) {
225            Err(HttpError::non_utf8_header(utf8_error))
226        } else {
227            // `http` 0.2.x accepts some header names that `http` 1.x rejects (for example names
228            // containing `"`). Convert fallibly and surface an error instead of panicking.
229            //
230            // A `None` key in `HeaderMap`'s iterator means "same name as the previous entry"
231            // (multi-value headers), so the converted names are collected in order before being
232            // extended into the map to preserve that association.
233            let converted: Vec<(Option<http_1x::HeaderName>, HeaderValue)> = value
234                .into_iter()
235                .map(|(k, v)| {
236                    let name = k
237                        .map(|n| http_1x::HeaderName::from_bytes(n.as_str().as_bytes()))
238                        .transpose()
239                        .map_err(HttpError::invalid_header_name)?;
240                    Ok((name, HeaderValue::from_http02x(v).expect("validated above")))
241                })
242                .collect::<Result<_, HttpError>>()?;
243            let mut string_safe_headers: http_1x::HeaderMap<HeaderValue> = Default::default();
244            string_safe_headers.extend(converted);
245            Ok(Headers {
246                headers: string_safe_headers,
247            })
248        }
249    }
250}
251
252#[cfg(feature = "http-1x")]
253impl TryFrom<http_1x::HeaderMap> for Headers {
254    type Error = HttpError;
255
256    fn try_from(value: http_1x::HeaderMap) -> Result<Self, Self::Error> {
257        if let Some(utf8_error) = value.iter().find_map(|(k, v)| {
258            std::str::from_utf8(v.as_bytes())
259                .err()
260                .map(|err| NonUtf8Header::new(k.as_str().to_owned(), v.as_bytes().to_vec(), err))
261        }) {
262            Err(HttpError::non_utf8_header(utf8_error))
263        } else {
264            let mut string_safe_headers: http_1x::HeaderMap<HeaderValue> = Default::default();
265            string_safe_headers.extend(
266                value
267                    .into_iter()
268                    .map(|(k, v)| (k, HeaderValue::from_http1x(v).expect("validated above"))),
269            );
270            Ok(Headers {
271                headers: string_safe_headers,
272            })
273        }
274    }
275}
276
277use sealed::AsHeaderComponent;
278
279mod sealed {
280    use super::*;
281    /// Trait defining things that may be converted into a header component (name or value)
282    pub trait AsHeaderComponent {
283        /// If the component can be represented as a Cow<'static, str>, return it
284        fn into_maybe_static(self) -> Result<MaybeStatic, HttpError>;
285
286        /// Return a string reference to this header
287        fn as_str(&self) -> Result<&str, HttpError>;
288
289        /// If a component is already internally represented as a `http_1x::HeaderName`, return it
290        fn repr_as_http1x_header_name(self) -> Result<http_1x::HeaderName, Self>
291        where
292            Self: Sized,
293        {
294            Err(self)
295        }
296    }
297
298    impl AsHeaderComponent for &'static str {
299        fn into_maybe_static(self) -> Result<MaybeStatic, HttpError> {
300            Ok(Cow::Borrowed(self))
301        }
302
303        fn as_str(&self) -> Result<&str, HttpError> {
304            Ok(self)
305        }
306    }
307
308    impl AsHeaderComponent for String {
309        fn into_maybe_static(self) -> Result<MaybeStatic, HttpError> {
310            Ok(Cow::Owned(self))
311        }
312
313        fn as_str(&self) -> Result<&str, HttpError> {
314            Ok(self)
315        }
316    }
317
318    impl AsHeaderComponent for Cow<'static, str> {
319        fn into_maybe_static(self) -> Result<MaybeStatic, HttpError> {
320            Ok(self)
321        }
322
323        fn as_str(&self) -> Result<&str, HttpError> {
324            Ok(self.as_ref())
325        }
326    }
327
328    #[cfg(feature = "http-02x")]
329    impl AsHeaderComponent for http_02x::HeaderValue {
330        fn into_maybe_static(self) -> Result<MaybeStatic, HttpError> {
331            Ok(Cow::Owned(
332                std::str::from_utf8(self.as_bytes())
333                    .map_err(|err| {
334                        HttpError::non_utf8_header(NonUtf8Header::new_missing_name(
335                            self.as_bytes().to_vec(),
336                            err,
337                        ))
338                    })?
339                    .to_string(),
340            ))
341        }
342
343        fn as_str(&self) -> Result<&str, HttpError> {
344            std::str::from_utf8(self.as_bytes()).map_err(|err| {
345                HttpError::non_utf8_header(NonUtf8Header::new_missing_name(
346                    self.as_bytes().to_vec(),
347                    err,
348                ))
349            })
350        }
351    }
352
353    #[cfg(feature = "http-02x")]
354    impl AsHeaderComponent for http_02x::HeaderName {
355        fn into_maybe_static(self) -> Result<MaybeStatic, HttpError> {
356            Ok(self.to_string().into())
357        }
358
359        fn as_str(&self) -> Result<&str, HttpError> {
360            Ok(self.as_ref())
361        }
362    }
363
364    impl AsHeaderComponent for http_1x::HeaderName {
365        fn into_maybe_static(self) -> Result<MaybeStatic, HttpError> {
366            Ok(self.to_string().into())
367        }
368
369        fn as_str(&self) -> Result<&str, HttpError> {
370            Ok(self.as_ref())
371        }
372
373        fn repr_as_http1x_header_name(self) -> Result<http_1x::HeaderName, Self>
374        where
375            Self: Sized,
376        {
377            Ok(self)
378        }
379    }
380
381    impl AsHeaderComponent for http_1x::HeaderValue {
382        fn into_maybe_static(self) -> Result<MaybeStatic, HttpError> {
383            Ok(Cow::Owned(
384                std::str::from_utf8(self.as_bytes())
385                    .map_err(|err| {
386                        HttpError::non_utf8_header(NonUtf8Header::new_missing_name(
387                            self.as_bytes().to_vec(),
388                            err,
389                        ))
390                    })?
391                    .to_string(),
392            ))
393        }
394
395        fn as_str(&self) -> Result<&str, HttpError> {
396            std::str::from_utf8(self.as_bytes()).map_err(|err| {
397                HttpError::non_utf8_header(NonUtf8Header::new_missing_name(
398                    self.as_bytes().to_vec(),
399                    err,
400                ))
401            })
402        }
403    }
404}
405
406mod header_value {
407    use super::*;
408
409    /// HeaderValue type
410    ///
411    /// **Note**: Unlike `HeaderValue` in `http`, this only supports UTF-8 header values
412    #[derive(Debug, Clone)]
413    pub struct HeaderValue {
414        _private: Inner,
415    }
416
417    #[derive(Debug, Clone)]
418    enum Inner {
419        #[cfg(feature = "http-02x")]
420        H0(http_02x::HeaderValue),
421        H1(http_1x::HeaderValue),
422    }
423
424    impl HeaderValue {
425        #[cfg(feature = "http-02x")]
426        pub(crate) fn from_http02x(value: http_02x::HeaderValue) -> Result<Self, HttpError> {
427            let _ = std::str::from_utf8(value.as_bytes()).map_err(|err| {
428                HttpError::non_utf8_header(NonUtf8Header::new_missing_name(
429                    value.as_bytes().to_vec(),
430                    err,
431                ))
432            })?;
433            Ok(Self {
434                _private: Inner::H0(value),
435            })
436        }
437
438        #[allow(dead_code)]
439        pub(crate) fn from_http1x(value: http_1x::HeaderValue) -> Result<Self, HttpError> {
440            let _ = std::str::from_utf8(value.as_bytes()).map_err(|err| {
441                HttpError::non_utf8_header(NonUtf8Header::new_missing_name(
442                    value.as_bytes().to_vec(),
443                    err,
444                ))
445            })?;
446            Ok(Self {
447                _private: Inner::H1(value),
448            })
449        }
450
451        #[cfg(feature = "http-02x")]
452        pub(crate) fn into_http02x(self) -> http_02x::HeaderValue {
453            match self._private {
454                Inner::H0(v) => v,
455                Inner::H1(v) => http_02x::HeaderValue::from_maybe_shared(v).expect("unreachable"),
456            }
457        }
458
459        #[allow(dead_code)]
460        pub(crate) fn into_http1x(self) -> http_1x::HeaderValue {
461            match self._private {
462                Inner::H1(v) => v,
463                #[cfg(feature = "http-02x")]
464                Inner::H0(v) => http_1x::HeaderValue::from_maybe_shared(v).expect("unreachable"),
465            }
466        }
467    }
468
469    impl AsRef<str> for HeaderValue {
470        fn as_ref(&self) -> &str {
471            let bytes = match &self._private {
472                #[cfg(feature = "http-02x")]
473                Inner::H0(v) => v.as_bytes(),
474                Inner::H1(v) => v.as_bytes(),
475            };
476            std::str::from_utf8(bytes).expect("unreachable—only strings may be stored")
477        }
478    }
479
480    impl From<HeaderValue> for String {
481        fn from(value: HeaderValue) -> Self {
482            value.as_ref().to_string()
483        }
484    }
485
486    impl HeaderValue {
487        /// Returns the string representation of this header value
488        pub fn as_str(&self) -> &str {
489            self.as_ref()
490        }
491    }
492
493    impl FromStr for HeaderValue {
494        type Err = HttpError;
495
496        fn from_str(s: &str) -> Result<Self, Self::Err> {
497            HeaderValue::try_from(s.to_string())
498        }
499    }
500
501    impl TryFrom<String> for HeaderValue {
502        type Error = HttpError;
503
504        fn try_from(value: String) -> Result<Self, Self::Error> {
505            Ok(HeaderValue::from_http1x(
506                http_1x::HeaderValue::try_from(value).map_err(HttpError::invalid_header_value)?,
507            )
508            .expect("input was a string"))
509        }
510    }
511}
512
513pub use header_value::HeaderValue;
514
515type MaybeStatic = Cow<'static, str>;
516
517fn header_name(
518    name: impl AsHeaderComponent,
519    panic_safe: bool,
520) -> Result<http_1x::HeaderName, HttpError> {
521    name.repr_as_http1x_header_name().or_else(|name| {
522        name.into_maybe_static().and_then(|mut cow| {
523            if cow.chars().any(|c| c.is_ascii_uppercase()) {
524                cow = Cow::Owned(cow.to_ascii_uppercase());
525            }
526            match cow {
527                Cow::Borrowed(s) if panic_safe => {
528                    http_1x::HeaderName::try_from(s).map_err(HttpError::invalid_header_name)
529                }
530                Cow::Borrowed(static_s) => Ok(http_1x::HeaderName::from_static(static_s)),
531                Cow::Owned(s) => {
532                    http_1x::HeaderName::try_from(s).map_err(HttpError::invalid_header_name)
533                }
534            }
535        })
536    })
537}
538
539fn header_value(value: MaybeStatic, panic_safe: bool) -> Result<HeaderValue, HttpError> {
540    let header = match value {
541        Cow::Borrowed(b) if panic_safe => {
542            http_1x::HeaderValue::try_from(b).map_err(HttpError::invalid_header_value)?
543        }
544        Cow::Borrowed(b) => http_1x::HeaderValue::from_static(b),
545        Cow::Owned(s) => {
546            http_1x::HeaderValue::try_from(s).map_err(HttpError::invalid_header_value)?
547        }
548    };
549    HeaderValue::from_http1x(header)
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn headers_can_be_any_string() {
558        let _: HeaderValue = "😹".parse().expect("can be any string");
559        let _: HeaderValue = "abcd".parse().expect("can be any string");
560        let _ = "a\nb"
561            .parse::<HeaderValue>()
562            .expect_err("cannot contain control characters");
563    }
564
565    #[test]
566    fn no_panic_insert_upper_case_header_name() {
567        let mut headers = Headers::new();
568        headers.insert("I-Have-Upper-Case", "foo");
569    }
570    #[test]
571    fn no_panic_append_upper_case_header_name() {
572        let mut headers = Headers::new();
573        headers.append("I-Have-Upper-Case", "foo");
574    }
575
576    #[test]
577    #[should_panic]
578    fn panic_insert_invalid_ascii_key() {
579        let mut headers = Headers::new();
580        headers.insert("💩", "foo");
581    }
582    #[test]
583    #[should_panic]
584    fn panic_insert_invalid_header_value() {
585        let mut headers = Headers::new();
586        headers.insert("foo", "💩");
587    }
588    #[test]
589    #[should_panic]
590    fn panic_append_invalid_ascii_key() {
591        let mut headers = Headers::new();
592        headers.append("💩", "foo");
593    }
594    #[test]
595    #[should_panic]
596    fn panic_append_invalid_header_value() {
597        let mut headers = Headers::new();
598        headers.append("foo", "💩");
599    }
600
601    #[test]
602    fn no_panic_try_insert_invalid_ascii_key() {
603        let mut headers = Headers::new();
604        assert!(headers.try_insert("💩", "foo").is_err());
605    }
606    #[test]
607    fn no_panic_try_insert_invalid_header_value() {
608        let mut headers = Headers::new();
609        assert!(headers
610            .try_insert(
611                "foo",
612                // Valid header value with invalid UTF-8
613                http_1x::HeaderValue::from_bytes(&[0xC0, 0x80]).unwrap()
614            )
615            .is_err());
616    }
617    #[test]
618    fn no_panic_try_append_invalid_ascii_key() {
619        let mut headers = Headers::new();
620        assert!(headers.try_append("💩", "foo").is_err());
621    }
622    #[test]
623    fn no_panic_try_append_invalid_header_value() {
624        let mut headers = Headers::new();
625        assert!(headers
626            .try_insert(
627                "foo",
628                // Valid header value with invalid UTF-8
629                http_1x::HeaderValue::from_bytes(&[0xC0, 0x80]).unwrap()
630            )
631            .is_err());
632    }
633
634    proptest::proptest! {
635        #[test]
636        fn insert_header_prop_test(input in ".*") {
637            let mut headers = Headers::new();
638            let _ = headers.try_insert(input.clone(), input);
639        }
640
641        #[test]
642        fn append_header_prop_test(input in ".*") {
643            let mut headers = Headers::new();
644            let _ = headers.try_append(input.clone(), input);
645        }
646    }
647
648    // `http` 0.2.x accepts header names (e.g. containing `"`) that `http` 1.x rejects. Converting
649    // such a map must return an `Err`, not panic.
650    #[cfg(feature = "http-02x")]
651    #[test]
652    fn converting_an_http02x_headermap_never_panics() {
653        let name = http_02x::HeaderName::from_bytes(b"a\"b").expect("http 0.2.x accepts this");
654        let mut map = http_02x::HeaderMap::new();
655        map.insert(name, http_02x::HeaderValue::from_static("v"));
656        let res = std::panic::catch_unwind(|| Headers::try_from(map));
657        assert!(
658            res.is_ok(),
659            "TryFrom<http_02x::HeaderMap> for Headers panicked on a name that http 0.2.x \
660             considers valid but http 1.x does not; a TryFrom should return Err"
661        );
662        assert!(
663            res.unwrap().is_err(),
664            "expected an Err for a header name that http 1.x rejects"
665        );
666    }
667
668    // Multi-value headers rely on the `None`-key semantics of `HeaderMap`'s iterator; make sure the
669    // fallible conversion preserves all values for a repeated name.
670    #[cfg(feature = "http-02x")]
671    #[test]
672    fn converting_an_http02x_headermap_preserves_multi_value_headers() {
673        let mut map = http_02x::HeaderMap::new();
674        map.append("multi", http_02x::HeaderValue::from_static("v1"));
675        map.append("multi", http_02x::HeaderValue::from_static("v2"));
676        let headers = Headers::try_from(map).expect("valid headers");
677        let values: Vec<_> = headers.get_all("multi").collect();
678        assert_eq!(values, vec!["v1", "v2"]);
679    }
680}
681
682#[cfg(test)]
683mod redaction_tests {
684    use super::*;
685
686    #[test]
687    fn debug_redacts_authorization() {
688        let mut headers = Headers::new();
689        headers.insert(
690            "authorization",
691            "AWS4-HMAC-SHA256 Credential=AKIAXXX/.../Signature=SECRETSIGMARKER",
692        );
693        let output = format!("{:?}", headers);
694        assert!(!output.contains("SECRETSIGMARKER"));
695        assert!(output.contains("authorization"));
696        assert!(output.contains("** redacted"));
697    }
698
699    #[test]
700    fn debug_redacts_security_token() {
701        let mut headers = Headers::new();
702        headers.insert("x-amz-security-token", "IQoJb3JpZ2luSECRETTOKENMARKERzzz");
703        let output = format!("{:?}", headers);
704        assert!(!output.contains("SECRETTOKENMARKER"));
705        assert!(output.contains("x-amz-security-token"));
706        assert!(output.contains("length="));
707    }
708
709    #[test]
710    fn debug_redacts_mixed_case_header_name() {
711        let mut headers = Headers::new();
712        headers.insert(
713            "Authorization",
714            "AWS4-HMAC-SHA256 Credential=AKIAXXX/.../Signature=SECRETSIGMARKER",
715        );
716        let output = format!("{:?}", headers);
717        assert!(!output.contains("SECRETSIGMARKER"));
718        assert!(output.contains("** redacted"));
719    }
720
721    #[test]
722    fn debug_preserves_non_sensitive_headers() {
723        let mut headers = Headers::new();
724        headers.insert("host", "example.com");
725        headers.insert("x-amz-user-agent", "aws-sdk-rust/1.0");
726        let output = format!("{:?}", headers);
727        assert!(output.contains("example.com"));
728        assert!(output.contains("aws-sdk-rust/1.0"));
729    }
730
731    #[test]
732    fn debug_handles_sse_customer_key() {
733        let mut headers = Headers::new();
734        headers.insert(
735            "x-amz-server-side-encryption-customer-key",
736            "BASE64KEYMARKER_DO_NOT_LOG",
737        );
738        let output = format!("{:?}", headers);
739        assert!(!output.contains("BASE64KEYMARKER_DO_NOT_LOG"));
740        assert!(output.contains("x-amz-server-side-encryption-customer-key"));
741        assert!(output.contains("** redacted"));
742    }
743
744    #[test]
745    fn debug_includes_length() {
746        let value = "exactly-twenty-chars";
747        assert_eq!(value.len(), 20);
748        let mut headers = Headers::new();
749        headers.insert("authorization", value);
750        let output = format!("{:?}", headers);
751        assert!(output.contains("length=20"));
752    }
753}