s3: HeadObject with partNumber returns the part's size and 206 (#11166)

* s3: HEAD with partNumber reports the part's size and range

HeadObject set its headers from the total object size and then only
validated the partNumber, so a client probing part 1 with HEAD got the
whole object's Content-Length and a 200 while the same GET returned the
part's size, a Content-Range and a 206.

Resolve the part's byte range before the headers are written, through the
range logic GetObject already used, and answer a partNumber HEAD as the
ranged HEAD that AWS documents.

* s3: answer an unsatisfiable partNumber with 416 InvalidPartNumber

GET and HEAD rejected a partNumber past the number of parts with 400
InvalidPart, the code for a missing part in CompleteMultipartUpload. AWS
answers a read of a part that does not exist with 416 InvalidPartNumber,
which lets a client probing for the part count tell the two apart.

The ceph suite pins RGW's 400 InvalidPart here, so the s3tests jobs patch
that expectation the way they already patch prefix ordering.

* s3: keep the whole-object checksum off a partNumber response

The stored checksum covers the whole object, so it is already withheld
from a ranged read. A partNumber HEAD now describes one part while the
request carries no Range header, so exclude it there too rather than
handing a client a checksum that does not match the bytes described.

* s3: resolve a partNumber against the parts the object records

Completion accepts ascending, not consecutive, part numbers, so the part
count is not the highest part number. Comparing the two rejected an
uploaded part 3 of a two-part object, and let a request for the absent
part 2 fall through to the positional chunk lookup and serve part 3's
bytes. Ask the recorded boundaries for the part instead, and keep the
count comparison for objects written before boundaries were stored.

* s3: apply a client Range within the part on HEAD too

GET narrowed the part by a Range sent alongside partNumber; HEAD reported
the whole part, so the two disagreed again for a request that carries
both. Move the narrowing into the shared range lookup so either verb
describes the same bytes.
This commit is contained in:
Chris Lu
2026-09-04 23:10:41 -07:00
committed by GitHub
parent 27b2411cdd
commit 5a515adab2
4 changed files with 257 additions and 112 deletions
+6
View File
@@ -128,6 +128,9 @@ jobs:
echo "All SeaweedFS components are ready!"
cd ../s3-tests
sed -i "s/assert prefixes == \['foo%2B1\/', 'foo\/', 'quux%20ab\/'\]/assert prefixes == \['foo\/', 'foo%2B1\/', 'quux%20ab\/'\]/" s3tests/functional/test_s3.py
# The suite expects RGW's 400 InvalidPart for a partNumber past the last
# part; AWS answers 416 InvalidPartNumber, which is what we return.
sed -i "/# request PartNumber out of range/,+4{s/assert status == 400/assert status == 416/; s/assert error_code == 'InvalidPart'/assert error_code == 'InvalidPartNumber'/}" s3tests/functional/test_s3.py
# Debug: Show the config file contents
echo "=== S3 Config File Contents ==="
@@ -968,6 +971,9 @@ jobs:
echo "All SeaweedFS components are ready!"
cd ../s3-tests
sed -i "s/assert prefixes == \['foo%2B1\/', 'foo\/', 'quux%20ab\/'\]/assert prefixes == \['foo\/', 'foo%2B1\/', 'quux%20ab\/'\]/" s3tests/functional/test_s3.py
# The suite expects RGW's 400 InvalidPart for a partNumber past the last
# part; AWS answers 416 InvalidPartNumber, which is what we return.
sed -i "/# request PartNumber out of range/,+4{s/assert status == 400/assert status == 416/; s/assert error_code == 'InvalidPart'/assert error_code == 'InvalidPartNumber'/}" s3tests/functional/test_s3.py
# Create and update s3tests.conf to use port 8004
cp ../docker/compose/s3tests.conf ../docker/compose/s3tests-sql.conf
sed -i 's/port = 8000/port = 8004/g' ../docker/compose/s3tests-sql.conf
+98 -107
View File
@@ -850,75 +850,15 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
// Continue with streaming immediately - will serve from remote or cached chunks
}
// Check if PartNumber query parameter is present (for multipart GET requests)
partNumberStr := r.URL.Query().Get("partNumber")
if partNumberStr == "" {
partNumberStr = r.URL.Query().Get("PartNumber")
}
// If PartNumber is specified, set headers and modify Range to read only that part
// This replicates the filer handler logic
if partNumberStr != "" {
if partNumber, parseErr := strconv.Atoi(partNumberStr); parseErr == nil && partNumber > 0 {
// Get actual parts count from metadata (not chunk count)
partsCount, partInfo := s3a.getMultipartInfo(objectEntryForSSE, partNumber)
// Validate part number
if partNumber > partsCount {
glog.Warningf("GetObject: Invalid part number %d, object has %d parts", partNumber, partsCount)
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPart)
// If PartNumber is specified, turn the request into a ranged read of that part
if partNumber := requestedPartNumber(r); partNumber > 0 {
startOffset, endOffset, errCode := s3a.partByteRange(w, r, objectEntryForSSE, partNumber)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Set parts count header
w.Header().Set(s3_constants.AmzMpPartsCount, strconv.Itoa(partsCount))
glog.V(3).Infof("GetObject: Set PartsCount=%d for multipart GET with PartNumber=%d", partsCount, partNumber)
// Calculate the byte range for this part
// Note: ETag is NOT overridden - AWS S3 returns the complete object's ETag
// even when requesting a specific part via PartNumber
var startOffset, endOffset int64
if partInfo != nil {
var ok bool
startOffset, endOffset, ok = partRange(partInfo, objectEntryForSSE.Chunks)
if !ok {
glog.Errorf("GetObject: part %d boundary chunks [%d,%d) out of range (chunks: %d)", partNumber, partInfo.StartChunk, partInfo.EndChunk, len(objectEntryForSSE.Chunks))
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
} else {
// Fallback: assume 1:1 part-to-chunk mapping (backward compatibility)
chunkIndex := partNumber - 1
if chunkIndex >= len(objectEntryForSSE.Chunks) {
glog.Warningf("GetObject: Part %d chunk index %d out of range (chunks: %d)", partNumber, chunkIndex, len(objectEntryForSSE.Chunks))
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPart)
return
}
partChunk := objectEntryForSSE.Chunks[chunkIndex]
startOffset = partChunk.Offset
endOffset = partChunk.Offset + int64(partChunk.Size) - 1
}
// Check if client supplied a Range header - if so, apply it within the part's boundaries
// S3 allows both partNumber and Range together, where Range applies within the selected part
clientRangeHeader := r.Header.Get("Range")
if clientRangeHeader != "" {
adjustedStart, adjustedEnd, rangeErr := adjustRangeForPart(startOffset, endOffset, clientRangeHeader)
if rangeErr != nil {
glog.Warningf("GetObject: Invalid Range for part %d: %v", partNumber, rangeErr)
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange)
return
}
startOffset = adjustedStart
endOffset = adjustedEnd
glog.V(3).Infof("GetObject: Client Range %s applied to part %d, adjusted to bytes=%d-%d", clientRangeHeader, partNumber, startOffset, endOffset)
}
// Set Range header to read the requested bytes (full part or client-specified range within part)
rangeHeader := fmt.Sprintf("bytes=%d-%d", startOffset, endOffset)
r.Header.Set("Range", rangeHeader)
glog.V(3).Infof("GetObject: Set Range header for part %d: %s", partNumber, rangeHeader)
}
r.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", startOffset, endOffset))
glog.V(3).Infof("GetObject: part %d served as bytes=%d-%d", partNumber, startOffset, endOffset)
}
// NEW OPTIMIZATION: Stream directly from volume servers, bypassing filer proxy
@@ -2289,10 +2229,10 @@ func (s3a *S3ApiServer) setResponseHeaders(w http.ResponseWriter, r *http.Reques
// Set checksum header if stored in metadata, but only when:
// 1. The request contains "x-amz-checksum-mode: ENABLED" (per AWS S3 spec)
// 2. The request is NOT a ranged GET (Range header absent)
// 2. The response covers the full object (no Range header, no partNumber)
// The stored checksum covers the full object; returning it for partial
// responses causes SDK checksum validation failures.
if r != nil && r.Header.Get("X-Amz-Checksum-Mode") == "ENABLED" && r.Header.Get("Range") == "" {
if r != nil && r.Header.Get("X-Amz-Checksum-Mode") == "ENABLED" && r.Header.Get("Range") == "" && requestedPartNumber(r) == 0 {
if entry.Extended != nil {
if algoName, ok := entry.Extended[s3_constants.ExtChecksumAlgorithm]; ok {
if checksumVal, ok := entry.Extended[s3_constants.ExtChecksumValue]; ok {
@@ -2596,35 +2536,23 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
// For HEAD requests, we already have all metadata - just set headers directly
totalSize := int64(filer.FileSize(objectEntryForSSE))
s3a.setResponseHeaders(w, r, objectEntryForSSE, totalSize)
responseSize := totalSize
statusCode := http.StatusOK
// Check if PartNumber query parameter is present (for multipart objects)
// This logic matches the filer handler for consistency
partNumberStr := r.URL.Query().Get("partNumber")
if partNumberStr == "" {
partNumberStr = r.URL.Query().Get("PartNumber")
}
// If PartNumber is specified, set headers (matching filer logic)
if partNumberStr != "" {
if partNumber, parseErr := strconv.Atoi(partNumberStr); parseErr == nil && partNumber > 0 {
// Get actual parts count from metadata (not chunk count)
partsCount, _ := s3a.getMultipartInfo(objectEntryForSSE, partNumber)
// Validate part number
if partNumber > partsCount {
glog.Warningf("HeadObject: Invalid part number %d, object has %d parts", partNumber, partsCount)
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPart)
// A partNumber HEAD is a ranged HEAD of that part: report the part's size and range
if partNumber := requestedPartNumber(r); partNumber > 0 {
startOffset, endOffset, errCode := s3a.partByteRange(w, r, objectEntryForSSE, partNumber)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
responseSize = endOffset - startOffset + 1
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)
}
// Set parts count header
// Note: ETag is NOT overridden - AWS S3 returns the complete object's ETag
// even when requesting a specific part via PartNumber
w.Header().Set(s3_constants.AmzMpPartsCount, strconv.Itoa(partsCount))
glog.V(3).Infof("HeadObject: Set PartsCount=%d for part %d", partsCount, partNumber)
}
}
s3a.setResponseHeaders(w, r, objectEntryForSSE, responseSize)
// Detect and handle SSE
glog.V(3).Infof("HeadObjectHandler: Retrieved entry for %s/%s - %d chunks", bucket, object, len(objectEntryForSSE.Chunks))
@@ -2656,7 +2584,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
s3a.addSSEResponseHeadersFromEntry(w, r, objectEntryForSSE, sseType)
}
w.WriteHeader(http.StatusOK)
w.WriteHeader(statusCode)
}
// fetchObjectEntry fetches the filer entry for an object
@@ -3254,16 +3182,17 @@ type rc struct {
}
// getMultipartInfo retrieves multipart metadata for a given part number
// Returns: (partsCount, partInfo)
// Returns: (partsCount, partInfo, hasBoundaries)
// - partsCount: total number of parts in the multipart object
// - partInfo: boundary information for the requested part (nil if not found or not a multipart object)
func (s3a *S3ApiServer) getMultipartInfo(entry *filer_pb.Entry, partNumber int) (int, *PartBoundaryInfo) {
// - hasBoundaries: the entry carries part boundaries, so a nil partInfo means the object has no such part
func (s3a *S3ApiServer) getMultipartInfo(entry *filer_pb.Entry, partNumber int) (int, *PartBoundaryInfo, bool) {
if entry == nil {
return 0, nil
return 0, nil, false
}
if entry.Extended == nil {
// Not a multipart object or no metadata
return len(entry.GetChunks()), nil
return len(entry.GetChunks()), nil, false
}
// Try to get parts count from metadata
@@ -3275,20 +3204,82 @@ func (s3a *S3ApiServer) getMultipartInfo(entry *filer_pb.Entry, partNumber int)
}
// Try to get part boundaries from metadata
if boundariesJSON, exists := entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries]; exists {
boundariesJSON, exists := entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries]
if !exists {
return partsCount, nil, false
}
var boundaries []PartBoundaryInfo
if err := json.Unmarshal(boundariesJSON, &boundaries); err == nil {
// Find the requested part
if err := json.Unmarshal(boundariesJSON, &boundaries); err != nil {
glog.Warningf("getMultipartInfo: failed to unmarshal part boundaries: %v", err)
return partsCount, nil, false
}
for i := range boundaries {
if boundaries[i].PartNumber == partNumber {
return partsCount, &boundaries[i]
}
}
return partsCount, &boundaries[i], true
}
}
// No part boundaries metadata or part not found
return partsCount, nil
// The object records its parts and this is not one of them
return partsCount, nil, true
}
// requestedPartNumber returns the partNumber query parameter, or 0 when it is
// absent or not a positive integer.
func requestedPartNumber(r *http.Request) int {
partNumberStr := r.URL.Query().Get("partNumber")
if partNumberStr == "" {
partNumberStr = r.URL.Query().Get("PartNumber")
}
partNumber, parseErr := strconv.Atoi(partNumberStr)
if parseErr != nil || partNumber <= 0 {
return 0
}
return partNumber
}
// partByteRange resolves the inclusive byte range a partNumber request reads,
// narrowed by a client Range within the part, and sets the parts count header.
// The object ETag is kept as is, matching AWS.
func (s3a *S3ApiServer) partByteRange(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry, partNumber int) (startOffset, endOffset int64, errCode s3err.ErrorCode) {
// Part numbers need not be consecutive, so an object that records its part
// boundaries is asked for the part itself rather than for a count.
partsCount, partInfo, hasBoundaries := s3a.getMultipartInfo(entry, partNumber)
if partInfo == nil && (hasBoundaries || partNumber > partsCount) {
glog.Warningf("partByteRange: object has no part %d, it has %d parts", partNumber, partsCount)
return 0, 0, s3err.ErrInvalidPartNumber
}
w.Header().Set(s3_constants.AmzMpPartsCount, strconv.Itoa(partsCount))
if partInfo != nil {
var ok bool
startOffset, endOffset, ok = partRange(partInfo, entry.Chunks)
if !ok {
glog.Errorf("partByteRange: part %d boundary chunks [%d,%d) out of range (chunks: %d)", partNumber, partInfo.StartChunk, partInfo.EndChunk, len(entry.Chunks))
return 0, 0, s3err.ErrInternalError
}
} else {
// Fallback: assume 1:1 part-to-chunk mapping (backward compatibility)
chunkIndex := partNumber - 1
if chunkIndex >= len(entry.Chunks) {
glog.Warningf("partByteRange: part %d chunk index %d out of range (chunks: %d)", partNumber, chunkIndex, len(entry.Chunks))
return 0, 0, s3err.ErrInvalidPartNumber
}
partChunk := entry.Chunks[chunkIndex]
startOffset, endOffset = partChunk.Offset, partChunk.Offset+int64(partChunk.Size)-1
}
// S3 allows both partNumber and Range together, where Range applies within the selected part
if clientRangeHeader := r.Header.Get("Range"); clientRangeHeader != "" {
adjustedStart, adjustedEnd, rangeErr := adjustRangeForPart(startOffset, endOffset, clientRangeHeader)
if rangeErr != nil {
glog.Warningf("partByteRange: invalid Range for part %d: %v", partNumber, rangeErr)
return 0, 0, s3err.ErrInvalidRange
}
startOffset, endOffset = adjustedStart, adjustedEnd
}
return startOffset, endOffset, s3err.ErrNone
}
// buildRemoteObjectPath builds the filer directory and object name from S3 bucket/object.
+142
View File
@@ -0,0 +1,142 @@
package s3api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
part1Size = 5 * 1024 * 1024
part2Size = 3 * 1024 * 1024
)
func TestRequestedPartNumber(t *testing.T) {
for query, want := range map[string]int{
"": 0,
"partNumber=1": 1,
"PartNumber=2": 2,
"partNumber=0": 0,
"partNumber=-1": 0,
"partNumber=abc": 0,
"versionId=v1": 0,
"partNumber=2&x=1": 2,
} {
r := httptest.NewRequest(http.MethodHead, "/bucket/object?"+query, nil)
assert.Equal(t, want, requestedPartNumber(r), "query %q", query)
}
}
// twoPartEntry models a 2-part multipart object of part1Size + part2Size bytes.
func twoPartEntry(t *testing.T, withBoundaries bool) *filer_pb.Entry {
t.Helper()
entry := &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{FileSize: part1Size + part2Size},
Chunks: []*filer_pb.FileChunk{
{FileId: "1,a", Offset: 0, Size: part1Size},
{FileId: "1,b", Offset: part1Size, Size: part2Size},
},
Extended: map[string][]byte{
s3_constants.SeaweedFSMultipartPartsCount: []byte("2"),
},
}
if withBoundaries {
boundaries, err := json.Marshal([]PartBoundaryInfo{
{PartNumber: 1, StartChunk: 0, EndChunk: 1, StartOffset: 0, EndOffset: part1Size},
{PartNumber: 2, StartChunk: 1, EndChunk: 2, StartOffset: part1Size, EndOffset: part1Size + part2Size},
})
require.NoError(t, err)
entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries] = boundaries
}
return entry
}
// partRequest is a HEAD of partNumber, optionally narrowed by a client Range.
func partRequest(rangeHeader string) *http.Request {
r := httptest.NewRequest(http.MethodHead, "/bucket/object", nil)
if rangeHeader != "" {
r.Header.Set("Range", rangeHeader)
}
return r
}
func TestPartByteRange(t *testing.T) {
s3a := &S3ApiServer{}
for _, withBoundaries := range []bool{true, false} {
entry := twoPartEntry(t, withBoundaries)
w := httptest.NewRecorder()
start, end, errCode := s3a.partByteRange(w, partRequest(""), entry, 1)
assert.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, int64(0), start)
assert.Equal(t, int64(part1Size-1), end)
assert.Equal(t, "2", w.Header().Get(s3_constants.AmzMpPartsCount))
w = httptest.NewRecorder()
start, end, errCode = s3a.partByteRange(w, partRequest(""), entry, 2)
assert.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, int64(part1Size), start)
assert.Equal(t, int64(part1Size+part2Size-1), end)
// A client Range applies within the selected part
w = httptest.NewRecorder()
start, end, errCode = s3a.partByteRange(w, partRequest("bytes=0-1023"), entry, 2)
assert.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, int64(part1Size), start)
assert.Equal(t, int64(part1Size+1023), end)
w = httptest.NewRecorder()
_, _, errCode = s3a.partByteRange(w, partRequest(""), entry, 3)
assert.Equal(t, s3err.ErrInvalidPartNumber, errCode, "part beyond the object must not resolve")
}
// AWS answers an unsatisfiable partNumber with 416, not 400.
assert.Equal(t, http.StatusRequestedRangeNotSatisfiable, s3err.GetAPIError(s3err.ErrInvalidPartNumber).HTTPStatusCode)
}
// Completion accepts ascending, not consecutive, part numbers.
func TestPartByteRangeSparsePartNumbers(t *testing.T) {
s3a := &S3ApiServer{}
entry := twoPartEntry(t, true)
boundaries, err := json.Marshal([]PartBoundaryInfo{
{PartNumber: 1, StartChunk: 0, EndChunk: 1, StartOffset: 0, EndOffset: part1Size},
{PartNumber: 3, StartChunk: 1, EndChunk: 2, StartOffset: part1Size, EndOffset: part1Size + part2Size},
})
require.NoError(t, err)
entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries] = boundaries
start, end, errCode := s3a.partByteRange(httptest.NewRecorder(), partRequest(""), entry, 3)
assert.Equal(t, s3err.ErrNone, errCode, "the uploaded part 3 must resolve")
assert.Equal(t, int64(part1Size), start)
assert.Equal(t, int64(part1Size+part2Size-1), end)
_, _, errCode = s3a.partByteRange(httptest.NewRecorder(), partRequest(""), entry, 2)
assert.Equal(t, s3err.ErrInvalidPartNumber, errCode, "part 2 was never uploaded")
}
// The stored checksum covers the whole object, so a part response must not carry it.
func TestSetResponseHeadersSkipsChecksumForPart(t *testing.T) {
s3a := &S3ApiServer{}
entry := twoPartEntry(t, true)
entry.Extended[s3_constants.ExtChecksumAlgorithm] = []byte("x-amz-checksum-crc32")
entry.Extended[s3_constants.ExtChecksumValue] = []byte("AAAAAA==")
for query, want := range map[string]string{
"": "AAAAAA==",
"partNumber=1": "",
} {
r := httptest.NewRequest(http.MethodHead, "/bucket/object?"+query, nil)
r.Header.Set("X-Amz-Checksum-Mode", "ENABLED")
w := httptest.NewRecorder()
s3a.setResponseHeaders(w, r, entry, part1Size)
assert.Equal(t, want, w.Header().Get("x-amz-checksum-crc32"), "query %q", query)
}
}
+6
View File
@@ -73,6 +73,7 @@ const (
ErrInvalidMaxDeleteObjects
ErrInvalidPartNumberMarker
ErrInvalidPart
ErrInvalidPartNumber
ErrInvalidPartOrder
ErrInvalidRange
ErrInternalError
@@ -349,6 +350,11 @@ var errorCodeResponse = map[ErrorCode]APIError{
Description: "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrInvalidPartNumber: {
Code: "InvalidPartNumber",
Description: "The requested partnumber is not satisfiable.",
HTTPStatusCode: http.StatusRequestedRangeNotSatisfiable,
},
ErrInvalidPartOrder: {
Code: "InvalidPartOrder",
Description: "The list of parts was not in ascending order. The parts list must be specified in order by part number.",