Skip to content

Commit b2ab371

Browse files
committed
internal/httpsfv: implement parsing support for date and display string
This change adds support for parsing date and display string, meaning this package can now fully parse any HTTP SFV that is compliant with RFC 9651. This package is still intended only for internal use at this point. For golang/go#75500 Change-Id: I07626b45f01e0c5cb4e92aa3fea04cc7e2d0c814 Reviewed-on: https://go-review.googlesource.com/c/net/+/708437 Reviewed-by: Damien Neil <[email protected]> Reviewed-by: Carlos Amedee <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]>
1 parent edb764c commit b2ab371

File tree

2 files changed

+211
-3
lines changed

2 files changed

+211
-3
lines changed

internal/httpsfv/httpsfv.go

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"slices"
1111
"strconv"
1212
"strings"
13+
"time"
1314
"unicode/utf8"
1415
)
1516

@@ -72,9 +73,6 @@ func decOctetHex(ch1, ch2 byte) (ch byte, ok bool) {
7273
return ch1<<4 | ch2, true
7374
}
7475

75-
// TODO(nsh): Implement parse functions for date and display string to make
76-
// this package fully support parsing RFC 9651-compliant HTTP SFV.
77-
7876
// ParseList parses a list from a given HTTP Structured Field Values.
7977
//
8078
// Given an HTTP SFV string that represents a list, it will call the given
@@ -534,6 +532,23 @@ func consumeDate(s string) (consumed, rest string, ok bool) {
534532
return consumed, rest, ok
535533
}
536534

535+
// ParseDate parses a date from a given HTTP Structured Field Values.
536+
//
537+
// The entire HTTP SFV string must consist of a valid date. It returns the
538+
// parsed date and an ok boolean value, indicating success or not.
539+
//
540+
// https://www.rfc-editor.org/rfc/rfc9651.html#name-parsing-a-date.
541+
func ParseDate(s string) (parsed time.Time, ok bool) {
542+
if _, rest, ok := consumeDate(s); !ok || rest != "" {
543+
return time.Time{}, false
544+
}
545+
if n, ok := ParseInteger(s[1:]); !ok {
546+
return time.Time{}, false
547+
} else {
548+
return time.Unix(n, 0), true
549+
}
550+
}
551+
537552
// https://www.rfc-editor.org/rfc/rfc9651.html#name-parsing-a-display-string.
538553
func consumeDisplayString(s string) (consumed, rest string, ok bool) {
539554
// To prevent excessive allocation, especially when input is large, we
@@ -593,6 +608,36 @@ func consumeDisplayString(s string) (consumed, rest string, ok bool) {
593608
return "", s, false
594609
}
595610

611+
// ParseDisplayString parses a display string from a given HTTP Structured
612+
// Field Values.
613+
//
614+
// The entire HTTP SFV string must consist of a valid display string. It
615+
// returns the parsed display string and an ok boolean value, indicating
616+
// success or not.
617+
//
618+
// https://www.rfc-editor.org/rfc/rfc9651.html#name-parsing-a-display-string.
619+
func ParseDisplayString(s string) (parsed string, ok bool) {
620+
if _, rest, ok := consumeDisplayString(s); !ok || rest != "" {
621+
return "", false
622+
}
623+
// consumeDisplayString() already validates that we have a valid display
624+
// string. Therefore, we can just construct the display string, without
625+
// validating it again.
626+
s = s[2 : len(s)-1]
627+
var b strings.Builder
628+
for i := 0; i < len(s); {
629+
if s[i] == '%' {
630+
decoded, _ := decOctetHex(s[i+1], s[i+2])
631+
b.WriteByte(decoded)
632+
i += 3
633+
continue
634+
}
635+
b.WriteByte(s[i])
636+
i++
637+
}
638+
return b.String(), true
639+
}
640+
596641
// https://www.rfc-editor.org/rfc/rfc9651.html#parse-bare-item.
597642
func consumeBareItem(s string) (consumed, rest string, ok bool) {
598643
if len(s) == 0 {

internal/httpsfv/httpsfv_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strconv"
99
"strings"
1010
"testing"
11+
"time"
1112
)
1213

1314
func TestParseList(t *testing.T) {
@@ -1166,6 +1167,64 @@ func TestConsumeDate(t *testing.T) {
11661167
}
11671168
}
11681169

1170+
func TestParseDate(t *testing.T) {
1171+
tests := []struct {
1172+
name string
1173+
in string
1174+
want time.Time
1175+
wantOk bool
1176+
}{
1177+
{
1178+
name: "valid zero date",
1179+
in: "@0",
1180+
want: time.Unix(0, 0),
1181+
wantOk: true,
1182+
},
1183+
{
1184+
name: "valid positive date",
1185+
in: "@1659578233",
1186+
want: time.Date(2022, 8, 4, 1, 57, 13, 0, time.UTC).Local(),
1187+
wantOk: true,
1188+
},
1189+
{
1190+
name: "valid negative date",
1191+
in: "@-1659578233",
1192+
want: time.Date(1917, 5, 30, 22, 2, 47, 0, time.UTC).Local(),
1193+
wantOk: true,
1194+
},
1195+
{
1196+
name: "valid max date required",
1197+
in: "@253402214400",
1198+
want: time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC).Local(),
1199+
wantOk: true,
1200+
},
1201+
{
1202+
name: "valid min date required",
1203+
in: "@-62135596800",
1204+
want: time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC).Local(),
1205+
wantOk: true,
1206+
},
1207+
{
1208+
name: "invalid date with fraction",
1209+
in: "@0.123",
1210+
},
1211+
{
1212+
name: "valid date with more content after",
1213+
in: "@0, @0",
1214+
},
1215+
}
1216+
1217+
for _, tc := range tests {
1218+
got, ok := ParseDate(tc.in)
1219+
if ok != tc.wantOk {
1220+
t.Fatalf("test %q: want ok to be %v, got: %v", tc.name, tc.wantOk, ok)
1221+
}
1222+
if tc.want != got {
1223+
t.Fatalf("test %q: mismatch.\n got: %#v\nwant: %#v\n", tc.name, got, tc.want)
1224+
}
1225+
}
1226+
}
1227+
11691228
func TestConsumeDisplayString(t *testing.T) {
11701229
tests := []struct {
11711230
name string
@@ -1274,3 +1333,107 @@ func TestConsumeDisplayString(t *testing.T) {
12741333
}
12751334
}
12761335
}
1336+
1337+
func TestParseDisplayString(t *testing.T) {
1338+
tests := []struct {
1339+
name string
1340+
in string
1341+
want string
1342+
wantOk bool
1343+
}{
1344+
{
1345+
name: "valid ascii string",
1346+
in: "%\" !%22#$%25&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\"",
1347+
want: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
1348+
wantOk: true,
1349+
},
1350+
{
1351+
name: "valid lowercase non-ascii string",
1352+
in: `%"f%c3%bc%c3%bc"`,
1353+
want: "füü",
1354+
wantOk: true,
1355+
},
1356+
{
1357+
name: "invalid uppercase non-ascii string",
1358+
in: `%"f%C3%BC%C3%BC"`,
1359+
},
1360+
{
1361+
name: "invalid unqouted string",
1362+
in: "%foo",
1363+
},
1364+
{
1365+
name: "invalid string missing initial quote",
1366+
in: `%foo"`,
1367+
},
1368+
{
1369+
name: "invalid string missing closing quote",
1370+
in: `%"foo`,
1371+
},
1372+
{
1373+
name: "invalid tab in string",
1374+
in: "%\"\t\"",
1375+
},
1376+
{
1377+
name: "invalid newline in string",
1378+
in: "%\"\n\"",
1379+
},
1380+
{
1381+
name: "invalid single quoted string",
1382+
in: `%'foo'`,
1383+
},
1384+
{
1385+
name: "invalid string bad escaping",
1386+
in: `%\"foo %a"`,
1387+
},
1388+
{
1389+
name: "valid string with escaped quotes",
1390+
in: "%\"foo %22bar%22 \\ baz\"",
1391+
want: "foo \"bar\" \\ baz",
1392+
wantOk: true,
1393+
},
1394+
{
1395+
name: "invalid sequence id utf-8 string",
1396+
in: `%"%a0%a1"`,
1397+
},
1398+
{
1399+
name: "invalid 2 bytes sequence utf-8 string",
1400+
in: `%"%c3%28"`,
1401+
},
1402+
{
1403+
name: "invalid 3 bytes sequence utf-8 string",
1404+
in: `%"%e2%28%a1"`,
1405+
},
1406+
{
1407+
name: "invalid 4 bytes sequence utf-8 string",
1408+
in: `%"%f0%28%8c%28"`,
1409+
},
1410+
{
1411+
name: "invalid hex utf-8 string",
1412+
in: `%"%g0%1w"`,
1413+
},
1414+
{
1415+
name: "valid byte order mark in display string",
1416+
in: `%"BOM: %ef%bb%bf"`,
1417+
want: "BOM: \uFEFF",
1418+
wantOk: true,
1419+
},
1420+
{
1421+
name: "valid string with content after",
1422+
in: `%"foo\nbar", foo;bar`,
1423+
},
1424+
{
1425+
name: "invalid unfinished 4 bytes rune",
1426+
in: `%"%f0%9f%98"`,
1427+
},
1428+
}
1429+
1430+
for _, tc := range tests {
1431+
got, ok := ParseDisplayString(tc.in)
1432+
if ok != tc.wantOk {
1433+
t.Fatalf("test %q: want ok to be %v, got: %v", tc.name, tc.wantOk, ok)
1434+
}
1435+
if tc.want != got {
1436+
t.Fatalf("test %q: mismatch.\n got: %#v\nwant: %#v\n", tc.name, got, tc.want)
1437+
}
1438+
}
1439+
}

0 commit comments

Comments
 (0)