1use std::path::PathBuf;
8
9use crate::error::{Error, Result};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ObjectKeyPolicy {
14 Logical,
19 WindowsPortable,
21}
22
23impl ObjectKeyPolicy {
24 pub fn for_local_destination(force_portable: bool) -> Self {
29 if force_portable || cfg!(windows) {
30 Self::WindowsPortable
31 } else {
32 Self::Logical
33 }
34 }
35
36 pub const fn for_remote_destination() -> Self {
38 Self::Logical
39 }
40}
41
42pub fn normalize_relative_key(value: &str, policy: ObjectKeyPolicy) -> Result<String> {
47 if value.starts_with(['/', '\\']) || value.contains('\\') {
48 return Err(Error::InvalidPath(format!(
49 "Object key must be relative and use '/' separators: {value}"
50 )));
51 }
52
53 let mut normalized = Vec::new();
54 for component in value.split('/') {
55 if component.is_empty() || component == "." {
56 continue;
57 }
58 if component == ".." {
59 return Err(Error::InvalidPath(
60 "Object keys must not contain traversal components".to_string(),
61 ));
62 }
63 validate_key_component(component, policy)?;
64 normalized.push(component);
65 }
66
67 if normalized.is_empty() {
68 return Err(Error::InvalidPath(
69 "Object key does not contain a file name".to_string(),
70 ));
71 }
72
73 Ok(normalized.join("/"))
74}
75
76pub fn relative_local_path_from_key(
81 key: &str,
82 prefix: &str,
83 policy: ObjectKeyPolicy,
84) -> Result<PathBuf> {
85 let relative = key
86 .strip_prefix(prefix)
87 .ok_or_else(|| Error::InvalidPath(format!("key is outside requested prefix '{prefix}'")))?
88 .trim_start_matches('/');
89 let normalized = normalize_relative_key(relative, policy)?;
90 Ok(normalized.split('/').collect())
91}
92
93fn validate_key_component(component: &str, policy: ObjectKeyPolicy) -> Result<()> {
94 if component.chars().any(char::is_control) {
95 return Err(Error::InvalidPath(format!(
96 "Object key component contains a control character: {component}"
97 )));
98 }
99
100 if policy != ObjectKeyPolicy::WindowsPortable {
101 return Ok(());
102 }
103
104 if component
105 .chars()
106 .any(|character| matches!(character, ':' | '<' | '>' | '"' | '|' | '?' | '*'))
107 || component.ends_with(['.', ' '])
108 {
109 return Err(Error::InvalidPath(format!(
110 "Object key component is not portable: {component}"
111 )));
112 }
113
114 let stem = component.split('.').next().unwrap_or_default();
115 let stem = stem.to_ascii_uppercase();
116 if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
117 || (stem.len() == 4
118 && (stem.starts_with("COM") || stem.starts_with("LPT"))
119 && matches!(stem.as_bytes()[3], b'1'..=b'9'))
120 {
121 return Err(Error::InvalidPath(format!(
122 "Object key uses a reserved device name: {component}"
123 )));
124 }
125
126 Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn logical_policy_accepts_colon_keys() {
135 assert_eq!(
136 normalize_relative_key(
137 "fake/deadbeef/19f6abd9af4:19f6abe0e77:499628ff",
138 ObjectKeyPolicy::Logical
139 )
140 .expect("colon key is valid on Unix"),
141 "fake/deadbeef/19f6abd9af4:19f6abe0e77:499628ff"
142 );
143 }
144
145 #[test]
146 fn logical_policy_accepts_question_and_asterisk_in_names() {
147 assert_eq!(
148 normalize_relative_key("logs/what?.txt", ObjectKeyPolicy::Logical).expect("valid"),
149 "logs/what?.txt"
150 );
151 assert_eq!(
152 normalize_relative_key("logs/star*.txt", ObjectKeyPolicy::Logical).expect("valid"),
153 "logs/star*.txt"
154 );
155 }
156
157 #[test]
158 fn windows_policy_rejects_colon_and_reserved_names() {
159 for value in [
160 "safe:stream",
161 "nested/bad?.txt",
162 "CON.txt",
163 "com1.log",
164 "trailing.",
165 "trailing ",
166 ] {
167 let error =
168 normalize_relative_key(value, ObjectKeyPolicy::WindowsPortable).expect_err(value);
169 assert!(
170 error.to_string().contains("portable")
171 || error.to_string().contains("reserved device name"),
172 "{value}: {error}"
173 );
174 }
175 }
176
177 #[test]
178 fn both_policies_reject_traversal_and_control_characters() {
179 for policy in [ObjectKeyPolicy::Logical, ObjectKeyPolicy::WindowsPortable] {
180 for value in [
181 "../secret",
182 "nested/../../secret",
183 "/absolute",
184 "nested\\escaped",
185 "nested/control\u{0007}.txt",
186 ] {
187 assert!(
188 normalize_relative_key(value, policy).is_err(),
189 "policy {policy:?} accepted {value}"
190 );
191 }
192 }
193 }
194
195 #[test]
196 fn local_destination_policy_is_logical_on_non_windows_by_default() {
197 let policy = ObjectKeyPolicy::for_local_destination(false);
198 if cfg!(windows) {
199 assert_eq!(policy, ObjectKeyPolicy::WindowsPortable);
200 } else {
201 assert_eq!(policy, ObjectKeyPolicy::Logical);
202 }
203 assert_eq!(
204 ObjectKeyPolicy::for_local_destination(true),
205 ObjectKeyPolicy::WindowsPortable
206 );
207 assert_eq!(
208 ObjectKeyPolicy::for_remote_destination(),
209 ObjectKeyPolicy::Logical
210 );
211 }
212
213 #[test]
214 fn relative_local_path_preserves_nested_colon_keys() {
215 let path = relative_local_path_from_key(
216 "loki/fake/deadbeef/19f6abd9af4:19f6abe0e77:499628ff",
217 "loki/",
218 ObjectKeyPolicy::Logical,
219 )
220 .expect("colon key should map onto a Unix path");
221
222 assert_eq!(
223 path,
224 PathBuf::from("fake")
225 .join("deadbeef")
226 .join("19f6abd9af4:19f6abe0e77:499628ff")
227 );
228 }
229
230 #[test]
231 fn relative_local_path_rejects_keys_outside_prefix() {
232 let error =
233 relative_local_path_from_key("other/file.txt", "loki/", ObjectKeyPolicy::Logical)
234 .expect_err("outside prefix");
235 assert!(error.to_string().contains("outside requested prefix"));
236 }
237
238 #[test]
239 fn relative_local_path_rejects_colon_keys_when_portable() {
240 assert!(
241 relative_local_path_from_key(
242 "loki/fake/deadbeef/19f6abd9af4:19f6abe0e77:499628ff",
243 "loki/",
244 ObjectKeyPolicy::WindowsPortable
245 )
246 .is_err()
247 );
248 }
249
250 #[test]
251 fn normalize_relative_key_rejects_empty_or_dot_only_keys() {
252 for policy in [ObjectKeyPolicy::Logical, ObjectKeyPolicy::WindowsPortable] {
253 for value in ["", ".", "./", "//"] {
254 assert!(
255 normalize_relative_key(value, policy).is_err(),
256 "policy {policy:?} accepted {value:?}"
257 );
258 }
259 }
260 }
261
262 #[test]
263 fn relative_local_path_rejects_traversal_after_prefix() {
264 assert!(
265 relative_local_path_from_key(
266 "reports/../../escaped",
267 "reports/",
268 ObjectKeyPolicy::Logical
269 )
270 .is_err()
271 );
272 }
273}