Skip to main content

aster_forge_storage_core/
object_key.rs

1//! Object-storage key normalization helpers.
2//!
3//! These helpers keep object keys relative, slash-separated, and safe to join with storage
4//! prefixes. They reject path escape attempts while preserving existing prefix placement rules that
5//! may matter for S3 bucket policies or migrated objects.
6
7use crate::{Result, StorageCoreError};
8
9const INVALID_RELATIVE_KEY_MESSAGE: &str = "object key must be a safe relative storage path";
10
11/// Normalize an external object key into a slash-separated relative key.
12///
13/// Empty/root-like input is represented as `"."`, so callers can distinguish the scoped root from
14/// a real object named with an empty string. Backslashes are treated as separators to prevent
15/// Windows-style escape attempts from bypassing `..` checks.
16pub fn normalize_relative_key(value: &str) -> Result<String> {
17    let value = value.trim_start_matches('/').replace('\\', "/");
18    if value.is_empty() {
19        return Ok(".".to_string());
20    }
21
22    let mut segments = Vec::new();
23    for segment in value.split('/') {
24        match segment {
25            "" | "." => {}
26            ".." => {
27                return Err(StorageCoreError::InvalidObjectKey(
28                    INVALID_RELATIVE_KEY_MESSAGE.to_string(),
29                ));
30            }
31            segment => segments.push(segment),
32        }
33    }
34
35    if segments.is_empty() {
36        Ok(".".to_string())
37    } else {
38        Ok(segments.join("/"))
39    }
40}
41
42/// Normalizes an object key and rejects the storage namespace root.
43///
44/// Use this for concrete object operations such as get, put, delete, exists,
45/// and metadata. It accepts leading slashes and Windows separators but rejects
46/// empty/root-like values and parent-directory escape attempts.
47pub fn normalize_object_key(value: &str) -> Result<String> {
48    let key = normalize_relative_key(value.trim())?;
49    if key == "." {
50        return Err(StorageCoreError::InvalidObjectKey(
51            "object key cannot target the storage namespace root".to_string(),
52        ));
53    }
54    Ok(key)
55}
56
57/// Normalizes a storage prefix.
58///
59/// Empty and root-like inputs map to an empty prefix. Concrete object keys
60/// should use [`normalize_object_key`] instead.
61pub fn normalize_object_prefix(value: &str) -> Result<String> {
62    let prefix = normalize_relative_key(value.trim())?;
63    if prefix == "." {
64        Ok(String::new())
65    } else {
66        Ok(prefix)
67    }
68}
69
70/// Join a storage prefix and object key without producing duplicate separators.
71///
72/// This deliberately only trims trailing slashes from the prefix. Existing S3 policies may have
73/// been configured with a leading slash, and preserving that keeps object placement stable.
74pub fn join_key_prefix(prefix: &str, key: &str) -> String {
75    let prefix = prefix.trim_end_matches('/');
76    let key = key.trim_start_matches('/');
77
78    if prefix.is_empty() {
79        key.to_string()
80    } else if key.is_empty() {
81        prefix.to_string()
82    } else {
83        format!("{prefix}/{key}")
84    }
85}
86
87/// Strip `prefix` from `key` only when the prefix matches a complete slash-separated segment.
88pub fn strip_key_prefix<'a>(prefix: &str, key: &'a str) -> Option<&'a str> {
89    let prefix = prefix.trim_end_matches('/');
90    if prefix.is_empty() {
91        return Some(key.trim_start_matches('/'));
92    }
93
94    if key == prefix {
95        return Some("");
96    }
97
98    key.strip_prefix(prefix)
99        .and_then(|suffix| suffix.strip_prefix('/'))
100}
101
102#[cfg(test)]
103mod tests {
104    use super::{
105        join_key_prefix, normalize_object_key, normalize_object_prefix, normalize_relative_key,
106        strip_key_prefix,
107    };
108
109    #[test]
110    fn normalize_relative_key_collapses_slashes_and_dot_segments() {
111        assert_eq!(
112            normalize_relative_key("/folder//./file.txt").unwrap(),
113            "folder/file.txt"
114        );
115        assert_eq!(normalize_relative_key("").unwrap(), ".");
116        assert_eq!(normalize_relative_key("/").unwrap(), ".");
117    }
118
119    #[test]
120    fn normalize_relative_key_rejects_escape_segments() {
121        assert!(normalize_relative_key("../secret.txt").is_err());
122        assert!(normalize_relative_key("folder/../secret.txt").is_err());
123        assert!(normalize_relative_key("folder\\..\\secret.txt").is_err());
124    }
125
126    #[test]
127    fn normalize_relative_key_handles_windows_separators_and_root_like_values() {
128        assert_eq!(
129            normalize_relative_key("\\folder\\.\\file.txt").unwrap(),
130            "folder/file.txt"
131        );
132        assert_eq!(normalize_relative_key("////").unwrap(), ".");
133        assert_eq!(normalize_relative_key("././").unwrap(), ".");
134    }
135
136    #[test]
137    fn normalize_object_key_rejects_root_like_values() {
138        assert_eq!(
139            normalize_object_key("/folder//file.txt").unwrap(),
140            "folder/file.txt"
141        );
142        assert!(normalize_object_key("").is_err());
143        assert!(normalize_object_key("/").is_err());
144        assert!(normalize_object_key("../secret.txt").is_err());
145    }
146
147    #[test]
148    fn normalize_object_prefix_allows_root_like_values() {
149        assert_eq!(normalize_object_prefix("").unwrap(), "");
150        assert_eq!(normalize_object_prefix("/").unwrap(), "");
151        assert_eq!(
152            normalize_object_prefix("/folder//prefix/").unwrap(),
153            "folder/prefix"
154        );
155        assert!(normalize_object_prefix("folder/../secret").is_err());
156    }
157
158    #[test]
159    fn join_key_prefix_handles_empty_and_slash_edge_cases() {
160        assert_eq!(join_key_prefix("", "/files/a.txt"), "files/a.txt");
161        assert_eq!(join_key_prefix("base/", "/files/a.txt"), "base/files/a.txt");
162        assert_eq!(join_key_prefix("base", ""), "base");
163        assert_eq!(
164            join_key_prefix("/base/", "/files/a.txt"),
165            "/base/files/a.txt"
166        );
167    }
168
169    #[test]
170    fn strip_key_prefix_matches_only_segment_boundaries() {
171        assert_eq!(strip_key_prefix("", "/files/a.txt"), Some("files/a.txt"));
172        assert_eq!(
173            strip_key_prefix("base", "base/files/a.txt"),
174            Some("files/a.txt")
175        );
176        assert_eq!(strip_key_prefix("base/", "base"), Some(""));
177        assert_eq!(strip_key_prefix("base", "baseball/files/a.txt"), None);
178        assert_eq!(
179            strip_key_prefix("/base/", "/base/files/a.txt"),
180            Some("files/a.txt")
181        );
182    }
183
184    #[test]
185    fn strip_key_prefix_rejects_partial_and_directional_mismatches() {
186        assert_eq!(strip_key_prefix("base/files", "base/file"), None);
187        assert_eq!(
188            strip_key_prefix("base/files", "base/files-extra/a.txt"),
189            None
190        );
191        assert_eq!(strip_key_prefix("base/files", "other/files/a.txt"), None);
192    }
193}