s3: keep a missing object a 404 under If-Match and If-Unmodified-Since (#10985)

* s3: keep a missing object a 404 under If-Match and If-Unmodified-Since

GET and HEAD resolved the target before evaluating the conditional headers, and
a missing target failed If-Match and If-Unmodified-Since outright, so absence
surfaced as 412 PreconditionFailed. AWS reports the missing object instead:
404 for HeadObject, NoSuchKey for GetObject, and 412 only when a live object
fails the condition. Clients cannot tell absence from a stale precondition
without an extra racy HEAD, so OpenDAL disabled its four conditional
stat/read capabilities against SeaweedFS.

A precondition now only fails against an object that exists; a missing one --
including a latest version that is a delete marker -- returns NoSuchKey.

Claude-Session: https://claude.ai/code/session_01X4kEbuwxd9DFsTnSXjfjgv

* s3: evaluate a conditional read against the version the request names

GET and HEAD resolved the latest version before evaluating the conditional
headers, so a request carrying versionId had its If-Match compared against a
different version than the one it was asking for: a live version whose ETag the
client held failed once a newer version -- or a delete marker -- became the
latest. resolveObjectEntry now resolves the named version on a versioned bucket,
the way DELETE already does.

A named version that resolves to nothing is left to the handler, which alone
knows whether the bucket is versioned and so whether it owes NoSuchVersion.

Claude-Session: https://claude.ai/code/session_01X4kEbuwxd9DFsTnSXjfjgv
This commit is contained in:
Chris Lu
2026-08-27 11:56:33 -07:00
committed by GitHub
parent 2d25c39da4
commit d8a189f07f
6 changed files with 215 additions and 19 deletions
@@ -0,0 +1,130 @@
package s3api
import (
"context"
"errors"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func requireAPIErrorCode(t *testing.T, err error, expected string) {
t.Helper()
require.Error(t, err)
var apiErr smithy.APIError
require.True(t, errors.As(err, &apiErr), "expected a smithy.APIError, got %T: %v", err, err)
assert.Equal(t, expected, apiErr.ErrorCode())
}
// TestConditionalReadsOfMissingObject verifies that a missing key stays a missing key
// under If-Match and If-Unmodified-Since instead of surfacing as 412.
// reproduces issue #10984
func TestConditionalReadsOfMissingObject(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
existing := putObject(t, client, bucketName, "etag-source", "content")
require.NotNil(t, existing.ETag)
future := aws.Time(time.Now().Add(24 * time.Hour))
missing := aws.String("conditional-missing")
t.Run("HeadObject If-Match", func(t *testing.T) {
_, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName), Key: missing, IfMatch: existing.ETag,
})
requireAPIErrorCode(t, err, "NotFound")
})
t.Run("HeadObject If-Unmodified-Since", func(t *testing.T) {
_, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName), Key: missing, IfUnmodifiedSince: future,
})
requireAPIErrorCode(t, err, "NotFound")
})
t.Run("GetObject If-Match", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: missing, IfMatch: existing.ETag,
})
requireAPIErrorCode(t, err, "NoSuchKey")
})
t.Run("GetObject If-Unmodified-Since", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: missing, IfUnmodifiedSince: future,
})
requireAPIErrorCode(t, err, "NoSuchKey")
})
t.Run("GetObject stale If-Match on a live object stays 412", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: aws.String("etag-source"),
IfMatch: aws.String(`"0000000000000000000000000000dead"`),
})
requireAPIErrorCode(t, err, "PreconditionFailed")
})
}
// TestConditionalReadsOfNamedVersion verifies that a conditional GET or HEAD of an
// explicit versionId is evaluated against that version rather than the latest one,
// including when the latest version is a delete marker.
func TestConditionalReadsOfNamedVersion(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
enableVersioning(t, client, bucketName)
key := "conditional-read-version"
v1 := putObject(t, client, bucketName, key, "content-v1")
require.NotNil(t, v1.ETag)
require.NotNil(t, v1.VersionId)
v2 := putObject(t, client, bucketName, key, "content-v2")
require.NotNil(t, v2.ETag)
require.NotEqual(t, *v1.ETag, *v2.ETag)
t.Run("If-Match matches the named version, not the latest", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key),
VersionId: v1.VersionId, IfMatch: v1.ETag,
})
require.NoError(t, err)
})
t.Run("If-Match against the latest ETag fails on the named version", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key),
VersionId: v1.VersionId, IfMatch: v2.ETag,
})
requireAPIErrorCode(t, err, "PreconditionFailed")
})
_, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key),
})
require.NoError(t, err)
t.Run("named version survives a delete marker on the latest", func(t *testing.T) {
_, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key),
VersionId: v1.VersionId, IfMatch: v1.ETag,
})
require.NoError(t, err)
})
t.Run("delete marker latest is a missing object", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key), IfMatch: v1.ETag,
})
requireAPIErrorCode(t, err, "NoSuchKey")
})
}
+1 -1
View File
@@ -376,7 +376,7 @@ func applyMultipartSSES3HeadersFromUploadEntry(dst *filer_pb.Entry, sses3Info *m
}
func (s3a *S3ApiServer) prepareMultipartCompletionState(r *http.Request, input *s3.CompleteMultipartUploadInput, uploadDirectory, entryName, dirName string, completedPartNumbers []int, completedPartMap map[int][]string, maxPartNo int) (*multipartCompletionState, *CompleteMultipartUploadResult, s3err.ErrorCode) {
if entry, err := s3a.resolveObjectEntry(*input.Bucket, *input.Key); err == nil && entry != nil && entry.Extended != nil {
if entry, err := s3a.resolveObjectEntry(*input.Bucket, *input.Key, ""); err == nil && entry != nil && entry.Extended != nil {
if uploadId, ok := entry.Extended[s3_constants.SeaweedFSUploadId]; ok && *input.UploadId == string(uploadId) {
cleanupEntries, _, cleanupErr := s3a.list(uploadDirectory, "", "", false, s3_constants.MaxS3MultipartParts+1)
if cleanupErr != nil && !errors.Is(cleanupErr, filer_pb.ErrNotFound) {
+7 -3
View File
@@ -423,9 +423,10 @@ func (s3a *S3ApiServer) checkDirectoryObject(bucket, object string) (*filer_pb.E
return dirEntry, true, nil
}
// resolveObjectEntry resolves the object entry for conditional checks,
// handling versioned buckets by resolving the latest version.
func (s3a *S3ApiServer) resolveObjectEntry(bucket, object string) (*filer_pb.Entry, error) {
// resolveObjectEntry resolves the object entry for conditional checks: the version the
// request names when the bucket is versioned, otherwise the latest version. Callers
// with no version to target pass an empty versionId.
func (s3a *S3ApiServer) resolveObjectEntry(bucket, object, versionId string) (*filer_pb.Entry, error) {
// Check if versioning is configured
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
if err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
@@ -434,6 +435,9 @@ func (s3a *S3ApiServer) resolveObjectEntry(bucket, object string) (*filer_pb.Ent
}
if versioningConfigured {
if versionId != "" {
return s3a.getSpecificObjectVersion(bucket, object, versionId)
}
// For versioned buckets, we must use getLatestObjectVersion to correctly
// find the latest versioned object (in .versions/) or null version.
// Standard getEntry would fail to find objects moved to .versions/.
@@ -0,0 +1,60 @@
package s3api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// A precondition can only fail against an object that exists. GET/HEAD of a missing
// key must stay a missing-key answer even when If-Match or If-Unmodified-Since is sent.
func TestValidateConditionalHeadersForReadsMissingObject(t *testing.T) {
s3a := &S3ApiServer{}
existing := &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()},
Extended: map[string][]byte{s3_constants.ExtETagKey: []byte("d41d8cd98f00b204e9800998ecf8427e")},
}
deleteMarker := &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()},
Extended: map[string][]byte{s3_constants.ExtDeleteMarkerKey: []byte("true")},
}
future := time.Now().Add(24 * time.Hour).UTC().Format(http.TimeFormat)
testCases := []struct {
name string
header string
value string
entry *filer_pb.Entry
want s3err.ErrorCode
}{
{"if-match on missing object", s3_constants.IfMatch, "0000", nil, s3err.ErrNoSuchKey},
{"if-match star on missing object", s3_constants.IfMatch, "*", nil, s3err.ErrNoSuchKey},
{"if-unmodified-since on missing object", s3_constants.IfUnmodifiedSince, future, nil, s3err.ErrNoSuchKey},
{"if-match on delete marker", s3_constants.IfMatch, "0000", deleteMarker, s3err.ErrNoSuchKey},
{"if-none-match on missing object", s3_constants.IfNoneMatch, "*", nil, s3err.ErrNone},
{"if-modified-since on missing object", s3_constants.IfModifiedSince, future, nil, s3err.ErrNone},
{"if-match mismatch on existing object", s3_constants.IfMatch, "0000", existing, s3err.ErrPreconditionFailed},
{"if-match hit on existing object", s3_constants.IfMatch, "d41d8cd98f00b204e9800998ecf8427e", existing, s3err.ErrNone},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/bucket/object", nil)
r.Header.Set(tc.header, tc.value)
headers, errCode := parseConditionalHeaders(r)
if errCode != s3err.ErrNone {
t.Fatalf("parseConditionalHeaders: %v", errCode)
}
result := s3a.validateConditionalHeadersForReads(r, headers, tc.entry, "bucket", "object")
if result.ErrorCode != tc.want {
t.Errorf("got %v, want %v", result.ErrorCode, tc.want)
}
})
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ func (s3a *S3ApiServer) resolveDeleteConditionalEntry(bucket, object, versionId,
}
return normalizeConditionalTargetEntry(entry), nil
default:
entry, err := s3a.resolveObjectEntry(bucket, normalizedObject)
entry, err := s3a.resolveObjectEntry(bucket, normalizedObject, "")
if err != nil {
return nil, err
}
+16 -14
View File
@@ -2277,7 +2277,7 @@ func (s3a *S3ApiServer) checkConditionalHeaders(r *http.Request, bucket, object
// Use resolveObjectEntry to correctly handle versioned objects.
// This ensures we check conditions against the LATEST version, not a null version.
entry, err := s3a.resolveObjectEntry(bucket, object)
entry, err := s3a.resolveObjectEntry(bucket, object, "")
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrDeleteMarker) {
entry = nil
@@ -2298,18 +2298,14 @@ func (s3a *S3ApiServer) validateConditionalHeadersForReads(r *http.Request, head
entry = normalizeConditionalTargetEntry(entry)
objectExists := entry != nil
// If object doesn't exist, fail for If-Match and If-Unmodified-Since
// A precondition only fails against an object that exists: AWS keeps GET/HEAD of a
// missing key a missing-key answer, so a condition never turns absence into 412.
// If-None-Match and If-Modified-Since pass here and the handler answers 404 itself.
if !objectExists {
if headers.ifMatch != "" {
glog.V(3).Infof("validateConditionalHeadersForReads: If-Match failed - object %s/%s does not exist", bucket, object)
return ConditionalHeaderResult{ErrorCode: s3err.ErrPreconditionFailed, Entry: nil}
if headers.ifMatch != "" || !headers.ifUnmodifiedSince.IsZero() {
glog.V(3).Infof("validateConditionalHeadersForReads: object %s/%s does not exist", bucket, object)
return ConditionalHeaderResult{ErrorCode: s3err.ErrNoSuchKey, Entry: nil}
}
if !headers.ifUnmodifiedSince.IsZero() {
glog.V(3).Infof("validateConditionalHeadersForReads: If-Unmodified-Since failed - object %s/%s does not exist", bucket, object)
return ConditionalHeaderResult{ErrorCode: s3err.ErrPreconditionFailed, Entry: nil}
}
// If-None-Match and If-Modified-Since succeed when object doesn't exist
// No entry to return since object doesn't exist
return ConditionalHeaderResult{ErrorCode: s3err.ErrNone, Entry: nil}
}
@@ -2396,9 +2392,10 @@ func (s3a *S3ApiServer) checkConditionalHeadersForReads(r *http.Request, bucket,
return ConditionalHeaderResult{ErrorCode: s3err.ErrNone, Entry: nil}
}
// Use resolveObjectEntry to correctly handle versioned objects.
// This ensures we check conditions against the LATEST version, not a null version.
entry, err := s3a.resolveObjectEntry(bucket, object)
// Use resolveObjectEntry to correctly handle versioned objects: the version the
// request names, or the LATEST version rather than a null version.
versionId := r.URL.Query().Get("versionId")
entry, err := s3a.resolveObjectEntry(bucket, object, versionId)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrDeleteMarker) {
entry = nil
@@ -2407,6 +2404,11 @@ func (s3a *S3ApiServer) checkConditionalHeadersForReads(r *http.Request, bucket,
return ConditionalHeaderResult{ErrorCode: s3err.ErrInternalError, Entry: nil}
}
}
// A named version that resolves to nothing is the handler's answer to give: only it
// knows whether the bucket is versioned, and so whether that is NoSuchVersion.
if versionId != "" && normalizeConditionalTargetEntry(entry) == nil {
return ConditionalHeaderResult{ErrorCode: s3err.ErrNone, Entry: nil}
}
return s3a.validateConditionalHeadersForReads(r, headers, entry, bucket, object)
}