Byte-index the strptime scanner so non-ASCII input cannot abort - #23
Conversation
strptime drives its scanner from byte offsets (String.char-at reads a byte, String.length is strlen) but sliced with String.suffix/String.prefix, which count characters. On any input where those disagree the character slice runs off the end of the decoded array and Array.unsafe-nth aborts the process — reachable from server-controlled data, since http-client feeds a Set-Cookie Expires value through a date parse. Every confused site now slices with String.byte-slice, so a byte offset meets a byte-indexed slice, and expand-compound-formats copies format bytes through verbatim instead of re-encoding each one as a codepoint via Char.str. Valid ASCII input is byte-identical; hostile input now takes the existing error paths.
There was a problem hiding this comment.
Build & Tests
Checked out claude/strptime-byte-indexed at f15591c and built it here.
carp -x test/time.carp— 282 passed, 0 failed, exit 0. Matches the claim.- CI green on both
ubuntu-latestandmacos-latest; the run'shead_shaisf15591c, so it covers this HEAD and not an older push.ci.ymlhas nocontinue-on-errorand every step runs underset -euo pipefail, so the green is real for the test, lint, format-check and gendocs steps. Note the test step builds with-Werror. - Merge-base is the current
origin/master(bd30971) — no drift. One commit, bot-authored; no human has pushed to this branch. anglerandcarp-fmt --checkover CI's file set — both clean, rc=0. I checkedangleragainst a bait file first ((let [x 1] 2)→unused-let-binding, rc=1), so the clean result is evidence rather than a null run.carp -x gendocs.carpleavesgit statusempty — committed docs are current. (CI runs gendocs but doesn't diff it, so that one is worth checking by hand; it passes.)
The abort reproduces on master, and it is not narrow. I wrote my own probe dispatching on argv[1] so each case gets a fresh process, and ran 15 hostile inputs on both sides:
master: 13 aborts (SIGABRT, rc=134) branch: 0
Including the PR's stated repro (%d %b %Y with six 0x80 bytes), and also %c, %r, %z, %j, %I:%M %p and a 4-byte emoji against %A %d %B %Y.
The improvement is larger than the PR body claims. The body frames the non-crash half as better error text. It is more than that: on master a format string containing any non-ASCII literal could not parse at all. Same fixture, one case per process, master vs branch:
| format | input | master |
branch |
|---|---|---|---|
%Y年%m月%d日 |
2024年03月15日 |
unexpected character at position 4 |
2024-03-15 |
Größe: %Y |
Größe: 2024 |
unexpected character at position 3 |
2024 |
%d.%m.%Y — %H:%M |
15.03.2024 — 14:30 |
unexpected character at position 11 |
2024-03-15 14:30 |
😀%Y |
😀2024 |
unexpected character at position 0 |
2024 |
€%B %d %Y |
€March 15 2024 |
unexpected character at position 0 |
2024-03-15 |
14 of 14 such formats went from total failure to a correct parse. A German or Japanese date format is not an exotic input, so this is a capability that did not previously exist rather than a cosmetic fix.
The "load-bearing" claim about %Z and %p holds, and I checked it the way it could have failed. Every specifier that consumes input at ipos > 0 behind multibyte bytes now works: %p (é %p …), %Z (é %Z … → Timezone "UTC", 日%Z → "CEST"), %b/%B via match-short-in-array/match-in-array (€%b, €%B), and %z (ü%z → 19800). ASCII controls for each (x %Z …, x%b …) are byte-identical on both sides, so the change is isolated to the multibyte case.
Nothing regressed. Across 91 cases — 20 valid ASCII formats covering every specifier and all nine compound expansions, 7 non-ASCII literal formats, 7 non-ASCII inputs, 16 degenerate formats ("", "%", "%%", "%Y%", "%Q", "hello"), 21 hostile byte sequences and 3 round-trips — master and branch differ on exactly two surviving cases, both the unknown-specifier error text, and both strictly better:
| format | master |
branch |
|---|---|---|
"%<0xC3><0xA9>" |
% + [195 131] (Char.str re-encoding) |
% + [195] — the actual byte |
"%<0x80>" |
% + [195 130] |
% + [128] |
Teeth: 13/13, confirmed independently. Reverting time.carp alone to master and running the branch's tests aborts at Array_unsafe_nth__Char: Assertion 'n < a.len' failed, exit 134 — so the 9 Result.error? assertions are genuine crash guards, and the abort masks the rest exactly as the PR says. I scored the 4 positive assertions against my own master measurements rather than re-running them one process at a time: «%Y-%m-%d», é %Z, é%I%p all fail on master with unexpected character, and %é yields [195 131] instead of the asserted [195]. All four fail on master by wrong result.
Every byte-slice call checked against the guard that dominates it. String.byte-slice is memcpy(ptr, *s + a, b - a) with no bounds check (core/carp_string.h:378), so this matters. All seven hold, and and short-circuiting makes the guard-then-slice pairs sound: parse-digits under (<= (+ pos n) (String.length s)); match-in-array as the second conjunct after (<= (+ pos clen) slen); match-short-in-array under the (> (+ pos 3) …) early return, with byte-slice candidate 0 3 behind (>= (String.length candidate) 3); %p under (> (+ ipos 2) ilen); %Z after a loop that only advances while (< (+ ipos n) ilen); both expand-compound-formats slices under (< (+ i 1) len) / (< i len); and the unknown-specifier slice under the (< (+ fpos 1) flen) that gates the whole % arm. ipos and fpos are monotonic and never exceed their lengths, so no negative-length memcpy is reachable.
I also checked the advance arithmetic, since a byte/char mismatch there would reintroduce the bug one level up: %B/%A advance by String.length of the matched candidate, which is a byte count and is exactly what match-in-array compared; %b/%a advance by 3 under a guard that already proved ipos + 3 <= ilen. Consistent.
ASan. carp -b, then hand-compiled ~/.carp/out/main.c with clang -fsanitize=address -I ~/carp-lang/core, all 91 cases in one process: 0 findings, rc=0. Positive control: a 64-byte over-read injected into parse-digits' new byte-slice fires
ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 68
#1 String_byte_MINUS_slice /home/hellerve/carp-lang/core/carp_string.h:381
so the sanitizer is provably live on this exact code path and the clean run means something.
Independence from #22 verified by building the merge, not just by git merge-tree. merge-tree --write-tree returns rc=0, but a clean textual merge of two PRs that both touch time.carp isn't evidence that the merged code works — #23 changes expand-compound-formats, which strftime also drives. So I materialized the merged tree with git archive and ran it:
merged (#22 + #23): 299 passed, 0 failed, exit 0
299 = 269 (master) + 17 (#22) + 13 (#23), so no assertion was lost or double-counted, and both fixes are present in the merged source. The two can merge in either order.
Findings
None. I looked specifically for a wrong success — an input master rejected that the branch now accepts with a bad value — and found none; every one of the 14 newly-parsing formats yields the correct fields. I also found no problem with: the byte-by-byte literal matcher (fc and char-at input ipos both advance one byte, so a multibyte literal matches correctly and mismatches at the right offset); %% and trailing-% handling through expand-compound-formats (a trailing lone % survives expansion and then matches a literal %, consistent with #22's decision on the format side); %Z only ever capturing [A-Za-z], so tz-nm stays ASCII; and Int.from-string, which is strtol with *err == 0, so a slice with any trailing non-digit is rejected rather than silently truncated.
Two things I turned up are pre-existing on master and not this PR's — recording them so they read as seen, not missed, and so nobody scores them against this branch:
%pwithout%H/%Iinvents an hour.hruses-1as its "unset" sentinel, but line 1093 applies the PM adjustment before that is checked, so(strptime "PM 2024-03-15" "%p %Y-%m-%d")returnshours = (Just 11)—-1 + 12. Identical on master and branch;%I %pis correct.parse-digitsinheritsstrtol's leniency, so%don-1/03/2024yields day-1and a leading space is accepted. Identical on both sides.
Neither is touched by this change and neither should hold it.
Verdict: merge
The premise is real and I reproduced it independently — 13 aborts on master, 0 here — and the fix is the right one: every byte offset now meets a byte-indexed slice, every unchecked memcpy sits under a guard I verified, and ASan is clean over 91 cases with a live positive control firing inside String_byte_slice itself. Behaviour is unchanged everywhere except two error strings that both get more accurate, the 13 new assertions all genuinely fail on master, and the merge with #22 builds and runs 299/0. The one thing I'd change is the framing rather than the code: this PR fixes more than it claims — a format string with a non-ASCII literal was previously unusable, and now works.
Datetime.strptimedrives its scanner from byte offsets —String.char-atreads a single byte andString.lengthisstrlen— but then sliced withString.suffix/String.prefix, which count characters. Wherever those two disagree the character slice runs off the end of the decoded array andArray.unsafe-nthaborts the process.On unmodified
master:This is reachable from server-controlled input:
http-clientfeeds aSet-Cookie: ...; Expires=<value>through a date parse.This is the parse-side half of the byte/character split. #22 is the sibling PR on the format side (
strftime/format/format-for); this branch is cut fromorigin/masterand touches only the parse side, so the two are independent.git merge-treeagainstorigin/claude/strftime-scannerreports no conflict.What changed
Every confused site slices with
String.byte-slice, so a byte offset meets a byte-indexed slice:parse-digitssuffix+prefix→byte-slice s pos (+ pos n)match-in-array(%B,%A)suffix+prefix→byte-slice s pos (+ pos clen)match-short-in-array(%b,%a)suffix+prefix→byte-slice s pos (+ pos 3)%psuffix+prefix→byte-slice input ipos (+ ipos 2)%Zsuffix+prefix→byte-slice input ipos (+ ipos n)expand-compound-formats(str c)/(str spec)→byte-slice fmt i …(str spec)→byte-slice &efmt …parse-rangedneeded no change — it delegates toparse-digits.Char.strwas the second half of the problem: it UTF-8 re-encodes the byte value as a codepoint, soexpand-compound-formatsturned a0xC3byte back into two bytes. Copying the format bytes straight through withbyte-sliceavoids that without aString.from-bytesround-trip.Why each
byte-sliceis in rangeString.byte-sliceis an uncheckedmemcpy(ptr, *s + a, b - a), so every call has to be provably within[0, strlen].andshort-circuits in Carp, so guard-then-slice in one condition is sound.parse-digits— inside(<= (+ pos n) (String.length s)).posstarts at 0 and only ever increases.match-in-array— second conjunct of anandwhose first is(<= (+ pos clen) slen).match-short-in-array— the early return covers(> (+ pos 3) (String.length s)); the candidate slice is the second conjunct after(>= (String.length candidate) 3).%p— theifreturns an error when(> (+ ipos 2) ilen).%Z— the scan loop only advancesnwhile(< (+ ipos n) ilen), soipos + n <= ilenon exit.expand-compound-formats— the two-byte slice sits under(< (+ i 1) len); the one-byte slice under the loop's(< i len).(< (+ fpos 1) flen).Audit
Audited
strptimeend to end (:763 → :1113) plusexpand-compound-formats(:655). Found clean, no change needed:%z— guarded by(> (+ ipos 5) ilen), and its only raw read ischar-atat a proven-valid index; the rest goes throughparse-digits.efmtagainst a byte frominputand advances one byte. Guarded by(< ipos ilen). Byte-consistent already.%%(:1056) and the twochar-atreads onefmt(:790, :793) — all guarded.%B/%Aadvance byString.lengthof the matched candidate — a byte count. That is correct becausematch-in-arraynow byte-compares, so the match consumed exactlyclenbytes. Same for the%b/%aadvance of 3.unexpected character at position Nreports a byte position, which is now consistent with the rest of the scanner.Two upstream Carp core bugs are in the blast radius; this PR stops triggering them and does not touch core:
String.starts-with?'s byte/char inconsistency, andArray.range n n stepoverrunning its allocation.timehas no CHANGELOG, so there is no entry to add.Verification
1. Aborts on master, one case per process (master aborts on some inputs and would otherwise mask the rest). 48 formats × 20 hostile inputs = 960 cases:
Every hostile class landed: 20×
0x80(40 aborts), 6×0x80(39), a 4-byte emoji (35),C3 A9(18), a truncated 3-byte leadE2 82(18),"A"+0x80(18), and trailing/embedded/leading continuation bytes.2. Differential over valid input. 31 valid
(format, input)pairs covering every supported specifier —%b %B %a %A %p %z %Z %j %U %W %V %G %u %n %y %Iand the compound%c %x %X %F %T %R %D %r %h %%— with and without timezone, plus a 10-casestrftime→strptimeround-trip. Results dumped as decimal bytes, not rendered text. Master and branch agree cell for cell (31/31 and 10/10). The corpus is not vacuous: all 31 are successful parses with substantive field values (e.g.%Y-%m-%dT%H:%M:%S%zon2024-03-15T14:30:00+0530→Timezone "" 19800 false).Of the 773 hostile cases where master survived, 748 are byte-identical. All 25 differences are non-ASCII format strings, and each is a strict improvement:
"%<0xC3><0xA9>"— master reportsunknown format specifier: %<0xC3><0x83>(theChar.strre-encoding); branch reports the actual byte,%<0xC3>."<0xC3><0xA9>%d"— master saysunexpected character at position 1, because it had mangled the literal into four bytes so it could never match. The branch matches the literal and then fails on%dfor the real reason.No ASCII format changed behaviour on any input.
Worth flagging: fixing
expand-compound-formatsis what makes non-ASCII literals traversable at all, which newly puts%Zand%patipos > 0with multibyte bytes behind them. Those two slice fixes are load-bearing for this PR, not hypothetical — without them the fix would introduce an abort where master merely errored.3. New assertions have teeth. 13 new assertions; with
time.carpalone reverted to master, 13/13 fail — 9 by SIGABRT and 4 by wrong result (measured one assertion per process, since an abort would otherwise mask the rest). No pre-existing assertion changes: 269/0 on master, 282/0 here.4. ASan. Built with
carp -b, hand-compiled from~/.carp/out/main.cwithclang -fsanitize=address -I …/carp-lang/core:parse-digitsfiresheap-buffer-overflow … READ of size 68, so the sanitizer is provably live.LSan not attempted — not usable on this 32-bit ARM box.
5. CI checks.
carp -x test/time.carp→ 282/0.carp-fmt -cclean.anglerclean on both files (verified against a bait file that it does report findings — the~/.carp/out/anglerbuild silently lints nothing,~/bin/angleris the working one).carp -x gendocs.carpleavesgit statusempty.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.