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> {
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
42pub 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
57pub 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
70pub 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
87pub 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}