From b29b498dc1542cee949f5b65eefbe416d3f4e810 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sat, 22 Aug 2026 12:28:52 +0200 Subject: [PATCH] Evaluate If-Range so a stale range request gets the whole representation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9110 §13.1.5 has an `If-Range` gate a `Range` header on the client still holding the version it is resuming; a server that honours the `Range` without checking splices bytes of a changed representation into the client's copy. Nothing in the library evaluated it — `Precondition`'s own doc string pointed at `ByteRange`, which does not evaluate it either. `Precondition.if-range-matches?` answers whether the `Range` is to be honoured, and `Request.if-range-matches?` wraps it the way `Request.range` and `Request.preconditions` wrap theirs. It lives in `Precondition` rather than `ByteRange` because `ByteRange` is defined before `ETag` and `HttpDate`, and Carp needs a definition before its use. The two validator forms are told apart by the first characters of the field value. An entity-tag is compared strongly, so a weak one — which §13.1.5 forbids a client to send and obliges a recipient to ignore — never matches. A date matches only when it names the very instant of the representation's last modification, per §8.8.2.2's strong-validator rule; an unreadable field value, and a validator the selected representation has no counterpart to, are non-matches. An `If-Range` without a `Range` to gate is ignored, as §13.1.5 requires. The order is §13.2.1's: the §13.2.2 preconditions, then `If-Range`, then the `Range`. --- README.md | 16 ++++++++- docs/Precondition.html | 42 ++++++++++++++++++++++-- docs/Request.html | 27 ++++++++++++++- http.carp | 73 +++++++++++++++++++++++++++++++++++++---- test/http.carp | 74 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2452c83..8c01d02 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,21 @@ of an `If-Match` or `If-None-Match` header. (Result.Error e) (IO.errorln &e)) ``` -`If-Range` is not evaluated yet. +`If-Range` gates a `Range` header on the client still holding the version it is +asking to continue (§13.1.5). A non-match means ignoring the `Range` and sending +the whole representation with a `200`, rather than splicing bytes of a changed +representation into the client's copy. + +```clojure +(if (Request.if-range-matches? &req &etag &modified) + (serve-range &req) ; no If-Range, or its validator still matches + (whole-representation)) +``` + +The validator is either an entity-tag, compared strongly — so a weak one never +matches — or an HTTP-date, which must name the very instant the representation +was last modified. §13.2.1 has the §13.2.2 preconditions evaluated first, then +`If-Range`, then the `Range` itself. ### Status codes diff --git a/docs/Precondition.html b/docs/Precondition.html index 32ab22c..ccfe454 100644 --- a/docs/Precondition.html +++ b/docs/Precondition.html @@ -158,9 +158,9 @@

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

+

If-Range demands no status of its own, so it is evaluated apart from the four, +by if-range-matches?: a range request whose condition fails +is answered with the whole representation rather than an error.

@@ -207,6 +207,42 @@

ETagList.strong-match? and ETagList.weak-match? are public for it.

+

+

+
+ +

+ if-range-matches? +

+
+
+ defn +
+

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

+
+                        (if-range-matches? hdrs etag modified)
+                    
+

+

whether the Range header in hdrs is to be honored, +given etag and modified, the entity-tag and last modification date of the +representation the server selected — either may be Nothing.

+

true when the request carries no If-Range, when it carries no Range for one +to gate, or when the If-Range validator matches (RFC 9110 §13.1.5). false +means the Range must be ignored and the whole representation sent with a 200, +the client holding a version other than the one it asks to continue.

+

The two validator forms are told apart by the first characters of the field +value: a quote or a W/ names an entity-tag, anything else an HTTP-date. An +entity-tag is compared strongly (§8.8.3.2), so a weak one — which §13.1.5 forbids +a client to send and obliges a recipient to ignore — never matches. A date +matches the modification date only when it names the very same instant, as +§8.8.2.2's strong-validator rule demands. A field value that reads as neither +form, and a validator the selected representation has no counterpart to, are +non-matches.

+

§13.2.1 orders the three steps of a conditional range request: +evaluate first, then this, then the Range itself.

+

diff --git a/docs/Request.html b/docs/Request.html index 748978f..cd4dc8b 100644 --- a/docs/Request.html +++ b/docs/Request.html @@ -358,6 +358,30 @@

+
+ +

+ if-range-matches? +

+
+
+ defn +
+

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

+
+                        (if-range-matches? r etag modified)
+                    
+

+

whether this request's Range header is to be honored +for a representation whose entity-tag is etag and whose last modification date +is modified. false means the Range is to be ignored and the whole +representation sent. See +Precondition.if-range-matches?.

+ +

+

@@ -637,7 +661,8 @@

(Maybe (Result (Array ByteRangeSpec) String)). Nothing when it carries no Range header. See ByteRange.parse, whose Error means the header is one RFC 9110 §14.2 has the server ignore — serving the whole -representation rather than a 416.

+representation rather than a 416. A request that also carries an If-Range +wants if-range-matches? consulted first.

diff --git a/http.carp b/http.carp index 2b82b03..286769e 100644 --- a/http.carp +++ b/http.carp @@ -2330,9 +2330,9 @@ the status they demand. (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.") +`If-Range` demands no status of its own, so it is evaluated apart from the four, +by [if-range-matches?](#if-range-matches?): a range request whose condition fails +is answered with the whole representation rather than an error.") (defmodule Precondition (hidden list-header) (private list-header) @@ -2445,7 +2445,59 @@ conditional creation an `If-None-Match` of `*` guards is the caller's too; (Maybe.Nothing) (if (read? method) (modified-since-status hdrs modified) - (Maybe.Nothing))))))) + (Maybe.Nothing)))))) + + (hidden if-range-etag-match?) + (private if-range-etag-match?) + (defn if-range-etag-match? [v etag] + (match (ETag.parse v) + (Result.Error _) false + (Result.Success e) + (match-ref etag + (Maybe.Nothing) false + (Maybe.Just cur) (ETag.strong-match? &e cur)))) + + (hidden if-range-date-match?) + (private if-range-date-match?) + (defn if-range-date-match? [v modified] + (match (HttpDate.parse v) + (Result.Error _) false + (Result.Success d) + (match-ref modified + (Maybe.Nothing) false + (Maybe.Just m) (Datetime.equal-instant? m &d)))) + + (doc if-range-matches? "whether the `Range` header in `hdrs` is to be honored, +given `etag` and `modified`, the entity-tag and last modification date of the +representation the server selected — either may be `Nothing`. + +`true` when the request carries no `If-Range`, when it carries no `Range` for one +to gate, or when the `If-Range` validator matches (RFC 9110 §13.1.5). `false` +means the `Range` must be ignored and the whole representation sent with a `200`, +the client holding a version other than the one it asks to continue. + +The two validator forms are told apart by the first characters of the field +value: a quote or a `W/` names an entity-tag, anything else an HTTP-date. An +entity-tag is compared strongly (§8.8.3.2), so a weak one — which §13.1.5 forbids +a client to send and obliges a recipient to ignore — never matches. A date +matches the modification date only when it names the very same instant, as +§8.8.2.2's strong-validator rule demands. A field value that reads as neither +form, and a validator the selected representation has no counterpart to, are +non-matches. + +§13.2.1 orders the three steps of a conditional range request: +[evaluate](#evaluate) first, then this, then the `Range` itself.") + (defn if-range-matches? [hdrs etag modified] + (match (header-lookup hdrs "If-Range") + (Maybe.Nothing) true + (Maybe.Just v) + (if (Maybe.nothing? &(header-lookup hdrs "Range")) + true + (let [t (String.trim &v)] + (if (or (String.byte-starts-with? &t "\"") + (String.byte-starts-with? &t "W/")) + (if-range-etag-match? &t etag) + (if-range-date-match? &t modified))))))) (defmodule Request (doc form-data "parses the request body as URL-encoded form data. @@ -2510,7 +2562,8 @@ from the server's `offers`, honoring the request's `Accept-Language` header (RFC `(Maybe (Result (Array ByteRangeSpec) String))`. `Nothing` when it carries no `Range` header. See [`ByteRange.parse`](ByteRange.html#parse), whose `Error` means the header is one RFC 9110 §14.2 has the server ignore — serving the whole -representation rather than a `416`.") +representation rather than a `416`. A request that also carries an `If-Range` +wants [if-range-matches?](#if-range-matches?) consulted first.") (defn range [r] (match (Request.header r "Range") (Maybe.Nothing) (Maybe.Nothing) @@ -2522,4 +2575,12 @@ 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))) + (Precondition.evaluate (headers r) (verb r) etag modified)) + + (doc if-range-matches? "whether this request's `Range` header is to be honored +for a representation whose entity-tag is `etag` and whose last modification date +is `modified`. `false` means the `Range` is to be ignored and the whole +representation sent. See +[`Precondition.if-range-matches?`](Precondition.html#if-range-matches?).") + (defn if-range-matches? [r etag modified] + (Precondition.if-range-matches? (headers r) etag modified))) diff --git a/test/http.carp b/test/http.carp index 9620260..ff11f48 100644 --- a/test/http.carp +++ b/test/http.carp @@ -345,6 +345,13 @@ (Maybe.Nothing) @"OK" (Maybe.Just code) (Int.str code)))) +(defn if-range? [v etag modified] + (Precondition.if-range-matches? + &{@"Range" [@"bytes=0-499"] + @"If-Range" [@v]} + etag + modified)) + ; ---- 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))) @@ -2031,6 +2038,73 @@ &(no-etag) &(at "Sun, 06 Nov 1994 08:49:37 GMT")) "Request.preconditions gates If-Modified-Since on the request's own method") + (assert-true test + (if-range? "\"a\"" &(some-etag "a" false) &(no-date)) + "an If-Range entity-tag matching the representation honors the Range") + (assert-false test + (if-range? "\"b\"" &(some-etag "a" false) &(no-date)) + "an If-Range entity-tag naming another version ignores the Range") + (assert-false test + (if-range? "W/\"a\"" &(some-etag "a" false) &(no-date)) + "an If-Range compares strongly, so a weak entity-tag never matches") + (assert-false test + (if-range? "\"a\"" &(some-etag "a" true) &(no-date)) + "an If-Range against a weakly validated representation never matches") + (assert-false test + (if-range? "\"a\"" &(no-etag) &(no-date)) + "an If-Range entity-tag against a representation with none is a non-match") + (assert-false test + (if-range? "junk" &(some-etag "a" false) &(no-date)) + "an If-Range that reads as neither validator form is a non-match") + (assert-true test + (if-range? "Sun, 06 Nov 1994 08:49:37 GMT" + &(no-etag) + &(at "Sun, 06 Nov 1994 08:49:37 GMT")) + "an If-Range date equal to the modification date honors the Range") + (assert-false test + (if-range? "Sun, 06 Nov 1994 08:49:36 GMT" + &(no-etag) + &(at "Sun, 06 Nov 1994 08:49:37 GMT")) + "an If-Range date a second before the modification date is a non-match") + (assert-false test + (if-range? "Sun, 06 Nov 1994 08:49:38 GMT" + &(no-etag) + &(at "Sun, 06 Nov 1994 08:49:37 GMT")) + "an If-Range date a second after the modification date is a non-match") + (assert-true test + (if-range? "Sunday, 06-Nov-94 08:49:37 GMT" + &(no-etag) + &(at "Sun, 06 Nov 1994 08:49:37 GMT")) + "an If-Range date names an instant, whichever HTTP-date format spells it") + (assert-false test + (if-range? "not a date" &(no-etag) &(at "Sun, 06 Nov 1994 08:49:37 GMT")) + "an If-Range date the server cannot read is a non-match") + (assert-false test + (if-range? "Sun, 06 Nov 1994 08:49:37 GMT" &(some-etag "a" false) &(no-date)) + "an If-Range date against a representation with none is a non-match") + (assert-true test + (Precondition.if-range-matches? &{@"Range" [@"bytes=0-499"]} + &(some-etag "a" false) + &(no-date)) + "a Range with no If-Range is honored") + (assert-true test + (Precondition.if-range-matches? &{@"If-Range" [@"\"b\""]} + &(some-etag "a" false) + &(no-date)) + "an If-Range without a Range to gate is ignored") + (assert-true test + (Precondition.if-range-matches? &(no-headers) &(no-etag) &(no-date)) + "a request with neither header is honored") + (assert-false test + (Request.if-range-matches? + &(Request.get (URI.zero) + [] + {@"Range" [@"bytes=0-499"] + @"If-Range" [@"\"b\""]} + @"") + &(some-etag "a" false) + &(no-date)) + "Request.if-range-matches? evaluates the request's own headers") (assert-equal test 412 Status.precondition-failed