From efcb8aa79bbf8565cebb5c7482cbe388173682ef Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Wed, 19 Aug 2026 07:36:09 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Add=20entity-tags=20and=20conditional=20req?= =?UTF-8?q?uests=20(RFC=209110=20=C2=A78.8.3,=20=C2=A713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ETag with strong and weak comparison, If-Match / If-None-Match list parsing, and precondition evaluation in §13.2.2's order, yielding 304 for GET/HEAD and 412 otherwise. Adds Status.precondition-failed. WIP: pushed by the heartbeat orchestrator after the topic session was cut off by its time cap mid-verification. --- README.md | 33 +++ docs/Accept.html | 15 ++ docs/AcceptEncoding.html | 15 ++ docs/AcceptLanguage.html | 15 ++ docs/Auth.html | 15 ++ docs/ByteRange.html | 15 ++ docs/ByteRangeSpec.html | 15 ++ docs/CacheControl.html | 15 ++ docs/ContentRange.html | 15 ++ docs/Cookie.html | 15 ++ docs/Credentials.html | 15 ++ docs/ETag.html | 498 +++++++++++++++++++++++++++++++++++++ docs/ETagList.html | 375 ++++++++++++++++++++++++++++ docs/Form.html | 15 ++ docs/FormPart.html | 15 ++ docs/HttpDate.html | 15 ++ docs/MediaRange.html | 15 ++ docs/MediaType.html | 15 ++ docs/Multipart.html | 15 ++ docs/Precondition.html | 213 ++++++++++++++++ docs/Request.html | 39 +++ docs/Response.html | 15 ++ docs/SameSite.html | 15 ++ docs/Status.html | 34 +++ docs/TransferEncoding.html | 15 ++ docs/Weighted.html | 15 ++ docs/http_index.html | 20 +- gendocs.carp | 8 +- http.carp | 293 +++++++++++++++++++++- test/http.carp | 324 +++++++++++++++++++++++- 30 files changed, 2133 insertions(+), 4 deletions(-) create mode 100644 docs/ETag.html create mode 100644 docs/ETagList.html create mode 100644 docs/Precondition.html diff --git a/README.md b/README.md index ef66536..2452c83 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,36 @@ is the `416` case. (Result.Error e) (IO.errorln &e)) ``` +### Conditional requests + +`Precondition` evaluates the RFC 9110 §13 conditional request headers — +`If-Match`, `If-Unmodified-Since`, `If-None-Match` and `If-Modified-Since` — +against the validators of the representation the server selected, in the +precedence §13.2.2 lays down, and answers with the status they demand. + +```clojure +(match (Request.preconditions &req &etag &modified) + (Maybe.Just code) (Response.respond code {} @"") ; 304 or 412 + (Maybe.Nothing) (Response.ok {} @"the body")) +``` + +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`. `If-Match` compares entity-tags +strongly and `If-None-Match` weakly, per §13.1.1 and §13.1.2. + +`ETag` reads and writes one entity-tag, `ETagList` the `*`-or-list field value +of an `If-Match` or `If-None-Match` header. + +```clojure +(ETag.str &(ETag.init @"xyzzy" true)) ; => W/"xyzzy" + +(match (ETag.parse "W/\"xyzzy\"") + (Result.Success e) (println* (ETag.weak &e)) ; => true + (Result.Error e) (IO.errorln &e)) +``` + +`If-Range` is not evaluated yet. + ### Status codes ```clojure @@ -202,6 +232,9 @@ Status.not-found ; => 404 | `CacheControl` | `Cache-Control` directive parser (RFC 7234 §5.2) | | `TransferEncoding` | Chunked transfer-encoding decoder | | `HttpDate` | HTTP-date parser and formatter (RFC 9110 §5.6.7) | +| `ETag` | one entity-tag, strong or weak (RFC 9110 §8.8.3) | +| `ETagList` | the `*`-or-list value of an `If-Match` or `If-None-Match` header | +| `Precondition` | conditional request evaluator (RFC 9110 §13) | ## Testing diff --git a/docs/Accept.html b/docs/Accept.html index 96e8976..fe74c8b 100644 --- a/docs/Accept.html +++ b/docs/Accept.html @@ -122,6 +122,21 @@ ContentRange +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/AcceptEncoding.html b/docs/AcceptEncoding.html index c0dc4d1..fa71380 100644 --- a/docs/AcceptEncoding.html +++ b/docs/AcceptEncoding.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/AcceptLanguage.html b/docs/AcceptLanguage.html index ec7c6a9..34ed2c4 100644 --- a/docs/AcceptLanguage.html +++ b/docs/AcceptLanguage.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/Auth.html b/docs/Auth.html index 88a42ca..f9b4c8f 100644 --- a/docs/Auth.html +++ b/docs/Auth.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/ByteRange.html b/docs/ByteRange.html index d65c74c..d71f1eb 100644 --- a/docs/ByteRange.html +++ b/docs/ByteRange.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/ByteRangeSpec.html b/docs/ByteRangeSpec.html index 6a667c1..874fe9f 100644 --- a/docs/ByteRangeSpec.html +++ b/docs/ByteRangeSpec.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/CacheControl.html b/docs/CacheControl.html index e6a08cf..b81b346 100644 --- a/docs/CacheControl.html +++ b/docs/CacheControl.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/ContentRange.html b/docs/ContentRange.html index fc436ea..b211c24 100644 --- a/docs/ContentRange.html +++ b/docs/ContentRange.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/Cookie.html b/docs/Cookie.html index 4c9a387..f565e85 100644 --- a/docs/Cookie.html +++ b/docs/Cookie.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/Credentials.html b/docs/Credentials.html index d961b80..9ab9fea 100644 --- a/docs/Credentials.html +++ b/docs/Credentials.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/ETag.html b/docs/ETag.html new file mode 100644 index 0000000..22a5ac0 --- /dev/null +++ b/docs/ETag.html @@ -0,0 +1,498 @@ + + + + + + + + + +
    + +
    +

    + 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", quoted
    +(ETag.str &(ETag.init @"xyzzy" true))  ; => W/"xyzzy"
    +
    + +
    +
    + +

    + copy +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETag a)] ETag) +

    + + + +

    +

    copies a ETag.

    + +

    +
    +
    + +

    + delete +

    +
    +
    + instantiate +
    +

    + (Fn [ETag] ()) +

    + + + +

    +

    deletes a ETag. Should usually not be called manually.

    + +

    +
    +
    + +

    + init +

    +
    +
    + instantiate +
    +

    + (Fn [String, Bool] ETag) +

    + + + +

    +

    creates a ETag.

    + +

    +
    +
    + +

    + parse +

    +
    +
    + defn +
    +

    + (Fn [(Ref String a)] (Result ETag String)) +

    +
    +                        (parse s)
    +                    
    +

    +

    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.

    + +

    +
    +
    + +

    + prn +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETag a)] String) +

    + + + +

    +

    converts a ETag to a string.

    + +

    +
    +
    + +

    + set-value +

    +
    +
    + instantiate +
    +

    + (Fn [ETag, String] ETag) +

    + + + +

    +

    sets the value property of a ETag.

    + +

    +
    +
    + +

    + set-value! +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETag a), String] ()) +

    + + + +

    +

    sets the value property of a ETag in place.

    + +

    +
    +
    + +

    + set-weak +

    +
    +
    + instantiate +
    +

    + (Fn [ETag, Bool] ETag) +

    + + + +

    +

    sets the weak property of a ETag.

    + +

    +
    +
    + +

    + set-weak! +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETag a), Bool] ()) +

    + + + +

    +

    sets the weak property of a ETag in place.

    + +

    +
    +
    + +

    + str +

    +
    +
    + defn +
    +

    + (Fn [(Ref ETag a)] String) +

    +
    +                        (str e)
    +                    
    +

    +

    serializes e back into an entity-tag, e.g. W/"xyzzy". +Anything parse produced reads back into an equal ETag; a hand-built +value holding a quote or a control character writes a tag parse rejects.

    + +

    +
    +
    + +

    + strong-match? +

    +
    +
    + defn +
    +

    + (Fn [(Ref ETag a), (Ref ETag a)] Bool) +

    +
    +                        (strong-match? a b)
    +                    
    +

    +

    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.

    + +

    +
    +
    + +

    + update-value +

    +
    +
    + instantiate +
    +

    + (Fn [ETag, (Ref (Fn [String] String a) b)] ETag) +

    + + + +

    +

    updates the value property of a ETag using a function f.

    + +

    +
    +
    + +

    + update-weak +

    +
    +
    + instantiate +
    +

    + (Fn [ETag, (Ref (Fn [Bool] Bool a) b)] ETag) +

    + + + +

    +

    updates the weak property of a ETag using a function f.

    + +

    +
    +
    + +

    + value +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETag a)] (Ref String a)) +

    + + + +

    +

    gets the value property of a ETag.

    + +

    +
    +
    + +

    + weak +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETag a)] (Ref Bool a)) +

    + + + +

    +

    gets the weak property of a ETag.

    + +

    +
    +
    + +

    + weak-match? +

    +
    +
    + defn +
    +

    + (Fn [(Ref ETag a), (Ref ETag a)] Bool) +

    +
    +                        (weak-match? a b)
    +                    
    +

    +

    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).

    + +

    +
    +
    +
    + + diff --git a/docs/ETagList.html b/docs/ETagList.html new file mode 100644 index 0000000..e59c3fe --- /dev/null +++ b/docs/ETagList.html @@ -0,0 +1,375 @@ + + + + + + + + + +
    + +
    +

    + 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.

    + +
    +
    + +

    + Any +

    +
    +
    + instantiate +
    +

    + (Fn [] ETagList) +

    + + + +

    +

    creates a Any.

    + +

    +
    +
    + +

    + Tags +

    +
    +
    + instantiate +
    +

    + (Fn [(Array ETag)] ETagList) +

    + + + +

    +

    creates a Tags.

    + +

    +
    +
    + +

    + copy +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETagList a)] ETagList) +

    + + + +

    +

    copies a ETagList.

    + +

    +
    +
    + +

    + delete +

    +
    +
    + instantiate +
    +

    + (Fn [ETagList] ()) +

    + + + +

    +

    deletes a ETagList. This should usually not be called manually.

    + +

    +
    +
    + +

    + get-tag +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETagList a)] Int) +

    + + + +

    +

    Gets the tag from a ETagList.

    + +

    +
    +
    + +

    + parse +

    +
    +
    + defn +
    +

    + (Fn [(Ref String a)] (Result ETagList String)) +

    +
    +                        (parse s)
    +                    
    +

    +

    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.

    + +

    +
    +
    + +

    + prn +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETagList a)] String) +

    + + + +

    +

    converts a ETagList to a string.

    + +

    +
    +
    + +

    + str +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref ETagList a)] String) +

    + + + +

    +

    converts a ETagList to a string.

    + +

    +
    +
    + +

    + strong-match? +

    +
    +
    + defn +
    +

    + (Fn [(Ref ETagList a), (Ref (Maybe ETag) a)] Bool) +

    +
    +                        (strong-match? l etag)
    +                    
    +

    +

    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 takes to be so whenever it +is called.

    + +

    +
    +
    + +

    + weak-match? +

    +
    +
    + defn +
    +

    + (Fn [(Ref ETagList a), (Ref (Maybe ETag) a)] Bool) +

    +
    +                        (weak-match? l etag)
    +                    
    +

    +

    whether l matches etag under the weak comparison +function If-None-Match calls for (RFC 9110 §13.1.2). See +strong-match? for how Any is read.

    + +

    +
    +
    +
    + + diff --git a/docs/Form.html b/docs/Form.html index 54e5ac5..88fa728 100644 --- a/docs/Form.html +++ b/docs/Form.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/FormPart.html b/docs/FormPart.html index 7484860..d369065 100644 --- a/docs/FormPart.html +++ b/docs/FormPart.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/HttpDate.html b/docs/HttpDate.html index fcd63f4..17fe73a 100644 --- a/docs/HttpDate.html +++ b/docs/HttpDate.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/MediaRange.html b/docs/MediaRange.html index 27d1a07..5df64e5 100644 --- a/docs/MediaRange.html +++ b/docs/MediaRange.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/MediaType.html b/docs/MediaType.html index 8041731..67f94a1 100644 --- a/docs/MediaType.html +++ b/docs/MediaType.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/Multipart.html b/docs/Multipart.html index 9ace6d7..481e8e0 100644 --- a/docs/Multipart.html +++ b/docs/Multipart.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/Precondition.html b/docs/Precondition.html new file mode 100644 index 0000000..0906979 --- /dev/null +++ b/docs/Precondition.html @@ -0,0 +1,213 @@ + + + + + + + + + +
    + +
    +

    + 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's business rather than a status.

    + +
    +
    + +

    + evaluate +

    +
    +
    + defn +
    +

    + (Fn [(Ref (Map String (Array String)) a), (Ref String b), (Ref (Maybe ETag) c), (Ref (Maybe Datetime) d)] (Maybe Int)) +

    +
    +                        (evaluate hdrs method etag modified)
    +                    
    +

    +

    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.strong-match? and +ETagList.weak-match? are public for it.

    + +

    +
    +
    +
    + + diff --git a/docs/Request.html b/docs/Request.html index 7dfc279..748978f 100644 --- a/docs/Request.html +++ b/docs/Request.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl @@ -538,6 +553,30 @@

    +
    + +

    + preconditions +

    +
    +
    + defn +
    +

    + (Fn [(Ref Request a), (Ref (Maybe ETag) b), (Ref (Maybe Datetime) c)] (Maybe Int)) +

    +
    +                        (preconditions r etag modified)
    +                    
    +

    +

    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.

    + +

    +

  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/SameSite.html b/docs/SameSite.html index 0820279..b63e1d1 100644 --- a/docs/SameSite.html +++ b/docs/SameSite.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/Status.html b/docs/Status.html index 4378106..c47cca6 100644 --- a/docs/Status.html +++ b/docs/Status.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl @@ -537,6 +552,25 @@

    +
    + +

    + precondition-failed +

    +
    +
    + def +
    +

    + Int +

    + + + +

    + +

    +

  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/Weighted.html b/docs/Weighted.html index 85a9aec..f45845d 100644 --- a/docs/Weighted.html +++ b/docs/Weighted.html @@ -122,6 +122,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl diff --git a/docs/http_index.html b/docs/http_index.html index 95f80ac..76b81c0 100644 --- a/docs/http_index.html +++ b/docs/http_index.html @@ -118,6 +118,21 @@ ContentRange
  • +
  • + + ETag + +
  • +
  • + + ETagList + +
  • +
  • + + Precondition + +
  • CacheControl @@ -143,7 +158,10 @@

    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