Enforce int_max_str_digits on int-to-str conversions (#7688)

* 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.
This commit is contained in:
Changjoon authored and GitHub committed 2026-04-27 21:41:40 +09:00
1 parent dc81c740cf
commit 9794ab7fdf
8 files changed
+94 -14

No files matched your search

-1
View File
@@ -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
+5 -3
View File
@@ -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):
-1
View File
@@ -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))
+11
View File
@@ -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 {
+29 -1
View File
@@ -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<Self>, _vm: &VirtualMachine) -> PyResult<String> {
fn repr_str(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult<String> {
check_int_to_str_digits(&zelf.value, vm)?;
Ok(zelf.to_str_radix_10())
}
}
+12 -7
View File
@@ -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::<PyInt>() {
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::<PyInt>() {
check_int_to_str_digits(i.as_bigint(), vm)?;
return Ok(spec.format_number(i.as_bigint()).into());
}
}
+2 -1
View File
@@ -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::<PyInt>(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,
+35
View File
@@ -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)