Skip to content

Commit 2297b7c

Browse files
committed
feat(net-http-headers): adopt .NET's sixteen lenient HTTP-date formats (#2360)
#2130 adopted RFC 9110 5.6.7's three REQUIRED forms and recorded the rest as "leniency, not required forms ... recorded as ticket #2360 rather than smuggled in here". This is that ticket. .NET's HttpDateParser tries strict "r" and then twenty-one format strings (Common/src/System/Net/HttpDateParser.cs:9-32), and a recipient that rejects what .NET accepts will drop responses .NET's own clients read. TWENTY-ONE FORMAT STRINGS ARE NOT TWENTY-ONE GRAMMARS. They are three shapes crossed with three axes, and transcribing the CROSS rather than the list is what makes the gaps visible: shape day-of-week date year zone RFC 1123 "Ddd," or absent d MMM yyyy 4 or 2 GMT | UTC | zzz | absent RFC 850 "Dddddd," only d-MMM-yy 2 only GMT | UTC | zzz | absent asctime "Ddd" no comma MMM d ... yyyy 4 only absent only TWO CELLS OF THAT CROSS ARE MISSING FROM .NET'S LIST, and are therefore rejected: a two-digit year combined with a numeric offset, with and without a day-of-week. Neither "ddd, d MMM yy H:m:s zzz" nor "d MMM yy H:m:s zzz" appears anywhere in the list. Writing the parser as a cross would have added them silently. "The obvious completion of the pattern" is exactly the widening that has no reference behind it, so both are excluded explicitly and pinned. The zone token is .NET's zzz, transcribed from ParseTimeZoneOffset (DateTimeParse.cs:3285-3345): a sign, one or two hour digits, then an OPTIONAL ':' before two minute digits -- so "-05:00" and "-0500" are both accepted and mean the same thing -- with the minute field rejected at 60. A missing zone means UTC, which is DateTimeStyles.AssumeUniversal rather than an assumption of this port's. Inner whitespace is allowed, which is AllowInnerWhite. THE WIDENING IS SAFE BY CONSTRUCTION, not by inspection: the lenient arm runs AFTER the three strict arms, so any value they accept never reaches it. Measured on top of that, reordering the chain so the lenient arm runs first changes no test result, because the two agree on everything both accept -- recorded as an EQUIVALENCE rather than counted as a caught mutation. A CORRECTION THE FIRST TESTS NEEDED. Retry-After dispatches on the first character: a digit means delta-seconds and the whole value must then be digits, so a date with no day-of-week can never reach its date branch. That is not a defect -- .NET does exactly the same and says so ("We either have a timespan or a date/time value. Determine which one we have by looking at the first char", RetryConditionHeaderValue.cs:94-98) -- so If-Range is the door the tests use to exercise the whole grammar, and Retry-After's dispatch is pinned as its own row. TWO PRE-EXISTING LENIENCIES SURFACED BY THESE TESTS ARE SPLIT OUT AS #2376, NOT BUNDLED. The three strict arms are sscanf conversions and sscanf's %d and %[A-Za-z] do not bound a field width, so the port accepts an ABBREVIATED weekday on the hyphenated RFC 850 shape (.NET spells it dddd) and a THREE-digit year on IMF-fixdate (.NET spells it yyyy, exactly four -- read literally as the year 199). Both repairs are NARROWINGS, and #2005 recorded that bundling a narrowing into a verified widening is the thing to avoid. Pinned meanwhile. Eight mutations caught. Two of them only after a stronger test, and both for the same reason: a strict arm already accepted the input and answered before the lenient one could be wrong about it, so the probe had to be routed through a zone token that arm declines. Downstream, measured per SA-2 condition 5: neither cna nor mobile-eggbert references either header type -- zero sites in both. Neither was modified. Gate: 17,311 run, 17,311 passed, 0 failed, 0 skipped across 38 executables, GREEN. docs/Migration-HttpDateLenientFormats.md
1 parent cb54b2b commit 2297b7c

5 files changed

Lines changed: 509 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — the HTTP-date parser accepts .NET's sixteen lenient formats (ticket #2360)
5+
6+
*2026-08-18.* Every header that carries an HTTP-date now accepts the same text .NET accepts. #2130
7+
adopted RFC 9110 §5.6.7's three **required** forms; this adds the sixteen further formats
8+
`HttpDateParser` tries beyond them.
9+
10+
**Pure widening.** No value that parsed before parses differently or fails now — guaranteed by
11+
construction, see §5. Landed under `docs/StandingApprovals.md` SA-5. No signature, layout or
12+
`noexcept` change.
13+
14+
---
15+
16+
## 1. What is newly accepted
17+
18+
All of these now parse to 1994-11-06T08:49:37Z:
19+
20+
| Value | `HttpDateParser.cs` |
21+
|---|---|
22+
| `Sun, 06 Nov 1994 08:49:37 UTC` | `:12` |
23+
| `Sun, 06 Nov 1994 08:49:37` | `:13` — no zone |
24+
| `06 Nov 1994 08:49:37 GMT` | `:14` — no day-of-week |
25+
| `06 Nov 1994 08:49:37 UTC` | `:15` |
26+
| `06 Nov 1994 08:49:37` | `:16` |
27+
| `Sun, 06 Nov 94 08:49:37 UTC` | `:18` |
28+
| `Sun, 06 Nov 94 08:49:37` | `:19` |
29+
| `06 Nov 94 08:49:37 GMT` | `:20` |
30+
| `06 Nov 94 08:49:37 UTC` | `:21` |
31+
| `06 Nov 94 08:49:37` | `:22` |
32+
| `Sunday, 06-Nov-94 08:49:37 UTC` | `:23` |
33+
| `Sunday, 06-Nov-94 08:49:37 +00:00` | `:24` |
34+
| `Sunday, 06-Nov-94 08:49:37` | `:25` |
35+
| `Sun, 06 Nov 1994 08:49:37 +00:00` | `:28` |
36+
| `06 Nov 1994 08:49:37 +00:00` | `:30` |
37+
| plus inner whitespace anywhere | `DateTimeStyles.AllowInnerWhite` |
38+
39+
A numeric offset is **applied**, not ignored: `… 08:49:37 -05:00` is 13:49:37 UTC. A missing zone
40+
is read as UTC, which is `DateTimeStyles.AssumeUniversal` rather than an assumption of this port's.
41+
42+
## 2. Twenty-one format strings are not twenty-one grammars
43+
44+
They are three shapes crossed with three axes, and transcribing the **cross** rather than the list
45+
is what makes the gaps visible:
46+
47+
| shape | day-of-week | date | year | zone |
48+
|---|---|---|---|---|
49+
| RFC 1123 / 5322 | `Ddd,` or absent | `d MMM yyyy` | 4 or 2 | `GMT` \| `UTC` \| `zzz` \| absent |
50+
| RFC 850 | `Dddddd,` only | `d-MMM-yy` | 2 only | `GMT` \| `UTC` \| `zzz` \| absent |
51+
| asctime | `Ddd` no comma | `MMM d … yyyy` | 4 only | absent only |
52+
53+
**Two cells of that cross are missing from .NET's list**, and are therefore rejected here: a
54+
two-digit year combined with a numeric offset, with and without a day-of-week. Neither
55+
`ddd, d MMM yy H:m:s zzz` nor `d MMM yy H:m:s zzz` appears between `HttpDateParser.cs:9` and `:32`.
56+
57+
Writing the parser as a cross would have added those two silently. *The obvious completion of the
58+
pattern* is exactly the widening that has no reference behind it, so they are excluded explicitly
59+
and pinned.
60+
61+
## 3. The zone token
62+
63+
From `zzz`, whose parser is `ParseTimeZoneOffset` (`DateTimeParse.cs:3285-3345`): a sign, one or
64+
two hour digits, then an **optional** `':'` before two minute digits. So `-05:00` and `-0500` are
65+
both accepted and mean the same thing, and a minute field of 60 or more is rejected
66+
(`DateTimeParse.cs:3334`). Only `GMT` and `UTC` are named zones; `EST` is not in .NET's list and
67+
is still refused.
68+
69+
## 4. Which door sees which forms — a correction
70+
71+
`Retry-After` dispatches on the **first character**: a digit means delta-seconds, and the whole
72+
value must then be digits. So `Retry-After: 06 Nov 1994 08:49:37 GMT` is still rejected there.
73+
74+
That is not a defect, and my first cut of the tests assumed it was. .NET does exactly the same and
75+
says so: *"We either have a timespan or a date/time value. Determine which one we have by looking
76+
at the first char. If it is a number, we have a timespan, otherwise we assume we have a date."*
77+
(`RetryConditionHeaderValue.cs:94-98`). `If-Range` has no such ambiguity, so it is the door the
78+
tests use to exercise the whole grammar.
79+
80+
## 5. Why this cannot break an existing value
81+
82+
The lenient arm runs **after** the three strict arms, so any value they accept never reaches it.
83+
The widening is safe by construction rather than by inspection.
84+
85+
Measured on top of that: reordering the chain so the lenient arm runs *first* changes no test
86+
result, because the two agree on everything both accept. The ordering is a defensive property, and
87+
that mutation demonstrates it is not load-bearing — recorded as an equivalence rather than counted
88+
as a caught mutation.
89+
90+
## 6. Two pre-existing leniencies this ticket surfaced and did **not** repair
91+
92+
The three strict arms are `sscanf` conversions, and `sscanf`'s `%d` and `%[A-Za-z]` do not bound a
93+
field width. So the port accepts two shapes .NET rejects:
94+
95+
* an **abbreviated** weekday on the hyphenated RFC 850 shape — .NET spells it `dddd`, full names
96+
only, so `Sun, 06-Nov-94 08:49:37 GMT` is rejected there and accepted here;
97+
* a **three-digit** year on the IMF-fixdate shape — .NET spells it `yyyy`, exactly four, so
98+
`Sun, 06 Nov 199 08:49:37 GMT` is accepted here and read literally as the year 199.
99+
100+
Both are **narrowings**, and #2360 is a widening. #2005 recorded that bundling a narrowing into a
101+
verified widening is the thing to avoid, so they are ticket **#2376** and are pinned by
102+
`Pin2376_TheStrictArmsAreWiderThanTheirFormatStrings`. The new lenient arm already enforces both
103+
bounds; only the three strict arms are affected.
104+
105+
## 7. Evidence
106+
107+
| Mutation | Caught |
108+
|---|---|
109+
| Complete the cross — allow a two-digit year with a numeric offset ||
110+
| Let the hyphenated shape take an abbreviated weekday | ✅ — **only after** probing through a zone the strict arm declines |
111+
| Let the space shape take a full weekday name ||
112+
| Accept any year width | ✅ — same, only after the strict arm was routed around |
113+
| A missing zone is not read as UTC ||
114+
| Drop the offset minute `< 60` check ||
115+
| Require the `':'` before the offset minutes ||
116+
| Accept any three-letter zone token, not only `GMT`/`UTC` | ✅ (2 tests) |
117+
| Run the lenient arm first | **equivalent — see §5** |
118+
119+
Two of the eight needed a stronger test, and both for the same reason: a strict arm already
120+
accepted the input and answered before the lenient one could be wrong about it.
121+
122+
## 8. Downstream, measured
123+
124+
Per SA-2 condition 5: neither `cna` nor `mobile-eggbert` references `RetryConditionHeaderValue`,
125+
`RangeConditionHeaderValue`, `Retry-After` or `If-Range`**zero sites in both**. Neither
126+
repository was modified. A widening could not change their answers in any case.

modules/net-http-headers/src/System/Net/Http/Headers/HttpDateParser.hpp

Lines changed: 250 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,253 @@ namespace System::Net::Http::Headers::detail {
108108
return true;
109109
}
110110

111+
112+
// -----------------------------------------------------------------------------------------
113+
// Ticket #2360 (2026-08-18) -- the sixteen LENIENT formats.
114+
//
115+
// #2130 adopted RFC 9110 5.6.7's three required forms and recorded the rest as "leniency,
116+
// not required forms ... recorded as ticket #2360 rather than smuggled in here". This is that
117+
// ticket. .NET's HttpDateParser tries strict "r" and then TWENTY-ONE format strings
118+
// (Common/src/System/Net/HttpDateParser.cs:9-32) with
119+
// DateTimeStyles.AllowInnerWhite | AssumeUniversal, and a recipient that rejects what .NET
120+
// accepts will drop responses .NET's own clients read.
121+
//
122+
// Twenty-one format strings are not twenty-one grammars. They are three shapes crossed with
123+
// three axes, and transcribing the CROSS rather than the list is what makes the gaps visible:
124+
//
125+
// shape day-of-week date year zone
126+
// ------- --------------- ------------ ----- ------------------------
127+
// RFC 1123 "Ddd," or absent d MMM yyyy 4 or 2 GMT | UTC | zzz | absent
128+
// RFC 850 "Dddddd," only d-MMM-yy 2 only GMT | UTC | zzz | absent
129+
// asctime "Ddd" no comma MMM d ... yyyy 4 only absent only
130+
//
131+
// TWO CELLS OF THAT CROSS ARE MISSING FROM .NET'S LIST and are therefore rejected here: a
132+
// two-digit year combined with a numeric offset, with or without a day-of-week. Writing the
133+
// table as a cross would have silently ADDED those two; they are excluded explicitly and
134+
// pinned, because "the obvious completion of the pattern" is exactly the kind of widening
135+
// that has no reference behind it.
136+
//
137+
// The zone token comes from .NET's `zzz` specifier, whose parser is ParseTimeZoneOffset
138+
// (DateTimeParse.cs:3285-3345): a sign, one or two hour digits, then an OPTIONAL ':' before
139+
// two minute digits -- so "-05:00" and "-0500" are both accepted, and the minute field is
140+
// rejected at 60. A missing zone means UTC, which is DateTimeStyles.AssumeUniversal rather
141+
// than an assumption of this port's.
142+
//
143+
// This runs AFTER the three strict arms above, and that ordering is the safety property: any
144+
// value they accept never reaches here, so no already-parsing input can change its answer.
145+
// It is a pure widening.
146+
// -----------------------------------------------------------------------------------------
147+
148+
/** @brief The seven full weekday names, for the RFC 850 shape's `dddd`. */
149+
inline bool IsFullWeekdayName(const std::string& name) {
150+
static constexpr std::array<const char*, 7> days = {
151+
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
152+
for (const char* d : days) {
153+
if (name == d) return true;
154+
}
155+
return false;
156+
}
157+
158+
/** @brief A forward-only cursor, deliberately not `sscanf`: `%d` accepts a sign and `+3` is not a day. */
159+
class HttpDateCursor {
160+
public:
161+
explicit HttpDateCursor(const std::string& text) noexcept : text_(text) {}
162+
163+
[[nodiscard]] bool atEnd() const noexcept { return index_ >= text_.size(); }
164+
[[nodiscard]] std::size_t position() const noexcept { return index_; }
165+
void seek(std::size_t position) noexcept { index_ = position; }
166+
167+
/** @brief Consumes any run of whitespace; `DateTimeStyles.AllowInnerWhite`. */
168+
void skipWhite() noexcept {
169+
while (index_ < text_.size() &&
170+
std::isspace(static_cast<unsigned char>(text_[index_])) != 0)
171+
++index_;
172+
}
173+
174+
bool take(char c) noexcept {
175+
if (index_ < text_.size() && text_[index_] == c) { ++index_; return true; }
176+
return false;
177+
}
178+
179+
/** @brief Consumes between @p minDigits and @p maxDigits ASCII digits, reporting the width. */
180+
bool takeDigits(int minDigits, int maxDigits, int& value, int* width = nullptr) noexcept {
181+
const std::size_t start = index_;
182+
int accumulated = 0, count = 0;
183+
while (index_ < text_.size() && count < maxDigits &&
184+
text_[index_] >= '0' && text_[index_] <= '9') {
185+
accumulated = accumulated * 10 + (text_[index_] - '0');
186+
++index_; ++count;
187+
}
188+
if (count < minDigits) { index_ = start; return false; }
189+
value = accumulated;
190+
if (width != nullptr) *width = count;
191+
return true;
192+
}
193+
194+
/** @brief Consumes a run of ASCII letters, at most @p maxLetters of them. */
195+
bool takeLetters(std::size_t maxLetters, std::string& out) {
196+
const std::size_t start = index_;
197+
while (index_ < text_.size() &&
198+
std::isalpha(static_cast<unsigned char>(text_[index_])) != 0 &&
199+
index_ - start < maxLetters)
200+
++index_;
201+
if (index_ == start) return false;
202+
out.assign(text_, start, index_ - start);
203+
return true;
204+
}
205+
206+
private:
207+
const std::string& text_;
208+
std::size_t index_ = 0;
209+
};
210+
211+
/** @brief The zone axis, as .NET's four possibilities. */
212+
enum class HttpDateZone { Gmt, Utc, NumericOffset, Absent };
213+
214+
/**
215+
* @brief Consumes an optional zone token, transcribing .NET's `zzz` where one is numeric.
216+
*
217+
* @param cursor Positioned after the seconds field.
218+
* @param kind Receives which of the four possibilities was found.
219+
* @param offset Receives the offset; `TimeSpan::Zero` for `GMT`, `UTC` and absent, the latter
220+
* being `DateTimeStyles.AssumeUniversal` rather than a guess.
221+
* @return @c false only for text that starts a zone token and then fails to be one.
222+
*/
223+
inline bool TakeHttpDateZone(HttpDateCursor& cursor, HttpDateZone& kind,
224+
System::TimeSpan& offset) {
225+
kind = HttpDateZone::Absent;
226+
offset = System::TimeSpan::Zero;
227+
228+
const std::size_t beforeWhite = cursor.position();
229+
cursor.skipWhite();
230+
if (cursor.atEnd()) { cursor.seek(beforeWhite); return true; }
231+
232+
const std::size_t start = cursor.position();
233+
std::string token;
234+
if (cursor.takeLetters(3, token)) {
235+
if (token == "GMT") { kind = HttpDateZone::Gmt; return true; }
236+
if (token == "UTC") { kind = HttpDateZone::Utc; return true; }
237+
cursor.seek(start);
238+
return false;
239+
}
240+
241+
bool negative = false;
242+
if (cursor.take('-')) {
243+
negative = true;
244+
} else if (!cursor.take('+')) {
245+
cursor.seek(beforeWhite);
246+
return true; // not a zone token at all; the trailing-text check will judge it
247+
}
248+
249+
int hours = 0, minutes = 0;
250+
if (!cursor.takeDigits(1, 2, hours)) return false;
251+
(void)cursor.take(':'); // ':' is optional -- DateTimeParse.cs:3315
252+
if (!cursor.takeDigits(2, 2, minutes)) return false;
253+
if (minutes >= 60) return false; // DateTimeParse.cs:3334
254+
255+
kind = HttpDateZone::NumericOffset;
256+
offset = System::TimeSpan::FromMinutes(negative ? -(hours * 60 + minutes)
257+
: (hours * 60 + minutes));
258+
return true;
259+
}
260+
261+
/**
262+
* @brief Parses the sixteen lenient forms .NET accepts beyond RFC 9110's three.
263+
*
264+
* Runs only after the three strict arms have declined, so it can never change an answer an
265+
* already-accepted value had.
266+
*/
267+
inline bool TryParseLenientHttpDate(const std::string& s, System::DateTimeOffset& result) {
268+
HttpDateCursor cursor(s);
269+
cursor.skipWhite();
270+
271+
// The day-of-week is optional, and WHICH forms may carry which spelling depends on the
272+
// date separator, which has not been seen yet. So it is captured now and judged below.
273+
std::string weekday;
274+
const std::size_t beforeWeekday = cursor.position();
275+
bool haveWeekday = false;
276+
if (cursor.takeLetters(9, weekday)) {
277+
cursor.skipWhite();
278+
if (cursor.take(',')) {
279+
haveWeekday = true;
280+
} else {
281+
cursor.seek(beforeWeekday); // an asctime-shaped value, or no weekday at all
282+
weekday.clear();
283+
}
284+
}
285+
cursor.skipWhite();
286+
287+
int day = 0;
288+
if (!cursor.takeDigits(1, 2, day)) return false;
289+
290+
// The separator picks the shape: '-' is RFC 850, whitespace is RFC 1123 / RFC 5322.
291+
bool hyphenated = false;
292+
if (cursor.take('-')) {
293+
hyphenated = true;
294+
} else {
295+
const std::size_t beforeSpace = cursor.position();
296+
cursor.skipWhite();
297+
if (cursor.position() == beforeSpace) return false;
298+
}
299+
300+
std::string monthName;
301+
if (!cursor.takeLetters(3, monthName)) return false;
302+
const int monthIndex = MonthIndexFromName(monthName.c_str());
303+
if (monthIndex < 0) return false;
304+
305+
if (hyphenated) {
306+
if (!cursor.take('-')) return false;
307+
} else {
308+
const std::size_t beforeSpace = cursor.position();
309+
cursor.skipWhite();
310+
if (cursor.position() == beforeSpace) return false;
311+
}
312+
313+
int year = 0, yearWidth = 0;
314+
if (!cursor.takeDigits(1, 4, year, &yearWidth)) return false;
315+
if (yearWidth != 2 && yearWidth != 4) return false; // .NET's yy and yyyy are exact
316+
317+
const std::size_t beforeTimeSpace = cursor.position();
318+
cursor.skipWhite();
319+
if (cursor.position() == beforeTimeSpace) return false;
320+
321+
int hour = 0, minute = 0, second = 0;
322+
if (!cursor.takeDigits(1, 2, hour) || !cursor.take(':') ||
323+
!cursor.takeDigits(1, 2, minute) || !cursor.take(':') ||
324+
!cursor.takeDigits(1, 2, second))
325+
return false;
326+
327+
HttpDateZone zone{};
328+
System::TimeSpan offset = System::TimeSpan::Zero;
329+
if (!TakeHttpDateZone(cursor, zone, offset)) return false;
330+
331+
cursor.skipWhite();
332+
if (!cursor.atEnd()) return false; // trailing text, which #2125 made a failure
333+
334+
// Now judge the combination against .NET's twenty-one, rather than against the cross.
335+
if (hyphenated) {
336+
// RFC 850: the weekday is REQUIRED and must be a full name; the year is two digits.
337+
if (!haveWeekday || !IsFullWeekdayName(weekday)) return false;
338+
if (yearWidth != 2) return false;
339+
} else {
340+
// RFC 1123 / RFC 5322: the weekday is optional and must be a three-letter name.
341+
if (haveWeekday && weekday.size() != 3) return false;
342+
// THE TWO MISSING CELLS. A two-digit year with a numeric offset is not in .NET's
343+
// list -- neither "ddd, d MMM yy H:m:s zzz" nor "d MMM yy H:m:s zzz" appears -- so
344+
// it is rejected rather than completed by symmetry.
345+
if (yearWidth == 2 && zone == HttpDateZone::NumericOffset) return false;
346+
}
347+
348+
if (yearWidth == 2) year = ExpandTwoDigitYear(year);
349+
350+
try {
351+
result = System::DateTimeOffset(year, monthIndex + 1, day, hour, minute, second, offset);
352+
return true;
353+
} catch (...) {
354+
return false;
355+
}
356+
}
357+
111358
/**
112359
* @brief Parses one HTTP-date in any of RFC 9110 §5.6.7's three forms, consuming the whole
113360
* value.
@@ -216,7 +463,9 @@ namespace System::Net::Http::Headers::detail {
216463
result);
217464
}
218465

219-
return false;
466+
// 4. #2360 -- the sixteen lenient forms, tried last so the three strict arms above keep
467+
// every answer they already gave.
468+
return TryParseLenientHttpDate(s, result);
220469
}
221470

222471
} // namespace System::Net::Http::Headers::detail

0 commit comments

Comments
 (0)