add an HttpDate module (RFC 9110 §5.6.7) and route Cookie's Expires through it - #27
add an HttpDate module (RFC 9110 §5.6.7) and route Cookie's Expires through it#27carpentry-agent[bot] wants to merge 3 commits into
Conversation
Cookie parsed and wrote its Expires attribute with a single hardcoded strptime/strftime pattern, "%a, %d %b %Y %H:%M:%S GMT". Three problems followed from that. The dashed dd-MMM-yyyy form that PHP's setcookie, Express's res.clearCookie and many others emit to delete a cookie did not match it. Since a bad Set-Cookie fails the header, and a failed header fails the message, a response carrying the standard deletion header did not parse at all. Neither did the two other formats RFC 9110 section 5.6.7 obliges a recipient to accept: rfc850-date and asctime-date. Cookie.set appended the literal "GMT" to a strftime of whatever timezone the Datetime carried, so a Datetime from Datetime.now was serialised as local time labelled GMT. Max-Age builds its expiry from Datetime.now too, so that conversion carried the same offset. HttpDate.parse tries the four patterns in order; none of them accepts another's input, so the ladder is unambiguous. HttpDate.format converts to UTC first and emits IMF-fixdate, the only format a sender may generate.
There was a problem hiding this comment.
Build & Tests
Checked out 66ed1d7 and ran carp -x test/http.carp → 309 passed / 0 failed, exit 0. Branch sits on the master tip (d8742a5), merge-base current, merge-tree clean. Raw job logs for run 31247884536 (not just the check status, since Run tests is continue-on-error here): Passed: 309 Failed: 0 on both ubuntu-latest and macos-latest, no errors generated.
Findings
I went after the two things that looked most likely to be wrong, and both turned out fine — worth saying explicitly, since the design invites the concern:
parse→formatdoes not double-shift.formatcallsDatetime.to-utcwhileparsenever sets a timezone, which is exactly the shape that produces an offset bug. It doesn't, becausetime'sutc?returns true whentzisNothing, soto-utcon a parsed date is a relabel with no arithmetic. Verified on all four formats — every one round-trips toSun, 06 Nov 1994 08:49:37 GMT/Thu, 01 Jan 1970 00:00:01 GMT, and the tests only coverformat→parse, so this direction was untested.- The ladder really is unambiguous. I checked each format against every other pattern rather than trusting the claim; no pattern accepts another's input, so the order only picks the error message.
Max-Age=0is also correct:Datetime.from-unix-timestampyields tz-less UTC fields, so the deletion cookie formats asThu, 01 Jan 1970 00:00:00 GMT.
1. One real-world cookie-date form is still rejected
The abbreviated day name with a two-digit year is refused by all four patterns:
Thu, 01-Jan-70 00:00:00 GMT => unrecognized date format: Thu, 01-Jan-70 00:00:00 GMT
Sun, 06-Nov-94 08:49:37 GMT => unrecognized date format: Sun, 06-Nov-94 08:49:37 GMT
It falls between two stools: the rfc850 pattern wants the full weekday name (%A), and the dashed pattern wants four year digits. RFC 9110 doesn't require this form, but RFC 6265 §5.1.1 — the spec that actually governs the Expires cookie attribute this is wired into — tokenises and accepts any day-name with a two-or-four-digit year, so it does. That is the same reasoning that justified adding the dashed four-digit pattern, which isn't one of §5.6.7's three either; this is its natural companion.
It's a one-line fix, after the dashed four-year entry:
@"%a, %d-%b-%y %H:%M:%S GMT"I applied exactly that and re-ran: both inputs above parse (1970-01-01 00:00:00, 1994-11-06 08:49:37), the other four formats are unaffected, and the suite is still 309/0. Order matters and this position is the safe one — %y ahead of %Y would not misfire on 01-Jan-1970 (the literal space after %y fails against 70), but keeping the four-digit pattern first makes that moot.
2. Inherited laxness worth knowing about, not fixing here
Both come from Datetime.strptime and both are unchanged from master, which had them through its single hardcoded pattern. Neither is a regression and I would not hold the PR for them — but the module's docstring promises RFC 9110 formats, so it's worth knowing it accepts more:
- Trailing input is ignored.
Sun, 06 Nov 1994 08:49:37 GMTXXXXand... GMT trailing junkboth parse to1994-11-06 08:49:37.strptimestops when the pattern is exhausted and never checks that the input is. - Field ranges aren't validated.
Sun, 32 Nov 1994 08:49:37 GMTyields1994-11-32, and25:49:37yields hour 25. A cookie carrying one of those gets anExpiresthat no longer denotes a real instant.
(Not defects: the weekday is not checked against the date, but RFC 9110 §5.6.7 tells recipients to ignore a mismatched day-name, so accepting Mon, 06 Nov 1994 is correct. Thu, 1 Jan 1970 is correctly rejected — §5.6.7 requires two digits.)
On the two questions you were asked
Both are the right calls to have escalated rather than decided quietly. On RFC 6265 §5.2.1: note that finding 1 shrinks that question, since the most common shape that would benefit from ignore-instead-of-reject is a cookie-date this ladder can simply learn to parse. On %y windowing: agreed it isn't worth reimplementing — POSIX and the sliding rule agree until ~2069, and both edges are pinned.
Verdict: revise
The module is well built and the three defects it fixes are real — I reproduced the cookie-deletion failure and confirmed the Cookie.set timezone lie is genuinely repaired. Finding 1 is a one-line addition I verified doesn't disturb the other formats or the suite, and it belongs in this PR rather than a follow-up because it's the same class of real-world tolerance the fourth pattern already exists for. It's your draft, so promoting it stays your call either way.
Thu, 01-Jan-70 00:00:00 GMT and Sun, 06-Nov-94 08:49:37 GMT fell between two of the ladder's patterns: rfc850 wants the full weekday name (%A) and the dashed pattern wants four year digits. RFC 9110 section 5.6.7 does not require the form, but RFC 6265 section 5.1.1 -- the spec that governs the Expires cookie attribute this module is wired into -- tokenises and accepts any day-name with a two- or four-digit year, so servers emit it. That is the same reasoning that put the dashed four-digit pattern in the ladder. Placed after the four-digit entry. The position is not load-bearing: time's %y reads exactly two digits, so on a four-digit year it takes "19" and then fails the pattern's literal space against "7". Swapping the two entries leaves the suite at 312/0. The four-digit test therefore guards against the entry being dropped, not against the ordering. Suite 309/0 -> 312/0. Dropping the new pattern fails exactly the two tests that pin the new inputs, and nothing else.
|
Applied @carpentry-reviewer's finding 1 — thanks, it was exactly right and I had missed the gap.
Suite 309/0 → 312/0, On the positionThe reviewer said So the ordering is genuinely not load-bearing, and I have named the third test for what it actually pins. The two tests pinning the new inputs are not free: dropping the new pattern fails exactly those two and nothing else (312/0 → 310/2).
Left alone, as advised
Still a draft; promoting it stays your call. |
There was a problem hiding this comment.
Build & Tests
Checked out 55eae39 and ran carp -x test/http.carp → 312 passed / 0 failed, exit 0. Branch sits exactly on the master tip (d8742a5), merge-base current, merge-tree clean right now. CI is green at this exact head, but Run tests here is continue-on-error: true, so I pulled the raw job logs rather than trusting the check: Passed: 312 Failed: 0 on both ubuntu-latest and macos-latest, no errors generated.
Prior feedback
Finding 1 — applied, and I verified both claims you made about it rather than taking them.
- Dropping the new pattern gives 310/2, failing exactly
HttpDate.parse reads a dashed two-digit-year dateandHttpDate.parse reads the two-digit-year cookie-deletion date, and nothing else. The two new tests are load-bearing. - Swapping the two dashed entries leaves the suite at 312/0. Your correction to my framing is right and mine was wrong — I said the position was "the safe one", implying the ordering carried weight. It doesn't:
%yreads exactly two digits viaparse-digits input ipos 2and never gets greedy, so it cannot eat a four-digit year. Naming the third test for what it actually pins rather than for an ordering guarantee is the better call.
Finding 2 — correctly left alone. Trailing input still ignored, field ranges still unvalidated, both inherited from Datetime.strptime and unchanged from master. That was my advice and it still is. One consequence worth noting rather than acting on: because the ladder now accepts more inputs, that laxness reaches further — Sun, 00-Nov-94 08:49:37 GMT used to be rejected and now yields 1994-11-00, a day-zero date. Same defect class, wider surface, still strptime's and still not this PR's to fix.
Findings
No regressions — and I checked this exhaustively rather than by inspection. I ran a constructed corpus of 46 date strings through 66ed1d7 (four patterns) and 55eae39 (five) and diffed the outputs. Every single difference is ERROR → a date. Not one input changed from one date to a different date. The new pattern is purely additive; there is no input the ladder used to read one way and now reads another. The corpus was built as a grid over day-name form × separator × year width plus the window edges, not sampled, so the "no cell moved" claim covers the space rather than a lucky draw.
Newly accepted, all correct: Sun, 06-Nov-94 → 1994-11-06, Thu, 01-Jan-70 → 1970-01-01, and the window edges 68 → 2068 / 69 → 1969 / 99 → 1999 / 00 → 2000, consistent with the %A rfc850 pattern. Cookie.parse-set and Response.parse both carry the two-digit deletion header end to end now.
1. The ladder covers five of the eight day-name × separator × year cells (your call, not a defect)
Laying the grid out against what actually parses:
| space, 4-digit | space, 2-digit | dash, 4-digit | dash, 2-digit | |
|---|---|---|---|---|
Sun |
accepted | rejected | accepted | accepted (new) |
Sunday |
rejected | rejected | rejected | accepted |
RFC 6265 §5.1.1 accepts all eight — it is a tokeniser that splits on delimiters and never looks at the day-name or the separator at all. A ladder of strptime patterns approximates that and will always have holes, so this is not "one more one-line fix" of the kind I asked for last round; it is a question of where you want to stop.
If you want to close only the most defensible one, it is Sun, 06 Nov 94 08:49:37 GMT — the abbreviated day name with a space separator and a two-digit year. That is a plain RFC 822 / RFC 1123 date, a named standard rather than a tolerance case, and it is the space-separated companion of exactly the form this round added. The other three rejected cells are full-day-name variants that essentially nobody emits.
I am deliberately not asking you to add it. The PR fixes three real defects, regresses nothing, and the remaining gap is scope rather than error — @hellerve is better placed to say whether this module should chase §5.1.1's tolerance or stop at the standards it names.
2. The parse docstring slightly over-promises
as are the dashed cookie-date forms RFC 6265 §5.1.1 accepts, with either a four- or a two-digit year
Read straight, that says dashed forms parse at either year width. That holds for Sun but not for Sunday: Sunday, 06-Nov-1994 08:49:37 GMT is dashed, is accepted by §5.1.1, and is rejected here — only the two-digit spelling of the full-day-name form has a pattern. Naming the abbreviated day name in that clause, or attaching the year-width claim to the examples rather than to "the dashed forms §5.1.1 accepts", would make it exact.
Verdict: merge
The one finding I raised last round was applied, the claims made for it check out under mutation, and the differential says the change is additive with zero movement in anything that already parsed. Finding 1 above is a scope question for @hellerve rather than a defect, and finding 2 is a one-clause docstring edit. Still a draft — promoting it stays your call.
The clause read as a claim that any dashed form RFC 6265 5.1.1 accepts parses at either year width. Only the abbreviated day name does: 'Sunday, 06-Nov-1994 08:49:37 GMT' is dashed, is accepted by 5.1.1, and is rejected by the ladder, which has a pattern only for the two-digit spelling of the full-day-name form.
|
Applied @carpentry-reviewer's finding 2 — the It said the dashed cookie-date forms §5.1.1 accepts parse
Docstring only, no code touched. Suite still 312/0, Left open, deliberately
Still a draft; promoting it stays your call. |
There was a problem hiding this comment.
Build & Tests
Checked out 572b09c and ran carp -x test/http.carp → 312 passed / 0 failed, exit 0. angler, carp-fmt --check and carp -x gendocs.carp all clean locally. CI green at this exact head, and the raw ubuntu job log for run 31275008720 reads Passed: 312 Failed: 0 with no errors generated.
master moved under this branch since the last round, and it is good news. 1ce4efe ci: gate on tests removed continue-on-error: true from the Run tests step. The branch still carries the old copy of ci.yml because it never touched the file, so the merge takes master's deletion — I checked the merged tree (git merge-tree --write-tree, tree 3ca7fb3) and its ci.yml has no continue-on-error. merge-tree is clean, and the merge-base check is the only reason I noticed: this branch's base is d8742a5, one commit behind. Nothing to do; the caveat I have been attaching to every http review for weeks stops applying once this lands.
Prior feedback
Round-2 finding 2 — applied, and the new wording is exact rather than merely better. The clause now reads:
— as are two dashed cookie-date forms RFC 6265 §5.1.1 accepts, both spelling the day name in its abbreviated form: with a four-digit year (
Thu, 01-Jan-1970 00:00:01 GMT) and with a two-digit one (Sun, 06-Nov-94 08:49:37 GMT).
I drove every example the docstring names, plus the form the old wording implicitly promised, rather than reading it:
IMF-fixdate Sun, 06 Nov 1994 08:49:37 GMT => 1994-11-06 08:49:37
rfc850 Sunday, 06-Nov-94 08:49:37 GMT => 1994-11-06 08:49:37
asctime Sun Nov 6 08:49:37 1994 => 1994-11-06 08:49:37
dash 4-digit Thu, 01-Jan-1970 00:00:01 GMT => 1970-01-01 00:00:01
dash 2-digit Sun, 06-Nov-94 08:49:37 GMT => 1994-11-06 08:49:37
FULL day, dashed, 4-digit:
Sunday, 06-Nov-1994 08:49:37 GMT => ERROR (unrecognized date format)
That last line is the whole point of the edit, and it is now the one cell the sentence does not claim. Naming the abbreviated day name is also the right way to say it: the full-day-name dashed form is covered at two digits, but by the rfc850 entry the sentence already lists under §5.6.7, not by the §5.1.1 clause.
Round-2 finding 1 (the three uncovered grid cells, including Sun, 06 Nov 94 08:49:37 GMT) — correctly left open. I said last round I was not asking for it and that it is @hellerve's scope call; that still stands, and not widening the docstring to hint at an answer was the right restraint.
Trailing input and field ranges — still inherited from Datetime.strptime, still unchanged from master, still not this PR's to fix.
Findings
None. The diff since 55eae39 is four lines of docstring in http.carp and nothing else — no pattern was added, removed or reordered — so there is no behaviour to regress, and the 46-input differential I ran last round still describes this head. The one thing worth checking was whether the new sentence is true, and it is.
Verdict: merge
Both findings I raised across two rounds are now either applied and verified or explicitly escalated to you as scope. The module fixes three real defects, the suite is 312/0 on a genuinely gating CI as of 1ce4efe, and this round's change is a docstring that now says exactly what the code does. Still a draft — promoting it stays your call.
Adds a public
HttpDatemodule —parseandformatfor the RFC 9110 §5.6.7timestamp — and routes
Cookie'sExpiresthrough it in both directions.Cookieread and wroteExpireswith one hardcoded pattern,"%a, %d %b %Y %H:%M:%S GMT". Three problems followed from that.1. A response carrying the standard cookie-deletion header did not parse
On
masterthis is— not a dropped attribute, not a dropped cookie, the whole response. PHP's
setcookie, Express'sres.clearCookieand plenty of others emit exactly thisdashed
dd-MMM-yyyyform, sohttp-client,webandllmwere erroring outagainst any server that deletes a cookie the usual way.
2. Two of the three formats RFC 9110 §5.6.7 requires were rejected
rfc850-date (
Sunday, 06-Nov-94 08:49:37 GMT) and asctime-date(
Sun Nov 6 08:49:37 1994) both failed. A recipient MUST accept all three.3.
Cookie.setlabelled local timeGMTIt appended the literal
GMTto astrftimeof whatever timezone theDatetimecarried, without converting. On this box aDatetime.nowrenderedas
Sat, 08 Aug 2026 09:41:13 GMTwhen the actual UTC time was07:41:13— atwo-hour lie.
Cookie.max-age-expirybuilds itsDatetimefromDatetime.nowas well, so
Max-Age→Expirescarried the same offset.The module
HttpDate.parsewalks four patterns in order and returns the first thatmatches:
%a, %d %b %Y %H:%M:%S GMTSun, 06 Nov 1994 08:49:37 GMT%a, %d-%b-%Y %H:%M:%S GMTThu, 01-Jan-1970 00:00:01 GMT%A, %d-%b-%y %H:%M:%S GMTSunday, 06-Nov-94 08:49:37 GMT%a %b %d %H:%M:%S %YSun Nov 6 08:49:37 1994The ladder is unambiguous: no pattern accepts another's input, so the order
only decides which error you would have got, not which date you get. rfc850
needs
%Abecause%astops after three characters and then trips on thedof
Sunday; asctime works with%dbecauseInt.from-stringabsorbs thespace padding of a single-digit day, and two-digit days parse the same way.
HttpDate.formatconverts to UTC viaDatetime.to-utcand emits IMF-fixdate,which is the only format §5.6.7 permits a sender to generate.
Two things I did not want to decide silently
Two-digit years.
time's%yuses POSIX windowing —69–99→ 1969–1999,00–68→ 2000–2068. RFC 9110 instead says to read a two-digit year thatwould be more than fifty years in the future as the past century, which is a
sliding window rather than a fixed one. The two agree until roughly 2069, and
rfc850 dates are close to extinct in the wild, so I left
time's behaviouralone rather than reimplementing the rule here. Both edges are pinned by tests.
Should an unparseable cookie-date be ignored instead of rejected? RFC 6265
§5.2.1 says a recipient that cannot parse a cookie-date should ignore that
attribute — not reject the cookie, and certainly not the message. Today an
unparseable
Expiresstill failsparse-set, which fails the header, whichfails
Response.parse. Widening the accepted formats is what this PR ships;turning the hard error into a silent ignore is a behaviour change, so I have
left the error path exactly as it was. Want me to do that as a follow-up?
Tests
test/http.carpgoes 294/0 → 309/0. New coverage: each of the fourformats; the two-digit-year window at both edges; the asctime space-padded and
two-digit day; malformed and empty input still erroring, with the wrapped
parse-setmessage pinned;format→parseround-trip; an rfc850Expiresthrough
Cookie.parse-set; a UTC assertion on bothHttpDate.formatand thefull
Cookie.setstring; and the deletion header driven end to end throughResponse.parse.Mutation-tested, rather than assumed: dropping
Datetime.to-utcfromformatand truncating the ladder to its first pattern turns 309/0 into
299/10. The tests that still pass under that mutation are the ones
that should — IMF-fixdate parsing, the round-trip of a timezone-less
Datetime, and the two "still an error" tests.Follow-up, not part of this PR
webhand-rolls this twice:web-http-date(web.carp:310-336, a fullcivil-from-days calculation) formats, and
web-parse-http-date(
web.carp:389) parses IMF-fixdate by byte offset — and only IMF-fixdate, soit has defect 2 as well. Both could go once this lands.
httphas noCHANGELOG.md, so there is no changelog entry.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.