s3: apply Range to HeadObject (#11856)

* s3: apply Range to HeadObject

* s3: apply Range to a HEAD of a stored directory key

Directory-key entries answer through serveDirectoryContent before
HeadObjectHandler's range branch, so a ranged HEAD returned 200 and the
full length.

* s3: keep the missing-directory check for plain directories only

A stored directory-key marker is an object, so a range on its empty body
answers 416 like on any other zero-byte object.

* s3: let partNumber keep its priority over Range on a directory key HEAD

* s3: serve ranged GETs of a stored directory key

The same branch now applies the validated range to the inline content on
GET, so a directory-key object answers 206 with the selected bytes like
any other object.

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
This commit is contained in:
Jay HemnaniandChris Lu authored and GitHub committed 2026-10-21 13:37:00 -07:00
1 parent 23d8c62844
commit a3e97ec709
2 files changed
+65 -4

No files matched your search

+35 -4
View File
@@ -181,8 +181,10 @@ func (s3a *S3ApiServer) parseAndValidateRange(w http.ResponseWriter, r *http.Req
return 0, totalSize, false, nil
}
// Empty directory: 404. A file promoted to a directory keeps its data and stays retrievable.
if entry.IsDirectory && totalSize == 0 {
// Empty directory: 404. A file promoted to a directory keeps its data and
// stays retrievable, and a stored directory-key marker is an object, not a
// missing key — an unsatisfiable range answers 416.
if entry.IsDirectory && totalSize == 0 && !entry.IsDirectoryKeyObject() {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("directory object %s/%s cannot be retrieved", bucket, object))
}
@@ -533,6 +535,24 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
if r.Method == http.MethodHead && len(entry.Chunks) > 0 {
contentLength = int64(entry.Attributes.FileSize)
}
statusCode := http.StatusOK
if r.Header.Get("Range") != "" && requestedPartNumber(r) == 0 {
// A ranged request answers like the object path does: 206 with its
// Content-Range and the range's length, served on GET, reported on HEAD.
totalSize := contentLength
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, entry, totalSize, bucket, object)
if rangeErr != nil {
return
}
if isRangeRequest {
contentLength = size
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
statusCode = http.StatusPartialContent
if r.Method != http.MethodHead {
content = content[offset : offset+size]
}
}
}
w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
// Set last modified
@@ -545,12 +565,12 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
// For HEAD requests, don't write body
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
w.WriteHeader(statusCode)
return
}
// Write content
w.WriteHeader(http.StatusOK)
w.WriteHeader(statusCode)
if len(content) > 0 {
if _, err := w.Write(content); err != nil {
glog.Errorf("serveDirectoryContent: failed to write response: %v", err)
@@ -2745,6 +2765,17 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startOffset, endOffset, totalSize))
statusCode = http.StatusPartialContent
glog.V(3).Infof("HeadObject: part %d is bytes %d-%d/%d", partNumber, startOffset, endOffset, totalSize)
} else if r.Header.Get("Range") != "" {
// A ranged HEAD reports the range like GET does: 206 with its Content-Range and length
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, objectEntryForSSE, totalSize, bucket, object)
if rangeErr != nil {
return
}
if isRangeRequest {
responseSize = size
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
statusCode = http.StatusPartialContent
}
}
s3a.setResponseHeaders(w, r, objectEntryForSSE, responseSize)
+30
View File
@@ -40,3 +40,33 @@ func TestParseAndValidateRangeRejectsMalformedNumericOffsets(t *testing.T) {
})
}
}
func TestHeadObjectRange(t *testing.T) {
s3a, f := newAnonymousReadTestServer(t)
f.entries["/buckets/b/o"] = anonymousReadEntry(anonymousReadGrant("READ"))
for _, tc := range []struct {
rangeHeader, contentRange, contentLength string
code int
}{
{"", "", "11", http.StatusOK},
{"bytes=1-4", "bytes 1-4/11", "4", http.StatusPartialContent},
{"bytes=-3", "bytes 8-10/11", "3", http.StatusPartialContent},
{"bytes=5-", "bytes 5-10/11", "6", http.StatusPartialContent},
{"bytes=20-", "bytes */11", "", http.StatusRequestedRangeNotSatisfiable},
} {
headers := map[string]string{}
if tc.rangeHeader != "" {
headers["Range"] = tc.rangeHeader
}
rr := serveAnonymousRead(s3a, http.MethodHead, "o", "", headers)
if rr.Code != tc.code {
t.Errorf("Range %q: status %d, want %d", tc.rangeHeader, rr.Code, tc.code)
}
if got := rr.Header().Get("Content-Range"); got != tc.contentRange {
t.Errorf("Range %q: Content-Range %q, want %q", tc.rangeHeader, got, tc.contentRange)
}
if got := rr.Header().Get("Content-Length"); tc.contentLength != "" && got != tc.contentLength {
t.Errorf("Range %q: Content-Length %q, want %q", tc.rangeHeader, got, tc.contentLength)
}
}
}