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) 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 diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..e4baede --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,56 @@ +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..c081703 --- /dev/null +++ b/tests/test_bitfield_invariants.py @@ -0,0 +1,42 @@ +import struct + +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("