#![no_main] use libfuzzer_sys::fuzz_target; mod path_containment_storage_api; use path_containment_storage_api::{check_object_name_for_length_and_slash, has_bad_path_component, is_valid_object_prefix}; use rustfs_utils::path::{clean, path_join}; use std::path::{Path, PathBuf}; const ROOT: &str = "/srv/rustfs"; fn parse_case(data: &[u8]) -> (String, String, String, Vec) { let text = String::from_utf8_lossy(data); let mut parts = text.splitn(4, '\n'); let bucket = parts.next().unwrap_or_default().to_string(); let object = parts.next().unwrap_or_default().to_string(); let extra = parts.next().unwrap_or_default().to_string(); let flags = parts .next() .unwrap_or_default() .split(',') .map(str::trim) .filter(|flag| !flag.is_empty()) .map(ToOwned::to_owned) .collect(); (bucket, object, extra, flags) } fn stays_within_root(root: &Path, candidate: &Path) -> bool { candidate == root || candidate.starts_with(root) } fn apply_flag(mut object: String, extra: &str, flag: &str) -> String { let extra_segment = if extra.is_empty() { "tail" } else { extra }; match flag { "leading_ws" => format!(" {object}"), "trailing_ws" => { object.push(' '); object } "leading_slash" => format!("/{object}"), "trailing_slash" => format!("{object}/"), "double_sep" => { if object.contains('/') { object.replacen('/', "//", 1) } else { format!("{object}//{extra_segment}") } } "backslash" => object.replace('/', "\\"), "append_extra" => format!("{object}/{extra_segment}"), "empty_segment" => { if let Some((left, right)) = object.split_once('/') { format!("{left}//{right}") } else { format!("{object}//{extra_segment}") } } "parent_segment" => format!("{object}/../{extra_segment}"), "current_segment" => format!("{object}/./{extra_segment}"), _ => object, } } fn materialize_object(base: String, extra: &str, flags: &[String]) -> String { flags.iter().fold(base, |object, flag| apply_flag(object, extra, flag)) } fn has_dot_segments(path: &str) -> bool { path.split(['/', '\\']).any(|segment| { let mut bytes = segment.as_bytes(); // Match ecstore's path-component check. `str::trim()` also trims // Unicode whitespace such as vertical tab, which would reject object // keys that the production validator accepts as ordinary path bytes. while let Some((first, rest)) = bytes.split_first() && first.is_ascii_whitespace() { bytes = rest; } while let Some((last, rest)) = bytes.split_last() && last.is_ascii_whitespace() { bytes = rest; } bytes == b"." || bytes == b".." }) } fuzz_target!(|data: &[u8]| { let (bucket, base_object, extra, flags) = parse_case(data); let object = materialize_object(base_object, &extra, &flags); let has_bad_component = has_bad_path_component(&object); let is_valid_prefix = is_valid_object_prefix(&object); let length_and_slash_ok = check_object_name_for_length_and_slash(&bucket, &object).is_ok(); if object.is_empty() || !(is_valid_prefix && length_and_slash_ok) { return; } assert!( !has_bad_component, "accepted object unexpectedly retained a bad path component: {:?}", object ); assert!( !has_dot_segments(&object), "accepted object unexpectedly retained dot segments: {:?}", object ); let root = PathBuf::from(ROOT); let joined = path_join(&[root.clone(), PathBuf::from(&object)]); let cleaned = PathBuf::from(clean(joined.to_string_lossy().as_ref())); let cleaned_text = cleaned.to_string_lossy(); assert!( stays_within_root(&root, &cleaned), "accepted object escaped root containment: object={:?} cleaned={:?}", object, cleaned ); assert!( !has_dot_segments(cleaned_text.as_ref()), "cleaned accepted object unexpectedly retained dot segments: object={:?} cleaned={:?}", object, cleaned ); });