From c919b7f8f730a7de412ddb807727862aa42e9b6b Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Mon, 22 Jun 2026 12:45:55 +1000 Subject: [PATCH] fix(apiv1): support comma-separated ETag lists in conditionals Parse If-Match / If-None-Match header values as RFC 7232 ETag lists so list-valued preconditions match when any listed ETag matches. Single-ETag and wildcard behaviour is unchanged. Closes #333 --- internal/strategy/apiv1.go | 19 +++++++++++++++++-- internal/strategy/apiv1_test.go | 4 ++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/strategy/apiv1.go b/internal/strategy/apiv1.go index 12aa41e5..05c7e19e 100644 --- a/internal/strategy/apiv1.go +++ b/internal/strategy/apiv1.go @@ -8,6 +8,7 @@ import ( "maps" "net/http" "os" + "strings" "time" "github.com/alecthomas/errors" @@ -223,14 +224,28 @@ func (d *APIV1) httpError(w http.ResponseWriter, code int, err error, message st // preconditions pass, otherwise the HTTP status code to send (304 or 412). func checkConditionals(r *http.Request, etag string) int { if ifMatch := r.Header.Get("If-Match"); ifMatch != "" { - if etag == "" || (ifMatch != "*" && ifMatch != etag) { + if etag == "" || !etagListMatches(ifMatch, etag) { return http.StatusPreconditionFailed } } if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" { - if (ifNoneMatch == "*" && etag != "") || ifNoneMatch == etag { + if etag != "" && etagListMatches(ifNoneMatch, etag) { return http.StatusNotModified } } return 0 } + +// etagListMatches reports whether etag matches an RFC 7232 If-Match / +// If-None-Match header value, which may be a comma-separated list of ETags or +// the "*" wildcard. Stored ETags are always strong, so weak comparison is not +// required. +func etagListMatches(headerValue, etag string) bool { + for candidate := range strings.SplitSeq(headerValue, ",") { + candidate = strings.TrimSpace(candidate) + if candidate == "*" || candidate == etag { + return true + } + } + return false +} diff --git a/internal/strategy/apiv1_test.go b/internal/strategy/apiv1_test.go index 2e4caa58..6353569b 100644 --- a/internal/strategy/apiv1_test.go +++ b/internal/strategy/apiv1_test.go @@ -90,6 +90,8 @@ func TestConditionalGetIfNoneMatch(t *testing.T) { {name: "Matching", ifNoneMatch: etag, expectedStatus: http.StatusNotModified}, {name: "NonMatching", ifNoneMatch: `"wrong"`, expectedStatus: http.StatusOK}, {name: "Wildcard", ifNoneMatch: "*", expectedStatus: http.StatusNotModified}, + {name: "ListMatching", ifNoneMatch: `"a", ` + etag + `, "b"`, expectedStatus: http.StatusNotModified}, + {name: "ListNonMatching", ifNoneMatch: `"a", "b"`, expectedStatus: http.StatusOK}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -134,6 +136,8 @@ func TestConditionalGetIfMatch(t *testing.T) { {name: "Matching", ifMatch: etag, expectedStatus: http.StatusOK}, {name: "NonMatching", ifMatch: `"wrong"`, expectedStatus: http.StatusPreconditionFailed}, {name: "Wildcard", ifMatch: "*", expectedStatus: http.StatusOK}, + {name: "ListMatching", ifMatch: `"a", ` + etag + `, "b"`, expectedStatus: http.StatusOK}, + {name: "ListNonMatching", ifMatch: `"a", "b"`, expectedStatus: http.StatusPreconditionFailed}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {