Skip to content
Open
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
271 changes: 271 additions & 0 deletions internal/net/preconditions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package net

import (
"net/http"
"net/textproto"
"strings"
"time"
)

// scanETag determines if a syntactically valid ETag is present at s. If so,
// the ETag and remaining text after consuming ETag is returned. Otherwise,
// it returns "", "".
func scanETag(s string) (etag string, remain string) {
s = textproto.TrimString(s)
start := 0
if strings.HasPrefix(s, "W/") {
start = 2
}
if len(s[start:]) < 2 || s[start] != '"' {
return "", ""
}
// ETag is either W/"text" or "text".
// See RFC 7232 2.3.
for i := start + 1; i < len(s); i++ {
c := s[i]
switch {
// Character values allowed in ETags.
case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
case c == '"':
return s[:i+1], s[i+1:]
default:
return "", ""
}
}
return "", ""
}

// etagStrongMatch reports whether a and b match using strong ETag comparison.
// Assumes a and b are valid ETags.
func etagStrongMatch(a, b string) bool {
return a == b && a != "" && a[0] == '"'
}

// etagWeakMatch reports whether a and b match using weak ETag comparison.
// Assumes a and b are valid ETags.
func etagWeakMatch(a, b string) bool {
return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/")
}

// condResult is the result of an HTTP request precondition check.
// See https://tools.ietf.org/html/rfc7232 section 3.
type condResult int

const (
condNone condResult = iota
condTrue
condFalse
)

func checkIfMatch(w http.ResponseWriter, r *http.Request, exists bool) condResult {
values := r.Header.Values("If-Match")
if len(values) == 0 {
return condNone
}
im := strings.Join(values, ",")
r.Header.Del("If-Match")
if !exists {
return condFalse
}
for {
im = textproto.TrimString(im)
if len(im) == 0 {
break
}
if im[0] == ',' {
im = im[1:]
continue
}
if im[0] == '*' {
return condTrue
}
etag, remain := scanETag(im)
if etag == "" {
break
}
if etagStrongMatch(etag, w.Header().Get("Etag")) {
return condTrue
}
im = remain
}

return condFalse
}

func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult {
ius := r.Header.Get("If-Unmodified-Since")
if ius == "" {
return condNone
}
r.Header.Del("If-Unmodified-Since")
if isZeroTime(modtime) {
return condNone
}
t, err := http.ParseTime(ius)
if err != nil {
return condNone
}

// The Last-Modified header truncates sub-second precision so
// the modtime needs to be truncated too.
modtime = modtime.Truncate(time.Second)
if ret := modtime.Compare(t); ret <= 0 {
return condTrue
}
return condFalse
}

func checkIfNoneMatch(w http.ResponseWriter, r *http.Request, exists bool) condResult {
values := r.Header.Values("If-None-Match")
if len(values) == 0 {
return condNone
}
inm := strings.Join(values, ",")
r.Header.Del("If-None-Match")
if !exists {
return condTrue
}
buf := inm
for {
buf = textproto.TrimString(buf)
if len(buf) == 0 {
break
}
if buf[0] == ',' {
buf = buf[1:]
continue
}
if buf[0] == '*' {
return condFalse
}
etag, remain := scanETag(buf)
if etag == "" {
break
}
if etagWeakMatch(etag, w.Header().Get("Etag")) {
return condFalse
}
buf = remain
}
return condTrue
}

func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
if r.Method != "GET" && r.Method != "HEAD" {
return condNone
}
ims := r.Header.Get("If-Modified-Since")
if ims == "" {
return condNone
}
r.Header.Del("If-Modified-Since")
if isZeroTime(modtime) {
return condNone
}
t, err := http.ParseTime(ims)
if err != nil {
return condNone
}
// The Last-Modified header truncates sub-second precision so
// the modtime needs to be truncated too.
modtime = modtime.Truncate(time.Second)
if ret := modtime.Compare(t); ret <= 0 {
return condFalse
}
return condTrue
}

func checkIfRange(w http.ResponseWriter, r *http.Request, modtime time.Time) condResult {
if r.Method != "GET" && r.Method != "HEAD" {
return condNone
}
ir := r.Header.Get("If-Range")
if ir == "" {
return condNone
}
r.Header.Del("If-Range")
etag, _ := scanETag(ir)
if etag != "" {
if etagStrongMatch(etag, w.Header().Get("Etag")) {
return condTrue
}
return condFalse
}
// The If-Range value is typically the ETag value, but it may also be
// the modtime date. See golang.org/issue/8367.
if modtime.IsZero() {
return condFalse
}
t, err := http.ParseTime(ir)
if err != nil {
return condFalse
}
if t.Unix() == modtime.Unix() {
return condTrue
}
return condFalse
}

var unixEpochTime = time.Unix(0, 0)

// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0).
func isZeroTime(t time.Time) bool {
return t.IsZero() || t.Equal(unixEpochTime)
}

func setLastModified(w http.ResponseWriter, modtime time.Time) {
if !isZeroTime(modtime) {
w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
}
}

func writeNotModified(w http.ResponseWriter) {
// RFC 7232 section 4.1:
// a sender SHOULD NOT generate representation metadata other than the
// above listed fields unless said metadata exists for the purpose of
// guiding cache updates (e.g., Last-Modified might be useful if the
// response does not have an ETag field).
h := w.Header()
delete(h, "Content-Type")
delete(h, "Content-Length")
delete(h, "Content-Encoding")
if h.Get("Etag") != "" {
delete(h, "Last-Modified")
}
w.WriteHeader(http.StatusNotModified)
}

// CheckPreconditions evaluates request preconditions and reports whether a precondition
// resulted in sending StatusNotModified or StatusPreconditionFailed. The caller must
// set the current ETag response header and indicate whether the representation exists.
func CheckPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time, exists bool) (done bool, rangeHeader string) {
// Evaluate preconditions in the order specified by RFC 9110 section 13.2.2.
ch := checkIfMatch(w, r, exists)
if ch == condNone {
ch = checkIfUnmodifiedSince(r, modtime)
}
if ch == condFalse {
w.WriteHeader(http.StatusPreconditionFailed)
return true, ""
}
switch checkIfNoneMatch(w, r, exists) {
case condFalse:
if r.Method == "GET" || r.Method == "HEAD" {
writeNotModified(w)
return true, ""
}
w.WriteHeader(http.StatusPreconditionFailed)
return true, ""
case condNone:
if checkIfModifiedSince(r, modtime) == condFalse {
writeNotModified(w)
return true, ""
}
}

rangeHeader = r.Header.Get("Range")
if rangeHeader != "" && checkIfRange(w, r, modtime) == condFalse {
rangeHeader = ""
}
return false, rangeHeader
}
114 changes: 114 additions & 0 deletions internal/net/preconditions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package net

import (
"net/http"
"net/http/httptest"
"testing"
"time"
)

func TestCheckPreconditions(t *testing.T) {
modified := time.Date(2026, time.January, 2, 3, 4, 5, 123456789, time.UTC)
date := modified.Format(http.TimeFormat)
older := modified.Add(-time.Second).Format(http.TimeFormat)
for _, tt := range []struct {
name string
method string
exists bool
etag string
header http.Header
status int
}{
{"unconditional create", "PUT", false, "", nil, 0},
{"unconditional overwrite", "PUT", true, `"current"`, nil, 0},
{"create only missing", "PUT", false, "", http.Header{"If-None-Match": {"*"}}, 0},
{"create only existing", "PUT", true, `"current"`, http.Header{"If-None-Match": {"*"}}, 412},
{"create only existing without etag", "PUT", true, "", http.Header{"If-None-Match": {"*"}}, 412},
{"match missing", "PUT", false, "", http.Header{"If-Match": {"*"}}, 412},
{"match existing", "PUT", true, `"current"`, http.Header{"If-Match": {"*"}}, 0},
{"match existing without etag", "PUT", true, "", http.Header{"If-Match": {"*"}}, 0},
{"match current", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}}, 0},
{"match stale", "PUT", true, `"current"`, http.Header{"If-Match": {`"stale"`}}, 412},
{"match missing tag", "PUT", false, "", http.Header{"If-Match": {`"current"`}}, 412},
{"match weak request", "PUT", true, `"current"`, http.Header{"If-Match": {`W/"current"`}}, 412},
{"match weak representation", "PUT", true, `W/"current"`, http.Header{"If-Match": {`"current"`}}, 412},
{"match list", "PUT", true, `"current"`, http.Header{"If-Match": {`"stale", "current"`}}, 0},
{"match multiple lines", "PUT", true, `"current"`, http.Header{"If-Match": {`"stale"`, `"current"`}}, 0},
{"match empty list members", "PUT", true, `"current"`, http.Header{"If-Match": {`, , "current",`}}, 0},
{"match quoted comma", "PUT", true, `"one,two"`, http.Header{"If-Match": {`"stale", "one,two"`}}, 0},
{"malformed match", "PUT", true, `"current"`, http.Header{"If-Match": {"current"}}, 412},
{"empty match", "PUT", true, `"current"`, http.Header{"If-Match": {""}}, 412},
{"none match current", "PUT", true, `"current"`, http.Header{"If-None-Match": {`"current"`}}, 412},
{"none match stale", "PUT", true, `"current"`, http.Header{"If-None-Match": {`"stale"`}}, 0},
{"none match missing tag", "PUT", false, "", http.Header{"If-None-Match": {`"current"`}}, 0},
{"none match weak request", "PUT", true, `"current"`, http.Header{"If-None-Match": {`W/"current"`}}, 412},
{"none match weak representation", "PUT", true, `W/"current"`, http.Header{"If-None-Match": {`"current"`}}, 412},
{"none match multiple lines", "PUT", true, `"current"`, http.Header{"If-None-Match": {`"stale"`, `"current"`}}, 412},
{"none match quoted comma", "PUT", true, `"one,two"`, http.Header{"If-None-Match": {`"stale", "one,two"`}}, 412},
{"both conditions must pass", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}, "If-None-Match": {`"current"`}}, 412},
{"both conditions pass", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}, "If-None-Match": {`"stale"`}}, 0},
{"both wildcards missing", "PUT", false, "", http.Header{"If-Match": {"*"}, "If-None-Match": {"*"}}, 412},
{"unmodified since older", "PUT", true, `"current"`, http.Header{"If-Unmodified-Since": {older}}, 412},
{"unmodified since same second", "PUT", true, `"current"`, http.Header{"If-Unmodified-Since": {date}}, 0},
{"unmodified since missing", "PUT", false, "", http.Header{"If-Unmodified-Since": {older}}, 0},
{"invalid unmodified since", "PUT", true, `"current"`, http.Header{"If-Unmodified-Since": {"invalid"}}, 0},
{"match overrides unmodified since", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}, "If-Unmodified-Since": {older}}, 0},
{"ignore modified since on put", "PUT", true, `"current"`, http.Header{"If-Modified-Since": {date}}, 0},
{"get not modified", "GET", true, `"current"`, http.Header{"If-None-Match": {`W/"current"`}}, 304},
{"head not modified", "HEAD", true, `"current"`, http.Header{"If-None-Match": {"*"}}, 304},
{"get stale match", "GET", true, `"current"`, http.Header{"If-Match": {`"stale"`}}, 412},
{"get modified since", "GET", true, `"current"`, http.Header{"If-Modified-Since": {date}}, 304},
{"none match overrides modified since", "GET", true, `"current"`, http.Header{"If-None-Match": {`"stale"`}, "If-Modified-Since": {date}}, 0},
{"empty none match overrides modified since", "GET", true, `"current"`, http.Header{"If-None-Match": {""}, "If-Modified-Since": {date}}, 0},
} {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(tt.method, "/file.txt", nil)
for name, values := range tt.header {
for _, value := range values {
r.Header.Add(name, value)
}
}
w := httptest.NewRecorder()
if tt.etag != "" {
w.Header().Set("Etag", tt.etag)
}
var modTime time.Time
if tt.exists {
modTime = modified
}
done, _ := CheckPreconditions(w, r, modTime, tt.exists)
if done != (tt.status != 0) {
t.Fatalf("done = %v, want status %d", done, tt.status)
}
if done && w.Code != tt.status {
t.Errorf("status = %d, want %d", w.Code, tt.status)
}
if w.Body.Len() != 0 {
t.Errorf("unexpected response body: %q", w.Body.String())
}
})
}
}

func TestCheckPreconditionsIfRange(t *testing.T) {
for _, tt := range []struct {
etag string
want string
}{
{`"current"`, "bytes=0-3"},
{`"stale"`, ""},
{`W/"current"`, ""},
} {
t.Run(tt.etag, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/file.txt", nil)
r.Header.Set("Range", "bytes=0-3")
r.Header.Set("If-Range", tt.etag)
w := httptest.NewRecorder()
w.Header().Set("Etag", `"current"`)
done, got := CheckPreconditions(w, r, time.Time{}, true)
if done || got != tt.want {
t.Fatalf("CheckPreconditions() = (%v, %q), want (false, %q)", done, got, tt.want)
}
})
}
}
2 changes: 1 addition & 1 deletion internal/net/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import (
func ServeHTTP(w http.ResponseWriter, r *http.Request, name string, modTime time.Time, size int64, RangeReadCloser model.RangeReadCloserIF) error {
defer RangeReadCloser.Close()
setLastModified(w, modTime)
done, rangeReq := checkPreconditions(w, r, modTime)
done, rangeReq := CheckPreconditions(w, r, modTime, true)
if done {
return nil
}
Expand Down
Loading
Loading