bound oversized format width and precision during inference (#3108)


Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
diff --git a/ChangeLog b/ChangeLog
index bbc0b7b..490a8e2 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -87,6 +87,14 @@
   Refs pylint-dev/pylint#10193
   Closes #3007
 
+* Avoid materializing a multi-gigabyte string while inferring a small literal
+  such as ``"{:>2000000000}".format("x")`` or ``f"{1.5:.2000000000f}"``.
+  ``_infer_str_format_call`` and ``FormattedValue._infer`` now yield
+  ``Uninferable`` when a format spec asks for a width or precision over 1e8,
+  mirroring the sequence/repetition caps in ``astroid.protocols``.
+
+Refs #3108
+
 * Fix uncaught ``IndentationError`` when parsing code whose lines end in
   ``\r``: the slice of source tokenized to compute a class or function
   ``position`` is split on ``\n`` only and could be misaligned with the AST
diff --git a/astroid/brain/brain_builtin_inference.py b/astroid/brain/brain_builtin_inference.py
index 2ef32d9..1a04dca 100644
--- a/astroid/brain/brain_builtin_inference.py
+++ b/astroid/brain/brain_builtin_inference.py
@@ -1091,6 +1091,15 @@
 
     keyword_values: dict[str, str] = {k: v.value for k, v in inferred_keyword.items()}
 
+    import string  # pylint: disable=import-outside-toplevel
+
+    try:
+        fields = list(string.Formatter().parse(format_template))
+    except ValueError:
+        return iter([util.Uninferable])
+    if any(spec and util.format_spec_too_large(spec) for _, _, spec, _ in fields):
+        return iter([util.Uninferable])
+
     try:
         formatted_string = _BoundedFormatter().format(
             format_template, *pos_values, **keyword_values
diff --git a/astroid/nodes/node_classes.py b/astroid/nodes/node_classes.py
index 958bf90..193e028 100644
--- a/astroid/nodes/node_classes.py
+++ b/astroid/nodes/node_classes.py
@@ -4698,6 +4698,12 @@
                 value_to_format = value
                 if isinstance(value, Const):
                     value_to_format = value.value
+                if isinstance(format_spec.value, str) and util.format_spec_too_large(
+                    format_spec.value
+                ):
+                    yield util.Uninferable
+                    uninferable_already_generated = True
+                    continue
                 try:
                     formatted = format(value_to_format, format_spec.value)
                     yield Const(
diff --git a/astroid/util.py b/astroid/util.py
index 4cb4629..e1071e1 100644
--- a/astroid/util.py
+++ b/astroid/util.py
@@ -5,6 +5,7 @@
 
 from __future__ import annotations
 
+import re
 import warnings
 from typing import TYPE_CHECKING, Any, Final, Literal
 
@@ -50,6 +51,29 @@
 
 Uninferable: Final = UninferableBase()
 
+# Width/precision in a format spec drive how large a formatted string gets.
+# Match the leading width and the optional ``.precision`` from the start of a
+# format spec (PEP 3101 / format mini-language).
+_FORMAT_SPEC_SIZE = re.compile(
+    r"(?:.?[<>=^])?[-+ ]?z?#?0?(?P<width>\d+)?(?:[,_])?(?:\.(?P<precision>\d+))?"
+)
+# Mirrors the sequence/repetition caps in astroid.protocols.
+MAX_FORMATTED_SIZE = 10**8
+
+
+def format_spec_too_large(format_spec: str) -> bool:
+    """Whether a format spec asks for an oversized width or precision.
+
+    Used to avoid materializing a multi-gigabyte string while inferring a tiny
+    literal such as ``"{:>2000000000}".format("x")`` or ``f"{1.5:.2e9f}"``.
+    """
+    # Every group in _FORMAT_SPEC_SIZE is optional, so match is never None.
+    match = _FORMAT_SPEC_SIZE.match(format_spec)
+    return any(
+        size is not None and int(size) > MAX_FORMATTED_SIZE
+        for size in match.group("width", "precision")
+    )
+
 
 class BadOperationMessage:
     """Object which describes a TypeError occurred somewhere in the inference chain.
diff --git a/tests/test_inference.py b/tests/test_inference.py
index 335bf5f..9690183 100644
--- a/tests/test_inference.py
+++ b/tests/test_inference.py
@@ -5378,6 +5378,20 @@
     assert inferred[0] is util.Uninferable
 
 
+@pytest.mark.parametrize(
+    "code",
+    [
+        "f'{1:>2000000000}'",
+        "f'{0:030000000000}'",
+        "f'{1.5:.2000000000f}'",
+    ],
+)
+def test_fstring_oversized_width_uninferable(code: str) -> None:
+    """A huge width/precision must not materialize a multi-gigabyte string."""
+    node = extract_node(code)
+    assert list(node.infer()) == [util.Uninferable]
+
+
 def test_augassign_recursion() -> None:
     """Make sure inference doesn't throw a RecursionError.
 
@@ -7242,6 +7256,32 @@
 
 
 @pytest.mark.parametrize(
+    "code",
+    [
+        '"{:>2000000000}".format("x")',
+        '"{:.2000000000f}".format(1.5)',
+    ],
+)
+def test_str_format_oversized_width_uninferable(code: str) -> None:
+    """str.format() with a huge width/precision must stay Uninferable."""
+    node = _extract_single_node(code)
+    assert next(node.infer()) is util.Uninferable
+
+
+@pytest.mark.parametrize(
+    "code",
+    [
+        '"{".format()',
+        '"}{".format()',
+    ],
+)
+def test_str_format_malformed_template_uninferable(code: str) -> None:
+    """A template Formatter().parse() rejects must stay Uninferable."""
+    node = _extract_single_node(code)
+    assert next(node.infer()) is util.Uninferable
+
+
+@pytest.mark.parametrize(
     "source, expected",
     [
         (