// 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 regex::Regex; use std::io::{Error, Result}; use std::sync::LazyLock; /// Parses a boolean value from a string. /// /// # Arguments /// `str` - A string slice representing the boolean value. /// /// # Returns /// A `Result` containing the parsed boolean value or an error if parsing fails. /// /// Examples /// ```no_run /// use rustfs_utils::string::parse_bool; /// /// let true_values = ["1", "t", "T", "true", "TRUE", "True", "on", "ON", "On", "enabled"]; /// let false_values = ["0", "f", "F", "false", "FALSE", "False", "off", "OFF", "Off", "disabled"]; /// /// for val in true_values.iter() { /// assert_eq!(parse_bool(val).expect("operation should succeed"), true); /// } /// for val in false_values.iter() { /// assert_eq!(parse_bool(val).expect("operation should succeed"), false); /// } /// ``` /// pub fn parse_bool(str: &str) -> Result { match str { "1" | "t" | "T" | "true" | "TRUE" | "True" | "on" | "ON" | "On" | "enabled" => Ok(true), "0" | "f" | "F" | "false" | "FALSE" | "False" | "off" | "OFF" | "Off" | "disabled" => Ok(false), _ => Err(Error::other(format!("ParseBool: parsing {str}"))), } } pub fn parse_bool_with_default(str: &str, default: bool) -> bool { match str { "1" | "t" | "T" | "true" | "TRUE" | "True" | "on" | "ON" | "On" | "enabled" => true, "0" | "f" | "F" | "false" | "FALSE" | "False" | "off" | "OFF" | "Off" | "disabled" => false, _ => default, } } /// Matches a simple pattern against a name using wildcards. /// /// # Arguments /// * `pattern` - The pattern to match, which may include wildcards '*' and '?' /// * `name` - The name to match against the pattern /// /// # Returns /// * `true` if the name matches the pattern, `false` otherwise /// /// Examples /// ```no_run /// use rustfs_utils::string::match_simple; /// assert!(match_simple("file*", "file123")); /// assert!(match_simple("file?", "file1")); /// assert!(!match_simple("file?", "file12")); /// ``` /// pub fn match_simple(pattern: &str, name: &str) -> bool { if pattern.is_empty() { return name == pattern; } if pattern == "*" { return true; } // Do an extended wildcard '*' and '?' match. deep_match_rune(name.as_bytes(), pattern.as_bytes(), true) } /// Matches a pattern against a name using wildcards. /// /// # Arguments /// * `pattern` - The pattern to match, which may include wildcards '*' and '?' /// * `name` - The name to match against the pattern /// /// # Returns /// * `true` if the name matches the pattern, `false` otherwise /// /// Examples /// ```no_run /// use rustfs_utils::string::match_pattern; /// /// assert!(match_pattern("file*", "file123")); /// assert!(match_pattern("file?", "file1")); /// assert!(!match_pattern("file?", "file12")); /// ``` /// pub fn match_pattern(pattern: &str, name: &str) -> bool { if pattern.is_empty() { return name == pattern; } if pattern == "*" { return true; } // Do an extended wildcard '*' and '?' match. deep_match_rune(name.as_bytes(), pattern.as_bytes(), false) } /// Checks if any pattern in the list matches the given string. /// /// # Arguments /// * `patterns` - A slice of patterns to match against /// * `match_str` - The string to match against the patterns /// /// # Returns /// * `true` if any pattern matches the string, `false` otherwise /// /// Examples /// ```no_run /// use rustfs_utils::string::has_pattern; /// /// let patterns = vec!["file*", "data?", "image*"]; /// assert!(has_pattern(&patterns, "file123")); /// assert!(has_pattern(&patterns, "data1")); /// assert!(!has_pattern(&patterns, "video1")); /// ``` /// pub fn has_pattern(patterns: &[&str], match_str: &str) -> bool { for pattern in patterns { if match_simple(pattern, match_str) { return true; } } false } /// Checks if the given string has any suffix from the provided list, ignoring case. /// /// # Arguments /// * `str` - The string to check /// * `list` - A slice of suffixes to check against /// /// # Returns /// * `true` if the string ends with any of the suffixes in the list, `false` otherwise /// /// Examples /// ```no_run /// use rustfs_utils::string::has_string_suffix_in_slice; /// /// let suffixes = vec![".txt", ".md", ".rs"]; /// assert!(has_string_suffix_in_slice("document.TXT", &suffixes)); /// assert!(!has_string_suffix_in_slice("image.png", &suffixes)); /// ``` pub fn has_string_suffix_in_slice(str: &str, list: &[impl AsRef]) -> bool { let str = str.to_lowercase(); for v in list { let v = v.as_ref(); if v == "*" { return true; } if str.ends_with(&v.to_lowercase()) { return true; } } false } fn deep_match_rune(str_: &[u8], pattern: &[u8], simple: bool) -> bool { let (mut str_, mut pattern) = (str_, pattern); while !pattern.is_empty() { match pattern[0] as char { '*' => { return if pattern.len() == 1 { true } else { deep_match_rune(str_, &pattern[1..], simple) || (!str_.is_empty() && deep_match_rune(&str_[1..], pattern, simple)) }; } '?' => { if str_.is_empty() { return simple; } } _ => { if str_.is_empty() || str_[0] != pattern[0] { return false; } } } str_ = &str_[1..]; pattern = &pattern[1..]; } str_.is_empty() && pattern.is_empty() } /// Matches a pattern as a prefix against the given text. /// /// # Arguments /// * `pattern` - The pattern to match, which may include wildcards '*' and '?' /// * `text` - The text to match against the pattern /// /// # Returns /// * `true` if the text matches the pattern as a prefix, `false` otherwise /// /// Examples /// ```no_run /// use rustfs_utils::string::match_as_pattern_prefix; /// /// assert!(match_as_pattern_prefix("file*", "file123")); /// assert!(match_as_pattern_prefix("file?", "file1")); /// assert!(!match_as_pattern_prefix("file?", "file12")); /// ``` /// pub fn match_as_pattern_prefix(pattern: &str, text: &str) -> bool { let mut i = 0; while i < text.len() && i < pattern.len() { match pattern.as_bytes()[i] as char { '*' => return true, '?' => i += 1, _ => { if pattern.as_bytes()[i] != text.as_bytes()[i] { return false; } } } i += 1; } text.len() <= pattern.len() } static ELLIPSES_RE: LazyLock = LazyLock::new(|| Regex::new(r"(.*)(\{[0-9A-Fa-f]*\.\.\.[0-9A-Fa-f]*\})(.*)").expect("operation should succeed")); /// Ellipses constants const OPEN_BRACES: &str = "{"; const CLOSE_BRACES: &str = "}"; const ELLIPSES: &str = "..."; const MAX_ELLIPSES_RANGE_SIZE: usize = 10_000; /// ellipses pattern, describes the range and also the /// associated prefix and suffixes. #[derive(Debug, Default, PartialEq, Eq)] pub struct Pattern { pub prefix: String, pub suffix: String, pub seq: Vec, } impl Pattern { /// expands a ellipses pattern. pub fn expand(&self) -> Vec { let mut ret = Vec::with_capacity(self.suffix.len()); for v in self.seq.iter() { match (self.prefix.is_empty(), self.suffix.is_empty()) { (false, true) => ret.push(format!("{}{}", self.prefix, v)), (true, false) => ret.push(format!("{}{}", v, self.suffix)), (true, true) => ret.push(v.to_string()), (false, false) => ret.push(format!("{}{}{}", self.prefix, v, self.suffix)), } } ret } pub fn len(&self) -> usize { self.seq.len() } pub fn is_empty(&self) -> bool { self.seq.is_empty() } } /// contains a list of patterns provided in the input. #[derive(Debug, PartialEq, Eq)] pub struct ArgPattern { inner: Vec, } impl AsRef> for ArgPattern { fn as_ref(&self) -> &Vec { &self.inner } } impl AsMut> for ArgPattern { fn as_mut(&mut self) -> &mut Vec { &mut self.inner } } impl ArgPattern { pub fn new(inner: Vec) -> Self { Self { inner } } /// expands all the ellipses patterns in the given argument. pub fn expand(&self) -> Vec> { let ret: Vec> = self.inner.iter().map(|v| v.expand()).collect(); Self::arg_expander(&ret) } /// recursively expands labels into its respective forms. fn arg_expander(lbs: &[Vec]) -> Vec> { if lbs.len() == 1 { return lbs[0].iter().map(|v| vec![v.to_string()]).collect(); } let mut ret = Vec::new(); let (first, others) = lbs.split_at(1); for bs in first[0].iter() { let ots = Self::arg_expander(others); for mut obs in ots { obs.push(bs.to_string()); ret.push(obs); } } ret } /// returns the total number of sizes in the given patterns. pub fn total_sizes(&self) -> usize { self.inner.iter().fold(1, |acc, v| acc * v.seq.len()) } } /// finds all ellipses patterns, recursively and parses the ranges numerically. /// /// # Arguments /// * `arg` - The argument string to search for ellipses patterns /// /// # Returns /// * `Result` - A result containing the parsed ArgPattern or an error if parsing fails /// /// # Errors /// This function will return an error if the ellipses pattern format is invalid. /// /// Examples /// ```no_run /// use rustfs_utils::string::find_ellipses_patterns; /// /// let pattern = "http://rustfs{2...3}/export/set{1...64}"; /// let arg_pattern = find_ellipses_patterns(pattern).expect("operation should succeed"); /// assert_eq!(arg_pattern.total_sizes(), 128); /// ``` pub fn find_ellipses_patterns(arg: &str) -> Result { let mut parts = match ELLIPSES_RE.captures(arg) { Some(caps) => caps, None => { return Err(Error::other(format!( "Invalid ellipsis format in ({arg}), Ellipsis range must be provided in format {{N...M}} where N and M are decimal or hexadecimal positive integers, M must be greater than N, with a maximum expanded range size of {MAX_ELLIPSES_RANGE_SIZE}" ))); } }; let mut patterns = Vec::new(); while let Some(prefix) = parts.get(1) { let seq = parse_ellipses_range(parts[2].into())?; match ELLIPSES_RE.captures(prefix.into()) { Some(cs) => { patterns.push(Pattern { seq, prefix: String::new(), suffix: parts[3].into(), }); parts = cs; } None => { patterns.push(Pattern { seq, prefix: prefix.as_str().to_owned(), suffix: parts[3].into(), }); break; } }; } // Check if any of the prefix or suffixes now have flower braces // left over, in such a case we generally think that there is // perhaps a typo in users input and error out accordingly. for p in patterns.iter() { if p.prefix.contains(OPEN_BRACES) || p.prefix.contains(CLOSE_BRACES) || p.suffix.contains(OPEN_BRACES) || p.suffix.contains(CLOSE_BRACES) { return Err(Error::other(format!( "Invalid ellipsis format in ({arg}), Ellipsis range must be provided in format {{N...M}} where N and M are decimal or hexadecimal positive integers, M must be greater than N, with a maximum expanded range size of {MAX_ELLIPSES_RANGE_SIZE}" ))); } } Ok(ArgPattern::new(patterns)) } /// returns true if input arg has ellipses type pattern. /// /// # Arguments /// * `s` - A slice of strings to check for ellipses patterns /// /// # Returns /// * `true` if any string contains ellipses patterns, `false` otherwise /// /// Examples /// ```no_run /// use rustfs_utils::string::has_ellipses; /// /// let args = vec!["http://rustfs{2...3}/export/set{1...64}", "mydisk-{a...z}{1...20}"]; /// assert!(has_ellipses(&args)); /// ``` /// pub fn has_ellipses>(s: &[T]) -> bool { s.iter().any(|v| { let v = v.as_ref(); let mut remaining = v; while let Some(start) = remaining.find(OPEN_BRACES) { let after_start = &remaining[start + OPEN_BRACES.len()..]; let Some(end) = after_start.find(CLOSE_BRACES) else { break; }; // Dotted brace content covers both valid ellipsis patterns like // {1...64} and invalid attempts like {1..64}, while excluding // Windows Volume{GUID} paths (GUIDs use hyphens, not dots). if after_start[..end].contains('.') { return true; } remaining = &after_start[end + CLOSE_BRACES.len()..]; } // Bare `...` without braces (e.g. "1...64") is still an ellipsis attempt. if v.contains(ELLIPSES) { return true; } false }) } /// Parses an ellipses range pattern of following style /// /// example: /// {1...64} /// {33...64} /// {0a...0f} /// /// # Arguments /// * `pattern` - A string slice representing the ellipses range pattern /// /// # Returns /// * `Result>` - A result containing a vector of strings representing the expanded range or an error if parsing fails /// /// # Errors /// This function will return an error if the ellipses range format is invalid. /// /// Examples /// ```no_run /// use rustfs_utils::string::parse_ellipses_range; /// /// let range = parse_ellipses_range("{1...5}").expect("operation should succeed"); /// assert_eq!(range, vec!["1", "2", "3", "4", "5"]); /// ``` /// pub fn parse_ellipses_range(pattern: &str) -> Result> { if !pattern.contains(OPEN_BRACES) { return Err(Error::other("Invalid argument")); } if !pattern.contains(CLOSE_BRACES) { return Err(Error::other("Invalid argument")); } let ellipses_range: Vec<&str> = pattern .trim_start_matches(OPEN_BRACES) .trim_end_matches(CLOSE_BRACES) .split(ELLIPSES) .collect(); if ellipses_range.len() != 2 { return Err(Error::other("Invalid argument")); } let is_hex_range = ellipses_range .iter() .any(|value| value.bytes().any(|ch| matches!(ch, b'a'..=b'f' | b'A'..=b'F'))); let is_upper_hex_range = ellipses_range .iter() .any(|value| value.bytes().any(|ch| matches!(ch, b'A'..=b'F'))); let radix = if is_hex_range { 16 } else { 10 }; let start = usize::from_str_radix(ellipses_range[0], radix).map_err(Error::other)?; let end = usize::from_str_radix(ellipses_range[1], radix).map_err(Error::other)?; if start > end { return Err(Error::other("Invalid argument:range start cannot be bigger than end")); } let range_size = end .checked_sub(start) .and_then(|size| size.checked_add(1)) .ok_or_else(|| Error::other("Invalid argument:range size overflow"))?; if range_size > MAX_ELLIPSES_RANGE_SIZE { return Err(Error::other("Invalid argument:range is too large")); } let mut ret: Vec = Vec::with_capacity(range_size); for i in start..=end { if is_hex_range { if is_upper_hex_range { ret.push(format!("{i:0width$X}", width = ellipses_range[1].len())); } else { ret.push(format!("{i:0width$x}", width = ellipses_range[1].len())); } } else if ellipses_range[0].starts_with('0') && ellipses_range[0].len() > 1 { ret.push(format!("{:0width$}", i, width = ellipses_range[1].len())); } else { ret.push(format!("{i}")); } } Ok(ret) } /// Tests whether the string s begins with prefix ignoring case /// /// # Arguments /// * `s` - The string to test /// * `prefix` - The prefix to look for /// /// # Returns /// * `true` if s starts with prefix (case-insensitive), `false` otherwise /// /// # Examples /// ```no_run /// use rustfs_utils::string::strings_has_prefix_fold; /// /// assert!(strings_has_prefix_fold("HelloWorld", "hello")); /// assert!(!strings_has_prefix_fold("HelloWorld", "world")); /// ``` /// pub fn strings_has_prefix_fold(s: &str, prefix: &str) -> bool { if s.starts_with(prefix) { return true; } s.get(..prefix.len()) .is_some_and(|s_prefix| s_prefix.eq_ignore_ascii_case(prefix)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_has_ellipses() { // Tests for all args without ellipses. let test_cases = [ (1, vec!["64"], false), // Found flower braces, still attempt to parse and throw an error. (2, vec!["{1..64}"], true), (3, vec!["{1..2..}"], true), // Test for valid input. (4, vec!["1...64"], true), (5, vec!["{1...2O}"], true), (6, vec!["..."], true), (7, vec!["{-1...1}"], true), (8, vec!["{0...-1}"], true), (9, vec!["{1....4}"], true), (10, vec!["{1...64}"], true), (11, vec!["{...}"], true), (12, vec!["{1...64}", "{65...128}"], true), (13, vec!["http://rustfs{2...3}/export/set{1...64}"], true), ( 14, vec![ "http://rustfs{2...3}/export/set{1...64}", "http://rustfs{2...3}/export/set{65...128}", ], true, ), (15, vec!["mydisk-{a...z}{1...20}"], true), (16, vec!["mydisk-{1...4}{1..2.}"], true), // Windows Volume{GUID} paths must not match; GUIDs use hyphens, not dots. (17, vec![r"\\?\Volume{9425a190-4bb3-11f1-86de-803253b6a8b8}\"], false), (18, vec![r"//./Volume{9425a190-4bb3-11f1-86de-803253b6a8b8}/"], false), (19, vec![r"\\?\Volume{9425a190-4bb3-11f1-86de-803253b6a8b8}\disk-{1..4}"], true), ]; for (i, args, expected) in test_cases { let ret = has_ellipses(&args); assert_eq!(ret, expected, "Test{i}: Expected {expected}, got {ret}"); } } #[test] fn test_find_ellipses_patterns() { #[derive(Default)] struct TestCase<'a> { num: usize, pattern: &'a str, success: bool, want: Vec>, } let test_cases = [ TestCase { num: 1, pattern: "{1..64}", ..Default::default() }, TestCase { num: 2, pattern: "1...64", ..Default::default() }, TestCase { num: 2, pattern: "...", ..Default::default() }, TestCase { num: 3, pattern: "{1...", ..Default::default() }, TestCase { num: 4, pattern: "...64}", ..Default::default() }, TestCase { num: 5, pattern: "{...}", ..Default::default() }, TestCase { num: 6, pattern: "{-1...1}", ..Default::default() }, TestCase { num: 7, pattern: "{0...-1}", ..Default::default() }, TestCase { num: 8, pattern: "{1...2O}", ..Default::default() }, TestCase { num: 9, pattern: "{64...1}", ..Default::default() }, TestCase { num: 10, pattern: "{1....4}", ..Default::default() }, TestCase { num: 11, pattern: "mydisk-{a...z}{1...20}", ..Default::default() }, TestCase { num: 12, pattern: "mydisk-{1...4}{1..2.}", ..Default::default() }, TestCase { num: 13, pattern: "{1..2.}-mydisk-{1...4}", ..Default::default() }, TestCase { num: 14, pattern: "{{1...4}}", ..Default::default() }, TestCase { num: 16, pattern: "{4...02}", ..Default::default() }, TestCase { num: 17, pattern: "{f...z}", ..Default::default() }, // Test for valid input. TestCase { num: 18, pattern: "{1...64}", success: true, want: vec![ vec!["1"], vec!["2"], vec!["3"], vec!["4"], vec!["5"], vec!["6"], vec!["7"], vec!["8"], vec!["9"], vec!["10"], vec!["11"], vec!["12"], vec!["13"], vec!["14"], vec!["15"], vec!["16"], vec!["17"], vec!["18"], vec!["19"], vec!["20"], vec!["21"], vec!["22"], vec!["23"], vec!["24"], vec!["25"], vec!["26"], vec!["27"], vec!["28"], vec!["29"], vec!["30"], vec!["31"], vec!["32"], vec!["33"], vec!["34"], vec!["35"], vec!["36"], vec!["37"], vec!["38"], vec!["39"], vec!["40"], vec!["41"], vec!["42"], vec!["43"], vec!["44"], vec!["45"], vec!["46"], vec!["47"], vec!["48"], vec!["49"], vec!["50"], vec!["51"], vec!["52"], vec!["53"], vec!["54"], vec!["55"], vec!["56"], vec!["57"], vec!["58"], vec!["59"], vec!["60"], vec!["61"], vec!["62"], vec!["63"], vec!["64"], ], }, TestCase { num: 19, pattern: "{1...5} {65...70}", success: true, want: vec![ vec!["1 ", "65"], vec!["2 ", "65"], vec!["3 ", "65"], vec!["4 ", "65"], vec!["5 ", "65"], vec!["1 ", "66"], vec!["2 ", "66"], vec!["3 ", "66"], vec!["4 ", "66"], vec!["5 ", "66"], vec!["1 ", "67"], vec!["2 ", "67"], vec!["3 ", "67"], vec!["4 ", "67"], vec!["5 ", "67"], vec!["1 ", "68"], vec!["2 ", "68"], vec!["3 ", "68"], vec!["4 ", "68"], vec!["5 ", "68"], vec!["1 ", "69"], vec!["2 ", "69"], vec!["3 ", "69"], vec!["4 ", "69"], vec!["5 ", "69"], vec!["1 ", "70"], vec!["2 ", "70"], vec!["3 ", "70"], vec!["4 ", "70"], vec!["5 ", "70"], ], }, TestCase { num: 20, pattern: "{01...036}", success: true, want: vec![ vec!["001"], vec!["002"], vec!["003"], vec!["004"], vec!["005"], vec!["006"], vec!["007"], vec!["008"], vec!["009"], vec!["010"], vec!["011"], vec!["012"], vec!["013"], vec!["014"], vec!["015"], vec!["016"], vec!["017"], vec!["018"], vec!["019"], vec!["020"], vec!["021"], vec!["022"], vec!["023"], vec!["024"], vec!["025"], vec!["026"], vec!["027"], vec!["028"], vec!["029"], vec!["030"], vec!["031"], vec!["032"], vec!["033"], vec!["034"], vec!["035"], vec!["036"], ], }, TestCase { num: 21, pattern: "{001...036}", success: true, want: vec![ vec!["001"], vec!["002"], vec!["003"], vec!["004"], vec!["005"], vec!["006"], vec!["007"], vec!["008"], vec!["009"], vec!["010"], vec!["011"], vec!["012"], vec!["013"], vec!["014"], vec!["015"], vec!["016"], vec!["017"], vec!["018"], vec!["019"], vec!["020"], vec!["021"], vec!["022"], vec!["023"], vec!["024"], vec!["025"], vec!["026"], vec!["027"], vec!["028"], vec!["029"], vec!["030"], vec!["031"], vec!["032"], vec!["033"], vec!["034"], vec!["035"], vec!["036"], ], }, TestCase { num: 22, pattern: "{0a...0f}", success: true, want: vec![vec!["0a"], vec!["0b"], vec!["0c"], vec!["0d"], vec!["0e"], vec!["0f"]], }, TestCase { num: 23, pattern: "{0A...0F}", success: true, want: vec![vec!["0A"], vec!["0B"], vec!["0C"], vec!["0D"], vec!["0E"], vec!["0F"]], }, ]; for test_case in test_cases { let ret = find_ellipses_patterns(test_case.pattern); match ret { Ok(v) => { if !test_case.success { panic!("Test{}: Expected failure but passed instead", test_case.num); } let got = v.expand(); if got.len() != test_case.want.len() { panic!("Test{}: Expected {}, got {}", test_case.num, test_case.want.len(), got.len()); } assert_eq!(got, test_case.want, "Test{}: Expected {:?}, got {:?}", test_case.num, test_case.want, got); } Err(e) => { if test_case.success { panic!("Test{}: Expected success but failed instead {:?}", test_case.num, e); } } } } } #[test] fn test_find_ellipses_patterns_error_mentions_hex_ranges() { let err = find_ellipses_patterns("{1..64}").unwrap_err(); assert!(err.to_string().contains("decimal or hexadecimal"), "unexpected error message: {err}"); } #[test] fn test_parse_ellipses_range_rejects_oversized_ranges() { let err = parse_ellipses_range("{0...10000}").unwrap_err(); assert!(err.to_string().contains("range is too large"), "unexpected error message: {err}"); } #[test] #[cfg(target_os = "windows")] fn test_strings_has_prefix_fold_handles_unicode_without_panicking() { assert!(!strings_has_prefix_fold( "s3-test-bucket/中文/日本語/한글-9cd5599a-f8eb-4e24-9df7-32ecd8d8ad1f", "D:\\Github\\rustfs\\target\\volumes\\test1", )); } }