use crate::{DuckLakeError, Result};
use datafusion::datasource::object_store::ObjectStoreUrl;
use percent_encoding::percent_decode_str;
use std::sync::Arc;
fn validate_no_null_bytes(path: &str) -> Result<()> {
if path.contains('\0') {
return Err(DuckLakeError::InvalidConfig(format!(
"Path contains null byte: {}",
path.replace('\0', "\\0")
)));
}
if path.contains("%00") {
return Err(DuckLakeError::InvalidConfig(format!(
"Path contains URL-encoded null byte (%00): {}",
path
)));
}
Ok(())
}
fn percent_decode_path(path: &str) -> String {
let mut result = String::with_capacity(path.len());
let bytes = path.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) = u8::from_str_radix(&path[i + 1..i + 3], 16)
{
result.push(byte as char);
i += 3;
continue;
}
result.push(bytes[i] as char);
i += 1;
}
result
}
fn has_dotdot_component(path: &str) -> bool {
path.split(['/', '\\']).any(|c| c == "..")
}
fn validate_no_path_traversal(path: &str) -> Result<()> {
if has_dotdot_component(path) {
return Err(DuckLakeError::InvalidConfig(format!(
"Path traversal detected: path contains '..' component: {}",
path
)));
}
if !path.contains('%') {
return Ok(());
}
let decoded = percent_decode_path(path);
if decoded != path && has_dotdot_component(&decoded) {
return Err(DuckLakeError::InvalidConfig(format!(
"Path traversal detected: URL-encoded '..': {}",
path
)));
}
Ok(())
}
fn validate_path(path: &str) -> Result<()> {
validate_no_null_bytes(path)?;
validate_no_path_traversal(path)?;
Ok(())
}
pub fn parse_object_store_url(data_path: &str) -> Result<(ObjectStoreUrl, String)> {
let data_path = data_path.trim();
validate_path(data_path)?;
let lower = data_path.to_ascii_lowercase();
if lower.starts_with("s3://") {
parse_s3_url(data_path)
} else if lower.starts_with("file://") {
parse_file_url(data_path)
} else {
parse_local_path(data_path)
}
}
fn parse_s3_url(data_path: &str) -> Result<(ObjectStoreUrl, String)> {
let url = url::Url::parse(data_path).map_err(|e| {
DuckLakeError::InvalidConfig(format!("Failed to parse S3 URL '{}': {}", data_path, e))
})?;
let bucket = url.host_str().ok_or_else(|| {
DuckLakeError::InvalidConfig(format!("S3 URL missing bucket: {}", data_path))
})?;
let object_store_url = ObjectStoreUrl::parse(format!("s3://{}/", bucket)).map_err(|e| {
DuckLakeError::InvalidConfig(format!("Failed to create ObjectStoreUrl: {}", e))
})?;
Ok((object_store_url, url.path().to_owned()))
}
fn parse_file_url(data_path: &str) -> Result<(ObjectStoreUrl, String)> {
let url = url::Url::parse(data_path).map_err(|e| {
DuckLakeError::InvalidConfig(format!("Failed to parse file URL '{}': {}", data_path, e))
})?;
let object_store_url = ObjectStoreUrl::parse("file:///").map_err(|e| {
DuckLakeError::InvalidConfig(format!("Failed to create ObjectStoreUrl: {}", e))
})?;
let decoded_path = percent_decode_str(url.path())
.decode_utf8()
.map_err(|e| {
DuckLakeError::InvalidConfig(format!(
"Invalid UTF-8 in file path '{}': {}",
data_path, e
))
})?
.to_string();
Ok((object_store_url, decoded_path))
}
fn parse_local_path(data_path: &str) -> Result<(ObjectStoreUrl, String)> {
if data_path.split(['/', '\\']).any(|c| c == "..") {
return Err(DuckLakeError::InvalidConfig(
"Path contains '..' traversal component".to_string(),
));
}
let has_trailing_slash = data_path.ends_with('/') || data_path.ends_with('\\');
let absolute_path = std::path::absolute(data_path).map_err(|e| {
DuckLakeError::InvalidConfig(format!("Failed to resolve path '{}': {}", data_path, e))
})?;
let object_store_url = ObjectStoreUrl::parse("file:///").map_err(|e| {
DuckLakeError::InvalidConfig(format!("Failed to create ObjectStoreUrl: {}", e))
})?;
let mut path_str = absolute_path.to_string_lossy().to_string();
if has_trailing_slash && !path_str.ends_with('/') && !path_str.ends_with('\\') {
path_str.push('/');
}
Ok((object_store_url, path_str))
}
pub fn resolve_path(base_path: &str, path: &str, is_relative: bool) -> Result<String> {
if is_relative {
join_paths(base_path, path)
} else {
validate_path(path)?;
Ok(path.to_string())
}
}
pub fn join_paths(base_path: &str, relative_path: &str) -> Result<String> {
validate_path(relative_path)?;
if base_path.ends_with('/') || base_path.ends_with('\\') {
Ok(format!("{}{}", base_path, relative_path))
} else {
Ok(format!("{}/{}", base_path, relative_path))
}
}
#[derive(Debug, Clone)]
pub struct PathResolver {
base_url: Arc<ObjectStoreUrl>,
base_path: String,
}
impl PathResolver {
pub fn new(base_url: Arc<ObjectStoreUrl>, base_path: String) -> Self {
Self {
base_url,
base_path,
}
}
pub fn resolve(&self, path: &str, is_relative: bool) -> Result<String> {
resolve_path(&self.base_path, path, is_relative)
}
pub fn child_resolver(&self, path: &str, is_relative: bool) -> Result<Self> {
let resolved = self.resolve(path, is_relative)?;
Ok(Self::new(self.base_url.clone(), resolved))
}
pub fn base_url(&self) -> &Arc<ObjectStoreUrl> {
&self.base_url
}
pub fn base_path(&self) -> &str {
&self.base_path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_s3_url() {
let (url, path) = parse_object_store_url("s3://bucket/prefix/data").unwrap();
assert_eq!(path, "/prefix/data");
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
}
#[test]
fn test_parse_s3_url_with_trailing_slash() {
let (url, path) = parse_object_store_url("s3://bucket/prefix/").unwrap();
assert_eq!(path, "/prefix/");
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
}
#[test]
fn test_parse_s3_url_no_prefix() {
let (url, path) = parse_object_store_url("s3://bucket/").unwrap();
assert_eq!(path, "/");
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
}
#[test]
fn test_parse_file_url() {
let (url, path) = parse_object_store_url("file:///tmp/data").unwrap();
assert_eq!(path, "/tmp/data");
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
}
#[test]
fn test_parse_file_url_with_trailing_slash() {
let (url, path) = parse_object_store_url("file:///tmp/data/").unwrap();
assert_eq!(path, "/tmp/data/");
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
}
#[test]
fn test_parse_s3_url_missing_bucket() {
let result = parse_object_store_url("s3:///path");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("S3 URL missing bucket")
);
}
#[test]
fn test_resolve_path_relative() {
let resolved = resolve_path("/data/schema1/", "table1/", true).unwrap();
assert_eq!(resolved, "/data/schema1/table1/");
}
#[test]
fn test_resolve_path_relative_no_trailing_slash() {
let resolved = resolve_path("/data/schema1", "table1/", true).unwrap();
assert_eq!(resolved, "/data/schema1/table1/");
}
#[test]
fn test_resolve_path_absolute() {
let resolved = resolve_path("/data/schema1/", "/other/path/", false).unwrap();
assert_eq!(resolved, "/other/path/");
}
#[test]
fn test_join_paths_with_trailing_slash() {
assert_eq!(
join_paths("/data/", "table/file.parquet").unwrap(),
"/data/table/file.parquet"
);
}
#[test]
fn test_join_paths_without_trailing_slash() {
assert_eq!(
join_paths("/data", "table/file.parquet").unwrap(),
"/data/table/file.parquet"
);
}
#[test]
fn test_join_paths_with_backslash() {
assert_eq!(
join_paths("C:\\data\\", "table\\file.parquet").unwrap(),
"C:\\data\\table\\file.parquet"
);
}
#[test]
fn test_path_resolver_resolve() {
let resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap()),
"/data/schema1/".to_string(),
);
let resolved = resolver.resolve("table1/", true).unwrap();
assert_eq!(resolved, "/data/schema1/table1/");
let absolute = resolver.resolve("/other/path/", false).unwrap();
assert_eq!(absolute, "/other/path/");
}
#[test]
fn test_path_resolver_child_resolver() {
let catalog_resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap()),
"/data/".to_string(),
);
let schema_resolver = catalog_resolver.child_resolver("schema1/", true).unwrap();
assert_eq!(schema_resolver.base_path(), "/data/schema1/");
assert_eq!(
*schema_resolver.base_url(),
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap())
);
let table_resolver = schema_resolver.child_resolver("table1/", true).unwrap();
assert_eq!(table_resolver.base_path(), "/data/schema1/table1/");
}
#[test]
fn test_path_resolver_child_resolver_absolute() {
let catalog_resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap()),
"/data/".to_string(),
);
let schema_resolver = catalog_resolver
.child_resolver("/other/schema/", false)
.unwrap();
assert_eq!(schema_resolver.base_path(), "/other/schema/");
}
#[test]
fn test_parse_s3_url_with_deep_prefix() {
let (url, path) =
parse_object_store_url("s3://my-bucket/level1/level2/level3/data").unwrap();
assert_eq!(path, "/level1/level2/level3/data");
assert_eq!(url, ObjectStoreUrl::parse("s3://my-bucket/").unwrap());
}
#[test]
fn test_parse_s3_url_bucket_only() {
let (url, path) = parse_object_store_url("s3://my-bucket").unwrap();
assert_eq!(path, "");
assert_eq!(url, ObjectStoreUrl::parse("s3://my-bucket/").unwrap());
}
#[test]
fn test_parse_s3_url_with_hyphens_and_dots() {
let (url, path) = parse_object_store_url("s3://my-bucket.name-123/data").unwrap();
assert_eq!(path, "/data");
assert_eq!(
url,
ObjectStoreUrl::parse("s3://my-bucket.name-123/").unwrap()
);
}
#[test]
fn test_parse_file_url_root() {
let (url, path) = parse_object_store_url("file:///").unwrap();
assert_eq!(path, "/");
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
}
#[test]
fn test_parse_file_url_with_host() {
let (url, path) = parse_object_store_url("file://localhost/tmp/data").unwrap();
assert_eq!(path, "/tmp/data");
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
}
#[test]
fn test_parse_local_path_nonexistent() {
let (url, path) = parse_object_store_url("/nonexistent/path/that/does/not/exist").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
assert_eq!(path, "/nonexistent/path/that/does/not/exist");
}
#[test]
fn test_parse_local_path_nonexistent_with_trailing_slash() {
let (url, path) = parse_object_store_url("/nonexistent/data/path/").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
assert_eq!(path, "/nonexistent/data/path/");
}
#[test]
fn test_reject_dotdot_in_local_path() {
let result = parse_object_store_url("/data/../../../etc/passwd");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("'..'"),
"error should mention '..' traversal: {}",
err
);
}
#[test]
fn test_reject_dotdot_in_local_path_backslash() {
let result = parse_object_store_url("/data\\..\\secret");
assert!(result.is_err());
}
#[test]
fn test_join_paths_nested_relative() {
assert_eq!(
join_paths("/data/", "schema1/table1/file.parquet").unwrap(),
"/data/schema1/table1/file.parquet"
);
}
#[test]
fn test_join_paths_windows_style() {
assert_eq!(
join_paths("C:\\data", "schema1\\table1").unwrap(),
"C:\\data/schema1\\table1"
);
}
#[test]
fn test_path_resolver_multiple_levels() {
let catalog_resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap()),
"/data/".to_string(),
);
let schema_resolver = catalog_resolver.child_resolver("schema1/", true).unwrap();
let table_resolver = schema_resolver.child_resolver("table1/", true).unwrap();
let file_path = table_resolver.resolve("file.parquet", true).unwrap();
assert_eq!(file_path, "/data/schema1/table1/file.parquet");
}
#[test]
fn test_resolve_path_with_special_chars() {
let resolved = resolve_path("/data/", "table-123_test.v2/", true).unwrap();
assert_eq!(resolved, "/data/table-123_test.v2/");
}
#[test]
fn test_join_paths_double_slash_in_relative() {
assert_eq!(
join_paths("/data/", "/absolute").unwrap(),
"/data//absolute"
);
}
#[test]
fn test_path_resolver_mixed_relative_absolute() {
let catalog_resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap()),
"/catalog/".to_string(),
);
let schema_resolver = catalog_resolver.child_resolver("schema1/", true).unwrap();
assert_eq!(schema_resolver.base_path(), "/catalog/schema1/");
let table_resolver = schema_resolver
.child_resolver("/absolute/table/", false)
.unwrap();
assert_eq!(table_resolver.base_path(), "/absolute/table/");
let file_resolver = table_resolver.child_resolver("subdir/", true).unwrap();
assert_eq!(file_resolver.base_path(), "/absolute/table/subdir/");
}
#[test]
fn test_s3_url_bucket_with_region() {
let (url, path) = parse_object_store_url("s3://my-bucket/data/warehouse").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://my-bucket/").unwrap());
assert_eq!(path, "/data/warehouse");
}
#[test]
fn test_s3_url_with_encoded_characters() {
let (url, path) = parse_object_store_url("s3://bucket/path%20with%20spaces/data").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
assert_eq!(path, "/path%20with%20spaces/data");
}
#[test]
fn test_s3_url_very_long_path() {
let long_path = "s3://bucket/".to_string() + &"level/".repeat(20) + "file.parquet";
let (url, path) = parse_object_store_url(&long_path).unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
assert!(path.starts_with("/level/"));
assert!(path.ends_with("file.parquet"));
}
#[test]
fn test_mixed_s3_and_local_hierarchy() {
let s3_resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://data-lake/").unwrap()),
"/prod/".to_string(),
);
let schema_resolver = s3_resolver.child_resolver("sales/", true).unwrap();
assert_eq!(schema_resolver.base_path(), "/prod/sales/");
assert_eq!(
*schema_resolver.base_url(),
Arc::new(ObjectStoreUrl::parse("s3://data-lake/").unwrap())
);
let table_resolver = schema_resolver
.child_resolver("transactions/", true)
.unwrap();
assert_eq!(table_resolver.base_path(), "/prod/sales/transactions/");
let file_path = table_resolver
.resolve("2024/01/data.parquet", true)
.unwrap();
assert_eq!(file_path, "/prod/sales/transactions/2024/01/data.parquet");
}
#[test]
fn test_hierarchical_path_with_absolute_override_s3() {
let catalog_resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap()),
"/warehouse/".to_string(),
);
let schema_resolver = catalog_resolver.child_resolver("prod/", true).unwrap();
assert_eq!(schema_resolver.base_path(), "/warehouse/prod/");
let table_resolver = schema_resolver
.child_resolver("/external/data/", false)
.unwrap();
assert_eq!(table_resolver.base_path(), "/external/data/");
let file_path = table_resolver.resolve("file.parquet", true).unwrap();
assert_eq!(file_path, "/external/data/file.parquet");
}
#[test]
fn test_parse_s3_url_minimal_bucket() {
let (url, path) = parse_object_store_url("s3://b").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://b/").unwrap());
assert_eq!(path, "");
}
#[test]
fn test_parse_s3_url_with_query_params() {
let (url, path) = parse_object_store_url("s3://bucket/path/data").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
assert_eq!(path, "/path/data");
}
#[test]
fn test_file_url_windows_style_path() {
let (url, path) = parse_object_store_url("file:///C:/Users/data").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
assert_eq!(path, "/C:/Users/data");
}
#[test]
fn test_path_resolution_with_dots() {
let resolved = resolve_path("/data/", "schema.v2/table.prod/", true).unwrap();
assert_eq!(resolved, "/data/schema.v2/table.prod/");
}
#[test]
fn test_path_resolution_with_underscores() {
let resolved = resolve_path("/data/", "my_schema/my_table/", true).unwrap();
assert_eq!(resolved, "/data/my_schema/my_table/");
}
#[test]
fn test_join_paths_with_multiple_slashes_in_relative() {
let result = join_paths("/base/", "a/b/c/d/file.parquet").unwrap();
assert_eq!(result, "/base/a/b/c/d/file.parquet");
}
#[test]
fn test_join_paths_preserves_trailing_slash() {
let result = join_paths("/base/", "subdir/").unwrap();
assert_eq!(result, "/base/subdir/");
}
#[test]
fn test_real_world_s3_scenario() {
let (catalog_url, catalog_path) =
parse_object_store_url("s3://data-lake/warehouse/").unwrap();
assert_eq!(
catalog_url,
ObjectStoreUrl::parse("s3://data-lake/").unwrap()
);
assert_eq!(catalog_path, "/warehouse/");
let catalog_resolver = PathResolver::new(Arc::new(catalog_url), catalog_path);
let schema_resolver = catalog_resolver.child_resolver("prod/", true).unwrap();
assert_eq!(schema_resolver.base_path(), "/warehouse/prod/");
let table_resolver = schema_resolver
.child_resolver("sales/transactions/", true)
.unwrap();
assert_eq!(
table_resolver.base_path(),
"/warehouse/prod/sales/transactions/"
);
let file_path = table_resolver.resolve("2024-01-01.parquet", true).unwrap();
assert_eq!(
file_path,
"/warehouse/prod/sales/transactions/2024-01-01.parquet"
);
}
#[test]
fn test_real_world_mixed_absolute_paths() {
let catalog_resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://catalog-db/").unwrap()),
"/metadata/".to_string(),
);
let schema_resolver = catalog_resolver
.child_resolver("/schemas/prod/", false)
.unwrap();
assert_eq!(schema_resolver.base_path(), "/schemas/prod/");
let table_resolver = schema_resolver.child_resolver("customers/", true).unwrap();
assert_eq!(table_resolver.base_path(), "/schemas/prod/customers/");
let file_path = table_resolver.resolve("data.parquet", true).unwrap();
assert_eq!(file_path, "/schemas/prod/customers/data.parquet");
}
#[test]
fn test_s3_url_uppercase_scheme() {
let (url, path) = parse_object_store_url("S3://bucket/path").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
assert_eq!(path, "/path");
}
#[test]
fn test_s3_url_mixed_case_scheme() {
let (url, path) = parse_object_store_url("s3://bucket/data").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
assert_eq!(path, "/data");
}
#[test]
fn test_file_url_uppercase_scheme() {
let (url, path) = parse_object_store_url("FILE:///tmp/data").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
assert_eq!(path, "/tmp/data");
}
#[test]
fn test_file_url_mixed_case_scheme() {
let (url, path) = parse_object_store_url("File:///tmp/data").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
assert_eq!(path, "/tmp/data");
}
#[test]
fn test_whitespace_trimming_s3() {
let (url, path) = parse_object_store_url(" s3://bucket/data ").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
assert_eq!(path, "/data");
}
#[test]
fn test_whitespace_trimming_file() {
let (url, path) = parse_object_store_url(" file:///tmp/data ").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
assert_eq!(path, "/tmp/data");
}
#[test]
fn test_file_url_non_ascii_preserved() {
let (url, path) = parse_object_store_url("file:///tmp/données/données").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
assert_eq!(path, "/tmp/données/données");
}
#[test]
fn test_file_url_unicode_preserved() {
let (url, path) = parse_object_store_url("file:///home/用户/数据").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
assert_eq!(path, "/home/用户/数据");
}
#[test]
fn test_file_url_spaces_preserved() {
let (url, path) = parse_object_store_url("file:///tmp/my data/file").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("file:///").unwrap());
assert_eq!(path, "/tmp/my data/file");
}
#[test]
fn test_whitespace_trimming_with_uppercase_scheme() {
let (url, path) = parse_object_store_url(" S3://bucket/data ").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket/").unwrap());
assert_eq!(path, "/data");
}
#[test]
fn test_edge_case_empty_base_path() {
let resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap()),
"".to_string(),
);
let resolved = resolver.resolve("path/to/file", true).unwrap();
assert_eq!(resolved, "/path/to/file");
}
#[test]
fn test_edge_case_root_base_path() {
let resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap()),
"/".to_string(),
);
let resolved = resolver.resolve("file.parquet", true).unwrap();
assert_eq!(resolved, "/file.parquet");
}
#[test]
fn test_path_with_consecutive_slashes() {
let resolved = resolve_path("/data/", "schema//table/", true).unwrap();
assert_eq!(resolved, "/data/schema//table/");
}
#[test]
fn test_s3_bucket_naming_conventions() {
let (url, _) = parse_object_store_url("s3://bucket123/data").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://bucket123/").unwrap());
let (url, _) = parse_object_store_url("s3://my-bucket-name/data").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://my-bucket-name/").unwrap());
let (url, _) = parse_object_store_url("s3://my.bucket.name/data").unwrap();
assert_eq!(url, ObjectStoreUrl::parse("s3://my.bucket.name/").unwrap());
}
#[test]
fn test_file_url_relative_path_not_supported() {
let result = parse_object_store_url("file://relative/path");
assert!(result.is_ok());
}
#[test]
fn test_complex_hierarchical_scenario() {
let catalog_resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://prod-data-lake/").unwrap()),
"/".to_string(),
);
let schema1 = catalog_resolver.child_resolver("warehouse/", true).unwrap();
assert_eq!(schema1.base_path(), "/warehouse/");
let table1 = schema1.child_resolver("sales/", true).unwrap();
assert_eq!(table1.base_path(), "/warehouse/sales/");
let partition1 = table1.child_resolver("year=2024/", true).unwrap();
assert_eq!(partition1.base_path(), "/warehouse/sales/year=2024/");
let partition2 = partition1.child_resolver("month=01/", true).unwrap();
assert_eq!(
partition2.base_path(),
"/warehouse/sales/year=2024/month=01/"
);
let file = partition2.resolve("data.parquet", true).unwrap();
assert_eq!(file, "/warehouse/sales/year=2024/month=01/data.parquet");
assert_eq!(
*partition2.base_url(),
Arc::new(ObjectStoreUrl::parse("s3://prod-data-lake/").unwrap())
);
}
#[test]
fn test_reject_literal_null_byte_in_resolve_path() {
let result = resolve_path("/data/", "table\0evil", true);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("null byte"), "error was: {}", err);
}
#[test]
fn test_reject_url_encoded_null_byte_in_resolve_path() {
let result = resolve_path("/data/", "table%00evil", true);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("null byte"), "error was: {}", err);
}
#[test]
fn test_reject_null_byte_case_insensitive() {
let result = resolve_path("/data/", "table%2e%2e/%00evil", true);
assert!(result.is_err());
}
#[test]
fn test_reject_null_byte_in_parse_object_store_url() {
let result = parse_object_store_url("s3://bucket/path\0evil");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("null byte"), "error was: {}", err);
}
#[test]
fn test_reject_url_encoded_null_in_parse_object_store_url() {
let result = parse_object_store_url("s3://bucket/path%00evil");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("null byte"), "error was: {}", err);
}
#[test]
fn test_reject_null_byte_absolute_path() {
let result = resolve_path("/data/", "/abs/path\0evil", false);
assert!(result.is_err());
}
#[test]
fn test_reject_dotdot_in_relative_path() {
let result = resolve_path("/data/", "../etc/passwd", true);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Path traversal"), "error was: {}", err);
}
#[test]
fn test_reject_dotdot_mid_path() {
let result = resolve_path("/data/", "table/../../../etc/passwd", true);
assert!(result.is_err());
}
#[test]
fn test_reject_dotdot_in_absolute_path() {
let result = resolve_path("/data/", "/tmp/../etc/passwd", false);
assert!(result.is_err());
}
#[test]
fn test_reject_backslash_dotdot() {
let result = resolve_path("/data/", "table\\..\\secret", true);
assert!(result.is_err());
}
#[test]
fn test_reject_url_encoded_dotdot() {
let result = resolve_path("/data/", "table/%2e%2e/secret", true);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Path traversal"), "error was: {}", err);
}
#[test]
fn test_reject_mixed_case_url_encoded_dotdot() {
let result = resolve_path("/data/", "table/%2E%2E/secret", true);
assert!(result.is_err());
}
#[test]
fn test_reject_dotdot_in_join_paths() {
let result = join_paths("/data/", "../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_reject_dotdot_in_parse_object_store_url() {
let result = parse_object_store_url("s3://bucket/../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_reject_dotdot_in_child_resolver() {
let resolver = PathResolver::new(
Arc::new(ObjectStoreUrl::parse("s3://bucket/").unwrap()),
"/data/".to_string(),
);
let result = resolver.child_resolver("../secret/", true);
assert!(result.is_err());
}
#[test]
fn test_reject_dotdot_percent_encoded_slash() {
let result = resolve_path("/data/", "..%2f..%2fetc", true);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Path traversal"), "error was: {}", err);
}
#[test]
fn test_reject_dotdot_percent_encoded_slash_uppercase() {
let result = resolve_path("/data/", "..%2F..%2Fetc", true);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Path traversal"), "error was: {}", err);
}
#[test]
fn test_reject_fully_encoded_dotdot_slash() {
let result = resolve_path("/data/", "%2e%2e%2fetc/passwd", true);
assert!(result.is_err());
}
#[test]
fn test_reject_mixed_encoded_dotdot_slash() {
let result = resolve_path("/data/", "sub/..%2fsecret", true);
assert!(result.is_err());
}
#[test]
fn test_allow_single_dot_in_path() {
let result = resolve_path("/data/", "file.parquet", true);
assert!(result.is_ok());
}
#[test]
fn test_allow_dotfile() {
let result = resolve_path("/data/", ".hidden/file", true);
assert!(result.is_ok());
}
#[test]
fn test_allow_multiple_dots_in_filename() {
let result = resolve_path("/data/", "file.backup.2024.parquet", true);
assert!(result.is_ok());
}
#[test]
fn test_allow_version_dots() {
let result = resolve_path("/data/", "schema.v2/table.prod/", true);
assert!(result.is_ok());
}
#[test]
fn test_allow_percent_in_path() {
let result = resolve_path("/data/", "path%20with%20spaces/file", true);
assert!(result.is_ok());
}
#[test]
fn test_allow_normal_s3_paths() {
let result = parse_object_store_url("s3://bucket/warehouse/prod/data");
assert!(result.is_ok());
}
}