diff --git a/crates/myfsio-common/src/error.rs b/crates/myfsio-common/src/error.rs
index c1a0c4d..5a39eec 100644
--- a/crates/myfsio-common/src/error.rs
+++ b/crates/myfsio-common/src/error.rs
@@ -5,6 +5,7 @@ pub enum S3ErrorCode {
AccessDenied,
BadDigest,
BucketAlreadyExists,
+ BucketAlreadyOwnedByYou,
BucketNotEmpty,
EntityTooLarge,
EntityTooSmall,
@@ -43,6 +44,7 @@ impl S3ErrorCode {
Self::AccessDenied => 403,
Self::BadDigest => 400,
Self::BucketAlreadyExists => 409,
+ Self::BucketAlreadyOwnedByYou => 409,
Self::BucketNotEmpty => 409,
Self::EntityTooLarge => 413,
Self::EntityTooSmall => 400,
@@ -72,7 +74,7 @@ impl S3ErrorCode {
Self::RequestTimeTooSkewed => 403,
Self::ServerSideEncryptionConfigurationNotFoundError => 404,
Self::SignatureDoesNotMatch => 403,
- Self::SlowDown => 429,
+ Self::SlowDown => 503,
}
}
@@ -81,6 +83,7 @@ impl S3ErrorCode {
Self::AccessDenied => "AccessDenied",
Self::BadDigest => "BadDigest",
Self::BucketAlreadyExists => "BucketAlreadyExists",
+ Self::BucketAlreadyOwnedByYou => "BucketAlreadyOwnedByYou",
Self::BucketNotEmpty => "BucketNotEmpty",
Self::EntityTooLarge => "EntityTooLarge",
Self::EntityTooSmall => "EntityTooSmall",
@@ -121,6 +124,7 @@ impl S3ErrorCode {
Self::AccessDenied => "Access Denied",
Self::BadDigest => "The Content-MD5 or checksum value you specified did not match what we received",
Self::BucketAlreadyExists => "The requested bucket name is not available",
+ Self::BucketAlreadyOwnedByYou => "Your previous request to create the named bucket succeeded and you already own it",
Self::BucketNotEmpty => "The bucket you tried to delete is not empty",
Self::EntityTooLarge => "Your proposed upload exceeds the maximum allowed size",
Self::EntityTooSmall => "Your proposed upload is smaller than the minimum allowed object size",
diff --git a/crates/myfsio-server/src/handlers/mod.rs b/crates/myfsio-server/src/handlers/mod.rs
index c842cbf..a0657b1 100644
--- a/crates/myfsio-server/src/handlers/mod.rs
+++ b/crates/myfsio-server/src/handlers/mod.rs
@@ -148,6 +148,7 @@ async fn ensure_object_lock_allows_write(
Ok(())
}
Err(myfsio_storage::error::StorageError::ObjectNotFound { .. }) => Ok(()),
+ Err(myfsio_storage::error::StorageError::DeleteMarker { .. }) => Ok(()),
Err(err) => Err(storage_err_response(err)),
}
}
@@ -2666,7 +2667,8 @@ async fn evaluate_put_preconditions(
}
None
}
- Err(myfsio_storage::error::StorageError::ObjectNotFound { .. }) => {
+ Err(myfsio_storage::error::StorageError::ObjectNotFound { .. })
+ | Err(myfsio_storage::error::StorageError::DeleteMarker { .. }) => {
if has_if_match {
Some(s3_error_response(S3Error::from_code(
S3ErrorCode::PreconditionFailed,
diff --git a/crates/myfsio-server/src/middleware/ratelimit.rs b/crates/myfsio-server/src/middleware/ratelimit.rs
index 6bf26aa..e1c624d 100644
--- a/crates/myfsio-server/src/middleware/ratelimit.rs
+++ b/crates/myfsio-server/src/middleware/ratelimit.rs
@@ -162,20 +162,31 @@ pub async fn rate_limit_layer(
let limiter = state.select_limiter(&req);
match limiter.check(&key) {
Ok(()) => next.run(req).await,
- Err(retry_after) => too_many_requests(retry_after),
+ Err(retry_after) => {
+ let resource = req.uri().path().to_string();
+ too_many_requests(retry_after, &resource)
+ }
}
}
-fn too_many_requests(retry_after: u64) -> Response {
- (
- StatusCode::TOO_MANY_REQUESTS,
+fn too_many_requests(retry_after: u64, resource: &str) -> Response {
+ let request_id = uuid::Uuid::new_v4().simple().to_string();
+ let body = myfsio_xml::response::rate_limit_exceeded_xml(resource, &request_id);
+ let mut response = (
+ StatusCode::SERVICE_UNAVAILABLE,
[
(header::CONTENT_TYPE, "application/xml".to_string()),
(header::RETRY_AFTER, retry_after.to_string()),
],
- myfsio_xml::response::rate_limit_exceeded_xml(),
+ body,
)
- .into_response()
+ .into_response();
+ if let Ok(value) = request_id.parse() {
+ response
+ .headers_mut()
+ .insert("x-amz-request-id", value);
+ }
+ response
}
fn rate_limit_key(req: &Request, num_trusted_proxies: usize) -> String {
diff --git a/crates/myfsio-server/tests/integration.rs b/crates/myfsio-server/tests/integration.rs
index 9e2fd8f..f33b8bc 100644
--- a/crates/myfsio-server/tests/integration.rs
+++ b/crates/myfsio-server/tests/integration.rs
@@ -163,7 +163,7 @@ async fn rate_limit_default_and_admin_are_independent() {
)
.await
.unwrap();
- assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
+ assert_eq!(second.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(second.headers().contains_key("retry-after"));
let admin_first = app
@@ -199,7 +199,7 @@ async fn rate_limit_default_and_admin_are_independent() {
)
.await
.unwrap();
- assert_eq!(admin_third.status(), StatusCode::TOO_MANY_REQUESTS);
+ assert_eq!(admin_third.status(), StatusCode::SERVICE_UNAVAILABLE);
}
fn test_ui_state() -> (myfsio_server::state::AppState, tempfile::TempDir) {
@@ -2311,9 +2311,16 @@ async fn test_versioned_object_can_be_read_and_deleted_by_version_id() {
)
.unwrap();
let archived_version_id = list_body
- .split("")
- .filter_map(|part| part.split_once("").map(|(id, _)| id))
- .find(|id| *id != "null")
+ .split("")
+ .skip(1)
+ .find(|block| block.contains("false"))
+ .and_then(|block| {
+ block
+ .split("")
+ .nth(1)
+ .and_then(|s| s.split_once("").map(|(id, _)| id))
+ })
+ .filter(|id| *id != "null")
.expect("archived version id")
.to_string();
@@ -2506,6 +2513,352 @@ async fn test_versioned_put_and_delete_emit_version_headers_and_delete_markers()
);
}
+#[tokio::test]
+async fn test_consecutive_slashes_in_key_round_trip() {
+ let (app, _tmp) = test_app();
+
+ app.clone()
+ .oneshot(signed_request(Method::PUT, "/slashes-bucket", Body::empty()))
+ .await
+ .unwrap();
+
+ let put_ab = app
+ .clone()
+ .oneshot(signed_request(
+ Method::PUT,
+ "/slashes-bucket/a/b",
+ Body::from("single"),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(put_ab.status(), StatusCode::OK);
+
+ let put_double = app
+ .clone()
+ .oneshot(signed_request(
+ Method::PUT,
+ "/slashes-bucket/a//b",
+ Body::from("double"),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(put_double.status(), StatusCode::OK);
+
+ let put_triple = app
+ .clone()
+ .oneshot(signed_request(
+ Method::PUT,
+ "/slashes-bucket/a///b",
+ Body::from("triple"),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(put_triple.status(), StatusCode::OK);
+
+ let get_ab = app
+ .clone()
+ .oneshot(signed_request(
+ Method::GET,
+ "/slashes-bucket/a/b",
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(get_ab.status(), StatusCode::OK);
+ let body_ab = get_ab.into_body().collect().await.unwrap().to_bytes();
+ assert_eq!(&body_ab[..], b"single");
+
+ let get_triple = app
+ .clone()
+ .oneshot(signed_request(
+ Method::GET,
+ "/slashes-bucket/a///b",
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(get_triple.status(), StatusCode::OK);
+ let body_triple = get_triple.into_body().collect().await.unwrap().to_bytes();
+ assert_eq!(&body_triple[..], b"triple");
+
+ let list_resp = app
+ .oneshot(signed_request(
+ Method::GET,
+ "/slashes-bucket?list-type=2",
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ let list_body = String::from_utf8(
+ list_resp
+ .into_body()
+ .collect()
+ .await
+ .unwrap()
+ .to_bytes()
+ .to_vec(),
+ )
+ .unwrap();
+ assert!(
+ list_body.contains("a/b"),
+ "expected a/b in listing: {}",
+ list_body
+ );
+ assert!(
+ list_body.contains("a//b"),
+ "expected a//b in listing: {}",
+ list_body
+ );
+ assert!(
+ list_body.contains("a///b"),
+ "expected a///b in listing: {}",
+ list_body
+ );
+}
+
+#[tokio::test]
+async fn test_delete_live_version_restores_previous_to_live_slot() {
+ let (app, _tmp) = test_app();
+
+ app.clone()
+ .oneshot(signed_request(Method::PUT, "/restore-bucket", Body::empty()))
+ .await
+ .unwrap();
+ app.clone()
+ .oneshot(
+ Request::builder()
+ .method(Method::PUT)
+ .uri("/restore-bucket?versioning")
+ .header("x-access-key", TEST_ACCESS_KEY)
+ .header("x-secret-key", TEST_SECRET_KEY)
+ .body(Body::from(
+ "Enabled",
+ ))
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+
+ let v1_resp = app
+ .clone()
+ .oneshot(signed_request(
+ Method::PUT,
+ "/restore-bucket/k",
+ Body::from("one"),
+ ))
+ .await
+ .unwrap();
+ let v1 = v1_resp
+ .headers()
+ .get("x-amz-version-id")
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string();
+
+ let v2_resp = app
+ .clone()
+ .oneshot(signed_request(
+ Method::PUT,
+ "/restore-bucket/k",
+ Body::from("two"),
+ ))
+ .await
+ .unwrap();
+ let v2 = v2_resp
+ .headers()
+ .get("x-amz-version-id")
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string();
+ assert_ne!(v1, v2);
+
+ let del = app
+ .clone()
+ .oneshot(signed_request(
+ Method::DELETE,
+ &format!("/restore-bucket/k?versionId={}", v2),
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(del.status(), StatusCode::NO_CONTENT);
+
+ let get_live = app
+ .clone()
+ .oneshot(signed_request(
+ Method::GET,
+ "/restore-bucket/k",
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(get_live.status(), StatusCode::OK);
+ let body = get_live.into_body().collect().await.unwrap().to_bytes();
+ assert_eq!(&body[..], b"one");
+
+ let get_v1 = app
+ .oneshot(signed_request(
+ Method::GET,
+ &format!("/restore-bucket/k?versionId={}", v1),
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(get_v1.status(), StatusCode::OK);
+}
+
+#[tokio::test]
+async fn test_delete_active_delete_marker_restores_previous_to_live_slot() {
+ let (app, _tmp) = test_app();
+
+ app.clone()
+ .oneshot(signed_request(Method::PUT, "/undel-bucket", Body::empty()))
+ .await
+ .unwrap();
+ app.clone()
+ .oneshot(
+ Request::builder()
+ .method(Method::PUT)
+ .uri("/undel-bucket?versioning")
+ .header("x-access-key", TEST_ACCESS_KEY)
+ .header("x-secret-key", TEST_SECRET_KEY)
+ .body(Body::from(
+ "Enabled",
+ ))
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+
+ app.clone()
+ .oneshot(signed_request(
+ Method::PUT,
+ "/undel-bucket/k",
+ Body::from("only"),
+ ))
+ .await
+ .unwrap();
+
+ let del = app
+ .clone()
+ .oneshot(signed_request(
+ Method::DELETE,
+ "/undel-bucket/k",
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ let dm_version = del
+ .headers()
+ .get("x-amz-version-id")
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string();
+ assert_eq!(
+ del.headers()
+ .get("x-amz-delete-marker")
+ .and_then(|v| v.to_str().ok()),
+ Some("true")
+ );
+
+ let shadowed = app
+ .clone()
+ .oneshot(signed_request(
+ Method::GET,
+ "/undel-bucket/k",
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(shadowed.status(), StatusCode::NOT_FOUND);
+
+ let del_dm = app
+ .clone()
+ .oneshot(signed_request(
+ Method::DELETE,
+ &format!("/undel-bucket/k?versionId={}", dm_version),
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(del_dm.status(), StatusCode::NO_CONTENT);
+
+ let restored = app
+ .oneshot(signed_request(
+ Method::GET,
+ "/undel-bucket/k",
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(restored.status(), StatusCode::OK);
+ let body = restored.into_body().collect().await.unwrap().to_bytes();
+ assert_eq!(&body[..], b"only");
+}
+
+#[tokio::test]
+async fn test_versioned_get_on_delete_marker_returns_method_not_allowed() {
+ let (app, _tmp) = test_app();
+
+ app.clone()
+ .oneshot(signed_request(Method::PUT, "/dm-bucket", Body::empty()))
+ .await
+ .unwrap();
+ app.clone()
+ .oneshot(
+ Request::builder()
+ .method(Method::PUT)
+ .uri("/dm-bucket?versioning")
+ .header("x-access-key", TEST_ACCESS_KEY)
+ .header("x-secret-key", TEST_SECRET_KEY)
+ .body(Body::from(
+ "Enabled",
+ ))
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+
+ app.clone()
+ .oneshot(signed_request(
+ Method::PUT,
+ "/dm-bucket/k",
+ Body::from("x"),
+ ))
+ .await
+ .unwrap();
+
+ let del = app
+ .clone()
+ .oneshot(signed_request(
+ Method::DELETE,
+ "/dm-bucket/k",
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ let dm_version = del
+ .headers()
+ .get("x-amz-version-id")
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string();
+
+ let versioned = app
+ .oneshot(signed_request(
+ Method::GET,
+ &format!("/dm-bucket/k?versionId={}", dm_version),
+ Body::empty(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(versioned.status(), StatusCode::METHOD_NOT_ALLOWED);
+}
+
#[tokio::test]
async fn test_retention_is_enforced_when_deleting_archived_version() {
let (app, _tmp) = test_app();
@@ -2586,9 +2939,16 @@ async fn test_retention_is_enforced_when_deleting_archived_version() {
)
.unwrap();
let archived_version_id = list_body
- .split("")
- .filter_map(|part| part.split_once("").map(|(id, _)| id))
- .find(|id| *id != "null")
+ .split("")
+ .skip(1)
+ .find(|block| block.contains("false"))
+ .and_then(|block| {
+ block
+ .split("")
+ .nth(1)
+ .and_then(|s| s.split_once("").map(|(id, _)| id))
+ })
+ .filter(|id| *id != "null")
.expect("archived version id")
.to_string();
diff --git a/crates/myfsio-storage/src/error.rs b/crates/myfsio-storage/src/error.rs
index 69b11d1..d1789fc 100644
--- a/crates/myfsio-storage/src/error.rs
+++ b/crates/myfsio-storage/src/error.rs
@@ -50,7 +50,7 @@ impl From for S3Error {
S3Error::from_code(S3ErrorCode::NoSuchBucket).with_resource(format!("/{}", name))
}
StorageError::BucketAlreadyExists(name) => {
- S3Error::from_code(S3ErrorCode::BucketAlreadyExists)
+ S3Error::from_code(S3ErrorCode::BucketAlreadyOwnedByYou)
.with_resource(format!("/{}", name))
}
StorageError::BucketNotEmpty(name) => {
diff --git a/crates/myfsio-storage/src/fs_backend.rs b/crates/myfsio-storage/src/fs_backend.rs
index af6061e..8fcaabf 100644
--- a/crates/myfsio-storage/src/fs_backend.rs
+++ b/crates/myfsio-storage/src/fs_backend.rs
@@ -16,6 +16,55 @@ use std::time::Instant;
use tokio::io::AsyncReadExt;
use uuid::Uuid;
+const EMPTY_SEGMENT_SENTINEL: &str = ".__myfsio_empty__";
+
+fn fs_encode_key(key: &str) -> String {
+ if key.is_empty() {
+ return String::new();
+ }
+ let trailing = key.ends_with('/');
+ let body = if trailing { &key[..key.len() - 1] } else { key };
+ if body.is_empty() {
+ return if trailing { "/".to_string() } else { String::new() };
+ }
+ let encoded: Vec = body
+ .split('/')
+ .map(|seg| {
+ if seg.is_empty() {
+ EMPTY_SEGMENT_SENTINEL.to_string()
+ } else {
+ seg.to_string()
+ }
+ })
+ .collect();
+ let mut result = encoded.join("/");
+ if trailing {
+ result.push('/');
+ }
+ result
+}
+
+fn fs_decode_key(rel_path: &str) -> String {
+ let normalized: String;
+ let input = if cfg!(windows) && rel_path.contains('\\') {
+ normalized = rel_path.replace('\\', "/");
+ normalized.as_str()
+ } else {
+ rel_path
+ };
+ input
+ .split('/')
+ .map(|seg| {
+ if seg == EMPTY_SEGMENT_SENTINEL {
+ ""
+ } else {
+ seg
+ }
+ })
+ .collect::>()
+ .join("/")
+}
+
fn validate_list_prefix(prefix: &str) -> StorageResult<()> {
if prefix.contains('\0') {
return Err(StorageError::InvalidObjectKey(
@@ -88,7 +137,7 @@ fn path_is_within(candidate: &Path, root: &Path) -> bool {
}
}
-type ListCacheEntry = (String, u64, f64, Option);
+type ListCacheEntry = (String, u64, f64, Option, Option);
#[derive(Clone, Default)]
struct ShallowCacheEntry {
@@ -213,13 +262,27 @@ impl FsStorageBackend {
fn object_path(&self, bucket_name: &str, object_key: &str) -> StorageResult {
self.validate_key(object_key)?;
+ let encoded = fs_encode_key(object_key);
if object_key.ends_with('/') {
+ let trimmed = encoded.trim_end_matches('/');
Ok(self
.bucket_path(bucket_name)
- .join(object_key)
+ .join(trimmed)
.join(DIR_MARKER_FILE))
} else {
- Ok(self.bucket_path(bucket_name).join(object_key))
+ Ok(self.bucket_path(bucket_name).join(&encoded))
+ }
+ }
+
+ fn object_live_path(&self, bucket_name: &str, object_key: &str) -> PathBuf {
+ let encoded = fs_encode_key(object_key);
+ if object_key.ends_with('/') {
+ let trimmed = encoded.trim_end_matches('/');
+ self.bucket_path(bucket_name)
+ .join(trimmed)
+ .join(DIR_MARKER_FILE)
+ } else {
+ self.bucket_path(bucket_name).join(&encoded)
}
}
@@ -247,7 +310,8 @@ impl FsStorageBackend {
fn index_file_for_key(&self, bucket_name: &str, key: &str) -> (PathBuf, String) {
let meta_root = self.bucket_meta_root(bucket_name);
if key.ends_with('/') {
- let trimmed = key.trim_end_matches('/');
+ let encoded = fs_encode_key(key);
+ let trimmed = encoded.trim_end_matches('/');
if trimmed.is_empty() {
return (meta_root.join(INDEX_FILE), DIR_MARKER_FILE.to_string());
}
@@ -256,13 +320,14 @@ impl FsStorageBackend {
DIR_MARKER_FILE.to_string(),
);
}
- let key_path = Path::new(key);
- let entry_name = key_path
+ let encoded = fs_encode_key(key);
+ let encoded_path = Path::new(&encoded);
+ let entry_name = encoded_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
- .unwrap_or_else(|| key.to_string());
+ .unwrap_or_else(|| encoded.clone());
- let parent = key_path.parent();
+ let parent = encoded_path.parent();
match parent {
Some(p) if p != Path::new("") && p != Path::new(".") => {
(meta_root.join(p).join(INDEX_FILE), entry_name)
@@ -304,6 +369,37 @@ impl FsStorageBackend {
out
}
+ fn load_dir_index_full_sync(
+ &self,
+ bucket_name: &str,
+ rel_dir: &Path,
+ ) -> HashMap, Option)> {
+ let index_path = self.index_file_for_dir(bucket_name, rel_dir);
+ if !index_path.exists() {
+ return HashMap::new();
+ }
+ let Ok(text) = std::fs::read_to_string(&index_path) else {
+ return HashMap::new();
+ };
+ let Ok(index) = serde_json::from_str::>(&text) else {
+ return HashMap::new();
+ };
+ let mut out = HashMap::with_capacity(index.len());
+ for (name, entry) in index {
+ let meta = entry.get("metadata").and_then(|m| m.as_object());
+ let etag = meta
+ .and_then(|m| m.get("__etag__"))
+ .and_then(|v| v.as_str())
+ .map(ToOwned::to_owned);
+ let version_id = meta
+ .and_then(|m| m.get("__version_id__"))
+ .and_then(|v| v.as_str())
+ .map(ToOwned::to_owned);
+ out.insert(name, (etag, version_id));
+ }
+ out
+ }
+
fn get_meta_index_lock(&self, index_path: &str) -> Arc> {
self.meta_index_locks
.entry(index_path.to_string())
@@ -344,7 +440,9 @@ impl FsStorageBackend {
}
fn version_dir(&self, bucket_name: &str, key: &str) -> PathBuf {
- self.bucket_versions_root(bucket_name).join(key)
+ let encoded = fs_encode_key(key);
+ let trimmed = encoded.trim_end_matches('/');
+ self.bucket_versions_root(bucket_name).join(trimmed)
}
fn delete_markers_root(&self, bucket_name: &str) -> PathBuf {
@@ -352,8 +450,10 @@ impl FsStorageBackend {
}
fn delete_marker_path(&self, bucket_name: &str, key: &str) -> PathBuf {
+ let encoded = fs_encode_key(key);
+ let trimmed = encoded.trim_end_matches('/');
self.delete_markers_root(bucket_name)
- .join(format!("{}.json", key))
+ .join(format!("{}.json", trimmed))
}
fn read_delete_marker_sync(
@@ -804,8 +904,7 @@ impl FsStorageBackend {
key: &str,
reason: &str,
) -> std::io::Result<(u64, Option)> {
- let bucket_path = self.bucket_path(bucket_name);
- let source = bucket_path.join(key);
+ let source = self.object_live_path(bucket_name, key);
if !source.exists() {
return Ok((0, None));
}
@@ -845,6 +944,101 @@ impl FsStorageBackend {
Ok((source_size, Some(version_id)))
}
+ fn promote_latest_archived_to_live_sync(
+ &self,
+ bucket_name: &str,
+ key: &str,
+ ) -> std::io::Result