s3: answer GET and HEAD of a delete marker as S3 does (#12051)

* s3: answer GET and HEAD of a delete marker as S3 does

A delete marker as the current version now returns 404 NoSuchKey with
x-amz-delete-marker: true and the marker's x-amz-version-id. Asking for the
marker by versionId returns 405 MethodNotAllowed with the same headers and
Last-Modified, instead of 404 NoSuchKey.

* s3: check the tag-conditioned read grant before answering a delete marker

The marker branch returned before the entry-based policy recheck the rest
of the read path performs, so an s3:ExistingObjectTag-conditioned Deny did
not apply to the delete-marker response and its version headers.

* s3: send Allow: DELETE with the delete-marker 405

RFC 9110 15.5.6 requires a 405 answer to list the methods the resource
supports; S3 sends allow: DELETE for a delete marker named by versionId.

* s3: keep delete-marker headers on conditional-read misses

If-Match or If-Unmodified-Since against a current delete marker short-
circuited to a bare NoSuchKey. Carry the resolved marker through the
conditional result so the 404 keeps x-amz-delete-marker and
x-amz-version-id, matching the unconditional read.

* s3: exercise delete-marker responses through GET and HEAD handlers

* s3: read marker version id from the entry before the caller's guess

A delete marker written outside the normal PUT path can miss the
version-id attribute while the v_<id> file name still names it; the
new x-amz-version-id header would then report "null". Reuse
versionIdFromEntry so both the handler and conditional-miss paths
emit the real id.

* s3: answer 405 for versionId=null on a suspended-versioning marker

The null marker lives in .versions while getSpecificObjectVersion only
checks the plain path, so GET/HEAD ?versionId=null answered
NoSuchVersion. On that miss, look up the null marker and answer it
like any other marker named by versionId.

* s3: propagate null-marker lookup failures instead of answering 404

A transient filer error reading .versions/v_null was swallowed as
"no marker", so GET/HEAD answered NoSuchVersion for a live read
failure. NotFound and absent entries still mean no marker; anything
else is InternalError now.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
Co-authored-by: chrislusf <chrislusf@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-25 12:02:19 -07:00
1 parent 9620e7ebfe
commit 515380c349
5 files changed
+333 -11

No files matched your search

+20
View File
@@ -314,6 +314,26 @@ func (s3a *S3ApiServer) createNullDeleteMarker(bucket, object string) error {
return nil
}
// nullDeleteMarkerEntry returns the suspended-versioning "null" delete marker
// stored under .versions, or nil when the object has none.
// getSpecificObjectVersion only checks the plain path for "null", so callers
// that must answer a marker named by versionId consult this on a miss. Lookup
// failures other than not found propagate: a masked read error would answer
// a live filer hiccup as a deleted version.
func (s3a *S3ApiServer) nullDeleteMarkerEntry(bucket, object string) (*filer_pb.Entry, error) {
entry, err := s3a.getEntry(s3a.getVersionedObjectDir(bucket, s3_constants.NormalizeObjectKey(object)), s3a.getVersionFileName("null"))
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return nil, nil
}
return nil, err
}
if entry == nil || string(entry.Extended[s3_constants.ExtDeleteMarkerKey]) != "true" {
return nil, nil
}
return entry, nil
}
// versionListItem represents an item in the unified version/prefix list
type versionListItem struct {
key string