use url::{ParseError, Url};
use crate::path_encoding::uri_encode_path;
use crate::utils::require;
use crate::{KernelError, KernelResult};
pub(crate) fn resolve_amt_location(path: &str, table_root: &Url) -> KernelResult<Url> {
match scheme_len(path) {
Some(scheme_len) => {
reject_dot_segments(path)?;
let (prefix, raw_path) = split_authority(path, scheme_len);
uri_with_encoded_path(prefix, raw_path).map_err(|e| {
KernelError::generic(format!(
"Failed to parse absolute AMT location {path:?}: {e}"
))
})
}
None => encode_and_join(path, table_root),
}
}
pub(crate) fn resolve_table_relative(path: &str, table_root: &Url) -> KernelResult<Url> {
validate_table_relative(path)?;
encode_and_join(path, table_root)
}
pub(crate) fn validate_table_relative(path: &str) -> KernelResult<()> {
require!(
!path.is_empty(),
KernelError::generic("table-relative path must not be empty")
);
require!(
!path.starts_with('/'),
KernelError::generic(format!(
"table-relative path must not begin with a leading '/': {path}"
))
);
require!(
scheme_len(path).is_none(),
KernelError::generic(format!(
"table-relative path must not be an absolute URL: {path}"
))
);
Ok(())
}
fn scheme_len(location: &str) -> Option<usize> {
for (position, ch) in location.char_indices() {
if ch == ':' {
return (position > 0).then_some(position);
}
if !is_scheme_char(ch, position) {
return None;
}
}
None
}
fn is_scheme_char(ch: char, position: usize) -> bool {
if ch.is_ascii_alphabetic() {
return true;
}
position > 0 && (ch.is_ascii_digit() || ch == '+' || ch == '-' || ch == '.')
}
fn encode_and_join(path: &str, table_root: &Url) -> KernelResult<Url> {
reject_dot_segments(path)?;
let mut base = table_root.as_str().to_string();
if !base.ends_with('/') {
base.push('/');
}
uri_with_encoded_path(&base, path).map_err(|e| {
KernelError::generic(format!(
"Failed to resolve relative AMT location {path:?} against table root {base}: {e}"
))
})
}
fn split_authority(location: &str, scheme_len: usize) -> (&str, &str) {
let after_scheme = scheme_len + 1;
let prefix_len = match location[after_scheme..].strip_prefix("//") {
Some(rest) => after_scheme + 2 + rest.find('/').unwrap_or(rest.len()),
None => after_scheme,
};
location.split_at(prefix_len)
}
fn uri_with_encoded_path(prefix: &str, raw_path: &str) -> Result<Url, ParseError> {
Url::parse(&format!("{prefix}{}", uri_encode_path(raw_path)))
}
fn reject_dot_segments(path: &str) -> KernelResult<()> {
require!(
!path
.split('/')
.any(|segment| segment == "." || segment == ".."),
KernelError::generic(format!(
"AMT path {path:?} contains a '.' or '..' segment, which is not supported"
))
);
Ok(())
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use test_utils::assert_result_error_with_message;
use super::*;
#[rstest]
#[case::relative_path(
"memory:///table/",
"metadata/root.parquet",
"memory:///table/metadata/root.parquet"
)]
#[case::absolute_path(
"memory:///table/",
"s3://bucket/table/metadata/root.parquet",
"s3://bucket/table/metadata/root.parquet"
)]
#[case::table_root_without_trailing_slash_gets_one(
"memory:///table",
"metadata/root.parquet",
"memory:///table/metadata/root.parquet"
)]
#[case::single_char_scheme_treated_as_absolute(
"memory:///table/",
"c:/foo/root.parquet",
"c:/foo/root.parquet"
)]
#[case::colon_in_relative_segment_stays_relative(
"memory:///table/",
"metadata/snap-123:456.parquet",
"memory:///table/metadata/snap-123:456.parquet"
)]
#[case::leading_digit_scheme_treated_as_relative(
"memory:///table/",
"3com/root.parquet",
"memory:///table/3com/root.parquet"
)]
#[case::non_ascii_scheme_treated_as_relative(
"memory:///table/",
"\u{03b1}scheme/root.parquet",
"memory:///table/%CE%B1scheme/root.parquet"
)]
#[case::compound_scheme_treated_as_absolute(
"memory:///table/",
"git+ssh://host/repo/root.parquet",
"git+ssh://host/repo/root.parquet"
)]
#[case::space_is_encoded(
"memory:///table/",
"metadata/leaf a.parquet",
"memory:///table/metadata/leaf%20a.parquet"
)]
#[case::percent_is_encoded(
"memory:///table/",
"data/test%dv.bin",
"memory:///table/data/test%25dv.bin"
)]
#[case::hash_is_encoded("memory:///table/", "data/a#b.bin", "memory:///table/data/a%23b.bin")]
#[case::question_is_encoded(
"memory:///table/",
"data/a?b.bin",
"memory:///table/data/a%3Fb.bin"
)]
#[case::non_ascii_is_encoded(
"memory:///table/",
"data/M\u{fc}nchen.bin",
"memory:///table/data/M%C3%BCnchen.bin"
)]
#[case::relative_percent_escape_is_literal(
"memory:///table/",
"data/a%20b.bin",
"memory:///table/data/a%2520b.bin"
)]
#[case::dots_within_segment_name(
"memory:///table/",
"a/..b/.c.bin",
"memory:///table/a/..b/.c.bin"
)]
#[case::absolute_hash_is_encoded(
"memory:///table/",
"s3://bucket/data/a#b.parquet",
"s3://bucket/data/a%23b.parquet"
)]
#[case::absolute_question_and_space_are_encoded(
"memory:///table/",
"s3://bucket/data/a b?c.parquet",
"s3://bucket/data/a%20b%3Fc.parquet"
)]
#[case::absolute_percent_escape_is_literal(
"memory:///table/",
"s3://bucket/data/a%20b.parquet",
"s3://bucket/data/a%2520b.parquet"
)]
fn test_resolve_amt_location(
#[case] table_root: &str,
#[case] path: &str,
#[case] expected_location: &str,
) {
let table_root = Url::parse(table_root).unwrap();
let location = resolve_amt_location(path, &table_root).unwrap();
assert_eq!(location.as_str(), expected_location);
}
#[rstest]
#[case::relative_dot_dot("a/../b.bin")]
#[case::relative_leading_dot("./a.bin")]
#[case::relative_trailing_dot("a/.")]
#[case::bare_dot_dot("..")]
#[case::absolute_dot_dot("s3://bucket/x/../y.parquet")]
fn test_resolve_amt_location_rejects_dot_segments(#[case] path: &str) {
let table_root = Url::parse("memory:///table/").unwrap();
assert_result_error_with_message(
resolve_amt_location(path, &table_root),
"contains a '.' or '..' segment",
);
}
}