// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::path::Path; use std::path::PathBuf; pub const GLOBAL_DIR_SUFFIX: &str = "__XLDIR__"; pub const SLASH_SEPARATOR: &str = "/"; pub const GLOBAL_DIR_SUFFIX_WITH_SLASH: &str = "__XLDIR__/"; #[inline] pub fn is_separator(c: u8) -> bool { c == b'/' || (cfg!(target_os = "windows") && c == b'\\') } /// Checks if the string `s` ends with `suffix`. /// /// On Windows, this comparison is case-insensitive. /// On other platforms, it is case-sensitive. pub fn has_suffix(s: &str, suffix: &str) -> bool { if cfg!(target_os = "windows") { s.to_lowercase().ends_with(&suffix.to_lowercase()) } else { s.ends_with(suffix) } } /// Encodes a directory object name by replacing the trailing slash with `GLOBAL_DIR_SUFFIX`. /// /// If the object name ends with a slash, it is considered a directory object. /// The trailing slash is removed and `GLOBAL_DIR_SUFFIX` is appended. /// If it does not end with a slash, the name is returned as is. pub fn encode_dir_object(object: &str) -> String { if has_suffix(object, SLASH_SEPARATOR) { format!("{}{}", object.trim_end_matches(SLASH_SEPARATOR), GLOBAL_DIR_SUFFIX) } else { object.to_string() } } /// Checks if the given object name represents a directory object. /// /// Returns true if the object name ends with `GLOBAL_DIR_SUFFIX`. pub fn is_dir_object(object: &str) -> bool { let obj = encode_dir_object(object); obj.ends_with(GLOBAL_DIR_SUFFIX) } /// Decodes a directory object name by replacing `GLOBAL_DIR_SUFFIX` with a trailing slash. /// /// If the object name ends with `GLOBAL_DIR_SUFFIX`, it is replaced with a slash. /// Otherwise, the name is returned as is. #[allow(dead_code)] pub fn decode_dir_object(object: &str) -> String { if has_suffix(object, GLOBAL_DIR_SUFFIX) { format!("{}{}", object.trim_end_matches(GLOBAL_DIR_SUFFIX), SLASH_SEPARATOR) } else { object.to_string() } } /// Ensures that the string ends with a trailing slash if it is not empty. /// /// If the string is empty, it is returned as is. /// If it already ends with a slash, it is returned as is. /// Otherwise, a slash is appended. pub fn retain_slash(s: &str) -> String { if s.is_empty() { return s.to_string(); } if s.ends_with(SLASH_SEPARATOR) { s.to_string() } else { format!("{s}{SLASH_SEPARATOR}") } } /// Checks if string `s` starts with `prefix` using case-insensitive comparison. pub fn strings_has_prefix_fold(s: &str, prefix: &str) -> bool { s.len() >= prefix.len() && (s[..prefix.len()] == *prefix || s[..prefix.len()].eq_ignore_ascii_case(prefix)) } /// Checks if string `s` starts with `prefix`. /// /// On Windows, this comparison is case-insensitive. /// On other platforms, it is case-sensitive. pub fn has_prefix(s: &str, prefix: &str) -> bool { if cfg!(target_os = "windows") { return strings_has_prefix_fold(s, prefix); } s.starts_with(prefix) } /// Joins multiple path components into a single `PathBuf`. /// /// This function normalizes the path separators to forward slashes (`/`) internally /// and cleans the path (resolving `.` and `..`). /// /// On Windows, backslashes in input components are treated as separators and normalized. pub fn path_join>(elem: &[P]) -> PathBuf { let trailing_slash = !elem.is_empty() && elem.last().is_some_and(|last| { let s = last.as_ref().to_string_lossy(); s.ends_with(SLASH_SEPARATOR) || (cfg!(target_os = "windows") && s.ends_with('\\')) }); let len = elem.iter().map(|s| s.as_ref().to_string_lossy().len()).sum::() + elem.len(); let mut dst = String::with_capacity(len); let mut added = 0; for e in elem { let s = e.as_ref().to_string_lossy(); if added > 0 || !s.is_empty() { if added > 0 { dst.push_str(SLASH_SEPARATOR); } dst.push_str(&s); added += s.len(); } } if path_needs_clean(dst.as_bytes()) { let mut clean_path = clean(&dst); if trailing_slash { clean_path.push_str(SLASH_SEPARATOR); } return clean_path.into(); } if trailing_slash { dst.push_str(SLASH_SEPARATOR); } dst.into() } /// Joins multiple string path components into a single `String`. /// /// This function normalizes the path separators to forward slashes (`/`) internally /// and cleans the path (resolving `.` and `..`). /// /// On Windows, backslashes in input components are treated as separators and normalized. pub fn path_join_buf(elements: &[&str]) -> String { let trailing_slash = !elements.is_empty() && elements .last() .is_some_and(|last| last.ends_with(SLASH_SEPARATOR) || (cfg!(target_os = "windows") && last.ends_with('\\'))); let len = elements.iter().map(|s| s.len()).sum::() + elements.len(); let mut dst = String::with_capacity(len); let mut added = 0; for e in elements { if added > 0 || !e.is_empty() { if added > 0 { dst.push_str(SLASH_SEPARATOR); } dst.push_str(e); added += e.len(); } } if path_needs_clean(dst.as_bytes()) { let mut clean_path = clean(&dst); if trailing_slash { clean_path.push_str(SLASH_SEPARATOR); } return clean_path; } if trailing_slash { dst.push_str(SLASH_SEPARATOR); } dst } /// path_needs_clean returns whether path cleaning may change the path. /// Will detect all cases that will be cleaned, /// but may produce false positives on non-trivial paths. fn path_needs_clean(path: &[u8]) -> bool { if path.is_empty() { return true; } // Check for backslashes on Windows if cfg!(target_os = "windows") && path.contains(&b'\\') { return true; } let rooted = path[0] == b'/'; let n = path.len(); let (mut r, mut w) = if rooted { (1, 1) } else { (0, 0) }; while r < n { match path[r] { b if b > 127 => { // Non ascii. return true; } b'/' => { // multiple / elements return true; } b'.' => { if r + 1 == n || path[r + 1] == b'/' { // . element - assume it has to be cleaned. return true; } if r + 1 < n && path[r + 1] == b'.' && (r + 2 == n || path[r + 2] == b'/') { // .. element: remove to last / - assume it has to be cleaned. return true; } // Handle single dot case if r + 1 == n { // . element - assume it has to be cleaned. return true; } // Copy the dot w += 1; r += 1; } _ => { // real path element. // add slash if needed if (rooted && w != 1) || (!rooted && w != 0) { w += 1; } // copy element while r < n && path[r] != b'/' { if cfg!(target_os = "windows") && path[r] == b'\\' { return true; } w += 1; r += 1; } // allow one slash, not at end if r < n - 1 && path[r] == b'/' { r += 1; } } } } // Turn empty string into "." if w == 0 { return true; } false } /// Splits a path into bucket and object names, removing a specified base path. /// /// The path is first trimmed of the `base_path` prefix and any leading slashes. /// Then it is split at the first slash into bucket and object. /// If no slash is found, the whole path is returned as the bucket name, and object name is empty. /// /// On Windows, this function handles backslashes as separators and performs case-insensitive prefix matching for `base_path`. /// The returned bucket and object names are normalized to use forward slashes. pub fn path_to_bucket_object_with_base_path(base_path: &str, path: &str) -> (String, String) { let mut p = path; // 1. Trim base_path if cfg!(target_os = "windows") { if strings_has_prefix_fold(p, base_path) { p = &p[base_path.len()..]; } } else if p.starts_with(base_path) { p = &p[base_path.len()..]; } // 2. Trim leading separators while let Some(c) = p.chars().next() { if is_separator(c as u8) { p = &p[c.len_utf8()..]; } else { break; } } // 3. Find split point let idx = if cfg!(target_os = "windows") { p.find(['/', '\\']) } else { p.find('/') }; match idx { Some(i) => { let bucket = &p[..i]; let object = &p[i + 1..]; // Skip the separator // Normalize separators on Windows let bucket = if cfg!(target_os = "windows") { bucket.replace('\\', "/") } else { bucket.to_string() }; let object = if cfg!(target_os = "windows") { object.replace('\\', "/") } else { object.to_string() }; (bucket, object) } None => { let bucket = if cfg!(target_os = "windows") { p.replace('\\', "/") } else { p.to_string() }; (bucket, "".to_string()) } } } /// Splits a path into bucket and object names. /// /// The path is trimmed of any leading slashes. /// Then it is split at the first slash into bucket and object. /// If no slash is found, the whole path is returned as the bucket name, and object name is empty. pub fn path_to_bucket_object(s: &str) -> (String, String) { path_to_bucket_object_with_base_path("", s) } /// Extracts the base directory from a prefix string. /// /// It returns the directory part of the prefix. /// If the prefix does not contain a separator, or resolves to root/current dir, an empty string is returned. /// The result ensures a trailing slash if not empty. pub fn base_dir_from_prefix(prefix: &str) -> String { let mut base_dir = dir(prefix).to_owned(); if base_dir == "." || base_dir == "./" || base_dir == "/" { base_dir = "".to_owned(); } let has_separator = if cfg!(target_os = "windows") { prefix.contains(['/', '\\']) } else { prefix.contains('/') }; if !has_separator { base_dir = "".to_owned(); } if !base_dir.is_empty() && !base_dir.ends_with(SLASH_SEPARATOR) { base_dir.push_str(SLASH_SEPARATOR); } base_dir } /// A helper struct for lazy buffer allocation during string processing. pub struct LazyBuf { s: String, buf: Option>, w: usize, } impl LazyBuf { /// Creates a new `LazyBuf` with the given source string. pub fn new(s: String) -> Self { LazyBuf { s, buf: None, w: 0 } } /// Returns the byte at index `i`, either from the buffer or the source string. pub fn index(&self, i: usize) -> u8 { if let Some(ref buf) = self.buf { buf[i] } else { self.s.as_bytes()[i] } } /// Appends a byte to the buffer. /// /// If the buffer hasn't been allocated yet, it checks if the byte matches the source string /// at the current write position. If it matches, it just advances the write position. /// If it doesn't match, it allocates the buffer and copies the prefix. pub fn append(&mut self, c: u8) { if self.buf.is_none() { if self.w < self.s.len() && self.s.as_bytes()[self.w] == c { self.w += 1; return; } let mut new_buf = vec![0; self.s.len()]; new_buf[..self.w].copy_from_slice(&self.s.as_bytes()[..self.w]); self.buf = Some(new_buf); } if let Some(ref mut buf) = self.buf { buf[self.w] = c; self.w += 1; } } /// Returns the resulting string. pub fn string(&self) -> String { if let Some(ref buf) = self.buf { String::from_utf8(buf[..self.w].to_vec()).unwrap() } else { self.s[..self.w].to_string() } } } /// Returns the shortest path name equivalent to the given path. /// /// It applies the following rules iteratively until no further processing can be done: /// 1. Replace multiple separators with a single one. /// 2. Eliminate each `.` path name element (the current directory). /// 3. Eliminate each inner `..` path name element (the parent directory) along with the non-`..` element that precedes it. /// 4. Eliminate `..` elements that begin a rooted path: that is, replace `/..` by `/` at the beginning of a path. /// /// On Windows, backslashes are converted to forward slashes. /// The returned path ends in a slash only if it represents a root directory, such as `/` on Unix or `C:/` on Windows. /// /// If the result of this process is an empty string, `clean` returns the string `.`. pub fn clean(path: &str) -> String { if path.is_empty() { return ".".to_string(); } let rooted = path.starts_with('/') || (cfg!(target_os = "windows") && path.starts_with('\\')); let n = path.len(); let mut out = LazyBuf::new(path.to_string()); let mut r = 0; let mut dotdot = 0; if rooted { out.append(b'/'); r = 1; dotdot = 1; } while r < n { let c = path.as_bytes()[r]; if is_separator(c) { // Empty path element r += 1; } else if c == b'.' && (r + 1 == n || is_separator(path.as_bytes()[r + 1])) { // . element r += 1; } else if c == b'.' && (r + 1 < n && path.as_bytes()[r + 1] == b'.') && (r + 2 == n || is_separator(path.as_bytes()[r + 2])) { // .. element: remove to last / r += 2; if out.w > dotdot { // Can backtrack out.w -= 1; while out.w > dotdot && out.index(out.w) != b'/' { out.w -= 1; } } else if !rooted { // Cannot backtrack but not rooted, so append .. element. if out.w > 0 { out.append(b'/'); } out.append(b'.'); out.append(b'.'); dotdot = out.w; } } else { // Real path element. // Add slash if needed if (rooted && out.w != 1) || (!rooted && out.w != 0) { out.append(b'/'); } // Copy element while r < n { let c = path.as_bytes()[r]; if is_separator(c) { break; } out.append(c); r += 1; } } } // Turn empty string into "." if out.w == 0 { return ".".to_string(); } out.string() } /// Splits path immediately following the final separator, separating it into a directory and file name component. /// /// If there is no separator in path, `split` returns an empty dir and file set to path. /// The returned values have the property that path = dir + file. /// /// On Windows, both `/` and `\` are treated as separators. pub fn split(path: &str) -> (&str, &str) { // Find the last occurrence of the separator let idx = if cfg!(target_os = "windows") { path.rfind(['/', '\\']) } else { path.rfind('/') }; if let Some(i) = idx { // Return the directory (up to and including the last separator) and the file name return (&path[..i + 1], &path[i + 1..]); } // If no separator is found, return an empty string for the directory and the whole path as the file name (path, "") } /// Returns all but the last element of path, typically the path's directory. /// /// After dropping the final element, `dir` calls `clean` on the path and trails the result. /// If the path is empty, `dir` returns ".". /// If the path consists entirely of separators, `dir` returns a single separator. /// The returned path does not end in a separator unless it is the root directory. pub fn dir(path: &str) -> String { let (a, _) = split(path); clean(a) } /// Trims double quotes from an ETag string. pub fn trim_etag(etag: &str) -> String { etag.trim_matches('"').to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn test_trim_etag() { // Test with quoted ETag assert_eq!(trim_etag("\"abc123\""), "abc123"); // Test with unquoted ETag assert_eq!(trim_etag("abc123"), "abc123"); // Test with empty string assert_eq!(trim_etag(""), ""); // Test with only quotes assert_eq!(trim_etag("\"\""), ""); // Test with MD5 hash assert_eq!(trim_etag("\"2c7ab85a893283e98c931e9511add182\""), "2c7ab85a893283e98c931e9511add182"); // Test with multipart ETag format assert_eq!(trim_etag("\"098f6bcd4621d373cade4e832627b4f6-2\""), "098f6bcd4621d373cade4e832627b4f6-2"); } #[test] fn test_base_dir_from_prefix() { let a = "da/"; // Test base_dir_from_prefix function let result = base_dir_from_prefix(a); assert!(!result.is_empty()); } #[test] fn test_clean() { assert_eq!(clean(""), "."); assert_eq!(clean("abc"), "abc"); assert_eq!(clean("abc/def"), "abc/def"); assert_eq!(clean("a/b/c"), "a/b/c"); assert_eq!(clean("."), "."); assert_eq!(clean(".."), ".."); assert_eq!(clean("../.."), "../.."); assert_eq!(clean("../../abc"), "../../abc"); assert_eq!(clean("/abc"), "/abc"); assert_eq!(clean("/"), "/"); assert_eq!(clean("abc/"), "abc"); assert_eq!(clean("abc/def/"), "abc/def"); assert_eq!(clean("a/b/c/"), "a/b/c"); assert_eq!(clean("./"), "."); assert_eq!(clean("../"), ".."); assert_eq!(clean("../../"), "../.."); assert_eq!(clean("/abc/"), "/abc"); assert_eq!(clean("abc//def//ghi"), "abc/def/ghi"); assert_eq!(clean("//abc"), "/abc"); assert_eq!(clean("///abc"), "/abc"); assert_eq!(clean("//abc//"), "/abc"); assert_eq!(clean("abc//"), "abc"); assert_eq!(clean("abc/./def"), "abc/def"); assert_eq!(clean("/./abc/def"), "/abc/def"); assert_eq!(clean("abc/."), "abc"); assert_eq!(clean("abc/./../def"), "def"); assert_eq!(clean("abc//./../def"), "def"); assert_eq!(clean("abc/../../././../def"), "../../def"); assert_eq!(clean("abc/def/ghi/../jkl"), "abc/def/jkl"); assert_eq!(clean("abc/def/../ghi/../jkl"), "abc/jkl"); assert_eq!(clean("abc/def/.."), "abc"); assert_eq!(clean("abc/def/../.."), "."); assert_eq!(clean("/abc/def/../.."), "/"); assert_eq!(clean("abc/def/../../.."), ".."); assert_eq!(clean("/abc/def/../../.."), "/"); assert_eq!(clean("abc/def/../../../ghi/jkl/../../../mno"), "../../mno"); } #[test] fn test_path_needs_clean() { struct PathTest { path: &'static str, result: &'static str, } let cleantests = vec![ // Already clean PathTest { path: "", result: "." }, PathTest { path: "abc", result: "abc", }, PathTest { path: "abc/def", result: "abc/def", }, PathTest { path: "a/b/c", result: "a/b/c", }, PathTest { path: ".", result: "." }, PathTest { path: "..", result: "..", }, PathTest { path: "../..", result: "../..", }, PathTest { path: "../../abc", result: "../../abc", }, PathTest { path: "/abc", result: "/abc", }, PathTest { path: "/abc/def", result: "/abc/def", }, PathTest { path: "/", result: "/" }, // Remove trailing slash PathTest { path: "abc/", result: "abc", }, PathTest { path: "abc/def/", result: "abc/def", }, PathTest { path: "a/b/c/", result: "a/b/c", }, PathTest { path: "./", result: "." }, PathTest { path: "../", result: "..", }, PathTest { path: "../../", result: "../..", }, PathTest { path: "/abc/", result: "/abc", }, // Remove doubled slash PathTest { path: "abc//def//ghi", result: "abc/def/ghi", }, PathTest { path: "//abc", result: "/abc", }, PathTest { path: "///abc", result: "/abc", }, PathTest { path: "//abc//", result: "/abc", }, PathTest { path: "abc//", result: "abc", }, // Remove . elements PathTest { path: "abc/./def", result: "abc/def", }, PathTest { path: "/./abc/def", result: "/abc/def", }, PathTest { path: "abc/.", result: "abc", }, // Remove .. elements PathTest { path: "abc/def/ghi/../jkl", result: "abc/def/jkl", }, PathTest { path: "abc/def/../ghi/../jkl", result: "abc/jkl", }, PathTest { path: "abc/def/..", result: "abc", }, PathTest { path: "abc/def/../..", result: ".", }, PathTest { path: "/abc/def/../..", result: "/", }, PathTest { path: "abc/def/../../..", result: "..", }, PathTest { path: "/abc/def/../../..", result: "/", }, PathTest { path: "abc/def/../../../ghi/jkl/../../../mno", result: "../../mno", }, // Combinations PathTest { path: "abc/./../def", result: "def", }, PathTest { path: "abc//./../def", result: "def", }, PathTest { path: "abc/../../././../def", result: "../../def", }, ]; for test in cleantests { let want = test.path != test.result; let got = path_needs_clean(test.path.as_bytes()); if want && !got { panic!("input: {:?}, want {}, got {}", test.path, want, got); } assert_eq!(clean(test.path), test.result); } } #[test] fn test_path_join() { // Test empty input let result = path_join::<&str>(&[]); assert_eq!(result, PathBuf::from(".")); // Test single path let result = path_join(&[PathBuf::from("abc")]); assert_eq!(result, PathBuf::from("abc")); // Test single absolute path let result = path_join(&[PathBuf::from("/abc")]); assert_eq!(result, PathBuf::from("/abc")); // Test multiple relative paths let result = path_join(&[PathBuf::from("a"), PathBuf::from("b"), PathBuf::from("c")]); assert_eq!(result, PathBuf::from("a/b/c")); // Test absolute path with relative paths let result = path_join(&[PathBuf::from("/a"), PathBuf::from("b"), PathBuf::from("c")]); assert_eq!(result, PathBuf::from("/a/b/c")); // Test paths with dots let result = path_join(&[PathBuf::from("a"), PathBuf::from("."), PathBuf::from("b")]); assert_eq!(result, PathBuf::from("a/b")); // Test paths with double dots let result = path_join(&[ PathBuf::from("a"), PathBuf::from("b"), PathBuf::from(".."), PathBuf::from("c"), ]); assert_eq!(result, PathBuf::from("a/c")); // Test paths that need cleaning let result = path_join(&[PathBuf::from("a//b"), PathBuf::from("c")]); assert_eq!(result, PathBuf::from("a/b/c")); // Test trailing slash preservation let result = path_join(&[PathBuf::from("a"), PathBuf::from("b/")]); assert_eq!(result, PathBuf::from("a/b/")); // Test empty path in middle let result = path_join(&[PathBuf::from("a"), PathBuf::from(""), PathBuf::from("b")]); assert_eq!(result, PathBuf::from("a/b")); // Test multiple absolute paths (should concatenate) let result = path_join(&[PathBuf::from("/a"), PathBuf::from("/b"), PathBuf::from("c")]); assert_eq!(result, PathBuf::from("/a/b/c")); // Test complex case with various path elements let result = path_join(&[ PathBuf::from("a"), PathBuf::from(".."), PathBuf::from("b"), PathBuf::from("."), PathBuf::from("c"), ]); assert_eq!(result, PathBuf::from("b/c")); } #[test] #[cfg(target_os = "windows")] fn test_windows_paths() { // Test clean with backslashes assert_eq!(clean("a\\b\\c"), "a/b/c"); assert_eq!(clean("a\\b/c"), "a/b/c"); assert_eq!(clean("a\\.\\b"), "a/b"); assert_eq!(clean("a\\b\\..\\c"), "a/c"); assert_eq!(clean("\\a\\b"), "/a/b"); assert_eq!(clean("a\\\\b"), "a/b"); // Test path_join with backslashes let result = path_join(&[PathBuf::from("a"), PathBuf::from("b\\c")]); assert_eq!(result, PathBuf::from("a/b/c")); // Test trailing backslash let result = path_join(&[PathBuf::from("a"), PathBuf::from("b\\")]); assert_eq!(result, PathBuf::from("a/b/")); // Test path_needs_clean assert!(path_needs_clean(b"a\\b")); // Test path_to_bucket_object_with_base_path let (bucket, object) = path_to_bucket_object_with_base_path("C:\\tmp", "C:\\tmp\\bucket\\object"); assert_eq!(bucket, "bucket"); assert_eq!(object, "object"); let (bucket, object) = path_to_bucket_object_with_base_path("c:\\tmp", "C:\\tmp\\bucket\\object"); assert_eq!(bucket, "bucket"); assert_eq!(object, "object"); } }