From 88aeaaa2e66a2bc20250a41e115d377759ac183f Mon Sep 17 00:00:00 2001 From: overtrue Date: Sun, 6 Sep 2026 02:03:45 +0800 Subject: [PATCH] test(odm): pin ambiguous native listing and absence failures --- rustfs/src/on_demand_migration/azure.rs | 94 +++++++++++++++++++++++++ rustfs/src/on_demand_migration/gcs.rs | 42 +++++++++++ 2 files changed, 136 insertions(+) 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+/="}"#;