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
84 changes: 84 additions & 0 deletions pkg/x/http/rangecontrol/benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package rangecontrol

import "testing"

var (
benchmarkByteRanges []ByteRange
benchmarkContentRangeString string
)

func BenchmarkParseRange(b *testing.B) {
cases := []struct {
name string
value string
size int64
}{
{"single", "bytes=0-499", 1 << 20},
{"open_ended", "bytes=500-", 1 << 20},
{"suffix", "bytes=-500", 1 << 20},
{"multi", "bytes=0-499,500-999,-500", 1 << 20},
{"spaced_multi", "bytes=0-0, 1-1, 1048570-", 1 << 20},
}

for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
ranges, err := ParseRange(tc.value, tc.size)
if err != nil {
b.Fatal(err)
}
benchmarkByteRanges = ranges
}
})
}
}

func BenchmarkParse(b *testing.B) {
cases := []struct {
name string
value string
size uint64
}{
{"single", "bytes=0-499", 1 << 20},
{"open_ended", "bytes=500-", 1 << 20},
{"suffix", "bytes=-500", 1 << 20},
{"multi", "bytes=0-499,500-999,-500", 1 << 20},
{"spaced_multi", "bytes=0-0, 1-1, 1048570-", 1 << 20},
}

for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
ranges, err := Parse(tc.value, tc.size)
if err != nil {
b.Fatal(err)
}
benchmarkByteRanges = ranges
}
})
}
}

func BenchmarkContentRange(b *testing.B) {
cases := []struct {
name string
r ByteRange
size uint64
}{
{"closed", ByteRange{Start: 0, End: 499}, 1 << 20},
{"open_ended", ByteRange{Start: 500, End: -1}, 1 << 20},
{"clamped", ByteRange{Start: 0, End: 2 << 20}, 1 << 20},
{"unknown_size", ByteRange{Start: 0, End: 499}, 0},
}

for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
benchmarkContentRangeString = tc.r.ContentRange(tc.size)
}
})
}
}
118 changes: 111 additions & 7 deletions pkg/x/http/rangecontrol/request_range.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (r ByteRange) MimeHeader(contentType string, size uint64) textproto.MIMEHea
}

// Parse parses a Range header and returns.
func Parse(rangeHeader string) ([]ByteRange, error) {
func Parse(rangeHeader string, totalSize uint64) ([]ByteRange, error) {
if rangeHeader == "" {
return nil, nil
}
Expand All @@ -67,11 +67,6 @@ func Parse(rangeHeader string) ([]ByteRange, error) {
for _, part := range parts {
part = strings.TrimSpace(part)

// suffix-range: "-500" (not supported without total size)
if strings.HasPrefix(part, "-") {
return nil, ErrInvalidRange
}

dash := strings.IndexByte(part, '-')
if dash < 0 {
return nil, ErrInvalidRange
Expand All @@ -80,6 +75,26 @@ func Parse(rangeHeader string) ([]ByteRange, error) {
startStr := part[:dash]
endStr := part[dash+1:]

// suffix-range: "-500"
if strings.HasPrefix(part, "-") {
end, err := strconv.ParseInt(endStr, 10, 64)
if err != nil || end < 0 {
return nil, ErrInvalidRange
}

start := int64(totalSize) - end
// If totalSize is unknown (0) or suffix exceeds totalSize, skip
if start < 0 {
continue
}

ranges = append(ranges, ByteRange{
Start: start,
End: int64(totalSize) - 1,
})
continue
}

start, err := strconv.ParseInt(startStr, 10, 64)
if err != nil || start < 0 {
return nil, ErrInvalidRange
Expand All @@ -88,7 +103,8 @@ func Parse(rangeHeader string) ([]ByteRange, error) {
var end int64
if endStr == "" {
// open-ended range: "100-"
end = -1
// end = -1
end = int64(totalSize) - 1
} else {
end, err = strconv.ParseInt(endStr, 10, 64)
if err != nil || end < start {
Expand All @@ -105,6 +121,92 @@ func Parse(rangeHeader string) ([]ByteRange, error) {
return ranges, nil
}

var (
// ErrNoOverlap is returned by ParseRange if first-byte-pos of
// all of the byte-range-spec values is greater than the content size.
ErrNoOverlap = errors.New("invalid range: failed to overlap")

// ErrInvalid is returned by ParseRange on invalid input.
ErrInvalid = errors.New("invalid range")
)

// ParseRange parses a Range header string as per RFC 7233.
// ErrNoOverlap is returned if none of the ranges overlap.
// ErrInvalid is returned if s is invalid range.
func ParseRange(s string, size int64) ([]ByteRange, error) { // nolint:gocognit
if s == "" {
return nil, nil // header not present
}
const b = "bytes="
if !strings.HasPrefix(s, b) {
return nil, ErrInvalid
}
var ranges []ByteRange
noOverlap := false
for _, ra := range strings.Split(s[len(b):], ",") {
ra = textproto.TrimString(ra)
if ra == "" {
continue
}
i := strings.Index(ra, "-")
if i < 0 {
return nil, ErrInvalid
}
start, end := textproto.TrimString(ra[:i]), textproto.TrimString(ra[i+1:])
var r ByteRange
if start == "" {
// If no start is specified, end specifies the
// range start relative to the end of the file,
// and we are dealing with <suffix-length>
// which has to be a non-negative integer as per
// RFC 7233 Section 2.1 "Byte-Ranges".
if end == "" || end[0] == '-' {
return nil, ErrInvalid
}
i, err := strconv.ParseInt(end, 10, 64)
if i < 0 || err != nil {
return nil, ErrInvalid
}
if i > size {
i = size
}
r.Start = size - i
r.End = size - r.Start
} else {
i, err := strconv.ParseInt(start, 10, 64)
if err != nil || i < 0 {
return nil, ErrInvalid
}
if i >= size {
// If the range begins after the size of the content,
// then it does not overlap.
noOverlap = true
continue
}
r.Start = i
if end == "" {
// If no end is specified, range extends to end of the file.
r.End = size - r.Start
} else {
i, err := strconv.ParseInt(end, 10, 64)
if err != nil || r.Start > i {
return nil, ErrInvalid
}
if i >= size {
i = size - 1
}
r.End = i - r.Start + 1
}
}
ranges = append(ranges, r)
}
if noOverlap && len(ranges) == 0 {
// The specified ranges did not overlap with the content.
return nil, ErrNoOverlap
}
return ranges, nil
}

func SortRanges(ranges []ByteRange) {
sort.Slice(ranges, func(i, j int) bool {
if ranges[i].Start != ranges[j].Start {
Expand All @@ -127,6 +229,8 @@ func MergeRanges(ranges []ByteRange) []ByteRange {
return ranges
}

SortRanges(ranges)

merged := make([]ByteRange, 0, len(ranges))
cur := ranges[0]

Expand Down
8 changes: 5 additions & 3 deletions pkg/x/http/rangecontrol/request_range_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ func TestParse(t *testing.T) {
{"empty header", "", nil, false},
{"valid single closed", "bytes=0-499", []ByteRange{{Start: 0, End: 499}}, false},
{"valid multiple", "bytes=0-0, 1-1", []ByteRange{{Start: 0, End: 0}, {Start: 1, End: 1}}, false},
{"open-ended", "bytes=100-", []ByteRange{{Start: 100, End: -1}}, false},
{"open-ended with known size", "bytes=100-", []ByteRange{{Start: 100, End: 1048575}}, false},
{"spaces and multiple", "bytes=0-10,20-30", []ByteRange{{Start: 0, End: 10}, {Start: 20, End: 30}}, false},
{"invalid prefix", "byt=0-1", nil, true},
{"suffix-range not supported", "bytes=-500", nil, true},
// {"suffix-range not supported", "bytes=-500", nil, true},
{"no dash", "bytes=500", nil, true},
{"non-numeric start", "bytes=a-5", nil, true},
{"end less than start", "bytes=10-5", nil, true},
{"suffix-range and closed range", "bytes=0-499,-500", []ByteRange{{Start: 0, End: 499}, {Start: 1048076, End: 1048575}}, false},
}
totalSize := uint64(1048576)

for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got, err := Parse(tc.header)
got, err := Parse(tc.header, totalSize)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error but got nil")
Expand Down
33 changes: 33 additions & 0 deletions server/middleware/caching/caching_fillrange.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"math"
"net/http"
"strconv"
"strings"

"github.com/omalloc/tavern/internal/protocol"
"github.com/omalloc/tavern/pkg/iobuf"
Expand Down Expand Up @@ -117,6 +118,9 @@ func (f *fillRange) fill(c *Caching, req *http.Request, rawRange string) *http.R
if c.md != nil {
objSize = c.md.Size
}
if objSize == uint64(math.MaxInt64) && hasUnresolvableRange(rawRange) {
return req
}

rng, err := xhttp.SingleRange(rawRange, objSize)
if err != nil {
Expand Down Expand Up @@ -188,6 +192,35 @@ func (f *fillRange) fill(c *Caching, req *http.Request, rawRange string) *http.R
return req.WithContext(context.WithValue(req.Context(), fillRangeKey{}, fill))
}

// hasUnresolvableRange returns true if the range header contains suffix ranges
// or open-ended ranges that cannot be resolved without knowing the object size.
func hasUnresolvableRange(rawRange string) bool {
const prefix = "bytes="
if !strings.HasPrefix(rawRange, prefix) {
return false
}

for _, part := range strings.Split(strings.TrimPrefix(rawRange, prefix), ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
dash := strings.IndexByte(part, '-')
if dash < 0 {
continue
}
// Suffix range: starts with "-" (e.g., "-512")
if dash == 0 {
return true
}
// Open-ended range: ends with "-" (e.g., "100-")
if dash == len(part)-1 {
return true
}
}
return false
}

func parseFillPercent(h http.Header, def uint64) uint64 {
fp := h.Get(protocol.InternalFillRangePercent)
if fp != "" {
Expand Down
Loading
Loading