diff --git a/rustfs/src/on_demand_migration/azure.rs b/rustfs/src/on_demand_migration/azure.rs
index dd7fc818b..a5cffed8b 100644
--- a/rustfs/src/on_demand_migration/azure.rs
+++ b/rustfs/src/on_demand_migration/azure.rs
@@ -722,6 +722,44 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn native_listing_rejects_duplicate_fields_and_nested_entries() {
+ for entry in [
+ "ab1",
+ "b1",
+ "a12",
+ "a/b/",
+ "b/",
+ "a1b2",
+ "a1b/",
+ "a/b2",
+ "a/b/",
+ ] {
+ let body = format!(
+ "valid0{entry}next"
+ );
+ let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body)]).await;
+ let result = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]))
+ .list(&SourceListRequest {
+ delimiter: Some("/"),
+ continuation_token: Some("opaque+/="),
+ max_keys: 2,
+ ..Default::default()
+ })
+ .await;
+ let err = result.expect_err("ambiguous entries must reject the entire page and its cursor");
+ assert!(matches!(err, SourceError::Other(_)), "{entry}: {err:?}");
+ assert!(!err.is_retryable(), "{entry}: {err:?}");
+ assert_requests(
+ &recorded,
+ &[(
+ "GET",
+ "/legacy?restype=container&comp=list&delimiter=%2F&marker=opaque%2B%2F%3D&maxresults=2",
+ )],
+ );
+ }
+ }
+
#[tokio::test]
async fn native_listing_preserves_zero_size_unicode_prefixes_and_opaque_cursors() {
let body = "目录/空 & file0目录/子/opaque+/=";
@@ -1140,6 +1178,62 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn s3_not_found_alias_never_proves_native_object_absence() {
+ for selector in [None, Some("versionid"), Some("snapshot")] {
+ for operation in ["head", "get", "list", "tags", "probe"] {
+ if selector.is_some() && !matches!(operation, "head" | "get") {
+ continue;
+ }
+ for (status, expected, retryable) in [
+ (403, "access_denied", false),
+ (404, "other", false),
+ (416, "other", false),
+ (500, "server_error", true),
+ ] {
+ let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(
+ status,
+ vec![(HEADER_ERROR_CODE, "NoSuchKey".to_string())],
+ "untrusted-error-body".to_string(),
+ )])
+ .await;
+ let credential = selector.map_or_else(
+ || Credential::SharedKey(vec![7_u8; 32]),
+ |selector| Credential::Sas(vec![(selector.to_string(), "old-version".to_string())]),
+ );
+ let backend = backend(&endpoint, credential);
+ let result = match operation {
+ "head" => backend.head("missing").await.map(|_| ()),
+ "get" => backend.get("missing", None).await.map(|_| ()),
+ "list" => backend.list(&SourceListRequest::default()).await.map(|_| ()),
+ "tags" => backend.tagging("missing").await.map(|_| ()),
+ "probe" => backend.probe().await,
+ _ => unreachable!(),
+ };
+ let err = result.expect_err("an S3 error alias is not Azure absence evidence");
+ assert_eq!(err.class_label(), expected, "{operation} {selector:?} HTTP {status}: {err:?}");
+ assert_eq!(err.is_retryable(), retryable, "{operation} {selector:?} HTTP {status}: {err:?}");
+ if status == 500 {
+ assert!(matches!(err, SourceError::ServerError(500)));
+ }
+ assert!(!err.to_string().contains("untrusted-error-body"));
+ let (method, mut target) = match operation {
+ "head" => ("HEAD", "/legacy/missing".to_string()),
+ "get" => ("GET", "/legacy/missing".to_string()),
+ "list" => ("GET", "/legacy?restype=container&comp=list".to_string()),
+ "tags" => ("GET", "/legacy/missing?comp=tags".to_string()),
+ "probe" => ("HEAD", "/legacy?restype=container".to_string()),
+ _ => unreachable!(),
+ };
+ if let Some(selector) = selector {
+ target.push_str(&format!("?{selector}=old-version"));
+ }
+ assert_requests(&recorded, &[(method, target.as_str())]);
+ }
+ }
+ }
+ }
+
#[tokio::test]
async fn ambiguous_head_preserves_the_container_probe_failure() {
for (status, expected, retryable) in [
diff --git a/rustfs/src/on_demand_migration/gcs.rs b/rustfs/src/on_demand_migration/gcs.rs
index 54a87cdc3..1afb38aae 100644
--- a/rustfs/src/on_demand_migration/gcs.rs
+++ b/rustfs/src/on_demand_migration/gcs.rs
@@ -602,6 +602,48 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn native_listing_rejects_empty_prefix_entries() {
+ for body in [
+ r#"{"items":[{"name":"valid","size":"1"}],"prefixes":[""],"nextPageToken":"next"}"#,
+ r#"{"prefixes":[""],"nextPageToken":"next"}"#,
+ r#"{"prefixes":["目录/子/",""],"nextPageToken":"next"}"#,
+ ] {
+ let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body.to_string())]).await;
+ let result = backend(&endpoint)
+ .list(&SourceListRequest {
+ delimiter: Some("/"),
+ continuation_token: Some("opaque+/="),
+ max_keys: 2,
+ ..Default::default()
+ })
+ .await;
+ let err = result.expect_err("an empty prefix must reject the entire page and its cursor");
+ assert!(matches!(err, SourceError::Other(_)), "{body}: {err:?}");
+ assert!(!err.is_retryable());
+ assert_requests(
+ &recorded,
+ &[("GET", "/storage/v1/b/legacy/o?delimiter=%2F&pageToken=opaque%2B%2F%3D&maxResults=2")],
+ );
+ }
+
+ let body = r#"{"prefixes":["目录/子/"],"nextPageToken":"opaque+/="}"#;
+ let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body.to_string())]).await;
+ let page = backend(&endpoint)
+ .list(&SourceListRequest {
+ delimiter: Some("/"),
+ max_keys: 1,
+ ..Default::default()
+ })
+ .await
+ .expect("a valid prefix-only page must remain usable");
+ assert!(page.objects.is_empty());
+ assert_eq!(page.common_prefixes, ["目录/子/"]);
+ assert!(page.is_truncated);
+ assert_eq!(page.next_continuation_token.as_deref(), Some("opaque+/="));
+ assert_requests(&recorded, &[("GET", "/storage/v1/b/legacy/o?delimiter=%2F&maxResults=1")]);
+ }
+
#[tokio::test]
async fn native_listing_preserves_zero_size_unicode_prefixes_and_opaque_cursors() {
let body = r#"{"items":[{"name":"目录/空 & file","size":"0"}],"prefixes":["目录/子/"],"nextPageToken":"opaque+/="}"#;