Skip to main content

rc_core/
object_key.rs

1//! Object-key normalization and local-name safety.
2//!
3//! S3 keys may contain characters that some local filesystems reject. Traversal
4//! and control-character checks always apply. Windows filename rules apply only
5//! when a key is being materialized onto a local filesystem that needs them.
6
7use std::path::PathBuf;
8
9use crate::error::{Error, Result};
10
11/// How a relative object key should be validated.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ObjectKeyPolicy {
14    /// Security checks only: relative `/` paths, no traversal, no control characters.
15    ///
16    /// Use this for remote-to-remote work and for local destinations on Unix-like
17    /// filesystems, where characters such as `:` are legal in file names.
18    Logical,
19    /// Also reject names that cannot be created portably on Windows filesystems.
20    WindowsPortable,
21}
22
23impl ObjectKeyPolicy {
24    /// Policy for writing an object key onto a local filesystem.
25    ///
26    /// Windows destinations always use [`Self::WindowsPortable`]. Other platforms
27    /// stay on [`Self::Logical`] unless the caller requests portable names.
28    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    /// Policy for remote object keys. S3 does not use Windows filename rules.
37    pub const fn for_remote_destination() -> Self {
38        Self::Logical
39    }
40}
41
42/// Normalize a source-relative object key.
43///
44/// The result uses `/` separators, rejects traversal, and optionally applies
45/// Windows filename portability rules.
46pub 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
76/// Strip `prefix` from `key` and return a relative local path.
77///
78/// Traversal and other unsafe components are rejected before any filesystem
79/// join so a single hostile key cannot escape the destination root.
80pub 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}