From a3e97ec7090379340fdc5bc2d8531a175c27f9db Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:07:00 +0530 Subject: [PATCH] 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 --- weed/s3api/s3api_object_handlers.go | 39 ++++++++++++++++++++++++--- weed/s3api/s3api_object_range_test.go | 30 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/weed/s3api/s3api_object_handlers.go b/weed/s3api/s3api_object_handlers.go index a29994b9e8..96d3dcade8 100644 --- a/weed/s3api/s3api_object_handlers.go +++ b/weed/s3api/s3api_object_handlers.go @@ -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) diff --git a/weed/s3api/s3api_object_range_test.go b/weed/s3api/s3api_object_range_test.go index ced294148e..40ccb8768b 100644 --- a/weed/s3api/s3api_object_range_test.go +++ b/weed/s3api/s3api_object_range_test.go @@ -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) + } + } +}