Skip to content

Commit 30c9b55

Browse files
committed
fix(cache): support validated ranged proxy downloads
1 parent ed9a4ba commit 30c9b55

26 files changed

Lines changed: 2074 additions & 95 deletions

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,17 @@ composition; verify lifecycle support when using an S3-compatible endpoint.
162162

163163
Local signed upload URLs expire after 24 hours; signed download URLs and S3 direct download URLs expire after 10 minutes.
164164

165+
Keep `ENABLE_DIRECT_DOWNLOADS=false` for Azure v2-compatible clients that may
166+
resume at a nonzero offset. Those clients send `x-ms-range`, which S3 does not
167+
translate to its standard `Range` header; proxied downloads perform the required
168+
translation and validation.
169+
170+
Proxied download URLs support `HEAD`, standard `Range`, and Azure's
171+
`x-ms-range` (which takes precedence when both range fields are present).
172+
Successful byte ranges return a single `206` response with exact
173+
`Content-Range`; unsatisfiable ranges return `416`. Malformed ranges, including
174+
the unsafe `x-ms-range` plus `If-Range` combination, fail closed with `400`.
175+
165176
Eligible caches are presigned directly; layouts that do not satisfy the
166177
backend's multipart constraints transparently fall back to server-proxied
167178
downloads.

e2e/cache_flow_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,22 @@ func TestSQLiteFilesystemSaveAndRestore(t *testing.T) {
6969

7070
require.Equal(t, "cache-content", downloadCache(t, router, downloadURL))
7171

72+
rangeReq := httptest.NewRequest(http.MethodGet, downloadURL.RequestURI(), nil)
73+
rangeReq.Header.Set("X-Ms-Range", "bytes=2-")
74+
rangeRec := httptest.NewRecorder()
75+
router.ServeHTTP(rangeRec, rangeReq)
76+
require.Equal(t, http.StatusPartialContent, rangeRec.Code)
77+
require.Equal(t, "bytes 2-12/13", rangeRec.Header().Get("Content-Range"))
78+
require.Equal(t, "che-content", rangeRec.Body.String())
79+
80+
headReq := httptest.NewRequest(http.MethodHead, downloadURL.RequestURI(), nil)
81+
headRec := httptest.NewRecorder()
82+
router.ServeHTTP(headRec, headReq)
83+
require.Equal(t, http.StatusOK, headRec.Code)
84+
require.Equal(t, "13", headRec.Header().Get("Content-Length"))
85+
require.Equal(t, "bytes", headRec.Header().Get("Accept-Ranges"))
86+
require.Empty(t, headRec.Body.String())
87+
7288
legacyURL := *downloadURL
7389
legacyQuery := legacyURL.Query()
7490
legacyQuery.Set("signature", legacyQuery.Get("sig"))

internal/cache/byte_range.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package cache
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
)
7+
8+
var ErrRangeNotSatisfiable = errors.New("requested byte range is not satisfiable")
9+
10+
type byteRangeKind uint8
11+
12+
const (
13+
closedByteRange byteRangeKind = iota
14+
openEndedByteRange
15+
suffixByteRange
16+
)
17+
18+
// ByteRange is a parsed, representation-independent byte range. Constructors
19+
// keep HTTP syntax out of the cache package while preserving the distinction
20+
// between closed, open-ended, and suffix ranges until the total size is known.
21+
type ByteRange struct {
22+
kind byteRangeKind
23+
first int64
24+
last int64
25+
suffix int64
26+
}
27+
28+
func ClosedByteRange(first, last int64) ByteRange {
29+
return ByteRange{kind: closedByteRange, first: first, last: last}
30+
}
31+
32+
func OpenEndedByteRange(first int64) ByteRange {
33+
return ByteRange{kind: openEndedByteRange, first: first}
34+
}
35+
36+
func SuffixByteRange(length int64) ByteRange {
37+
return ByteRange{kind: suffixByteRange, suffix: length}
38+
}
39+
40+
type DownloadRange struct {
41+
Offset int64
42+
Count int64
43+
}
44+
45+
type RangeNotSatisfiableError struct {
46+
SizeBytes int64
47+
}
48+
49+
func (e *RangeNotSatisfiableError) Error() string {
50+
return fmt.Sprintf("%s: size %d", ErrRangeNotSatisfiable, e.SizeBytes)
51+
}
52+
53+
func (e *RangeNotSatisfiableError) Unwrap() error {
54+
return ErrRangeNotSatisfiable
55+
}
56+
57+
func resolveByteRanges(ranges []ByteRange, size int64) (DownloadRange, error) {
58+
if size < 0 {
59+
return DownloadRange{}, fmt.Errorf("invalid cache size %d", size)
60+
}
61+
for _, candidate := range ranges {
62+
switch candidate.kind {
63+
case closedByteRange:
64+
if candidate.first < 0 || candidate.last < candidate.first {
65+
return DownloadRange{}, fmt.Errorf("invalid closed byte range %d-%d", candidate.first, candidate.last)
66+
}
67+
if candidate.first >= size {
68+
continue
69+
}
70+
last := min(candidate.last, size-1)
71+
return DownloadRange{Offset: candidate.first, Count: last - candidate.first + 1}, nil
72+
case openEndedByteRange:
73+
if candidate.first < 0 {
74+
return DownloadRange{}, fmt.Errorf("invalid open-ended byte range %d-", candidate.first)
75+
}
76+
if candidate.first >= size {
77+
continue
78+
}
79+
return DownloadRange{Offset: candidate.first, Count: size - candidate.first}, nil
80+
case suffixByteRange:
81+
if candidate.suffix < 0 {
82+
return DownloadRange{}, fmt.Errorf("invalid suffix byte range -%d", candidate.suffix)
83+
}
84+
if candidate.suffix == 0 || size == 0 {
85+
continue
86+
}
87+
count := min(candidate.suffix, size)
88+
return DownloadRange{Offset: size - count, Count: count}, nil
89+
default:
90+
return DownloadRange{}, fmt.Errorf("invalid byte range kind %d", candidate.kind)
91+
}
92+
}
93+
return DownloadRange{}, &RangeNotSatisfiableError{SizeBytes: size}
94+
}

internal/cache/byte_range_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package cache
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestResolveByteRangesSelectsFirstSatisfiableRange(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
ranges []ByteRange
13+
size int64
14+
want DownloadRange
15+
}{
16+
{name: "closed clamps end", ranges: []ByteRange{ClosedByteRange(2, 20)}, size: 9, want: DownloadRange{Offset: 2, Count: 7}},
17+
{name: "open ended", ranges: []ByteRange{OpenEndedByteRange(2)}, size: 9, want: DownloadRange{Offset: 2, Count: 7}},
18+
{name: "suffix", ranges: []ByteRange{SuffixByteRange(3)}, size: 9, want: DownloadRange{Offset: 6, Count: 3}},
19+
{name: "large suffix", ranges: []ByteRange{SuffixByteRange(20)}, size: 9, want: DownloadRange{Offset: 0, Count: 9}},
20+
{name: "later satisfiable", ranges: []ByteRange{ClosedByteRange(99, 100), ClosedByteRange(1, 2)}, size: 9, want: DownloadRange{Offset: 1, Count: 2}},
21+
}
22+
for _, test := range tests {
23+
t.Run(test.name, func(t *testing.T) {
24+
got, err := resolveByteRanges(test.ranges, test.size)
25+
require.NoError(t, err)
26+
require.Equal(t, test.want, got)
27+
})
28+
}
29+
}
30+
31+
func TestResolveByteRangesReportsUnsatisfiableSize(t *testing.T) {
32+
for _, test := range []struct {
33+
name string
34+
spec ByteRange
35+
size int64
36+
}{
37+
{name: "start at end", spec: OpenEndedByteRange(4), size: 4},
38+
{name: "zero suffix", spec: SuffixByteRange(0), size: 4},
39+
{name: "empty object", spec: SuffixByteRange(1), size: 0},
40+
} {
41+
t.Run(test.name, func(t *testing.T) {
42+
_, err := resolveByteRanges([]ByteRange{test.spec}, test.size)
43+
var rangeErr *RangeNotSatisfiableError
44+
require.ErrorAs(t, err, &rangeErr)
45+
require.Equal(t, test.size, rangeErr.SizeBytes)
46+
})
47+
}
48+
}

0 commit comments

Comments
 (0)