diff --git a/.github/workflows/go-rdma.yml b/.github/workflows/go-rdma.yml index 8521da8b4..5e30d3189 100644 --- a/.github/workflows/go-rdma.yml +++ b/.github/workflows/go-rdma.yml @@ -24,18 +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 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 a4b2af7ae..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,32 +96,32 @@ func (c *Client) StatObject(ctx context.Context, bucketName, objectName string, }) defer closeResponse(resp) if err != nil { - return ObjectInfo{}, err - } - - if resp != nil { + // 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 + } 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, // whether delete marker can be replicated + }, 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..7de66f9fb --- /dev/null +++ b/api-stat_test.go @@ -0,0 +1,207 @@ +/* + * 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" + "reflect" + "testing" + + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// 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(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"}) + 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 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 + 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) { + 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) + }) + + 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 != 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) + } + 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) { + 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) + }) + + 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 !reflect.DeepEqual(objInfo, ObjectInfo{}) { + 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) { + 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) + }) + + 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") + } +}