use multistore::api::list_rewrite::ListRewrite;
use multistore::registry::{BucketRegistry, ResolvedBucket};
#[derive(Debug, Clone)]
pub struct PathMapping {
pub bucket_segments: usize,
pub bucket_separator: String,
pub display_bucket_segments: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MappedPath {
pub bucket: String,
pub key: Option<String>,
pub display_bucket: String,
pub key_prefix: String,
pub segments: Vec<String>,
}
impl PathMapping {
pub fn parse(&self, path: &str) -> Option<MappedPath> {
let trimmed = path.strip_prefix('/').unwrap_or(path);
if trimmed.is_empty() {
return None;
}
let parts: Vec<&str> = trimmed.splitn(self.bucket_segments + 1, '/').collect();
if parts.len() < self.bucket_segments {
return None;
}
for part in &parts[..self.bucket_segments] {
if part.is_empty() {
return None;
}
}
let segments: Vec<String> = parts[..self.bucket_segments]
.iter()
.map(|s| s.to_string())
.collect();
let bucket = segments.join(&self.bucket_separator);
let key = if parts.len() > self.bucket_segments {
let k = parts[self.bucket_segments];
if k.is_empty() {
None
} else {
Some(k.to_string())
}
} else {
None
};
let display_bucket = segments[..self.display_bucket_segments].join("/");
let key_prefix = if self.display_bucket_segments < self.bucket_segments {
let prefix_parts = &segments[self.display_bucket_segments..self.bucket_segments];
format!("{}/", prefix_parts.join("/"))
} else {
String::new()
};
Some(MappedPath {
bucket,
key,
display_bucket,
key_prefix,
segments,
})
}
pub fn parse_bucket_name(&self, bucket_name: &str) -> Option<MappedPath> {
let segments: Vec<String> = bucket_name
.split(&self.bucket_separator)
.map(|s| s.to_string())
.collect();
if segments.len() != self.bucket_segments {
return None;
}
for seg in &segments {
if seg.is_empty() {
return None;
}
}
let display_bucket = segments[..self.display_bucket_segments].join("/");
let key_prefix = if self.display_bucket_segments < self.bucket_segments {
let prefix_parts = &segments[self.display_bucket_segments..self.bucket_segments];
format!("{}/", prefix_parts.join("/"))
} else {
String::new()
};
Some(MappedPath {
bucket: bucket_name.to_string(),
key: None,
display_bucket,
key_prefix,
segments,
})
}
pub fn rewrite_request(&self, path: &str, query: Option<&str>) -> (String, Option<String>) {
if let Some(mapped) = self.parse(path) {
let rewritten_path = match mapped.key {
Some(ref key) => format!("/{}/{}", mapped.bucket, key),
None => format!("/{}", mapped.bucket),
};
return (rewritten_path, query.map(|q| q.to_string()));
}
let trimmed = path.trim_matches('/');
if !trimmed.is_empty() && !trimmed.contains('/') {
let query_str = query.unwrap_or("");
if is_list_request(query_str) {
if let Some(prefix) = extract_query_param(query_str, "prefix") {
if !prefix.is_empty() {
return self.rewrite_prefix_to_bucket(trimmed, &prefix, query_str);
}
}
}
}
(path.to_string(), query.map(|q| q.to_string()))
}
fn rewrite_prefix_to_bucket(
&self,
account: &str,
prefix: &str,
query_str: &str,
) -> (String, Option<String>) {
let (product, remaining_prefix) = if let Some(slash_pos) = prefix.find('/') {
(&prefix[..slash_pos], &prefix[slash_pos + 1..])
} else {
(prefix, "")
};
let bucket = format!("{}{}{}", account, self.bucket_separator, product);
let new_query = rewrite_prefix_in_query(query_str, remaining_prefix);
(format!("/{}", bucket), Some(new_query))
}
}
fn is_list_request(query: &str) -> bool {
query.split('&').any(|p| p.starts_with("list-type="))
}
fn extract_query_param(query: &str, key: &str) -> Option<String> {
query.split('&').find_map(|pair| {
pair.split_once('=')
.filter(|(k, _)| *k == key)
.map(|(_, v)| {
percent_encoding::percent_decode_str(v)
.decode_utf8_lossy()
.into_owned()
})
})
}
const QUERY_VALUE_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
.add(b' ')
.add(b'#')
.add(b'&')
.add(b'=')
.add(b'+');
fn rewrite_prefix_in_query(query: &str, new_prefix: &str) -> String {
let encoded: String =
percent_encoding::utf8_percent_encode(new_prefix, QUERY_VALUE_ENCODE).to_string();
query
.split('&')
.map(|pair| {
if pair.starts_with("prefix=") {
format!("prefix={}", encoded)
} else {
pair.to_string()
}
})
.collect::<Vec<_>>()
.join("&")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_list_request_detects_list_type() {
assert!(is_list_request("list-type=2"));
assert!(is_list_request("foo=bar&list-type=2&baz=qux"));
assert!(!is_list_request("foo=bar"));
assert!(!is_list_request(""));
}
#[test]
fn is_list_request_rejects_substring_match() {
assert!(!is_list_request("not-list-type=2"));
assert!(!is_list_request("foo=bar¬-list-type=2"));
}
#[test]
fn extract_query_param_finds_value() {
assert_eq!(
extract_query_param("list-type=2&prefix=foo/", "prefix"),
Some("foo/".to_string())
);
}
#[test]
fn extract_query_param_missing() {
assert_eq!(extract_query_param("list-type=2", "prefix"), None);
}
#[test]
fn extract_query_param_decodes_percent() {
assert_eq!(
extract_query_param("prefix=hello%20world", "prefix"),
Some("hello world".to_string())
);
}
#[test]
fn rewrite_prefix_replaces_value() {
assert_eq!(
rewrite_prefix_in_query("list-type=2&prefix=old/", "new/"),
"list-type=2&prefix=new/"
);
}
#[test]
fn rewrite_prefix_to_empty() {
assert_eq!(
rewrite_prefix_in_query("prefix=old/&max-keys=100", ""),
"prefix=&max-keys=100"
);
}
#[test]
fn rewrite_prefix_encodes_special_chars() {
assert_eq!(
rewrite_prefix_in_query("list-type=2&prefix=old/", "sub dir/"),
"list-type=2&prefix=sub%20dir/"
);
}
}
#[derive(Debug, Clone)]
pub struct MappedRegistry<R> {
inner: R,
mapping: PathMapping,
}
impl<R> MappedRegistry<R> {
pub fn new(inner: R, mapping: PathMapping) -> Self {
Self { inner, mapping }
}
}
impl<R: BucketRegistry> BucketRegistry for MappedRegistry<R> {
async fn get_bucket(
&self,
name: &str,
identity: &multistore::types::ResolvedIdentity,
operation: &multistore::types::S3Operation,
) -> Result<ResolvedBucket, multistore::error::ProxyError> {
let mapped = self.mapping.parse_bucket_name(name);
let mut resolved = self.inner.get_bucket(name, identity, operation).await?;
if let Some(mapped) = mapped {
tracing::debug!(
bucket = %name,
display_name = %mapped.display_bucket,
key_prefix = %mapped.key_prefix,
"Applying path mapping to resolved bucket"
);
resolved.display_name = Some(mapped.display_bucket);
if !mapped.key_prefix.is_empty() {
resolved.list_rewrite = Some(ListRewrite {
strip_prefix: String::new(),
add_prefix: mapped.key_prefix,
});
}
}
Ok(resolved)
}
async fn list_buckets(
&self,
identity: &multistore::types::ResolvedIdentity,
) -> Result<Vec<multistore::api::response::BucketEntry>, multistore::error::ProxyError> {
self.inner.list_buckets(identity).await
}
fn bucket_owner(&self) -> multistore::types::BucketOwner {
self.inner.bucket_owner()
}
}