mirror of
https://github.com/RustPython/RustPython.git
synced 2026-10-28 18:59:04 +08:00
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:
1 parent
dc81c740cf
commit
9794ab7fdf
8 files changed
+94
-14
No files matched your search
Vendored
-1
@@ -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
|
||||
|
||||
Vendored
+5
-3
@@ -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):
|
||||
|
||||
Vendored
-1
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user