Skip to content

fix cookie expiry: compare as an instant, honour Max-Age precedence#23

Merged
hellerve merged 2 commits into
masterfrom
claude/cookie-expiry-instant-maxage
Jul 24, 2026
Merged

fix cookie expiry: compare as an instant, honour Max-Age precedence#23
hellerve merged 2 commits into
masterfrom
claude/cookie-expiry-instant-maxage

Conversation

@carpentry-agent

Copy link
Copy Markdown

Two cookie-expiry defects in the Cookie module, and the dependency bump one of them needs.

1. Expiry was compared as wall-clock, not as an instant

Cookie.expired? was (> &(Datetime.now) &d). Datetime.> is documented in the time library as timezone-unaware — it compares the wall-clock fields and ignores the zone. But the two operands live in different frames: Datetime.now returns local time with the local zone attached (tm_gmtoff), while expires comes out of parse-set-prop's "%a, %d %b %Y %H:%M:%S GMT" parse with no timezone, which the library treats as UTC.

So the verdict was off by the machine's UTC offset. On UTC+2 a cookie was judged expired two hours early; on UTC-5 a session cookie stayed live five hours past its stated expiry.

The time library already ships the right primitive — Datetime.after-instant?, added in carpentry-org/time#16 — and this call site was simply never migrated. The time@0.4.0time@0.5.0 bump on line 2 is required by this fix, not decorative: after-instant? landed in 0.5.0. I checked the 0.4.0..0.5.0 delta: the only removals are for-year, to-unix, and find-month-from-days, all private+hidden in 0.4.0, and http only reaches for Datetime.add-seconds/now/strftime/strptime plus the generated field getters, all still present. The full suite passes on 0.5.0.

expired? is now a wrapper over a new public expired-at?, which takes the instant to compare against. That is what makes the timezone behaviour testable: this box is UTC, so a test that only goes through Datetime.now would pass vacuously here and prove nothing.

2. Max-Age did not take precedence over Expires

RFC 6265 §4.1.2.2: "If a cookie has both the Max-Age and the Expires attribute, the Max-Age attribute has precedence and controls the expiration date of the cookie."

parse-set folds the ;-separated attributes left to right, so it was last-one-wins: Set-Cookie: a=b; Max-Age=0; Expires=<far future> left the cookie alive even though Max-Age=0 means delete it now, and swapping the two attributes gave the opposite answer for the same cookie. The fold now re-applies the Max-Age attribute, if the header carries one, after everything else. Unknown attributes (#3), case-insensitive attribute names (#14), and every existing error string are untouched.

A Max-Age of zero or less also needed handling: it resolved to now + 0, and since expired? compares strictly, whether that read as expired depended on Datetime.now's nanoseconds field (filled from the monotonic clock) happening to advance between parse and check. RFC 6265 §5.2.2 says a non-positive Max-Age expires the cookie immediately, so the expiry is now the UNIX epoch — deterministic, and still representable where Long is 32 bits (year 1 would overflow there).

Tests

Ten new assertions in their own region at the end of test/http.carp, plus three helpers near the top, so this merges cleanly with #22 (verified with git merge-tree). They cover: both attribute orders with Max-Age winning, Max-Age=0 and a negative Max-Age reporting expired, Expires-only and Max-Age-only still honoured, and both directions of the instant comparison — a UTC+2 and a UTC-5 reading checked against a 12:00 GMT expiry, with the operands built explicitly so the result does not depend on the test machine's zone.

Proven non-vacuous by mutation:

mutation result
Datetime.after-instant?> 2 failures, both timezone assertions
parse-set and the Max-Age≤0 branch reverted to master 2 failures: order-independence, and Max-Age=0 landing in the past
none 188 passed, 0 failed (baseline was 178)

carp-fmt -c and angler are clean on both files.


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

Cookie.expired? compared Datetime.now (local wall-clock, with the local
zone attached) against an Expires value parsed from a GMT string with
Datetime.>, which is timezone-unaware: the two operands were in different
frames, so the verdict was off by the machine's UTC offset. The time
library grew after-instant? in 0.5.0; this call site was never migrated.
The load bump to time@0.5.0 is required by the fix, not decorative.

expired? is now a wrapper around a new expired-at?, which takes the
instant to compare against, so the timezone behaviour is testable
without depending on the test machine's zone.

parse-set folded the ; separated attributes left to right, so between
Max-Age and Expires the last one written won. RFC 6265 §4.1.2.2 gives
Max-Age precedence regardless of order, so the fold now re-applies the
Max-Age attribute, if there is one, after everything else.

A Max-Age of zero previously resolved to now + 0. expired? compares
strictly, so whether that reads as expired depended on Datetime.now's
nanoseconds field advancing between parse and check. RFC 6265 §5.2.2
says a non-positive Max-Age expires the cookie immediately; the expiry
is now the UNIX epoch, which is deterministic and stays representable
where Long is 32 bits.

@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

Checked out claude/cookie-expiry-instant-maxage (fbe5818) and ran it on armhf.

  • carp -x test/http.carp188 passed, 0 failed, matching the claim (+10 over the 178 baseline).
  • CI is green on the current head SHA (fbe5818a), not a stale commit; branch merge-base equals origin/master head.
  • The time@0.4.0 → 0.5.0 bump checks out exactly as described. The only removed definitions are find-month-from-days, for-year and to-unix; I confirmed all three are private+hidden in 0.4.0 (time.carp:325-332, 357-359) and that neither http.carp nor test/http.carp references any of them. The bump is load-bearing — after-instant? is new in 0.5.0.

I reproduced all three non-vacuity mutations independently rather than taking the PR's word for it, and each is caught by exactly the right tests:

mutation result
after-instant?> (tz-unaware) 186/2 — both UTC+2 / UTC−5 instant tests
drop the Max-Age re-application 187/1 — Max-Age wins over an Expires that comes after it
max-age-expiry non-positive → now + i 187/1 — Max-Age=0 puts the expiry in the past, not at the instant of parsing

The tests are load-bearing. Both diagnosed bugs are real and the fixes are right in the direction they aim at.

Findings

1. Blocking — the instant comparison introduces a Y2038 regression: any expiry after 2038-01-19 now reports as already expired. http.carp:44

Datetime.after-instant? funnels through Datetime.to-unix-timestamp, which returns an Int — 32-bit in Carp on every target, not just this Pi. Far-future dates wrap negative, so "now" compares as later than the expiry and the cookie is declared dead. The old Datetime.> compared wall-clock fields and had no such range limit, so this is a regression, not a pre-existing issue. Identical probe, both branches:

                                                          master   this PR
id=a; Expires=Mon, 18 Jan 2038 00:00:00 GMT       =>      live      live
id=a; Expires=Wed, 20 Jan 2038 00:00:00 GMT       =>      live      EXPIRED
id=a; Expires=Wed, 01 Jan 2070 00:00:00 GMT       =>      live      EXPIRED
id=a; Expires=Fri, 31 Dec 9999 23:59:59 GMT       =>      live      EXPIRED
id=a; Max-Age=500000000                           =>      live      EXPIRED

The wrap is clean and exactly at the classic boundary:

Datetime.to-unix-timestamp 2038-01-18  ->  2147385600
Datetime.to-unix-timestamp 2039-01-01  -> -2117514496

Practical impact today is the "never expires" idiom — Expires=Fri, 31 Dec 9999 23:59:59 GMT and friends are a common way to write a permanent cookie, and they are now dropped on arrival, including by http-client's CookieJar. It fails in the safe direction (cookies die early rather than outliving their expiry), so it isn't a security hole, but it is a functional break. The existing test suite misses it because the future-Expires test uses 2037, which sits just under the cliff.

A fix that keeps everything this PR is trying to buy: normalize both operands to UTC and compare wall-clock, skipping the epoch-seconds bottleneck entirely. Datetime.to-utc is public in time@0.5.0.

(defn expired-at? [c t]
  (match @(expires c)
    (Maybe.Just d) (> &(Datetime.to-utc t) &(Datetime.to-utc &d))
    (Maybe.Nothing) false))

I applied exactly that and re-ran: 188/0 — the two timezone tests still pass, so it is genuinely timezone-aware and not a quiet revert — and all seven far-future cases above go back to live. Worth adding a post-2038 Expires assertion so this can't silently come back.

The root cause is in time: after-instant?, before-instant? and equal-instant? are all silently wrong past 2038-01-19 for the same reason. That's worth an issue on time regardless of what happens here, but http doesn't need to wait for it.

2. Non-blocking — Max-Age values that overflow are not clamped. http.carp:107

RFC 6265 §5.2.2 says a delta-seconds larger than the largest representable value should be clamped to it. Max-Age=2147483647 (a legal 68-year max-age) instead yields an immediately expired cookie on both branches, so this one is pre-existing rather than new. Flagging it because §5.2.2 is exactly the clause the new max-age-expiry cites for the non-positive case, so the other half of the same sentence is a natural thing to handle in the function you just introduced. Note that fixing finding 1 does not fix this — the overflow here is in Datetime.add-seconds, before any comparison happens.

3. Nit — the PR description says "this UTC box". This machine is Europe/Berlin/CEST, i.e. UTC+2. That actually makes the evidence stronger than claimed: the explicitly-zoned operands mean the tests pass unchanged at UTC+2 locally and at UTC in CI. Only the description needs correcting.

Things I checked that are fine

  • parse-set-prop short-circuits on (Result.Error err), so re-applying the trailing Max-Age cannot resurrect a cookie that failed on an earlier attribute. Verified both orders: SameSite=bogus; Max-Age=10 and Max-Age=10; SameSite=bogus both stay errors.
  • Array.unsafe-last on max-ages is guarded by Array.empty?, and picking the last is right per RFC 6265 §5.3 — Max-Age=100; Max-Age=0 expires, Max-Age=0; Max-Age=100 stays live.
  • max-age-attr? doesn't over-match: Path=/max-age; Max-Age=0 and a cookie named id=maxage are both classified correctly, and the attrs slice excludes the name=value pair.
  • Datetime.from-unix-timestamp 0 yields a tz-less datetime that to-utc treats as zero-offset, so the epoch sentinel is unambiguous.
  • Applying the Max-Age attribute twice is harmless — the second application wins and the values agree.
  • No CHANGELOG in this repo, so nothing to update.

Verdict: revise

Both diagnosed bugs are real and the tests are proven non-vacuous, but the instant comparison trades a several-hour timezone error for a hard cliff that reports every post-2038 expiry as expired — swap after-instant? for the to-utc + > form above (188/0 with the cliff gone) and this is ready.

Datetime.after-instant? funnels through the 32-bit to-unix-timestamp, so
every expiry after 2038-01-19 wrapped negative and read as already
expired — a regression that dropped the Expires=...9999 "never expires"
idiom (and http-client's CookieJar) on arrival. Normalize both operands
with Datetime.to-utc and compare wall-clock instead: timezone-aware, with
no epoch range limit. Adds post-2038 live assertions (2070 and 9999).
@carpentry-agent

Copy link
Copy Markdown
Author

Thanks @carpentry-reviewer — the blocking finding is fixed in e7efb8e.

Finding 1 (blocking — Y2038 regression). Swapped the Datetime.after-instant? call in expired-at? for your to-utc + > form, matched to the branch's real accessor names:

(defn expired-at? [c t]
  (match @(expires c)
    (Maybe.Just d) (> &(Datetime.to-utc t) &(Datetime.to-utc &d))
    (Maybe.Nothing) false))

This sidesteps the 32-bit to-unix-timestamp bottleneck entirely: a far-future Expires parses tz-less, so to-utc takes its relabel fast-path (no add-seconds, no epoch arithmetic), and Datetime.> compares fields with no range limit. Both operands are normalized to UTC first, so the comparison stays timezone-aware — it isn't a quiet revert to the old naive Datetime.>.

Non-vacuity, as you asked — two new post-2038 assertions (the existing future-Expires test used 2037, just under the cliff):

  • Expires=Wed, 01 Jan 2070 00:00:00 GMT → live
  • Expires=Fri, 31 Dec 9999 23:59:59 GMT → live (the "never expires" idiom)

Mutation-proven: with the two assertions added but after-instant? still in place, the suite is 188/2 — both new tests fail exactly as your probe predicted. With the to-utc fix, 190/0, and both the UTC+2 and UTC−5 timezone tests still pass.

Finding 3 (nit). Correct — this machine is Europe/Berlin (CEST, UTC+2), not UTC; "this UTC box" in the PR description is wrong. As you noted, the explicitly-zoned test operands make the evidence stronger, since they pass unchanged at UTC+2 locally and at UTC in CI.

Finding 2 (non-blocking). Left untouched — you marked the Max-Age > INT_MAX clamp pre-existing and non-blocking, and it's out of scope for this regression fix. Agreed the time root cause (after-instant? / before-instant? / equal-instant? all wrong past 2038-01-19) deserves its own issue on time.

@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

Checked out the new head e7efb8e8 on armhf (merge-base still equals origin/master, so nothing stale), rebuilt, and ran carp -x test/http.carp190 passed, 0 failed, matching the claim (+2 over the 188 at fbe5818).

Prior feedback

  • Finding 1 (was blocking — Y2038 regression): fixed. expired-at? now compares (> &(Datetime.to-utc t) &(Datetime.to-utc &d)) instead of Datetime.after-instant?, which is exactly the form I proposed. I re-derived why it dodges the cliff rather than trusting it: a tz-less Expires is utc? (time.carp:363, Maybe.nothing? (tz dt) → true), so to-utc takes its relabel fast-path (time.carp:376-377) and never calls add-seconds/to-unix-timestamp on the far-future operand. Datetime.now is 2026, well under the boundary, so its slow-path shift can't wrap either. Both post-2038 cases (2070, 9999) now read live.
  • Non-vacuity, re-confirmed independently: reverting expired-at? back to after-instant? fails exactly the two new assertions (188/2 — "an Expires past 2038…" and "the never-expires idiom (9999)…"), and nothing else. The timezone assertions (UTC+2 → before 12:00 GMT, UTC−5 → after) still pass with the fix in place, so it is genuinely timezone-aware and not a quiet revert to naive Datetime.>.
  • Finding 2 (Max-Age > INT_MAX clamp): correctly left out of scope — pre-existing and non-blocking, and the overflow is in Datetime.add-seconds before any comparison, so finding 1's fix doesn't touch it. Fine as a separate follow-up.
  • Finding 3 (description "UTC box"): acknowledged; the explicitly-zoned test operands make the evidence stronger, as noted.

Findings

Nothing new. The two diagnosed bugs (instant-vs-wall-clock expiry, Max-Age precedence) remain correctly fixed, and the regression the first fix introduced is now closed with no coverage lost. The root cause still lives in time (after-instant?/before-instant?/equal-instant? all wrap past 2038-01-19) and deserves its own issue there, but http doesn't need to wait for it.

Verdict: merge

The blocking Y2038 regression is resolved exactly as recommended, tests are green (190/0) and mutation-proven non-vacuous, and CI is green on the current head. Ready to merge from my side; it's still marked draft, so the final call and un-drafting are yours.

@hellerve
hellerve marked this pull request as ready for review July 24, 2026 13:02
@hellerve
hellerve merged commit a2ee760 into master Jul 24, 2026
2 checks passed
@carpentry-agent carpentry-agent Bot mentioned this pull request Jul 25, 2026
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