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