aster_forge_storage_core/
object_key.rs1use crate::{Result, StorageCoreError};
8
9const INVALID_RELATIVE_KEY_MESSAGE: &str = "object key must be a safe relative storage path";
10
11pub 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
46pub 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
66pub 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#[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#[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}