Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Changed-20261102-153500.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Changed
body: "**Breaking:** Ranged `DownloadFile*` calls now return the new `base.ErrInvalidRangeResponse` for HTTP 200 at a nonzero offset, an overlong HTTP 200 or 206 response, a chunked response without `Content-Length`, or a missing or invalid `Content-Range` on HTTP 206. Callers should handle this error and ensure their server or transport honors `Range` requests (#179)."
time: 2026-11-02T15:35:00-06:00
39 changes: 38 additions & 1 deletion b2/downloadbyid_extended_wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"testing"

"github.com/Backblaze/blazer/base"
"github.com/Backblaze/blazer/internal/b2wiretest"
)

type downloadByIDRequest struct {
Expand All @@ -46,6 +47,7 @@ type downloadByIDWire struct {
authorizations int
unauthorizedOnce bool
partialBody bool
ignoreRange bool
}

func newDownloadByIDWire(t *testing.T) *downloadByIDWire {
Expand Down Expand Up @@ -78,6 +80,7 @@ func (w *downloadByIDWire) serveHTTP(rw http.ResponseWriter, r *http.Request) {
w.unauthorizedOnce = false
}
partialBody := w.partialBody
ignoreRange := w.ignoreRange
w.requests = append(w.requests, downloadByIDRequest{method: r.Method, path: r.URL.Path, query: r.URL.RawQuery, headers: r.Header.Clone()})
w.mu.Unlock()
if status == http.StatusUnauthorized {
Expand All @@ -96,6 +99,22 @@ func (w *downloadByIDWire) serveHTTP(rw http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(rw, `{"status":404,"code":"not_found","message":"file ID not found"}`)
return
}
if status == http.StatusOK {
byteRange := r.Header.Get("Range")
if ignoreRange {
byteRange = ""
}
response := b2wiretest.RangeDownload(body, byteRange)
status, body = response.Status, response.Body
if status >= http.StatusBadRequest {
rw.WriteHeader(status)
_, _ = fmt.Fprint(rw, body)
return
}
for name, values := range response.Header {
rw.Header()[name] = values
}
}
if partialBody {
rw.Header().Set("Content-Length", "10")
} else {
Expand Down Expand Up @@ -192,7 +211,7 @@ func TestDownloadFileByIDHeadAndRangeWire(t *testing.T) {
bucket := wire.baselineBucket(t)
ctx := context.Background()

reader, err := bucket.downloadFileByID(ctx, "file-id", 2, 3, false, nil)
reader, err := bucket.downloadFileByID(ctx, "historical-id", 2, 3, false, nil)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -240,6 +259,24 @@ func TestDownloadFileByIDNewClientRangeWire(t *testing.T) {
}
}

func TestDownloadFileByIDRangeErrorNamesMethod(t *testing.T) {
wire := newDownloadByIDWire(t)
wire.mu.Lock()
wire.ignoreRange = true
wire.mu.Unlock()

_, err := wire.baselineBucket(t).downloadFileByID(context.Background(), "historical-id", 1, 2, false, nil)
if !errors.Is(err, base.ErrInvalidRangeResponse) {
t.Fatalf("DownloadFileByID error = %v, want an invalid range response", err)
}
if !strings.Contains(err.Error(), "b2_download_file_by_id") {
t.Errorf("DownloadFileByID error = %q, want b2_download_file_by_id", err)
}
if strings.Contains(err.Error(), "b2_download_file_by_name") {
t.Errorf("DownloadFileByID error = %q, must not name b2_download_file_by_name", err)
}
}

func TestDownloadFileByIDMapsStatusAndInvalidIDErrors(t *testing.T) {
for _, tc := range []struct {
name string
Expand Down
49 changes: 10 additions & 39 deletions b2/progress_wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
"sync"
"testing"
"time"

"github.com/Backblaze/blazer/internal/b2wiretest"
)

type progressWireRequest struct {
Expand Down Expand Up @@ -110,48 +112,17 @@ func (w *progressWire) download(t *testing.T, rw http.ResponseWriter, r *http.Re
rw.Header().Set("Content-Length", strconv.Itoa(len(data)))
return
}
start, end := int64(0), int64(len(data)-1)
if h := r.Header.Get("Range"); h != "" {
bounds := strings.SplitN(strings.TrimPrefix(h, "bytes="), "-", 2)
if len(bounds) != 2 {
t.Errorf("invalid Range %q", h)
rw.WriteHeader(http.StatusBadRequest)
return
}
var err error
start, err = strconv.ParseInt(bounds[0], 10, 64)
if err != nil {
t.Errorf("invalid Range %q: %v", h, err)
rw.WriteHeader(http.StatusBadRequest)
return
}
if bounds[1] != "" {
end, err = strconv.ParseInt(bounds[1], 10, 64)
if err != nil {
t.Errorf("invalid Range %q: %v", h, err)
rw.WriteHeader(http.StatusBadRequest)
return
}
}
}
if start < 0 || start >= int64(len(data)) {
rw.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
_, _ = fmt.Fprint(rw, `{"status":416,"code":"range_not_satisfiable","message":"past end"}`)
return
}
if end >= int64(len(data)) {
end = int64(len(data)) - 1
response := b2wiretest.RangeDownload(string(data), r.Header.Get("Range"))
for name, values := range response.Header {
rw.Header()[name] = values
}
if end < start {
t.Errorf("invalid download range: %d-%d", start, end)
rw.WriteHeader(http.StatusBadRequest)
if response.Status >= http.StatusBadRequest {
rw.WriteHeader(response.Status)
_, _ = fmt.Fprint(rw, response.Body)
return
}
chunk := data[int(start) : int(end)+1]
rw.Header().Set("Content-Length", strconv.Itoa(len(chunk)))
if r.Header.Get("Range") != "" {
rw.WriteHeader(http.StatusPartialContent)
}
chunk := []byte(response.Body)
rw.WriteHeader(response.Status)
first := len(chunk)
if first > 1024 {
first = 1024
Expand Down
192 changes: 192 additions & 0 deletions b2/range_response_wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright 2026, the Blazer authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package b2

import (
"context"
"errors"
"io"
"net/http"
"testing"

"github.com/Backblaze/blazer/base"
"github.com/Backblaze/blazer/internal/b2wiretest"
)

func newRangeReaderWireBucket(t *testing.T, download func(b2wiretest.Request) b2wiretest.Response) (*Bucket, *b2wiretest.Server) {
t.Helper()
srv := b2wiretest.New(t)
srv.Handle("object", download)
return newListingWireBucket(t, context.Background(), srv), srv
}

func assertRangeCalls(t *testing.T, srv *b2wiretest.Server, want ...string) {
t.Helper()
calls := srv.Calls("object")
if len(calls) != len(want) {
t.Fatalf("range calls = %d, want %d", len(calls), len(want))
}
for i, call := range calls {
b2wiretest.AssertHeader(t, call, "Range", want[i])
}
}

func TestRangeReaderRejectsFullObjectResponse(t *testing.T) {
bucket, srv := newRangeReaderWireBucket(t, func(b2wiretest.Request) b2wiretest.Response {
return b2wiretest.RangeDownload("abcdef", "")
})
r := bucket.Object("object").NewRangeReader(context.Background(), 2, 2)
r.ChunkSize = 3
t.Cleanup(func() { _ = r.Close() })

n, err := io.ReadFull(r, make([]byte, 3))
if n != 0 || !errors.Is(err, base.ErrInvalidRangeResponse) {
t.Fatalf("NewRangeReader = (%d bytes, %v), want (0, invalid range response)", n, err)
}
assertRangeCalls(t, srv, "bytes=2-3")
}

func TestRangeReaderRejectsNonPartialRangeResponse(t *testing.T) {
bucket, _ := newRangeReaderWireBucket(t, func(b2wiretest.Request) b2wiretest.Response {
response := b2wiretest.RangeDownload("cd", "")
response.Header.Set("Content-Range", "bytes 2-3/6")
return response
})
r := bucket.Object("object").NewRangeReader(context.Background(), 2, 2)
t.Cleanup(func() { _ = r.Close() })
got, err := io.ReadAll(r)
if len(got) != 0 || !errors.Is(err, base.ErrInvalidRangeResponse) {
t.Fatalf("NewRangeReader = (%q, %v), want (empty, invalid range response)", got, err)
}
}

func TestNewReaderAcceptsWholeFileResponse(t *testing.T) {
bucket, srv := newRangeReaderWireBucket(t, func(req b2wiretest.Request) b2wiretest.Response {
return b2wiretest.RangeDownload("abcdef", req.Header.Get("Range"))
})
r := bucket.Object("object").NewReader(context.Background())
r.ChunkSize = 7
t.Cleanup(func() { _ = r.Close() })
got, err := io.ReadAll(r)
if err != nil || string(got) != "abcdef" {
t.Fatalf("NewReader = (%q, %v), want (abcdef, nil)", got, err)
}
assertRangeCalls(t, srv, "bytes=0-6", "bytes=7-13")
}

func TestRangeReaderRejectsOversizedOffsetZeroResponse(t *testing.T) {
bucket, srv := newRangeReaderWireBucket(t, func(b2wiretest.Request) b2wiretest.Response {
return b2wiretest.RangeDownload("abcdef", "")
})
r := bucket.Object("object").NewRangeReader(context.Background(), 0, 2)
t.Cleanup(func() { _ = r.Close() })
got, err := io.ReadAll(r)
if len(got) != 0 || !errors.Is(err, base.ErrInvalidRangeResponse) {
t.Fatalf("NewRangeReader = (%q, %v), want (empty, invalid range response)", got, err)
}
assertRangeCalls(t, srv, "bytes=0-1")
}

func TestRangeReaderRejectsUnboundedFullObjectResponse(t *testing.T) {
bucket, _ := newRangeReaderWireBucket(t, func(b2wiretest.Request) b2wiretest.Response {
return b2wiretest.RangeDownload("abcdef", "")
})
r := bucket.Object("object").NewRangeReader(context.Background(), 0, -1)
r.ChunkSize = 3
t.Cleanup(func() { _ = r.Close() })
got := make([]byte, 4)
n, err := io.ReadFull(r, got)
if n != 0 || !errors.Is(err, base.ErrInvalidRangeResponse) {
t.Fatalf("NewRangeReader = (%q, %v), want (empty, invalid range response)", got[:n], err)
}
}

func TestRangeReaderRejectsWrongContentRange(t *testing.T) {
for _, span := range []string{"bytes 0-1/6", "bytes 2-999999/10"} {
t.Run(span, func(t *testing.T) {
bucket, _ := newRangeReaderWireBucket(t, func(b2wiretest.Request) b2wiretest.Response {
response := b2wiretest.RangeDownload("abcdef", "bytes=2-3")
response.Header.Set("Content-Range", span)
return response
})
r := bucket.Object("object").NewRangeReader(context.Background(), 2, 2)
t.Cleanup(func() { _ = r.Close() })
got, err := io.ReadAll(r)
if len(got) != 0 || !errors.Is(err, base.ErrInvalidRangeResponse) {
t.Fatalf("NewRangeReader = (%q, %v), want (empty, invalid range response)", got, err)
}
})
}
}

func TestRangeReaderRejectsOversizedPartialResponse(t *testing.T) {
bucket, _ := newRangeReaderWireBucket(t, func(b2wiretest.Request) b2wiretest.Response {
response := b2wiretest.RangeDownload("abcdef", "bytes=2-3")
response.Body = "cdefgh"
response.Header.Set("Content-Length", "6")
return response
})
r := bucket.Object("object").NewRangeReader(context.Background(), 2, 2)
t.Cleanup(func() { _ = r.Close() })
got, err := io.ReadAll(r)
if len(got) != 0 || !errors.Is(err, base.ErrInvalidRangeResponse) {
t.Fatalf("NewRangeReader = (%q, %v), want (empty, invalid range response)", got, err)
}
}

func TestRangeReaderAcceptsMatchingContentLength(t *testing.T) {
bucket, srv := newRangeReaderWireBucket(t, func(req b2wiretest.Request) b2wiretest.Response {
return b2wiretest.RangeDownload("abcdef", req.Header.Get("Range"))
})
r := bucket.Object("object").NewRangeReader(context.Background(), 2, 2)
t.Cleanup(func() { _ = r.Close() })
got, err := io.ReadAll(r)
if err != nil || string(got) != "cd" {
t.Fatalf("NewRangeReader = (%q, %v), want (cd, nil)", got, err)
}
assertRangeCalls(t, srv, "bytes=2-3")
}

func TestRangeReaderRejectsMissingContentRange(t *testing.T) {
bucket, _ := newRangeReaderWireBucket(t, func(b2wiretest.Request) b2wiretest.Response {
return b2wiretest.Response{Status: http.StatusPartialContent, Header: http.Header{"Content-Length": {"2"}}, Body: "cd"}
})
r := bucket.Object("object").NewRangeReader(context.Background(), 2, 2)
t.Cleanup(func() { _ = r.Close() })
got, err := io.ReadAll(r)
if len(got) != 0 || !errors.Is(err, base.ErrInvalidRangeResponse) {
t.Fatalf("NewRangeReader = (%q, %v), want (empty, invalid range response)", got, err)
}
}

func TestRangeReaderRejectsChunkedWholeFileResponse(t *testing.T) {
bucket, srv := newRangeReaderWireBucket(t, func(b2wiretest.Request) b2wiretest.Response {
return b2wiretest.Response{Status: http.StatusOK, Header: http.Header{"Transfer-Encoding": {"chunked"}}, Body: "aa"}
})
probe, err := http.Get(srv.URL() + "/file/bucket/object")
if err != nil {
t.Fatal(err)
}
_ = probe.Body.Close()
if got := probe.Header.Get("Content-Length"); got != "" {
t.Fatalf("chunked fixture Content-Length = %q, want absent", got)
}
r := bucket.Object("object").NewRangeReader(context.Background(), 0, 2)
t.Cleanup(func() { _ = r.Close() })
got, err := io.ReadAll(r)
if len(got) != 0 || !errors.Is(err, base.ErrInvalidRangeResponse) {
t.Fatalf("NewRangeReader = (%q, %v), want (empty, invalid range response)", got, err)
}
}
32 changes: 7 additions & 25 deletions b2/sha1_wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"testing"

"github.com/Backblaze/blazer/internal/b2wiretest"
Expand Down Expand Up @@ -150,29 +148,13 @@ func TestReaderVerify(t *testing.T) {

func downloadResponse(body []byte, sha1 string) func(b2wiretest.Request) b2wiretest.Response {
return func(req b2wiretest.Request) b2wiretest.Response {
offset := 0
if value := req.Header.Get("Range"); value != "" {
start, _, ok := strings.Cut(strings.TrimPrefix(value, "bytes="), "-")
parsed, err := strconv.Atoi(start)
if !ok || err != nil {
return b2wiretest.Response{Status: http.StatusBadRequest, Body: `{"status":400,"code":"bad_request","message":"invalid range"}`}
}
offset = parsed
}
if offset >= len(body) {
return b2wiretest.Response{Status: http.StatusRequestedRangeNotSatisfiable, Body: `{"status":416,"code":"range_not_satisfiable","message":"past end"}`}
}
chunk := body[offset:]
headers := make(http.Header)
headers.Set("Content-Length", strconv.Itoa(len(chunk)))
headers.Set("Content-Type", "application/octet-stream")
headers.Set("X-Bz-File-Id", "file-"+req.Call)
headers.Set("X-Bz-File-Name", req.Call)
headers.Set("X-Bz-Content-Sha1", sha1)
status := http.StatusOK
if req.Header.Get("Range") != "" {
status = http.StatusPartialContent
response := b2wiretest.RangeDownload(string(body), req.Header.Get("Range"))
if response.Status == http.StatusOK || response.Status == http.StatusPartialContent {
response.Header.Set("Content-Type", "application/octet-stream")
response.Header.Set("X-Bz-File-Id", "file-"+req.Call)
response.Header.Set("X-Bz-File-Name", req.Call)
response.Header.Set("X-Bz-Content-Sha1", sha1)
}
return b2wiretest.Response{Status: status, Header: headers, Body: string(chunk)}
return response
}
}
3 changes: 1 addition & 2 deletions b2/writer_reader_edges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ func TestReaderChunkSizeOneAndDefaults(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
srv, bucket := newSHA1WireBucket(t)
srv.Handle("one-byte", func(req b2wiretest.Request) b2wiretest.Response {
body := map[string]string{"bytes=0-0": "a", "bytes=1-1": "b", "bytes=2-2": "c", "bytes=0-2": "abc"}[req.Header.Get("Range")]
return b2wiretest.Response{Status: http.StatusPartialContent, Body: body}
return b2wiretest.RangeDownload("abc", req.Header.Get("Range"))
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
Expand Down
Loading
Loading