aws_smithy_http_server/instrumentation/sensitivity/uri/
label.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! A wrapper around a path [`&str`](str) to allow for sensitivity.
7
8use std::fmt::{Debug, Display, Error, Formatter};
9
10use crate::instrumentation::{sensitivity::Sensitive, MakeFmt};
11
12/// A wrapper around a path [`&str`](str) which modifies the behavior of [`Display`]. Specific path segments are marked
13/// as sensitive by providing predicate over the segment index. This accommodates the [httpLabel trait] with
14/// non-greedy labels.
15///
16/// The [`Display`] implementation will respect the `unredacted-logging` flag.
17///
18/// # Example
19///
20/// ```
21/// # use aws_smithy_http_server::instrumentation::sensitivity::uri::Label;
22/// # use http::Uri;
23/// # let path = "";
24/// // Path segment 2 is redacted and a trailing greedy label
25/// let uri = Label::new(&path, |x| x == 2, None);
26/// println!("{uri}");
27/// ```
28///
29/// [httpLabel trait]: https://smithy.io/2.0/spec/http-bindings.html#httplabel-trait
30#[allow(missing_debug_implementations)]
31#[derive(Clone)]
32pub struct Label<'a, F> {
33    path: &'a str,
34    label_marker: F,
35    greedy_label: Option<GreedyLabel>,
36}
37
38/// Marks a segment as a greedy label up until a char offset from the end.
39///
40/// # Example
41///
42/// The pattern, `/alpha/beta/{greedy+}/trail`, has segment index 2 and offset from the end of 6.
43///
44/// ```rust
45/// # use aws_smithy_http_server::instrumentation::sensitivity::uri::GreedyLabel;
46/// let greedy_label = GreedyLabel::new(2, 6);
47/// ```
48#[derive(Clone, Debug)]
49pub struct GreedyLabel {
50    segment_index: usize,
51    end_offset: usize,
52}
53
54impl GreedyLabel {
55    /// Constructs a new [`GreedyLabel`] from a segment index and an offset from the end of the URI.
56    pub fn new(segment_index: usize, end_offset: usize) -> Self {
57        Self {
58            segment_index,
59            end_offset,
60        }
61    }
62}
63
64impl<'a, F> Label<'a, F> {
65    /// Constructs a new [`Label`].
66    pub fn new(path: &'a str, label_marker: F, greedy_label: Option<GreedyLabel>) -> Self {
67        Self {
68            path,
69            label_marker,
70            greedy_label,
71        }
72    }
73}
74
75impl<'a, F> Display for Label<'a, F>
76where
77    F: Fn(usize) -> bool,
78{
79    #[inline]
80    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
81        if let Some(greedy_label) = &self.greedy_label {
82            // Calculate the byte index of the start of the greedy label and whether it was reached while writing the
83            // normal labels.
84            // TODO(clippy): Switch from fold to try_fold
85            #[allow(clippy::manual_try_fold)]
86            let (greedy_start, greedy_hit) = self
87                .path
88                .split('/')
89                // Skip the first segment which will always be empty.
90                .skip(1)
91                // Iterate up to the segment index given in the `GreedyLabel`.
92                .take(greedy_label.segment_index + 1)
93                .enumerate()
94                .fold(Ok((0, false)), |acc, (index, segment)| {
95                    acc.and_then(|(greedy_start, _)| {
96                        if index == greedy_label.segment_index {
97                            // We've hit the greedy label, set `hit_greedy` to `true`.
98                            Ok((greedy_start, true))
99                        } else {
100                            // Prior to greedy segment, use `label_marker` to redact segments.
101                            if (self.label_marker)(index) {
102                                write!(f, "/{}", Sensitive(segment))?;
103                            } else {
104                                write!(f, "/{}", segment)?;
105                            }
106                            // Add the segment length and the separator to the `greedy_start`.
107                            let greedy_start = greedy_start + segment.len() + 1;
108                            Ok((greedy_start, false))
109                        }
110                    })
111                })?;
112
113            // If we reached the greedy label segment then use the `end_offset` to redact the interval
114            // and print the remainder.
115            if greedy_hit {
116                if let Some(end_index) = self.path.len().checked_sub(greedy_label.end_offset) {
117                    if greedy_start < end_index {
118                        // [greedy_start + 1 .. end_index] is a non-empty slice - redact it.
119                        let greedy_redaction = Sensitive(&self.path[greedy_start + 1..end_index]);
120                        let remainder = &self.path[end_index..];
121                        write!(f, "/{greedy_redaction}{remainder}")?;
122                    } else {
123                        // [greedy_start + 1 .. end_index] is an empty slice - don't redact it.
124                        // NOTE: This is unreachable if the greedy label is valid.
125                        write!(f, "{}", &self.path[greedy_start..])?;
126                    }
127                }
128            } else {
129                // NOTE: This is unreachable if the greedy label is valid.
130            }
131        } else {
132            // Use `label_marker` to redact segments.
133            for (index, segment) in self
134                .path
135                .split('/')
136                // Skip the first segment which will always be empty.
137                .skip(1)
138                .enumerate()
139            {
140                if (self.label_marker)(index) {
141                    write!(f, "/{}", Sensitive(segment))?;
142                } else {
143                    write!(f, "/{}", segment)?;
144                }
145            }
146        }
147
148        Ok(())
149    }
150}
151
152/// A [`MakeFmt`] producing [`Label`].
153#[derive(Clone)]
154pub struct MakeLabel<F> {
155    pub(crate) label_marker: F,
156    pub(crate) greedy_label: Option<GreedyLabel>,
157}
158
159impl<F> Debug for MakeLabel<F> {
160    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
161        f.debug_struct("MakeLabel")
162            .field("greedy_label", &self.greedy_label)
163            .finish_non_exhaustive()
164    }
165}
166
167impl<'a, F> MakeFmt<&'a str> for MakeLabel<F>
168where
169    F: Clone,
170{
171    type Target = Label<'a, F>;
172
173    fn make(&self, path: &'a str) -> Self::Target {
174        Label::new(path, self.label_marker.clone(), self.greedy_label.clone())
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use http::Uri;
181
182    use crate::instrumentation::sensitivity::uri::{tests::EXAMPLES, GreedyLabel};
183
184    use super::Label;
185
186    #[test]
187    fn mark_none() {
188        let originals = EXAMPLES.into_iter().map(Uri::from_static);
189        for original in originals {
190            let expected = original.path().to_string();
191            let output = Label::new(original.path(), |_| false, None).to_string();
192            assert_eq!(output, expected, "original = {original}");
193        }
194    }
195
196    #[cfg(not(feature = "unredacted-logging"))]
197    const ALL_EXAMPLES: [&str; 19] = [
198        "g:h",
199        "http://a/{redacted}/{redacted}/{redacted}",
200        "http://a/{redacted}/{redacted}/{redacted}/{redacted}",
201        "http://a/{redacted}",
202        "http://a/{redacted}",
203        "http://a/{redacted}/{redacted}/{redacted}",
204        "http://a/{redacted}/{redacted}/{redacted}",
205        "http://a/{redacted}/{redacted}/{redacted}",
206        "http://a/{redacted}/{redacted}/{redacted}",
207        "http://a/{redacted}/{redacted}/{redacted}",
208        "http://a/{redacted}/{redacted}/{redacted}",
209        "http://a/{redacted}/{redacted}/{redacted}",
210        "http://a/{redacted}/{redacted}/{redacted}",
211        "http://a/{redacted}/{redacted}/{redacted}",
212        "http://a/{redacted}/{redacted}/{redacted}",
213        "http://a/{redacted}/{redacted}/{redacted}",
214        "http://a/{redacted}/{redacted}",
215        "http://a/{redacted}/{redacted}",
216        "http://a/{redacted}",
217    ];
218
219    #[cfg(feature = "unredacted-logging")]
220    pub const ALL_EXAMPLES: [&str; 19] = EXAMPLES;
221
222    #[test]
223    fn mark_all() {
224        let originals = EXAMPLES.into_iter().map(Uri::from_static);
225        let expecteds = ALL_EXAMPLES.into_iter().map(Uri::from_static);
226        for (original, expected) in originals.zip(expecteds) {
227            let output = Label::new(original.path(), |_| true, None).to_string();
228            assert_eq!(output, expected.path(), "original = {original}");
229        }
230    }
231
232    #[cfg(not(feature = "unredacted-logging"))]
233    pub const GREEDY_EXAMPLES: [&str; 19] = [
234        "g:h",
235        "http://a/b/{redacted}",
236        "http://a/b/{redacted}",
237        "http://a/g",
238        "http://g",
239        "http://a/b/{redacted}?y",
240        "http://a/b/{redacted}?y",
241        "http://a/b/{redacted}?q#s",
242        "http://a/b/{redacted}",
243        "http://a/b/{redacted}?y#s",
244        "http://a/b/{redacted}",
245        "http://a/b/{redacted}",
246        "http://a/b/{redacted}?y#s",
247        "http://a/b/{redacted}?q",
248        "http://a/b/{redacted}",
249        "http://a/b/{redacted}",
250        "http://a/b/{redacted}",
251        "http://a/b/{redacted}",
252        "http://a/",
253    ];
254
255    #[cfg(feature = "unredacted-logging")]
256    pub const GREEDY_EXAMPLES: [&str; 19] = EXAMPLES;
257
258    #[test]
259    fn greedy() {
260        let originals = EXAMPLES.into_iter().map(Uri::from_static);
261        let expecteds = GREEDY_EXAMPLES.into_iter().map(Uri::from_static);
262        for (original, expected) in originals.zip(expecteds) {
263            let output = Label::new(original.path(), |_| false, Some(GreedyLabel::new(1, 0))).to_string();
264            assert_eq!(output, expected.path(), "original = {original}");
265        }
266    }
267
268    #[cfg(not(feature = "unredacted-logging"))]
269    pub const GREEDY_EXAMPLES_OFFSET: [&str; 19] = [
270        "g:h",
271        "http://a/b/{redacted}g",
272        "http://a/b/{redacted}/",
273        "http://a/g",
274        "http://g",
275        "http://a/b/{redacted}p?y",
276        "http://a/b/{redacted}g?y",
277        "http://a/b/{redacted}p?q#s",
278        "http://a/b/{redacted}g",
279        "http://a/b/{redacted}g?y#s",
280        "http://a/b/{redacted}x",
281        "http://a/b/{redacted}x",
282        "http://a/b/{redacted}x?y#s",
283        "http://a/b/{redacted}p?q",
284        "http://a/b/{redacted}/",
285        "http://a/b/{redacted}/",
286        "http://a/b/",
287        "http://a/b/{redacted}g",
288        "http://a/",
289    ];
290
291    #[cfg(feature = "unredacted-logging")]
292    pub const GREEDY_EXAMPLES_OFFSET: [&str; 19] = EXAMPLES;
293
294    #[test]
295    fn greedy_offset_a() {
296        let originals = EXAMPLES.into_iter().map(Uri::from_static);
297        let expecteds = GREEDY_EXAMPLES_OFFSET.into_iter().map(Uri::from_static);
298        for (original, expected) in originals.zip(expecteds) {
299            let output = Label::new(original.path(), |_| false, Some(GreedyLabel::new(1, 1))).to_string();
300            assert_eq!(output, expected.path(), "original = {original}");
301        }
302    }
303
304    const EXTRA_EXAMPLES_UNREDACTED: [&str; 4] = [
305        "http://base/a/b/hello_world",
306        "http://base/a/b/c/hello_world",
307        "http://base/a",
308        "http://base/a/b/c",
309    ];
310
311    #[cfg(feature = "unredacted-logging")]
312    const EXTRA_EXAMPLES_REDACTED: [&str; 4] = EXTRA_EXAMPLES_UNREDACTED;
313    #[cfg(not(feature = "unredacted-logging"))]
314    const EXTRA_EXAMPLES_REDACTED: [&str; 4] = [
315        "http://base/a/b/{redacted}world",
316        "http://base/a/b/{redacted}world",
317        "http://base/a",
318        "http://base/a/b/c",
319    ];
320
321    #[test]
322    fn greedy_offset_b() {
323        let originals = EXTRA_EXAMPLES_UNREDACTED.into_iter().map(Uri::from_static);
324        let expecteds = EXTRA_EXAMPLES_REDACTED.into_iter().map(Uri::from_static);
325        for (original, expected) in originals.zip(expecteds) {
326            let output = Label::new(original.path(), |_| false, Some(GreedyLabel::new(2, 5))).to_string();
327            assert_eq!(output, expected.path(), "original = {original}");
328        }
329    }
330}