1#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7pub enum FileMapping {
8 Exact {
9 public_path: Vec<String>,
10 file_path: String,
11 },
12 Subtree {
13 public_prefix: Vec<String>,
14 filesystem_root: String,
15 },
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HttpRequestTarget {
21 path: String,
22 query: Option<String>,
23 segments: Vec<String>,
24 trailing_slash: bool,
25}
26
27impl HttpRequestTarget {
28 pub fn parse(target: &str) -> Result<Self, String> {
29 let (path, query) = match target.split_once('?') {
30 Some((path, query)) => (path, Some(query.to_string())),
31 None => (target, None),
32 };
33 let remaining = path.strip_prefix('/').ok_or("unsafe-path")?;
34 let trailing_slash = !remaining.is_empty() && remaining.ends_with('/');
35 let segments = if remaining.is_empty() {
36 Vec::new()
37 } else {
38 let remaining = if trailing_slash {
39 &remaining[..remaining.len() - 1]
40 } else {
41 remaining
42 };
43 remaining
44 .split('/')
45 .map(decode_segment)
46 .collect::<Result<Vec<_>, _>>()?
47 };
48 Ok(Self {
49 path: path.to_string(),
50 query,
51 segments,
52 trailing_slash,
53 })
54 }
55
56 pub fn path(&self) -> &str {
57 &self.path
58 }
59 pub fn query(&self) -> Option<&str> {
60 self.query.as_deref()
61 }
62 pub fn segments(&self) -> &[String] {
63 &self.segments
64 }
65 pub fn trailing_slash(&self) -> bool {
66 self.trailing_slash
67 }
68}
69
70impl FileMapping {
71 pub fn compile(source: &str, target: &str) -> Result<Self, String> {
73 let (source, subtree) = match source.strip_suffix("/*") {
74 Some(prefix) if prefix.ends_with('/') => return Err("source-path".into()),
75 Some(prefix) => (if prefix.is_empty() { "/" } else { prefix }, true),
76 None => (source, false),
77 };
78 if source.contains('*') {
79 return Err("source-wildcard".into());
80 }
81 if source.contains(['$', '?', '#']) {
82 return Err("source-path".into());
83 }
84 let path = HttpRequestTarget::parse(source).map_err(|_| "source-path")?;
85 if path.trailing_slash {
86 return Err("source-path".into());
87 }
88 let (target, allow_root) = if subtree {
89 let root = target.strip_suffix("/$1").ok_or("target-placeholder")?;
90 if root.ends_with('/') {
91 return Err("target-path".into());
92 }
93 (if root.is_empty() { "/" } else { root }, true)
94 } else {
95 (target, false)
96 };
97 validate_file_mapping(&path.segments, target, allow_root)?;
98 Ok(if subtree {
99 Self::Subtree {
100 public_prefix: path.segments,
101 filesystem_root: target.into(),
102 }
103 } else {
104 Self::Exact {
105 public_path: path.segments,
106 file_path: target.into(),
107 }
108 })
109 }
110}
111
112pub fn validate_file_mapping(
114 segments: &[String],
115 target: &str,
116 allow_root: bool,
117) -> Result<(), String> {
118 if segments
119 .iter()
120 .any(|segment| !valid_decoded_segment(segment))
121 {
122 return Err("source-path".into());
123 }
124 if target.contains('$') {
125 return Err("target-placeholder".into());
126 }
127 if !target.starts_with('/')
128 || target.chars().any(char::is_control)
129 || (!allow_root && target == "/")
130 || (target != "/"
131 && target[1..]
132 .split('/')
133 .any(|segment| !valid_decoded_segment(segment)))
134 {
135 return Err("target-path".into());
136 }
137 Ok(())
138}
139
140pub fn valid_decoded_segment(segment: &str) -> bool {
141 !segment.is_empty()
142 && segment != "."
143 && segment != ".."
144 && !segment
145 .bytes()
146 .any(|byte| byte <= 0x1f || byte == 0x7f || byte == b'/' || byte == b'\\')
147}
148
149fn decode_segment(raw: &str) -> Result<String, String> {
150 let mut decoded = Vec::with_capacity(raw.len());
151 let mut bytes = raw.bytes();
152 while let Some(byte) = bytes.next() {
153 if byte == b'%' {
154 let high = bytes.next().and_then(hex).ok_or("unsafe-path")?;
155 let low = bytes.next().and_then(hex).ok_or("unsafe-path")?;
156 decoded.push(high * 16 + low);
157 } else if byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:@".contains(&byte) {
158 decoded.push(byte);
159 } else {
160 return Err("unsafe-path".into());
161 }
162 }
163 let decoded = String::from_utf8(decoded).map_err(|_| "unsafe-path")?;
164 if !valid_decoded_segment(&decoded) {
165 return Err("unsafe-path".into());
166 }
167 Ok(decoded)
168}
169
170fn hex(byte: u8) -> Option<u8> {
171 match byte {
172 b'0'..=b'9' => Some(byte - b'0'),
173 b'a'..=b'f' => Some(byte - b'a' + 10),
174 b'A'..=b'F' => Some(byte - b'A' + 10),
175 _ => None,
176 }
177}