Skip to content

Commit 90e31cd

Browse files
committed
feat(path): add option to normalize forward slash of object name
Signed-off-by: w0od <dingboning02@163.com>
1 parent 507e131 commit 90e31cd

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

crates/s3s/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ rustdoc-args = ["--cfg", "docsrs"]
2020
[features]
2121
openssl = ["dep:openssl"]
2222
minio = []
23+
# Normalize forward slash for object name
24+
normalize_forward_slash = []
2325

2426
[target.'cfg(not(windows))'.dependencies]
2527
openssl = { workspace = true, optional = true }

crates/s3s/src/path.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
//! + [Bucket naming rules](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html)
77
88
use crate::validation::{AwsNameValidation, NameValidation};
9+
#[cfg(feature = "normalize_forward_slash")]
10+
use std::borrow::Cow;
911
use std::net::IpAddr;
1012

1113
/// A path in the S3 storage
@@ -156,6 +158,40 @@ pub const fn check_key(key: &str) -> bool {
156158
key.len() <= 1024
157159
}
158160

161+
/// Normalizes a path:
162+
/// - If the path does not start with `/`, no normalization is performed.
163+
/// - If the path starts with `/`, normalization:
164+
/// - removes all leading slashes (unless the entire path is one or more `/`)
165+
/// - replaces consecutive internal slashes with a single slash
166+
/// - reduces one or more trailing slashes to a single trailing slash
167+
/// - Examples:
168+
/// - "/" -> "/"
169+
/// - "/keyname" -> "keyname"
170+
/// - "//keyname" -> "keyname"
171+
/// - "//keyname/" -> "keyname/"
172+
/// - "//dir////keyname" -> "dir/keyname"
173+
/// - "dir///sub//file" -> "dir///sub//file"
174+
/// - "/dir///sub//file" -> "dir/sub/file"
175+
#[cfg(feature = "normalize_forward_slash")]
176+
fn normalize_path(path: &str) -> Cow<'_, str> {
177+
if !path.starts_with('/') {
178+
return Cow::Borrowed(path);
179+
}
180+
let end_with_slash = path.ends_with('/');
181+
let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
182+
183+
if parts.is_empty() {
184+
Cow::Borrowed("/")
185+
} else {
186+
let joined = parts.join("/");
187+
if end_with_slash {
188+
Cow::Owned(format!("{joined}/"))
189+
} else {
190+
Cow::Owned(joined)
191+
}
192+
}
193+
}
194+
159195
/// Parses a path-style request
160196
/// # Errors
161197
/// Returns an `Err` if the s3 path is invalid
@@ -185,6 +221,11 @@ pub fn parse_path_style_with_validation(uri_path: &str, validation: &dyn NameVal
185221

186222
let Some(key) = key else { return Ok(S3Path::bucket(bucket)) };
187223

224+
#[cfg(feature = "normalize_forward_slash")]
225+
let normalized_key = normalize_path(key);
226+
#[cfg(feature = "normalize_forward_slash")]
227+
let key = normalized_key.as_ref();
228+
188229
if !check_key(key) {
189230
return Err(ParseS3PathError::KeyTooLong);
190231
}
@@ -219,6 +260,11 @@ pub fn parse_virtual_hosted_style_with_validation(
219260
return Ok(S3Path::Bucket { bucket: bucket.into() });
220261
}
221262

263+
#[cfg(feature = "normalize_forward_slash")]
264+
let normalized_key = normalize_path(key);
265+
#[cfg(feature = "normalize_forward_slash")]
266+
let key = normalized_key.as_ref();
267+
222268
if !check_key(key) {
223269
return Err(ParseS3PathError::KeyTooLong);
224270
}
@@ -393,6 +439,46 @@ mod tests {
393439
assert_eq!(result1.is_err(), result2.is_err());
394440
}
395441

442+
#[test]
443+
#[cfg(feature = "normalize_forward_slash")]
444+
fn test_path_style_normalize_forward_slash() {
445+
let cases = [
446+
("/bucket-name//", Ok(S3Path::object("bucket-name", "/"))),
447+
("/bucket-name/object-name", Ok(S3Path::object("bucket-name", "object-name"))),
448+
("/bucket-name//object-name", Ok(S3Path::object("bucket-name", "object-name"))),
449+
("/bucket-name///object-name/", Ok(S3Path::object("bucket-name", "object-name/"))),
450+
("/bucket-name/dir/object-name", Ok(S3Path::object("bucket-name", "dir/object-name"))),
451+
("/bucket-name/dir//object-name", Ok(S3Path::object("bucket-name", "dir//object-name"))),
452+
("/bucket-name//dir/object-name/", Ok(S3Path::object("bucket-name", "dir/object-name/"))),
453+
];
454+
455+
for (uri_path, expected) in cases {
456+
assert_eq!(parse_path_style_with_validation(uri_path, &AwsNameValidation::new()), expected);
457+
}
458+
}
459+
460+
#[test]
461+
#[cfg(feature = "normalize_forward_slash")]
462+
fn test_virtual_hosted_style_normalize_forward_slash() {
463+
let cases = [
464+
("/", Ok(S3Path::bucket("bucket-name"))),
465+
("//", Ok(S3Path::object("bucket-name", "/"))),
466+
("///", Ok(S3Path::object("bucket-name", "/"))),
467+
("/object-name", Ok(S3Path::object("bucket-name", "object-name"))),
468+
("//object-name", Ok(S3Path::object("bucket-name", "object-name"))),
469+
("///object-name/", Ok(S3Path::object("bucket-name", "object-name/"))),
470+
("/dir//object-name/", Ok(S3Path::object("bucket-name", "dir//object-name/"))),
471+
("//dir//object-name/", Ok(S3Path::object("bucket-name", "dir/object-name/"))),
472+
];
473+
474+
for (uri_path, expected) in cases {
475+
assert_eq!(
476+
parse_virtual_hosted_style_with_validation(Some("bucket-name"), uri_path, &AwsNameValidation::new()),
477+
expected
478+
);
479+
}
480+
}
481+
396482
// --- S3Path accessor coverage ---
397483

398484
#[test]
@@ -481,4 +567,26 @@ mod tests {
481567
let err = ParseS3PathError::KeyTooLong;
482568
assert!(!format!("{err}").is_empty());
483569
}
570+
571+
#[test]
572+
#[cfg(feature = "normalize_forward_slash")]
573+
fn key_name_normalize_forward_slash() {
574+
let cases = [
575+
("/", "/"),
576+
("/////", "/"),
577+
("object-name", "object-name"),
578+
("object-name/", "object-name/"),
579+
("object-name///", "object-name///"),
580+
("/object-name", "object-name"),
581+
("///object-name", "object-name"),
582+
("///object-name/", "object-name/"),
583+
("/dir/object-name", "dir/object-name"),
584+
("///dir/object-name", "dir/object-name"),
585+
("/dir////object-name", "dir/object-name"),
586+
("///dir1////dir2/object-name////////", "dir1/dir2/object-name/"),
587+
];
588+
for (input, expected) in &cases {
589+
assert_eq!(normalize_path(input).as_ref(), *expected);
590+
}
591+
}
484592
}

0 commit comments

Comments
 (0)