From 9794ab7fdf32dc3a17fa0450708948351fe22125 Mon Sep 17 00:00:00 2001 From: Changjoon Date: Mon, 27 Apr 2026 21:41:40 +0900 Subject: [PATCH] Enforce int_max_str_digits on int-to-str conversions (#7688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enforce int_max_str_digits on int-to-str conversions The str-to-int direction already enforced sys.get_int_max_str_digits() via bytes_to_int; the int-to-str direction did not. CPython 3.14 enforces both per PEP 644. Adds check_int_to_str_digits helper in builtins::int (bit-count fast path + digit upper-bound from log10(2)), wired into the four Python-level entry points: repr, the str fast path in protocol::object, int.__format__ (decimal/n/empty spec only — binary bases x/o/b are exempt per CPython), and the DecimalD/I/U branches of vm::cformat for both str % and bytes %. Unmasks 8 expectedFailure tests across test_int (max_str_digits, DoS prevention, int_from_other_bases — each mirrored in IntSubclass), test_ast (test_repr_large_input_crash) and test_reprlib (test_numbers). Boundary cases (4299/4300/4301 digits at limit=4300) match CPython 3.14.4. * Skip int-to-str DoS test on platforms without time.get_clock_info The test_denial_of_service_prevented_int_to_str regression test uses support.Stopwatch, which calls time.get_clock_info('monotonic'). In RustPython that function is gated to unix/windows targets only, so on wasm32-wasip1 it surfaces as AttributeError and breaks the wasm-wasi CI. Guard the test with skipUnless(hasattr(time, 'get_clock_info'), ...) so it runs everywhere it can and is skipped on wasm. Also narrow is_decimal_int_format to Number(Case::Lower): 'N' is rejected by format_int as UnknownFormatCode, so excluding it preserves that error path instead of intercepting it with the digit-limit check. * Add TODO: RUSTPYTHON marker to skipUnless reason scripts/update_lib uses TODO: RUSTPYTHON markers inside unittest decorator reason strings to identify and migrate custom RustPython patches across CPython library updates. * Use expectedFailureIf for wasm get_clock_info gap skipUnless silently hides the test forever; expectedFailureIf surfaces unexpected success once RustPython implements time.get_clock_info on wasm, prompting marker removal. --- Lib/test/test_ast/test_ast.py | 1 - Lib/test/test_int.py | 8 ++++--- Lib/test/test_reprlib.py | 1 - crates/common/src/format.rs | 11 +++++++++ crates/vm/src/builtins/int.rs | 30 ++++++++++++++++++++++++- crates/vm/src/cformat.rs | 19 ++++++++++------ crates/vm/src/protocol/object.rs | 3 ++- extra_tests/snippets/builtin_int.py | 35 +++++++++++++++++++++++++++++ 8 files changed, 94 insertions(+), 14 deletions(-) diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index 7142064dd..0578b755d 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -1057,7 +1057,6 @@ class AST_Tests(unittest.TestCase): with self.subTest(test_input=test): self.assertEqual(repr(ast.parse(test)), snapshot) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised def test_repr_large_input_crash(self): # gh-125010: Fix use-after-free in ast repr() source = "0x0" + "e" * 10_000 diff --git a/Lib/test/test_int.py b/Lib/test/test_int.py index f7b26e37e..e61b1c657 100644 --- a/Lib/test/test_int.py +++ b/Lib/test/test_int.py @@ -1,4 +1,5 @@ import sys +import time import unittest # TODO: RUSTPYTHON @@ -573,7 +574,6 @@ class IntStrDigitLimitsTests(unittest.TestCase): else: self.int_class(i, base) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_max_str_digits(self): maxdigits = sys.get_int_max_str_digits() @@ -588,7 +588,10 @@ class IntStrDigitLimitsTests(unittest.TestCase): with self.assertRaises(ValueError): str(i) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailureIf( + not hasattr(time, "get_clock_info"), + "TODO: RUSTPYTHON; time.get_clock_info is not available on wasm", + ) def test_denial_of_service_prevented_int_to_str(self): """Regression test: ensure we fail before performing O(N**2) work.""" maxdigits = sys.get_int_max_str_digits() @@ -713,7 +716,6 @@ class IntStrDigitLimitsTests(unittest.TestCase): with self.assertRaises(ValueError) as err: int_class(f'{s}1', base) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_int_from_other_bases(self): base = 3 with self.subTest(base=base): diff --git a/Lib/test/test_reprlib.py b/Lib/test/test_reprlib.py index 3396b54cc..db3d87bd1 100644 --- a/Lib/test/test_reprlib.py +++ b/Lib/test/test_reprlib.py @@ -150,7 +150,6 @@ class ReprTests(unittest.TestCase): eq(r(frozenset({1, 2, 3, 4, 5, 6})), "frozenset({1, 2, 3, 4, 5, 6})") eq(r(frozenset({1, 2, 3, 4, 5, 6, 7})), "frozenset({1, 2, 3, 4, 5, 6, ...})") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_numbers(self): for x in [123, 1.0 / 3]: self.assertEqual(r(x), repr(x)) diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index 4d31acf7a..9b769a038 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -478,6 +478,17 @@ impl FormatSpec { matches!(self.format_type, Some(FormatType::Number(Case::Lower))) } + /// Returns true if this format spec produces a decimal int representation + /// subject to `sys.get_int_max_str_digits()` (no spec, 'd', or 'n'). + /// Binary bases ('b', 'o', 'x', 'X') are exempt per CPython. 'N' is rejected + /// later in `format_int` as `UnknownFormatCode`, so it is not included here. + pub fn is_decimal_int_format(&self) -> bool { + matches!( + self.format_type, + None | Some(FormatType::Decimal) | Some(FormatType::Number(Case::Lower)) + ) + } + /// Insert locale-aware thousands separators into an integer string. /// Follows CPython's GroupGenerator logic for variable-width grouping. fn insert_locale_grouping(int_part: &str, locale: &LocaleInfo) -> String { diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index 0ae1c6d86..bf92d49c2 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -500,6 +500,9 @@ impl PyInt { } let format_spec = FormatSpec::parse(spec.as_str()).map_err(|err| err.into_pyexception(vm))?; + if format_spec.is_decimal_int_format() { + check_int_to_str_digits(&zelf.value, vm)?; + } let result = if format_spec.has_locale_format() { let locale = crate::format::get_locale_info(); format_spec.format_int_locale(&zelf.value, &locale) @@ -655,9 +658,34 @@ impl Comparable for PyInt { } } +/// Pre-format check enforcing `sys.get_int_max_str_digits()` on int → str conversions. +/// Mirrors CPython's PEP 644 DoS mitigation. Cheap fast-path for small values via +/// bit-count upper bound on decimal digits. +pub(crate) fn check_int_to_str_digits(value: &BigInt, vm: &VirtualMachine) -> PyResult<()> { + let limit = vm.state.int_max_str_digits.load(); + if limit == 0 { + return Ok(()); + } + let bits = value.bits(); + // Below ~452 decimal digits: definitely under any reasonable limit. + if bits < 1500 { + return Ok(()); + } + // Upper bound on decimal digit count: ⌈bits × log10(2)⌉ + 1, with log10(2) ≈ 0.30103. + let digits_upper = (bits as usize * 30103 / 100000) + 1; + if digits_upper > limit { + return Err(vm.new_value_error(format!( + "Exceeds the limit ({limit} digits) for integer string conversion; \ + use sys.set_int_max_str_digits() to increase the limit" + ))); + } + Ok(()) +} + impl Representable for PyInt { #[inline] - fn repr_str(zelf: &Py, _vm: &VirtualMachine) -> PyResult { + fn repr_str(zelf: &Py, vm: &VirtualMachine) -> PyResult { + check_int_to_str_digits(&zelf.value, vm)?; Ok(zelf.to_str_radix_10()) } } diff --git a/crates/vm/src/cformat.rs b/crates/vm/src/cformat.rs index 5421b21d4..87b943f89 100644 --- a/crates/vm/src/cformat.rs +++ b/crates/vm/src/cformat.rs @@ -9,7 +9,8 @@ use crate::{ AsObject, PyObject, PyObjectRef, PyResult, TryFromBorrowedObject, TryFromObject, VirtualMachine, builtins::{ - PyBaseExceptionRef, PyByteArray, PyBytes, PyFloat, PyInt, PyStr, try_f64_to_bigint, tuple, + PyBaseExceptionRef, PyByteArray, PyBytes, PyFloat, PyInt, PyStr, + int::check_int_to_str_digits, try_f64_to_bigint, tuple, }, function::ArgIntoFloat, protocol::PyBuffer, @@ -54,17 +55,19 @@ fn spec_format_bytes( CNumberType::DecimalD | CNumberType::DecimalI | CNumberType::DecimalU => { match_class!(match &obj { ref i @ PyInt => { + check_int_to_str_digits(i.as_bigint(), vm)?; Ok(spec.format_number(i.as_bigint()).into_bytes()) } ref f @ PyFloat => { - Ok(spec - .format_number(&try_f64_to_bigint(f.to_f64(), vm)?) - .into_bytes()) + let bigint = try_f64_to_bigint(f.to_f64(), vm)?; + check_int_to_str_digits(&bigint, vm)?; + Ok(spec.format_number(&bigint).into_bytes()) } obj => { if let Some(method) = vm.get_method(obj.clone(), identifier!(vm, __int__)) { let result = method?.call((), vm)?; if let Some(i) = result.downcast_ref::() { + check_int_to_str_digits(i.as_bigint(), vm)?; return Ok(spec.format_number(i.as_bigint()).into_bytes()); } } @@ -149,17 +152,19 @@ fn spec_format_string( CNumberType::DecimalD | CNumberType::DecimalI | CNumberType::DecimalU => { match_class!(match &obj { ref i @ PyInt => { + check_int_to_str_digits(i.as_bigint(), vm)?; Ok(spec.format_number(i.as_bigint()).into()) } ref f @ PyFloat => { - Ok(spec - .format_number(&try_f64_to_bigint(f.to_f64(), vm)?) - .into()) + let bigint = try_f64_to_bigint(f.to_f64(), vm)?; + check_int_to_str_digits(&bigint, vm)?; + Ok(spec.format_number(&bigint).into()) } obj => { if let Some(method) = vm.get_method(obj.clone(), identifier!(vm, __int__)) { let result = method?.call((), vm)?; if let Some(i) = result.downcast_ref::() { + check_int_to_str_digits(i.as_bigint(), vm)?; return Ok(spec.format_number(i.as_bigint()).into()); } } diff --git a/crates/vm/src/protocol/object.rs b/crates/vm/src/protocol/object.rs index e59a1f15a..1ddbc1162 100644 --- a/crates/vm/src/protocol/object.rs +++ b/crates/vm/src/protocol/object.rs @@ -5,7 +5,7 @@ use crate::{ AsObject, Py, PyObject, PyObjectRef, PyRef, PyResult, TryFromObject, VirtualMachine, builtins::{ PyBytes, PyDict, PyDictRef, PyGenericAlias, PyInt, PyList, PyStr, PyTuple, PyTupleRef, - PyType, PyTypeRef, PyUtf8Str, pystr::AsPyStr, + PyType, PyTypeRef, PyUtf8Str, int::check_int_to_str_digits, pystr::AsPyStr, }, common::{hash::PyHash, str::to_ascii}, convert::{ToPyObject, ToPyResult}, @@ -392,6 +392,7 @@ impl PyObject { // Fast path for exact int: skip __str__ method resolution let obj = match obj.downcast_exact::(vm) { Ok(int) => { + check_int_to_str_digits(int.as_bigint(), vm)?; return Ok(vm.ctx.new_str(int.to_str_radix_10())); } Err(obj) => obj, diff --git a/extra_tests/snippets/builtin_int.py b/extra_tests/snippets/builtin_int.py index aab24cbb4..2828b5ad2 100644 --- a/extra_tests/snippets/builtin_int.py +++ b/extra_tests/snippets/builtin_int.py @@ -366,3 +366,38 @@ class SubInt(int): subint = int.__new__(SubInt, 11) assert subint.real is not subint assert type(subint.real) is int + + +# sys.set_int_max_str_digits enforced on int → str conversions (PEP 644). +# Decimal paths (str, repr, f-string, %d, format(d/n/empty)) raise ValueError; +# binary bases ('x', 'o', 'b') are exempt. +import sys + +_orig_limit = sys.get_int_max_str_digits() +try: + sys.set_int_max_str_digits(4000) + huge = 10**4001 # 4002 decimal digits, well over the limit + + for fn in [ + lambda: str(huge), + lambda: repr(huge), + lambda: f"{huge}", + lambda: "%d" % huge, + lambda: b"%d" % huge, + lambda: format(huge, ""), + lambda: format(huge, "d"), + lambda: format(huge, ",d"), + ]: + with assert_raises(ValueError): + fn() + + # Binary bases must NOT raise. + assert format(huge, "x") + assert format(huge, "o") + assert format(huge, "b") + + # Limit disabled: no check. + sys.set_int_max_str_digits(0) + assert str(huge) +finally: + sys.set_int_max_str_digits(_orig_limit)