From 7f644bc5fce189e18cb3da40dcea9df7bf427e0f Mon Sep 17 00:00:00 2001 From: Ljubisa Gacevic Date: Thu, 30 Jul 2026 14:15:42 +0200 Subject: [PATCH 1/6] fix(api): send complete entity headers on HEAD /bytes and /bzz --- openapi/Swarm.yaml | 42 ++++++++++++++++++++-- pkg/api/api.go | 2 ++ pkg/api/bytes.go | 27 ++++---------- pkg/api/bytes_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++ pkg/api/bzz.go | 6 ++++ 5 files changed, 137 insertions(+), 23 deletions(-) diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index a0b3e296b8f..56f2e0bea50 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -268,19 +268,30 @@ paths: type: string example: application/octet-stream Content-Length: - description: The size of the chunk in bytes. + description: The size of the retrievable content in bytes. schema: type: integer example: 1024 + Accept-Ranges: + description: Indicates that ranged requests are supported for the reference. + schema: + type: string + example: bytes + ETag: + description: The reference, as an entity tag. + schema: + type: string Access-Control-Expose-Headers: description: Headers exposed for CORS. schema: type: string - example: Accept-Ranges, Content-Encoding + example: Content-Disposition, Accept-Ranges, Content-Encoding "400": $ref: "SwarmCommon.yaml#/components/responses/400" "404": $ref: "SwarmCommon.yaml#/components/responses/404" + "500": + $ref: "SwarmCommon.yaml#/components/responses/500" default: description: Default response @@ -484,10 +495,37 @@ paths: responses: "200": description: Chunk exists + headers: + Content-Type: + description: The MIME type of the resource. + schema: + type: string + example: application/octet-stream + Content-Length: + description: The size of the retrievable content in bytes. + schema: + type: integer + example: 1024 + Accept-Ranges: + description: Indicates that ranged requests are supported for the reference. + schema: + type: string + example: bytes + ETag: + description: The reference, as an entity tag. + schema: + type: string + Access-Control-Expose-Headers: + description: Headers exposed for CORS. + schema: + type: string + example: Content-Disposition, Accept-Ranges, Content-Encoding "400": $ref: "SwarmCommon.yaml#/components/responses/400" "404": $ref: "SwarmCommon.yaml#/components/responses/404" + "500": + $ref: "SwarmCommon.yaml#/components/responses/500" default: description: Default response diff --git a/pkg/api/api.go b/pkg/api/api.go index 28586141e75..786bd92d6a2 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -107,6 +107,8 @@ const ( ContentTypeHeader = "Content-Type" ContentDispositionHeader = "Content-Disposition" ContentLengthHeader = "Content-Length" + ContentEncodingHeader = "Content-Encoding" + AcceptRangesHeader = "Accept-Ranges" RangeHeader = "Range" OriginHeader = "Origin" AccessControlExposeHeaders = "Access-Control-Expose-Headers" diff --git a/pkg/api/bytes.go b/pkg/api/bytes.go index 1ecbd3e2f7a..cbb73baf090 100644 --- a/pkg/api/bytes.go +++ b/pkg/api/bytes.go @@ -5,15 +5,12 @@ package api import ( - "encoding/binary" "errors" "fmt" "net/http" - "strconv" "time" "github.com/ethersphere/bee/v2/pkg/accesscontrol" - "github.com/ethersphere/bee/v2/pkg/cac" "github.com/ethersphere/bee/v2/pkg/file/redundancy" "github.com/ethersphere/bee/v2/pkg/jsonhttp" "github.com/ethersphere/bee/v2/pkg/postage" @@ -209,24 +206,12 @@ func (s *Service) bytesHeadHandler(w http.ResponseWriter, r *http.Request) { address = v } - getter := s.storer.Download(true) - ch, err := getter.Get(r.Context(), address) - if err != nil { - logger.Debug("get root chunk failed", "chunk_address", address, "error", err) - logger.Error(nil, "get root chunk failed") - w.WriteHeader(http.StatusNotFound) - return + additionalHeaders := http.Header{ + ContentTypeHeader: {"application/octet-stream"}, } - w.Header().Add(AccessControlExposeHeaders, "Accept-Ranges, Content-Encoding") - w.Header().Add(ContentTypeHeader, "application/octet-stream") - var span int64 - - if cac.Valid(ch) { - span = int64(binary.LittleEndian.Uint64(ch.Data()[:swarm.SpanSize])) - } else { - span = int64(len(ch.Data())) - } - w.Header().Set(ContentLengthHeader, strconv.FormatInt(span, 10)) - w.WriteHeader(http.StatusOK) // HEAD requests do not write a body + // share the download path with the GET handler so that the length is resolved + // by the joiner. It strips the redundancy level that may be encoded in the + // root chunk span, and splits an encrypted reference into address and key. + s.downloadHandler(logger, w, r, address, additionalHeaders, true, true, nil) } diff --git a/pkg/api/bytes_test.go b/pkg/api/bytes_test.go index 5a15694ea01..7cecbd76a45 100644 --- a/pkg/api/bytes_test.go +++ b/pkg/api/bytes_test.go @@ -441,3 +441,86 @@ func TestBytesRedundancyLevel(t *testing.T) { }) } } + +// TestBytesHead tests that a HEAD request reports the same entity headers as a +// GET, for every redundancy level and for encrypted references. The redundancy +// level is encoded into the most significant byte of the root chunk span and has +// to be stripped before the length is read out of it, and an encrypted reference +// carries a decryption key that is not part of the root chunk address. +func TestBytesHead(t *testing.T) { + t.Parallel() + + g := mockbytes.New(0, mockbytes.MockTypeStandard).WithModulus(255) + content, err := g.SequentialBytes(swarm.ChunkSize * 10) + if err != nil { + t.Fatal(err) + } + + for level := redundancy.NONE; level <= redundancy.PARANOID; level++ { + for _, encrypt := range []bool{false, true} { + t.Run(fmt.Sprintf("level %d encrypt %v", level, encrypt), func(t *testing.T) { + t.Parallel() + + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + + var resp struct { + Reference swarm.Address `json:"reference"` + } + jsonhttptest.Request(t, client, http.MethodPost, "/bytes", http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmRedundancyLevelHeader, strconv.Itoa(int(level))), + jsonhttptest.WithRequestHeader(api.SwarmEncryptHeader, strconv.FormatBool(encrypt)), + jsonhttptest.WithRequestBody(bytes.NewReader(content)), + jsonhttptest.WithUnmarshalJSONResponse(&resp), + ) + + resource := "/bytes/" + resp.Reference.String() + + jsonhttptest.Request(t, client, http.MethodHead, resource, http.StatusOK, + jsonhttptest.WithExpectedContentLength(len(content)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"), + jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"), + ) + jsonhttptest.Request(t, client, http.MethodGet, resource, http.StatusOK, + jsonhttptest.WithExpectedContentLength(len(content)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"), + jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"), + ) + }) + } + } +} + +// TestBytesHeadErrorsMatchGet tests that HEAD reports the same status as GET for +// references that cannot be served, so a client probing with HEAD is not told +// something different from what the subsequent GET would do. +func TestBytesHeadErrorsMatchGet(t *testing.T) { + t.Parallel() + + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + + tests := []struct { + name string + ref string + want int + }{ + {"unresolvable address", "abcd", http.StatusInternalServerError}, + {"unknown address", "0000000000000000000000000000000000000000000000000000000000000001", http.StatusNotFound}, + {"invalid address", "zzzz", http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resource := "/bytes/" + tt.ref + jsonhttptest.Request(t, client, http.MethodHead, resource, tt.want) + jsonhttptest.Request(t, client, http.MethodGet, resource, tt.want) + }) + } +} diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index 2df221938fa..2a1306fcdb0 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -794,6 +794,12 @@ func (s *Service) downloadHandler(logger log.Logger, w http.ResponseWriter, r *h w.Header().Add(AccessControlExposeHeaders, ContentDispositionHeader) if headersOnly { + // http.ServeContent, which advertises range support on the GET path, is + // never reached here, so the header has to be set explicitly to keep HEAD + // responses consistent with GET, as RFC 9110 section 9.3.2 requires. + w.Header().Set(AcceptRangesHeader, "bytes") + w.Header().Add(AccessControlExposeHeaders, AcceptRangesHeader) + w.Header().Add(AccessControlExposeHeaders, ContentEncodingHeader) w.WriteHeader(http.StatusOK) return } From 25c3a2186865e8c99620f4c4dade87099f45ecde Mon Sep 17 00:00:00 2001 From: Ljubisa Gacevic Date: Thu, 30 Jul 2026 14:21:31 +0200 Subject: [PATCH 2/6] chore(api): simplify HEAD handler comments --- pkg/api/bytes.go | 5 ++--- pkg/api/bytes_test.go | 10 +++------- pkg/api/bzz.go | 5 ++--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/pkg/api/bytes.go b/pkg/api/bytes.go index cbb73baf090..8be4d90320c 100644 --- a/pkg/api/bytes.go +++ b/pkg/api/bytes.go @@ -210,8 +210,7 @@ func (s *Service) bytesHeadHandler(w http.ResponseWriter, r *http.Request) { ContentTypeHeader: {"application/octet-stream"}, } - // share the download path with the GET handler so that the length is resolved - // by the joiner. It strips the redundancy level that may be encoded in the - // root chunk span, and splits an encrypted reference into address and key. + // share the GET path: the joiner strips the redundancy level encoded in the + // root chunk span and splits an encrypted reference into address and key. s.downloadHandler(logger, w, r, address, additionalHeaders, true, true, nil) } diff --git a/pkg/api/bytes_test.go b/pkg/api/bytes_test.go index 7cecbd76a45..e5d8c77f31b 100644 --- a/pkg/api/bytes_test.go +++ b/pkg/api/bytes_test.go @@ -442,11 +442,8 @@ func TestBytesRedundancyLevel(t *testing.T) { } } -// TestBytesHead tests that a HEAD request reports the same entity headers as a -// GET, for every redundancy level and for encrypted references. The redundancy -// level is encoded into the most significant byte of the root chunk span and has -// to be stripped before the length is read out of it, and an encrypted reference -// carries a decryption key that is not part of the root chunk address. +// TestBytesHead tests that HEAD reports the same entity headers as GET, for every +// redundancy level and for encrypted references. func TestBytesHead(t *testing.T) { t.Parallel() @@ -496,8 +493,7 @@ func TestBytesHead(t *testing.T) { } // TestBytesHeadErrorsMatchGet tests that HEAD reports the same status as GET for -// references that cannot be served, so a client probing with HEAD is not told -// something different from what the subsequent GET would do. +// references that cannot be served. func TestBytesHeadErrorsMatchGet(t *testing.T) { t.Parallel() diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index 2a1306fcdb0..b4e0d42527a 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -794,9 +794,8 @@ func (s *Service) downloadHandler(logger log.Logger, w http.ResponseWriter, r *h w.Header().Add(AccessControlExposeHeaders, ContentDispositionHeader) if headersOnly { - // http.ServeContent, which advertises range support on the GET path, is - // never reached here, so the header has to be set explicitly to keep HEAD - // responses consistent with GET, as RFC 9110 section 9.3.2 requires. + // http.ServeContent sets these on the GET path, but is not reached here. + // "bytes" is the range unit, not the endpoint. w.Header().Set(AcceptRangesHeader, "bytes") w.Header().Add(AccessControlExposeHeaders, AcceptRangesHeader) w.Header().Add(AccessControlExposeHeaders, ContentEncodingHeader) From 38ea65282bba05bc22c9e8a3ccabbdcd610890c2 Mon Sep 17 00:00:00 2001 From: Ljubisa Gacevic Date: Thu, 30 Jul 2026 14:37:28 +0200 Subject: [PATCH 3/6] chore(api): drop dead Content-Encoding CORS exposure and trim comments --- openapi/Swarm.yaml | 4 ++-- pkg/api/api.go | 1 - pkg/api/bzz.go | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index 56f2e0bea50..9388368e0f1 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -285,7 +285,7 @@ paths: description: Headers exposed for CORS. schema: type: string - example: Content-Disposition, Accept-Ranges, Content-Encoding + example: Content-Disposition, Accept-Ranges "400": $ref: "SwarmCommon.yaml#/components/responses/400" "404": @@ -519,7 +519,7 @@ paths: description: Headers exposed for CORS. schema: type: string - example: Content-Disposition, Accept-Ranges, Content-Encoding + example: Content-Disposition, Accept-Ranges "400": $ref: "SwarmCommon.yaml#/components/responses/400" "404": diff --git a/pkg/api/api.go b/pkg/api/api.go index 786bd92d6a2..4e4f26bf1c0 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -107,7 +107,6 @@ const ( ContentTypeHeader = "Content-Type" ContentDispositionHeader = "Content-Disposition" ContentLengthHeader = "Content-Length" - ContentEncodingHeader = "Content-Encoding" AcceptRangesHeader = "Accept-Ranges" RangeHeader = "Range" OriginHeader = "Origin" diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index b4e0d42527a..ee8d9290f93 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -794,11 +794,10 @@ func (s *Service) downloadHandler(logger log.Logger, w http.ResponseWriter, r *h w.Header().Add(AccessControlExposeHeaders, ContentDispositionHeader) if headersOnly { - // http.ServeContent sets these on the GET path, but is not reached here. + // http.ServeContent sets this on the GET path, but is not reached here. // "bytes" is the range unit, not the endpoint. w.Header().Set(AcceptRangesHeader, "bytes") w.Header().Add(AccessControlExposeHeaders, AcceptRangesHeader) - w.Header().Add(AccessControlExposeHeaders, ContentEncodingHeader) w.WriteHeader(http.StatusOK) return } From 9c9b79aeaf18d8c557b969a33dbc0754b9c5d427 Mon Sep 17 00:00:00 2001 From: Ljubisa Gacevic Date: Fri, 31 Jul 2026 14:10:00 +0200 Subject: [PATCH 4/6] docs(api): document download params honored by HEAD --- openapi/Swarm.yaml | 14 ++++++++++++-- pkg/api/bytes_test.go | 4 ++-- pkg/api/bzz.go | 2 +- pkg/api/bzz_test.go | 1 + 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index 9388368e0f1..35184c61947 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -255,12 +255,17 @@ paths: $ref: "SwarmCommon.yaml#/components/schemas/SwarmAddress" required: true description: Swarm address of chunk + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmCache" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyStrategyParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyFallbackModeParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyLevelParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmChunkRetrievalTimeoutParameter" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" responses: "200": - description: The chunk exists. + description: Headers for the content at the reference. headers: Content-Type: description: The MIME type of the resource (e.g., application/octet-stream). @@ -489,12 +494,17 @@ paths: $ref: "SwarmCommon.yaml#/components/schemas/SwarmAddress" required: true description: Swarm address of chunk + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmCache" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyStrategyParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyFallbackModeParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyLevelParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmChunkRetrievalTimeoutParameter" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" responses: "200": - description: Chunk exists + description: Headers for the content at the reference. headers: Content-Type: description: The MIME type of the resource. diff --git a/pkg/api/bytes_test.go b/pkg/api/bytes_test.go index e5d8c77f31b..a6725d70c0e 100644 --- a/pkg/api/bytes_test.go +++ b/pkg/api/bytes_test.go @@ -507,9 +507,9 @@ func TestBytesHeadErrorsMatchGet(t *testing.T) { ref string want int }{ - {"unresolvable address", "abcd", http.StatusInternalServerError}, + {"short address", "abcd", http.StatusInternalServerError}, {"unknown address", "0000000000000000000000000000000000000000000000000000000000000001", http.StatusNotFound}, - {"invalid address", "zzzz", http.StatusBadRequest}, + {"non-hex address", "zzzz", http.StatusBadRequest}, } for _, tt := range tests { diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index ee8d9290f93..f3370f2c89c 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -794,7 +794,7 @@ func (s *Service) downloadHandler(logger log.Logger, w http.ResponseWriter, r *h w.Header().Add(AccessControlExposeHeaders, ContentDispositionHeader) if headersOnly { - // http.ServeContent sets this on the GET path, but is not reached here. + // http.ServeContent would set this, but the GET path is not reached here. // "bytes" is the range unit, not the endpoint. w.Header().Set(AcceptRangesHeader, "bytes") w.Header().Add(AccessControlExposeHeaders, AcceptRangesHeader) diff --git a/pkg/api/bzz_test.go b/pkg/api/bzz_test.go index 3a122c9d0ed..c3c3cfcd75a 100644 --- a/pkg/api/bzz_test.go +++ b/pkg/api/bzz_test.go @@ -597,6 +597,7 @@ func TestBzzFiles(t *testing.T) { jsonhttptest.WithRequestBody(bytes.NewReader(simpleData)), jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), jsonhttptest.WithExpectedContentLength(21), + jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"), ) }) }) From 8a5fae2c7b703a51b5d86fc8e86e20aa0d883948 Mon Sep 17 00:00:00 2001 From: Ljubisa Gacevic Date: Wed, 5 Aug 2026 13:59:06 +0200 Subject: [PATCH 5/6] fix(api): apply Range and preconditions to HEAD as GET does --- openapi/Swarm.yaml | 68 +++++++++++++++++++++++++- pkg/api/api.go | 1 + pkg/api/bytes_test.go | 111 ++++++++++++++++++++++++++++++++++++++---- pkg/api/bzz.go | 15 +++--- 4 files changed, 176 insertions(+), 19 deletions(-) diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index 35184c61947..73b326d2cbf 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -246,6 +246,9 @@ paths: description: Default response head: summary: Retrieve headers containing the content type and length for the reference + description: > + Identical to GET on the same reference except that no content is sent, + so Range and conditional request headers are honored the same way. tags: - Bytes parameters: @@ -286,15 +289,44 @@ paths: description: The reference, as an entity tag. schema: type: string + Last-Modified: + description: The time the response was generated. + schema: + type: string Access-Control-Expose-Headers: description: Headers exposed for CORS. schema: type: string - example: Content-Disposition, Accept-Ranges + example: Content-Disposition + "206": + description: Headers for the range requested via the Range header. + headers: + Content-Range: + description: The range covered, and the total size of the content. + schema: + type: string + example: bytes 0-1023/4096 + Content-Length: + description: The size of the requested range in bytes. + schema: + type: integer + example: 1024 + "304": + description: The entity tag in If-None-Match matches the reference. "400": $ref: "SwarmCommon.yaml#/components/responses/400" "404": $ref: "SwarmCommon.yaml#/components/responses/404" + "412": + description: A conditional request header was not satisfied. + "416": + description: The requested range cannot be satisfied. + headers: + Content-Range: + description: The total size of the content. + schema: + type: string + example: bytes */4096 "500": $ref: "SwarmCommon.yaml#/components/responses/500" default: @@ -485,6 +517,9 @@ paths: description: Default response head: summary: Retrieve headers with content type and length for the reference + description: > + Identical to GET on the same reference except that no content is sent, + so Range and conditional request headers are honored the same way. tags: - BZZ parameters: @@ -525,15 +560,44 @@ paths: description: The reference, as an entity tag. schema: type: string + Last-Modified: + description: The time the response was generated. + schema: + type: string Access-Control-Expose-Headers: description: Headers exposed for CORS. schema: type: string - example: Content-Disposition, Accept-Ranges + example: Content-Disposition + "206": + description: Headers for the range requested via the Range header. + headers: + Content-Range: + description: The range covered, and the total size of the content. + schema: + type: string + example: bytes 0-1023/4096 + Content-Length: + description: The size of the requested range in bytes. + schema: + type: integer + example: 1024 + "304": + description: The entity tag in If-None-Match matches the reference. "400": $ref: "SwarmCommon.yaml#/components/responses/400" "404": $ref: "SwarmCommon.yaml#/components/responses/404" + "412": + description: A conditional request header was not satisfied. + "416": + description: The requested range cannot be satisfied. + headers: + Content-Range: + description: The total size of the content. + schema: + type: string + example: bytes */4096 "500": $ref: "SwarmCommon.yaml#/components/responses/500" default: diff --git a/pkg/api/api.go b/pkg/api/api.go index 4e4f26bf1c0..db4160e1721 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -107,6 +107,7 @@ const ( ContentTypeHeader = "Content-Type" ContentDispositionHeader = "Content-Disposition" ContentLengthHeader = "Content-Length" + ContentRangeHeader = "Content-Range" AcceptRangesHeader = "Accept-Ranges" RangeHeader = "Range" OriginHeader = "Origin" diff --git a/pkg/api/bytes_test.go b/pkg/api/bytes_test.go index a6725d70c0e..1f9e761ef63 100644 --- a/pkg/api/bytes_test.go +++ b/pkg/api/bytes_test.go @@ -477,21 +477,112 @@ func TestBytesHead(t *testing.T) { resource := "/bytes/" + resp.Reference.String() - jsonhttptest.Request(t, client, http.MethodHead, resource, http.StatusOK, - jsonhttptest.WithExpectedContentLength(len(content)), - jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"), - jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"), - ) - jsonhttptest.Request(t, client, http.MethodGet, resource, http.StatusOK, - jsonhttptest.WithExpectedContentLength(len(content)), - jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"), - jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"), - ) + for _, method := range []string{http.MethodHead, http.MethodGet} { + jsonhttptest.Request(t, client, method, resource, http.StatusOK, + jsonhttptest.WithExpectedContentLength(len(content)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"), + jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"), + jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", resp.Reference)), + jsonhttptest.WithNonEmptyResponseHeader("Last-Modified"), + ) + } }) } } } +// TestBytesHeadRangeAndConditional tests that HEAD applies Range and precondition +// headers exactly as GET does, since HEAD differs from GET only in sending no body. +func TestBytesHeadRangeAndConditional(t *testing.T) { + t.Parallel() + + g := mockbytes.New(0, mockbytes.MockTypeStandard).WithModulus(255) + content, err := g.SequentialBytes(swarm.ChunkSize * 10) + if err != nil { + t.Fatal(err) + } + + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + + var resp struct { + Reference swarm.Address `json:"reference"` + } + jsonhttptest.Request(t, client, http.MethodPost, "/bytes", http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(content)), + jsonhttptest.WithUnmarshalJSONResponse(&resp), + ) + + resource := "/bytes/" + resp.Reference.String() + etag := fmt.Sprintf("%q", resp.Reference) + + tests := []struct { + name string + header [2]string + want int + headers []jsonhttptest.Option + }{ + { + name: "satisfiable range", + header: [2]string{api.RangeHeader, "bytes=0-99"}, + want: http.StatusPartialContent, + headers: []jsonhttptest.Option{ + jsonhttptest.WithExpectedContentLength(100), + jsonhttptest.WithExpectedResponseHeader(api.ContentRangeHeader, fmt.Sprintf("bytes 0-99/%d", len(content))), + }, + }, + { + name: "unsatisfiable range", + header: [2]string{api.RangeHeader, "bytes=99999999-"}, + want: http.StatusRequestedRangeNotSatisfiable, + headers: []jsonhttptest.Option{ + jsonhttptest.WithExpectedResponseHeader(api.ContentRangeHeader, fmt.Sprintf("bytes */%d", len(content))), + }, + }, + { + name: "matching if-none-match", + header: [2]string{"If-None-Match", etag}, + want: http.StatusNotModified, + headers: []jsonhttptest.Option{ + jsonhttptest.WithNoResponseBody(), + }, + }, + { + name: "non-matching if-none-match", + header: [2]string{"If-None-Match", `"0000000000000000000000000000000000000000000000000000000000000001"`}, + want: http.StatusOK, + headers: []jsonhttptest.Option{ + jsonhttptest.WithExpectedContentLength(len(content)), + }, + }, + { + // A body-less response must not inherit the full content length, or the + // server truncates the connection and the client sees an unexpected EOF. + name: "non-matching if-match", + header: [2]string{"If-Match", `"0000000000000000000000000000000000000000000000000000000000000001"`}, + want: http.StatusPreconditionFailed, + headers: []jsonhttptest.Option{ + jsonhttptest.WithNoResponseBody(), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, method := range []string{http.MethodHead, http.MethodGet} { + opts := append([]jsonhttptest.Option{ + jsonhttptest.WithRequestHeader(tt.header[0], tt.header[1]), + }, tt.headers...) + jsonhttptest.Request(t, client, method, resource, tt.want, opts...) + } + }) + } +} + // TestBytesHeadErrorsMatchGet tests that HEAD reports the same status as GET for // references that cannot be served. func TestBytesHeadErrorsMatchGet(t *testing.T) { diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index f3370f2c89c..6ffef43fa0b 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -15,7 +15,6 @@ import ( "net/http" "path" "path/filepath" - "strconv" "strings" "time" @@ -790,15 +789,17 @@ func (s *Service) downloadHandler(logger log.Logger, w http.ResponseWriter, r *h if etag { w.Header().Set(ETagHeader, fmt.Sprintf("%q", reference)) } - w.Header().Set(ContentLengthHeader, strconv.FormatInt(l, 10)) + // Content-Length is left to http.ServeContent, which knows how many bytes the + // response actually carries. Setting it here would survive into the responses + // that carry no content, notably the 412 from a failed precondition. w.Header().Add(AccessControlExposeHeaders, ContentDispositionHeader) + // http.ServeContent writes no body for HEAD, so header-only responses take the + // same path as GET and inherit its preconditions, Range handling and headers. + // The reader is passed unbuffered: a response without a body has nothing to + // read ahead for. if headersOnly { - // http.ServeContent would set this, but the GET path is not reached here. - // "bytes" is the range unit, not the endpoint. - w.Header().Set(AcceptRangesHeader, "bytes") - w.Header().Add(AccessControlExposeHeaders, AcceptRangesHeader) - w.WriteHeader(http.StatusOK) + http.ServeContent(w, r, "", time.Now(), reader) return } From b7805b83709d23c01656bfe2c94f5e8e2c8f83a0 Mon Sep 17 00:00:00 2001 From: Ljubisa Gacevic Date: Wed, 5 Aug 2026 14:19:53 +0200 Subject: [PATCH 6/6] test(api): cover ranged HEAD across redundancy levels and encryption --- pkg/api/bytes_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/api/bytes_test.go b/pkg/api/bytes_test.go index 1f9e761ef63..576e263983d 100644 --- a/pkg/api/bytes_test.go +++ b/pkg/api/bytes_test.go @@ -486,6 +486,17 @@ func TestBytesHead(t *testing.T) { jsonhttptest.WithNonEmptyResponseHeader("Last-Modified"), ) } + + // The range is resolved against the length decoded from the root + // chunk span, so it has to hold for every redundancy level and for + // encrypted references, where that span is decrypted first. + for _, method := range []string{http.MethodHead, http.MethodGet} { + jsonhttptest.Request(t, client, method, resource, http.StatusPartialContent, + jsonhttptest.WithRequestHeader(api.RangeHeader, "bytes=0-10"), + jsonhttptest.WithExpectedContentLength(11), + jsonhttptest.WithExpectedResponseHeader(api.ContentRangeHeader, fmt.Sprintf("bytes 0-10/%d", len(content))), + ) + } }) } }