AcceptEncoding and
AcceptLanguage, or the Request wrappers around them.
Auth reads and writes the RFC 7235 authentication headers,
-ByteRange the RFC 9110 range headers.
+ByteRange the RFC 9110 range headers, and
+Precondition evaluates the RFC 9110 conditional request
+headers — If-Match, If-None-Match, If-Modified-Since and
+If-Unmodified-Since — into the 304 or 412 they demand.
Parsing
(match (Request.parse "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
(Result.Success req) (println* (Request.verb &req))
diff --git a/gendocs.carp b/gendocs.carp
index 6634dd2..9950803 100644
--- a/gendocs.carp
+++ b/gendocs.carp
@@ -22,7 +22,10 @@ Content is negotiated with [`Accept`](./Accept.html),
[`AcceptEncoding`](./AcceptEncoding.html) and
[`AcceptLanguage`](./AcceptLanguage.html), or the `Request` wrappers around them.
[`Auth`](./Auth.html) reads and writes the RFC 7235 authentication headers,
-[`ByteRange`](./ByteRange.html) the RFC 9110 range headers.
+[`ByteRange`](./ByteRange.html) the RFC 9110 range headers, and
+[`Precondition`](./Precondition.html) evaluates the RFC 9110 conditional request
+headers — `If-Match`, `If-None-Match`, `If-Modified-Since` and
+`If-Unmodified-Since` — into the `304` or `412` they demand.
### Parsing
```
@@ -59,5 +62,8 @@ Status.not-found ; => 404
ByteRange
ByteRangeSpec
ContentRange
+ ETag
+ ETagList
+ Precondition
CacheControl)
(quit)
diff --git a/http.carp b/http.carp
index 91234c3..07c7da3 100644
--- a/http.carp
+++ b/http.carp
@@ -765,6 +765,7 @@ zero-size chunk (RFC 7230 §4.1). The result decodes back to `body` via
(def not-acceptable 406)
(def conflict 409)
(def gone 410)
+ (def precondition-failed 412)
(def unsupported-media-type 415)
(def range-not-satisfiable 416)
(def unprocessable-entity 422)
@@ -817,6 +818,8 @@ zero-size chunk (RFC 7230 §4.1). The result decodes back to `body` via
@"Conflict"
410
@"Gone"
+ 412
+ @"Precondition Failed"
415
@"Unsupported Media Type"
416
@@ -2158,6 +2161,286 @@ its `first-pos` fails, and so does a known `complete-length` at or below the
(Result.Error (fmt "malformed Content-Range: '%s'" s))
(Maybe.Just cr) (Result.Success cr))))))))
+(doc ETag
+ "is an entity-tag (RFC 9110 §8.8.3): the opaque `value` a server sends
+in an `ETag` header to name one version of a representation, and `weak`, whether
+it carried the `W/` prefix that marks it as a weak validator. `value` is held
+without its surrounding quotes.
+
+```
+(ETag.str &(ETag.init @\"xyzzy\" false)) ; => \"xyzzy\"
+(ETag.str &(ETag.init @\"xyzzy\" true)) ; => W/\"xyzzy\"
+```")
+(deftype ETag [value String
+ weak Bool])
+
+(defmodule ETag
+ (hidden etagc?)
+ (private etagc?)
+ ; §8.8.3's `etagc`: any byte but DQUOTE, DEL, and the controls and space below
+ (defn etagc? [b]
+ (let [i (Byte.to-int @b)] (and (> i 32) (and (/= i 34) (/= i 127)))))
+
+ (doc parse "parses one `entity-tag` (RFC 9110 §8.8.3) as
+`(Result ETag String)`.
+
+Both forms are read: the strong `\"xyzzy\"` and the weak `W/\"xyzzy\"`. The `W/`
+prefix is case-sensitive, as §8.8.3's grammar has it, so a lowercase `w/` is not
+a weak tag but a malformed one. Whitespace around the tag is ignored. An
+unquoted tag, an unterminated one, and one whose opaque-tag holds a byte
+`etagc` has no room for — a control character, a space or a quote — all fail.")
+ (defn parse [s]
+ (let [t (String.trim s)
+ weak (String.byte-starts-with? &t "W/")
+ q (if weak (String.byte-slice &t 2 (String.length &t)) @&t)
+ n (String.length &q)]
+ (if (or (< n 2)
+ (or (not (String.byte-starts-with? &q "\""))
+ (not (String.byte-ends-with? &q "\""))))
+ (Result.Error (fmt "malformed entity-tag: '%s'" s))
+ (let [v (String.byte-slice &q 1 (Int.dec n))]
+ (if (Array.all? &etagc? &(String.to-bytes &v))
+ (Result.Success (init v weak))
+ (Result.Error (fmt "malformed entity-tag: '%s'" s)))))))
+
+ (doc str "serializes `e` back into an `entity-tag`, e.g. `W/\"xyzzy\"`.
+Anything [parse](#parse) produced reads back into an equal `ETag`; a hand-built
+value holding a quote or a control character writes a tag `parse` rejects.")
+ (defn str [e]
+ (if @(weak e) (fmt "W/\"%s\"" (value e)) (fmt "\"%s\"" (value e))))
+ (implements str ETag.str)
+
+ (doc strong-match? "compares `a` and `b` with the strong comparison function
+of RFC 9110 §8.8.3.2: they match only when neither is weak and their opaque
+values are byte-identical. It is what `If-Match` uses (§13.1.1). A weak tag
+never matches strongly, not even itself.")
+ (defn strong-match? [a b]
+ (and (not @(weak a)) (and (not @(weak b)) (= (value a) (value b)))))
+
+ (doc weak-match? "compares `a` and `b` with the weak comparison function of
+RFC 9110 §8.8.3.2: they match whenever their opaque values are byte-identical,
+however either one is marked. It is what `If-None-Match` uses (§13.1.2).")
+ (defn weak-match? [a b] (= (value a) (value b))))
+
+(doc ETagList "is the field value of an `If-Match` or `If-None-Match` header
+(RFC 9110 §13.1.1, §13.1.2): `Any` for the `*` that names whatever
+representation the resource currently has, and `Tags` for a list of
+entity-tags.")
+(deftype ETagList
+ (Any [])
+ (Tags [(Array ETag)]))
+
+(defmodule ETagList
+ (hidden split-list)
+ (private split-list)
+ ; §5.6.1.2's list splitting; a comma inside an opaque-tag is not a delimiter
+ (defn split-list [s]
+ (let-do [bs (String.to-bytes s)
+ n (Array.length &bs)
+ out (the (Array String) [])
+ start 0
+ quoted false]
+ (for [i 0 n]
+ (let [b (Byte.to-int @(Array.unsafe-nth &bs i))]
+ (cond
+ (= b 34) (set! quoted (not quoted))
+ (and (= b 44) (not quoted))
+ (do
+ (Array.push-back! &out (String.byte-slice s start i))
+ (set! start (Int.inc i)))
+ ())))
+ (Array.push-back! &out (String.byte-slice s start n))
+ out))
+
+ (doc parse "parses an `If-Match` or `If-None-Match` field value as
+`(Result ETagList String)`.
+
+A field value of `*` yields `Any`; any other is read as a list of entity-tags,
+in header order. Whitespace around the commas is ignored, as are empty list
+elements (RFC 9110 §5.6.1.2); a comma inside an opaque-tag is not a delimiter.
+A member that is not a well-formed `entity-tag` fails, and so does an empty
+list, which neither header's grammar has room for.")
+ (defn parse [s]
+ (let [t (String.trim s)]
+ (if (= &t "*")
+ (Result.Success (ETagList.Any))
+ (let-do [segs (split-list &t)
+ n (Array.length &segs)
+ tags (the (Array ETag) [])
+ bad @""
+ i 0]
+ (while-do (and (< i n) (String.empty? &bad))
+ (let [seg (String.trim (Array.unsafe-nth &segs i))]
+ (unless (String.empty? &seg)
+ (match (ETag.parse &seg)
+ (Result.Error e) (set! bad e)
+ (Result.Success e) (Array.push-back! &tags e))))
+ (set! i (Int.inc i)))
+ (cond
+ (not (String.empty? &bad)) (Result.Error bad)
+ (Array.empty? &tags)
+ (Result.Error (fmt "empty entity-tag list: '%s'" s))
+ (Result.Success (ETagList.Tags tags)))))))
+
+ (hidden matches?)
+ (private matches?)
+ (defn matches? [l etag strong]
+ (match-ref l
+ (ETagList.Any) true
+ (ETagList.Tags ts)
+ (match-ref etag
+ (Maybe.Nothing) false
+ (Maybe.Just cur)
+ (let-do [found false]
+ (for [i 0 (Array.length ts)]
+ (let [t (Array.unsafe-nth ts i)]
+ (when (if strong
+ (ETag.strong-match? t cur)
+ (ETag.weak-match? t cur))
+ (set! found true))))
+ found))))
+
+ (doc strong-match? "whether `l` matches `etag`, the entity-tag of the
+representation the server selected — `Nothing` when it has none — under the
+strong comparison function `If-Match` calls for (RFC 9110 §13.1.1).
+
+`Any` matches whenever the resource has a current representation, which
+[`Precondition.evaluate`](Precondition.html#evaluate) takes to be so whenever it
+is called.")
+ (defn strong-match? [l etag] (matches? l etag true))
+
+ (doc weak-match? "whether `l` matches `etag` under the weak comparison
+function `If-None-Match` calls for (RFC 9110 §13.1.2). See
+[strong-match?](#strong-match?) for how `Any` is read.")
+ (defn weak-match? [l etag] (matches? l etag false)))
+
+(doc Precondition
+ "evaluates the conditional request headers of RFC 9110 §13 —
+`If-Match`, `If-Unmodified-Since`, `If-None-Match` and `If-Modified-Since` —
+against the validators of the representation a server selected, and answers with
+the status they demand.
+
+```
+(match (Request.preconditions &req &etag &modified)
+ (Maybe.Just code) (Response.respond code {} @\"\")
+ (Maybe.Nothing) (Response.ok {} @\"the body\"))
+```
+
+`If-Range` is not evaluated: a range request whose condition fails is answered
+with the whole representation, which is
+[`ByteRange`](ByteRange.html)'s business rather than a status.")
+(defmodule Precondition
+ (hidden list-header)
+ (private list-header)
+ ; the comma-joined values of a list-based header, `Nothing` when it has none
+ (defn list-header [hdrs name]
+ (let [vs (header-values hdrs name)]
+ (if (Array.empty? &vs) (Maybe.Nothing) (Maybe.Just (String.join ", " &vs)))))
+
+ (hidden date-header)
+ (private date-header)
+ ; §13.1.3 and §13.1.4 both have a recipient ignore an invalid HTTP-date
+ (defn date-header [hdrs name]
+ (match (header-lookup hdrs name)
+ (Maybe.Nothing) (Maybe.Nothing)
+ (Maybe.Just v) (Result.to-maybe (HttpDate.parse &v))))
+
+ (hidden read?)
+ (private read?)
+ (defn read? [method] (or (= method "GET") (= method "HEAD")))
+
+ (hidden match-status)
+ (private match-status)
+ ; §13.2.2 step 1; a field value the grammar has no room for fails closed
+ (defn match-status [v etag]
+ (match (ETagList.parse v)
+ (Result.Error _) (Maybe.Just Status.precondition-failed)
+ (Result.Success l)
+ (if (ETagList.strong-match? &l etag)
+ (Maybe.Nothing)
+ (Maybe.Just Status.precondition-failed))))
+
+ (hidden unmodified-since-status)
+ (private unmodified-since-status)
+ ; §13.2.2 step 2
+ (defn unmodified-since-status [hdrs modified]
+ (match (date-header hdrs "If-Unmodified-Since")
+ (Maybe.Nothing) (Maybe.Nothing)
+ (Maybe.Just d)
+ (match-ref modified
+ (Maybe.Nothing) (Maybe.Nothing)
+ (Maybe.Just m)
+ (if (Datetime.after-instant? m &d)
+ (Maybe.Just Status.precondition-failed)
+ (Maybe.Nothing)))))
+
+ (hidden none-match-status)
+ (private none-match-status)
+ ; §13.2.2 step 3; an unreadable field value is ignored
+ (defn none-match-status [v etag method]
+ (match (ETagList.parse v)
+ (Result.Error _) (Maybe.Nothing)
+ (Result.Success l)
+ (if (ETagList.weak-match? &l etag)
+ (Maybe.Just
+ (if (read? method) Status.not-modified Status.precondition-failed))
+ (Maybe.Nothing))))
+
+ (hidden modified-since-status)
+ (private modified-since-status)
+ ; §13.2.2 step 4
+ (defn modified-since-status [hdrs modified]
+ (match (date-header hdrs "If-Modified-Since")
+ (Maybe.Nothing) (Maybe.Nothing)
+ (Maybe.Just d)
+ (match-ref modified
+ (Maybe.Nothing) (Maybe.Nothing)
+ (Maybe.Just m)
+ (if (Datetime.after-instant? m &d)
+ (Maybe.Nothing)
+ (Maybe.Just Status.not-modified)))))
+
+ (doc evaluate "evaluates the conditional request headers in `hdrs` against
+`etag` and `modified`, the entity-tag and last modification date of the
+representation the server selected — either may be `Nothing` — for a request of
+method `method`. Returns the status to send as `(Maybe Int)`: `Nothing` when
+every precondition held and the request should be answered normally, otherwise
+a `304` or a `412`.
+
+The four headers are evaluated in the precedence RFC 9110 §13.2.2 lays down.
+`If-Match` is evaluated first, and only when it is absent is
+`If-Unmodified-Since`; then `If-None-Match`, and only when *it* is absent, and
+the method is `GET` or `HEAD`, is `If-Modified-Since`. A failed `If-None-Match`
+is a `304` for a `GET` or a `HEAD` and a `412` for anything else; every other
+failure is a `412`.
+
+Both date headers are ignored when the representation has no modification date
+or the field value is not a valid HTTP-date, as §13.1.3 and §13.1.4 require. An
+`If-None-Match` that is not a valid entity-tag list is ignored too, but an
+`If-Match` that is not fails closed with a `412`: the header is there to prevent
+a lost update, so a value the server cannot read is not one it may act on.
+
+A `*` is read as matching whenever `evaluate` is called, so a resource with no
+current representation must not reach here: answer that with a `404`, or with a
+`412` when the request carries an `If-Match`. Its existence is the one thing a
+`*` cannot be told apart from a representation carrying no entity-tag, so the
+conditional creation an `If-None-Match` of `*` guards is the caller's too;
+[`ETagList.parse`](ETagList.html#parse),
+[`ETagList.strong-match?`](ETagList.html#strong-match?) and
+[`ETagList.weak-match?`](ETagList.html#weak-match?) are public for it.")
+ (defn evaluate [hdrs method etag modified]
+ (let [early (match (list-header hdrs "If-Match")
+ (Maybe.Just v) (match-status &v etag)
+ (Maybe.Nothing) (unmodified-since-status hdrs modified))]
+ (if (Maybe.just? &early)
+ early
+ (match (list-header hdrs "If-None-Match")
+ (Maybe.Just v) (none-match-status &v etag method)
+ (Maybe.Nothing)
+ (if (read? method)
+ (modified-since-status hdrs modified)
+ (Maybe.Nothing)))))))
+
(defmodule Request
(doc form-data "parses the request body as URL-encoded form data.
Returns `(Result (Map String String) String)`.")
@@ -2225,4 +2508,12 @@ representation rather than a `416`.")
(defn range [r]
(match (Request.header r "Range")
(Maybe.Nothing) (Maybe.Nothing)
- (Maybe.Just v) (Maybe.Just (ByteRange.parse &v)))))
+ (Maybe.Just v) (Maybe.Just (ByteRange.parse &v))))
+
+ (doc preconditions "the status the conditional request headers of this request
+demand for a representation whose entity-tag is `etag` and whose last
+modification date is `modified`, as `(Maybe Int)`. `Nothing` means every
+precondition held and the request should be answered normally. See
+[`Precondition.evaluate`](Precondition.html#evaluate).")
+ (defn preconditions [r etag modified]
+ (Precondition.evaluate (headers r) (verb r) etag modified)))
diff --git a/test/http.carp b/test/http.carp
index 9743229..0928521 100644
--- a/test/http.carp
+++ b/test/http.carp
@@ -310,6 +310,41 @@
(Maybe.Just (Result.Error _)) @"ERROR"
(Maybe.Just (Result.Success specs)) (ByteRange.str &specs))))
+; ---- conditional request test helpers ----
+(defn etag-verdict [s]
+ (match (ETag.parse s)
+ (Result.Success e) (ETag.str &e)
+ (Result.Error _) @"ERROR"))
+
+(defn tags-verdict [s]
+ (match (ETagList.parse s)
+ (Result.Error _) @"ERROR"
+ (Result.Success l)
+ (match l
+ (ETagList.Any) @"*"
+ (ETagList.Tags ts) (String.join ", " &(Array.copy-map &ETag.str &ts)))))
+
+(defn some-etag [v weak] (Maybe.Just (ETag.init @v weak)))
+
+(defn no-etag [] (the (Maybe ETag) (Maybe.Nothing)))
+
+(defn at [s] (Result.to-maybe (HttpDate.parse s)))
+
+(defn no-date [] (the (Maybe Datetime) (Maybe.Nothing)))
+
+(defn no-headers [] (the (Map String (Array String)) {}))
+
+(defn precondition [hdrs method etag modified]
+ (match (Precondition.evaluate &hdrs method etag modified)
+ (Maybe.Nothing) @"OK"
+ (Maybe.Just code) (Int.str code)))
+
+(defn req-precondition [hdrs method etag modified]
+ (let [r (Request.init @method @"HTTP/1.1" (URI.zero) [] hdrs @"")]
+ (match (Request.preconditions &r etag modified)
+ (Maybe.Nothing) @"OK"
+ (Maybe.Just code) (Int.str code))))
+
; ---- hostile-byte test helpers ----
; a run of UTF-8 continuation bytes: `n` bytes long, zero characters long
(defn continuation [n] (String.from-bytes &(Array.replicate n &128b)))
@@ -1612,6 +1647,289 @@
&(content-range-error "nonsense")
"a rejected Content-Range is reported against the wire header name")
+ (assert-equal test
+ "\"xyzzy\""
+ &(etag-verdict "\"xyzzy\"")
+ "a strong entity-tag round-trips")
+ (assert-equal test
+ "W/\"xyzzy\""
+ &(etag-verdict "W/\"xyzzy\"")
+ "a weak entity-tag round-trips")
+ (assert-equal test
+ "\"\""
+ &(etag-verdict "\"\"")
+ "an empty opaque-tag is a valid entity-tag")
+ (assert-equal test
+ "\"xyzzy\""
+ &(etag-verdict " \"xyzzy\" ")
+ "whitespace around an entity-tag is ignored")
+ (assert-equal test
+ "ERROR"
+ &(etag-verdict "xyzzy")
+ "an unquoted entity-tag is rejected")
+ (assert-equal test
+ "ERROR"
+ &(etag-verdict "\"xyzzy")
+ "an unterminated entity-tag is rejected")
+ (assert-equal test
+ "ERROR"
+ &(etag-verdict "w/\"xyzzy\"")
+ "the weak prefix is case-sensitive")
+ (assert-equal test
+ "ERROR"
+ &(etag-verdict "\"xy zzy\"")
+ "a space in an opaque-tag is rejected")
+ (assert-true test
+ (ETag.strong-match? &(ETag.init @"a" false) &(ETag.init @"a" false))
+ "two strong entity-tags with the same value match strongly")
+ (assert-false test
+ (ETag.strong-match? &(ETag.init @"a" true) &(ETag.init @"a" true))
+ "a weak entity-tag never matches strongly, not even itself")
+ (assert-false test
+ (ETag.strong-match? &(ETag.init @"a" false) &(ETag.init @"a" true))
+ "a strong entity-tag does not strongly match its weak twin")
+ (assert-true test
+ (ETag.weak-match? &(ETag.init @"a" false) &(ETag.init @"a" true))
+ "weak comparison ignores how either entity-tag is marked")
+ (assert-false test
+ (ETag.weak-match? &(ETag.init @"a" false) &(ETag.init @"b" false))
+ "entity-tags with different values never match")
+ (assert-equal test
+ "*"
+ &(tags-verdict "*")
+ "a field value of * is the any-representation list")
+ (assert-equal test
+ "\"a\", W/\"b\""
+ &(tags-verdict "\"a\", W/\"b\"")
+ "a multi-element entity-tag list round-trips")
+ (assert-equal test
+ "\"a\", \"b\""
+ &(tags-verdict "\"a\" , , \"b\"")
+ "empty entity-tag list elements are ignored")
+ (assert-equal test
+ "\"a,b\""
+ &(tags-verdict "\"a,b\"")
+ "a comma inside an opaque-tag is not a list delimiter")
+ (assert-equal test
+ "ERROR"
+ &(tags-verdict "")
+ "an empty entity-tag list is rejected")
+ (assert-equal test
+ "ERROR"
+ &(tags-verdict "\"a\", junk")
+ "an entity-tag list with a malformed member is rejected")
+ (assert-equal test
+ "OK"
+ &(precondition (no-headers)
+ "GET"
+ &(some-etag "a" false)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "a request with no conditional headers is answered normally")
+ (assert-equal test
+ "OK"
+ &(precondition {@"If-Match" [@"\"a\""]}
+ "PUT"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-Match that matches lets the request through")
+ (assert-equal test
+ "412"
+ &(precondition {@"If-Match" [@"\"b\""]}
+ "PUT"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-Match that does not match is a 412")
+ (assert-equal test
+ "OK"
+ &(precondition {@"If-Match" [@"*"]} "PUT" &(no-etag) &(no-date))
+ "an If-Match of * holds for a representation with no entity-tag")
+ (assert-equal test
+ "412"
+ &(precondition {@"If-Match" [@"\"a\""]}
+ "PUT"
+ &(some-etag "a" true)
+ &(no-date))
+ "If-Match compares strongly, so a weak entity-tag is a 412")
+ (assert-equal test
+ "412"
+ &(precondition {@"If-Match" [@"\"a\""]} "PUT" &(no-etag) &(no-date))
+ "an If-Match against a representation with no entity-tag is a 412")
+ (assert-equal test
+ "412"
+ &(precondition {@"If-Match" [@"junk"]}
+ "PUT"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-Match the server cannot read fails closed with a 412")
+ (assert-equal test
+ "412"
+ &(precondition
+ {@"If-Unmodified-Since" [@"Sat, 05 Nov 1994 08:49:37 GMT"]}
+ "PUT"
+ &(no-etag)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "an If-Unmodified-Since older than the representation is a 412")
+ (assert-equal test
+ "OK"
+ &(precondition
+ {@"If-Unmodified-Since" [@"Mon, 07 Nov 1994 08:49:37 GMT"]}
+ "PUT"
+ &(no-etag)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "an If-Unmodified-Since newer than the representation lets it through")
+ (assert-equal test
+ "OK"
+ &(precondition
+ {@"If-Unmodified-Since" [@"Sat, 05 Nov 1994 08:49:37 GMT"]}
+ "PUT"
+ &(no-etag)
+ &(no-date))
+ "If-Unmodified-Since is ignored without a modification date")
+ (assert-equal test
+ "OK"
+ &(precondition {@"If-Unmodified-Since" [@"not a date"]}
+ "PUT"
+ &(no-etag)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "If-Unmodified-Since is ignored when its value is not an HTTP-date")
+ (assert-equal test
+ "OK"
+ &(precondition
+ {@"If-Match" [@"\"a\""]
+ @"If-Unmodified-Since" [@"Sat, 05 Nov 1994 08:49:37 GMT"]}
+ "PUT"
+ &(some-etag "a" false)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "If-Unmodified-Since is evaluated only when If-Match is absent")
+ (assert-equal test
+ "304"
+ &(precondition {@"If-None-Match" [@"\"a\""]}
+ "GET"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-None-Match that matches is a 304 for a GET")
+ (assert-equal test
+ "304"
+ &(precondition {@"If-None-Match" [@"\"a\""]}
+ "HEAD"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-None-Match that matches is a 304 for a HEAD")
+ (assert-equal test
+ "412"
+ &(precondition {@"If-None-Match" [@"\"a\""]}
+ "PUT"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-None-Match that matches is a 412 for anything but a GET or HEAD")
+ (assert-equal test
+ "304"
+ &(precondition {@"If-None-Match" [@"\"a\""]}
+ "GET"
+ &(some-etag "a" true)
+ &(no-date))
+ "If-None-Match compares weakly, so a weak entity-tag still matches")
+ (assert-equal test
+ "OK"
+ &(precondition {@"If-None-Match" [@"\"b\""]}
+ "GET"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-None-Match that does not match lets the request through")
+ (assert-equal test
+ "304"
+ &(precondition {@"If-None-Match" [@"*"]}
+ "GET"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-None-Match of * matches any current representation")
+ (assert-equal test
+ "304"
+ &(precondition {@"If-None-Match" [@"\"x\", W/\"a\""]}
+ "GET"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-None-Match matches on any member of its list")
+ (assert-equal test
+ "304"
+ &(precondition {@"If-None-Match" [@"\"x\"" @"\"a\""]}
+ "GET"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-None-Match split across header lines is read as one list")
+ (assert-equal test
+ "OK"
+ &(precondition {@"If-None-Match" [@"junk"]}
+ "GET"
+ &(some-etag "a" false)
+ &(no-date))
+ "an If-None-Match the server cannot read is ignored")
+ (assert-equal test
+ "412"
+ &(precondition
+ {@"If-Match" [@"\"b\""]
+ @"If-None-Match" [@"\"a\""]}
+ "GET"
+ &(some-etag "a" false)
+ &(no-date))
+ "a failed If-Match wins over an If-None-Match that would be a 304")
+ (assert-equal test
+ "304"
+ &(precondition
+ {@"If-Modified-Since" [@"Sun, 06 Nov 1994 08:49:37 GMT"]}
+ "GET"
+ &(no-etag)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "an If-Modified-Since equal to the modification date is a 304")
+ (assert-equal test
+ "OK"
+ &(precondition
+ {@"If-Modified-Since" [@"Sat, 05 Nov 1994 08:49:37 GMT"]}
+ "GET"
+ &(no-etag)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "an If-Modified-Since older than the modification date sends the body")
+ (assert-equal test
+ "OK"
+ &(precondition
+ {@"If-Modified-Since" [@"Sun, 06 Nov 1994 08:49:37 GMT"]}
+ "POST"
+ &(no-etag)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "If-Modified-Since is ignored for a method that is not GET or HEAD")
+ (assert-equal test
+ "OK"
+ &(precondition
+ {@"If-Modified-Since" [@"Sun, 06 Nov 1994 08:49:37 GMT"]}
+ "GET"
+ &(no-etag)
+ &(no-date))
+ "If-Modified-Since is ignored without a modification date")
+ (assert-equal test
+ "OK"
+ &(precondition
+ {@"If-None-Match" [@"\"b\""]
+ @"If-Modified-Since" [@"Sun, 06 Nov 1994 08:49:37 GMT"]}
+ "GET"
+ &(some-etag "a" false)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "If-Modified-Since is evaluated only when If-None-Match is absent")
+ (assert-equal test
+ "304"
+ &(req-precondition {@"If-None-Match" [@"\"a\""]}
+ "GET"
+ &(some-etag "a" false)
+ &(no-date))
+ "Request.preconditions evaluates the request's own headers and method")
+ (assert-equal test
+ 412
+ Status.precondition-failed
+ "Status.precondition-failed is 412")
+ (assert-equal test
+ "Precondition Failed"
+ &(Status.reason 412)
+ "412 has a reason phrase")
+
(assert-equal test
&(dechunk-ok "4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n")
"Wikipedia"
@@ -2327,4 +2645,8 @@
(assert-equal test
"en-US"
&(pick-lang "en, de;q=0.5" &[@"en-US" (continuation 3)])
- "AcceptLanguage.negotiate tolerates an offer that is not valid UTF-8"))
+ "AcceptLanguage.negotiate tolerates an offer that is not valid UTF-8")
+ (assert-equal test
+ &(String.append "\"" &(String.append &(continuation 3) "\""))
+ &(etag-verdict &(String.append "\"" &(String.append &(continuation 3) "\"")))
+ "an entity-tag whose opaque-tag is not valid UTF-8 round-trips"))
From d04a8678b3b50419d6bbf5aa53f5f233988ba283 Mon Sep 17 00:00:00 2001
From: "carpentry-heartbeat[bot]"
Date: Wed, 19 Aug 2026 07:37:58 +0200
Subject: [PATCH 2/3] Regenerate ETag docs after the doc example fix
---
docs/ETag.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/ETag.html b/docs/ETag.html
index 22a5ac0..852a36f 100644
--- a/docs/ETag.html
+++ b/docs/ETag.html
@@ -154,7 +154,7 @@
in an ETag header to name one version of a representation, and weak, whether
it carried the W/ prefix that marks it as a weak validator. value is held
without its surrounding quotes.
-(ETag.str &(ETag.init @"xyzzy" false)) ; => "xyzzy", quoted
+(ETag.str &(ETag.init @"xyzzy" false)) ; => "xyzzy"
(ETag.str &(ETag.init @"xyzzy" true)) ; => W/"xyzzy"
From 185af3a298fd97721c5c1bd740708d31eb1d3bd6 Mon Sep 17 00:00:00 2001
From: "carpentry-heartbeat[bot]"
Date: Wed, 19 Aug 2026 13:25:26 +0200
Subject: [PATCH 3/3] Pin the method Request.preconditions reads, and two
ETag.parse guards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four mutants that survived the suite at d04a867, reproduced here and killed.
Behaviour is unchanged: the shipped code answers every one of these inputs
correctly, so these are coverage gaps rather than live bugs.
- Request.preconditions with `(verb r)` replaced by a hardcoded "GET" passed
442/442. Through the public API that turned a PUT or a POST carrying a
matching If-None-Match into a 304 where §13.2.2 step 3 asks for a 412, and
answered a POST carrying an If-Modified-Since with a 304 where step 4 does
not apply at all. Two assertions pin both halves of that split.
- ETag.parse's `(< n 2)` weakened to `(< n 1)` passed 442/442 and then let a
field value of a single quote reach `(String.byte-slice q 1 0)`, a
negative-length copy that corrupts the heap. The assertion pins the parse
verdict rather than the crash, so it fails however the allocator behaves.
- `etagc?` losing `(/= i 34)` passed 442/442 and accepted `"a"b"` as an
entity-tag; losing `(/= i 127)` passed 442/442 and accepted a DEL byte.
Precondition.evaluate's doc string said a malformed If-None-Match "is ignored",
which reads as treated-as-absent. §13.2.2 step 4 is gated on the header being
present, so it still suppresses If-Modified-Since; the doc now says so.
---
docs/Precondition.html | 8 +++++---
http.carp | 8 +++++---
test/http.carp | 28 ++++++++++++++++++++++++++++
3 files changed, 38 insertions(+), 6 deletions(-)
diff --git a/docs/Precondition.html b/docs/Precondition.html
index 0906979..32ab22c 100644
--- a/docs/Precondition.html
+++ b/docs/Precondition.html
@@ -193,9 +193,11 @@
failure is a 412.
Both date headers are ignored when the representation has no modification date
or the field value is not a valid HTTP-date, as §13.1.3 and §13.1.4 require. An
-If-None-Match that is not a valid entity-tag list is ignored too, but an
-If-Match that is not fails closed with a 412: the header is there to prevent
-a lost update, so a value the server cannot read is not one it may act on.
+If-None-Match that is not a valid entity-tag list is ignored too, but it is
+still present, and step 4 is gated on presence: it suppresses If-Modified-Since
+just as a readable one would. An If-Match that is not readable fails closed
+with a 412 instead: the header is there to prevent a lost update, so a value
+the server cannot read is not one it may act on.
A * is read as matching whenever evaluate is called, so a resource with no
current representation must not reach here: answer that with a 404, or with a
412 when the request carries an If-Match. Its existence is the one thing a
diff --git a/http.carp b/http.carp
index 07c7da3..4ba0cd6 100644
--- a/http.carp
+++ b/http.carp
@@ -2416,9 +2416,11 @@ failure is a `412`.
Both date headers are ignored when the representation has no modification date
or the field value is not a valid HTTP-date, as §13.1.3 and §13.1.4 require. An
-`If-None-Match` that is not a valid entity-tag list is ignored too, but an
-`If-Match` that is not fails closed with a `412`: the header is there to prevent
-a lost update, so a value the server cannot read is not one it may act on.
+`If-None-Match` that is not a valid entity-tag list is ignored too, but it is
+still present, and step 4 is gated on presence: it suppresses `If-Modified-Since`
+just as a readable one would. An `If-Match` that is not readable fails closed
+with a `412` instead: the header is there to prevent a lost update, so a value
+the server cannot read is not one it may act on.
A `*` is read as matching whenever `evaluate` is called, so a resource with no
current representation must not reach here: answer that with a `404`, or with a
diff --git a/test/http.carp b/test/http.carp
index 0928521..18b8323 100644
--- a/test/http.carp
+++ b/test/http.carp
@@ -1679,6 +1679,19 @@
"ERROR"
&(etag-verdict "\"xy zzy\"")
"a space in an opaque-tag is rejected")
+ (assert-equal test
+ "ERROR"
+ &(etag-verdict "\"")
+ "a lone quote is not an empty entity-tag")
+ (assert-equal test
+ "ERROR"
+ &(etag-verdict "\"a\"b\"")
+ "a quote inside an opaque-tag is rejected")
+ (assert-equal test
+ "ERROR"
+ &(etag-verdict
+ &(String.append "\"a" &(String.append &(String.from-bytes &[127b]) "b\"")))
+ "a DEL byte in an opaque-tag is rejected")
(assert-true test
(ETag.strong-match? &(ETag.init @"a" false) &(ETag.init @"a" false))
"two strong entity-tags with the same value match strongly")
@@ -1921,6 +1934,21 @@
&(some-etag "a" false)
&(no-date))
"Request.preconditions evaluates the request's own headers and method")
+ (assert-equal test
+ "412"
+ &(req-precondition {@"If-None-Match" [@"\"a\""]}
+ "PUT"
+ &(some-etag "a" false)
+ &(no-date))
+ "Request.preconditions answers a non-GET If-None-Match with a 412")
+ (assert-equal test
+ "OK"
+ &(req-precondition
+ {@"If-Modified-Since" [@"Sun, 06 Nov 1994 08:49:37 GMT"]}
+ "POST"
+ &(no-etag)
+ &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
+ "Request.preconditions gates If-Modified-Since on the request's own method")
(assert-equal test
412
Status.precondition-failed