Skip to content

Commit 9cb6c5b

Browse files
authored
util/httphdr: add new package for parsing HTTP headers (tailscale#9797)
This adds support for parsing Range and Content-Range headers according to RFC 7230. The package could be extended in the future to handle other headers. Updates tailscale/corp#14772 Signed-off-by: Joe Tsai <joetsai@digital-static.net>
1 parent af5a586 commit 9cb6c5b

File tree

2 files changed

+293
-0
lines changed

2 files changed

+293
-0
lines changed

util/httphdr/httphdr.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
// Package httphdr implements functionality for parsing and formatting
5+
// standard HTTP headers.
6+
package httphdr
7+
8+
import (
9+
"bytes"
10+
"strconv"
11+
"strings"
12+
)
13+
14+
// Range is a range of bytes within some content.
15+
type Range struct {
16+
// Start is the starting offset.
17+
// It is zero if Length is negative; it must not be negative.
18+
Start int64
19+
// Length is the length of the content.
20+
// It is zero if the length extends to the end of the content.
21+
// It is negative if the length is relative to the end (e.g., last 5 bytes).
22+
Length int64
23+
}
24+
25+
// ows is optional whitespace.
26+
const ows = " \t" // per RFC 7230, section 3.2.3
27+
28+
// ParseRange parses a "Range" header per RFC 7233, section 3.
29+
// It only handles "Range" headers where the units is "bytes".
30+
// The "Range" header is usually only specified in GET requests.
31+
func ParseRange(hdr string) (ranges []Range, ok bool) {
32+
// Grammar per RFC 7233, appendix D:
33+
// Range = byte-ranges-specifier | other-ranges-specifier
34+
// byte-ranges-specifier = bytes-unit "=" byte-range-set
35+
// bytes-unit = "bytes"
36+
// byte-range-set =
37+
// *("," OWS)
38+
// (byte-range-spec | suffix-byte-range-spec)
39+
// *(OWS "," [OWS ( byte-range-spec | suffix-byte-range-spec )])
40+
// byte-range-spec = first-byte-pos "-" [last-byte-pos]
41+
// suffix-byte-range-spec = "-" suffix-length
42+
// We do not support other-ranges-specifier.
43+
// All other identifiers are 1*DIGIT.
44+
hdr = strings.Trim(hdr, ows) // per RFC 7230, section 3.2
45+
units, elems, hasUnits := strings.Cut(hdr, "=")
46+
elems = strings.TrimLeft(elems, ","+ows)
47+
for _, elem := range strings.Split(elems, ",") {
48+
elem = strings.Trim(elem, ows) // per RFC 7230, section 7
49+
switch {
50+
case strings.HasPrefix(elem, "-"): // i.e., "-" suffix-length
51+
n, ok := parseNumber(strings.TrimPrefix(elem, "-"))
52+
if !ok {
53+
return ranges, false
54+
}
55+
ranges = append(ranges, Range{0, -n})
56+
case strings.HasSuffix(elem, "-"): // i.e., first-byte-pos "-"
57+
n, ok := parseNumber(strings.TrimSuffix(elem, "-"))
58+
if !ok {
59+
return ranges, false
60+
}
61+
ranges = append(ranges, Range{n, 0})
62+
default: // i.e., first-byte-pos "-" last-byte-pos
63+
prefix, suffix, hasDash := strings.Cut(elem, "-")
64+
n, ok2 := parseNumber(prefix)
65+
m, ok3 := parseNumber(suffix)
66+
if !hasDash || !ok2 || !ok3 || m < n {
67+
return ranges, false
68+
}
69+
ranges = append(ranges, Range{n, m - n + 1})
70+
}
71+
}
72+
return ranges, units == "bytes" && hasUnits && len(ranges) > 0 // must see at least one element per RFC 7233, section 2.1
73+
}
74+
75+
// FormatRange formats a "Range" header per RFC 7233, section 3.
76+
// It only handles "Range" headers where the units is "bytes".
77+
// The "Range" header is usually only specified in GET requests.
78+
func FormatRange(ranges []Range) (hdr string, ok bool) {
79+
b := []byte("bytes=")
80+
for _, r := range ranges {
81+
switch {
82+
case r.Length > 0: // i.e., first-byte-pos "-" last-byte-pos
83+
if r.Start < 0 {
84+
return string(b), false
85+
}
86+
b = strconv.AppendUint(b, uint64(r.Start), 10)
87+
b = append(b, '-')
88+
b = strconv.AppendUint(b, uint64(r.Start+r.Length-1), 10)
89+
b = append(b, ',')
90+
case r.Length == 0: // i.e., first-byte-pos "-"
91+
if r.Start < 0 {
92+
return string(b), false
93+
}
94+
b = strconv.AppendUint(b, uint64(r.Start), 10)
95+
b = append(b, '-')
96+
b = append(b, ',')
97+
case r.Length < 0: // i.e., "-" suffix-length
98+
if r.Start != 0 {
99+
return string(b), false
100+
}
101+
b = append(b, '-')
102+
b = strconv.AppendUint(b, uint64(-r.Length), 10)
103+
b = append(b, ',')
104+
default:
105+
return string(b), false
106+
}
107+
}
108+
return string(bytes.TrimRight(b, ",")), len(ranges) > 0
109+
}
110+
111+
// ParseContentRange parses a "Content-Range" header per RFC 7233, section 4.2.
112+
// It only handles "Content-Range" headers where the units is "bytes".
113+
// The "Content-Range" header is usually only specified in HTTP responses.
114+
//
115+
// If only the completeLength is specified, then start and length are both zero.
116+
//
117+
// Otherwise, the parses the start and length and the optional completeLength,
118+
// which is -1 if unspecified. The start is non-negative and the length is positive.
119+
func ParseContentRange(hdr string) (start, length, completeLength int64, ok bool) {
120+
// Grammar per RFC 7233, appendix D:
121+
// Content-Range = byte-content-range | other-content-range
122+
// byte-content-range = bytes-unit SP (byte-range-resp | unsatisfied-range)
123+
// bytes-unit = "bytes"
124+
// byte-range-resp = byte-range "/" (complete-length | "*")
125+
// unsatisfied-range = "*/" complete-length
126+
// byte-range = first-byte-pos "-" last-byte-pos
127+
// We do not support other-content-range.
128+
// All other identifiers are 1*DIGIT.
129+
hdr = strings.Trim(hdr, ows) // per RFC 7230, section 3.2
130+
suffix, hasUnits := strings.CutPrefix(hdr, "bytes ")
131+
suffix, unsatisfied := strings.CutPrefix(suffix, "*/")
132+
if unsatisfied { // i.e., unsatisfied-range
133+
n, ok := parseNumber(suffix)
134+
if !ok {
135+
return start, length, completeLength, false
136+
}
137+
completeLength = n
138+
} else { // i.e., byte-range "/" (complete-length | "*")
139+
prefix, suffix, hasDash := strings.Cut(suffix, "-")
140+
middle, suffix, hasSlash := strings.Cut(suffix, "/")
141+
n, ok0 := parseNumber(prefix)
142+
m, ok1 := parseNumber(middle)
143+
o, ok2 := parseNumber(suffix)
144+
if suffix == "*" {
145+
o, ok2 = -1, true
146+
}
147+
if !hasDash || !hasSlash || !ok0 || !ok1 || !ok2 || m < n || (o >= 0 && o <= m) {
148+
return start, length, completeLength, false
149+
}
150+
start = n
151+
length = m - n + 1
152+
completeLength = o
153+
}
154+
return start, length, completeLength, hasUnits
155+
}
156+
157+
// FormatContentRange parses a "Content-Range" header per RFC 7233, section 4.2.
158+
// It only handles "Content-Range" headers where the units is "bytes".
159+
// The "Content-Range" header is usually only specified in HTTP responses.
160+
//
161+
// If start and length are non-positive, then it encodes just the completeLength,
162+
// which must be a non-negative value.
163+
//
164+
// Otherwise, it encodes the start and length as a byte-range,
165+
// and optionally emits the complete length if it is non-negative.
166+
// The length must be positive (as RFC 7233 uses inclusive end offsets).
167+
func FormatContentRange(start, length, completeLength int64) (hdr string, ok bool) {
168+
b := []byte("bytes ")
169+
switch {
170+
case start <= 0 && length <= 0 && completeLength >= 0: // i.e., unsatisfied-range
171+
b = append(b, "*/"...)
172+
b = strconv.AppendUint(b, uint64(completeLength), 10)
173+
ok = true
174+
case start >= 0 && length > 0: // i.e., byte-range "/" (complete-length | "*")
175+
b = strconv.AppendUint(b, uint64(start), 10)
176+
b = append(b, '-')
177+
b = strconv.AppendUint(b, uint64(start+length-1), 10)
178+
b = append(b, '/')
179+
if completeLength >= 0 {
180+
b = strconv.AppendUint(b, uint64(completeLength), 10)
181+
ok = completeLength >= start+length && start+length > 0
182+
} else {
183+
b = append(b, '*')
184+
ok = true
185+
}
186+
}
187+
return string(b), ok
188+
}
189+
190+
// parseNumber parses s as an unsigned decimal integer.
191+
// It parses according to the 1*DIGIT grammar, which allows leading zeros.
192+
func parseNumber(s string) (int64, bool) {
193+
suffix := strings.TrimLeft(s, "0123456789")
194+
prefix := s[:len(s)-len(suffix)]
195+
n, err := strconv.ParseInt(prefix, 10, 64)
196+
return n, suffix == "" && err == nil
197+
}

util/httphdr/httphdr_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package httphdr
5+
6+
import (
7+
"testing"
8+
9+
"github.com/google/go-cmp/cmp"
10+
)
11+
12+
func valOk[T any](v T, ok bool) (out struct {
13+
V T
14+
Ok bool
15+
}) {
16+
out.V = v
17+
out.Ok = ok
18+
return out
19+
}
20+
21+
func TestRange(t *testing.T) {
22+
tests := []struct {
23+
in string
24+
want []Range
25+
wantOk bool
26+
roundtrip bool
27+
}{
28+
{"", nil, false, false},
29+
{"1-3", nil, false, false},
30+
{"units=1-3", []Range{{1, 3}}, false, false},
31+
{"bytes=1-3", []Range{{1, 3}}, true, true},
32+
{"bytes=#-3", nil, false, false},
33+
{"bytes=#-", nil, false, false},
34+
{"bytes=13", nil, false, false},
35+
{"bytes=1-#", nil, false, false},
36+
{"bytes=-#", nil, false, false},
37+
{"bytes= , , , ,\t , \t 1-3", []Range{{1, 3}}, true, false},
38+
{"bytes=1-1", []Range{{1, 1}}, true, true},
39+
{"bytes=01-01", []Range{{1, 1}}, true, false},
40+
{"bytes=1-0", nil, false, false},
41+
{"bytes=0-5,2-3", []Range{{0, 6}, {2, 2}}, true, true},
42+
{"bytes=2-3,0-5", []Range{{2, 2}, {0, 6}}, true, true},
43+
{"bytes=0-5,2-,-5", []Range{{0, 6}, {2, 0}, {0, -5}}, true, true},
44+
}
45+
46+
for _, tt := range tests {
47+
got, gotOk := ParseRange(tt.in)
48+
if d := cmp.Diff(valOk(got, gotOk), valOk(tt.want, tt.wantOk)); d != "" {
49+
t.Errorf("ParseRange(%q) mismatch (-got +want):\n%s", tt.in, d)
50+
}
51+
if tt.roundtrip {
52+
got, gotOk := FormatRange(tt.want)
53+
if d := cmp.Diff(valOk(got, gotOk), valOk(tt.in, tt.wantOk)); d != "" {
54+
t.Errorf("FormatRange(%v) mismatch (-got +want):\n%s", tt.want, d)
55+
}
56+
}
57+
}
58+
}
59+
60+
type contentRange struct{ Start, Length, CompleteLength int64 }
61+
62+
func TestContentRange(t *testing.T) {
63+
tests := []struct {
64+
in string
65+
want contentRange
66+
wantOk bool
67+
roundtrip bool
68+
}{
69+
{"", contentRange{}, false, false},
70+
{"bytes 5-6/*", contentRange{5, 2, -1}, true, true},
71+
{"units 5-6/*", contentRange{}, false, false},
72+
{"bytes 5-6/*", contentRange{}, false, false},
73+
{"bytes 5-5/*", contentRange{5, 1, -1}, true, true},
74+
{"bytes 5-4/*", contentRange{}, false, false},
75+
{"bytes 5-5/6", contentRange{5, 1, 6}, true, true},
76+
{"bytes 05-005/0006", contentRange{5, 1, 6}, true, false},
77+
{"bytes 5-5/5", contentRange{}, false, false},
78+
{"bytes #-5/6", contentRange{}, false, false},
79+
{"bytes 5-#/6", contentRange{}, false, false},
80+
{"bytes 5-5/#", contentRange{}, false, false},
81+
}
82+
83+
for _, tt := range tests {
84+
start, length, completeLength, gotOk := ParseContentRange(tt.in)
85+
got := contentRange{start, length, completeLength}
86+
if d := cmp.Diff(valOk(got, gotOk), valOk(tt.want, tt.wantOk)); d != "" {
87+
t.Errorf("ParseContentRange mismatch (-got +want):\n%s", d)
88+
}
89+
if tt.roundtrip {
90+
got, gotOk := FormatContentRange(tt.want.Start, tt.want.Length, tt.want.CompleteLength)
91+
if d := cmp.Diff(valOk(got, gotOk), valOk(tt.in, tt.wantOk)); d != "" {
92+
t.Errorf("FormatContentRange mismatch (-got +want):\n%s", d)
93+
}
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)