From 30cfc01d8afe7583afddc0227e13cc67f90cc062 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 11 Mar 2026 16:12:56 -0400 Subject: [PATCH 1/4] dffi: fixup get_symbol include_incomplete logic --- src/dwarffi/dffi.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/dwarffi/dffi.py b/src/dwarffi/dffi.py index b11cea1..21ac0bb 100644 --- a/src/dwarffi/dffi.py +++ b/src/dwarffi/dffi.py @@ -204,9 +204,7 @@ def _is_acceptable(sym: VtypeSymbol) -> bool: if include_incomplete: return True addr = getattr(sym, "address", None) - has_addr = addr not in (None, 0) - has_type = getattr(sym, "type_info", None) is not None - return has_addr or has_type + return addr not in (None, 0) if path is not None: vj = self.vtypejsons.get(path) From bb463264284f67be2bfd5e6be9e1f1098d30569e Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 11 Mar 2026 16:14:56 -0400 Subject: [PATCH 2/4] types: fixup get_decoded_constant_data --- src/dwarffi/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dwarffi/types.py b/src/dwarffi/types.py index 36fdf34..c8ee7b4 100644 --- a/src/dwarffi/types.py +++ b/src/dwarffi/types.py @@ -421,9 +421,9 @@ def __init__(self, name: str, data: Dict[str, Any]): def get_decoded_constant_data(self) -> Optional[bytes]: """Decodes base64-encoded constant data associated with the symbol.""" - if self.constant_data: + if self.constant_data is not None: try: - return base64.b64decode(self.constant_data) + return base64.b64decode(self.constant_data, validate=True) except Exception: return None return None From 162caf0d07fe0ac30b6d4f0566c35018d47c2772 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 11 Mar 2026 16:17:27 -0400 Subject: [PATCH 3/4] more tests --- tests/test_backends.py | 56 ++ tests/test_bitfield_invariants.py | 41 ++ tests/test_comp.py | 1136 +++++++++++++++++++++++++++++ tests/test_ffi.py | 44 +- tests/test_zero_copy.py | 51 ++ 5 files changed, 1327 insertions(+), 1 deletion(-) create mode 100644 tests/test_backends.py create mode 100644 tests/test_bitfield_invariants.py create mode 100644 tests/test_comp.py create mode 100644 tests/test_zero_copy.py diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..9b8c202 --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,56 @@ +import pytest +from dwarffi import DFFI + +class RecordingBackend: + def __init__(self, mem: bytearray): + self.mem = mem + self.reads = [] + self.writes = [] + + def read(self, address: int, size: int) -> bytes: + self.reads.append((address, size)) + return bytes(self.mem[address:address+size]) + + def write(self, address: int, data: bytes) -> None: + self.writes.append((address, bytes(data))) + self.mem[address:address+len(data)] = data + +def _isf_ptr_struct(): + return { + "metadata": {}, + "base_types": { + "u32": {"kind": "int", "size": 4, "signed": False, "endian": "little"}, + "pointer": {"kind": "pointer", "size": 8, "endian": "little"}, + "void": {"kind": "void", "size": 0, "signed": False, "endian": "little"}, + }, + "user_types": { + "node": { + "kind": "struct", + "size": 16, + "fields": { + "val": {"offset": 0, "type": {"kind": "base", "name": "u32"}}, + "next": {"offset": 8, "type": {"kind": "pointer", "subtype": {"kind": "struct", "name": "node"}}}, + }, + } + }, + "enums": {}, + "symbols": {}, + } + +def test_backend_read_write_sizes_and_addresses(): + ffi = DFFI(_isf_ptr_struct()) + mem = bytearray(b"\x00" * 0x100) + backend = RecordingBackend(mem) + ffi.backend = backend + + # bind a node at address 0x20 + n = ffi.from_address("struct node", 0x20) + + # write should call backend.write with 4 bytes at 0x20 + n.val = 0xDEADBEEF + assert backend.writes[-1][0] == 0x20 + assert len(backend.writes[-1][1]) == 4 + + # read should call backend.read with 4 bytes at 0x20 + _ = n.val + assert backend.reads[-1] == (0x20, 4) diff --git a/tests/test_bitfield_invariants.py b/tests/test_bitfield_invariants.py new file mode 100644 index 0000000..7023f0c --- /dev/null +++ b/tests/test_bitfield_invariants.py @@ -0,0 +1,41 @@ +import struct +import pytest +from dwarffi import DFFI + +def test_bitfield_preserves_other_bits_across_patterns(): + base_types = { + "u16": {"size": 2, "signed": False, "kind": "int", "endian": "little"}, + "void": {"size": 0, "signed": False, "kind": "void", "endian": "little"}, + "pointer": {"size": 8, "signed": False, "kind": "pointer", "endian": "little"}, + } + ffi = DFFI({ + "metadata": {}, + "base_types": base_types, + "user_types": { + "bf": { + "kind": "struct", "size": 2, + "fields": { + # 5-bit field at bit 3 + "f": {"offset": 0, "type": {"kind": "bitfield", "bit_length": 5, "bit_position": 3, + "type": {"kind": "base", "name": "u16"}}}, + }, + } + }, + "enums": {}, "symbols": {}, + }) + + mask = ((1 << 5) - 1) << 3 # 0b11111 << 3 + patterns = [0x0000, 0xFFFF, 0xA55A, 0x1234, 0x8001, 0x0F0F] + + for cur in patterns: + buf = bytearray(2) + struct.pack_into(" dict: + base = { + "int": {"kind": "int", "size": 4, "signed": True, "endian": endian}, + "char": {"kind": "int", "size": 1, "signed": False, "endian": endian}, + "pointer": {"kind": "pointer", "size": ptr_size, "endian": endian}, + "void": {"kind": "void", "size": 0, "signed": False, "endian": endian}, + } + if extra_base: + base.update(extra_base) + return { + "metadata": {}, + "base_types": base, + "user_types": extra_user or {}, + "enums": extra_enums or {}, + "symbols": extra_syms or {}, + } + + +@pytest.fixture +def simple_ffi() -> DFFI: + return DFFI(_minimal_isf()) + + +@pytest.fixture +def struct_ffi() -> DFFI: + isf = _minimal_isf( + extra_user={ + "point": { + "kind": "struct", + "size": 8, + "fields": { + "x": {"offset": 0, "type": {"kind": "base", "name": "int"}}, + "y": {"offset": 4, "type": {"kind": "base", "name": "int"}}, + }, + } + } + ) + return DFFI(isf) + + +@pytest.fixture +def enum_ffi() -> DFFI: + isf = _minimal_isf( + extra_enums={ + "color": { + "size": 4, + "base": "int", + "constants": {"RED": 0, "GREEN": 1, "BLUE": 2}, + } + } + ) + return DFFI(isf) + + +# --------------------------------------------------------------------------- +# 1. VtypeJson schema validation +# --------------------------------------------------------------------------- + +class TestVtypeJsonValidation: + def test_missing_base_types_section(self): + with pytest.raises(ValueError, match="missing required"): + VtypeJson({"metadata": {}, "user_types": {}, "enums": {}, "symbols": {}}) + + def test_missing_user_types_section(self): + with pytest.raises(ValueError, match="missing required"): + VtypeJson({"metadata": {}, "base_types": {}, "enums": {}, "symbols": {}}) + + def test_user_type_missing_kind_raises(self): + bad = { + "metadata": {}, + "base_types": {}, + "user_types": {"bad_type": {"size": 4, "fields": {}}}, # no 'kind' + "enums": {}, + "symbols": {}, + } + with pytest.raises(ValueError, match="missing the required 'kind'"): + VtypeJson(bad) + + def test_non_dict_root_raises(self): + with pytest.raises(ValueError, match="root must be an object"): + VtypeJson(io.StringIO(json.dumps([{"base_types": {}, "user_types": {}}]))) + + def test_bad_input_type_raises_typeerror(self): + with pytest.raises(TypeError): + VtypeJson(12345) + + def test_valid_minimal_dict_loads(self): + vj = VtypeJson(_minimal_isf()) + assert isinstance(vj, VtypeJson) + + def test_file_not_found_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + VtypeJson(str(tmp_path / "nonexistent.json")) + + def test_json_decode_error_raises(self, tmp_path): + bad_json = tmp_path / "bad.json" + bad_json.write_text("this is not json", encoding="utf-8") + with pytest.raises(ValueError, match="Error decoding JSON"): + VtypeJson(str(bad_json)) + + def test_lzma_error_raises(self, tmp_path): + bad_xz = tmp_path / "bad.json.xz" + bad_xz.write_bytes(b"not xz data at all") + with pytest.raises(ValueError, match="Error decompressing"): + VtypeJson(str(bad_xz)) + + def test_file_like_object_loads(self): + vj = VtypeJson(io.StringIO(json.dumps(_minimal_isf()))) + assert isinstance(vj, VtypeJson) + + def test_json_file_loads(self, tmp_path): + p = tmp_path / "ok.json" + p.write_text(json.dumps(_minimal_isf()), encoding="utf-8") + vj = VtypeJson(str(p)) + assert isinstance(vj, VtypeJson) + + def test_xz_file_loads(self, tmp_path): + p = tmp_path / "ok.json.xz" + with lzma.open(p, "wt", encoding="utf-8") as f: + json.dump(_minimal_isf(), f) + vj = VtypeJson(str(p)) + assert isinstance(vj, VtypeJson) + + def test_circular_typedef_in_vtypejson_resolve(self): + isf = _minimal_isf() + isf["typedefs"] = { + "A": {"kind": "typedef", "name": "B"}, + "B": {"kind": "typedef", "name": "A"}, + } + vj = VtypeJson(isf) + with pytest.raises(ValueError, match="Circular typedef"): + vj._resolve_type_info({"kind": "typedef", "name": "A"}) + + +# --------------------------------------------------------------------------- +# 2. _FallbackBytesStruct +# --------------------------------------------------------------------------- + +class TestFallbackBytesStruct: + def test_unpack_returns_bytes(self): + fb = _FallbackBytesStruct(6) + result = fb.unpack_from(b"ABCDEF", 0) + assert result == (b"ABCDEF",) + + def test_unpack_with_offset(self): + fb = _FallbackBytesStruct(3) + result = fb.unpack_from(b"XYZABC", 3) + assert result == (b"ABC",) + + def test_pack_into_writes_bytes(self): + fb = _FallbackBytesStruct(4) + buf = bytearray(8) + fb.pack_into(buf, 2, b"\xAA\xBB\xCC\xDD") + assert buf[2:6] == b"\xAA\xBB\xCC\xDD" + + def test_pack_wrong_size_raises(self): + fb = _FallbackBytesStruct(4) + buf = bytearray(8) + with pytest.raises(ValueError, match="Expected exactly 4 bytes"): + fb.pack_into(buf, 0, b"\x01\x02") + + def test_pack_non_bytes_raises(self): + fb = _FallbackBytesStruct(4) + buf = bytearray(8) + with pytest.raises(TypeError): + fb.pack_into(buf, 0, 12345) + + def test_unpack_short_slice_pads(self): + fb = _FallbackBytesStruct(4) + result = fb.unpack_from(b"\xAA\xBB", 0) + assert result == (b"\xAA\xBB\x00\x00",) + + def test_format_is_empty_string(self): + fb = _FallbackBytesStruct(10) + assert fb.format == "" + + def test_size_attribute(self): + fb = _FallbackBytesStruct(16) + assert fb.size == 16 + + +# --------------------------------------------------------------------------- +# 3. VtypeBaseType exotic kinds: bool, char, f16 +# --------------------------------------------------------------------------- + +class TestVtypeBaseTypeExoticKinds: + def test_bool_kind_compiles(self): + bt = VtypeBaseType("mybool", {"kind": "bool", "size": 1, "signed": False, "endian": "little"}) + cs = bt.get_compiled_struct() + assert cs is not None + buf = bytearray(1) + cs.pack_into(buf, 0, True) + assert buf[0] == 1 + + def test_char_kind_unsigned(self): + bt = VtypeBaseType("char", {"kind": "char", "size": 1, "signed": False, "endian": "little"}) + cs = bt.get_compiled_struct() + assert cs is not None + buf = bytearray(1) + cs.pack_into(buf, 0, 255) + assert buf[0] == 255 + + def test_f16_half_precision(self): + bt = VtypeBaseType("f16", {"kind": "float", "size": 2, "signed": True, "endian": "little"}) + cs = bt.get_compiled_struct() + assert cs is not None + assert cs.size == 2 + + def test_f32_and_f64(self): + f32 = VtypeBaseType("f32", {"kind": "float", "size": 4, "signed": True, "endian": "little"}) + f64 = VtypeBaseType("f64", {"kind": "float", "size": 8, "signed": True, "endian": "little"}) + assert f32.get_compiled_struct() is not None + assert f64.get_compiled_struct() is not None + + +# --------------------------------------------------------------------------- +# 4. VtypeUserType.get_aggregated_struct() failure paths +# --------------------------------------------------------------------------- + +class TestAggregatedStructFailurePaths: + def _make_ffi_with_struct(self, fields: dict, size: int) -> DFFI: + isf = _minimal_isf(extra_user={"s": {"kind": "struct", "size": size, "fields": fields}}) + return DFFI(isf) + + def test_pointer_field_returns_none(self): + ffi = self._make_ffi_with_struct( + {"p": {"offset": 0, "type": {"kind": "pointer", "subtype": {"kind": "base", "name": "int"}}}}, + size=8, + ) + t = ffi.get_user_type("s") + assert t.get_aggregated_struct(ffi) is None + + def test_exotic_int_fallback_blocks_aggregation(self): + isf = _minimal_isf( + extra_base={"int24": {"kind": "int", "size": 3, "signed": True, "endian": "little"}}, + extra_user={ + "s": { + "kind": "struct", + "size": 3, + "fields": {"v": {"offset": 0, "type": {"kind": "base", "name": "int24"}}}, + } + }, + ) + ffi = DFFI(isf) + t = ffi.get_user_type("s") + # _FallbackIntStruct has empty .format, so aggregation should fail + assert t.get_aggregated_struct(ffi) is None + + def test_union_overlap_blocks_aggregation(self): + isf = _minimal_isf( + extra_user={ + "u": { + "kind": "union", + "size": 4, + "fields": { + "a": {"offset": 0, "type": {"kind": "base", "name": "int"}}, + "b": {"offset": 0, "type": {"kind": "base", "name": "int"}}, + }, + } + } + ) + ffi = DFFI(isf) + t = ffi.get_user_type("u") + assert t.get_aggregated_struct(ffi) is None + + +# --------------------------------------------------------------------------- +# 5. VtypeEnum lazy _val_to_name cache +# --------------------------------------------------------------------------- + +class TestVtypeEnumCache: + def test_val_to_name_starts_none(self): + e = VtypeEnum("e", {"size": 4, "base": "int", "constants": {"A": 1, "B": 2}}) + assert e._val_to_name is None + + def test_get_name_for_value_builds_cache(self): + e = VtypeEnum("e", {"size": 4, "base": "int", "constants": {"A": 1, "B": 2}}) + assert e.get_name_for_value(1) == "A" + assert e._val_to_name is not None + assert e.get_name_for_value(2) == "B" + + def test_unknown_value_returns_none(self): + e = VtypeEnum("e", {"size": 4, "base": "int", "constants": {"A": 0}}) + assert e.get_name_for_value(99) is None + + +# --------------------------------------------------------------------------- +# 6. VtypeSymbol.get_decoded_constant_data() +# --------------------------------------------------------------------------- + +class TestVtypeSymbolConstantData: + def test_valid_base64_decodes(self): + payload = b"hello world" + encoded = base64.b64encode(payload).decode() + s = VtypeSymbol("sym", {"address": 0x1000, "constant_data": encoded}) + assert s.get_decoded_constant_data() == payload + + def test_no_constant_data_returns_none(self): + s = VtypeSymbol("sym", {"address": 0x1000}) + assert s.get_decoded_constant_data() is None + + def test_invalid_base64_returns_none(self): + s = VtypeSymbol("sym", {"address": 0x1000, "constant_data": "!!!not valid!!!"}) + assert s.get_decoded_constant_data() is None + + def test_symbol_pretty_print(self): + s = VtypeSymbol("my_var", {"address": 0xDEAD, "type": {"kind": "base", "name": "int"}}) + out = s.pretty_print() + assert "my_var" in out + assert "0xdead" in out.lower() + + def test_symbol_str_equals_pretty_print(self): + s = VtypeSymbol("my_var", {"address": 0x10, "type": {"kind": "base", "name": "int"}}) + assert str(s) == s.pretty_print() + + def test_symbol_to_dict(self): + s = VtypeSymbol("foo", {"address": 0x400, "type": {"kind": "struct", "name": "bar"}}) + d = s.to_dict() + assert d["name"] == "foo" + assert d["address"] == 0x400 + assert d["type_info"]["name"] == "bar" + + +# --------------------------------------------------------------------------- +# 7. EnumInstance equality semantics +# --------------------------------------------------------------------------- + +class TestEnumInstanceEquality: + @pytest.fixture + def color_enum(self): + return VtypeEnum("color", {"size": 4, "base": "int", "constants": {"RED": 0, "GREEN": 1}}) + + def test_equal_to_same_value(self, color_enum): + assert EnumInstance(color_enum, 0) == EnumInstance(color_enum, 0) + + def test_not_equal_different_value(self, color_enum): + assert EnumInstance(color_enum, 0) != EnumInstance(color_enum, 1) + + def test_equal_to_int(self, color_enum): + assert EnumInstance(color_enum, 1) == 1 + + def test_not_equal_to_wrong_int(self, color_enum): + assert EnumInstance(color_enum, 1) != 99 + + def test_equal_to_str_name(self, color_enum): + assert EnumInstance(color_enum, 0) == "RED" + + def test_not_equal_to_wrong_str(self, color_enum): + assert EnumInstance(color_enum, 0) != "GREEN" + + def test_not_equal_to_unknown_type(self, color_enum): + assert EnumInstance(color_enum, 0) != [0] + + def test_repr_known_name(self, color_enum): + r = repr(EnumInstance(color_enum, 1)) + assert "GREEN" in r and "1" in r + + def test_repr_unknown_value(self, color_enum): + r = repr(EnumInstance(color_enum, 99)) + assert "99" in r + + +# --------------------------------------------------------------------------- +# 8. BoundArrayView equality edge cases +# --------------------------------------------------------------------------- + +class TestBoundArrayViewEquality: + @pytest.fixture + def arr(self, simple_ffi): + return simple_ffi.new("int[4]", [10, 20, 30, 40]) + + def test_equal_to_matching_list(self, arr): + assert arr == [10, 20, 30, 40] + + def test_not_equal_to_different_list(self, arr): + assert arr != [10, 20, 30, 99] + + def test_length_mismatch_not_equal(self, arr): + assert arr != [10, 20, 30] + + def test_not_equal_to_string(self, arr): + assert arr != "not a list" + + def test_not_equal_to_int(self, arr): + assert arr != 42 + + def test_equal_to_another_array_view_same_content(self, simple_ffi): + arr2 = simple_ffi.new("int[4]", [10, 20, 30, 40]) + arr1 = simple_ffi.new("int[4]", [10, 20, 30, 40]) + assert arr1 == arr2 + + def test_ne_operator_works(self, arr): + assert arr != [1, 2, 3, 4] + + +# --------------------------------------------------------------------------- +# 9. BoundArrayView.__add__ address propagation +# --------------------------------------------------------------------------- + +class TestBoundArrayViewAdd: + def test_add_without_base_address(self, simple_ffi): + arr = simple_ffi.new("int[5]") + ptr = arr + 2 + assert isinstance(ptr, Ptr) + assert ptr.address == 2 * 4 # offset 0 + 2 * sizeof(int) + + def test_add_with_base_address(self, simple_ffi): + buf = bytearray(20) + arr = simple_ffi.from_buffer("int[5]", buf, address=0xCAFE0000) + ptr = arr + 3 + assert ptr.address == 0xCAFE0000 + 3 * 4 + + def test_add_preserves_subtype(self, simple_ffi): + arr = simple_ffi.new("int[3]") + ptr = arr + 0 + assert ptr.points_to_type_name == "int" + + def test_add_non_int_returns_not_implemented(self, simple_ffi): + arr = simple_ffi.new("int[3]") + assert arr.__add__("bad") is NotImplemented + + +# --------------------------------------------------------------------------- +# 10. Ptr.__hash__ / set / dict usage +# --------------------------------------------------------------------------- + +class TestPtrHashAndGetitem: + def test_ptr_is_hashable(self, simple_ffi): + p = simple_ffi.cast("int *", 0x1000) + assert isinstance(hash(p), int) + + def test_ptr_usable_as_dict_key(self, simple_ffi): + p = simple_ffi.cast("int *", 0x2000) + d = {p: "value"} + assert d[p] == "value" + + def test_different_address_different_hash(self, simple_ffi): + p1 = simple_ffi.cast("int *", 0x100) + p2 = simple_ffi.cast("int *", 0x200) + assert hash(p1) != hash(p2) + + def test_ptr_in_set_deduplicates(self, simple_ffi): + p1 = simple_ffi.cast("int *", 0x500) + p2 = simple_ffi.cast("int *", 0x500) + assert len({p1, p2}) == 1 + + def test_getitem_without_backend_raises(self, simple_ffi): + p = simple_ffi.cast("int *", 0x1000) + with pytest.raises(RuntimeError, match="No memory backend"): + _ = p[0] + + def test_getitem_non_int_raises(self, simple_ffi): + p = simple_ffi.cast("int *", 0x0) + with pytest.raises(TypeError, match="integers"): + _ = p["bad"] + + +# --------------------------------------------------------------------------- +# 11. DFFI.string() on enum instances +# --------------------------------------------------------------------------- + +class TestDFFIStringOnEnum: + def test_string_known_name(self, enum_ffi): + inst = enum_ffi.from_buffer("enum color", bytearray(4)) + inst[0] = "GREEN" + assert enum_ffi.string(inst) == b"GREEN" + + def test_string_unknown_value_returns_numeric(self, enum_ffi): + buf = bytearray(4) + stdlib_struct.pack_into(" Date: Wed, 11 Mar 2026 16:18:50 -0400 Subject: [PATCH 4/4] ruff --- tests/test_backends.py | 2 +- tests/test_bitfield_invariants.py | 3 ++- tests/test_comp.py | 6 +----- tests/test_zero_copy.py | 2 ++ 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/test_backends.py b/tests/test_backends.py index 9b8c202..e4baede 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -1,6 +1,6 @@ -import pytest from dwarffi import DFFI + class RecordingBackend: def __init__(self, mem: bytearray): self.mem = mem diff --git a/tests/test_bitfield_invariants.py b/tests/test_bitfield_invariants.py index 7023f0c..c081703 100644 --- a/tests/test_bitfield_invariants.py +++ b/tests/test_bitfield_invariants.py @@ -1,7 +1,8 @@ import struct -import pytest + from dwarffi import DFFI + def test_bitfield_preserves_other_bits_across_patterns(): base_types = { "u16": {"size": 2, "signed": False, "kind": "int", "endian": "little"}, diff --git a/tests/test_comp.py b/tests/test_comp.py index d74de40..f7232cb 100644 --- a/tests/test_comp.py +++ b/tests/test_comp.py @@ -43,9 +43,8 @@ import io import json import lzma -import sys import struct as stdlib_struct -from typing import Any, Dict +import sys import pytest @@ -53,7 +52,6 @@ from dwarffi import ( DFFI, BoundArrayView, - BoundTypeInstance, BytesBackend, EnumInstance, Ptr, @@ -66,10 +64,8 @@ VtypeSymbol, VtypeUserType, _FallbackBytesStruct, - _FallbackIntStruct, ) - # --------------------------------------------------------------------------- # Shared helpers / fixtures # --------------------------------------------------------------------------- diff --git a/tests/test_zero_copy.py b/tests/test_zero_copy.py index e47a5cd..56755d8 100644 --- a/tests/test_zero_copy.py +++ b/tests/test_zero_copy.py @@ -1,6 +1,8 @@ import pytest + from dwarffi import DFFI + def _isf_base_le(): return { "metadata": {},