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