use std::sync::Arc;
use object_store::{DynObjectStore, GetRange};
use url::Url;
use crate::error::ProxyResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyVerb {
Get,
Head,
Put,
}
impl ProxyVerb {
pub fn is_write(self) -> bool {
matches!(self, ProxyVerb::Put)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Securable {
Table {
full_name: String,
},
Volume {
full_name: String,
},
Path {
url: Url,
},
}
impl Securable {
pub fn parse(segment: &str) -> ProxyResult<Securable> {
use crate::error::ProxyError;
let (kind, ident) = segment.split_once(':').ok_or_else(|| {
ProxyError::InvalidArgument(format!(
"securable must be `<kind>:<ident>` (got `{segment}`)"
))
})?;
if ident.is_empty() {
return Err(ProxyError::InvalidArgument(format!(
"securable `{kind}:` has an empty identifier"
)));
}
match kind {
"table" => Ok(Securable::Table {
full_name: ident.to_string(),
}),
"vol" => Ok(Securable::Volume {
full_name: ident.to_string(),
}),
"path" => {
let url = Url::parse(ident).map_err(|e| {
ProxyError::InvalidArgument(format!("path securable is not a valid URL: {e}"))
})?;
Ok(Securable::Path { url })
}
other => Err(ProxyError::InvalidArgument(format!(
"unknown securable kind `{other}` (expected table|vol|path)"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct ProxyReq {
pub verb: ProxyVerb,
pub securable: Securable,
pub key: String,
pub range: Option<GetRange>,
pub if_match: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProxyCapabilities {
pub enabled: bool,
}
pub trait ForwardedUser {
fn forwarded_user(&self) -> Option<&str>;
}
impl ForwardedUser for () {
fn forwarded_user(&self) -> Option<&str> {
None
}
}
#[async_trait::async_trait]
pub trait StorageProxyBackend<Cx = ()>: Send + Sync + 'static {
fn capabilities(&self) -> ProxyCapabilities {
ProxyCapabilities::default()
}
async fn authorize(&self, req: &ProxyReq, cx: &Cx) -> ProxyResult<()>;
async fn open(&self, req: &ProxyReq, cx: &Cx) -> ProxyResult<Arc<DynObjectStore>>;
}
pub fn reject_key_traversal(key: &str) -> ProxyResult<()> {
use crate::error::ProxyError;
if key.starts_with('/') {
return Err(ProxyError::OutOfScope("absolute key not allowed".into()));
}
if key.contains('\\') || key.contains('\0') {
return Err(ProxyError::OutOfScope(
"key contains an illegal character".into(),
));
}
for segment in key.split('/') {
if segment == ".." {
return Err(ProxyError::OutOfScope(
"key contains a `..` path traversal".into(),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_table_and_volume() {
assert_eq!(
Securable::parse("table:main.default.events").unwrap(),
Securable::Table {
full_name: "main.default.events".into()
}
);
assert_eq!(
Securable::parse("vol:main.default.landing").unwrap(),
Securable::Volume {
full_name: "main.default.landing".into()
}
);
}
#[test]
fn parse_path_url() {
let s = Securable::parse("path:s3://bucket/prefix/").unwrap();
match s {
Securable::Path { url } => assert_eq!(url.as_str(), "s3://bucket/prefix/"),
other => panic!("expected Path, got {other:?}"),
}
}
#[test]
fn parse_rejects_unknown_kind_and_empty_and_bad_url() {
assert!(Securable::parse("bogus:x").is_err());
assert!(Securable::parse("table:").is_err());
assert!(Securable::parse("no-colon").is_err());
assert!(Securable::parse("path:not a url").is_err());
}
#[test]
fn traversal_guard() {
assert!(reject_key_traversal("a/b/c.parquet").is_ok());
assert!(reject_key_traversal("_delta_log/00000000000000000001.json").is_ok());
assert!(reject_key_traversal("a/../../etc/passwd").is_err());
assert!(reject_key_traversal("/absolute").is_err());
assert!(reject_key_traversal("a\\b").is_err());
assert!(reject_key_traversal("../sibling").is_err());
assert!(reject_key_traversal("..").is_err());
assert!(reject_key_traversal("a/./b").is_ok());
assert!(reject_key_traversal("a//b").is_ok());
}
}