From f15591c6f22cf1ff3dfc329df01ed8b9ea2c4b54 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Fri, 14 Aug 2026 18:10:12 +0200 Subject: [PATCH] Byte-index the strptime scanner so non-ASCII input cannot abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/time.carp | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++ time.carp | 35 +++++++++++++------------- 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/test/time.carp b/test/time.carp index 7446600..a3421ed 100644 --- a/test/time.carp +++ b/test/time.carp @@ -17,6 +17,9 @@ ; render a Datetime as "YYYY-MM-DD HH:MM:SS" for whole-instant comparisons. (defn stamp [dt] (fmt "%s %s" &(Datetime.isoformat dt) &(Datetime.isotime dt))) +; n UTF-8 continuation bytes, which decode to no character at all. +(defn conts [n] (String.from-bytes &(Array.replicate n &128b))) + ; sweep range: two leap years, two 53-week ISO years (2020 and 2026) (def SWEEP-FROM (Datetime.to-ordinal &(Datetime.date 2018 1 1))) (def SWEEP-TO (Datetime.to-ordinal &(Datetime.date 2026 12 31))) @@ -1361,6 +1364,69 @@ (Result.error? &(Datetime.strptime "2024-7" "%Y-%w")) "strptime rejects %w outside 0-6") + (assert-true test + (Result.error? &(Datetime.strptime &(conts 6) "%d %b %Y")) + "strptime rejects continuation bytes for a numeric field") + (assert-true test + (Result.error? &(Datetime.strptime &(conts 20) "%Y-%m-%dT%H:%M:%S%z")) + "strptime rejects continuation bytes across every numeric field") + (assert-true test + (Result.error? &(Datetime.strptime &(conts 4) "%B")) + "strptime rejects continuation bytes for %B") + (assert-true test + (Result.error? &(Datetime.strptime &(conts 3) "%b")) + "strptime rejects continuation bytes for %b") + (assert-true test + (Result.error? &(Datetime.strptime &(conts 2) "%p")) + "strptime rejects continuation bytes for %p") + (assert-true test + (Result.error? &(Datetime.strptime &(conts 3) "%c")) + "strptime rejects continuation bytes for a compound specifier") + (assert-true test + (Result.error? &(Datetime.strptime &(conts 6) "%j")) + "strptime rejects continuation bytes for a ranged field") + (assert-true test + (Result.error? &(Datetime.strptime &(String.from-bytes &[226b 130b]) "%m")) + "strptime rejects input truncated mid-character") + (assert-true test + (Result.error? &(Datetime.strptime "Jané" "%B")) + "strptime rejects a month name running into a multibyte character") + + (assert-equal test + &(Result.Success (Datetime.date 2024 3 15)) + &(Datetime.strptime "«2024-03-15»" "«%Y-%m-%d»") + "strptime matches multibyte literals in the format") + (assert-equal test + &(Result.Success + (Datetime.init 0 + 0 + 0 + (Maybe.Nothing) + (Maybe.Nothing) + (Maybe.Nothing) + (Maybe.Nothing) + (Maybe.Just (Timezone.init @"UTC" 0l false)))) + &(Datetime.strptime "é UTC" "é %Z") + "strptime reads %Z after a multibyte literal") + (assert-equal test + &(Result.Success + (Datetime.init 0 + 0 + 0 + (Maybe.Just 14) + (Maybe.Nothing) + (Maybe.Nothing) + (Maybe.Nothing) + (Maybe.Nothing))) + &(Datetime.strptime "é02PM" "é%I%p") + "strptime reads %p after a multibyte literal") + (assert-equal test + &(Result.Error + (String.concat + &[@"unknown format specifier: %" (String.from-bytes &[195b])])) + &(Datetime.strptime "" "%é") + "strptime reports an unknown specifier byte without re-encoding it") + (assert-equal test &@"" &(roundtrip-sweep SWEEP-FROM SWEEP-TO "%Y-%j") diff --git a/time.carp b/time.carp index b79df5a..448b8f5 100644 --- a/time.carp +++ b/time.carp @@ -604,9 +604,7 @@ It mimics [the C interface](http://www.cplusplus.com/reference/ctime/strftime/). (hidden parse-digits) (defn parse-digits [s pos n] (if (<= (+ pos n) (String.length s)) - (let [sub (String.suffix s pos) - tok (String.prefix &sub n)] - (Int.from-string &tok)) + (Int.from-string &(String.byte-slice s pos (+ pos n))) (Maybe.Nothing))) (private parse-ranged) @@ -622,13 +620,12 @@ It mimics [the C interface](http://www.cplusplus.com/reference/ctime/strftime/). (defn match-in-array [arr s pos] (let-do [result -1 i 1 - slen (String.length s) - sub (String.suffix s pos)] + slen (String.length s)] (while (and (= result -1) (< i (Array.length arr))) (let [candidate (Array.unsafe-nth arr i) clen (String.length candidate)] (if (and (<= (+ pos clen) slen) - (= &(String.prefix &sub clen) candidate)) + (= &(String.byte-slice s pos (+ pos clen)) candidate)) (set! result i) (set! i (+ i 1))))) result)) @@ -640,12 +637,11 @@ It mimics [the C interface](http://www.cplusplus.com/reference/ctime/strftime/). -1 (let-do [result -1 i 1 - sub (String.suffix s pos) - tok (String.prefix &sub 3)] + tok (String.byte-slice s pos (+ pos 3))] (while (and (= result -1) (< i (Array.length arr))) (let [candidate (Array.unsafe-nth arr i)] (if (and (>= (String.length candidate) 3) - (= &tok &(String.prefix candidate 3))) + (= &tok &(String.byte-slice candidate 0 3))) (set! result i) (set! i (+ i 1))))) result))) @@ -699,9 +695,14 @@ It mimics [the C interface](http://www.cplusplus.com/reference/ctime/strftime/). (set! result (String.concat &[result @"%b"])) (set! i (+ i 2))) (do - (set! result (String.concat &[result (str c) (str spec)])) + (set! result + (String.concat + &[result (String.byte-slice fmt i (+ i 2))])) (set! i (+ i 2))))) - (do (set! result (String.concat &[result (str c)])) (set! i (+ i 1)))))) + (do + (set! result + (String.concat &[result (String.byte-slice fmt i (+ i 1))])) + (set! i (+ i 1)))))) result)) (doc strptime "parses a string `input` according to format string `fmt`, @@ -851,8 +852,7 @@ Example: \p (if (> (+ ipos 2) ilen) (do (set! err @"failed to parse %p") (set! ok false)) - (let [sub (String.suffix input ipos) - tok (String.prefix &sub 2)] + (let [tok (String.byte-slice input ipos (+ ipos 2))] (cond (= &tok "AM") (do @@ -1045,9 +1045,9 @@ Example: (and (>= c \a) (<= c \z))))) (set! n (+ n 1))) (if (> n 0) - (let-do [sub (String.suffix input ipos) - nm (String.prefix &sub n)] - (set! tz-nm nm) + (do + (set! tz-nm + (String.byte-slice input ipos (+ ipos n))) (set! has-tz true) (set! ipos (+ ipos n)) (set! fpos (+ fpos 2))) @@ -1059,7 +1059,8 @@ Example: (do (set! err (String.concat - &[@"unknown format specifier: %" (str spec)])) + &[@"unknown format specifier: %" + (String.byte-slice &efmt (+ fpos 1) (+ fpos 2))])) (set! ok false)))) (and (< ipos ilen) (= fc (String.char-at input ipos))) (do (set! ipos (+ ipos 1)) (set! fpos (+ fpos 1)))