use super::kernel::parse_table_location;
use crate::{DeltaReaderError, error::InvalidTableLocationSnafu};
pub(crate) fn normalize_table_location(table_location: &str) -> Result<url::Url, DeltaReaderError> {
if table_location.trim().is_empty() {
return InvalidTableLocationSnafu {
reason: "empty_table_location",
}
.fail();
}
parse_table_location(table_location).map_err(|_| {
InvalidTableLocationSnafu {
reason: "invalid_table_location",
}
.build()
})
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use super::normalize_table_location;
use crate::DeltaReaderPhase;
struct TestDir(PathBuf);
impl TestDir {
fn absolute(name: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path = std::env::temp_dir().join(unique_name(name)?);
fs::create_dir_all(&path)?;
Ok(Self(path))
}
fn relative(name: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path = Path::new("target")
.join("delta-arrow-reader-location-tests")
.join(unique_name(name)?);
fs::create_dir_all(&path)?;
Ok(Self(path))
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn unique_name(name: &str) -> Result<String, Box<dyn std::error::Error>> {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(format!("{}-{name}-{nanos}", std::process::id()))
}
#[test]
fn normalizes_absolute_and_relative_local_paths() -> Result<(), Box<dyn std::error::Error>> {
let absolute = TestDir::absolute("absolute")?;
let relative = TestDir::relative("relative")?;
let absolute_uri = normalize_table_location(&absolute.0.to_string_lossy())?;
let relative_uri = normalize_table_location(&relative.0.to_string_lossy())?;
let relative_path = relative_uri
.to_file_path()
.map_err(|()| std::io::Error::other("expected a local file URI"))?;
assert!(absolute_uri.as_str().starts_with("file://"));
assert!(absolute_uri.as_str().ends_with('/'));
assert_eq!(relative_path, fs::canonicalize(&relative.0)?);
assert_eq!(
normalize_table_location(absolute_uri.as_str())?,
absolute_uri
);
Ok(())
}
#[test]
fn preserves_remote_url_semantics_without_opening_a_store()
-> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
normalize_table_location("s3://bucket/path/to/table")?.as_str(),
"s3://bucket/path/to/table/"
);
Ok(())
}
#[test]
fn rejects_empty_missing_and_hostile_locations_without_disclosure()
-> Result<(), Box<dyn std::error::Error>> {
let missing = std::env::temp_dir()
.join("sensitive-missing-table")
.join(unique_name("missing")?);
let parent = TestDir::absolute("regular-file")?;
let regular_file = parent.0.join("not-a-directory");
fs::write(®ular_file, "not a table")?;
for (table_location, expected_reason) in [
("", "empty_table_location"),
(" \t\n", "empty_table_location"),
(&missing.to_string_lossy(), "invalid_table_location"),
(®ular_file.to_string_lossy(), "invalid_table_location"),
(
"s3://secret-user:secret-password@[",
"invalid_table_location",
),
] {
let error =
normalize_table_location(table_location).expect_err("location should be rejected");
assert_eq!(error.code(), "invalid_table_location");
assert_eq!(error.phase(), DeltaReaderPhase::TableLocation);
assert!(error.to_string().contains(expected_reason));
assert!(!error.to_string().contains("secret"));
assert!(!format!("{error:?}").contains("secret"));
}
Ok(())
}
}