mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-31 02:22:13 +00:00
2dea4a9acf
* fix(s3): correlate server-owned request IDs * fix(server): preserve trace context and Swift routing --------- Co-authored-by: houseme <housemecn@gmail.com>
340 lines
12 KiB
Rust
340 lines
12 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Swift URL routing and parsing
|
|
|
|
use axum::http::{Method, Uri};
|
|
use regex::Regex;
|
|
use std::sync::LazyLock;
|
|
|
|
/// Decode percent-encoded URL segment
|
|
fn decode_url_segment(segment: &str) -> String {
|
|
percent_encoding::percent_decode_str(segment).decode_utf8_lossy().into_owned()
|
|
}
|
|
|
|
/// Regex pattern for Swift account URLs: /v1/AUTH_{project_id}
|
|
/// Accepts any non-empty alphanumeric string with hyphens and underscores
|
|
static ACCOUNT_PATTERN: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^AUTH_([a-zA-Z0-9_-]+)$").expect("ACCOUNT_PATTERN regex is hardcoded and must be valid"));
|
|
|
|
/// Represents a parsed Swift route
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SwiftRoute {
|
|
/// Account operation: /v1/{account}
|
|
Account { account: String, method: Method },
|
|
/// Container operation: /v1/{account}/{container}
|
|
Container {
|
|
account: String,
|
|
container: String,
|
|
method: Method,
|
|
},
|
|
/// Object operation: /v1/{account}/{container}/{object}
|
|
Object {
|
|
account: String,
|
|
container: String,
|
|
object: String,
|
|
method: Method,
|
|
},
|
|
}
|
|
|
|
impl SwiftRoute {
|
|
/// Get the account identifier from the route
|
|
#[allow(dead_code)] // Public API for future use
|
|
pub fn account(&self) -> &str {
|
|
match self {
|
|
SwiftRoute::Account { account, .. } => account,
|
|
SwiftRoute::Container { account, .. } => account,
|
|
SwiftRoute::Object { account, .. } => account,
|
|
}
|
|
}
|
|
|
|
/// Extract project_id from account string (removes AUTH_ prefix)
|
|
#[allow(dead_code)] // Public API for future use
|
|
pub fn project_id(&self) -> Option<&str> {
|
|
let account = self.account();
|
|
ACCOUNT_PATTERN
|
|
.captures(account)
|
|
.and_then(|caps| caps.get(1).map(|m| m.as_str()))
|
|
}
|
|
}
|
|
|
|
/// Swift URL router
|
|
#[derive(Debug, Clone)]
|
|
pub struct SwiftRouter {
|
|
/// Enable Swift API
|
|
enabled: bool,
|
|
/// Optional URL prefix (e.g., "swift" for /swift/v1/... URLs)
|
|
url_prefix: Option<String>,
|
|
}
|
|
|
|
impl SwiftRouter {
|
|
/// Create a new Swift router
|
|
pub fn new(enabled: bool, url_prefix: Option<String>) -> Self {
|
|
Self { enabled, url_prefix }
|
|
}
|
|
|
|
/// Return whether a URI matches the Swift route shape without allocating
|
|
/// decoded route components.
|
|
pub fn matches(&self, uri: &Uri) -> bool {
|
|
let Some(path) = self.route_path(uri) else {
|
|
return false;
|
|
};
|
|
let mut segments = path.trim_start_matches('/').split('/');
|
|
segments.next() == Some("v1") && segments.next().is_some_and(Self::is_valid_account)
|
|
}
|
|
|
|
/// Parse a URI and return a SwiftRoute if it matches Swift URL pattern
|
|
pub fn route(&self, uri: &Uri, method: Method) -> Option<SwiftRoute> {
|
|
let path = self.route_path(uri)?;
|
|
|
|
// Split path into segments - preserve empty segments to maintain object key fidelity
|
|
// Swift allows trailing slashes and consecutive slashes in object names (e.g., "dir/" or "a//b")
|
|
let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
|
|
|
// Swift URLs must start with "v1"
|
|
if segments.first() != Some(&"v1") {
|
|
return None;
|
|
}
|
|
|
|
// Match path segments
|
|
match segments.as_slice() {
|
|
// /v1/{account}
|
|
["v1", account] => {
|
|
if !Self::is_valid_account(account) {
|
|
return None;
|
|
}
|
|
Some(SwiftRoute::Account {
|
|
account: decode_url_segment(account),
|
|
method,
|
|
})
|
|
}
|
|
// /v1/{account}/ - trailing slash, route as Account
|
|
["v1", account, ""] => {
|
|
if !Self::is_valid_account(account) {
|
|
return None;
|
|
}
|
|
Some(SwiftRoute::Account {
|
|
account: decode_url_segment(account),
|
|
method,
|
|
})
|
|
}
|
|
// /v1/{account}/{container}
|
|
["v1", account, container] if !container.is_empty() && !container.contains('/') => {
|
|
if !Self::is_valid_account(account) {
|
|
return None;
|
|
}
|
|
Some(SwiftRoute::Container {
|
|
account: decode_url_segment(account),
|
|
container: decode_url_segment(container),
|
|
method,
|
|
})
|
|
}
|
|
// /v1/{account}/{container}/ - trailing slash, route as Container
|
|
["v1", account, container, ""] if !container.is_empty() => {
|
|
if !Self::is_valid_account(account) {
|
|
return None;
|
|
}
|
|
Some(SwiftRoute::Container {
|
|
account: decode_url_segment(account),
|
|
container: decode_url_segment(container),
|
|
method,
|
|
})
|
|
}
|
|
// /v1/{account}/{container}/{object...}
|
|
["v1", account, container, object @ ..] if !object.is_empty() => {
|
|
if !Self::is_valid_account(account) {
|
|
return None;
|
|
}
|
|
// Join remaining segments as object key, preserving empty segments
|
|
// Decode each segment individually to handle percent-encoding correctly
|
|
let object_key = object.iter().map(|s| decode_url_segment(s)).collect::<Vec<_>>().join("/");
|
|
Some(SwiftRoute::Object {
|
|
account: decode_url_segment(account),
|
|
container: decode_url_segment(container),
|
|
object: object_key,
|
|
method,
|
|
})
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Validate account format (must be AUTH_{uuid})
|
|
fn is_valid_account(account: &str) -> bool {
|
|
ACCOUNT_PATTERN.is_match(account)
|
|
}
|
|
|
|
fn route_path<'a>(&self, uri: &'a Uri) -> Option<&'a str> {
|
|
if !self.enabled {
|
|
return None;
|
|
}
|
|
let path = uri.path();
|
|
let Some(prefix) = &self.url_prefix else {
|
|
return Some(path);
|
|
};
|
|
path.strip_prefix('/')?.strip_prefix(prefix)?.strip_prefix('/')
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_account_pattern() {
|
|
// Valid UUID-style project IDs
|
|
assert!(ACCOUNT_PATTERN.is_match("AUTH_7188e165c0ae4424ac68ae2e89a05c50"));
|
|
assert!(ACCOUNT_PATTERN.is_match("AUTH_550e8400-e29b-41d4-a716-446655440000"));
|
|
|
|
// Valid alphanumeric project IDs (non-UUID)
|
|
assert!(ACCOUNT_PATTERN.is_match("AUTH_project123"));
|
|
assert!(ACCOUNT_PATTERN.is_match("AUTH_my-project_01"));
|
|
|
|
// Invalid patterns
|
|
assert!(!ACCOUNT_PATTERN.is_match("AUTH_")); // Empty project ID
|
|
assert!(!ACCOUNT_PATTERN.is_match("7188e165c0ae4424ac68ae2e89a05c50")); // Missing AUTH_ prefix
|
|
assert!(!ACCOUNT_PATTERN.is_match("AUTH_project with spaces")); // Spaces not allowed
|
|
}
|
|
|
|
#[test]
|
|
fn test_route_account() {
|
|
let router = SwiftRouter::new(true, None);
|
|
let uri = "/v1/AUTH_7188e165c0ae4424ac68ae2e89a05c50".parse().unwrap();
|
|
let route = router.route(&uri, Method::GET);
|
|
|
|
assert_eq!(
|
|
route,
|
|
Some(SwiftRoute::Account {
|
|
account: "AUTH_7188e165c0ae4424ac68ae2e89a05c50".to_string(),
|
|
method: Method::GET
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_route_container() {
|
|
let router = SwiftRouter::new(true, None);
|
|
let uri = "/v1/AUTH_7188e165c0ae4424ac68ae2e89a05c50/photos".parse().unwrap();
|
|
let route = router.route(&uri, Method::PUT);
|
|
|
|
assert_eq!(
|
|
route,
|
|
Some(SwiftRoute::Container {
|
|
account: "AUTH_7188e165c0ae4424ac68ae2e89a05c50".to_string(),
|
|
container: "photos".to_string(),
|
|
method: Method::PUT
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_route_object() {
|
|
let router = SwiftRouter::new(true, None);
|
|
let uri = "/v1/AUTH_7188e165c0ae4424ac68ae2e89a05c50/photos/vacation/beach.jpg"
|
|
.parse()
|
|
.unwrap();
|
|
let route = router.route(&uri, Method::GET);
|
|
|
|
assert_eq!(
|
|
route,
|
|
Some(SwiftRoute::Object {
|
|
account: "AUTH_7188e165c0ae4424ac68ae2e89a05c50".to_string(),
|
|
container: "photos".to_string(),
|
|
object: "vacation/beach.jpg".to_string(),
|
|
method: Method::GET
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_route_with_prefix() {
|
|
let router = SwiftRouter::new(true, Some("swift".to_string()));
|
|
let uri = "/swift/v1/AUTH_7188e165c0ae4424ac68ae2e89a05c50/photos".parse().unwrap();
|
|
let route = router.route(&uri, Method::GET);
|
|
|
|
assert_eq!(
|
|
route,
|
|
Some(SwiftRoute::Container {
|
|
account: "AUTH_7188e165c0ae4424ac68ae2e89a05c50".to_string(),
|
|
container: "photos".to_string(),
|
|
method: Method::GET
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_route_invalid_account() {
|
|
let router = SwiftRouter::new(true, None);
|
|
let uri = "/v1/invalid_account/photos".parse().unwrap();
|
|
let route = router.route(&uri, Method::GET);
|
|
|
|
assert_eq!(route, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_route_disabled() {
|
|
let router = SwiftRouter::new(false, None);
|
|
let uri = "/v1/AUTH_7188e165c0ae4424ac68ae2e89a05c50".parse().unwrap();
|
|
let route = router.route(&uri, Method::GET);
|
|
|
|
assert_eq!(route, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_matches_agrees_with_route_without_decoding_components() {
|
|
let router = SwiftRouter::new(true, None);
|
|
for path in [
|
|
"/v1/AUTH_project",
|
|
"/v1/AUTH_project/",
|
|
"/v1/AUTH_project/container",
|
|
"/v1/AUTH_project/container/a%20long/object",
|
|
"//v1/AUTH_project/container/object",
|
|
"/v1/not-a-swift-account/object",
|
|
"/v1/AUTH_/object",
|
|
"/bucket/object",
|
|
] {
|
|
let uri = path.parse().expect("Swift route test URI");
|
|
assert_eq!(
|
|
router.matches(&uri),
|
|
router.route(&uri, Method::GET).is_some(),
|
|
"classification must match full routing for {path}"
|
|
);
|
|
}
|
|
|
|
let prefixed_router = SwiftRouter::new(true, Some("swift".to_string()));
|
|
for path in [
|
|
"/swift/v1/AUTH_project/container",
|
|
"/swiftish/v1/AUTH_project/container",
|
|
"/v1/AUTH_project/container",
|
|
] {
|
|
let uri = path.parse().expect("prefixed Swift route test URI");
|
|
assert_eq!(
|
|
prefixed_router.matches(&uri),
|
|
prefixed_router.route(&uri, Method::GET).is_some(),
|
|
"prefixed classification must match full routing for {path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_id_extraction() {
|
|
let route = SwiftRoute::Account {
|
|
account: "AUTH_7188e165c0ae4424ac68ae2e89a05c50".to_string(),
|
|
method: Method::GET,
|
|
};
|
|
|
|
assert_eq!(route.project_id(), Some("7188e165c0ae4424ac68ae2e89a05c50"));
|
|
}
|
|
}
|