Skip to main content

multistore_path_mapping/
lib.rs

1//! Hierarchical path mapping for the multistore S3 proxy gateway.
2//!
3//! This crate provides [`PathMapping`] for translating hierarchical URL paths
4//! (e.g., `/{account}/{product}/{key}`) into flat internal bucket names
5//! (e.g., `account--product`), and [`MappedRegistry`] for wrapping a
6//! [`BucketRegistry`] so that path-based routing and list rewrite rules are
7//! applied automatically.
8
9use multistore::api::list_rewrite::ListRewrite;
10use multistore::registry::{BucketRegistry, ResolvedBucket};
11
12/// Defines how URL path segments map to internal bucket names.
13#[derive(Debug, Clone)]
14pub struct PathMapping {
15    /// Number of path segments that form the "bucket" portion.
16    /// E.g., 2 for `/{account}/{product}/...`
17    pub bucket_segments: usize,
18
19    /// Separator to join segments into an internal bucket name.
20    /// E.g., "--" produces `account--product`.
21    pub bucket_separator: String,
22
23    /// How many leading segments form the "display bucket" name for XML responses.
24    /// E.g., 1 means `<Name>` shows just `account`.
25    pub display_bucket_segments: usize,
26}
27
28/// Result of mapping a request path.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct MappedPath {
31    /// Internal bucket name (e.g., "account--product")
32    pub bucket: String,
33    /// Remaining key after bucket segments (e.g., "file.parquet")
34    pub key: Option<String>,
35    /// Display bucket name for XML responses (e.g., "account")
36    pub display_bucket: String,
37    /// Key prefix to add in XML responses (e.g., "product/")
38    pub key_prefix: String,
39    /// The individual path segments that formed the bucket
40    pub segments: Vec<String>,
41}
42
43impl PathMapping {
44    /// Parse a URL path into a `MappedPath`.
45    ///
46    /// The path is expected to start with `/`. Segments are split on `/`,
47    /// and the first `bucket_segments` segments form the internal bucket name.
48    /// Any remaining content becomes the key.
49    ///
50    /// Returns `None` if there are fewer than `bucket_segments` non-empty segments.
51    pub fn parse(&self, path: &str) -> Option<MappedPath> {
52        let trimmed = path.strip_prefix('/').unwrap_or(path);
53        if trimmed.is_empty() {
54            return None;
55        }
56
57        // Split into at most bucket_segments + 1 parts so the key portion
58        // preserves any internal `/` characters.
59        let parts: Vec<&str> = trimmed.splitn(self.bucket_segments + 1, '/').collect();
60
61        if parts.len() < self.bucket_segments {
62            return None;
63        }
64
65        // Verify none of the bucket segments are empty.
66        for part in &parts[..self.bucket_segments] {
67            if part.is_empty() {
68                return None;
69            }
70        }
71
72        let segments: Vec<String> = parts[..self.bucket_segments]
73            .iter()
74            .map(|s| s.to_string())
75            .collect();
76
77        let bucket = segments.join(&self.bucket_separator);
78
79        let key = if parts.len() > self.bucket_segments {
80            let k = parts[self.bucket_segments];
81            if k.is_empty() {
82                None
83            } else {
84                Some(k.to_string())
85            }
86        } else {
87            None
88        };
89
90        let display_bucket = segments[..self.display_bucket_segments].join("/");
91
92        let key_prefix = if self.display_bucket_segments < self.bucket_segments {
93            let prefix_parts = &segments[self.display_bucket_segments..self.bucket_segments];
94            format!("{}/", prefix_parts.join("/"))
95        } else {
96            String::new()
97        };
98
99        Some(MappedPath {
100            bucket,
101            key,
102            display_bucket,
103            key_prefix,
104            segments,
105        })
106    }
107
108    /// Parse a bucket name (e.g., "account--product") back into a `MappedPath`.
109    ///
110    /// Used by `MappedRegistry` when it receives an already-mapped bucket name.
111    /// Returns `None` if the bucket name does not split into exactly `bucket_segments` parts.
112    pub fn parse_bucket_name(&self, bucket_name: &str) -> Option<MappedPath> {
113        let segments: Vec<String> = bucket_name
114            .split(&self.bucket_separator)
115            .map(|s| s.to_string())
116            .collect();
117
118        if segments.len() != self.bucket_segments {
119            return None;
120        }
121
122        // Verify none of the segments are empty.
123        for seg in &segments {
124            if seg.is_empty() {
125                return None;
126            }
127        }
128
129        let display_bucket = segments[..self.display_bucket_segments].join("/");
130
131        let key_prefix = if self.display_bucket_segments < self.bucket_segments {
132            let prefix_parts = &segments[self.display_bucket_segments..self.bucket_segments];
133            format!("{}/", prefix_parts.join("/"))
134        } else {
135            String::new()
136        };
137
138        Some(MappedPath {
139            bucket: bucket_name.to_string(),
140            key: None,
141            display_bucket,
142            key_prefix,
143            segments,
144        })
145    }
146}
147
148/// Wraps a `BucketRegistry` to add path-based routing.
149///
150/// When `get_bucket` is called, the bucket name is parsed via
151/// `PathMapping::parse_bucket_name` and the resulting `ListRewrite`
152/// and `display_name` are applied to the resolved bucket.
153#[derive(Debug, Clone)]
154pub struct MappedRegistry<R> {
155    inner: R,
156    mapping: PathMapping,
157}
158
159impl<R> MappedRegistry<R> {
160    /// Create a new `MappedRegistry` wrapping the given registry with a path mapping.
161    pub fn new(inner: R, mapping: PathMapping) -> Self {
162        Self { inner, mapping }
163    }
164}
165
166impl<R: BucketRegistry> BucketRegistry for MappedRegistry<R> {
167    async fn get_bucket(
168        &self,
169        name: &str,
170        identity: &multistore::types::ResolvedIdentity,
171        operation: &multistore::types::S3Operation,
172    ) -> Result<ResolvedBucket, multistore::error::ProxyError> {
173        let mapped = self.mapping.parse_bucket_name(name);
174
175        let mut resolved = self.inner.get_bucket(name, identity, operation).await?;
176
177        if let Some(mapped) = mapped {
178            tracing::debug!(
179                bucket = %name,
180                display_name = %mapped.display_bucket,
181                key_prefix = %mapped.key_prefix,
182                "Applying path mapping to resolved bucket"
183            );
184
185            resolved.display_name = Some(mapped.display_bucket);
186
187            if !mapped.key_prefix.is_empty() {
188                resolved.list_rewrite = Some(ListRewrite {
189                    strip_prefix: String::new(),
190                    add_prefix: mapped.key_prefix,
191                });
192            }
193        }
194
195        Ok(resolved)
196    }
197
198    async fn list_buckets(
199        &self,
200        identity: &multistore::types::ResolvedIdentity,
201    ) -> Result<Vec<multistore::api::response::BucketEntry>, multistore::error::ProxyError> {
202        self.inner.list_buckets(identity).await
203    }
204
205    fn bucket_owner(&self) -> multistore::types::BucketOwner {
206        self.inner.bucket_owner()
207    }
208}