use std::path::PathBuf;
use super::error::DrainError;
use super::pipeline::hex_digest;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DestinationScheme {
S3,
File,
Gs,
Az,
}
impl DestinationScheme {
fn parse(scheme: &str) -> Option<Self> {
match scheme {
"s3" => Some(Self::S3),
"file" => Some(Self::File),
"gs" => Some(Self::Gs),
"az" => Some(Self::Az),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DestinationUri {
S3 {
bucket: String,
prefix: String,
region: Option<String>,
profile: Option<String>,
role_arn: Option<String>,
},
File {
path: PathBuf,
},
}
impl DestinationUri {
pub fn parse(uri: &str) -> Result<Self, DrainError> {
let trimmed = uri.trim();
let Some((scheme, rest)) = trimmed.split_once("://") else {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "expected `<scheme>://…`, found no `://` separator".to_string(),
});
};
let scheme_lower = scheme.to_ascii_lowercase();
let Some(known) = DestinationScheme::parse(&scheme_lower) else {
return Err(DrainError::UnsupportedScheme {
scheme: scheme_lower,
uri: uri.to_string(),
});
};
match known {
DestinationScheme::S3 => Self::parse_s3(uri, rest),
DestinationScheme::File => Self::parse_file(uri, rest),
DestinationScheme::Gs | DestinationScheme::Az => Err(DrainError::UnsupportedScheme {
scheme: scheme_lower,
uri: uri.to_string(),
}),
}
}
fn parse_s3(uri: &str, rest: &str) -> Result<Self, DrainError> {
let (path_part, query) = match rest.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (rest, None),
};
let (bucket, prefix) = match path_part.split_once('/') {
Some((b, p)) => (b, p),
None => (path_part, ""),
};
if bucket.is_empty() {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "bucket name is empty — expected `s3://<bucket>[/<prefix>]`".to_string(),
});
}
let params = match query {
Some(q) => parse_s3_query(uri, q)?,
None => S3Params::default(),
};
Ok(Self::S3 {
bucket: bucket.to_string(),
prefix: normalise_prefix(prefix),
region: params.region,
profile: params.profile,
role_arn: params.role_arn,
})
}
fn parse_file(uri: &str, rest: &str) -> Result<Self, DrainError> {
if rest.contains('?') {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "`file://` takes no query parameters".to_string(),
});
}
if !rest.starts_with('/') {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "expected an absolute path — `file:///abs/path`, with three slashes"
.to_string(),
});
}
let path = rest.trim_end_matches('/');
if path.is_empty() {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "path is the filesystem root — refusing to drain into `/`".to_string(),
});
}
Ok(Self::File {
path: PathBuf::from(path),
})
}
pub fn prefix(&self) -> &str {
match self {
Self::S3 { prefix, .. } => prefix,
Self::File { .. } => "",
}
}
pub fn cache_namespace(&self) -> String {
let (scheme, canonical) = match self {
Self::S3 {
bucket,
prefix,
profile,
role_arn,
..
} => {
let identity = match (profile.as_deref(), role_arn.as_deref()) {
(None, None) => String::new(),
(p, r) => format!(
"#profile={}&role_arn={}",
p.unwrap_or_default(),
r.unwrap_or_default()
),
};
("s3", format!("s3://{bucket}/{prefix}{identity}"))
}
Self::File { path } => ("file", format!("file://{}", path.display())),
};
let digest = hex_digest(canonical.as_bytes());
format!("{scheme}-{}", &digest[..16])
}
pub fn scheme(&self) -> DestinationScheme {
match self {
Self::S3 { .. } => DestinationScheme::S3,
Self::File { .. } => DestinationScheme::File,
}
}
}
#[derive(Debug, Default)]
struct S3Params {
region: Option<String>,
profile: Option<String>,
role_arn: Option<String>,
}
fn parse_s3_query(uri: &str, query: &str) -> Result<S3Params, DrainError> {
let mut params = S3Params::default();
for pair in query.split('&').filter(|p| !p.is_empty()) {
let (key, value) = pair.split_once('=').ok_or_else(|| DrainError::Uri {
uri: uri.to_string(),
reason: format!("query parameter `{pair}` has no `=`"),
})?;
if value.is_empty() {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: format!("`{key}=` is empty"),
});
}
let slot = match key {
"region" => &mut params.region,
"profile" => &mut params.profile,
"role_arn" => &mut params.role_arn,
_ => {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: format!(
"unknown query parameter `{key}` — only `region`, `profile`, \
and `role_arn` are accepted"
),
});
}
};
if slot.is_some() {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: format!("query parameter `{key}` is given more than once"),
});
}
*slot = Some(value.to_string());
}
if params.region.is_none() && params.profile.is_none() && params.role_arn.is_none() {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "query string is present but empty".to_string(),
});
}
Ok(params)
}
fn normalise_prefix(prefix: &str) -> String {
prefix.trim_matches('/').to_string()
}