From 2bd0f113f19717301b4829e61c7e51661e60e5b7 Mon Sep 17 00:00:00 2001 From: Allan Roger Reid Date: Sat, 1 Aug 2026 01:47:20 +0000 Subject: [PATCH 1/3] fix: return delete-marker ObjectInfo from StatObject alongside the error StatObject's delete-marker block became unreachable for its intended 405/404 paths after #2115 made executeMethod error on every response outside its success set. Move the block into the error branch so VersionID and IsDeleteMarker plus the MethodNotAllowed code (405 path) and ReplicationReady (other error responses) reach callers again, as before v7.0.93. Fixes #2260 --- api-stat.go | 41 ++++---- api-stat_test.go | 236 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 21 deletions(-) create mode 100644 api-stat_test.go diff --git a/api-stat.go b/api-stat.go index a4b2af7ae..5bbb8af19 100644 --- a/api-stat.go +++ b/api-stat.go @@ -92,32 +92,31 @@ func (c *Client) StatObject(ctx context.Context, bucketName, objectName string, }) defer closeResponse(resp) if err != nil { - return ObjectInfo{}, err - } - - if resp != nil { + // Surface the version, delete-marker, and replication-ready + // fields carried in the response headers alongside the error. + if resp == nil { + return ObjectInfo{}, err + } deleteMarker := resp.Header.Get(amzDeleteMarker) == "true" replicationReady := resp.Header.Get(minioTgtReplicationReady) == "true" - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { - if resp.StatusCode == http.StatusMethodNotAllowed && opts.VersionID != "" && deleteMarker { - errResp := ErrorResponse{ - StatusCode: resp.StatusCode, - Code: MethodNotAllowed, - Message: s3ErrorResponseMap[MethodNotAllowed], - BucketName: bucketName, - Key: objectName, - } - return ObjectInfo{ - VersionID: resp.Header.Get(amzVersionID), - IsDeleteMarker: deleteMarker, - }, errResp + if resp.StatusCode == http.StatusMethodNotAllowed && opts.VersionID != "" && deleteMarker { + errResp := ErrorResponse{ + StatusCode: resp.StatusCode, + Code: MethodNotAllowed, + Message: s3ErrorResponseMap[MethodNotAllowed], + BucketName: bucketName, + Key: objectName, } return ObjectInfo{ - VersionID: resp.Header.Get(amzVersionID), - IsDeleteMarker: deleteMarker, - ReplicationReady: replicationReady, // whether delete marker can be replicated - }, httpRespToErrorResponse(resp, bucketName, objectName) + VersionID: resp.Header.Get(amzVersionID), + IsDeleteMarker: deleteMarker, + }, errResp } + return ObjectInfo{ + VersionID: resp.Header.Get(amzVersionID), + IsDeleteMarker: deleteMarker, + ReplicationReady: replicationReady, + }, err } return ToObjectInfo(bucketName, objectName, resp.Header) diff --git a/api-stat_test.go b/api-stat_test.go new file mode 100644 index 000000000..b37d43822 --- /dev/null +++ b/api-stat_test.go @@ -0,0 +1,236 @@ +/* + * MinIO Go Library for Amazon S3 Compatible Cloud Storage + * Copyright 2026 MinIO, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package minio + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// Tests that StatObject returns the delete-marker ObjectInfo fields +// (VersionID, IsDeleteMarker) and the MethodNotAllowed error code when a +// versioned HEAD hits a delete marker (HTTP 405). +func TestStatObjectDeleteMarker(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(amzDeleteMarker, "true") + w.Header().Set(amzVersionID, "test-version-id") + w.WriteHeader(http.StatusMethodNotAllowed) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + + clnt, err := New(u.Host, &Options{ + Creds: credentials.NewStaticV4("foo", "foo12345", ""), + Region: "us-east-1", + }) + if err != nil { + t.Fatal(err) + } + + objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", + StatObjectOptions{VersionID: "test-version-id"}) + if err == nil { + t.Fatal("expected error for delete marker, got nil") + } + errResp := ToErrorResponse(err) + if errResp.Code != MethodNotAllowed { + t.Errorf("error code = %q, want %q", errResp.Code, MethodNotAllowed) + } + if errResp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("error status = %d, want %d", errResp.StatusCode, http.StatusMethodNotAllowed) + } + if !objInfo.IsDeleteMarker { + t.Error("expected IsDeleteMarker to be true") + } + if objInfo.VersionID != "test-version-id" { + t.Errorf("VersionID = %q, want %q", objInfo.VersionID, "test-version-id") + } +} + +// Tests that a 405 response missing either half of the delete-marker +// shape (the x-amz-delete-marker header, or a version-targeted stat) +// falls through to the generic error path with the raw status code. +func TestStatObjectMethodNotAllowedGeneric(t *testing.T) { + tests := []struct { + name string + deleteMarker bool + versionID string + }{ + {"no delete-marker header", false, "test-version-id"}, + {"no version id", true, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if tt.deleteMarker { + w.Header().Set(amzDeleteMarker, "true") + } + w.Header().Set(amzVersionID, "test-version-id") + w.WriteHeader(http.StatusMethodNotAllowed) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + + clnt, err := New(u.Host, &Options{ + Creds: credentials.NewStaticV4("foo", "foo12345", ""), + Region: "us-east-1", + }) + if err != nil { + t.Fatal(err) + } + + objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", + StatObjectOptions{VersionID: tt.versionID}) + if err == nil { + t.Fatal("expected error, got nil") + } + if errResp := ToErrorResponse(err); errResp.Code != "405 Method Not Allowed" { + t.Errorf("error code = %q, want %q", errResp.Code, "405 Method Not Allowed") + } + if objInfo.IsDeleteMarker != tt.deleteMarker { + t.Errorf("IsDeleteMarker = %v, want %v", objInfo.IsDeleteMarker, tt.deleteMarker) + } + if objInfo.VersionID != "test-version-id" { + t.Errorf("VersionID = %q, want %q", objInfo.VersionID, "test-version-id") + } + }) + } +} + +// Tests that 202 and 204 responses, which executeMethod treats as +// success, are parsed like a 200 instead of being converted into errors. +func TestStatObjectNoContentSuccess(t *testing.T) { + for _, status := range []int{http.StatusAccepted, http.StatusNoContent} { + t.Run(http.StatusText(status), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Last-Modified", "Thu, 30 Jul 2026 00:00:00 GMT") + w.Header().Set("ETag", `"deadbeef"`) + w.Header().Set(amzVersionID, "test-version-id") + w.WriteHeader(status) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + + clnt, err := New(u.Host, &Options{ + Creds: credentials.NewStaticV4("foo", "foo12345", ""), + Region: "us-east-1", + }) + if err != nil { + t.Fatal(err) + } + + objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", StatObjectOptions{}) + if err != nil { + t.Fatalf("expected nil error for %d, got %v", status, err) + } + if objInfo.ETag != "deadbeef" { + t.Errorf("ETag = %q, want %q", objInfo.ETag, "deadbeef") + } + if objInfo.VersionID != "test-version-id" { + t.Errorf("VersionID = %q, want %q", objInfo.VersionID, "test-version-id") + } + }) + } +} + +// Tests that StatObject returns a zero ObjectInfo when the request fails +// before any response is received. +func TestStatObjectNoResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + addr := srv.Listener.Addr().String() + srv.Close() + + clnt, err := New(addr, &Options{ + Creds: credentials.NewStaticV4("foo", "foo12345", ""), + Region: "us-east-1", + MaxRetries: 1, + }) + if err != nil { + t.Fatal(err) + } + + objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", StatObjectOptions{}) + if err == nil { + t.Fatal("expected error for unreachable endpoint, got nil") + } + if objInfo.IsDeleteMarker || objInfo.VersionID != "" || objInfo.ETag != "" { + t.Errorf("expected zero ObjectInfo, got %+v", objInfo) + } +} + +// Tests that StatObject surfaces the delete-marker and replication-ready +// headers on a generic error response, e.g. HEAD on an object whose +// latest version is a delete marker (HTTP 404). +func TestStatObjectErrorHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(amzDeleteMarker, "true") + w.Header().Set(amzVersionID, "test-version-id") + w.Header().Set(minioTgtReplicationReady, "true") + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + + clnt, err := New(u.Host, &Options{ + Creds: credentials.NewStaticV4("foo", "foo12345", ""), + Region: "us-east-1", + }) + if err != nil { + t.Fatal(err) + } + + objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", StatObjectOptions{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if errResp := ToErrorResponse(err); errResp.Code != NoSuchKey { + t.Errorf("error code = %q, want %q", errResp.Code, NoSuchKey) + } + if !objInfo.IsDeleteMarker { + t.Error("expected IsDeleteMarker to be true") + } + if objInfo.VersionID != "test-version-id" { + t.Errorf("VersionID = %q, want %q", objInfo.VersionID, "test-version-id") + } + if !objInfo.ReplicationReady { + t.Error("expected ReplicationReady to be true") + } +} From 46683fe8037bf80d5f658ffd50a97fb9c58f3cc6 Mon Sep 17 00:00:00 2001 From: Allan Roger Reid Date: Sat, 1 Aug 2026 12:32:20 +0000 Subject: [PATCH 2/3] ci: pin vcpkg checkout in go-rdma to an immutable sha The go-rdma workflow checks out microsoft/vcpkg master at run time. Since vcpkg commit 5397c5c9f its port scripts use string(JSON ... STRING_ENCODE), which needs a newer CMake than the ubuntu-24.04-arm runner provides, so every arm64 run fails while building openssl before Go is even set up. Pin vcpkg to the commit behind release 2026.06.24, the last release that predates the change; the amd64 lane is unaffected because vcpkg bootstraps its own newer CMake there. The tag anchor comment records the human-readable version for future bumps. No third-party container images exist in this repo's workflows to digest-pin alongside it. --- .github/workflows/go-rdma.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/go-rdma.yml b/.github/workflows/go-rdma.yml index 8521da8b4..fea03064d 100644 --- a/.github/workflows/go-rdma.yml +++ b/.github/workflows/go-rdma.yml @@ -36,6 +36,10 @@ jobs: uses: actions/checkout@v4 with: repository: microsoft/vcpkg + # Pinned: vcpkg master requires a newer CMake than the arm64 + # runner image ships (scripts use string(JSON ... STRING_ENCODE) + # since microsoft/vcpkg@5397c5c9f), which fails every arm64 run. + ref: cd61e1e26a038e82d6550a3ebbe0fbbfe7da78e3 # tag 2026.06.24 path: "vcpkg" - name: Install system dependencies From d3d5611684abf34447a07b4f71e1d0ec70cb99d0 Mon Sep 17 00:00:00 2001 From: Allan Roger Reid Date: Sat, 1 Aug 2026 22:37:34 +0000 Subject: [PATCH 3/3] fix: tighten StatObject error-path docs, tests, and go-rdma checkout hygiene Correct the StatObject error-branch comment and godoc to state only what each path surfaces, restore the ReplicationReady field note, extract a newTestStatClient helper with strengthened delete-marker assertions, fix the vcpkg pin comment (CMake >= 4.3 arrives via vcpkg's per-arch tool bootstrap, absent on arm64), and set persist-credentials: false on all three go-rdma.yml checkouts. --- .github/workflows/go-rdma.yml | 11 +++- api-stat.go | 13 +++-- api-stat_test.go | 101 ++++++++++++---------------------- 3 files changed, 53 insertions(+), 72 deletions(-) diff --git a/.github/workflows/go-rdma.yml b/.github/workflows/go-rdma.yml index fea03064d..5e30d3189 100644 --- a/.github/workflows/go-rdma.yml +++ b/.github/workflows/go-rdma.yml @@ -24,22 +24,27 @@ jobs: - name: Checkout minio-go uses: actions/checkout@v4 with: + persist-credentials: false path: "minio-go" - name: Checkout minio-cpp uses: actions/checkout@v4 with: repository: minio/minio-cpp + persist-credentials: false path: "minio-cpp" - name: Checkout vcpkg uses: actions/checkout@v4 with: repository: microsoft/vcpkg - # Pinned: vcpkg master requires a newer CMake than the arm64 - # runner image ships (scripts use string(JSON ... STRING_ENCODE) - # since microsoft/vcpkg@5397c5c9f), which fails every arm64 run. + # Pinned: vcpkg master needs CMake >= 4.3 (scripts use + # string(JSON ... STRING_ENCODE) since microsoft/vcpkg@5397c5c9f). + # vcpkg bootstraps its own CMake 4.4 on linux-amd64 but not on + # linux-arm64, which falls back to apt's CMake 3.28 and fails + # every arm64 run. Unpin once vcpkg supplies an arm64 CMake. ref: cd61e1e26a038e82d6550a3ebbe0fbbfe7da78e3 # tag 2026.06.24 + persist-credentials: false path: "vcpkg" - name: Install system dependencies diff --git a/api-stat.go b/api-stat.go index 5bbb8af19..e883d04df 100644 --- a/api-stat.go +++ b/api-stat.go @@ -57,7 +57,11 @@ func (c *Client) BucketExists(ctx context.Context, bucketName string) (bool, err } // StatObject verifies if object exists, you have permission to access it -// and returns information about the object. +// and returns information about the object. When the returned error is +// non-nil but a response was received, the ObjectInfo still carries the +// VersionID and IsDeleteMarker values parsed from the response headers, +// plus ReplicationReady on every error path except the versioned +// delete-marker 405. func (c *Client) StatObject(ctx context.Context, bucketName, objectName string, opts StatObjectOptions) (ObjectInfo, error) { // Input validation. if err := s3utils.CheckValidBucketName(bucketName); err != nil { @@ -92,8 +96,9 @@ func (c *Client) StatObject(ctx context.Context, bucketName, objectName string, }) defer closeResponse(resp) if err != nil { - // Surface the version, delete-marker, and replication-ready - // fields carried in the response headers alongside the error. + // executeMethod returns a non-nil error for every non-success + // status. When a response exists, its headers still carry the + // version and delete-marker fields — surface them with the error. if resp == nil { return ObjectInfo{}, err } @@ -115,7 +120,7 @@ func (c *Client) StatObject(ctx context.Context, bucketName, objectName string, return ObjectInfo{ VersionID: resp.Header.Get(amzVersionID), IsDeleteMarker: deleteMarker, - ReplicationReady: replicationReady, + ReplicationReady: replicationReady, // whether delete marker can be replicated }, err } diff --git a/api-stat_test.go b/api-stat_test.go index b37d43822..7de66f9fb 100644 --- a/api-stat_test.go +++ b/api-stat_test.go @@ -21,35 +21,40 @@ import ( "context" "net/http" "net/http/httptest" - "net/url" + "reflect" "testing" "github.com/minio/minio-go/v7/pkg/credentials" ) -// Tests that StatObject returns the delete-marker ObjectInfo fields -// (VersionID, IsDeleteMarker) and the MethodNotAllowed error code when a -// versioned HEAD hits a delete marker (HTTP 405). -func TestStatObjectDeleteMarker(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set(amzDeleteMarker, "true") - w.Header().Set(amzVersionID, "test-version-id") - w.WriteHeader(http.StatusMethodNotAllowed) - })) - defer srv.Close() - - u, err := url.Parse(srv.URL) - if err != nil { - t.Fatal(err) - } +// newTestStatClient returns a Client pointed at an httptest server that +// serves handler; the server is closed via t.Cleanup. +func newTestStatClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) - clnt, err := New(u.Host, &Options{ + clnt, err := New(srv.Listener.Addr().String(), &Options{ Creds: credentials.NewStaticV4("foo", "foo12345", ""), Region: "us-east-1", }) if err != nil { t.Fatal(err) } + return clnt +} + +// Tests that StatObject returns the delete-marker ObjectInfo fields +// (VersionID and IsDeleteMarker — ReplicationReady is deliberately not +// merged into this return) and the MethodNotAllowed error code when a +// versioned HEAD hits a delete marker (HTTP 405). +func TestStatObjectDeleteMarker(t *testing.T) { + clnt := newTestStatClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(amzDeleteMarker, "true") + w.Header().Set(amzVersionID, "test-version-id") + w.Header().Set(minioTgtReplicationReady, "true") + w.WriteHeader(http.StatusMethodNotAllowed) + }) objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", StatObjectOptions{VersionID: "test-version-id"}) @@ -63,18 +68,26 @@ func TestStatObjectDeleteMarker(t *testing.T) { if errResp.StatusCode != http.StatusMethodNotAllowed { t.Errorf("error status = %d, want %d", errResp.StatusCode, http.StatusMethodNotAllowed) } + if errResp.BucketName != "bucket-name" || errResp.Key != "object-name" { + t.Errorf("error bucket/key = %q/%q, want %q/%q", + errResp.BucketName, errResp.Key, "bucket-name", "object-name") + } if !objInfo.IsDeleteMarker { t.Error("expected IsDeleteMarker to be true") } if objInfo.VersionID != "test-version-id" { t.Errorf("VersionID = %q, want %q", objInfo.VersionID, "test-version-id") } + if objInfo.ReplicationReady { + t.Error("expected ReplicationReady to stay false on the delete-marker return") + } } // Tests that a 405 response missing either half of the delete-marker // shape (the x-amz-delete-marker header, or a version-targeted stat) // falls through to the generic error path with the raw status code. func TestStatObjectMethodNotAllowedGeneric(t *testing.T) { + const wantCode = "405 Method Not Allowed" tests := []struct { name string deleteMarker bool @@ -85,35 +98,21 @@ func TestStatObjectMethodNotAllowedGeneric(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + clnt := newTestStatClient(t, func(w http.ResponseWriter, _ *http.Request) { if tt.deleteMarker { w.Header().Set(amzDeleteMarker, "true") } w.Header().Set(amzVersionID, "test-version-id") w.WriteHeader(http.StatusMethodNotAllowed) - })) - defer srv.Close() - - u, err := url.Parse(srv.URL) - if err != nil { - t.Fatal(err) - } - - clnt, err := New(u.Host, &Options{ - Creds: credentials.NewStaticV4("foo", "foo12345", ""), - Region: "us-east-1", }) - if err != nil { - t.Fatal(err) - } objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", StatObjectOptions{VersionID: tt.versionID}) if err == nil { t.Fatal("expected error, got nil") } - if errResp := ToErrorResponse(err); errResp.Code != "405 Method Not Allowed" { - t.Errorf("error code = %q, want %q", errResp.Code, "405 Method Not Allowed") + if errResp := ToErrorResponse(err); errResp.Code != wantCode { + t.Errorf("error code = %q, want %q", errResp.Code, wantCode) } if objInfo.IsDeleteMarker != tt.deleteMarker { t.Errorf("IsDeleteMarker = %v, want %v", objInfo.IsDeleteMarker, tt.deleteMarker) @@ -130,26 +129,12 @@ func TestStatObjectMethodNotAllowedGeneric(t *testing.T) { func TestStatObjectNoContentSuccess(t *testing.T) { for _, status := range []int{http.StatusAccepted, http.StatusNoContent} { t.Run(http.StatusText(status), func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + clnt := newTestStatClient(t, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Last-Modified", "Thu, 30 Jul 2026 00:00:00 GMT") w.Header().Set("ETag", `"deadbeef"`) w.Header().Set(amzVersionID, "test-version-id") w.WriteHeader(status) - })) - defer srv.Close() - - u, err := url.Parse(srv.URL) - if err != nil { - t.Fatal(err) - } - - clnt, err := New(u.Host, &Options{ - Creds: credentials.NewStaticV4("foo", "foo12345", ""), - Region: "us-east-1", }) - if err != nil { - t.Fatal(err) - } objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", StatObjectOptions{}) if err != nil { @@ -187,7 +172,7 @@ func TestStatObjectNoResponse(t *testing.T) { if err == nil { t.Fatal("expected error for unreachable endpoint, got nil") } - if objInfo.IsDeleteMarker || objInfo.VersionID != "" || objInfo.ETag != "" { + if !reflect.DeepEqual(objInfo, ObjectInfo{}) { t.Errorf("expected zero ObjectInfo, got %+v", objInfo) } } @@ -196,26 +181,12 @@ func TestStatObjectNoResponse(t *testing.T) { // headers on a generic error response, e.g. HEAD on an object whose // latest version is a delete marker (HTTP 404). func TestStatObjectErrorHeaders(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + clnt := newTestStatClient(t, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set(amzDeleteMarker, "true") w.Header().Set(amzVersionID, "test-version-id") w.Header().Set(minioTgtReplicationReady, "true") w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, err := url.Parse(srv.URL) - if err != nil { - t.Fatal(err) - } - - clnt, err := New(u.Host, &Options{ - Creds: credentials.NewStaticV4("foo", "foo12345", ""), - Region: "us-east-1", }) - if err != nil { - t.Fatal(err) - } objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", StatObjectOptions{}) if err == nil {