proj/portage:master commit in: lib/portage/tests/dep/

"Matt Turner" <[email protected]>
Newsgroups gmane.linux.gentoo.cvs
Message-ID <1785965216.8da18040ef5c699873692d6d4e2cd86d497ff800.mattst88@gentoo>
commit:     8da18040ef5c699873692d6d4e2cd86d497ff800
Author:     Matt Turner <mattst88 <AT> gentoo <DOT> org>
AuthorDate: Tue Aug  4 15:51:00 2026 +0000
Commit:     Matt Turner <mattst88 <AT> gentoo <DOT> org>
CommitDate: Wed Aug  5 21:26:56 2026 +0000
URL:        https://gitweb.gentoo.org/proj/portage.git/commit/?id=8da18040

tests: exercise the C parser's allocation failure paths

Every allocation the C dep parser makes has a cleanup path that no
ordinary test reaches, because allocations do not fail in practice.
_testcapi.set_nomemory(n, n + 1) makes the nth allocation fail, so
sweeping n over a whole call exercises those paths one at a time.

Each sweep checks that the failure surfaces as MemoryError rather than a
crash, and that the same call still returns the right answer afterwards.
The corpus includes an atom long enough to miss the on-stack buffer in
join_atom_string() and nesting deeper than PY_PARSE_INLINE_DEPTH, so the
heap paths for both buffers are covered.

Argument-conversion failures get their own test, since no allocation
sweep reaches them.

gcov line coverage from lib/portage/tests/dep, before -> after:

  src/dep_parser.c        88.41% -> 97.97%
  src/dep_parser_core.c   98.52% -> 99.70%

What is left is unreachable from Python: module-init failure, a dead
operator fallback, and allocation failures for empty list/dict/tuple
objects, which come from a freelist and so never call the allocator.

_testcapi is a CPython test-support module that is not installed
everywhere, so the tests skip when it is missing.

Signed-off-by: Matt Turner <mattst88 <AT> gentoo.org>

 lib/portage/tests/dep/test_c_parser.py | 141 +++++++++++++++++++++++++++++++++
 1 file changed, 141 insertions(+)

diff --git a/lib/portage/tests/dep/test_c_parser.py b/lib/portage/tests/dep/test_c_parser.py
index e939d4ded..f0033a080 100644
--- a/lib/portage/tests/dep/test_c_parser.py
+++ b/lib/portage/tests/dep/test_c_parser.py
@@ -8,6 +8,16 @@ import subprocess
 import sys
 from pathlib import Path
 
+try:
+    import _testcapi
+except ImportError:
+    _testcapi = None
+else:
+    # PyPy ships a _testcapi module, but not the allocator hooks that the
+    # allocation-failure sweep needs.
+    if not all(hasattr(_testcapi, x) for x in ("set_nomemory", "remove_mem_hooks")):
+        _testcapi = None
+
 import portage
 import portage.dep as _dep_mod
 from portage.dep import Atom, _get_eapi_attrs, _use_dep, use_reduce
@@ -1469,3 +1479,134 @@ class TestKillSwitch(TestCase):
         for value in ("1", "", "no"):
             with self.subTest(value=value):
                 self.assertEqual(self._probe(value), "False")
+
+
+class TestCParserAllocFailure(TestCase):
+    """Every allocation the C parser makes can fail, and each failure has its
+    own cleanup path that no ordinary test reaches.  _testcapi.set_nomemory(n,
+    n + 1) makes the nth allocation fail, so sweeping n over a whole call
+    exercises those paths one at a time.
+
+    A failed allocation must surface as MemoryError, must not crash, and must
+    not leave the parser unusable: the group-frame stack and the half-built
+    Atom of the aborted call have to be released.
+
+    set_nomemory() hooks CPython's own allocator, so these tests skip wherever
+    it is unavailable: _testcapi is a test-support module that is not installed
+    everywhere, and PyPy provides the module without the memory hooks."""
+
+    # Large enough that the sweep runs past the last allocation of every case
+    # here; _sweep() fails if a case ever outgrows it.
+    SWEEP_LIMIT = 400
+
+    PARSE_CASES = (
+        ("dev-libs/a", (), False),
+        ("=sys-apps/portage-2.1-r1:0/1=[doc,a=,!b=,c?,!d?,-e]", (), True),
+        ("|| ( dev-libs/a dev-libs/b )", (), True),
+        ("|| ( ( dev-libs/a dev-libs/b ) dev-libs/c )", (), True),
+        ("foo? ( dev-libs/a dev-libs/b )", ("foo",), False),
+        ("foo? ( dev-libs/a )", (), False),
+        (
+            "dev-libs/A >=dev-libs/B-2.0 || ( dev-libs/C dev-libs/D ) "
+            "foo? ( =dev-libs/E-1.0:0[bar,baz?] )",
+            ("foo",),
+            False,
+        ),
+        # Long enough to miss the on-stack buffer in join_atom_string().
+        ("=cat/" + "a" * 300 + "-1.0", (), True),
+        # Deeper than PY_PARSE_INLINE_DEPTH, so the group-frame stack moves to
+        # the heap (PyMem_New) and then grows again (PyMem_Realloc).
+        ("( " * 40 + "dev-libs/a" + " )" * 40, (), True),
+        ("( " * 70 + "dev-libs/a" + " )" * 70, (), True),
+    )
+
+    ATOM_CASES = (
+        "sys-apps/portage",
+        ">=dev-libs/foo-1.2.3-r1:0/1=[a,-b,c?,!d?,e=]",
+        "!!media-libs/x:2",
+        "=cat/pkg-1.2*",
+    )
+
+    USE_DEP_CASES = (
+        ("foo",),
+        ("foo", "-bar", "!baz?", "qux=", "quux(+)"),
+        ("a", "b?", "!c?", "d=", "!e=", "-f", "g(+)", "h(-)"),
+    )
+
+    def setUp(self):
+        if _orig_c_dep_parser is None:
+            self.skipTest("_parser extension not available")
+        if _testcapi is None:
+            self.skipTest("_testcapi memory hooks not available")
+        self._parser = _orig_c_dep_parser
+
+    def _sweep(self, fn, *args, **kwargs):
+        """Call fn(*args, **kwargs) once per allocation index, failing that
+        allocation."""
+        failed = []
+        for n in range(self.SWEEP_LIMIT):
+            _testcapi.set_nomemory(n, n + 1)
+            try:
+                fn(*args, **kwargs)
+            except MemoryError:
+                failed.append(n)
+            finally:
+                _testcapi.remove_mem_hooks()
+        self.assertTrue(failed, "no allocation failure was injected")
+        # A failure in the last stretch of the sweep means the call allocates
+        # more than SWEEP_LIMIT times, so its deepest error paths were never
+        # reached and the limit needs raising.
+        self.assertLess(
+            failed[-1],
+            self.SWEEP_LIMIT - 50,
+            f"SWEEP_LIMIT={self.SWEEP_LIMIT} too low for this case",
+        )
+
+    def test_parse(self):
+        for depstr, uselist, matchall in self.PARSE_CASES:
+            with self.subTest(depstr=depstr):
+                kw = dict(uselist=uselist, matchall=matchall)
+                expected = self._parser.parse(depstr, **kw)
+                self._sweep(self._parser.parse, depstr, **kw)
+                self.assertEqual(self._parser.parse(depstr, **kw), expected)
+
+    def test_scan_atom(self):
+        for s in self.ATOM_CASES:
+            with self.subTest(s=s):
+                expected = str(self._parser.scan_atom(s))
+                self._sweep(self._parser.scan_atom, s)
+                self.assertEqual(str(self._parser.scan_atom(s)), expected)
+
+    def test_classify_use_deps(self):
+        for tokens in self.USE_DEP_CASES:
+            with self.subTest(tokens=tokens):
+                expected = self._parser.classify_use_deps(tokens)
+                self._sweep(self._parser.classify_use_deps, tokens)
+                self.assertEqual(self._parser.classify_use_deps(tokens), expected)
+
+    def test_bad_argument_types(self):
+        """Argument conversion failures are error paths of their own, and no
+        allocation sweep reaches them."""
+        for arg in (42, None, b"dev-libs/a", ["dev-libs/a"]):
+            with self.subTest(arg=arg):
+                with self.assertRaises(TypeError):
+                    self._parser.parse(arg)
+                with self.assertRaises(TypeError):
+                    self._parser.scan_atom(arg)
+        # classify_use_deps() wants a sequence, and its items must be strings.
+        for arg in (42, None):
+            with self.subTest(arg=arg):
+                with self.assertRaises(TypeError):
+                    self._parser.classify_use_deps(arg)
+        with self.assertRaises(TypeError):
+            self._parser.classify_use_deps((42,))
+
+    def test_atom_fast_path(self):
+        """Atom() drives scan_atom() plus _c_fast_init(), so a failure here
+        can also leave a partly initialized portage.dep.Atom behind."""
+        for s in self.ATOM_CASES:
+            with self.subTest(s=s):
+                expected = Atom(s, eapi="8")
+                self._sweep(Atom, s, eapi="8")
+                ok, msg = _atoms_equal(Atom(s, eapi="8"), expected)
+                self.assertTrue(ok, f"{s!r}: {msg}")
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.