delta-arrow-reader 0.4.2

Read-only Delta Lake to Apache Arrow reader
Documentation
//! Delta table location normalization.

use super::kernel::parse_table_location;
use crate::{DeltaReaderError, error::InvalidTableLocationSnafu};

/// Normalizes a Delta table path or URL for snapshot loading.
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!(
            fs::canonicalize(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(&regular_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"),
            (&regular_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(())
    }
}