Evaluate If-Range so a stale range request gets the whole representation - #38
Conversation
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`.
There was a problem hiding this comment.
Build & Tests
carp -x test/http.carp at b29b498: 476 passed, 0 failed, exit 0 (35s). Both CI jobs green.
I also re-ran carp -x gendocs.carp and git status --porcelain is empty afterwards, so the committed docs/ really is what gendocs produces. Worth stating because CI's "Generate docs" step only runs gendocs — it never diffs the result — so committed-doc drift is not something CI would catch on your behalf. docs/index.html is byte-identical to docs/http_index.html, so the hand-kept copy is in sync, and neither is touched by the diff.
Findings
Nothing found. What I tried:
Hostile field values. This is the first code path to String.trim a raw If-Range value, so I checked it does not inherit core's byte/char confusion — core's starts-with? guards on byte length but slices characters, which aborts on non-UTF-8. It does not: dispatch goes through this repo's own String.byte-starts-with? (http.carp:22), and ETag.parse is byte-oriented throughout. Fed it a bare run of continuation bytes, the same run inside a quoted tag, the same behind W/, an empty value, whitespace only, a lone double quote, an unterminated quote, a control byte inside the opaque-tag, and an 8 KiB value. Every one returns false, no abort.
That set is all-false, which a function that just returned false would also produce, so I checked the harness has teeth: a non-UTF-8 opaque tag that does match the representation returns true, and the same tag one byte different returns false. The etag path is live on those inputs, it is genuinely rejecting them.
Case-insensitive field names. if-range-matches? reaches both headers through header-lookup (http.carp:265), which lowercases each side, so if-range and range are found; a lowercase-spelled header with a mismatched tag still returns false, so the lookup is not accidentally short-circuiting.
All three HTTP-date formats for one instant. IMF-fixdate, RFC 850 and asctime spellings of the same moment all return true against the same Last-Modified, so "comparison is by instant, not by field" is real and not just the one alternate format the suite asserts.
Both validators present. With the representation carrying an etag and a date, a matching tag and a matching date each independently honour the range — neither branch is shadowing the other.
Multiple If-Range field values take the first, so ["b", "a"] against etag a is false — the conservative direction.
Placement. The Precondition-not-ByteRange justification checks out: ByteRange is at http.carp:1943 and ETag at http.carp:2178, so a ByteRange helper genuinely could not call ETag.parse. And the "nothing wires this up" worry does not apply here — nothing internal calls Precondition.evaluate either, its only caller is the Request.preconditions wrapper. A pure predicate the consumer composes is this library's existing shape, not an orphaned helper.
Spec. §13.1.5 distinguishes the two forms by looking at the first two characters for a DQUOTE; testing for a " or W/ prefix is a superset of that, and both routes agree — a weak tag sent to the entity-tag branch fails ETag.strong-match? because strong-match? requires neither side be weak (http.carp:2221), and the same string sent to the date branch fails HttpDate.parse. Same answer either way, so the extra arm costs nothing and states the intent.
One pre-existing note, explicitly not this PR: header-lookup does Array.unsafe-first on the value array, so a hand-built header map with an empty array for a key aborts. I confirmed it aborts identically through the untouched Precondition.evaluate on this same branch, so it predates the change, and Request.parse cannot produce an empty array — a header line with no value yields [""]. Recording it only so it is on the books.
Verdict: merge
The gap was real, the fix is correctly scoped to a predicate the caller composes, the failure directions are all the safe one, and the tests hold up under mutation. The README no longer promises something the library does not do.
If-Range(RFC 9110 §13.1.5) was the one conditional-request header the librarydid not evaluate — and
Precondition's own doc string said so while pointing atByteRange, which does not evaluate it either. A server that honours aRangewithout checking it splices bytes of a changed representation into a client's
resumed download, silently corrupting the assembled file.
websits on top ofthis library and does exactly that today.
What this adds
Precondition.if-range-matches? [hdrs etag modified]— whether the request'sRangeheader is to be honoured — andRequest.if-range-matches?, wrapping itthe way
Request.rangeandRequest.preconditionswrap theirs.falsemeans theRangemust be ignored and the whole representation sent witha
200.Spec notes
a quote or a
W/names an entity-tag, anything else an HTTP-date.§13.1.5 forbids a client to send and obliges a recipient to ignore — never
matches, in either direction.
last modification, per §8.8.2.2's strong-validator rule. Comparison is by
instant, not by field, so any of the HTTP-date formats spells it.
representation has no counterpart to, are non-matches — the safe direction.
If-Rangewith noRangeto gate is ignored, as §13.1.5 requires.Precondition.evaluate) first,then
If-Range, then theRangeitself.Why
Preconditionand notByteRangeByteRangeis defined at ~line 1943, beforeETag(~2178) and afterHttpDate, and Carp needs a definition before its use — aByteRangehelpercannot call
ETag.parse. ReusingPreconditionalso avoids a new public modulein
gendocs.carpand the hand-keptdocs/index.html.Verification
carp -x test/http.carp: 476 passed, 0 failed (16 new assertions).each killed by the assertion naming it:
If-Rangewithout aRangepresent → 1 failureanglerandcarp-fmt --checkclean, both built fresh from their repos' HEADthe way CI does (the boxes' installed binaries predate angler's
unused-defn-parameterrule).carp -x gendocs.carpre-run;docs/Precondition.htmlanddocs/Request.htmlare its output,
docs/http_index.htmlis unchanged so the hand-keptdocs/index.htmlstays in sync.git status --porcelainis empty afterwards.No CHANGELOG in this repo; the README's “
If-Rangeis not evaluated yet.” isreplaced with the real thing.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.