Skip to main content

multistore_path_mapping/
lib.rs

1//! Hierarchical path mapping for the multistore S3 proxy gateway.
2//!
3//! S3 uses a flat namespace: each bucket is an independent container resolved
4//! to a single backend. Some applications need a *hierarchical* URL scheme
5//! where multiple path segments determine which backend to use. For example,
6//! a data catalog might expose `/{account}/{product}/{key}` but store each
7//! account/product pair in its own backend bucket.
8//!
9//! This crate bridges those two worlds:
10//!
11//! - **[`PathMapping`]** defines *how many* leading URL segments form the
12//!   logical "bucket", what separator joins them into an internal name, and
13//!   how many segments appear as the display name in S3 XML responses.
14//!
15//! - **[`PathMapping::rewrite_request`]** rewrites an incoming `(path, query)`
16//!   pair so the gateway sees a single-segment bucket. It handles both
17//!   path-based routing (`/{a}/{b}/{key}` → `/{a:b}/{key}`) and query-based
18//!   prefix routing (`/{a}?prefix=b/sub/` → `/{a:b}?prefix=sub/`).
19//!
20//! - **[`MappedRegistry`]** wraps any [`BucketRegistry`] and automatically
21//!   applies display-name and list-rewrite rules so XML responses show the
22//!   original hierarchical names to clients.
23//!
24//! # Example
25//!
26//! ```rust
27//! use multistore_path_mapping::PathMapping;
28//!
29//! let mapping = PathMapping {
30//!     bucket_segments: 2,
31//!     bucket_separator: ":".into(),
32//!     display_bucket_segments: 1,
33//! };
34//!
35//! // Path-based: two segments become one internal bucket
36//! let mapped = mapping.parse("/acme/data/report.csv").unwrap();
37//! assert_eq!(mapped.bucket, "acme:data");
38//! assert_eq!(mapped.key, Some("report.csv".to_string()));
39//! assert_eq!(mapped.display_bucket, "acme");
40//!
41//! // Full request rewrite (path + query)
42//! let result = mapping.rewrite_request(
43//!     "/acme/data/report.csv",
44//!     None,
45//! );
46//! assert_eq!(result.path, "/acme:data/report.csv");
47//! assert_eq!(result.query, None);
48//! assert_eq!(result.signing_path, "/acme/data/report.csv");
49//!
50//! // Prefix-based list rewrite
51//! let result = mapping.rewrite_request(
52//!     "/acme",
53//!     Some("list-type=2&prefix=data/subdir/"),
54//! );
55//! assert_eq!(result.path, "/acme:data");
56//! assert_eq!(result.query, Some("list-type=2&prefix=subdir/".to_string()));
57//! assert_eq!(result.signing_query, Some("list-type=2&prefix=data/subdir/".to_string()));
58//! ```
59
60use multistore::api::list_rewrite::ListRewrite;
61use multistore::registry::{BucketRegistry, ResolvedBucket};
62
63/// Defines how URL path segments map to internal bucket names.
64#[derive(Debug, Clone)]
65pub struct PathMapping {
66    /// Number of path segments that form the "bucket" portion.
67    /// E.g., 2 for `/{account}/{product}/...`
68    pub bucket_segments: usize,
69
70    /// Separator to join segments into an internal bucket name.
71    /// E.g., ":" produces `account:product`.
72    pub bucket_separator: String,
73
74    /// How many leading segments form the "display bucket" name for XML responses.
75    /// E.g., 1 means `<Name>` shows just `account`.
76    pub display_bucket_segments: usize,
77}
78
79/// Result of rewriting a request path and query string.
80///
81/// Contains both the rewritten values (for S3 operation parsing) and the
82/// original values (for SigV4 signature verification).
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct RewriteResult {
85    /// Rewritten path for S3 operation parsing.
86    pub path: String,
87    /// Rewritten query for operation parsing.
88    pub query: Option<String>,
89    /// Original client path for SigV4 verification.
90    pub signing_path: String,
91    /// Original client query for SigV4 verification.
92    pub signing_query: Option<String>,
93}
94
95/// Result of mapping a request path.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct MappedPath {
98    /// Internal bucket name (e.g., "account:product")
99    pub bucket: String,
100    /// Remaining key after bucket segments (e.g., "file.parquet")
101    pub key: Option<String>,
102    /// Display bucket name for XML responses (e.g., "account")
103    pub display_bucket: String,
104    /// Key prefix to add in XML responses (e.g., "product/")
105    pub key_prefix: String,
106    /// The individual path segments that formed the bucket
107    pub segments: Vec<String>,
108}
109
110impl PathMapping {
111    /// Parse a URL path into a [`MappedPath`].
112    ///
113    /// The path is expected to start with `/`. Segments are split on `/`,
114    /// and the first `bucket_segments` segments form the internal bucket name.
115    /// Any remaining content becomes the key.
116    ///
117    /// Returns `None` if there are fewer than `bucket_segments` non-empty segments.
118    pub fn parse(&self, path: &str) -> Option<MappedPath> {
119        let trimmed = path.strip_prefix('/').unwrap_or(path);
120        if trimmed.is_empty() {
121            return None;
122        }
123
124        // Split into at most bucket_segments + 1 parts so the key portion
125        // preserves any internal `/` characters.
126        let parts: Vec<&str> = trimmed.splitn(self.bucket_segments + 1, '/').collect();
127
128        if parts.len() < self.bucket_segments {
129            return None;
130        }
131
132        // Verify none of the bucket segments are empty.
133        for part in &parts[..self.bucket_segments] {
134            if part.is_empty() {
135                return None;
136            }
137        }
138
139        let segments: Vec<String> = parts[..self.bucket_segments]
140            .iter()
141            .map(|s| s.to_string())
142            .collect();
143
144        let bucket = segments.join(&self.bucket_separator);
145
146        let key = if parts.len() > self.bucket_segments {
147            let k = parts[self.bucket_segments];
148            if k.is_empty() {
149                None
150            } else {
151                Some(k.to_string())
152            }
153        } else {
154            None
155        };
156
157        let display_bucket = segments[..self.display_bucket_segments].join("/");
158
159        let key_prefix = if self.display_bucket_segments < self.bucket_segments {
160            let prefix_parts = &segments[self.display_bucket_segments..self.bucket_segments];
161            format!("{}/", prefix_parts.join("/"))
162        } else {
163            String::new()
164        };
165
166        Some(MappedPath {
167            bucket,
168            key,
169            display_bucket,
170            key_prefix,
171            segments,
172        })
173    }
174
175    /// Parse a bucket name (e.g., "account:product") back into a [`MappedPath`].
176    ///
177    /// Used by [`MappedRegistry`] when it receives an already-mapped bucket name.
178    /// Returns `None` if the bucket name does not split into exactly `bucket_segments` parts.
179    pub fn parse_bucket_name(&self, bucket_name: &str) -> Option<MappedPath> {
180        let segments: Vec<String> = bucket_name
181            .split(&self.bucket_separator)
182            .map(|s| s.to_string())
183            .collect();
184
185        if segments.len() != self.bucket_segments {
186            return None;
187        }
188
189        // Verify none of the segments are empty.
190        for seg in &segments {
191            if seg.is_empty() {
192                return None;
193            }
194        }
195
196        let display_bucket = segments[..self.display_bucket_segments].join("/");
197
198        let key_prefix = if self.display_bucket_segments < self.bucket_segments {
199            let prefix_parts = &segments[self.display_bucket_segments..self.bucket_segments];
200            format!("{}/", prefix_parts.join("/"))
201        } else {
202            String::new()
203        };
204
205        Some(MappedPath {
206            bucket: bucket_name.to_string(),
207            key: None,
208            display_bucket,
209            key_prefix,
210            segments,
211        })
212    }
213
214    /// Rewrite an incoming request path and query string for the gateway.
215    ///
216    /// Translates hierarchical paths into internal single-segment bucket paths:
217    ///
218    /// 1. **Path-based**: if the path has enough segments, they are joined into
219    ///    a single bucket name.
220    ///    `/{a}/{b}/{key}` → `/{a:b}/{key}`
221    ///
222    /// 2. **Prefix-based**: if the path has fewer segments than required but the
223    ///    query string contains a `list-type=` param with a non-empty `prefix=`,
224    ///    the first component of the prefix is folded into the bucket name.
225    ///    `/{a}?list-type=2&prefix=b/sub/` → `/{a:b}?list-type=2&prefix=sub/`
226    ///
227    /// 3. **Pass-through**: all other paths are returned unchanged. Route handlers
228    ///    or the gateway itself will handle them.
229    pub fn rewrite_request(&self, path: &str, query: Option<&str>) -> RewriteResult {
230        let signing_path = path.to_string();
231        let signing_query = query.map(|q| q.to_string());
232
233        // Case 1: enough path segments to map directly
234        if let Some(mapped) = self.parse(path) {
235            let rewritten_path = match mapped.key {
236                Some(ref key) => format!("/{}/{}", mapped.bucket, key),
237                None => format!("/{}", mapped.bucket),
238            };
239            return RewriteResult {
240                path: rewritten_path,
241                query: query.map(|q| q.to_string()),
242                signing_path,
243                signing_query,
244            };
245        }
246
247        // Case 2: single-segment path with a list-type query and non-empty prefix
248        let trimmed = path.trim_matches('/');
249        if !trimmed.is_empty() && !trimmed.contains('/') {
250            let query_str = query.unwrap_or("");
251            if is_list_request(query_str) {
252                if let Some(prefix) = extract_query_param(query_str, "prefix") {
253                    if !prefix.is_empty() {
254                        let (rewritten_path, rewritten_query) =
255                            self.rewrite_prefix_to_bucket(trimmed, &prefix, query_str);
256                        return RewriteResult {
257                            path: rewritten_path,
258                            query: rewritten_query,
259                            signing_path,
260                            signing_query,
261                        };
262                    }
263                }
264            }
265        }
266
267        // Case 3: pass through unchanged
268        RewriteResult {
269            path: path.to_string(),
270            query: query.map(|q| q.to_string()),
271            signing_path,
272            signing_query,
273        }
274    }
275
276    /// Rewrite an `x-amz-copy-source` header value into the gateway's bucket
277    /// namespace.
278    ///
279    /// `CopyObject` names its source in a header rather than the URL, so
280    /// [`rewrite_request`](Self::rewrite_request) never sees it and the gateway
281    /// would otherwise resolve a client-facing name (`alukach`) that the
282    /// registry has never heard of. Feed the result to
283    /// [`RequestInfo::with_copy_source`] alongside the untouched header, which
284    /// stays as sent for SigV4 verification.
285    ///
286    /// `/{a}/{b}/{key}[?versionId=id]` → `/{a:b}/{key}[?versionId=id]`. The key
287    /// keeps its percent-encoding (only leading segments are rewritten), and
288    /// the `versionId` suffix rides through. Returns `None` when the value has
289    /// too few segments to map, leaving the caller to pass the header through
290    /// unchanged.
291    ///
292    /// [`RequestInfo::with_copy_source`]: multistore::route_handler::RequestInfo::with_copy_source
293    pub fn rewrite_copy_source(&self, copy_source: &str) -> Option<String> {
294        let value = copy_source.strip_prefix('/').unwrap_or(copy_source);
295        let (path, version) = match value.split_once("?versionId=") {
296            Some((p, v)) => (p, Some(v)),
297            None => (value, None),
298        };
299
300        let mapped = self.parse(&format!("/{path}"))?;
301        let key = mapped.key?;
302        let mut out = format!("/{}/{}", mapped.bucket, key);
303        if let Some(version) = version {
304            out.push_str("?versionId=");
305            out.push_str(version);
306        }
307        Some(out)
308    }
309
310    /// Fold the first prefix component into the bucket name.
311    ///
312    /// `/{account}?prefix=product/sub/` → `/{account:product}?prefix=sub/`
313    fn rewrite_prefix_to_bucket(
314        &self,
315        account: &str,
316        prefix: &str,
317        query_str: &str,
318    ) -> (String, Option<String>) {
319        let (product, remaining_prefix) = if let Some(slash_pos) = prefix.find('/') {
320            (&prefix[..slash_pos], &prefix[slash_pos + 1..])
321        } else {
322            (prefix, "")
323        };
324
325        let bucket = format!("{}{}{}", account, self.bucket_separator, product);
326        let new_query = rewrite_prefix_in_query(query_str, remaining_prefix);
327        (format!("/{}", bucket), Some(new_query))
328    }
329}
330
331// ── Query-string helpers (private) ──────────────────────────────────
332
333/// Check whether a query string contains a `list-type=` parameter.
334fn is_list_request(query: &str) -> bool {
335    query.split('&').any(|p| p.starts_with("list-type="))
336}
337
338/// Extract and percent-decode a single query parameter value.
339fn extract_query_param(query: &str, key: &str) -> Option<String> {
340    query.split('&').find_map(|pair| {
341        pair.split_once('=')
342            .filter(|(k, _)| *k == key)
343            .map(|(_, v)| {
344                percent_encoding::percent_decode_str(v)
345                    .decode_utf8_lossy()
346                    .into_owned()
347            })
348    })
349}
350
351/// Characters that must be percent-encoded when placed in a query parameter value.
352const QUERY_VALUE_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
353    .add(b' ')
354    .add(b'#')
355    .add(b'&')
356    .add(b'=')
357    .add(b'+');
358
359/// Replace the `prefix=` value in a query string, percent-encoding the new value.
360fn rewrite_prefix_in_query(query: &str, new_prefix: &str) -> String {
361    let encoded: String =
362        percent_encoding::utf8_percent_encode(new_prefix, QUERY_VALUE_ENCODE).to_string();
363    query
364        .split('&')
365        .map(|pair| {
366            if pair.starts_with("prefix=") {
367                format!("prefix={}", encoded)
368            } else {
369                pair.to_string()
370            }
371        })
372        .collect::<Vec<_>>()
373        .join("&")
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn mapping() -> PathMapping {
381        PathMapping {
382            bucket_segments: 2,
383            bucket_separator: ":".into(),
384            display_bucket_segments: 1,
385        }
386    }
387
388    /// The copy-source header is a client-facing path and must be folded into
389    /// the internal bucket name, or the gateway resolves `alukach` (an account,
390    /// not a bucket) and the copy fails with `NoSuchBucket`.
391    #[test]
392    fn rewrite_copy_source_folds_leading_segments_into_the_bucket() {
393        let m = mapping();
394        assert_eq!(
395            m.rewrite_copy_source("/alukach/experimentation/README.md")
396                .as_deref(),
397            Some("/alukach:experimentation/README.md")
398        );
399        // A leading slash is optional on the wire.
400        assert_eq!(
401            m.rewrite_copy_source("alukach/experimentation/README.md")
402                .as_deref(),
403            Some("/alukach:experimentation/README.md")
404        );
405        // Nested keys keep every segment past the bucket.
406        assert_eq!(
407            m.rewrite_copy_source("/alukach/experimentation/a/b/c.txt")
408                .as_deref(),
409            Some("/alukach:experimentation/a/b/c.txt")
410        );
411    }
412
413    /// The key stays percent-encoded (the gateway decodes it) and `versionId`
414    /// rides through untouched.
415    #[test]
416    fn rewrite_copy_source_preserves_encoding_and_version() {
417        assert_eq!(
418            mapping()
419                .rewrite_copy_source("/acme/data/a%20b.txt?versionId=v42")
420                .as_deref(),
421            Some("/acme:data/a%20b.txt?versionId=v42")
422        );
423    }
424
425    /// Too few segments to map (no key, or no product) → `None`, so the caller
426    /// passes the header through and the gateway reports the real error.
427    #[test]
428    fn rewrite_copy_source_declines_unmappable_values() {
429        let m = mapping();
430        assert_eq!(m.rewrite_copy_source("/acme/data"), None);
431        assert_eq!(m.rewrite_copy_source("/acme"), None);
432    }
433
434    #[test]
435    fn is_list_request_detects_list_type() {
436        assert!(is_list_request("list-type=2"));
437        assert!(is_list_request("foo=bar&list-type=2&baz=qux"));
438        assert!(!is_list_request("foo=bar"));
439        assert!(!is_list_request(""));
440    }
441
442    #[test]
443    fn is_list_request_rejects_substring_match() {
444        assert!(!is_list_request("not-list-type=2"));
445        assert!(!is_list_request("foo=bar&not-list-type=2"));
446    }
447
448    #[test]
449    fn extract_query_param_finds_value() {
450        assert_eq!(
451            extract_query_param("list-type=2&prefix=foo/", "prefix"),
452            Some("foo/".to_string())
453        );
454    }
455
456    #[test]
457    fn extract_query_param_missing() {
458        assert_eq!(extract_query_param("list-type=2", "prefix"), None);
459    }
460
461    #[test]
462    fn extract_query_param_decodes_percent() {
463        assert_eq!(
464            extract_query_param("prefix=hello%20world", "prefix"),
465            Some("hello world".to_string())
466        );
467    }
468
469    #[test]
470    fn rewrite_prefix_replaces_value() {
471        assert_eq!(
472            rewrite_prefix_in_query("list-type=2&prefix=old/", "new/"),
473            "list-type=2&prefix=new/"
474        );
475    }
476
477    #[test]
478    fn rewrite_prefix_to_empty() {
479        assert_eq!(
480            rewrite_prefix_in_query("prefix=old/&max-keys=100", ""),
481            "prefix=&max-keys=100"
482        );
483    }
484
485    #[test]
486    fn rewrite_prefix_encodes_special_chars() {
487        assert_eq!(
488            rewrite_prefix_in_query("list-type=2&prefix=old/", "sub dir/"),
489            "list-type=2&prefix=sub%20dir/"
490        );
491    }
492}
493
494// ── MappedRegistry ──────────────────────────────────────────────────
495
496/// Wraps a [`BucketRegistry`] to add path-based routing.
497///
498/// When `get_bucket` is called, the bucket name is parsed via
499/// [`PathMapping::parse_bucket_name`] and the resulting [`ListRewrite`]
500/// and `display_name` are applied to the resolved bucket. This allows the
501/// gateway to present hierarchical names in S3 XML responses while storing
502/// data in flat internal buckets.
503#[derive(Debug, Clone)]
504pub struct MappedRegistry<R> {
505    inner: R,
506    mapping: PathMapping,
507}
508
509impl<R> MappedRegistry<R> {
510    /// Create a new `MappedRegistry` wrapping the given registry with a path mapping.
511    pub fn new(inner: R, mapping: PathMapping) -> Self {
512        Self { inner, mapping }
513    }
514}
515
516impl<R: BucketRegistry> BucketRegistry for MappedRegistry<R> {
517    async fn get_bucket(
518        &self,
519        name: &str,
520        identity: &multistore::types::ResolvedIdentity,
521        operation: &multistore::types::S3Operation,
522    ) -> Result<ResolvedBucket, multistore::error::ProxyError> {
523        let mapped = self.mapping.parse_bucket_name(name);
524
525        let mut resolved = self.inner.get_bucket(name, identity, operation).await?;
526
527        if let Some(mapped) = mapped {
528            tracing::debug!(
529                bucket = %name,
530                display_name = %mapped.display_bucket,
531                key_prefix = %mapped.key_prefix,
532                "Applying path mapping to resolved bucket"
533            );
534
535            resolved.display_name = Some(mapped.display_bucket);
536
537            if !mapped.key_prefix.is_empty() {
538                resolved.list_rewrite = Some(ListRewrite {
539                    strip_prefix: String::new(),
540                    add_prefix: mapped.key_prefix,
541                });
542            }
543        }
544
545        Ok(resolved)
546    }
547
548    async fn list_buckets(
549        &self,
550        identity: &multistore::types::ResolvedIdentity,
551    ) -> Result<Vec<multistore::api::response::BucketEntry>, multistore::error::ProxyError> {
552        self.inner.list_buckets(identity).await
553    }
554
555    /// Forward per-key authorization to the inner registry, so an inner
556    /// registry's `authorize_key` override is honored (the default trait impl
557    /// would bypass it). Arguments pass through unchanged, matching how
558    /// `get_bucket` forwards the bucket name.
559    async fn authorize_key(
560        &self,
561        name: &str,
562        identity: &multistore::types::ResolvedIdentity,
563        action: multistore::types::Action,
564        key: &str,
565    ) -> bool {
566        self.inner.authorize_key(name, identity, action, key).await
567    }
568
569    fn bucket_owner(&self) -> multistore::types::BucketOwner {
570        self.inner.bucket_owner()
571    }
572}