Skip to main content

fakecloud_core/
path.rs

1//! URI path handling shared by every REST service.
2//!
3//! Smithy `@httpLabel` semantics: the client percent-encodes each label
4//! value, so a `/` inside a non-greedy label travels as `%2F` and `:` in an
5//! ARN usually travels as `%3A`. The server must therefore split the raw
6//! path on `/` FIRST and percent-decode each segment afterwards; decoding
7//! before splitting would turn an encoded `/` into a segment boundary. A
8//! greedy label (`{Key+}`) is the decoded segments re-joined with `/`.
9//!
10//! Dispatch builds [`crate::service::AwsRequest::path_segments`] with
11//! [`split_path_segments`], so handlers receive decoded labels and must not
12//! decode them again (a label of `%2525` is the literal `%25`, not `%`).
13//! Routers that need the undecoded wire path read `raw_path` instead.
14
15/// Percent-decode one URI path segment per RFC 3986. `+` is a literal `+`
16/// in a path (only `application/x-www-form-urlencoded` data maps it to a
17/// space). A malformed escape (`%zz`, a trailing `%`) is kept verbatim, and
18/// bytes that do not form valid UTF-8 are replaced lossily -- decoding never
19/// fails and never panics.
20pub fn percent_decode_segment(segment: &str) -> String {
21    if !segment.contains('%') {
22        return segment.to_string();
23    }
24    percent_encoding::percent_decode_str(segment)
25        .decode_utf8_lossy()
26        .into_owned()
27}
28
29/// Split a raw URI path into decoded segments: split on `/`, drop empty
30/// segments (leading, trailing and doubled slashes), then percent-decode
31/// each segment exactly once.
32pub fn split_path_segments(raw_path: &str) -> Vec<String> {
33    raw_path
34        .split('/')
35        .filter(|s| !s.is_empty())
36        .map(percent_decode_segment)
37        .collect()
38}
39
40/// Split a raw URI path into its undecoded segments, dropping empty ones.
41/// This is the wire form of [`split_path_segments`] for handlers that must
42/// forward or match the path exactly as the client sent it (API Gateway
43/// execute-api data plane, for one).
44pub fn split_raw_path_segments(raw_path: &str) -> Vec<String> {
45    raw_path
46        .split('/')
47        .filter(|s| !s.is_empty())
48        .map(str::to_string)
49        .collect()
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn decodes_encoded_colon_in_arn_label() {
58        assert_eq!(
59            split_path_segments(
60                "/tags/arn%3Aaws%3Abatch%3Aus-east-1%3A123456789012%3Ajob-queue%2Fq"
61            ),
62            vec![
63                "tags".to_string(),
64                "arn:aws:batch:us-east-1:123456789012:job-queue/q".to_string()
65            ]
66        );
67    }
68
69    #[test]
70    fn encoded_slash_stays_inside_its_non_greedy_label() {
71        // Split happens before decoding, so `%2F` does not create a segment.
72        assert_eq!(
73            split_path_segments("/v1/pipes/a%2Fb/start"),
74            vec!["v1", "pipes", "a/b", "start"]
75        );
76    }
77
78    #[test]
79    fn double_encoded_percent_decodes_exactly_once() {
80        assert_eq!(split_path_segments("/x/100%2525"), vec!["x", "100%25"]);
81        assert_eq!(percent_decode_segment("%2525"), "%25");
82    }
83
84    #[test]
85    fn greedy_label_is_the_rejoined_decoded_segments() {
86        let segs = split_path_segments("/bucket/dir%20one/sub/file%3Dx.txt");
87        assert_eq!(segs[1..].join("/"), "dir one/sub/file=x.txt");
88    }
89
90    #[test]
91    fn plus_is_literal_in_paths() {
92        assert_eq!(split_path_segments("/k/a+b%2Bc"), vec!["k", "a+b+c"]);
93    }
94
95    #[test]
96    fn empty_segments_are_dropped() {
97        assert_eq!(split_path_segments("//a//b/"), vec!["a", "b"]);
98        assert!(split_path_segments("/").is_empty());
99    }
100
101    #[test]
102    fn malformed_escapes_and_multibyte_are_safe() {
103        assert_eq!(percent_decode_segment("%zz%"), "%zz%");
104        assert_eq!(percent_decode_segment("%€"), "%€");
105        assert_eq!(percent_decode_segment("caf%C3%A9"), "café");
106        assert_eq!(percent_decode_segment("%FF"), "\u{FFFD}");
107    }
108
109    #[test]
110    fn raw_segments_are_not_decoded() {
111        assert_eq!(
112            split_raw_path_segments("/prod/a%2Fb//c%3A"),
113            vec!["prod", "a%2Fb", "c%3A"]
114        );
115    }
116}