Skip to content

Evaluate If-Range so a stale range request gets the whole representation - #38

Merged
hellerve merged 1 commit into
masterfrom
claude/if-range
Aug 22, 2026
Merged

Evaluate If-Range so a stale range request gets the whole representation#38
hellerve merged 1 commit into
masterfrom
claude/if-range

Conversation

@carpentry-agent

Copy link
Copy Markdown

If-Range (RFC 9110 §13.1.5) was the one conditional-request header the library
did not evaluate — and Precondition's own doc string said so while pointing at
ByteRange, which does not evaluate it either. A server that honours a Range
without checking it splices bytes of a changed representation into a client's
resumed download, silently corrupting the assembled file. web sits on top of
this library and does exactly that today.

What this adds

Precondition.if-range-matches? [hdrs etag modified] — whether the request's
Range header is to be honoured — and Request.if-range-matches?, wrapping it
the way Request.range and Request.preconditions wrap theirs.

(if (Request.if-range-matches? &req &etag &modified)
  (serve-range &req)      ; no If-Range, or its validator still matches
  (whole-representation))

false means the Range must be ignored and the whole representation sent with
a 200.

Spec notes

  • The two validator forms are told apart by the field value's first characters:
    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, in either direction.
  • 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. Comparison is by
    instant, not by field, so any of the HTTP-date formats spells it.
  • A field value that reads as neither form, and a validator the selected
    representation has no counterpart to, are non-matches — the safe direction.
  • An If-Range with no Range to gate is ignored, as §13.1.5 requires.
  • Order is §13.2.1's: the §13.2.2 preconditions (Precondition.evaluate) first,
    then If-Range, then the Range itself.

Why Precondition and not ByteRange

ByteRange is defined at ~line 1943, before ETag (~2178) and after
HttpDate, and Carp needs a definition before its use — a ByteRange helper
cannot call ETag.parse. Reusing Precondition also avoids a new public module
in gendocs.carp and the hand-kept docs/index.html.

Verification

  • carp -x test/http.carp: 476 passed, 0 failed (16 new assertions).
  • The new tests were checked for teeth with three mutants of the implementation,
    each killed by the assertion naming it:
    • weak instead of strong entity-tag comparison → 2 failures
    • “not older than” instead of exact-instant date equality → 1 failure
    • evaluating If-Range without a Range present → 1 failure
  • angler and carp-fmt --check clean, both built fresh from their repos' HEAD
    the way CI does (the boxes' installed binaries predate angler's
    unused-defn-parameter rule).
  • carp -x gendocs.carp re-run; docs/Precondition.html and docs/Request.html
    are its output, docs/http_index.html is unchanged so the hand-kept
    docs/index.html stays in sync. git status --porcelain is empty afterwards.

No CHANGELOG in this repo; the README's “If-Range is not evaluated yet.” is
replaced with the real thing.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

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

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@hellerve
hellerve merged commit b7e018b into master Aug 22, 2026
2 checks passed
@hellerve
hellerve deleted the claude/if-range branch August 22, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant