Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/go-rdma.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,27 @@ jobs:
- name: Checkout minio-go
uses: actions/checkout@v4
with:
persist-credentials: false
Comment thread
allanrogerr marked this conversation as resolved.
path: "minio-go"

- name: Checkout minio-cpp
uses: actions/checkout@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
48 changes: 26 additions & 22 deletions api-stat.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ func (c *Client) BucketExists(ctx context.Context, bucketName string) (bool, err
}
Comment thread
allanrogerr marked this conversation as resolved.

// 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
Comment thread
allanrogerr marked this conversation as resolved.
// 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 {
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return ToObjectInfo(bucketName, objectName, resp.Header)
Expand Down
207 changes: 207 additions & 0 deletions api-stat_test.go
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
allanrogerr marked this conversation as resolved.
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")
}
}
Comment thread
allanrogerr marked this conversation as resolved.
Loading