From 1a68d10e5060ac440b11822a850c41a24a3d0606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennard=20Gei=C3=9Fler?= Date: Sat, 26 Sep 2026 10:36:24 -0700 Subject: [PATCH] fix: return Marker when the request omits it ListObjects omitted the Marker element when the request had no marker. S3 returns an empty Marker element in this case, allowing clients to read the field without a presence check. The backend converts an empty marker to a nil pointer and encoding/xml omits nil pointers. Restore the request marker in the controller after the backend call so the behavior is consistent across backends. Update the controller, integration, and REST coverage to verify that an empty Marker element is returned. Fixes #2191 --- s3api/controllers/bucket-get.go | 8 +++++ s3api/controllers/bucket-get_test.go | 9 ++++- .../drivers/list_objects/list_objects_rest.sh | 33 +++++++++++++++++++ tests/integration/ListObjects.go | 9 +++-- tests/test_rest_list_objects.sh | 20 +++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/s3api/controllers/bucket-get.go b/s3api/controllers/bucket-get.go index 96423fa0..0399c9ad 100644 --- a/s3api/controllers/bucket-get.go +++ b/s3api/controllers/bucket-get.go @@ -651,6 +651,14 @@ func (c S3ApiController) ListObjects(ctx fiber.Ctx) (*Response, error) { }, err } + // S3 echoes the request marker back in the response, and includes the + // element even when the request carried no marker. Backends return a nil + // pointer for an empty marker, which encoding/xml drops entirely, so + // restore it here for every backend. + if res.Marker == nil { + res.Marker = &marker + } + return &Response{ Headers: map[string]*string{ "x-amz-bucket-region": ®ion, diff --git a/s3api/controllers/bucket-get_test.go b/s3api/controllers/bucket-get_test.go index 3c65f72b..ac290891 100644 --- a/s3api/controllers/bucket-get_test.go +++ b/s3api/controllers/bucket-get_test.go @@ -1169,6 +1169,13 @@ func TestS3ApiController_ListObjects(t *testing.T) { {Key: utils.GetStringPtr("my-key")}, }, } + // The controller echoes the request marker back, so the element is in + // the response even when the backend leaves it unset. utils.GetStringPtr + // collapses "" to nil, which is the behaviour under test here, so take + // the address of an empty string directly. + emptyMarker := "" + listResultEchoed := listResult + listResultEchoed.Marker = &emptyMarker tests := []struct { name string input testInput @@ -1229,7 +1236,7 @@ func TestS3ApiController_ListObjects(t *testing.T) { }, output: testOutput{ response: &Response{ - Data: listResult, + Data: listResultEchoed, Headers: map[string]*string{ "x-amz-bucket-region": utils.GetStringPtr(defaultRegion), }, diff --git a/tests/drivers/list_objects/list_objects_rest.sh b/tests/drivers/list_objects/list_objects_rest.sh index 0598cd65..f23f51f7 100644 --- a/tests/drivers/list_objects/list_objects_rest.sh +++ b/tests/drivers/list_objects/list_objects_rest.sh @@ -564,3 +564,36 @@ list_objects_v1_check_nextmarker_empty() { fi return 0 } + +# S3 returns the request marker in a v1 listing response, including an empty +# element when the request carries no marker. +list_objects_v1_check_empty_marker() { + if ! check_param_count "list_objects_v1_check_empty_marker" "bucket" 1 $#; then + return 1 + fi + if ! response=$(get_file_names 2 2>&1); then + log 2 "error getting file names: $response" + return 1 + fi + read -r file_name list_bucket_file <<< "$response" + + if ! result=$(COMMAND_LOG="$COMMAND_LOG" BUCKET_NAME="$1" VERSION_TWO="FALSE" OUTPUT_FILE="$TEST_FILE_FOLDER/$file_name" ./tests/rest_scripts/list_objects.sh); then + log 2 "error listing objects: $result" + return 1 + fi + if [ "$result" != "200" ]; then + log 2 "expected '200' was '$result' ($(cat "$TEST_FILE_FOLDER/$file_name"))" + return 1 + fi + log 5 "objects: $(cat "$TEST_FILE_FOLDER/$file_name")" + if ! list_bucket_result=$(xmllint --xpath '//*[local-name()="ListBucketResult"]' "$TEST_FILE_FOLDER/$file_name" 2>&1); then + log 2 "error getting list bucket result: $list_bucket_result" + return 1 + fi + echo -n "$list_bucket_result" > "$TEST_FILE_FOLDER/$list_bucket_file" + if ! check_xml_element "$TEST_FILE_FOLDER/$list_bucket_file" "" "Marker"; then + log 2 "Marker should be present and empty" + return 1 + fi + return 0 +} diff --git a/tests/integration/ListObjects.go b/tests/integration/ListObjects.go index e0abf1c9..31dfead9 100644 --- a/tests/integration/ListObjects.go +++ b/tests/integration/ListObjects.go @@ -402,8 +402,13 @@ func ListObjects_list_all_objs(s *S3Conf) error { return err } - if out.Marker != nil { - return fmt.Errorf("expected the Marker to be nil, instead got %v", + // S3 echoes the request marker back, so the response carries the + // element with an empty value rather than omitting it + if out.Marker == nil { + return fmt.Errorf("expected the Marker to be an empty string, instead got nil") + } + if *out.Marker != "" { + return fmt.Errorf("expected the Marker to be an empty string, instead got %v", *out.Marker) } if out.NextMarker != nil { diff --git a/tests/test_rest_list_objects.sh b/tests/test_rest_list_objects.sh index 28c0ae4f..f215e700 100755 --- a/tests/test_rest_list_objects.sh +++ b/tests/test_rest_list_objects.sh @@ -347,3 +347,23 @@ list_objects_delimiter() { run list_objects_with_prefix_and_delimiter_check_results "$bucket_name" "$1" "$prefix" "/" "a-b/" "--" "a-b-1.txt" "a-b-2.txt" assert_success } + +# tags: curl,ListObjects,marker +@test "REST - ListObjects - Marker returned when request has no marker" { + run get_bucket_name "$BUCKET_ONE_NAME" + assert_success + bucket_name="$output" + + run get_file_name + assert_success + test_file="$output" + + run setup_bucket_and_file_v2 "$bucket_name" "$test_file" + assert_success + + run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file" + assert_success + + run list_objects_v1_check_empty_marker "$bucket_name" + assert_success +}