[m-rev.] for review: overflow checks for string conversions in the calendar module

Julien Fischer jfischer at opturion.com
Sat Aug 15 01:36:07 AEST 2026


For review by anyone.

------------------------------

Overflow checks for string conversions in calendar module.

The predicates date_time_from_string/2 and duration_from_string/2 do not
currently do any overflow checking on their components; add the missing
overflow checks.

library/calendar.m:
    As above.

tests/hard_coded/calendar_date_time_conv.{m,exp}:
tests/hard_coded/calendar_duration.{m,exp}:
    Extend these test to check for component overflow.

Julien.

diff --git a/library/calendar.m b/library/calendar.m
index bfedaec53..dc813ec4f 100644
--- a/library/calendar.m
+++ b/library/calendar.m
@@ -216,8 +216,11 @@
     % The microseconds component (.mmmmmm) is optional. If present,
     % it may have between one and six digits.
     %
-    % This predicate fails if the string does not conform to the above format,
-    % or if any date or time component is outside its valid range.
+    % This predicate fails if
+    % - the string does not conform to the above format;
+    % - any date or time component is outside its valid range;
+    % - the absolute value of the year cannot be represented by Mercury's
+    %   int type.
     %
 :- pred date_time_from_string(string::in, date_time::out) is semidet.

@@ -460,8 +463,11 @@
     % This fraction component cannot include more than six digits, since
     % the maximum resolution of a duration is a microsecond.
     %
-    % Fail if the string does not conform to the above format, or if the
-    % fractional part of the seconds component has more than six digits.
+    % This predicates fails if
+    % - the string does not conform to the above format;
+    % - the fractional part of the seconds component has more than six digits;
+    % - any of the duration components cannot be represented by Mercury's
+    %   int type.
     %
     % For example, the duration 1 year, 18 months, 100 days, 10 hours, 15
     % minutes, 90 seconds and 300 microseconds can be written as:
@@ -1498,25 +1504,45 @@ foldl3_days(Pred, !.Curr, End, !Acc1, !Acc2, !Acc3) :-

 read_next_char(Char, [Char | Rest], Rest).

+    % read_int_and_return_num_digits(Int, NumDigits, !Chars):
+    %
+    % Int is a non-negative integer of NumDigits digits at the start
+    % of the given character list.
+    % Int and NumDigits are both zero if the start of the character list does
+    % not contain a digit.
+    % Fails if the integer is outside the bounds of what can be represented by
+    % Mercury's int type.
+    %
 :- pred read_int_and_return_num_digits(int::out, int::out,
-    list(char)::in, list(char)::out) is det.
+    list(char)::in, list(char)::out) is semidet.

 read_int_and_return_num_digits(Int, NumDigits, !Chars) :-
-    read_int_and_return_num_digits_loop(0, Int, 0, NumDigits, !Chars).
-
-:- pred read_int_and_return_num_digits_loop(int::in, int::out,
-    int::in, int::out, list(char)::in, list(char)::out) is det.
-
-read_int_and_return_num_digits_loop(!Int, !NumDigits, !Chars) :-
+    % See the comment for string.do_base_string_to_positive_int_loop/8
+    % for an explanation of how we check for overflow here.
+    CutOff = max_int `unchecked_quotient` 10,
+    CutLimit = max_int `unchecked_rem` 10,
+    read_int_and_return_num_digits_loop(CutOff, CutLimit, 0, Int,
+        0, NumDigits, !Chars).
+
+:- pred read_int_and_return_num_digits_loop(int::in, int::in,
+    int::in, int::out, int::in, int::out, list(char)::in, list(char)::out)
+    is semidet.
+
+read_int_and_return_num_digits_loop(CutOff, CutLimit, Acc0, Acc,
+        !NumDigits, !Chars) :-
     ( if
         !.Chars = [Char | !:Chars],
         decimal_digit_to_int(Char, Digit)
     then
-        !:Int = !.Int * 10 + Digit,
-        read_int_and_return_num_digits_loop(!Int, !.NumDigits + 1, !:NumDigits,
-            !Chars)
+        % Fail on overflow.
+        ( Acc0 < CutOff
+        ; Acc0 = CutOff, Digit =< CutLimit
+        ),
+        NextAcc = (10 * Acc0) + Digit,
+        read_int_and_return_num_digits_loop(CutOff, CutLimit, NextAcc, Acc,
+            !.NumDigits + 1, !:NumDigits, !Chars)
     else
-        true
+        Acc = Acc0
     ).

 :- pred read_microseconds(microseconds::out, list(char)::in, list(char)::out)
diff --git a/tests/hard_coded/calendar_date_time_conv.exp
b/tests/hard_coded/calendar_date_time_conv.exp
index 01258fd13..f565cf7ef 100644
--- a/tests/hard_coded/calendar_date_time_conv.exp
+++ b/tests/hard_coded/calendar_date_time_conv.exp
@@ -129,6 +129,9 @@ PASS date_time(1970, 1, 1, 0, 0, 0, 0)
 "1582-10-15 00:00:00":        first day of the Gregorian calendar
 PASS date_time(1582, 10, 15, 0, 0, 0, 0)

+"2147483647-01-01 00:00:00":  year on 32-bit integer boundary
+PASS date_time(2147483647, 1, 1, 0, 0, 0, 0)
+
 "2024-01-01 00:00:00.10":     one fractional trailing zero
 PASS date_time(2024, 1, 1, 0, 0, 0, 100000)
 TEST 2024-01-01 00:00:00.10
@@ -366,3 +369,9 @@ PASS: ERROR DETECTED

 "2024-01-01 00:00:":          trailing colon with no second digits
 PASS: ERROR DETECTED
+
+"9223372036854775808-01-01 00:00:00": integer overflow in year component
+PASS: ERROR DETECTED
+
+"23058430092136939520-01-01 00:00:00": bad overflow check
+PASS: ERROR DETECTED
diff --git a/tests/hard_coded/calendar_date_time_conv.m
b/tests/hard_coded/calendar_date_time_conv.m
index 994f5d788..a180d333f 100644
--- a/tests/hard_coded/calendar_date_time_conv.m
+++ b/tests/hard_coded/calendar_date_time_conv.m
@@ -181,7 +181,11 @@ valid_date_times = [
     dt_conv_test("all maximum", "2024-12-31 23:59:60.999999"),
     dt_conv_test("Unix epoch", "1970-01-01 00:00:00"),
     dt_conv_test("first day of the Gregorian calendar",
-        "1582-10-15 00:00:00")
+        "1582-10-15 00:00:00"),
+
+    % Valid in all grades.
+    dt_conv_test("year on 32-bit integer boundary",
+        "2147483647-01-01 00:00:00")
 ].

     % These cannot be round-tripped through date_time_to_string/1.
@@ -294,7 +298,15 @@ invalid_date_times = [
     dt_conv_test("missing day", "2024-01- 00:00:00"),
     dt_conv_test("missing hour", "2024-01-01 :00:00"),
     dt_conv_test("missing minute", "2024-01-01 00::00"),
-    dt_conv_test("trailing colon with no second digits", "2024-01-01 00:00:")
+    dt_conv_test("trailing colon with no second digits", "2024-01-01 00:00:"),
+
+    % One past max_int for 64-bit integers.
+    dt_conv_test("integer overflow in year component",
+        "9223372036854775808-01-01 00:00:00"),
+
+    % A test case intended to defeat incorrect overflow checks.
+    dt_conv_test("bad overflow check",
+        "23058430092136939520-01-01 00:00:00")
 ].

 %---------------------------------------------------------------------------%
diff --git a/tests/hard_coded/calendar_duration_conv.exp
b/tests/hard_coded/calendar_duration_conv.exp
index be64d8e8e..befb93b69 100644
--- a/tests/hard_coded/calendar_duration_conv.exp
+++ b/tests/hard_coded/calendar_duration_conv.exp
@@ -333,3 +333,21 @@ PASS: ERROR DETECTED

 "PTS":                              S with no second digits
 PASS: ERROR DETECTED
+
+"P9223372036854775808Y":            integer overflow in years component
+PASS: ERROR DETECTED
+
+"P9223372036854775808M":            integer overflow in months component
+PASS: ERROR DETECTED
+
+"P9223372036854775808D":            integer overflow in days component
+PASS: ERROR DETECTED
+
+"PT9223372036854775808H":           integer overflow in hours component
+PASS: ERROR DETECTED
+
+"PT9223372036854775808M":           integer overflow in minutes component
+PASS: ERROR DETECTED
+
+"PT9223372036854775808S":           integer overflow in seconds component
+PASS: ERROR DETECTED
diff --git a/tests/hard_coded/calendar_duration_conv.m
b/tests/hard_coded/calendar_duration_conv.m
index b47410d13..f853b36ad 100644
--- a/tests/hard_coded/calendar_duration_conv.m
+++ b/tests/hard_coded/calendar_duration_conv.m
@@ -289,7 +289,21 @@ invalid_durations = [
     duration_test("D with no day digits", "P5YD"),
     duration_test("H with no hour digits", "PTH"),
     duration_test("M with no minute digits", "PT5HM"),
-    duration_test("S with no second digits", "PTS")
+    duration_test("S with no second digits", "PTS"),
+
+    % Integer overflow in components.
+    duration_test("integer overflow in years component",
+        "P9223372036854775808Y"),
+    duration_test("integer overflow in months component",
+        "P9223372036854775808M"),
+    duration_test("integer overflow in days component",
+        "P9223372036854775808D"),
+    duration_test("integer overflow in hours component",
+        "PT9223372036854775808H"),
+    duration_test("integer overflow in minutes component",
+        "PT9223372036854775808M"),
+    duration_test("integer overflow in seconds component",
+        "PT9223372036854775808S")
 ].

 %---------------------------------------------------------------------------%


More information about the reviews mailing list