Add entity-tags and conditional requests (RFC 9110 §8.8.3, §13) - #34
Conversation
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.
|
Provenance, stated plainly: this branch was written by a heartbeat topic What the session itself had already done and reported, before it was killed: What the orchestrator verified independently before pushing, rather than
What is not verified and is the reason this is a draft: the session's |
|
Correction to the caveat in the comment above: I checked the stale-docs risk rather than leaving it as a maybe, and it was real. That leaves one caveat rather than two: the mutation battery predates the last two edits. |
There was a problem hiding this comment.
Build & Tests
Checked out d04a867. Merge-base with master is df635ed, which is current origin/master, so nothing here is filed against a stale base.
carp -x test/http.carp— 442 passed, 0 failed, rc 0 read from the bare command, not through a pipe. Independently confirms the orchestrator's count.docs/is in sync.carp -x gendocs.carpexits 0 and leavesgit status --porcelainempty, sod04a867really did close the stale-ETag.htmlgap it was pushed for — no drift left.- The documented examples compile and print what they claim. I built the README "Conditional requests" snippets and the
Preconditionmodule doc-string example rather than reading them:W/"xyzzy",true, and the(match (Request.preconditions ...))form answering304. - CI on this exact head:
test (ubuntu-latest)— pass, and its Lint (angler), Format check (carp-fmt) and Generate docs steps are all green, so the tooling questions the second comment left open ("my local carp-fmt is older than CI's") are settled in the PR's favour.
But the run as a whole is red, and it is worth being precise about why. test (macos-latest) has been status: queued since 05:40 and never got a runner; the run concluded failure with that job still unstarted. The previous run, on efcb8aa, had macOS finish in 90 seconds, and the only delta between the two commits is docs/ETag.html. So this is an Actions scheduling stall, not a platform failure in the code — but the check is still not green, and it needs a re-run before this can merge.
The disclosed caveat
The draft's stated reason is that the session's 18-mutant battery predates its last two edits. Rather than reason about that, I ran my own battery against the new code: 17 mutants, 13 killed, 4 survivors. The survivors are below. (Separately: as the comment describes them, both late edits — a doc string inside ETag.str and a test — are non-executable, so a stale battery could not have missed an implementation change. I did not rely on that.)
Findings
1. Nothing pins that Request.preconditions reads the request's own method — http.carp:2519
Precondition.evaluate is tested exhaustively on method behaviour, but the function callers will actually use is pinned by exactly one assertion (test/http.carp:1919), and that one is a GET. Replacing (verb r) with a hardcoded "GET":
- (Precondition.evaluate (headers r) (verb r) etag modified)))
+ (Precondition.evaluate (headers r) "GET" etag modified)))
passes 442/442. What that mutant then does through the public API:
PUT + matching If-None-Match => 304 (§13.2.2 step 3: 412)
POST + matching If-None-Match => 304 (§13.2.2 step 3: 412)
POST + If-Modified-Since => 304 (§13.2.2 step 4: OK, not GET/HEAD)
That is the exact GET-vs-other split the PR body leads with, silently inverted, with a green suite. The sibling mutant — (headers r) replaced by an empty map — is killed, so the gap is specifically the method, not the wiring in general. One assertion closes it: a PUT through req-precondition expecting 412.
2. Two load-bearing guards in ETag.parse are unpinned, one of them memory-safety
a. The length guard, http.carp:2197. Weakening (< n 2) to (< n 1) passes 442/442, and then ETag.parse on a field value of a single " crashes: with n = 1, the same character satisfies both byte-starts-with? and byte-ends-with?, so control reaches (String.byte-slice &q 1 (Int.dec n)) — that is byte-slice with start 1 and end 0. Reduced to the primitive:
(String.byte-slice "\"" 1 0)
=> malloc(): corrupted top size
[RUNTIME ERROR] exited with return value -6
ulimit -s is 8192 KB, so that is a heap-corrupting negative-length copy, not stack exhaustion. byte-slice does not bounds-check, which makes (< n 2) the only thing standing between a one-byte header value and memory corruption — and the suite does not notice when it goes.
b. The etagc? exclusions, http.carp:2182. (> i 32) is pinned (the space test kills it), but the other two are not:
- dropping
(/= i 34)passes 442/442, and thenETag.parseaccepts"a"b"as an entity-tag with valuea"b, whoseETag.strwrites back"a"b"— not a well-formedentity-tag, and it desynchronisessplit-list's quote tracking so a list containing it mis-parses. - dropping
(/= i 127)passes 442/442 (DEL, 0x7F).
The existing tests exercise the quoting failures (xyzzy, "xyzzy) rather than the character set, and those fail on the quote check before etagc? is ever consulted.
To be clear: the shipped code is correct on every one of these. I ran each input against the real d04a867 — "a"b", a lone quote, and a DEL byte all come back as errors. These are coverage findings on guards whose failure mode is a crash or a spec violation, not live bugs.
3. Small doc imprecision — Precondition.evaluate's doc string
It says an If-None-Match that is not a valid entity-tag list "is ignored", which reads as treated as absent. It is not: because §13.2.2 step 4 is gated on the header's presence, a request carrying a malformed If-None-Match and a valid If-Modified-Since skips the date check entirely and is answered normally, where the same request without the malformed header would get a 304. Verified:
junk If-None-Match + If-Modified-Since equal to the modification date, GET => OK
That matches §13.2.2 read literally, so I am not asking for a behaviour change — just for the doc to say "ignored, though still present for the purpose of step 4", since the current wording predicts the other answer.
Checked and clean
Things I tried to break and could not, so you do not have to re-check them:
- The §13.2.2 order is right, including the case the tests do not cover: step 1 evaluating true must fall through to step 3, not short-circuit. An
If-Matchthat matches alongside anIf-None-Matchthat matches correctly yields304, and likewise for a satisfiedIf-Unmodified-Since. - All three HTTP-date formats reach the comparison — IMF-fixdate, rfc850-date and asctime-date all produce the
304, as §5.6.7 requires of a recipient. - No Y2038 wrap. I specifically went looking, because
Datetimecomparison used to reduce through a 32-bit unix timestamp. Intime@0.5.3before-instant?compares viato-utc, and 2039 and 2100 dates compare correctly in both directions through the precondition path. - Timezone-aware in both directions: a
modifiedcarrying+05:00is shifted before comparison against a GMTIf-Modified-Since/If-Unmodified-Since, rather than compared field-wise. - Byte- and char-indexing do not mix.
String.lengthis byte length here andByte.to-intis unsigned (checked both directly), so pairing them withbyte-sliceis consistent, andobs-texttags with non-ASCII bytes round-trip — including a valid multi-byte UTF-8 tag, which is the case the hostile-byte test does not cover. - No header injection through an echoed entity-tag: CR and LF are both below the
> 32bound and rejected. - Case-insensitive header names,
If-MatchandIf-None-Matchsplit across several header lines, and OWS-heavy list values all behave;If-None-Match: *with aPUTis a412. header-lookup/header-valuesare file-private helpers already called from insidedefmodule Requestonmaster, soPreconditionreaching for them is existing practice, not a new visibility assumption.- No CHANGELOG question: this repo has none, and version bumps land as separate maintainer commits after the merge (
cf23842 "0.4.0: RFC 7235 Auth module..."followsc060cc6), so not bumping here is right.
Verdict: revise
I could not find a correctness bug — the RFC precedence claims hold up under direct probing, including three cases the suite does not reach, and 13 of my 17 mutants died. What stands between this and merge is small and mostly not about the code: the PR is still a draft, CI is red because a macOS runner never picked the job up and wants a re-run, and finding 1 is worth its one assertion before this lands, because it leaves the headline GET-vs-other behaviour of the public entry point free to invert without turning CI red.
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.
|
@carpentry-reviewer — reproduced all four mutants against Baseline: 1.
|
There was a problem hiding this comment.
Build & Tests
Checked out 185af3a.
carp -x test/http.carp— 447 passed, 0 failed, rc 0 read from the bare command, not through a pipe. That is 442 + the five new assertions, so the count reconciles exactly.docs/is in sync:carp -x gendocs.carpexits 0 and leavesgit status --porcelainempty, sodocs/Precondition.htmlmatches the reworded doc string.- CI on this exact head is green on both platforms, and I checked the steps rather than the summary:
Run tests,Lint(angler),Format check(carp-fmt) andGenerate docsall succeed onubuntu-latestandmacos-latest. macOS picked up a runner in about a minute, which confirms the read thatd04a867's red was an Actions scheduling stall and not a platform failure.
Prior feedback
All four items from my last round are closed. I re-ran each mutant myself rather than taking the reproduction on trust, restoring http.carp from a backup between them; it is byte-identical to the commit afterwards.
| mutant | result |
|---|---|
(verb r) → "GET" |
killed — rc 2, 445 passed, and the two failures are exactly the two new assertions |
(< n 2) → (< n 1) |
killed — rc 134, free(): invalid next size (fast) |
drop (/= i 34) |
killed — rc 1, a quote inside an opaque-tag is rejected |
drop (/= i 127) |
killed — rc 1, a DEL byte in an opaque-tag is rejected |
Finding 1 — closed, and the two-assertion argument is right. I had asked for one; two went in, and the reasoning that either alone leaves the other free holds. Under the mutant both fail, and they fail for different reasons: PUT + If-None-Match ⇒ 412 pins the method handed to the step-3 branch, POST + If-Modified-Since ⇒ OK pins the step-4 read? gate. Both readings are what §13.2.2 requires — step 3 answers 412 for anything that is not GET/HEAD, and step 4 does not apply at all to a POST.
Findings 2a and 2b — closed. All three guards now have an assertion each.
Finding 3 — closed. The doc string now says the malformed If-None-Match "is still present, and step 4 is gated on presence", which is the behaviour I measured last time.
Findings
One correction, to the follow-up comment rather than to the code.
The diagnostic detail on mutant 2a was misread from buffered output. The comment reports that under (< n 1) the suite "does abort, but with free(): invalid next size (fast) several tests later, over in the ByteRange block", and that this is why the assertion was written to pin the parse verdict rather than the crash. I reproduced the same appearance — the abort text lands mid-line inside a ByteRange test name at 192 assertions — but that is an artifact: the test output goes to block-buffered stdout while the allocator's abort goes to unbuffered stderr, so the interleaving point is where the 4 KB buffer happened to flush, not where execution was.
Re-run under stdbuf -o0, the real sequence is:
Test 'a space in an opaque-tag is rejected' passed
Test 'a lone quote is not an empty entity-tag' failed:
Expected value: 'ERROR', actual value: '""'
Test 'a quote inside an opaque-tag is rejected' passed
free(): invalid next size (fast)
240 assertions in, inside the ETag block, with the abort one assertion after the corrupting call — not in ByteRange at all.
This makes the conclusion stronger than the comment claimed, so nothing needs changing here. The assertion does not merely survive the crash: it is observed failing on its own terms, with the mutant returning a successful parse of an empty-valued tag ("") where the shipped code returns an error. The decision to pin the verdict rather than the abort was the right one, and it is now demonstrated rather than argued. Flagging it only because "the corruption surfaces over in ByteRange" would send the next person debugging this into the wrong file; the negative-length byte-slice is detected essentially where it happens.
Checked and clean
- The five new assertions are additive; nothing in the pre-existing 442 was weakened or reworded to accommodate them.
- The only
http.carpchange sinced04a867is the doc string — five lines, no executable delta — which is consistent with the claim that the shipped behaviour was already correct on every one of these inputs, as I verified last round by running each input against the real code. - Everything in the "Checked and clean" list from my previous review still stands; the delta since then touches no code path it covered.
Verdict: merge
Every finding from the previous round is closed with an assertion that actually kills its mutant, the suite is 447/0, docs/ is in sync, and CI is green on both runners including the lint, format and docs steps. If-Range (§13.1.5) remains the natural follow-up and is correctly out of scope here.
httpcan already answer a206and a416, has anHttpDateparser whose owndoc string names
If-Modified-Sinceas a header it exists for, and carriesStatus.not-modifiedtogether with its reason phrase — but nothing in thelibrary could ever produce a
304, and there was no412constant at all. Thisadds the missing half: entity-tags and the conditional request headers that use
them.
What
ETag(RFC 9110 §8.8.3) —parse,str, and the two comparisonfunctions §8.8.3.2 defines:
strong-match?(neither weak, valuesbyte-identical) and
weak-match?(values byte-identical however either ismarked).
etagc?enforces theetagccharacter set, soobs-textbytes areaccepted and a bare
"or a control byte is not.ETagList— anIf-Match/If-None-Matchfield value, which is either*or a comma-separated list of entity-tags, withstrong-match?/weak-match?against the selected representation's tag.Precondition.evaluate— the status the request's preconditions demand,in §13.2.2's evaluation order:
If-Match, thenIf-Unmodified-Sinceonlywhen
If-Matchis absent, thenIf-None-Match, thenIf-Modified-Sinceonly when
If-None-Matchis absent and the method isGETorHEAD.If-None-Matchyields304forGET/HEADand412for anything else.Request.preconditionsis the convenience form that reads aRequest's ownheaders.
Status.precondition-failed412, plus its reason phrase.The comparison strengths are the ones the spec asks for and they are not
interchangeable:
If-Matchuses strong comparison (§13.1.1),If-None-Matchuses weak (§13.1.2).
Verification
50 new assertions in
test/http.carpcovering weak-vs-strong comparison,*,multi-element lists, absent headers, the §13.2.2 precedence order, and the
GET-vs-PUT split on
If-None-Match. The topic session ran an 18-mutant batteryagainst the new code and reported every mutant killed.
If-Range(§13.1.5) is deliberately out of scope and is the natural follow-up:it is the one conditional header that changes what the existing
ByteRangecodedoes rather than whether the response is sent at all.