diff --git a/fusil/python/write_python_code.py b/fusil/python/write_python_code.py index 96f1561..a322311 100644 --- a/fusil/python/write_python_code.py +++ b/fusil/python/write_python_code.py @@ -471,6 +471,24 @@ def _write_script_header_and_imports(self) -> None: def skip_trivial_type(obj_instance_or_class): if type(obj_instance_or_class) in TRIVIAL_TYPES: return True + # The int-as-pointer family, reached as a module ATTRIBUTE rather than as a + # module. --blacklist already excludes _cffi_backend/_rawffi/ctypes by NAME, but + # PyPy's stdlib hands out live cffi objects as attributes -- resource.ffi, + # _ssl.ffi, _sqlite3._ffi, _lzma.ffi, _gdbm.ffi, _curses.ffi, _tkinter.tkffi, + # _sha3._ffi, _posixshmem.ffi and their matching .lib -- so the surface is + # reachable regardless of the module blacklist. ffi.memmove(int, int, n) treats + # its arguments as raw pointers and dies BY CONTRACT on whatever integer it is + # handed, exactly as CPython's ctypes does on the same input, so a fault here is + # never a target defect. Seen in four consecutive PyPy fleets (05, 06, 07, 08). + # + # (Worded without fusil's own crash vocabulary on purpose: help() echoes emitted + # comments via inspect.getcomments, and SelfNoiseVocabularyTests enforces it.) + # + # Matched on the TYPE's defining module, not on the attribute name: an + # OBJECT_BLACKLIST entry for "ffi"/"lib"/"_ffi"/"tkffi" would match those names + # on unrelated targets that legitimately expose them. + if type(obj_instance_or_class).__module__ == "_cffi_backend": + return True return False """), ) diff --git a/tests/python/golden/fakemod_seed1234.py b/tests/python/golden/fakemod_seed1234.py index fbd6df9..a1f4520 100644 --- a/tests/python/golden/fakemod_seed1234.py +++ b/tests/python/golden/fakemod_seed1234.py @@ -42,6 +42,24 @@ def skip_trivial_type(obj_instance_or_class): if type(obj_instance_or_class) in TRIVIAL_TYPES: return True + # The int-as-pointer family, reached as a module ATTRIBUTE rather than as a + # module. --blacklist already excludes _cffi_backend/_rawffi/ctypes by NAME, but + # PyPy's stdlib hands out live cffi objects as attributes -- resource.ffi, + # _ssl.ffi, _sqlite3._ffi, _lzma.ffi, _gdbm.ffi, _curses.ffi, _tkinter.tkffi, + # _sha3._ffi, _posixshmem.ffi and their matching .lib -- so the surface is + # reachable regardless of the module blacklist. ffi.memmove(int, int, n) treats + # its arguments as raw pointers and dies BY CONTRACT on whatever integer it is + # handed, exactly as CPython's ctypes does on the same input, so a fault here is + # never a target defect. Seen in four consecutive PyPy fleets (05, 06, 07, 08). + # + # (Worded without fusil's own crash vocabulary on purpose: help() echoes emitted + # comments via inspect.getcomments, and SelfNoiseVocabularyTests enforces it.) + # + # Matched on the TYPE's defining module, not on the attribute name: an + # OBJECT_BLACKLIST entry for "ffi"/"lib"/"_ffi"/"tkffi" would match those names + # on unrelated targets that legitimately expose them. + if type(obj_instance_or_class).__module__ == "_cffi_backend": + return True return False diff --git a/tests/python/test_golden_output.py b/tests/python/test_golden_output.py index d3d713d..b1a26b9 100644 --- a/tests/python/test_golden_output.py +++ b/tests/python/test_golden_output.py @@ -173,6 +173,69 @@ def test_full_script_matches_snapshot(self): ) +class SkipTrivialTypeTests(unittest.TestCase): + """The emitted ``skip_trivial_type`` must refuse to dive into cffi objects. + + ``--blacklist`` excludes ``_cffi_backend`` / ``_rawffi`` / ``ctypes`` by MODULE NAME, but + PyPy's stdlib hands out live cffi objects as module ATTRIBUTES -- ``resource.ffi``, + ``_ssl.ffi``, ``_sqlite3._ffi``, ``_lzma.ffi``, ``_tkinter.tkffi`` and their matching + ``.lib`` -- so the int-as-pointer surface was reachable regardless. Four consecutive PyPy + fleets kept dirs from it. + + The function is emitted as SOURCE into every generated script, so testing the snapshot + text alone would not catch a logic error in it. Exec the emitted definition and exercise + it, matching on the type's defining module rather than on the attribute name. + """ + + def _emitted_skip_trivial_type(self): + source = generate() + tree = ast.parse(source) + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "skip_trivial_type": + namespace = {} + # TRIVIAL_TYPES is assigned just above the def in the same emitted block. + for prior in tree.body: + if ( + isinstance(prior, ast.Assign) + and getattr(prior.targets[0], "id", "") == "TRIVIAL_TYPES" + ): + exec(compile(ast.Module([prior], []), "", "exec"), namespace) + exec(compile(ast.Module([node], []), "", "exec"), namespace) + return namespace["skip_trivial_type"] + self.fail("skip_trivial_type was not emitted") + + def test_cffi_objects_are_skipped_and_ordinary_objects_are_not(self): + skip = self._emitted_skip_trivial_type() + + class _FakeFFI: + pass + + # cffi types report _cffi_backend as their defining module; that is the signal, not + # the attribute name the object happens to be bound to. + _FakeFFI.__module__ = "_cffi_backend" + self.assertTrue(skip(_FakeFFI())) + + class _OrdinaryTarget: + pass + + self.assertFalse(skip(_OrdinaryTarget())) + + def test_trivial_types_are_still_skipped(self): + skip = self._emitted_skip_trivial_type() + for value in (1, "s", 1.0, True, b"b", (), [], {}, set(), None): + with self.subTest(value=value): + self.assertTrue(skip(value)) + + def test_an_attribute_merely_NAMED_ffi_is_not_skipped(self): + """Name-based matching would collide with legitimate targets; this is type-based.""" + skip = self._emitted_skip_trivial_type() + + class ffi: # noqa: N801 - deliberately named like the cffi attribute + pass + + self.assertFalse(skip(ffi())) + + class SelfNoiseVocabularyTests(unittest.TestCase): """Emitted COMMENTS must not contain fusil's own crash vocabulary.