diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8ae703..1365aa9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 15 strategy: matrix: - python-version: ["3.10", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 @@ -39,7 +39,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install --upgrade wheel build - python -m pip install pwntools pytest libdebug + python -m pip install pwntools pytest libdebug mktestdocs - name: Install library run: | diff --git a/README.md b/README.md index dabc79d..ee49a49 100644 --- a/README.md +++ b/README.md @@ -1 +1,65 @@ # libdestruct + +**Native structs made Pythonic.** + +libdestruct is a Python library for defining C-like data structures and inflating them directly from raw memory. It is designed for reverse engineering, binary analysis, and debugger scripting — anywhere you need to make sense of packed binary data without writing boilerplate. + +With libdestruct you can: +- Define C structs using Python type annotations +- Read and write typed values from raw memory buffers +- Follow pointers, including self-referential types (linked lists, trees) +- Work with arrays, enums, and nested structs +- Parse C struct definitions directly from source +- Snapshot values and track changes with freeze/diff/reset + +## Installation + +```bash +pip install git+https://github.com/mrindeciso/libdestruct.git +``` + +## Your first script + +```python +from libdestruct import struct, c_int, c_long, inflater + +class player_t(struct): + health: c_int + score: c_long + +memory = bytearray(b"\x64\x00\x00\x00\x39\x05\x00\x00\x00\x00\x00\x00") + +lib = inflater(memory) +player = lib.inflate(player_t, 0) + +print(player.health.value) # 100 +print(player.score.value) # 1337 + +# Write a new value back to memory +player.health.value = 200 +print(player.health.value) # 200 +``` + +You can also skip the Python definition and parse C directly: + +```python +from libdestruct.c.struct_parser import definition_to_type + +player_t = definition_to_type(""" + struct player_t { + int health; + long score; + }; +""") + +player = player_t.from_bytes(b"\x64\x00\x00\x00\x39\x05\x00\x00\x00\x00\x00\x00") +print(player.health.value) # 100 +``` + +## Project Links + +Documentation: [docs/](docs/) + +## License + +libdestruct is licensed under the [MIT License](LICENSE). diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..046865e --- /dev/null +++ b/SKILL.md @@ -0,0 +1,564 @@ +# libdestruct Skills + +libdestruct is a Python library for destructuring binary data into typed objects. It maps raw bytes to C-like types (integers, floats, strings, structs, pointers, arrays, enums, bitfields) with read/write support. + +## Installation + +```bash +pip install git+https://github.com/mrindeciso/libdestruct.git +``` + +## Core Concepts + +All types inherit from `obj`. Every `obj` has: +- `.value` property to read/write the underlying data +- `.address` property for the memory offset +- `.to_bytes()` to serialize back to bytes +- `.freeze()` / `.diff()` / `.reset()` for snapshotting +- `.hexdump()` for a hex dump of the object's bytes +- `.from_bytes(data)` class method to create a read-only instance from raw bytes + +Memory is accessed through an `inflater`, which wraps a `bytes`, `bytearray`, or `mmap.mmap` buffer. Use `bytearray` or writable mmap for read/write access. For file-backed memory, use `inflater_from_file()`. + +## Quick Reference + +### Imports + +```python +from typing import Annotated +from libdestruct import ( + inflater, # memory wrapper (bytearray / mmap) + inflater_from_file, # file-backed inflater (convenience) + FileInflater, # file-backed inflater class + struct, # struct base class + c_int, c_uint, # 32-bit integers (signed/unsigned) + c_long, c_ulong, # 64-bit integers (signed/unsigned) + c_short, c_ushort, # 16-bit integers (signed/unsigned) + c_char, c_uchar, # 8-bit integers (signed/unsigned) + c_float, c_double, # IEEE 754 floats (32/64-bit) + c_str, # null-terminated C string + ptr, # 8-byte pointer + ptr_to, # typed pointer field descriptor (legacy) + ptr_to_self, # self-referential pointer field descriptor (legacy) + array, array_of, # array type + field descriptor + vla_of, # variable-length array field descriptor + enum, enum_of, # enum type + field descriptor + flags, flags_of, # bit flags type + field descriptor + bitfield_of, # bitfield descriptor + union, # union annotation type + union_of, # plain union field descriptor + tagged_union, # tagged union field descriptor + offset, # explicit field offset + size_of, # get size in bytes of any type/instance/field + alignment_of, # get natural alignment of any type/instance +) +``` + +### Type Sizes + +| Type | Size (bytes) | +|---|---| +| `c_int` / `c_uint` | 4 | +| `c_long` / `c_ulong` | 8 | +| `c_float` | 4 | +| `c_double` | 8 | +| `ptr` | 8 | +| `c_str` | variable (reads until null) | + +### Reading Primitives from a Buffer + +```python +memory = bytearray(b"\x2a\x00\x00\x00\x00\x00\x00\x00") +lib = inflater(memory) + +x = lib.inflate(c_int, 0) # inflate c_int at offset 0 +print(x.value) # 42 + +y = lib.inflate(c_long, 0) # inflate c_long at offset 0 +print(y.value) +``` + +### Reading Primitives from Raw Bytes + +```python +x = c_int.from_bytes(b"\x2a\x00\x00\x00") +print(x.value) # 42 +# Note: from_bytes returns a frozen (read-only) object +``` + +### Writing Primitives + +```python +memory = bytearray(4) +lib = inflater(memory) +x = lib.inflate(c_int, 0) +x.value = -1 +print(memory) # bytearray(b'\xff\xff\xff\xff') +``` + +### Defining Structs + +```python +class player_t(struct): + health: c_int + score: c_uint + position_x: c_float + position_y: c_float +``` + +Struct fields are laid out sequentially. Access members as attributes; each returns a typed `obj` (use `.value` to get the Python value). + +### Inflating Structs + +```python +import struct as pystruct + +memory = bytearray(16) +memory[0:4] = pystruct.pack(" B -> C) and alignment inheritance both work. Parent fields always appear first in layout and `to_dict()`. + +### size_of + +```python +size_of(c_int) # 4 +size_of(c_long) # 8 +size_of(player_t) # computed from fields +size_of(array_of(c_int, 10)) # 40 +size_of(some_instance) # works on instances too +``` + +### Hex Dump + +```python +player = lib.inflate(player_t, 0) +print(player.hexdump()) +# 00000000 64 00 00 00 88 13 00 00 00 00 c0 3f 00 00 40 c0 |d..........?..@.| health, score, position_x, position_y +``` + +Struct hexdumps annotate lines with field names. Primitive hexdumps show raw bytes. + +### Dict / JSON Export + +```python +point = point_t.from_bytes(memory) +point.to_dict() # {"x": 10, "y": 20} + +import json +json.dumps(entity.to_dict()) # nested structs produce nested dicts +``` + +`to_dict()` works on all types: primitives return their value, structs return `{name: value}` dicts, arrays return lists, unions return variant values, enums return their int value. + +### Freeze / Diff / Reset + +```python +x = lib.inflate(c_int, 0) +x.freeze() # snapshot current value +x.value = 99 # raises ValueError (frozen) + +# For non-frozen objects: +x.freeze() # save state +# ... memory changes externally ... +print(x.diff()) # (old_value, new_value) +x.reset() # restore to frozen value +x.update() # update frozen value to current +``` + +### C Struct Parser + +Parse C struct definitions directly (requires `pycparser`): + +```python +from libdestruct.c.struct_parser import definition_to_type + +player_t = definition_to_type(""" + struct player_t { + int health; + unsigned int score; + float x; + double y; + }; +""") + +player = player_t.from_bytes(data) +``` + +Supports: nested structs, pointers (including self-referential), arrays, bitfields, typedefs, `#include` directives (requires a C preprocessor), and `__attribute__` stripping. + +## Common Patterns + +### Parsing a binary format + +```python +class header_t(struct): + magic: c_uint + version: c_int + num_entries: c_int + entries_ptr: ptr[entry_t] + +with open("file.bin", "rb") as f: + data = bytearray(f.read()) + +lib = inflater(data) +header = lib.inflate(header_t, 0) + +for i in range(header.num_entries.value): + entry = header.entries_ptr[i] + # process entry... +``` + +### Modifying binary data in-place + +```python +data = bytearray(open("save.bin", "rb").read()) +lib = inflater(data) +player = lib.inflate(player_t, 0x100) +player.health.value = 999 +open("save.bin", "wb").write(data) +``` + +### File-backed inflater + +Read (and optionally write) binary files directly via mmap, without loading the entire file into memory: + +```python +# Read-only +with inflater_from_file("firmware.bin") as lib: + header = lib.inflate(header_t, 0) + print(header.magic.value) + +# Writable — changes are persisted to the file +with inflater_from_file("save.bin", writable=True) as lib: + player = lib.inflate(player_t, 0x100) + player.health.value = 999 +``` + +You can also pass an `mmap.mmap` object directly to `inflater()`. + +### Working with libdebug + +libdestruct integrates with [libdebug](https://github.com/libdebug/libdebug) for live process memory inspection. The debugger's memory view can be passed directly to `inflater`. diff --git a/docs/advanced/alignment.md b/docs/advanced/alignment.md new file mode 100644 index 0000000..57ec9e0 --- /dev/null +++ b/docs/advanced/alignment.md @@ -0,0 +1,126 @@ +# Struct Alignment + +By default, libdestruct structs are **packed** — fields are placed sequentially with no padding, like C structs with `__attribute__((packed))`. + +You can opt into natural alignment (matching standard C struct layout) by setting `_aligned_ = True` on your struct: + +## Enabling Alignment + +```python +from libdestruct import struct, c_char, c_int, c_long, c_short, size_of, alignment_of + +class packed_t(struct): + a: c_char + b: c_int + +size_of(packed_t) # 5 (1 + 4, no padding) + +class aligned_t(struct): + _aligned_ = True + a: c_char + b: c_int + +size_of(aligned_t) # 8 (1 + 3 padding + 4) +``` + +## Alignment Rules + +When `_aligned_ = True`: + +1. **Field alignment**: Each field is placed at an offset that is a multiple of its natural alignment (1 for `c_char`, 2 for `c_short`, 4 for `c_int`/`c_float`, 8 for `c_long`/`c_double`/`ptr`). +2. **Tail padding**: The struct's total size is rounded up to a multiple of the struct's alignment (the maximum alignment of any member). + +```python +class mixed_t(struct): + _aligned_ = True + a: c_char # offset 0, size 1 + b: c_short # offset 2 (aligned to 2), size 2 + c: c_char # offset 4, size 1 + d: c_int # offset 8 (aligned to 4), size 4 + e: c_char # offset 12, size 1 + f: c_long # offset 16 (aligned to 8), size 8 + +size_of(mixed_t) # 24 (padded to 8-byte boundary) +``` + +## Reading Aligned Structs + +```python +import struct as pystruct +from libdestruct import inflater + +class header_t(struct): + _aligned_ = True + flags: c_char + size: c_int + +# flags at offset 0, 3 bytes padding, size at offset 4 +memory = pystruct.pack(" + + struct packet { + int type; + unsigned long length; + }; +""") +``` + +!!! warning + Include expansion requires a C preprocessor (`cpp`) to be available on your system. + +## GCC Attributes + +`__attribute__((...))` annotations are automatically stripped before parsing: + +```python +t = definition_to_type(""" + struct __attribute__((packed)) data { + int x; + int y; + }; +""") +``` + +## Caching + +Parsed struct definitions are cached globally. Parsing the same struct name twice returns the cached version: + +```python +# First call parses +t1 = definition_to_type("struct foo { int x; };") + +# Second call with same name returns cached type +t2 = definition_to_type("struct foo { int x; };") +``` diff --git a/docs/advanced/forward_refs.md b/docs/advanced/forward_refs.md new file mode 100644 index 0000000..c54d1bd --- /dev/null +++ b/docs/advanced/forward_refs.md @@ -0,0 +1,85 @@ +# Forward References + +Forward references allow structs to reference types that haven't been fully defined yet — most commonly, the struct itself. This is essential for recursive data structures like linked lists and trees. + +## The `ptr["TypeName"]` Syntax + +Use a string inside `ptr[...]` to reference a type by name: + +```python +from libdestruct import struct, c_int, ptr + +class Node(struct): + val: c_int + next: ptr["Node"] +``` + +At inflation time, the string `"Node"` is resolved to the actual `Node` class. This works because Python's `from __future__ import annotations` (used internally by libdestruct) defers annotation evaluation. + +## The Legacy `ptr_to_self` Shortcut + +For the common case of a pointer to the enclosing struct, the legacy `ptr_to_self` syntax is also available: + +```python +from libdestruct import struct, c_int, ptr, ptr_to_self + +class Node(struct): + val: c_int + next: ptr = ptr_to_self() +``` + +This is equivalent to `ptr["Node"]` but doesn't require you to spell out the type name. The `ptr["TypeName"]` syntax is preferred as it is more explicit. + +## Linked List Example + +```python +from libdestruct import struct, c_int, ptr, inflater + +class Node(struct): + val: c_int + next: ptr["Node"] + +# Build a two-node list in memory +# Node layout: c_int(4) + ptr(8) = 12 bytes +memory = bytearray(24) + +import struct as pystruct +# Node 0 at offset 0 +memory[0:4] = pystruct.pack(" offset 12 + +# Node 1 at offset 12 +memory[12:16] = pystruct.pack(" out of bounds + +lib = inflater(memory) +head = lib.inflate(Node, 0) + +print(head.val.value) # 10 +print(head.next.unwrap().val.value) # 20 +print(head.next.unwrap().next.try_unwrap()) # None (address out of bounds) +``` + +## Tree Example + +```python +from libdestruct import struct, c_uint, ptr + +class TreeNode(struct): + data: c_uint + left: ptr["TreeNode"] + right: ptr["TreeNode"] +``` + +## How It Works + +When libdestruct encounters a `ptr["TypeName"]` annotation: + +1. It stores the string reference during struct class creation +2. At inflation time, it resolves the string against all known struct types +3. The resolved type is used as the pointer's wrapper type + +This means the referenced type must be defined before the struct is inflated, but not necessarily before it is declared. + +!!! info + Forward references are resolved through the `TypeRegistry` at inflation time. If the referenced type is not found, an error is raised. diff --git a/docs/advanced/freeze_diff.md b/docs/advanced/freeze_diff.md new file mode 100644 index 0000000..3352f7d --- /dev/null +++ b/docs/advanced/freeze_diff.md @@ -0,0 +1,134 @@ +# Freeze, Diff & Reset + +libdestruct supports snapshotting values for change tracking. This is useful when you want to detect what changed in memory between two points in time. + +## Freezing + +Call `freeze()` to snapshot the current value: + +```python +from libdestruct import c_int, inflater + +memory = bytearray(4) +lib = inflater(memory) +x = lib.inflate(c_int, 0) + +x.value = 42 +x.freeze() +``` + +Once frozen, the object remembers its value at the time of the freeze. Further reads still return the live value from memory, but writes are blocked: + +```python +# Writing to a frozen object raises ValueError +try: + x.value = 99 +except ValueError: + print("Cannot write to frozen object") +``` + +## Diffing + +Use `diff()` to compare the frozen value with the current live value: + +```python +memory = bytearray(4) +lib = inflater(memory) +x = lib.inflate(c_int, 0) + +x.value = 42 +x.freeze() + +# Something changes the underlying memory +memory[0:4] = (100).to_bytes(4, "little") + +frozen_val, current_val = x.diff() +print(f"Was: {frozen_val}, Now: {current_val}") +# Was: 42, Now: 100 +``` + +!!! note + `diff()` only works on frozen objects. It returns a tuple of `(frozen_value, current_value)`. + +## Resetting + +Call `reset()` to restore the memory to the frozen value: + +```python +x.reset() +print(x.value) # 42 (restored to frozen value) +``` + +## Updating + +Call `update()` to re-freeze with the current live value, discarding the old snapshot: + +```python +x.update() +# The frozen value is now whatever is currently in memory +``` + +## Freezing Structs + +When you freeze a struct, all its members are frozen recursively: + +```python +from libdestruct import struct, c_int, inflater + +class pair_t(struct): + a: c_int + b: c_int + +memory = bytearray(8) +lib = inflater(memory) +pair = lib.inflate(pair_t, 0) + +pair.a.value = 10 +pair.b.value = 20 + +pair.freeze() + +# Both members are now frozen +try: + pair.a.value = 999 +except ValueError: + print("Frozen!") +``` + +## Workflow Example + +A typical workflow for detecting changes: + +```python +class game_state_t(struct): + health: c_int + score: c_int + level: c_int + +memory = bytearray(12) +lib = inflater(memory) + +# 1. Inflate the struct +state = lib.inflate(game_state_t, 0) + +# 2. Freeze the current state +state.health.value = 100 +state.score.value = 500 +state.level.value = 3 +state.freeze() + +# 3. Something changes the underlying memory +memory[4:8] = (9999).to_bytes(4, "little") # score changed + +# 4. Check what changed +for name in ["health", "score", "level"]: + member = getattr(state, name) + old, new = member.diff() + if old != new: + print(f"{name}: {old} -> {new}") +# score: 500 -> 9999 + +# 5. Optionally reset to the frozen state +state.reset() +print(state.score.value) # 500 (restored) +``` diff --git a/docs/advanced/hexdump.md b/docs/advanced/hexdump.md new file mode 100644 index 0000000..6b796b2 --- /dev/null +++ b/docs/advanced/hexdump.md @@ -0,0 +1,57 @@ +# Hex Dump + +Every libdestruct object has a `hexdump()` method that returns a classic hex dump of its serialized bytes. + +## Basic Usage + +```python +from libdestruct import c_int, inflater + +memory = bytearray(b"Hello, World!\x00\x00\x00") +lib = inflater(memory) +x = lib.inflate(c_int, 0) +print(x.hexdump()) +``` + +Output format: + +``` +00000000 48 65 6c 6c |Hell| +``` + +Each line shows: offset, hex bytes (up to 16 per line), and ASCII representation (non-printable bytes shown as `.`). + +## Struct Hex Dump + +When called on a struct, `hexdump()` annotates each line with the field names that start on that line: + +```python +from libdestruct import struct, c_int, c_long + +class player_t(struct): + health: c_int + score: c_long + +memory = bytearray(12) +memory[0:4] = (100).to_bytes(4, "little") +memory[4:12] = (9999).to_bytes(8, "little") + +player = player_t.from_bytes(memory) +print(player.hexdump()) +``` + +Output: + +``` +00000000 64 00 00 00 0f 27 00 00 00 00 00 00 |d....'......| health, score +``` + +## Standalone Utility + +The underlying `format_hexdump` function can be used directly: + +```python +from libdestruct.common.hexdump import format_hexdump + +print(format_hexdump(b"\xde\xad\xbe\xef", base_address=0x1000)) +``` diff --git a/docs/advanced/inheritance.md b/docs/advanced/inheritance.md new file mode 100644 index 0000000..7406c32 --- /dev/null +++ b/docs/advanced/inheritance.md @@ -0,0 +1,107 @@ +# Struct Inheritance + +libdestruct structs support Python class inheritance. A derived struct inherits all fields from its parent, with new fields appended after the parent's fields. + +## Basic Usage + +```python +from libdestruct import struct, c_int, size_of + +class base_t(struct): + a: c_int + +class derived_t(base_t): + b: c_int + +size_of(base_t) # 4 +size_of(derived_t) # 8 (a + b) +``` + +Reading and writing works as expected: + +```python +import struct as pystruct + +data = pystruct.pack(" offset 12 +memory[12:16] = pystruct.pack(" offset 12 +memory[0:4] = pystruct.pack(" out of bounds +memory[12:16] = pystruct.pack(" FakeResolver: + def __init__(self: FakeResolver, memory: dict | None = None, address: int | None = 0, endianness: str = "little") -> None: """Initializes a basic fake resolver.""" self.memory = memory if memory is not None else {} self.address = address self.parent = None self.offset = None + self.endianness = endianness def resolve_address(self: FakeResolver) -> int: """Resolves self's address, mainly used by children to determine their own address.""" @@ -28,14 +29,14 @@ def resolve_address(self: FakeResolver) -> int: def relative_from_own(self: FakeResolver, address_offset: int, _: int) -> FakeResolver: """Creates a resolver that references a parent, such that a change in the parent is propagated on the child.""" - new_resolver = FakeResolver(self.memory, None) + new_resolver = FakeResolver(self.memory, None, self.endianness) new_resolver.parent = self new_resolver.offset = address_offset return new_resolver def absolute_from_own(self: FakeResolver, address: int) -> FakeResolver: """Creates a resolver that has an absolute reference to an object, from the parent's view.""" - return FakeResolver(self.memory, address) + return FakeResolver(self.memory, address, self.endianness) def resolve(self: FakeResolver, size: int, _: int) -> bytes: """Resolves itself, providing the bytes it references for the specified size and index.""" @@ -47,7 +48,7 @@ def resolve(self: FakeResolver, size: int, _: int) -> bytes: result = b"" while size: - page = self.memory.get(page_address, b"\x00" * (0x1000 - page_offset)) + page = self.memory.get(page_address, b"\x00" * 0x1000) page_size = min(size, 0x1000 - page_offset) result += page[page_offset : page_offset + page_size] size -= page_size diff --git a/libdestruct/backing/memory_resolver.py b/libdestruct/backing/memory_resolver.py index 291d8a1..34c559d 100644 --- a/libdestruct/backing/memory_resolver.py +++ b/libdestruct/backing/memory_resolver.py @@ -17,12 +17,13 @@ class MemoryResolver(Resolver): """A class that can resolve itself to a value in a referenced memory storage.""" - def __init__(self: MemoryResolver, memory: MutableSequence, address: int | None) -> MemoryResolver: + def __init__(self: MemoryResolver, memory: MutableSequence, address: int | None, endianness: str = "little") -> None: """Initializes a basic memory resolver.""" self.memory = memory self.address = address self.parent = None self.offset = None + self.endianness = endianness def resolve_address(self: MemoryResolver) -> int: """Resolves self's address, mainly used by childs to determine their own address.""" @@ -33,21 +34,21 @@ def resolve_address(self: MemoryResolver) -> int: def relative_from_own(self: MemoryResolver, address_offset: int, _: int) -> MemoryResolver: """Creates a resolver that references a parent, such that a change in the parent is propagated on the child.""" - new_resolver = MemoryResolver(self.memory, None) + new_resolver = MemoryResolver(self.memory, None, self.endianness) new_resolver.parent = self new_resolver.offset = address_offset return new_resolver - def absolute_from_own(self: Resolver, address: int) -> MemoryResolver: + def absolute_from_own(self: MemoryResolver, address: int) -> MemoryResolver: """Creates a resolver that has an absolute reference to an object, from the parent's view.""" - return MemoryResolver(self.memory, address) + return MemoryResolver(self.memory, address, self.endianness) def resolve(self: MemoryResolver, size: int, _: int) -> bytes: """Resolves itself, providing the bytes it references for the specified size and index.""" address = self.resolve_address() - return self.memory[address : address + size] + return bytes(self.memory[address : address + size]) - def modify(self: Resolver, size: int, _: int, value: bytes) -> None: + def modify(self: MemoryResolver, size: int, _: int, value: bytes) -> None: """Modifies itself in memory.""" address = self.resolve_address() self.memory[address : address + size] = value diff --git a/libdestruct/backing/resolver.py b/libdestruct/backing/resolver.py index 3c04cd8..32612f9 100644 --- a/libdestruct/backing/resolver.py +++ b/libdestruct/backing/resolver.py @@ -16,6 +16,9 @@ class Resolver(ABC): parent: Self + endianness: str = "little" + """The endianness of the data this resolver accesses.""" + @abstractmethod def relative_from_own(self: Resolver, address_offset: int, index_offset: int) -> Self: """Creates a resolver that references a parent, such that a change in the parent is propagated on the child.""" diff --git a/libdestruct/c/__init__.py b/libdestruct/c/__init__.py index 9f81aee..777a1bd 100644 --- a/libdestruct/c/__init__.py +++ b/libdestruct/c/__init__.py @@ -4,10 +4,14 @@ # Licensed under the MIT license. See LICENSE file in the project root for details. # +from libdestruct.c.c_float_types import c_double, c_float from libdestruct.c.c_integer_types import c_char, c_int, c_long, c_short, c_uchar, c_uint, c_ulong, c_ushort from libdestruct.c.c_str import c_str -__all__ = ["c_char", "c_uchar", "c_short", "c_ushort", "c_int", "c_uint", "c_long", "c_ulong", "c_str"] +__all__ = [ + "c_char", "c_double", "c_float", "c_int", "c_long", "c_short", + "c_str", "c_uchar", "c_uint", "c_ulong", "c_ushort", +] import libdestruct.c.base_type_inflater import libdestruct.c.ctypes_generic_field # noqa: F401 diff --git a/libdestruct/c/base_type_inflater.py b/libdestruct/c/base_type_inflater.py index a927e5a..7d93127 100644 --- a/libdestruct/c/base_type_inflater.py +++ b/libdestruct/c/base_type_inflater.py @@ -6,6 +6,7 @@ from __future__ import annotations +from libdestruct.c.c_float_types import c_double, c_float from libdestruct.c.c_integer_types import _c_integer, c_char, c_int, c_long, c_short, c_uchar, c_uint, c_ulong, c_ushort from libdestruct.c.c_str import c_str from libdestruct.common.type_registry import TypeRegistry @@ -22,4 +23,6 @@ registry.register_mapping(c_uint, c_uint) registry.register_mapping(c_long, c_long) registry.register_mapping(c_ulong, c_ulong) +registry.register_mapping(c_float, c_float) +registry.register_mapping(c_double, c_double) registry.register_mapping(c_str, c_str) diff --git a/libdestruct/c/c_float_types.py b/libdestruct/c/c_float_types.py new file mode 100644 index 0000000..135816a --- /dev/null +++ b/libdestruct/c/c_float_types.py @@ -0,0 +1,64 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +import struct + +from libdestruct.common.obj import obj + + +class _c_float_base(obj): + """A generic C floating-point type, to be subclassed by c_float and c_double.""" + + size: int + """The size of the float in bytes.""" + + _format: str + """The struct format character ('f' or 'd').""" + + _frozen_value: float | None = None + """The frozen value of the float.""" + + def _format_char(self: _c_float_base) -> str: + prefix = "<" if self.endianness == "little" else ">" + return prefix + self._format + + def get(self: _c_float_base) -> float: + """Return the value of the float.""" + return struct.unpack(self._format_char(), self.resolver.resolve(self.size, 0))[0] + + def _set(self: _c_float_base, value: float) -> None: + """Set the value of the float.""" + self.resolver.modify(self.size, 0, struct.pack(self._format_char(), value)) + + def to_bytes(self: _c_float_base) -> bytes: + """Return the serialized representation of the float.""" + if self._frozen: + return struct.pack(self._format_char(), self._frozen_value) + return self.resolver.resolve(self.size, 0) + + def __float__(self: _c_float_base) -> float: + """Return the value as a Python float.""" + return self.get() + + def __int__(self: _c_float_base) -> int: + """Return the value as a Python int.""" + return int(self.get()) + + +class c_float(_c_float_base): + """A C float (IEEE 754 single-precision, 32-bit).""" + + size: int = 4 + _format: str = "f" + + +class c_double(_c_float_base): + """A C double (IEEE 754 double-precision, 64-bit).""" + + size: int = 8 + _format: str = "d" diff --git a/libdestruct/c/c_str.py b/libdestruct/c/c_str.py index 87ff11b..3895a31 100644 --- a/libdestruct/c/c_str.py +++ b/libdestruct/c/c_str.py @@ -26,13 +26,13 @@ def count(self: c_str) -> int: def get(self: c_str, index: int = -1) -> bytes: """Return the character at the given index.""" - if index != -1 and index < 0 or index >= self.count(): + if (index != -1 and index < 0) or index >= self.count(): raise IndexError("String index out of range.") if index == -1: return self.resolver.resolve(self.count(), 0) - return bytes([self.resolver.resolve(index)[-1]]) + return bytes([self.resolver.resolve(index + 1, 0)[-1]]) def to_bytes(self: c_str) -> bytes: """Return the serialized representation of the object.""" @@ -40,7 +40,7 @@ def to_bytes(self: c_str) -> bytes: def _set(self: c_str, value: bytes, index: int = -1) -> None: """Set the character at the given index to the given value.""" - if index != -1 and index < 0 or index >= self.count(): + if (index != -1 and index < 0) or index >= self.count(): raise IndexError("String index out of range.") if index == -1: @@ -50,6 +50,10 @@ def _set(self: c_str, value: bytes, index: int = -1) -> None: prev = self.resolver.resolve(index, 0) self.resolver.modify(index + len(value), 0, prev + value) + def __setitem__(self: c_str, index: int, value: bytes) -> None: + """Set the character at the given index to the given value.""" + self._set(value, index) + def __iter__(self: c_str) -> iter: """Return an iterator over the string.""" for i in range(self.count()): diff --git a/libdestruct/c/ctypes_generic.py b/libdestruct/c/ctypes_generic.py index 7025910..5620d6b 100644 --- a/libdestruct/c/ctypes_generic.py +++ b/libdestruct/c/ctypes_generic.py @@ -34,6 +34,6 @@ def _set(self: _ctypes_generic, value: Any) -> None: def to_bytes(self: _ctypes_generic) -> bytes: """Serialize the type to bytes.""" if self._frozen: - return bytes(self._frozen_value) + return bytes(self.backing_type(self._frozen_value)) return self.resolver.resolve(self.size, 0) diff --git a/libdestruct/c/struct_parser.py b/libdestruct/c/struct_parser.py index 0b5e88f..8403903 100644 --- a/libdestruct/c/struct_parser.py +++ b/libdestruct/c/struct_parser.py @@ -14,10 +14,38 @@ from pycparser import c_ast, c_parser +from libdestruct.c.c_float_types import c_double, c_float +from libdestruct.c.c_integer_types import c_char, c_int, c_long, c_short, c_uchar, c_uint, c_ulong, c_ushort from libdestruct.common.array.array_of import array_of +from libdestruct.common.bitfield.bitfield_of import bitfield_of from libdestruct.common.ptr.ptr_factory import ptr_to, ptr_to_self from libdestruct.common.struct import struct +# Mapping from ctypes types to libdestruct native integer types (needed for bitfields) +_CTYPES_TO_NATIVE = { + ctypes.c_byte: c_char, + ctypes.c_char: c_char, + ctypes.c_ubyte: c_uchar, + ctypes.c_short: c_short, + ctypes.c_ushort: c_ushort, + ctypes.c_int: c_int, + ctypes.c_uint: c_uint, + ctypes.c_long: c_long, + ctypes.c_ulong: c_ulong, + ctypes.c_longlong: c_long, + ctypes.c_ulonglong: c_ulong, + ctypes.c_int8: c_char, + ctypes.c_int16: c_short, + ctypes.c_int32: c_int, + ctypes.c_int64: c_long, + ctypes.c_uint8: c_uchar, + ctypes.c_uint16: c_ushort, + ctypes.c_uint32: c_uint, + ctypes.c_uint64: c_ulong, + ctypes.c_size_t: c_ulong, + ctypes.c_ssize_t: c_long, +} + if TYPE_CHECKING: from libdestruct.common.obj import obj @@ -29,6 +57,12 @@ """A cache for parsed type definitions, indexed by name.""" +def clear_parser_cache() -> None: + """Clear cached struct definitions and typedefs from previous parses.""" + PARSED_STRUCTS.clear() + TYPEDEFS.clear() + + def definition_to_type(definition: str) -> type[obj]: """Converts a C struct definition to a struct object.""" parser = c_parser.CParser() @@ -63,7 +97,8 @@ def definition_to_type(definition: str) -> type[obj]: result = struct_to_type(root) - PARSED_STRUCTS[root.name] = result + if root.name: + PARSED_STRUCTS[root.name] = result return result @@ -81,14 +116,25 @@ def struct_to_type(struct_node: c_ast.Struct) -> type[struct]: elif not struct_node.decls: raise ValueError("Struct must have fields.") + class_dict = {} + for decl in struct_node.decls: name = decl.name typ = type_decl_to_type(decl.type, struct_node) fields[name] = typ + # Handle bitfields: decl.bitsize is set when the declaration has ": N" + if decl.bitsize is not None: + bit_width = int(decl.bitsize.value) + # Convert ctypes types to native libdestruct types for bitfield backing + native_type = _CTYPES_TO_NATIVE.get(typ, typ) + class_dict[name] = bitfield_of(native_type, bit_width) + fields[name] = native_type + type_name = struct_node.name if struct_node.name else "anon_struct" - return type(type_name, (struct,), {"__annotations__": fields}) + class_dict["__annotations__"] = fields + return type(type_name, (struct,), class_dict) def ptr_to_type(ptr: c_ast.PtrDecl, parent: c_ast.Struct | None = None) -> type[obj]: @@ -96,6 +142,11 @@ def ptr_to_type(ptr: c_ast.PtrDecl, parent: c_ast.Struct | None = None) -> type[ if not isinstance(ptr, c_ast.PtrDecl): raise TypeError("Definition must be a pointer.") + # Handle nested pointers (e.g., int **pp) by recursively wrapping in ptr_to + if isinstance(ptr.type, c_ast.PtrDecl): + inner = ptr_to_type(ptr.type, parent) + return ptr_to(inner) + if not isinstance(ptr.type, c_ast.TypeDecl): raise TypeError("Definition must be a type declaration.") @@ -120,6 +171,9 @@ def arr_to_type(arr: c_ast.ArrayDecl) -> type[obj]: typ = ptr_to_type(arr.type) if isinstance(arr.type, c_ast.PtrDecl) else type_decl_to_type(arr.type) + if arr.dim is None: + raise ValueError("Unsized arrays (flexible array members) are not supported.") + return array_of(typ, int(arr.dim.value)) @@ -152,11 +206,16 @@ def typedef_to_pair(typedef: c_ast.Typedef) -> tuple[str, type[obj]]: if not isinstance(typedef, c_ast.Typedef): raise TypeError("Definition must be a typedef.") - if not isinstance(typedef.type, c_ast.TypeDecl): - raise TypeError("Definition must be a type declaration.") - name = "".join(typedef.name) - definition = type_decl_to_type(typedef.type) + + if isinstance(typedef.type, c_ast.PtrDecl): + definition = ptr_to_type(typedef.type) + elif isinstance(typedef.type, c_ast.ArrayDecl): + definition = arr_to_type(typedef.type) + elif isinstance(typedef.type, c_ast.TypeDecl): + definition = type_decl_to_type(typedef.type) + else: + raise TypeError("Unsupported typedef target type.") return name, definition @@ -210,6 +269,11 @@ def identifier_to_type(identifier: c_ast.IdentifierType) -> type[obj]: identifier_name = "".join(identifier.names) + # Native float/double types (before ctypes fallback, so we get libdestruct types) + native_float_types = {"float": c_float, "double": c_double} + if identifier_name in native_float_types: + return native_float_types[identifier_name] + ctypes_name = "c_" + identifier_name if hasattr(ctypes, ctypes_name): diff --git a/libdestruct/common/__init__.py b/libdestruct/common/__init__.py index 6a0982b..21a8b08 100644 --- a/libdestruct/common/__init__.py +++ b/libdestruct/common/__init__.py @@ -3,3 +3,5 @@ # Copyright (c) 2024 Roberto Alessandro Bertolini. All rights reserved. # Licensed under the MIT license. See LICENSE file in the project root for details. # + +import libdestruct.common.forward_ref_inflater # noqa: F401 diff --git a/libdestruct/common/array/__init__.py b/libdestruct/common/array/__init__.py index c70fb7b..220d9f0 100644 --- a/libdestruct/common/array/__init__.py +++ b/libdestruct/common/array/__init__.py @@ -6,7 +6,9 @@ from libdestruct.common.array.array import array from libdestruct.common.array.array_of import array_of +from libdestruct.common.array.vla_of import vla_of -__all__ = ["array", "array_of"] +__all__ = ["array", "array_of", "vla_of"] -import libdestruct.common.array.array_field_inflater # noqa: F401 +import libdestruct.common.array.array_field_inflater +import libdestruct.common.array.vla_field_inflater # noqa: F401 — side-effect: registers VLA handler diff --git a/libdestruct/common/array/array.py b/libdestruct/common/array/array.py index 231ba55..e204302 100644 --- a/libdestruct/common/array/array.py +++ b/libdestruct/common/array/array.py @@ -7,6 +7,7 @@ from __future__ import annotations from abc import abstractmethod +from types import GenericAlias from libdestruct.common.obj import obj @@ -14,6 +15,12 @@ class array(obj): """An array of objects.""" + def __class_getitem__(cls, params: tuple) -> GenericAlias: + """Support array[c_int, 3] subscript syntax.""" + if not isinstance(params, tuple): + params = (params,) + return GenericAlias(cls, params) + @abstractmethod def count(self: array) -> int: """Return the size of the array.""" @@ -23,8 +30,8 @@ def __len__(self: array) -> int: return self.count() @abstractmethod - def get(self: array, index: int) -> object: - """Return the element at the given index.""" + def get(self: array, index: int = -1) -> object: + """Return the element at the given index, or all elements if index is -1.""" def __getitem__(self: array, index: int) -> object: """Return the element at the given index.""" diff --git a/libdestruct/common/array/array_field_inflater.py b/libdestruct/common/array/array_field_inflater.py index 51738d9..26ef9ef 100644 --- a/libdestruct/common/array/array_field_inflater.py +++ b/libdestruct/common/array/array_field_inflater.py @@ -9,7 +9,10 @@ from typing import TYPE_CHECKING +from libdestruct.common.array.array import array from libdestruct.common.array.linear_array_field import LinearArrayField +from libdestruct.common.array.vla_field import VLAField +from libdestruct.common.array.vla_field_inflater import vla_field_inflater from libdestruct.common.type_registry import TypeRegistry if TYPE_CHECKING: # pragma: no cover @@ -18,6 +21,7 @@ from libdestruct.backing.resolver import Resolver from libdestruct.common.obj import obj + registry = TypeRegistry() @@ -32,4 +36,25 @@ def linear_array_field_inflater( return field.inflate +def _subscripted_array_handler( + item: object, + args: tuple, + owner: tuple[obj, type[obj]] | None, +) -> Callable[[Resolver], obj] | None: + """Handle subscripted array types like array[c_int, 3] or array[c_int, 'length'].""" + if len(args) != 2: + return None + element_type, count = args + if isinstance(count, str): + # Variable-length array: count is a field name + field = VLAField(element_type, count) + return vla_field_inflater(field, type(None), owner) + if not isinstance(count, int) or count <= 0: + raise ValueError(f"array count must be a positive integer, got {count}") + field = LinearArrayField(element_type, count) + field.item = registry.inflater_for(element_type) + return field.inflate + + registry.register_instance_handler(LinearArrayField, linear_array_field_inflater) +registry.register_generic_handler(array, _subscripted_array_handler) diff --git a/libdestruct/common/array/array_impl.py b/libdestruct/common/array/array_impl.py index 41520ae..94d2e57 100644 --- a/libdestruct/common/array/array_impl.py +++ b/libdestruct/common/array/array_impl.py @@ -46,8 +46,14 @@ def count(self: array_impl) -> int: """Get the size of the array.""" return self._count - def get(self: array, index: int) -> object: - """Return the element at the given index.""" + def get(self: array, index: int = -1) -> object: + """Return the element at the given index, or all elements if index is -1.""" + if hasattr(self, "_frozen_elements"): + if index == -1: + return list(self._frozen_elements) + return self._frozen_elements[index] + if index == -1: + return [self.backing_type(self.resolver.relative_from_own(i * self.item_size, 0)) for i in range(self._count)] return self.backing_type(self.resolver.relative_from_own(index * self.item_size, 0)) def _set(self: array_impl, _: list[obj]) -> None: @@ -58,8 +64,25 @@ def set(self: array_impl, _: list[obj]) -> None: """Set the array from a list.""" raise NotImplementedError("Cannot set items in an array.") + def to_dict(self: array_impl) -> list[object]: + """Return a JSON-serializable list of element values.""" + return [elem.to_dict() for elem in self] + + def freeze(self: array_impl) -> None: + """Freeze the array, individually freezing each element.""" + self._frozen_elements = [ + self.backing_type(self.resolver.relative_from_own(i * self.item_size, 0)) + for i in range(self._count) + ] + for elem in self._frozen_elements: + elem.freeze() + self._frozen_array_bytes = b"".join(bytes(x) for x in self._frozen_elements) + super().freeze() + def to_bytes(self: array_impl) -> bytes: """Return the serialized representation of the array.""" + if self._frozen: + return self._frozen_array_bytes return b"".join(bytes(x) for x in self) def to_str(self: array_impl, indent: int = 0) -> str: @@ -81,5 +104,8 @@ def __setitem__(self: array_impl, index: int, value: obj) -> None: def __iter__(self: array_impl) -> Generator[obj, None, None]: """Iterate over the array.""" - for i in range(self._count): - yield self[i] + if self._frozen: + yield from self._frozen_elements + else: + for i in range(self._count): + yield self[i] diff --git a/libdestruct/common/array/vla_field.py b/libdestruct/common/array/vla_field.py new file mode 100644 index 0000000..73df868 --- /dev/null +++ b/libdestruct/common/array/vla_field.py @@ -0,0 +1,42 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.array.array_field import ArrayField + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + + +class VLAField(ArrayField): + """A generator for a variable-length array whose count is determined by another struct field. + + At size-computation time, ``vla_field_inflater`` returns ``field.inflate`` + as a bound method so that ``size_of`` can call ``get_size()`` (returns 0). + ``inflate`` itself is never invoked; the real inflation is handled by the + closure built in ``vla_field_inflater`` which reads the count at runtime. + """ + + def __init__(self: VLAField, element_type: type[obj], count_field: str) -> None: + """Initialize the field.""" + self.item = element_type + self.count_field = count_field + + def inflate(self: VLAField, resolver: Resolver) -> None: + """Placeholder — never called at runtime. + + ``size_of`` detects the bound method via ``is_field_bound_method`` and + calls ``get_size()`` directly, so this body is unreachable. + """ + raise NotImplementedError("VLAField.inflate is a size-computation stub; use vla_field_inflater instead") + + def get_size(self: VLAField) -> int: + """VLA has zero static size — actual size is determined at inflation time.""" + return 0 diff --git a/libdestruct/common/array/vla_field_inflater.py b/libdestruct/common/array/vla_field_inflater.py new file mode 100644 index 0000000..3961d7a --- /dev/null +++ b/libdestruct/common/array/vla_field_inflater.py @@ -0,0 +1,53 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.array.vla_field import VLAField +from libdestruct.common.array.vla_impl import vla_impl +from libdestruct.common.type_registry import TypeRegistry + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Callable + + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + +registry = TypeRegistry() + + +def vla_field_inflater( + field: VLAField, + _: type[obj], + owner: tuple[obj, type[obj]] | None, +) -> Callable[[Resolver], obj]: + """Return the inflater for a variable-length array field. + + During size computation (owner[0] is None), returns field.inflate which + is a bound method on a Field — ``size_of`` detects this and calls + ``get_size()`` (returns 0) without ever invoking the method. + + During actual inflation, returns a closure that creates a ``vla_impl`` + holding a reference to the count member for dynamic count reads. + """ + if owner is None or owner[0] is None: + field.item = registry.inflater_for(field.item) + return field.inflate + + struct_instance = owner[0] + element_inflater = registry.inflater_for(field.item) + + def inflate_vla(resolver: Resolver) -> vla_impl: + members = object.__getattribute__(struct_instance, "_members") + count_member = members[field.count_field] + return vla_impl(resolver, element_inflater, count_member) + + return inflate_vla + + +registry.register_instance_handler(VLAField, vla_field_inflater) diff --git a/libdestruct/common/array/vla_impl.py b/libdestruct/common/array/vla_impl.py new file mode 100644 index 0000000..839b6bb --- /dev/null +++ b/libdestruct/common/array/vla_impl.py @@ -0,0 +1,72 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.array.array import array +from libdestruct.common.array.array_impl import array_impl +from libdestruct.common.utils import size_of + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + + +class vla_impl(array_impl): + """An array whose element count is read dynamically from a sibling struct field.""" + + _count_member: obj + """The struct member whose .value gives the current element count.""" + + def __init__( + self: vla_impl, + resolver: Resolver, + backing_type: obj, + count_member: obj, + ) -> None: + """Initialize the VLA. + + Unlike array_impl, the count is not a fixed integer but a reference + to a sibling struct member that is read on every access. + """ + # Skip array_impl.__init__ — it stores a fixed _count. + # Call array (grandparent) init only. + array.__init__(self, resolver) + self.backing_type = backing_type + self._count_member = count_member + self.item_size = size_of(backing_type) + + @property # type: ignore[override] + def _count(self: vla_impl) -> int: + """Read the current element count from the sibling field.""" + count = self._count_member.value + + if not isinstance(count, int): + raise TypeError( + f"VLA count field must be an integer type, got {type(count).__name__}", + ) + + if count < 0: + raise ValueError( + f"VLA count field must be non-negative, got {count}", + ) + + return count + + @_count.setter + def _count(self: vla_impl, _: int) -> None: + """No-op — count is always derived from the sibling member.""" + + @property # type: ignore[override] + def size(self: vla_impl) -> int: + """Return the current byte size of the VLA data.""" + return self.item_size * self._count + + @size.setter + def size(self: vla_impl, _: int) -> None: + """No-op — size is always derived from count.""" diff --git a/libdestruct/common/array/vla_of.py b/libdestruct/common/array/vla_of.py new file mode 100644 index 0000000..cc04dc3 --- /dev/null +++ b/libdestruct/common/array/vla_of.py @@ -0,0 +1,25 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.array.vla_field import VLAField + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.common.array.array_field import ArrayField + from libdestruct.common.obj import obj + + +def vla_of(element_type: type[obj], count_field: str) -> ArrayField: + """Return a new variable-length array field. + + Args: + element_type: The type of each element in the array. + count_field: The name of the struct field that holds the element count. + """ + return VLAField(element_type, count_field) diff --git a/libdestruct/common/bitfield/__init__.py b/libdestruct/common/bitfield/__init__.py new file mode 100644 index 0000000..fd055c9 --- /dev/null +++ b/libdestruct/common/bitfield/__init__.py @@ -0,0 +1,11 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from libdestruct.common.bitfield.bitfield import bitfield +from libdestruct.common.bitfield.bitfield_field import BitfieldField +from libdestruct.common.bitfield.bitfield_of import bitfield_of + +__all__ = ["BitfieldField", "bitfield", "bitfield_of"] diff --git a/libdestruct/common/bitfield/bitfield.py b/libdestruct/common/bitfield/bitfield.py new file mode 100644 index 0000000..d226829 --- /dev/null +++ b/libdestruct/common/bitfield/bitfield.py @@ -0,0 +1,106 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.obj import obj + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.backing.resolver import Resolver + + +class bitfield(obj): + """A bitfield within a backing integer type.""" + + _backing_instance: obj + """The inflated backing integer instance (shared with sibling bitfields).""" + + _bit_offset: int + """The starting bit position within the backing integer.""" + + _bit_width: int + """The number of bits this field occupies.""" + + _signed: bool + """Whether to sign-extend when reading.""" + + _is_group_owner: bool + """Whether this bitfield owns the backing bytes (first in its group).""" + + def __init__( + self: bitfield, + resolver: Resolver, + backing_instance: obj, + bit_offset: int, + bit_width: int, + signed: bool, + is_group_owner: bool, + ) -> None: + """Initialize the bitfield. + + Args: + resolver: The backing resolver. + backing_instance: The already-inflated backing integer (shared across bitfields in the same group). + bit_offset: The starting bit position within the backing integer. + bit_width: The number of bits this field occupies. + signed: Whether to sign-extend when reading. + is_group_owner: Whether this bitfield is the first in its group (owns the backing bytes). + """ + super().__init__(resolver) + self._backing_instance = backing_instance + self._bit_offset = bit_offset + self._bit_width = bit_width + self._signed = signed + self._mask = (1 << bit_width) - 1 + self._is_group_owner = is_group_owner + # Owner reports the full backing size; non-owners report 0 + self.size = backing_instance.size if is_group_owner else 0 + + def get(self: bitfield) -> int: + """Return the value of the bitfield.""" + raw = self._backing_instance.get() + # For signed backing types, raw may be negative. Work with unsigned representation. + if raw < 0: + raw += 1 << (self._backing_instance.size * 8) + value = (raw >> self._bit_offset) & self._mask + if self._signed and (value >> (self._bit_width - 1)) & 1: + value -= 1 << self._bit_width + return value + + def _set(self: bitfield, value: int) -> None: + """Set the value of the bitfield.""" + masked_value = value & self._mask + raw = self._backing_instance.get() + if raw < 0: + raw += 1 << (self._backing_instance.size * 8) + raw = (raw & ~(self._mask << self._bit_offset)) | (masked_value << self._bit_offset) + total_bits = self._backing_instance.size * 8 + is_signed = hasattr(self._backing_instance, "signed") and self._backing_instance.signed + if is_signed and raw >= (1 << (total_bits - 1)): + raw -= 1 << total_bits + self._backing_instance._set(raw) + + def to_bytes(self: bitfield) -> bytes: + """Return the serialized representation of the backing type. + + Only the group owner emits bytes; non-owners return empty bytes + to avoid duplication when the struct serializes all members. + """ + if self._is_group_owner: + return self._backing_instance.to_bytes() + return b"" + + def freeze(self: bitfield) -> None: + """Freeze the bitfield, also freezing the shared backing instance if this is the group owner.""" + if self._is_group_owner and not self._backing_instance._frozen: + self._backing_instance.freeze() + super().freeze() + + def to_str(self: bitfield, _: int = 0) -> str: + """Return a string representation of the bitfield.""" + return f"{self.get()}" diff --git a/libdestruct/common/bitfield/bitfield_field.py b/libdestruct/common/bitfield/bitfield_field.py new file mode 100644 index 0000000..7dd51d5 --- /dev/null +++ b/libdestruct/common/bitfield/bitfield_field.py @@ -0,0 +1,40 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.field import Field + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + + +class BitfieldField(Field): + """A generator for a bitfield within a struct.""" + + base_type: type[obj] + + def __init__(self: BitfieldField, backing_type: type, bit_width: int) -> None: + """Initialize the bitfield field. + + Args: + backing_type: The backing integer type (e.g., c_int, c_uint). + bit_width: The number of bits this field occupies. + """ + self.backing_type = backing_type + self.bit_width = bit_width + self.base_type = backing_type + + def inflate(self: BitfieldField, resolver: Resolver) -> obj: + """Inflate the field. Not used directly — struct_impl handles bitfield inflation.""" + raise NotImplementedError("BitfieldField inflation is handled by struct_impl.") + + def get_size(self: BitfieldField) -> int: + """Returns 0 — bitfields do not independently advance the struct offset.""" + return 0 diff --git a/libdestruct/common/bitfield/bitfield_of.py b/libdestruct/common/bitfield/bitfield_of.py new file mode 100644 index 0000000..3bd9d6a --- /dev/null +++ b/libdestruct/common/bitfield/bitfield_of.py @@ -0,0 +1,30 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.bitfield.bitfield_field import BitfieldField + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.common.obj import obj + + +def bitfield_of(backing_type: type[obj], bit_width: int) -> BitfieldField: + """Create a bitfield descriptor for use in struct annotations. + + Args: + backing_type: The backing integer type (e.g., c_int, c_uint). + bit_width: The number of bits this field occupies. + """ + if bit_width <= 0: + raise ValueError("Bit width must be positive.") + + if hasattr(backing_type, "size") and bit_width > backing_type.size * 8: + raise ValueError(f"Bit width {bit_width} exceeds backing type size ({backing_type.size * 8} bits).") + + return BitfieldField(backing_type, bit_width) diff --git a/libdestruct/common/bitfield/bitfield_tracker.py b/libdestruct/common/bitfield/bitfield_tracker.py new file mode 100644 index 0000000..ff129f1 --- /dev/null +++ b/libdestruct/common/bitfield/bitfield_tracker.py @@ -0,0 +1,125 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.bitfield.bitfield import bitfield + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.backing.resolver import Resolver + from libdestruct.common.bitfield.bitfield_field import BitfieldField + from libdestruct.common.obj import obj + from libdestruct.common.type_registry import TypeRegistry + + +class BitfieldTracker: + """Tracks bitfield group state during struct field inflation. + + Consecutive bitfields with the same backing type are packed into a shared + backing integer instance. This class manages the grouping, bit offset + tracking, and byte offset advancement. + """ + + def __init__(self: BitfieldTracker) -> None: + """Initialize the tracker with no active group.""" + self._bit_offset: int = 0 + self._backing_type: type | None = None + self._backing_instance: obj | None = None + + @property + def active(self: BitfieldTracker) -> bool: + """Return whether a bitfield group is currently active.""" + return self._backing_type is not None + + def needs_new_group(self: BitfieldTracker, field: BitfieldField) -> bool: + """Return whether the given field would start a new bitfield group.""" + return ( + self._backing_type is not field.backing_type + or self._bit_offset + field.bit_width > field.backing_type.size * 8 + ) + + def flush(self: BitfieldTracker) -> int: + """Close the current bitfield group and return the byte size to advance. + + Returns: + The backing type's byte size if a group was active, 0 otherwise. + """ + if self._backing_type is not None: + size = self._backing_type.size + self._backing_type = None + self._backing_instance = None + self._bit_offset = 0 + return size + return 0 + + def create_bitfield( + self: BitfieldTracker, + field: BitfieldField, + inflater: TypeRegistry, + resolver: Resolver, + current_offset: int, + ) -> tuple[bitfield, int]: + """Create a bitfield instance, managing group transitions. + + Args: + field: The BitfieldField descriptor. + inflater: The type registry for inflating the backing type. + resolver: The struct's resolver. + current_offset: The current byte offset in the struct. + + Returns: + A tuple of (bitfield_instance, byte_offset_delta). + The delta is nonzero only when a new group starts (flushing the old one). + """ + backing_type = field.backing_type + bit_width = field.bit_width + backing_size_bits = backing_type.size * 8 + offset_delta = 0 + + # Start a new group if the backing type changed or bits would overflow + if self._backing_type is not backing_type or self._bit_offset + bit_width > backing_size_bits: + offset_delta = self.flush() + self._backing_type = backing_type + self._backing_instance = inflater.inflater_for(backing_type)( + resolver.relative_from_own(current_offset + offset_delta, 0), + ) + + is_owner = self._bit_offset == 0 + signed = getattr(backing_type, "signed", False) + + result = bitfield( + resolver.relative_from_own(current_offset + offset_delta, 0), + self._backing_instance, + self._bit_offset, + bit_width, + signed, + is_owner, + ) + self._bit_offset += bit_width + return result, offset_delta + + def compute_size(self: BitfieldTracker, field: BitfieldField) -> int: + """Account for a bitfield during size computation, without inflating. + + Args: + field: The BitfieldField descriptor. + + Returns: + The byte size delta (nonzero only when a new group starts). + """ + backing_type = field.backing_type + bit_width = field.bit_width + backing_size_bits = backing_type.size * 8 + size_delta = 0 + + if self._backing_type is not backing_type or self._bit_offset + bit_width > backing_size_bits: + size_delta = self.flush() + self._backing_type = backing_type + + self._bit_offset += bit_width + return size_delta diff --git a/libdestruct/common/enum/enum.py b/libdestruct/common/enum/enum.py index 162a07a..7aac7af 100644 --- a/libdestruct/common/enum/enum.py +++ b/libdestruct/common/enum/enum.py @@ -6,6 +6,7 @@ from __future__ import annotations +from types import GenericAlias from typing import TYPE_CHECKING from libdestruct.common.obj import obj @@ -20,6 +21,12 @@ class enum(obj): """A generic enum.""" + def __class_getitem__(cls, params: tuple) -> GenericAlias: + """Support enum[MyEnum] and enum[MyEnum, c_short] subscript syntax.""" + if not isinstance(params, tuple): + params = (params,) + return GenericAlias(cls, params) + python_enum: type[Enum] """The backing Python enum.""" @@ -47,11 +54,17 @@ def __init__( def get(self: enum) -> Enum: """Return the value of the enum.""" - return self.python_enum(self._backing_type.get()) + raw = self._backing_type.get() + if self.lenient: + try: + return self.python_enum(raw) + except ValueError: + return raw + return self.python_enum(raw) def _set(self: enum, value: Enum) -> None: """Set the value of the enum.""" - self._backing_type.set(value.value) + self._backing_type.set(int(value)) def to_bytes(self: enum) -> bytes: """Return the serialized representation of the enum.""" @@ -59,4 +72,4 @@ def to_bytes(self: enum) -> bytes: def to_str(self: obj, indent: int = 0) -> str: """Return a string representation of the object.""" - return f"{' ' * indent}{self.get()!r}" + return f"{self.get()!r}" diff --git a/libdestruct/common/enum/enum_field_inflater.py b/libdestruct/common/enum/enum_field_inflater.py index 4724a3b..d4f99cb 100644 --- a/libdestruct/common/enum/enum_field_inflater.py +++ b/libdestruct/common/enum/enum_field_inflater.py @@ -8,6 +8,8 @@ from typing import TYPE_CHECKING +from libdestruct.c.c_integer_types import c_int +from libdestruct.common.enum.enum import enum from libdestruct.common.enum.int_enum_field import IntEnumField from libdestruct.common.type_registry import TypeRegistry @@ -30,4 +32,19 @@ def generic_enum_field_inflater( return field.inflate +def _subscripted_enum_handler( + item: object, + args: tuple, + owner: tuple[obj, type[obj]] | None, +) -> Callable[[Resolver], obj] | None: + """Handle subscripted enum types like enum[MyEnum] or enum[MyEnum, c_short].""" + if not args: + return None + python_enum = args[0] + backing_type = args[1] if len(args) > 1 else c_int + field = IntEnumField(python_enum, backing_type=backing_type) + return field.inflate + + registry.register_instance_handler(IntEnumField, generic_enum_field_inflater) +registry.register_generic_handler(enum, _subscripted_enum_handler) diff --git a/libdestruct/common/enum/int_enum_field.py b/libdestruct/common/enum/int_enum_field.py index 291df0a..a9ead12 100644 --- a/libdestruct/common/enum/int_enum_field.py +++ b/libdestruct/common/enum/int_enum_field.py @@ -21,17 +21,28 @@ class IntEnumField(EnumField): """A generator for an enum of integers.""" - def __init__(self: IntEnumField, enum: type[IntEnum], lenient: bool = True, size: int = 4) -> None: + def __init__( + self: IntEnumField, + enum: type[IntEnum], + lenient: bool = True, + size: int = 4, + backing_type: type | None = None, + ) -> None: """Initialize the field. Args: enum: The enum class. lenient: Whether the conversion is lenient or not. - size: The size of the field in bytes. + size: The size of the field in bytes (used when backing_type is not provided). + backing_type: The explicit backing type to use. If provided, overrides size. """ self.enum = enum self.lenient = lenient + if backing_type is not None: + self.backing_type = backing_type + return + if not 0 < size <= 8: raise ValueError("The size of the field must be between 1 and 8 bytes.") diff --git a/libdestruct/common/flags/__init__.py b/libdestruct/common/flags/__init__.py new file mode 100644 index 0000000..631cfeb --- /dev/null +++ b/libdestruct/common/flags/__init__.py @@ -0,0 +1,12 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from libdestruct.common.flags.flags import flags +from libdestruct.common.flags.flags_of import flags_of + +__all__ = ["flags", "flags_of"] + +import libdestruct.common.flags.flags_field_inflater # noqa: F401 diff --git a/libdestruct/common/flags/flags.py b/libdestruct/common/flags/flags.py new file mode 100644 index 0000000..eb324de --- /dev/null +++ b/libdestruct/common/flags/flags.py @@ -0,0 +1,79 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from types import GenericAlias +from typing import TYPE_CHECKING + +from libdestruct.common.obj import obj +from libdestruct.common.type_registry import TypeRegistry + +if TYPE_CHECKING: # pragma: no cover + from enum import IntFlag + + from libdestruct.backing.resolver import Resolver + + +class flags(obj): + """A generic bit flags field.""" + + def __class_getitem__(cls, params: tuple) -> GenericAlias: + """Support flags[MyFlags] and flags[MyFlags, c_short] subscript syntax.""" + if not isinstance(params, tuple): + params = (params,) + return GenericAlias(cls, params) + + python_flag: type[IntFlag] + """The backing Python IntFlag.""" + + _backing_type: type[obj] + """The backing type.""" + + lenient: bool + """Whether the conversion is lenient or not.""" + + def __init__( + self: flags, + resolver: Resolver, + python_flag: type[IntFlag], + backing_type: type[obj], + lenient: bool = True, + ) -> None: + """Initialize the flags object.""" + super().__init__(resolver) + + self.python_flag = python_flag + self._backing_type = TypeRegistry().inflater_for(backing_type)(resolver) + self.lenient = lenient + + self.size = self._backing_type.size + + def get(self: flags) -> IntFlag: + """Return the value of the flags.""" + raw = self._backing_type.get() + if not self.lenient: + # Compute the mask of all defined flag bits + all_bits = 0 + for member in self.python_flag: + all_bits |= member.value + if raw & ~all_bits: + raise ValueError( + f"Unknown bits 0x{raw & ~all_bits:x} in {self.python_flag.__name__}({raw!r})" + ) + return self.python_flag(raw) + + def _set(self: flags, value: IntFlag) -> None: + """Set the value of the flags.""" + self._backing_type.set(int(value)) + + def to_bytes(self: flags) -> bytes: + """Return the serialized representation of the flags.""" + return self._backing_type.to_bytes() + + def to_str(self: obj, indent: int = 0) -> str: + """Return a string representation of the object.""" + return f"{self.get()!r}" diff --git a/libdestruct/common/flags/flags_field.py b/libdestruct/common/flags/flags_field.py new file mode 100644 index 0000000..0acffb7 --- /dev/null +++ b/libdestruct/common/flags/flags_field.py @@ -0,0 +1,27 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING + +from libdestruct.common.field import Field +from libdestruct.common.flags.flags import flags + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + + +class FlagsField(Field): + """A generator for a flags field.""" + + base_type: type[obj] = flags + + @abstractmethod + def inflate(self: FlagsField, resolver: Resolver) -> flags: + """Inflate the field.""" diff --git a/libdestruct/common/flags/flags_field_inflater.py b/libdestruct/common/flags/flags_field_inflater.py new file mode 100644 index 0000000..f0c35ce --- /dev/null +++ b/libdestruct/common/flags/flags_field_inflater.py @@ -0,0 +1,50 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.c.c_integer_types import c_int +from libdestruct.common.flags.flags import flags +from libdestruct.common.flags.int_flag_field import IntFlagField +from libdestruct.common.type_registry import TypeRegistry + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Callable + + from libdestruct.backing.resolver import Resolver + from libdestruct.common.flags.flags_field import FlagsField + from libdestruct.common.obj import obj + +registry = TypeRegistry() + + +def generic_flags_field_inflater( + field: FlagsField, + _: type[obj], + __: tuple[obj, type[obj]] | None, +) -> Callable[[Resolver], obj]: + """Returns the inflater for a flags field of a struct.""" + return field.inflate + + +def _subscripted_flags_handler( + item: object, + args: tuple, + owner: tuple[obj, type[obj]] | None, +) -> Callable[[Resolver], obj] | None: + """Handle subscripted flags types like flags[Perms] or flags[Perms, c_short].""" + if not args: + return None + python_flag = args[0] + backing_type = args[1] if len(args) > 1 else c_int + field = IntFlagField(python_flag, backing_type=backing_type) + return field.inflate + + +registry.register_instance_handler(IntFlagField, generic_flags_field_inflater) +registry.register_generic_handler(flags, _subscripted_flags_handler) diff --git a/libdestruct/common/flags/flags_of.py b/libdestruct/common/flags/flags_of.py new file mode 100644 index 0000000..3b54689 --- /dev/null +++ b/libdestruct/common/flags/flags_of.py @@ -0,0 +1,23 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from enum import IntFlag +from typing import TYPE_CHECKING + +from libdestruct.common.flags.int_flag_field import IntFlagField + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.common.flags.flags_field import FlagsField + + +def flags_of(flag_type: type[IntFlag], lenient: bool = True, size: int = 4) -> FlagsField: + """Return a new flags field.""" + if not issubclass(flag_type, IntFlag): + raise TypeError("The flag type must be a subclass of IntFlag.") + + return IntFlagField(flag_type, lenient, size) diff --git a/libdestruct/common/flags/int_flag_field.py b/libdestruct/common/flags/int_flag_field.py new file mode 100644 index 0000000..b79d5f5 --- /dev/null +++ b/libdestruct/common/flags/int_flag_field.py @@ -0,0 +1,60 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.c.c_integer_types import c_char, c_int, c_long, c_short +from libdestruct.common.flags.flags import flags +from libdestruct.common.flags.flags_field import FlagsField + +if TYPE_CHECKING: # pragma: no cover + from enum import IntFlag + + from libdestruct.backing.resolver import Resolver + + +class IntFlagField(FlagsField): + """A generator for an IntFlag-based flags field.""" + + def __init__( + self: IntFlagField, + flag_type: type[IntFlag], + lenient: bool = True, + size: int = 4, + backing_type: type | None = None, + ) -> None: + """Initialize the field.""" + self.flag_type = flag_type + self.lenient = lenient + + if backing_type is not None: + self.backing_type = backing_type + return + + if not 0 < size <= 8: + raise ValueError("The size of the field must be between 1 and 8 bytes.") + + match size: + case 1: + self.backing_type = c_char + case 2: + self.backing_type = c_short + case 4: + self.backing_type = c_int + case 8: + self.backing_type = c_long + case _: + raise ValueError("The size of the field must be a power of 2.") + + def inflate(self: IntFlagField, resolver: Resolver) -> flags: + """Inflate the field.""" + return flags(resolver, self.flag_type, self.backing_type, self.lenient) + + def get_size(self: IntFlagField) -> int: + """Returns the size of the object inflated by this field.""" + return self.backing_type.size diff --git a/libdestruct/common/forward_ref_inflater.py b/libdestruct/common/forward_ref_inflater.py new file mode 100644 index 0000000..0df3c2d --- /dev/null +++ b/libdestruct/common/forward_ref_inflater.py @@ -0,0 +1,133 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2025 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, ForwardRef + +from libdestruct.common.ptr.ptr import ptr +from libdestruct.common.ptr.ptr_field import PtrField +from libdestruct.common.type_registry import TypeRegistry + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Callable + + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + + +registry = TypeRegistry() + + +class _LazyPtrField(PtrField): + """A PtrField that lazily resolves a forward reference at inflation time.""" + + def __init__(self: _LazyPtrField, forward_ref: ForwardRef, owner: tuple[obj, type[obj]] | None) -> None: + super().__init__(None) + self.forward_ref = forward_ref + self.owner = owner + + def inflate(self: _LazyPtrField, resolver: Resolver) -> obj: + """Inflate the field, resolving the forward reference on first use.""" + if self.backing_type is None: + resolved = self._resolve_forward_ref() + if resolved is not None: + self.backing_type = registry.inflater_for(resolved) + + if self.backing_type: + return ptr(resolver, self.backing_type) + + return ptr(resolver) + + def _resolve_forward_ref(self: _LazyPtrField) -> type | None: + """Resolve the forward reference to an actual type.""" + globalns = {} + localns = {} + + if self.owner: + _, owner_type = self.owner + + # Get the user's reference struct for proper module resolution + ref_struct = getattr(owner_type, "_reference_struct", owner_type) + + if hasattr(ref_struct, "__module__"): + module = sys.modules.get(ref_struct.__module__) + if module: + globalns = module.__dict__ + + # Add the reference struct to locals for self-references + if hasattr(ref_struct, "__name__"): + localns[ref_struct.__name__] = ref_struct + + try: + resolved = eval(self.forward_ref.__forward_arg__, globalns, localns) # noqa: S307 + if isinstance(resolved, type): + return resolved + return None + except Exception: + return None + + +def _subscripted_ptr_handler( + item: object, + args: tuple, + owner: tuple[obj, type[obj]] | None, +) -> Callable[[Resolver], obj] | None: + """Handle subscripted ptr types like ptr["Node"] or ptr[SomeType].""" + target = args[0] if args else None + + if target is None: + field = PtrField(None) + return field.inflate + + if isinstance(target, type): + field = PtrField(target) + field.backing_type = registry.inflater_for(target) + return field.inflate + + # String or ForwardRef: use lazy resolution + if isinstance(target, str): + target = ForwardRef(target) + + if isinstance(target, ForwardRef): + lazy_field = _LazyPtrField(target, owner) + return lazy_field.inflate + + field = PtrField(None) + return field.inflate + + +def _forward_ref_inflater( + forward_ref: ForwardRef, + _: type[obj], + owner: tuple[obj, type[obj]] | None, +) -> Callable[[Resolver], obj]: + """Handle bare ForwardRef annotations that couldn't be resolved at annotation time.""" + forward_arg = forward_ref.__forward_arg__ + + # Check if it's a ptr forward reference (e.g., from `from __future__ import annotations` + # where ptr wasn't in scope) + if forward_arg.startswith("ptr[") and forward_arg.endswith("]"): + inner_type = forward_arg[4:-1] + if (inner_type.startswith("'") and inner_type.endswith("'")) or ( + inner_type.startswith('"') and inner_type.endswith('"') + ): + inner_type = inner_type[1:-1] + + target_ref = ForwardRef(inner_type) + lazy_field = _LazyPtrField(target_ref, owner) + return lazy_field.inflate + + raise ValueError( + f"Cannot resolve forward reference '{forward_arg}'. " + f"Ensure the type is imported and available in the module scope.", + ) + + +registry.register_generic_handler(ptr, _subscripted_ptr_handler) +registry.register_instance_handler(ForwardRef, _forward_ref_inflater) diff --git a/libdestruct/common/hexdump.py b/libdestruct/common/hexdump.py new file mode 100644 index 0000000..0775dda --- /dev/null +++ b/libdestruct/common/hexdump.py @@ -0,0 +1,48 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + + +def format_hexdump( + data: bytes, + base_address: int = 0, + annotations: dict[int, str] | None = None, +) -> str: + """Format a classic hex dump of the given data. + + Args: + data: The bytes to dump. + base_address: The starting address shown in the offset column. + annotations: Optional mapping from byte offset to field name, shown in the margin. + + Returns: + A formatted hex dump string. + """ + lines = [] + for offset in range(0, len(data), 16): + chunk = data[offset : offset + 16] + addr = base_address + offset + + hex_parts = " ".join(f"{b:02x}" for b in chunk) + # Pad to full 16-byte width + hex_parts = hex_parts.ljust(47) + + ascii_parts = "".join(chr(b) if chr(b).isprintable() and b < 128 else "." for b in chunk) # noqa: PLR2004 + + line = f"{addr:08x} {hex_parts} |{ascii_parts}|" + + # Add field annotations for this line + if annotations: + fields_on_line = [ + name for byte_offset, name in sorted(annotations.items()) if offset <= byte_offset < offset + 16 + ] + if fields_on_line: + line += " " + ", ".join(fields_on_line) + + lines.append(line) + + return "\n".join(lines) diff --git a/libdestruct/common/inflater.py b/libdestruct/common/inflater.py index fddb6f6..ff457c8 100644 --- a/libdestruct/common/inflater.py +++ b/libdestruct/common/inflater.py @@ -21,9 +21,10 @@ class Inflater: """The memory manager, which inflates any memory-referencing type.""" - def __init__(self: Inflater, memory: MutableSequence) -> None: + def __init__(self: Inflater, memory: MutableSequence, endianness: str = "little") -> None: """Initialize the memory manager.""" self.memory = memory + self.endianness = endianness self.type_registry = TypeRegistry() def inflate(self: Inflater, item: type, address: int | Resolver) -> obj: @@ -38,6 +39,6 @@ def inflate(self: Inflater, item: type, address: int | Resolver) -> obj: """ if isinstance(address, int): # Create a memory resolver from the address - address = MemoryResolver(self.memory, address) + address = MemoryResolver(self.memory, address, self.endianness) return self.type_registry.inflater_for(item)(address) diff --git a/libdestruct/common/obj.py b/libdestruct/common/obj.py index 84001ee..4f59bbd 100644 --- a/libdestruct/common/obj.py +++ b/libdestruct/common/obj.py @@ -7,13 +7,16 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Generic, TypeVar + +from libdestruct.common.hexdump import format_hexdump if TYPE_CHECKING: # pragma: no cover from libdestruct.backing.resolver import Resolver +T = TypeVar("T") -class obj(ABC): +class obj(ABC, Generic[T]): """A generic object, with reference to the backing memory view.""" endianness: str = "little" @@ -35,6 +38,8 @@ def __init__(self: obj, resolver: Resolver) -> None: resolver: The resolver for the value of this object. """ self.resolver = resolver + if resolver is not None: + self.endianness = resolver.endianness @property def address(self: obj) -> int: @@ -54,9 +59,12 @@ def to_bytes(self: obj) -> bytes: """Serialize the object to bytes.""" @classmethod - def from_bytes(cls: type[obj], data: bytes) -> obj: + def from_bytes(cls: type[obj], data: bytes, endianness: str = "little") -> obj: """Deserialize the object from bytes.""" - item = cls(data, 0) + from libdestruct.libdestruct import inflater + + lib = inflater(data, endianness=endianness) + item = lib.inflate(cls, 0) item.freeze() return item @@ -69,8 +77,8 @@ def set(self: obj, value: object) -> None: def freeze(self: obj) -> None: """Freeze the object.""" - self._frozen_value = self.get() - self._frozen = True + object.__setattr__(self, "_frozen_value", self.get()) + object.__setattr__(self, "_frozen", True) def diff(self: obj) -> tuple[object, object]: """Return the difference between the current value and the frozen value.""" @@ -89,7 +97,7 @@ def reset(self: obj) -> None: def update(self: obj) -> None: """Update the object with the given value.""" try: - self._frozen_value = self.get() + object.__setattr__(self, "_frozen_value", self.get()) except ValueError as e: raise RuntimeError("Could not update the object.") from e @@ -124,12 +132,69 @@ def __repr__(self: obj) -> str: """Return a string representation of the object.""" return f"{self.__class__.__name__}({self.get()})" - def __eq__(self: obj, value: object) -> bool: + def _compare_value(self: obj, other: object) -> tuple[object, object] | None: + """Extract comparable values from self and other, or None if incompatible.""" + self_val = self.value + if isinstance(other, obj): + other_val = other.value + # Guard against incompatible value types (e.g. int vs str from struct.get()) + if type(self_val) is not type(other_val) and not isinstance(self_val, type(other_val)) and not isinstance(other_val, type(self_val)): + return None + return self_val, other_val + if isinstance(other, int | float | bytes): + return self_val, other + return None + + def __eq__(self: obj, other: object) -> bool: """Return whether the object is equal to the given value.""" - if not isinstance(value, obj): - return False - - return self.get() == value.get() + pair = self._compare_value(other) + if pair is None: + return NotImplemented + return pair[0] == pair[1] + + def __ne__(self: obj, other: object) -> bool: + """Return whether the object is not equal to the given value.""" + pair = self._compare_value(other) + if pair is None: + return NotImplemented + return pair[0] != pair[1] + + def __lt__(self: obj, other: object) -> bool: + """Return whether this object is less than the given value.""" + pair = self._compare_value(other) + if pair is None: + return NotImplemented + return pair[0] < pair[1] + + def __le__(self: obj, other: object) -> bool: + """Return whether this object is less than or equal to the given value.""" + pair = self._compare_value(other) + if pair is None: + return NotImplemented + return pair[0] <= pair[1] + + def __gt__(self: obj, other: object) -> bool: + """Return whether this object is greater than the given value.""" + pair = self._compare_value(other) + if pair is None: + return NotImplemented + return pair[0] > pair[1] + + def __ge__(self: obj, other: object) -> bool: + """Return whether this object is greater than or equal to the given value.""" + pair = self._compare_value(other) + if pair is None: + return NotImplemented + return pair[0] >= pair[1] + + def to_dict(self: obj) -> object: + """Return a JSON-serializable representation of the object.""" + return self.value + + def hexdump(self: obj) -> str: + """Return a hex dump of this object's bytes.""" + address = self.address if not self._frozen else 0 + return format_hexdump(self.to_bytes(), address) def __bytes__(self: obj) -> bytes: """Return the serialized object.""" diff --git a/libdestruct/common/ptr/ptr.py b/libdestruct/common/ptr/ptr.py index 8b3ebbd..b550905 100644 --- a/libdestruct/common/ptr/ptr.py +++ b/libdestruct/common/ptr/ptr.py @@ -6,16 +6,44 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TypeVar +from libdestruct.backing.resolver import Resolver from libdestruct.common.field import Field from libdestruct.common.obj import obj +from libdestruct.common.utils import size_of -if TYPE_CHECKING: # pragma: no cover - from libdestruct.backing.resolver import Resolver +T = TypeVar("T") -class ptr(obj): +class _ArithmeticResolver(Resolver): + """A resolver for pointers produced by arithmetic operations. + + Stores a fixed address but delegates memory access to the original resolver. + """ + + def __init__(self: _ArithmeticResolver, original: Resolver, address: int) -> None: + self._original = original + self._address = address + self.endianness = original.endianness + + def resolve_address(self: _ArithmeticResolver) -> int: + return self._address + + def resolve(self: _ArithmeticResolver, size: int, _: int) -> bytes: + return self._address.to_bytes(size, self.endianness) + + def modify(self: _ArithmeticResolver, _size: int, _index: int, _value: bytes) -> None: + raise RuntimeError("Cannot modify a synthetic pointer.") + + def absolute_from_own(self: _ArithmeticResolver, address: int) -> Resolver: + return self._original.absolute_from_own(address) + + def relative_from_own(self: _ArithmeticResolver, address_offset: int, _index_offset: int) -> Resolver: + return self._original.absolute_from_own(self._address + address_offset) + + +class ptr(obj[T]): """A pointer to an object in memory.""" size: int = 8 @@ -30,6 +58,9 @@ def __init__(self: ptr, resolver: Resolver, wrapper: type | None = None) -> None """ super().__init__(resolver) self.wrapper = wrapper + self._cached_unwrap: obj | bytes | None = None + self._cache_valid: bool = False + self._cached_length: int | None = None def get(self: ptr) -> int: """Return the value of the pointer.""" @@ -46,37 +77,53 @@ def to_bytes(self: obj) -> bytes: def _set(self: ptr, value: int) -> None: """Set the value of the pointer to the given value.""" self.resolver.modify(self.size, 0, value.to_bytes(self.size, self.endianness)) + self.invalidate() + + def invalidate(self: ptr) -> None: + """Clear the cached unwrap result.""" + self._cached_unwrap = None + self._cache_valid = False + self._cached_length = None - def unwrap(self: ptr, length: int | None = None) -> obj: + def unwrap(self: ptr, length: int | None = None) -> obj | bytes: """Return the object pointed to by the pointer. Args: length: The length of the object in memory this points to. """ + if self._cache_valid and self._cached_length == length: + return self._cached_unwrap + address = self.get() if self.wrapper: if length: raise ValueError("Length is not supported when unwrapping a pointer to a wrapper object.") - return self.wrapper(self.resolver.absolute_from_own(address)) - - if not length: - length = 1 + result = self.wrapper(self.resolver.absolute_from_own(address)) + else: + target_resolver = self.resolver.absolute_from_own(address) + result = target_resolver.resolve(length if length is not None else 1, 0) - return self.resolver.resolve(length, 0) + self._cached_unwrap = result + self._cache_valid = True + self._cached_length = length + return result - def try_unwrap(self: ptr, length: int | None = None) -> obj | None: + def try_unwrap(self: ptr, length: int | None = None) -> obj | bytes | None: """Return the object pointed to by the pointer, if it is valid. Args: length: The length of the object in memory this points to. """ + if self._cache_valid and self._cached_length == length: + return self._cached_unwrap + address = self.get() try: # If the address is invalid, this will raise an IndexError or ValueError. - self.resolver.absolute_from_own(address).resolve(length) + self.resolver.absolute_from_own(address).resolve(length if length is not None else 1, 0) except (IndexError, ValueError): return None @@ -95,6 +142,27 @@ def to_str(self: ptr, _: int = 0) -> str: return f"{name}@0x{self.get():x}" + @property + def _element_size(self: ptr) -> int: + """Return the byte size of the pointed-to element.""" + if self.wrapper is None: + return 1 + return size_of(self.wrapper) + + def __add__(self: ptr, n: int) -> ptr: + """Return a new pointer advanced by n elements.""" + new_addr = self.get() + n * self._element_size + return ptr(_ArithmeticResolver(self.resolver, new_addr), self.wrapper) + + def __sub__(self: ptr, n: int) -> ptr: + """Return a new pointer retreated by n elements.""" + new_addr = self.get() - n * self._element_size + return ptr(_ArithmeticResolver(self.resolver, new_addr), self.wrapper) + + def __getitem__(self: ptr, n: int) -> obj: + """Return the object at index n relative to this pointer.""" + return (self + n).unwrap() + def __str__(self: ptr) -> str: """Return a string representation of the pointer.""" return self.to_str() diff --git a/libdestruct/common/struct/__init__.py b/libdestruct/common/struct/__init__.py index e7733b6..c6909e4 100644 --- a/libdestruct/common/struct/__init__.py +++ b/libdestruct/common/struct/__init__.py @@ -8,7 +8,7 @@ from libdestruct.common.struct.struct import struct from libdestruct.common.struct.struct_impl import struct_impl -__all__ = ["struct", "struct_impl", "ptr_to", "ptr_to_self"] +__all__ = ["ptr_to", "ptr_to_self", "struct", "struct_impl"] import libdestruct.common.ptr.ptr_field_inflater import libdestruct.common.struct.struct_inflater # noqa: F401 diff --git a/libdestruct/common/struct/struct.py b/libdestruct/common/struct/struct.py index 894302a..2d812d6 100644 --- a/libdestruct/common/struct/struct.py +++ b/libdestruct/common/struct/struct.py @@ -26,12 +26,14 @@ def __init__(self: struct) -> None: def __new__(cls: type[struct], *args: ..., **kwargs: ...) -> struct: # noqa: PYI034 """Create a new struct.""" # Look for an inflater for this struct - inflater = TypeRegistry().inflater_for(cls) - return inflater(*args, **kwargs) + type_impl = TypeRegistry().inflater_for(cls) + return type_impl(*args, **kwargs) @classmethod - def from_bytes(cls: type[struct], data: bytes) -> struct_impl: + def from_bytes(cls: type[struct], data: bytes, endianness: str = "little") -> struct_impl: """Create a struct from a serialized representation.""" - type_inflater = inflater(data) + type_inflater = inflater(data, endianness=endianness) - return type_inflater.inflate(cls, 0) + result = type_inflater.inflate(cls, 0) + result.freeze() + return result diff --git a/libdestruct/common/struct/struct_impl.py b/libdestruct/common/struct/struct_impl.py index dc40b6a..7ef8c02 100644 --- a/libdestruct/common/struct/struct_impl.py +++ b/libdestruct/common/struct/struct_impl.py @@ -6,16 +6,23 @@ from __future__ import annotations +from types import GenericAlias +from typing import Annotated, get_args, get_origin + from typing_extensions import Self from libdestruct.backing.fake_resolver import FakeResolver from libdestruct.backing.resolver import Resolver +from libdestruct.common.array.vla_field import VLAField from libdestruct.common.attributes.offset_attribute import OffsetAttribute +from libdestruct.common.bitfield.bitfield_field import BitfieldField +from libdestruct.common.bitfield.bitfield_tracker import BitfieldTracker from libdestruct.common.field import Field +from libdestruct.common.hexdump import format_hexdump from libdestruct.common.obj import obj from libdestruct.common.struct import struct from libdestruct.common.type_registry import TypeRegistry -from libdestruct.common.utils import iterate_annotation_chain, size_of +from libdestruct.common.utils import _align_offset, alignment_of, iterate_annotation_chain, size_of class struct_impl(struct): @@ -42,8 +49,8 @@ def __init__(self: struct_impl, resolver: Resolver | None = None, **kwargs: ...) # struct overrides the __init__ method, so we need to call the parent class __init__ method obj.__init__(self, resolver) - self.name = self.__class__.__name__ - self._members = {} + object.__setattr__(self, "_struct_name", self.__class__.__name__) + object.__setattr__(self, "_members", {}) reference_type = self._reference_struct self._inflate_struct_attributes(self._inflater, resolver, reference_type) @@ -51,6 +58,28 @@ def __init__(self: struct_impl, resolver: Resolver | None = None, **kwargs: ...) for name, value in kwargs.items(): getattr(self, name).value = value + def __getattribute__(self: struct_impl, name: str) -> object: + """Return the attribute, checking struct members first to avoid collisions with obj properties.""" + # Check _members dict directly to avoid infinite recursion + try: + members = object.__getattribute__(self, "_members") + if name in members: + return members[name] + except AttributeError: + pass + return super().__getattribute__(name) + + def __setattr__(self: struct_impl, name: str, value: object) -> None: + """Set an attribute, delegating to member.value for struct fields.""" + try: + members = object.__getattribute__(self, "_members") + if name in members: + members[name].value = value + return + except AttributeError: + pass + object.__setattr__(self, name, value) + def __new__(cls: struct_impl, *args: ..., **kwargs: ...) -> Self: """Create a new struct.""" # Skip the __new__ method of the parent class @@ -64,124 +93,286 @@ def _inflate_struct_attributes( reference_type: type, ) -> None: current_offset = 0 + max_alignment = 1 + bf_tracker = BitfieldTracker() + aligned = getattr(reference_type, "_aligned_", False) + object.__setattr__(self, "_member_offsets", {}) for name, annotation, reference in iterate_annotation_chain(reference_type, terminate_at=struct): - if name in reference.__dict__: - # Field associated with the annotation - attrs = getattr(reference, name) - - # If attrs is not a tuple, we need to convert it to a tuple - if not isinstance(attrs, tuple): - attrs = (attrs,) - - # Assert that in all attributes, there is only one Field - if sum(isinstance(attr, Field) for attr in attrs) > 1: - raise ValueError("Only one Field is allowed per attribute.") - - resolved_type = None - - for attr in attrs: - if isinstance(attr, Field): - resolved_type = inflater.inflater_for( - (attr, annotation), - owner=(self, reference_type._type_impl), - ) - elif isinstance(attr, OffsetAttribute): - offset = attr.offset - if offset < current_offset: - raise ValueError("Offset must be greater than the current size.") - current_offset = offset - else: - raise TypeError("Only Field and OffsetAttribute are allowed in attributes.") - - # If we don't have a Field, we need to inflate the type as if we have no attributes - if not resolved_type: - resolved_type = inflater.inflater_for(annotation, owner=(self, reference_type._type_impl)) + if name == "_aligned_": + continue + + resolved_type, bitfield_field, explicit_offset = struct_impl._resolve_field( + name, annotation, reference, inflater, owner=(self, reference_type._type_impl), + ) + + if explicit_offset is not None: + current_offset += bf_tracker.flush() + if explicit_offset < current_offset: + raise ValueError("Offset must be greater than the current size.") + current_offset = explicit_offset + + if bitfield_field: + if aligned and bf_tracker.needs_new_group(bitfield_field): + current_offset += bf_tracker.flush() + field_align = alignment_of(bitfield_field.backing_type) + max_alignment = max(max_alignment, field_align) + current_offset = _align_offset(current_offset, field_align) + self._member_offsets[name] = current_offset + result, offset_delta = bf_tracker.create_bitfield( + bitfield_field, inflater, resolver, current_offset, + ) + current_offset += offset_delta else: - resolved_type = inflater.inflater_for(annotation, owner=(self, reference_type._type_impl)) + current_offset += bf_tracker.flush() + if aligned and explicit_offset is None: + # Try alignment from the resolved type directly; for closures + # (e.g. union inflaters) alignment_of can't inspect them, so + # fall back to creating a probe instance. + field_align = alignment_of(resolved_type) + if field_align <= 1: + try: + probe = resolved_type(resolver.relative_from_own(current_offset, 0)) + field_align = alignment_of(probe) + except (ValueError, TypeError): + pass + max_alignment = max(max_alignment, field_align) + current_offset = _align_offset(current_offset, field_align) + self._member_offsets[name] = current_offset + result = resolved_type(resolver.relative_from_own(current_offset, 0)) + current_offset += size_of(result) - result = resolved_type(resolver.relative_from_own(current_offset, 0)) - setattr(self, name, result) self._members[name] = result - current_offset += size_of(result) + + current_offset += bf_tracker.flush() + + # Apply tail padding for aligned structs + if aligned: + if isinstance(aligned, int) and aligned is not True: + max_alignment = max(max_alignment, aligned) + current_offset = _align_offset(current_offset, max_alignment) + + # For VLA structs, size must be computed dynamically since the count + # can change at runtime. Detect VLA by duck-typing: vla_impl has a + # _count_member attribute that plain array_impl does not. + members = object.__getattribute__(self, "_members") + last_member = list(members.values())[-1] if members else None + if last_member is not None and hasattr(last_member, "_count_member"): + last_name = list(members.keys())[-1] + object.__setattr__(self, "_vla_fixed_offset", self._member_offsets[last_name]) + else: + object.__setattr__(self, "size", current_offset) + + @staticmethod + def _resolve_field( + name: str, + annotation: type, + reference: type, + inflater: TypeRegistry, + owner: tuple[obj, type] | None, + ) -> tuple[object | None, BitfieldField | None, int | None]: + """Resolve a single struct field annotation to its inflater or BitfieldField. + + Returns: + A tuple of (resolved_inflater, bitfield_field, explicit_offset). + Either resolved_inflater or bitfield_field will be non-None (not both). + explicit_offset is set when an OffsetAttribute is present. + """ + # Unwrap Annotated[type, metadata...] — extract the real type and any metadata + annotated_offset = None + if get_origin(annotation) is Annotated: + ann_args = get_args(annotation) + annotation = ann_args[0] + for meta in ann_args[1:]: + if isinstance(meta, OffsetAttribute): + annotated_offset = meta.offset + + if name not in reference.__dict__: + return inflater.inflater_for(annotation, owner=owner), None, annotated_offset + + attrs = getattr(reference, name) + if not isinstance(attrs, tuple): + attrs = (attrs,) + + if sum(isinstance(attr, Field) for attr in attrs) > 1: + raise ValueError("Only one Field is allowed per attribute.") + + resolved_type = None + bitfield_field = None + explicit_offset = annotated_offset + + for attr in attrs: + if isinstance(attr, BitfieldField): + bitfield_field = attr + elif isinstance(attr, Field): + resolved_type = inflater.inflater_for( + (attr, annotation), owner=owner, + ) + elif isinstance(attr, OffsetAttribute): + explicit_offset = attr.offset + else: + raise TypeError("Only Field, BitfieldField, and OffsetAttribute are allowed in attributes.") + + if not resolved_type and not bitfield_field: + resolved_type = inflater.inflater_for(annotation, owner=owner) + + return resolved_type, bitfield_field, explicit_offset @classmethod def compute_own_size(cls: type[struct_impl], reference_type: type) -> None: """Compute the size of the struct.""" size = 0 + max_alignment = 1 + bf_tracker = BitfieldTracker() + aligned = getattr(reference_type, "_aligned_", False) + seen_vla = False for name, annotation, reference in iterate_annotation_chain(reference_type, terminate_at=struct): - if name in reference.__dict__: - # Field associated with the annotation - attrs = getattr(reference, name) - - # If attrs is not a tuple, we need to convert it to a tuple - if not isinstance(attrs, tuple): - attrs = (attrs,) - - # Assert that in all attributes, there is only one Field - if sum(isinstance(attr, Field) for attr in attrs) > 1: - raise ValueError("Only one Field is allowed per attribute.") - - attribute = None - - for attr in attrs: - if isinstance(attr, Field): - attribute = cls._inflater.inflater_for((attr, annotation))(None) - elif isinstance(attr, OffsetAttribute): - offset = attr.offset - if offset < size: - raise ValueError("Offset must be greater than the current size.") - size = offset - else: - raise TypeError("Only Field and OffsetAttribute are allowed in attributes.") - - # If we don't have a Field, we need to inflate the attribute as if we have no attributes - if not attribute: - attribute = cls._inflater.inflater_for(annotation) - elif isinstance(annotation, Field): - attribute = cls._inflater.inflater_for((annotation, annotation.base_type))(None) + if name == "_aligned_": + continue + + # VLA must be the last field + if seen_vla: + raise ValueError( + f"Variable-length array must be the last field in a struct. " + f"Field '{name}' follows a VLA." + ) + # Detect VLA from default value or subscript annotation + default = getattr(reference, name, None) if hasattr(reference, name) else None + is_vla = isinstance(default, VLAField) + if not is_vla and isinstance(annotation, GenericAlias): + args = annotation.__args__ + if len(args) == 2 and isinstance(args[1], str): + is_vla = True + if is_vla: + seen_vla = True + + resolved_type, bitfield_field, explicit_offset = struct_impl._resolve_field( + name, annotation, reference, cls._inflater, owner=(None, cls), + ) + + has_explicit_offset = explicit_offset is not None + if has_explicit_offset: + size += bf_tracker.flush() + if explicit_offset < size: + raise ValueError("Offset must be greater than the current size.") + size = explicit_offset + + if bitfield_field: + if aligned and bf_tracker.needs_new_group(bitfield_field): + size += bf_tracker.flush() + field_align = alignment_of(bitfield_field.backing_type) + max_alignment = max(max_alignment, field_align) + size = _align_offset(size, field_align) + size += bf_tracker.compute_size(bitfield_field) else: - attribute = cls._inflater.inflater_for(annotation) - - size += size_of(attribute) + size += bf_tracker.flush() + # Get attribute for size computation — try size_of directly first, + # falling back to calling the inflater with None for complex fields. + # Direct size_of avoids recursion for forward-ref pointers. + try: + attribute_size = size_of(resolved_type) + attribute = resolved_type + except (ValueError, TypeError): + attribute = resolved_type(None) + attribute_size = size_of(attribute) + if aligned and not has_explicit_offset: + field_align = alignment_of(attribute) + max_alignment = max(max_alignment, field_align) + size = _align_offset(size, field_align) + size += attribute_size + + size += bf_tracker.flush() + + if aligned: + if isinstance(aligned, int) and aligned is not True: + max_alignment = max(max_alignment, aligned) + size = _align_offset(size, max_alignment) cls.size = size + cls.alignment = max_alignment if aligned else 1 + + @property + def address(self: struct_impl) -> int: + """Return the address of the struct, bypassing __getattribute__ to avoid member collisions.""" + resolver = object.__getattribute__(self, "resolver") + return resolver.resolve_address() def get(self: struct_impl) -> str: """Return the value of the struct.""" - return f"{self.name}(address={self.address}, size={size_of(self)})" + name = object.__getattribute__(self, "_struct_name") + addr = struct_impl.address.fget(self) + return f"{name}(address={addr}, size={size_of(self)})" def to_bytes(self: struct_impl) -> bytes: - """Return the serialized representation of the struct.""" - return b"".join(member.to_bytes() for member in self._members.values()) + """Return the serialized representation of the struct, including padding.""" + if object.__getattribute__(self, "_frozen"): + return object.__getattribute__(self, "_frozen_struct_bytes") + resolver = object.__getattribute__(self, "resolver") + return resolver.resolve(size_of(self), 0) + + def to_dict(self: struct_impl) -> dict[str, object]: + """Return a JSON-serializable dict of field names to values.""" + members = object.__getattribute__(self, "_members") + return {name: member.to_dict() for name, member in members.items()} + + def hexdump(self: struct_impl) -> str: + """Return a hex dump of this struct's bytes with field annotations.""" + member_offsets = object.__getattribute__(self, "_member_offsets") + members = object.__getattribute__(self, "_members") + annotations: dict[int, str] = {} + for name in members: + off = member_offsets[name] + if off in annotations: + annotations[off] += ", " + name + else: + annotations[off] = name + address = struct_impl.address.fget(self) if not object.__getattribute__(self, "_frozen") else 0 + return format_hexdump(self.to_bytes(), address, annotations) def _set(self: struct_impl, _: str) -> None: """Set the value of the struct to the given value.""" raise RuntimeError("Cannot set the value of a struct.") def freeze(self: struct_impl) -> None: - """Freeze the struct.""" - # The struct has no implicit value, but it must freeze its members - for member in self._members.values(): + """Freeze the struct, capturing the full byte representation including padding.""" + resolver = object.__getattribute__(self, "resolver") + object.__setattr__(self, "_frozen_struct_bytes", resolver.resolve(size_of(self), 0)) + + members = object.__getattribute__(self, "_members") + for member in members.values(): member.freeze() - self._frozen = True + super().freeze() + + def reset(self: struct_impl) -> None: + """Reset each member to its frozen value.""" + if not object.__getattribute__(self, "_frozen"): + raise RuntimeError("Cannot reset a struct that has not been frozen.") + + members = object.__getattribute__(self, "_members") + for member in members.values(): + member.reset() def to_str(self: struct_impl, indent: int = 0) -> str: """Return a string representation of the struct.""" + name = object.__getattribute__(self, "_struct_name") + members_dict = object.__getattribute__(self, "_members") members = ",\n".join( - [f"{' ' * (indent + 4)}{name}: {member.to_str(indent + 4)}" for name, member in self._members.items()], + [f"{' ' * (indent + 4)}{n}: {member.to_str(indent + 4)}" for n, member in members_dict.items()], ) - return f"""{self.name} {{ + return f"""{name} {{ {members} -{' ' * indent}}}""" +{" " * indent}}}""" def __repr__(self: struct_impl) -> str: """Return a string representation of the struct.""" - members = ",\n".join([f"{name}: {member}" for name, member in self._members.items()]) - return f"""{self.name} {{ - address: 0x{self.address:x}, + name = object.__getattribute__(self, "_struct_name") + addr = struct_impl.address.fget(self) + members_dict = object.__getattribute__(self, "_members") + members = ",\n".join([f"{n}: {member}" for n, member in members_dict.items()]) + return f"""{name} {{ + address: 0x{addr:x}, size: 0x{size_of(self):x}, members: {{ {members} @@ -191,12 +382,15 @@ def __repr__(self: struct_impl) -> str: def __eq__(self: struct_impl, value: object) -> bool: """Return whether the struct is equal to the given value.""" if not isinstance(value, struct_impl): - return False + return NotImplemented if size_of(self) != size_of(value): return False - if not self._members.keys() == value._members.keys(): + self_members = object.__getattribute__(self, "_members") + other_members = object.__getattribute__(value, "_members") + + if self_members.keys() != other_members.keys(): return False - return all(getattr(self, name) == getattr(value, name) for name in self._members) + return all(getattr(self, name) == getattr(value, name) for name in self_members) diff --git a/libdestruct/common/type_registry.py b/libdestruct/common/type_registry.py index 71516d5..35a9c23 100644 --- a/libdestruct/common/type_registry.py +++ b/libdestruct/common/type_registry.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, get_args, get_origin from libdestruct.common.field import Field @@ -28,6 +28,9 @@ class TypeRegistry: type_handlers: dict[type, list[Callable[[type[obj]], type[obj] | None]]] """The handlers for generic object types, with basic inheritance support.""" + generic_handlers: dict[type, list[Callable]] + """The handlers for subscripted generic types like ptr[T].""" + instance_handlers: dict[ type, list[ @@ -46,6 +49,7 @@ def __new__(cls: type[TypeRegistry]) -> Self: cls._instance.mapping = {} cls._instance.type_handlers = {} + cls._instance.generic_handlers = {} cls._instance.instance_handlers = {} return cls._instance @@ -64,6 +68,10 @@ def inflater_for( Returns: The inflater for the object type. """ + origin = get_origin(item) + if origin is not None: + return self._inflater_for_generic(item, origin, get_args(item), owner) + if isinstance(item, type): if item in self.mapping: return self.mapping[item] @@ -86,6 +94,20 @@ def _inflater_for_type(self: TypeRegistry, item: type[obj]) -> type[obj]: raise ValueError(f"No applicable inflater found for {item}") + def _inflater_for_generic( + self: TypeRegistry, + item: object, + origin: type, + args: tuple, + owner: tuple[obj, type[obj]] | None, + ) -> Callable[[Resolver], obj]: + for handler in self.generic_handlers.get(origin, []): + result = handler(item, args, owner) + if result is not None: + return result + + raise ValueError(f"No applicable inflater found for subscripted type {item}") + def _inflater_for_instance( self: TypeRegistry, instance: Field | tuple[object, type[obj]], @@ -106,7 +128,6 @@ def _inflater_for_instance( result = handler(item, annotation, owner) if result is not None: - self.mapping[base] = result return result raise ValueError(f"No applicable inflater found for {item}") @@ -125,7 +146,8 @@ def register_type_handler( if parent not in self.type_handlers: self.type_handlers[parent] = [] - self.type_handlers[parent].append(handler) + if handler not in self.type_handlers[parent]: + self.type_handlers[parent].append(handler) def register_instance_handler( self: TypeRegistry, @@ -144,7 +166,25 @@ def register_instance_handler( if parent not in self.instance_handlers: self.instance_handlers[parent] = [] - self.instance_handlers[parent].append(handler) + if handler not in self.instance_handlers[parent]: + self.instance_handlers[parent].append(handler) + + def register_generic_handler( + self: TypeRegistry, + origin: type, + handler: Callable, + ) -> None: + """Register a handler for a subscripted generic type. + + Args: + origin: The origin type (e.g., ptr for ptr[T]). + handler: The handler for the subscripted type. + """ + if origin not in self.generic_handlers: + self.generic_handlers[origin] = [] + + if handler not in self.generic_handlers[origin]: + self.generic_handlers[origin].append(handler) def register_mapping( self: TypeRegistry, diff --git a/libdestruct/common/union/__init__.py b/libdestruct/common/union/__init__.py new file mode 100644 index 0000000..587cf52 --- /dev/null +++ b/libdestruct/common/union/__init__.py @@ -0,0 +1,14 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from libdestruct.common.union.tagged_union_of import tagged_union +from libdestruct.common.union.union import union +from libdestruct.common.union.union_of import union_of + +__all__ = ["tagged_union", "union", "union_of"] + +import libdestruct.common.union.tagged_union_field_inflater +import libdestruct.common.union.union_field_inflater # noqa: F401 diff --git a/libdestruct/common/union/tagged_union_field.py b/libdestruct/common/union/tagged_union_field.py new file mode 100644 index 0000000..9c4929c --- /dev/null +++ b/libdestruct/common/union/tagged_union_field.py @@ -0,0 +1,49 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.field import Field +from libdestruct.common.union.union import union +from libdestruct.common.utils import alignment_of, size_of + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + + +class TaggedUnionField(Field): + """A field descriptor for a tagged union in a struct.""" + + base_type: type[obj] = union + + def __init__(self: TaggedUnionField, discriminator: str, variants: dict[object, type]) -> None: + """Initialize the tagged union field. + + Args: + discriminator: The name of the struct field used as the discriminator. + variants: A mapping from discriminator values to variant types. + """ + self.discriminator = discriminator + self.variants = variants + + def inflate(self: TaggedUnionField, resolver: Resolver | None) -> union: + """Inflate the field (used during size computation with resolver=None). + + Args: + resolver: The backing resolver (None during size computation). + """ + return union(resolver, None, self.get_size()) + + def get_size(self: TaggedUnionField) -> int: + """Return the size of the union (max of all variant sizes).""" + return max(size_of(variant) for variant in self.variants.values()) + + def get_alignment(self: TaggedUnionField) -> int: + """Return the alignment of the union (max of all variant alignments).""" + return max(alignment_of(variant) for variant in self.variants.values()) diff --git a/libdestruct/common/union/tagged_union_field_inflater.py b/libdestruct/common/union/tagged_union_field_inflater.py new file mode 100644 index 0000000..afccd4c --- /dev/null +++ b/libdestruct/common/union/tagged_union_field_inflater.py @@ -0,0 +1,61 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.type_registry import TypeRegistry +from libdestruct.common.union.tagged_union_field import TaggedUnionField +from libdestruct.common.union.union import union + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Callable + + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + +registry = TypeRegistry() + + +def tagged_union_field_inflater( + field: TaggedUnionField, + _: type[obj], + owner: tuple[obj, type[obj]] | None, +) -> Callable[[Resolver], obj]: + """Return the inflater for a tagged union field. + + During size computation (owner[0] is None), returns field.inflate which + creates a stub with the correct max size. + + During actual inflation, returns a closure that reads the discriminator + from the struct instance and inflates the matching variant. + """ + if owner is None or owner[0] is None: + return field.inflate + + struct_instance = owner[0] + + def inflate_with_discriminator(resolver: Resolver) -> union: + members = object.__getattribute__(struct_instance, "_members") + disc_value = members[field.discriminator].value + + if disc_value not in field.variants: + raise ValueError( + f"Unknown discriminator value {disc_value!r} for field '{field.discriminator}'. " + f"Valid values: {list(field.variants.keys())}" + ) + + variant_type = field.variants[disc_value] + variant_inflater = registry.inflater_for(variant_type) + variant = variant_inflater(resolver) + + return union(resolver, variant, field.get_size()) + + return inflate_with_discriminator + + +registry.register_instance_handler(TaggedUnionField, tagged_union_field_inflater) diff --git a/libdestruct/common/union/tagged_union_of.py b/libdestruct/common/union/tagged_union_of.py new file mode 100644 index 0000000..5555026 --- /dev/null +++ b/libdestruct/common/union/tagged_union_of.py @@ -0,0 +1,22 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from libdestruct.common.union.tagged_union_field import TaggedUnionField + + +def tagged_union(discriminator: str, variants: dict[object, type]) -> TaggedUnionField: + """Create a tagged union field descriptor. + + Args: + discriminator: The name of the struct field used to select the active variant. + variants: A mapping from discriminator values to variant types. + + Returns: + A TaggedUnionField for use as a struct field default value. + """ + return TaggedUnionField(discriminator, variants) diff --git a/libdestruct/common/union/union.py b/libdestruct/common/union/union.py new file mode 100644 index 0000000..9e73f04 --- /dev/null +++ b/libdestruct/common/union/union.py @@ -0,0 +1,135 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.obj import obj + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.backing.resolver import Resolver + + +class union(obj): + """A union value, supporting both tagged (single active variant) and plain (all variants overlaid) modes.""" + + _variant: obj | None + """The single active variant (tagged union mode).""" + + _variants: dict[str, obj] + """Named variants (plain union mode).""" + + _frozen_bytes: bytes | None + """The frozen bytes of the full union region.""" + + def __init__( + self: union, + resolver: Resolver | None, + variant: obj | None, + max_size: int, + variants: dict[str, obj] | None = None, + ) -> None: + """Initialize the union. + + Args: + resolver: The backing resolver. + variant: The single active variant (tagged union mode, None for plain unions). + max_size: The size of the union (max of all variant sizes). + variants: Named variants dict (plain union mode, None for tagged unions). + """ + super().__init__(resolver) + self._variant = variant + self._variants = variants or {} + self.size = max_size + self._frozen_bytes = None + + @property + def variant(self: union) -> obj | None: + """Return the active variant object (tagged union mode).""" + return self._variant + + def get(self: union) -> object: + """Return the value of the active variant.""" + if self._variant is not None: + return self._variant.get() + if self._variants: + return {name: v.get() for name, v in self._variants.items()} + return None + + def _set(self: union, value: object) -> None: + """Set the value of the active variant.""" + if self._variant is None: + raise RuntimeError("Cannot set the value of a union without an active variant.") + self._variant._set(value) + + def to_dict(self: union) -> object: + """Return a JSON-serializable representation of the union.""" + if self._variant is not None: + return self._variant.to_dict() + if self._variants: + return {name: v.to_dict() for name, v in self._variants.items()} + return None + + def to_bytes(self: union) -> bytes: + """Return the full union-sized region as bytes.""" + if self._frozen_bytes is not None: + return self._frozen_bytes + if self.resolver is None: + return b"\x00" * self.size + return self.resolver.resolve(self.size, 0) + + def freeze(self: union) -> None: + """Freeze the union and all its variants.""" + if self.resolver is not None: + self._frozen_bytes = self.resolver.resolve(self.size, 0) + else: + self._frozen_bytes = b"\x00" * self.size + if self._variant is not None: + self._variant.freeze() + for v in self._variants.values(): + v.freeze() + super().freeze() + + def diff(self: union) -> tuple[object, object]: + """Return the difference between the frozen and current value.""" + if self._variant is not None: + return self._variant.diff() + return {name: v.diff() for name, v in self._variants.items()} + + def reset(self: union) -> None: + """Reset the union to its frozen value by restoring the full frozen byte region.""" + if self._frozen_bytes is None: + raise RuntimeError("Cannot reset a union that has not been frozen.") + if self.resolver is not None: + self.resolver.modify(self.size, 0, self._frozen_bytes) + + def to_str(self: union, indent: int = 0) -> str: + """Return a string representation of the union.""" + if self._variant is not None: + return self._variant.to_str(indent) + if self._variants: + members = ", ".join(self._variants) + return f"union({members})" + return "union(empty)" + + def __getattr__(self: union, name: str) -> object: + """Delegate attribute access to named variants or the active variant.""" + try: + variants = object.__getattribute__(self, "_variants") + if name in variants: + return variants[name] + except AttributeError: + pass + + try: + variant = object.__getattribute__(self, "_variant") + if variant is not None: + return getattr(variant, name) + except AttributeError: + pass + + raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") diff --git a/libdestruct/common/union/union_field.py b/libdestruct/common/union/union_field.py new file mode 100644 index 0000000..5f709c9 --- /dev/null +++ b/libdestruct/common/union/union_field.py @@ -0,0 +1,47 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.field import Field +from libdestruct.common.union.union import union +from libdestruct.common.utils import alignment_of, size_of + +if TYPE_CHECKING: # pragma: no cover + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + + +class UnionField(Field): + """A field descriptor for a plain (non-discriminated) union in a struct.""" + + base_type: type[obj] = union + + def __init__(self: UnionField, variants: dict[str, type]) -> None: + """Initialize the union field. + + Args: + variants: A mapping from variant names to their types. + """ + self.variants = variants + + def inflate(self: UnionField, resolver: Resolver | None) -> union: + """Inflate the field (used during size computation with resolver=None). + + Args: + resolver: The backing resolver (None during size computation). + """ + return union(resolver, None, self.get_size()) + + def get_size(self: UnionField) -> int: + """Return the size of the union (max of all variant sizes).""" + return max(size_of(variant) for variant in self.variants.values()) + + def get_alignment(self: UnionField) -> int: + """Return the alignment of the union (max of all variant alignments).""" + return max(alignment_of(variant) for variant in self.variants.values()) diff --git a/libdestruct/common/union/union_field_inflater.py b/libdestruct/common/union/union_field_inflater.py new file mode 100644 index 0000000..59804d2 --- /dev/null +++ b/libdestruct/common/union/union_field_inflater.py @@ -0,0 +1,51 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libdestruct.common.type_registry import TypeRegistry +from libdestruct.common.union.union import union +from libdestruct.common.union.union_field import UnionField + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Callable + + from libdestruct.backing.resolver import Resolver + from libdestruct.common.obj import obj + +registry = TypeRegistry() + + +def union_field_inflater( + field: UnionField, + _: type[obj], + owner: tuple[obj, type[obj]] | None, +) -> Callable[[Resolver], obj]: + """Return the inflater for a plain union field. + + During size computation (owner[0] is None), returns field.inflate which + creates a stub with the correct max size. + + During actual inflation, returns a closure that inflates all variants + at the same memory location. + """ + if owner is None or owner[0] is None: + return field.inflate + + def inflate_all_variants(resolver: Resolver) -> union: + variants = {} + for name, variant_type in field.variants.items(): + variant_inflater = registry.inflater_for(variant_type) + variants[name] = variant_inflater(resolver) + + return union(resolver, None, field.get_size(), variants=variants) + + return inflate_all_variants + + +registry.register_instance_handler(UnionField, union_field_inflater) diff --git a/libdestruct/common/union/union_of.py b/libdestruct/common/union/union_of.py new file mode 100644 index 0000000..e7486c3 --- /dev/null +++ b/libdestruct/common/union/union_of.py @@ -0,0 +1,21 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +from __future__ import annotations + +from libdestruct.common.union.union_field import UnionField + + +def union_of(variants: dict[str, type]) -> UnionField: + """Create a plain union field descriptor. + + Args: + variants: A mapping from variant names to their types. + + Returns: + A UnionField for use as a struct field default value. + """ + return UnionField(variants) diff --git a/libdestruct/common/utils.py b/libdestruct/common/utils.py index 4d8aeeb..d37836a 100644 --- a/libdestruct/common/utils.py +++ b/libdestruct/common/utils.py @@ -6,10 +6,13 @@ from __future__ import annotations -from types import MethodType -from typing import TYPE_CHECKING, Any +import contextlib +import sys +from types import GenericAlias, MethodType +from typing import TYPE_CHECKING, Any, ForwardRef from libdestruct.common.field import Field +from libdestruct.common.type_registry import TypeRegistry if TYPE_CHECKING: # pragma: no cover from collections.abc import Generator @@ -24,23 +27,128 @@ def is_field_bound_method(item: obj) -> bool: def size_of(item_or_inflater: obj | callable[[Resolver], obj]) -> int: - """Return the size of an object, from an obj or it's inflater.""" - if hasattr(item_or_inflater.__class__, "size"): - # This has the priority over the size of the object itself - # as we might be dealing with a struct object - # that defines an attribute named "size" - return item_or_inflater.__class__.size - if hasattr(item_or_inflater, "size"): - return item_or_inflater.size - - # Check if item is the bound method of a Field + """Return the size in bytes of a type, instance, or field descriptor.""" + # Field instances (e.g. array_of, ptr_to) — must come before .size check + if isinstance(item_or_inflater, Field): + return item_or_inflater.get_size() if is_field_bound_method(item_or_inflater): - field_object = item_or_inflater.__self__ - return field_object.get_size() + return item_or_inflater.__self__.get_size() + + # Subscripted GenericAlias types (e.g. array[c_int, 10], enum[Color], ptr[T]) + if isinstance(item_or_inflater, GenericAlias): + inflater = TypeRegistry().inflater_for(item_or_inflater) + return size_of(inflater) + + # Struct types: size is on the inflated _type_impl class (check own __dict__ to avoid MRO leaks) + if isinstance(item_or_inflater, type) and "_type_impl" in item_or_inflater.__dict__: + return item_or_inflater._type_impl.size + + # Struct types not yet inflated: trigger inflation to compute size + if isinstance(item_or_inflater, type) and not hasattr(item_or_inflater, "size"): + impl = TypeRegistry().inflater_for(item_or_inflater) + if hasattr(impl, "size") and isinstance(impl.size, int): + return impl.size + + # Check class-level size (works for both types and instances) + if isinstance(item_or_inflater, type): + if hasattr(item_or_inflater, "size") and isinstance(item_or_inflater.size, int): + return item_or_inflater.size + elif "_vla_fixed_offset" in item_or_inflater.__dict__: + # VLA struct: size = fixed offset + dynamic VLA size + vla_offset = item_or_inflater.__dict__["_vla_fixed_offset"] + members = object.__getattribute__(item_or_inflater, "_members") + last_member = list(members.values())[-1] + return vla_offset + last_member.size + elif "size" in item_or_inflater.__dict__: + return item_or_inflater.__dict__["size"] + elif hasattr(item_or_inflater, "size"): + # Handles both class-level attributes and properties (e.g. vla_impl.size) + return item_or_inflater.size raise ValueError(f"Cannot determine the size of {item_or_inflater}") +def alignment_of(item: obj | type[obj]) -> int: + """Return the natural alignment of a type or instance. + + For primitive types, alignment equals their size (1, 2, 4, or 8). + For struct types, alignment is computed as the max of member alignments. + For packed structs (the default), alignment is 1. + """ + # For uninflated struct types, trigger inflation first so alignment is computed + if isinstance(item, type) and not hasattr(item, "size") and "_type_impl" not in item.__dict__: + with contextlib.suppress(ValueError, TypeError): + size_of(item) + + # Struct types with computed alignment (check own __dict__ to avoid MRO leaks) + if isinstance(item, type) and "_type_impl" in item.__dict__: + impl = item._type_impl + if hasattr(impl, "alignment"): + return impl.alignment + + # Explicit alignment attribute (struct_impl instances, arrays, etc.) + if not isinstance(item, type) and hasattr(item, "alignment") and isinstance(item.alignment, int): + return item.alignment + if isinstance(item, type) and "alignment" in item.__dict__ and isinstance(item.__dict__["alignment"], int): + return item.__dict__["alignment"] + + # Field descriptors — use get_alignment if available, else derive from element type or size + if isinstance(item, Field): + if hasattr(item, "get_alignment"): + return item.get_alignment() + if hasattr(item, "item"): + return alignment_of(item.item) + return _alignment_from_size(item.get_size()) + if is_field_bound_method(item): + field = item.__self__ + if hasattr(field, "get_alignment"): + return field.get_alignment() + if hasattr(field, "item"): + return alignment_of(field.item) + return _alignment_from_size(field.get_size()) + + # Derive from size for power-of-2 sized types + try: + s = size_of(item) + return _alignment_from_size(s) + except (ValueError, TypeError): + return 1 + + +def _alignment_from_size(s: int) -> int: + """Derive alignment from size: return size if it's a power of 2 and <= 8, else 1.""" + max_alignment = 8 + if s > 0 and (s & (s - 1)) == 0 and s <= max_alignment: + return s + return 1 + + +def _align_offset(offset: int, alignment: int) -> int: + """Round up offset to the next multiple of alignment.""" + remainder = offset % alignment + return offset + (alignment - remainder) if remainder else offset + + +def _resolve_annotation(annotation: Any, defining_class: type) -> Any: + """Resolve a string annotation to its actual type. + + For annotations that are strings (e.g., from ``from __future__ import annotations``), + evaluates them in the defining class's module namespace. + Non-string annotations are returned as-is. + """ + if not isinstance(annotation, str): + return annotation + + module = sys.modules.get(defining_class.__module__, None) + globalns = getattr(module, "__dict__", {}) if module else {} + localns = {defining_class.__name__: defining_class} + + try: + return eval(annotation, globalns, localns) # noqa: S307 + except Exception: + return ForwardRef(annotation) + + def iterate_annotation_chain(item: obj, terminate_at: object | None = None) -> Generator[tuple[str, Any, type[obj]]]: """Iterate over the annotation chain of the provided item.""" current_item = item @@ -53,4 +161,4 @@ def iterate_annotation_chain(item: obj, terminate_at: object | None = None) -> G for reference_item in chain: for name, annotation in reference_item.__annotations__.items(): - yield name, annotation, reference_item + yield name, _resolve_annotation(annotation, reference_item), reference_item diff --git a/libdestruct/libdestruct.py b/libdestruct/libdestruct.py index 81e8235..04f05ee 100644 --- a/libdestruct/libdestruct.py +++ b/libdestruct/libdestruct.py @@ -6,31 +6,89 @@ from __future__ import annotations +import mmap from collections.abc import Sequence +from pathlib import Path from typing import TYPE_CHECKING +from typing_extensions import Self + from libdestruct.backing.resolver import Resolver from libdestruct.common.inflater import Inflater if TYPE_CHECKING: # pragma: no cover + import io + from libdestruct.common.obj import obj -def inflater(memory: Sequence) -> Inflater: +_VALID_ENDIANNESS = ("little", "big") + + +def inflater(memory: Sequence | mmap.mmap, endianness: str = "little") -> Inflater: """Return a TypeInflater instance.""" - if not isinstance(memory, Sequence): - raise TypeError(f"memory must be a MutableSequence, not {type(memory).__name__}") + if not isinstance(memory, Sequence | mmap.mmap): + raise TypeError(f"memory must be a Sequence, not {type(memory).__name__}") + + if endianness not in _VALID_ENDIANNESS: + raise ValueError(f"endianness must be 'little' or 'big', not {endianness!r}") + + return Inflater(memory, endianness=endianness) + + +class FileInflater(Inflater): + """An inflater backed by a memory-mapped file.""" + + def __init__( + self: FileInflater, + file_handle: io.BufferedReader, + mmap_obj: mmap.mmap, + endianness: str = "little", + ) -> None: + """Initialize the file-backed inflater.""" + super().__init__(mmap_obj, endianness=endianness) + self._file_handle = file_handle + self._mmap = mmap_obj + + def __enter__(self: FileInflater) -> Self: + """Enter context manager.""" + return self + + def __exit__(self: FileInflater, *args: object) -> None: + """Close mmap and file handle.""" + self._mmap.close() + self._file_handle.close() + + +def inflater_from_file(path: str, writable: bool = False, endianness: str = "little") -> FileInflater: + """Create an inflater backed by a memory-mapped file. + + Args: + path: Path to the binary file. + writable: If True, writes through the inflater are persisted to the file. + endianness: The byte order ("little" or "big"). + + Returns: + A FileInflater context manager. + """ + if endianness not in _VALID_ENDIANNESS: + raise ValueError(f"endianness must be 'little' or 'big', not {endianness!r}") - return Inflater(memory) + mode = "r+b" if writable else "rb" + access = mmap.ACCESS_WRITE if writable else mmap.ACCESS_READ + file_handle = Path(path).open(mode) # noqa: SIM115 — managed by FileInflater.__exit__ + mmap_obj = mmap.mmap(file_handle.fileno(), 0, access=access) + return FileInflater(file_handle, mmap_obj, endianness=endianness) -def inflate(item: type, memory: Sequence, address: int | Resolver) -> obj: +def inflate(item: type, memory: Sequence, address: int | Resolver, endianness: str = "little") -> obj: """Inflate a memory-referencing type. Args: item: The type to inflate. memory: The memory view, which can be mutable or immutable. address: The address of the object in the memory view. + endianness: The byte order ("little" or "big"). Returns: The inflated object. @@ -38,4 +96,4 @@ def inflate(item: type, memory: Sequence, address: int | Resolver) -> obj: if not isinstance(address, int) and not isinstance(address, Resolver): raise TypeError(f"address must be an int or a Resolver, not {type(address).__name__}") - return inflater(memory).inflate(item, address) + return inflater(memory, endianness=endianness).inflate(item, address) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..2b5e014 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,75 @@ +site_name: Docs / libdestruct +repo_url: https://github.com/mrindeciso/libdestruct +repo_name: mrindeciso/libdestruct + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: deep purple + accent: purple + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: deep purple + accent: purple + toggle: + icon: material/brightness-4 + name: Switch to light mode + font: + text: Nunito + code: Hack + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + +plugins: + - search + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.tabbed: + alternate_style: true + - attr_list + - md_in_html + - toc: + permalink: true + +nav: + - Home: index.md + - The Basics: + - Getting Started: basics/getting_started.md + - C Types: basics/types.md + - Structs: basics/structs.md + - Pointers: basics/pointers.md + - Arrays: basics/arrays.md + - Enums: basics/enums.md + - Bit Flags: basics/flags.md + - Memory: + - The Inflater: memory/inflater.md + - File-Backed Memory: memory/file_inflater.md + - Resolvers: memory/resolvers.md + - Advanced: + - Bitfields: advanced/bitfields.md + - Freeze, Diff & Reset: advanced/freeze_diff.md + - C Struct Parser: advanced/c_parser.md + - Forward References: advanced/forward_refs.md + - Hex Dump: advanced/hexdump.md + - Unions: advanced/tagged_unions.md + - Struct Alignment: advanced/alignment.md + - Struct Inheritance: advanced/inheritance.md + - Variable-Length Arrays: advanced/vla.md + - Field Offsets: advanced/offset.md diff --git a/pyproject.toml b/pyproject.toml index 09ee941..06dd261 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ dev = [ "rich", ] +[tool.setuptools.packages.find] +include = ["libdestruct*"] + [tool.ruff] line-length = 120 indent-width = 4 @@ -44,7 +47,7 @@ exclude = ["test/"] [tool.ruff.lint] select = ["ALL"] -ignore = ["D100", "D104", "EM", "FBT", "G", "TD", "TRY002", "TRY003", "RET505", "SLF001", "S603", "S606", "N801"] +ignore = ["D100", "D104", "EM", "FBT", "G", "TD", "TRY002", "TRY003", "RET505", "SLF001", "S603", "S606", "N801", "COM812"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/test/scripts/alignment_test.py b/test/scripts/alignment_test.py new file mode 100644 index 0000000..b714b5e --- /dev/null +++ b/test/scripts/alignment_test.py @@ -0,0 +1,310 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +import struct as pystruct +import unittest + +from libdestruct import c_char, c_int, c_long, c_short, c_uchar, inflater, offset, size_of, struct +from libdestruct.common.utils import alignment_of + + +class AlignmentOfTest(unittest.TestCase): + def test_alignment_of_c_char(self): + self.assertEqual(alignment_of(c_char), 1) + + def test_alignment_of_c_short(self): + self.assertEqual(alignment_of(c_short), 2) + + def test_alignment_of_c_int(self): + self.assertEqual(alignment_of(c_int), 4) + + def test_alignment_of_c_long(self): + self.assertEqual(alignment_of(c_long), 8) + + +class AlignedStructTest(unittest.TestCase): + def test_packed_struct_no_padding(self): + """Default structs are packed with no alignment padding.""" + class packed_t(struct): + a: c_char + b: c_int + + # packed: 1 + 4 = 5 + self.assertEqual(size_of(packed_t), 5) + + def test_aligned_struct_padding(self): + """Aligned struct inserts padding for field alignment.""" + class aligned_t(struct): + _aligned_ = True + a: c_char + b: c_int + + # aligned: 1 + 3 padding + 4 = 8 + self.assertEqual(size_of(aligned_t), 8) + + def test_aligned_struct_tail_padding(self): + """Aligned struct pads total size to max alignment.""" + class aligned_t(struct): + _aligned_ = True + a: c_int + b: c_char + + # aligned: 4 + 1 + 3 tail padding = 8 (aligned to 4-byte boundary) + self.assertEqual(size_of(aligned_t), 8) + + def test_aligned_struct_read_values(self): + """Values are read correctly from aligned positions.""" + class aligned_t(struct): + _aligned_ = True + a: c_char + b: c_int + + # a at offset 0, padding 3 bytes, b at offset 4 + memory = pystruct.pack(" combined: 0b01010_101 = 0x55 + memory = (0b01010_101).to_bytes(4, "little") + test = test_t.from_bytes(memory) + self.assertEqual(test.a.value, 5) + self.assertEqual(test.b.value, 10) + + # Struct should be 4 bytes total (both share one c_uint) + self.assertEqual(test.to_bytes(), memory) + + def test_bitfield_signed(self): + class test_t(struct): + val: c_int = bitfield_of(c_int, 4) + + # 4-bit signed: 0b1111 = -1 + memory = (0b1111).to_bytes(4, "little") + test = test_t.from_bytes(memory) + self.assertEqual(test.val.value, -1) + + # 4-bit signed: 0b0111 = 7 + memory2 = (0b0111).to_bytes(4, "little") + test2 = test_t.from_bytes(memory2) + self.assertEqual(test2.val.value, 7) + + def test_bitfield_full_width(self): + class test_t(struct): + val: c_uint = bitfield_of(c_uint, 32) + + memory = (0xDEADBEEF).to_bytes(4, "little") + test = test_t.from_bytes(memory) + self.assertEqual(test.val.value, 0xDEADBEEF) + + +class BitfieldWriteTest(unittest.TestCase): + """Bitfield write operations.""" + + def test_bitfield_write(self): + class test_t(struct): + a: c_uint = bitfield_of(c_uint, 3) + b: c_uint = bitfield_of(c_uint, 5) + + memory = bytearray(4) + from libdestruct import inflater + lib = inflater(memory) + test = lib.inflate(test_t, 0) + + test.a.value = 7 # 0b111 + test.b.value = 15 # 0b01111 + + self.assertEqual(test.a.value, 7) + self.assertEqual(test.b.value, 15) + + # Verify only relevant bits changed + raw = int.from_bytes(memory[:4], "little") + self.assertEqual(raw & 0b111, 7) # bits 0-2 + self.assertEqual((raw >> 3) & 0b11111, 15) # bits 3-7 + + +class BitfieldRoundTripTest(unittest.TestCase): + """Bitfield serialization.""" + + def test_bitfield_round_trip(self): + class test_t(struct): + a: c_uint = bitfield_of(c_uint, 3) + b: c_uint = bitfield_of(c_uint, 5) + c: c_int + + # a=3, b=10, c=42 + # a=0b011, b=0b01010 -> byte 0-3: 0b01010_011 = 0x53 + val = 0b01010_011 + memory = val.to_bytes(4, "little") + (42).to_bytes(4, "little") + + test = test_t.from_bytes(memory) + self.assertEqual(test.a.value, 3) + self.assertEqual(test.b.value, 10) + self.assertEqual(test.c.value, 42) + + self.assertEqual(test.to_bytes(), memory) + + +class BitfieldBackingTypeTest(unittest.TestCase): + """Bitfield backing type transitions.""" + + def test_bitfield_backing_type_change(self): + class test_t(struct): + a: c_uint = bitfield_of(c_uint, 3) + b: c_uint = bitfield_of(c_uint, 5) + # Different backing type -> new group + c: c_long = bitfield_of(c_long, 16) + + # a+b share 4 bytes (c_uint), c uses 8 bytes (c_long) + # Total = 12 bytes + memory = b"\x00" * 12 + test = test_t.from_bytes(memory) + self.assertEqual(len(test.to_bytes()), 12) + + +class BitfieldCParserTest(unittest.TestCase): + """C parser bitfield support.""" + + def test_bitfield_c_parser(self): + t = definition_to_type("struct test { unsigned int flags:3; unsigned int reserved:5; };") + self.assertIn("flags", t.__annotations__) + self.assertIn("reserved", t.__annotations__) + + # Inflate and verify + memory = (0b01010_101).to_bytes(4, "little") + test = t.from_bytes(memory) + self.assertEqual(test.flags.value, 5) + self.assertEqual(test.reserved.value, 10) + + +class BitfieldFreezeTest(unittest.TestCase): + def test_bitfield_freeze_to_bytes(self): + """Frozen bitfield struct to_bytes returns original bytes.""" + memory = bytearray(4) + memory[0] = 0b_00101_011 # a=3, b=5 + + class flags_t(struct): + a: c_int = bitfield_of(c_int, 3) + b: c_int = bitfield_of(c_int, 5) + + lib = inflater(memory) + s = lib.inflate(flags_t, 0) + original_bytes = bytes(s.to_bytes()) + s.freeze() + memory[0] = 0xFF + self.assertEqual(s.to_bytes(), original_bytes) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/scripts/bug_verification_test.py b/test/scripts/bug_verification_test.py new file mode 100644 index 0000000..9489766 --- /dev/null +++ b/test/scripts/bug_verification_test.py @@ -0,0 +1,417 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# +# Tests that verify bugs found during code review. +# Each test is expected to FAIL on the current dev branch. +# + +import struct as pystruct +import unittest + +from libdestruct import ( + c_char, + c_int, + c_long, + c_short, + inflater, + ptr, + ptr_to, + size_of, + struct, + tagged_union, + union_of, +) +from libdestruct.backing.memory_resolver import MemoryResolver +from libdestruct.common.union.union import union +from libdestruct.common.union.union_field import UnionField +from libdestruct.common.union.tagged_union_field import TaggedUnionField +from libdestruct.common.utils import alignment_of + + +class Bug1_AlignedStructTailPaddingInstance(unittest.TestCase): + """Bug: _inflate_struct_attributes does not apply tail padding for aligned structs. + + compute_own_size (class-level) correctly adds tail padding so that + size_of(aligned_t) == 8, but _inflate_struct_attributes (instance-level) + sets self.size = current_offset without tail padding, giving size 5. + + This means size_of(instance) != size_of(class) for aligned structs + where the last field doesn't end on an aligned boundary. + """ + + def test_instance_size_matches_class_size(self): + """size_of(instance) should equal size_of(class) for aligned structs.""" + class aligned_t(struct): + _aligned_ = True + a: c_int # 4 bytes at offset 0 + b: c_char # 1 byte at offset 4, then 3 bytes tail padding + + # Class size correctly includes tail padding + self.assertEqual(size_of(aligned_t), 8) + + memory = pystruct.pack("i", 0x12345678)) + lib = inflater(memory, endianness="big") + val = lib.inflate(c_int, 0) + self.assertEqual(val.value, 0x12345678) + + def test_c_int_read_little_endian_default(self): + """Default endianness is little-endian (backward compatibility).""" + memory = bytearray(pystruct.pack("h", -1234)) + lib = inflater(memory, endianness="big") + val = lib.inflate(c_short, 0) + self.assertEqual(val.value, -1234) + + def test_c_long_read_big_endian(self): + """Big-endian c_long reads correctly.""" + memory = bytearray(pystruct.pack(">q", 0x0102030405060708)) + lib = inflater(memory, endianness="big") + val = lib.inflate(c_long, 0) + self.assertEqual(val.value, 0x0102030405060708) + + def test_c_uint_read_big_endian(self): + """Big-endian unsigned int reads correctly.""" + memory = bytearray(pystruct.pack(">I", 0xDEADBEEF)) + lib = inflater(memory, endianness="big") + val = lib.inflate(c_uint, 0) + self.assertEqual(val.value, 0xDEADBEEF) + + def test_c_int_write_big_endian(self): + """Writing a big-endian c_int stores bytes in big-endian order.""" + memory = bytearray(4) + lib = inflater(memory, endianness="big") + val = lib.inflate(c_int, 0) + val.value = 0x12345678 + self.assertEqual(memory, pystruct.pack(">i", 0x12345678)) + + def test_c_int_to_bytes_big_endian(self): + """to_bytes returns big-endian representation.""" + memory = bytearray(pystruct.pack(">i", 42)) + lib = inflater(memory, endianness="big") + val = lib.inflate(c_int, 0) + self.assertEqual(val.to_bytes(), pystruct.pack(">i", 42)) + + +class BigEndianFloatTest(unittest.TestCase): + def test_c_float_read_big_endian(self): + """Big-endian c_float reads correctly.""" + memory = bytearray(pystruct.pack(">f", 3.14)) + lib = inflater(memory, endianness="big") + val = lib.inflate(c_float, 0) + self.assertAlmostEqual(val.value, 3.14, places=5) + + def test_c_double_read_big_endian(self): + """Big-endian c_double reads correctly.""" + memory = bytearray(pystruct.pack(">d", 2.718281828)) + lib = inflater(memory, endianness="big") + val = lib.inflate(c_double, 0) + self.assertAlmostEqual(val.value, 2.718281828, places=8) + + def test_c_float_write_big_endian(self): + """Writing a big-endian c_float stores bytes in big-endian order.""" + memory = bytearray(4) + lib = inflater(memory, endianness="big") + val = lib.inflate(c_float, 0) + val.value = 1.5 + self.assertEqual(memory, pystruct.pack(">f", 1.5)) + + +class BigEndianStructTest(unittest.TestCase): + def test_struct_fields_inherit_endianness(self): + """Struct fields inherit big-endian from the inflater.""" + class s_t(struct): + a: c_int + b: c_short + + memory = bytearray(pystruct.pack(">i", 0x12345678) + pystruct.pack(">h", -100)) + lib = inflater(memory, endianness="big") + s = lib.inflate(s_t, 0) + self.assertEqual(s.a.value, 0x12345678) + self.assertEqual(s.b.value, -100) + + def test_nested_struct_inherits_endianness(self): + """Nested struct fields also inherit big-endian.""" + class inner_t(struct): + x: c_int + + class outer_t(struct): + a: c_short + inner: inner_t + + memory = bytearray(pystruct.pack(">h", 0x0102) + pystruct.pack(">i", 0x03040506)) + lib = inflater(memory, endianness="big") + s = lib.inflate(outer_t, 0) + self.assertEqual(s.a.value, 0x0102) + self.assertEqual(s.inner.x.value, 0x03040506) + + def test_struct_write_big_endian(self): + """Writing to struct fields stores bytes in big-endian order.""" + class s_t(struct): + a: c_int + + memory = bytearray(4) + lib = inflater(memory, endianness="big") + s = lib.inflate(s_t, 0) + s.a.value = 0x1A2B3C4D + self.assertEqual(memory, b"\x1A\x2B\x3C\x4D") + + +class BigEndianFromBytesTest(unittest.TestCase): + def test_from_bytes_big_endian(self): + """from_bytes with endianness='big' reads correctly.""" + data = pystruct.pack(">i", 0x12345678) + val = c_int.from_bytes(data, endianness="big") + self.assertEqual(val.value, 0x12345678) + + def test_from_bytes_default_little_endian(self): + """from_bytes defaults to little-endian.""" + data = pystruct.pack("i", 1000) + pystruct.pack(">h", 2000) + s = s_t.from_bytes(data, endianness="big") + self.assertEqual(s.a.value, 1000) + self.assertEqual(s.b.value, 2000) + + +class BigEndianRoundTripTest(unittest.TestCase): + def test_int_round_trip(self): + """Big-endian int survives from_bytes -> to_bytes round trip.""" + original = pystruct.pack(">i", 0x12345678) + val = c_int.from_bytes(original, endianness="big") + self.assertEqual(val.to_bytes(), original) + + def test_struct_round_trip(self): + """Big-endian struct survives from_bytes -> to_bytes round trip.""" + class s_t(struct): + a: c_int + b: c_short + + original = pystruct.pack(">i", 0x1A2B3C4D) + pystruct.pack(">h", 0x1122) + s = s_t.from_bytes(original, endianness="big") + self.assertEqual(s.to_bytes(), original) + + def test_float_round_trip(self): + """Big-endian float survives from_bytes -> to_bytes round trip.""" + original = pystruct.pack(">f", 3.14) + val = c_float.from_bytes(original, endianness="big") + self.assertEqual(val.to_bytes(), original) + + +class BigEndianPointerTest(unittest.TestCase): + def test_ptr_read_big_endian(self): + """Big-endian pointer reads address in big-endian order.""" + memory = bytearray(16) + # Pointer at offset 0 with big-endian value 8 + memory[0:8] = pystruct.pack(">Q", 8) + # Target int at offset 8 + memory[8:12] = pystruct.pack(">i", 42) + + lib = inflater(memory, endianness="big") + p = lib.inflate(ptr_to(c_int), 0) + self.assertEqual(p.get(), 8) + self.assertEqual(p.unwrap().value, 42) + + def test_ptr_arithmetic_big_endian(self): + """Pointer arithmetic works with big-endian pointers.""" + memory = bytearray(24) + # Pointer at offset 0 pointing to offset 8 + memory[0:8] = pystruct.pack(">Q", 8) + # Two ints at offset 8 and 12 + memory[8:12] = pystruct.pack(">i", 100) + memory[12:16] = pystruct.pack(">i", 200) + + lib = inflater(memory, endianness="big") + p = lib.inflate(ptr_to(c_int), 0) + self.assertEqual(p[0].value, 100) + self.assertEqual(p[1].value, 200) + + +class BigEndianArrayTest(unittest.TestCase): + def test_array_big_endian(self): + """Array elements inherit big-endian.""" + class s_t(struct): + arr: list[c_int] = array_of(c_int, 3) + + data = b"" + for v in [10, 20, 30]: + data += pystruct.pack(">i", v) + + memory = bytearray(data) + lib = inflater(memory, endianness="big") + s = lib.inflate(s_t, 0) + self.assertEqual(s.arr[0].value, 10) + self.assertEqual(s.arr[1].value, 20) + self.assertEqual(s.arr[2].value, 30) + + +class BigEndianBitfieldTest(unittest.TestCase): + def test_bitfield_big_endian(self): + """Bitfield reads from big-endian backing integer.""" + class s_t(struct): + flags: c_uint = bitfield_of(c_uint, 3) + + # Value 5 (0b101) in big-endian uint32 + memory = bytearray(pystruct.pack(">I", 5)) + lib = inflater(memory, endianness="big") + s = lib.inflate(s_t, 0) + self.assertEqual(s.flags.value, 5) + + def test_bitfield_write_big_endian(self): + """Bitfield writes to big-endian backing integer.""" + class s_t(struct): + flags: c_uint = bitfield_of(c_uint, 3) + + memory = bytearray(4) + lib = inflater(memory, endianness="big") + s = lib.inflate(s_t, 0) + s.flags.value = 5 + self.assertEqual(memory, pystruct.pack(">I", 5)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/scripts/enum_test.py b/test/scripts/enum_test.py index 1231425..bd283ac 100644 --- a/test/scripts/enum_test.py +++ b/test/scripts/enum_test.py @@ -7,7 +7,7 @@ import unittest from enum import Enum, IntEnum -from libdestruct import inflater, enum, enum_of, struct +from libdestruct import inflater, c_int, enum, enum_of, struct class EnumTest(unittest.TestCase): def test_enum(self): @@ -70,3 +70,64 @@ class TestHolder2(struct): with self.assertRaises(ValueError): enum_of(Test, size=9) + + def test_enum_to_str_no_leading_indent(self): + """enum.to_str() should not add unexpected leading indentation.""" + class Color(IntEnum): + RED = 0 + GREEN = 1 + + class test_t(struct): + color: enum = enum_of(Color) + x: c_int + + memory = b"" + memory += (1).to_bytes(4, "little") # GREEN + memory += (42).to_bytes(4, "little") + + test = test_t.from_bytes(memory) + result = test.to_str() + + # Should be " color: ", not " color: " + self.assertIn("color: ", result) + + def test_enum_standalone_to_str(self): + class Color(IntEnum): + RED = 0 + BLUE = 1 + + class test_t(struct): + color: enum = enum_of(Color) + + memory = (0).to_bytes(4, "little") + test = test_t.from_bytes(memory) + + result = test.color.to_str() + self.assertFalse(result.startswith(" ")) + + def test_enum_value_extraction(self): + class Status(IntEnum): + OK = 0 + ERROR = 1 + PENDING = 2 + + class test_t(struct): + status: enum = enum_of(Status) + + memory = (2).to_bytes(4, "little") + test = test_t.from_bytes(memory) + self.assertEqual(test.status.value, Status.PENDING) + + def test_bytes_on_bytearray_backed_enum(self): + class Color(IntEnum): + RED = 0 + GREEN = 1 + + class test_t(struct): + color: enum = enum_of(Color) + + lib = inflater(bytearray(b"\x01\x00\x00\x00")) + test = lib.inflate(test_t, 0) + + result = bytes(test) + self.assertIsInstance(result, bytes) diff --git a/test/scripts/file_inflater_test.py b/test/scripts/file_inflater_test.py new file mode 100644 index 0000000..8268e24 --- /dev/null +++ b/test/scripts/file_inflater_test.py @@ -0,0 +1,106 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +import mmap +import os +import struct as pystruct +import tempfile +import unittest + +from libdestruct import struct, c_int, c_long, inflater, inflater_from_file, size_of + + +class point_t(struct): + x: c_int + y: c_int + + +class FileInflaterTest(unittest.TestCase): + """File-backed inflater tests.""" + + def setUp(self): + self.tmpfile = tempfile.NamedTemporaryFile(delete=False) + self.tmpfile.write(pystruct.pack(" to_bytes identity.""" + data = pystruct.pack(" 0) + + def test_flags_rejects_non_intflag(self): + """flags_of(IntEnum) raises TypeError.""" + class Color(IntEnum): + RED = 1 + + with self.assertRaises(TypeError): + flags_of(Color) + + def test_size_of_flags(self): + """size_of(flags[Perms]) returns 4.""" + self.assertEqual(size_of(flags[Perms]), 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/scripts/inheritance_test.py b/test/scripts/inheritance_test.py new file mode 100644 index 0000000..427c703 --- /dev/null +++ b/test/scripts/inheritance_test.py @@ -0,0 +1,134 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +import struct as pystruct +import unittest + +from libdestruct import struct, c_char, c_int, c_long, inflater, size_of, alignment_of + + +class base_t(struct): + a: c_int + + +class derived_t(base_t): + b: c_int + + +class level_a(struct): + x: c_int + + +class level_b(level_a): + y: c_int + + +class level_c(level_b): + z: c_int + + +class InheritanceTest(unittest.TestCase): + """Struct inheritance tests.""" + + def test_basic_inheritance_fields(self): + """Derived struct has both parent and own fields.""" + data = pystruct.pack(" B -> C, each adding c_int.""" + self.assertEqual(size_of(level_c), 12) + data = pystruct.pack("f", 3.14) + f = c_float.from_bytes(original, endianness="big") + self.assertAlmostEqual(f.value, 3.14, places=5) + self.assertEqual(f.to_bytes(), original) + + def test_c_double_big_endian(self): + original = pystruct.pack(">d", 2.718) + d = c_double.from_bytes(original, endianness="big") + self.assertAlmostEqual(d.value, 2.718, places=3) + self.assertEqual(d.to_bytes(), original) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/scripts/review_fix_test_2.py b/test/scripts/review_fix_test_2.py new file mode 100644 index 0000000..bf09ce3 --- /dev/null +++ b/test/scripts/review_fix_test_2.py @@ -0,0 +1,289 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +"""Tests that expose bugs found during the second-pass code review.""" + +import unittest + +from libdestruct import ( + array, + bitfield_of, + c_int, + c_uint, + inflater, + size_of, + struct, +) +from libdestruct.c.struct_parser import clear_parser_cache, definition_to_type + + +class ComparisonOperatorSafetyTest(unittest.TestCase): + """Comparison operators must not raise TypeError for incompatible obj types.""" + + def test_lt_primitive_vs_struct_returns_not_implemented(self): + """c_int < struct should return NotImplemented, not raise TypeError.""" + class s_t(struct): + x: c_int + + memory = bytearray(4) + lib = inflater(memory) + val = lib.inflate(c_int, 0) + s = lib.inflate(s_t, 0) + + # Must not raise TypeError + result = val.__lt__(s) + self.assertIs(result, NotImplemented) + + def test_gt_primitive_vs_struct_returns_not_implemented(self): + class s_t(struct): + x: c_int + + memory = bytearray(4) + lib = inflater(memory) + val = lib.inflate(c_int, 0) + s = lib.inflate(s_t, 0) + + result = val.__gt__(s) + self.assertIs(result, NotImplemented) + + def test_le_primitive_vs_struct_returns_not_implemented(self): + class s_t(struct): + x: c_int + + memory = bytearray(4) + lib = inflater(memory) + val = lib.inflate(c_int, 0) + s = lib.inflate(s_t, 0) + + result = val.__le__(s) + self.assertIs(result, NotImplemented) + + def test_ge_primitive_vs_struct_returns_not_implemented(self): + class s_t(struct): + x: c_int + + memory = bytearray(4) + lib = inflater(memory) + val = lib.inflate(c_int, 0) + s = lib.inflate(s_t, 0) + + result = val.__ge__(s) + self.assertIs(result, NotImplemented) + + def test_eq_primitive_vs_struct_returns_not_implemented(self): + class s_t(struct): + x: c_int + + memory = bytearray(4) + lib = inflater(memory) + val = lib.inflate(c_int, 0) + s = lib.inflate(s_t, 0) + + result = val.__eq__(s) + self.assertIs(result, NotImplemented) + + def test_ne_primitive_vs_struct_returns_not_implemented(self): + class s_t(struct): + x: c_int + + memory = bytearray(4) + lib = inflater(memory) + val = lib.inflate(c_int, 0) + s = lib.inflate(s_t, 0) + + result = val.__ne__(s) + self.assertIs(result, NotImplemented) + + def test_lt_between_compatible_primitives_works(self): + """Comparisons between compatible primitives should still work.""" + memory = bytearray(8) + lib = inflater(memory) + a = lib.inflate(c_int, 0) + b = lib.inflate(c_int, 4) + a.value = 1 + b.value = 2 + + self.assertTrue(a < b) + self.assertFalse(b < a) + + def test_comparison_with_raw_int(self): + memory = bytearray(4) + lib = inflater(memory) + a = lib.inflate(c_int, 0) + a.value = 5 + + self.assertTrue(a < 10) + self.assertTrue(a > 2) + self.assertTrue(a <= 5) + self.assertTrue(a >= 5) + + +class NegativeArrayCountTest(unittest.TestCase): + """array[T, N] must reject non-positive counts at handler time.""" + + def test_negative_count_raises(self): + """array[c_int, -5] must raise ValueError.""" + with self.assertRaises(ValueError): + class s_t(struct): + data: array[c_int, -5] + # Force size computation + size_of(s_t) + + def test_zero_count_raises(self): + """array[c_int, 0] must raise ValueError.""" + with self.assertRaises(ValueError): + class s_t(struct): + data: array[c_int, 0] + size_of(s_t) + + def test_positive_count_works(self): + """array[c_int, 3] must work fine.""" + class s_t(struct): + data: array[c_int, 3] + self.assertEqual(size_of(s_t), 12) + + +class BitfieldFreezeSafetyTest(unittest.TestCase): + """Frozen bitfields must reject writes even for non-owners.""" + + def test_non_owner_bitfield_rejects_write_after_freeze(self): + """The second bitfield in a group (non-owner) must reject writes when frozen.""" + class s_t(struct): + a: c_uint = bitfield_of(c_uint, 1) + b: c_uint = bitfield_of(c_uint, 1) + + memory = bytearray(4) + lib = inflater(memory) + s = lib.inflate(s_t, 0) + + s.a.value = 1 + s.b.value = 1 + + # Freeze the entire struct (which freezes all members) + s.freeze() + + # Both bitfields should reject writes + with self.assertRaises(ValueError): + s.a.value = 0 + + with self.assertRaises(ValueError): + s.b.value = 0 + + def test_individually_frozen_non_owner_rejects_write(self): + """Freezing a non-owner bitfield individually must also reject writes.""" + class s_t(struct): + a: c_uint = bitfield_of(c_uint, 1) + b: c_uint = bitfield_of(c_uint, 1) + + memory = bytearray(4) + lib = inflater(memory) + s = lib.inflate(s_t, 0) + + s.b.value = 1 + + # Freeze only the non-owner bitfield b + s.b.freeze() + + with self.assertRaises(ValueError): + s.b.value = 0 + + +class TypeRegistryDeduplicationTest(unittest.TestCase): + """Repeated handler registration must not accumulate duplicates.""" + + def test_generic_handler_not_duplicated(self): + """Registering the same handler twice must not produce duplicate entries.""" + from libdestruct.common.type_registry import TypeRegistry + + registry = TypeRegistry() + + class DummyType: + pass + + def dummy_handler(item, args, owner): + return None + + initial_count = len(registry.generic_handlers.get(DummyType, [])) + + registry.register_generic_handler(DummyType, dummy_handler) + registry.register_generic_handler(DummyType, dummy_handler) + + count = len(registry.generic_handlers[DummyType]) + self.assertEqual(count, initial_count + 1) + + def test_instance_handler_not_duplicated(self): + """Registering the same instance handler twice must not produce duplicate entries.""" + from libdestruct.common.type_registry import TypeRegistry + + registry = TypeRegistry() + + class DummyField: + pass + + def dummy_handler(item, annotation, owner): + return None + + initial_count = len(registry.instance_handlers.get(DummyField, [])) + + registry.register_instance_handler(DummyField, dummy_handler) + registry.register_instance_handler(DummyField, dummy_handler) + + count = len(registry.instance_handlers[DummyField]) + self.assertEqual(count, initial_count + 1) + + def test_type_handler_not_duplicated(self): + """Registering the same type handler twice must not produce duplicate entries.""" + from libdestruct.common.type_registry import TypeRegistry + + registry = TypeRegistry() + + class DummyParent: + pass + + def dummy_handler(item): + return None + + initial_count = len(registry.type_handlers.get(DummyParent, [])) + + registry.register_type_handler(DummyParent, dummy_handler) + registry.register_type_handler(DummyParent, dummy_handler) + + count = len(registry.type_handlers[DummyParent]) + self.assertEqual(count, initial_count + 1) + + +class ForwardTypedefTest(unittest.TestCase): + """Forward typedef references are a known parser limitation.""" + + def setUp(self): + clear_parser_cache() + + def tearDown(self): + clear_parser_cache() + + def test_chained_typedefs_in_order(self): + """Chained typedefs in declaration order must work.""" + t = definition_to_type(""" + typedef unsigned int u32; + typedef u32 mytype; + struct S { mytype x; }; + """) + data = (42).to_bytes(4, "little") + s = t.from_bytes(data) + self.assertEqual(s.x.value, 42) + + def test_forward_typedef_reference_raises(self): + """Forward typedef reference (use before define) must raise a clear error, not crash.""" + with self.assertRaises((ValueError, TypeError)): + definition_to_type(""" + typedef mytype1 mytype2; + typedef unsigned int mytype1; + struct S { mytype2 x; }; + """) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/scripts/struct_parser_unit_test.py b/test/scripts/struct_parser_unit_test.py new file mode 100644 index 0000000..3593306 --- /dev/null +++ b/test/scripts/struct_parser_unit_test.py @@ -0,0 +1,92 @@ +# +# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct). +# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +import struct as pystruct +import unittest + +from libdestruct.c.struct_parser import definition_to_type +from libdestruct import inflater + + +class StructParserTest(unittest.TestCase): + """C struct parser tests.""" + + def test_simple_struct(self): + t = definition_to_type("struct Foo { int x; unsigned int y; };") + self.assertIn("x", t.__annotations__) + self.assertIn("y", t.__annotations__) + + def test_double_pointer(self): + """Parser should handle double pointers (int **pp).""" + t = definition_to_type("struct test { int **pp; };") + self.assertIn("pp", t.__annotations__) + + def test_triple_pointer(self): + t = definition_to_type("struct test { int ***ppp; };") + self.assertIn("ppp", t.__annotations__) + + def test_array_field(self): + t = definition_to_type("struct test { int arr[4]; };") + self.assertIn("arr", t.__annotations__) + + def test_nested_struct_definition(self): + t = definition_to_type(""" + struct inner { int x; }; + struct outer { struct inner a; int b; }; + """) + self.assertIn("a", t.__annotations__) + self.assertIn("b", t.__annotations__) + + +class TypedefTest(unittest.TestCase): + """Typedef support in C struct parser.""" + + def test_simple_typedef(self): + t = definition_to_type(""" + typedef unsigned int uint32_t; + struct S { uint32_t x; }; + """) + self.assertIn("x", t.__annotations__) + + def test_typedef_of_struct(self): + t = definition_to_type(""" + typedef struct { int x; } Point; + struct S { Point p; }; + """) + self.assertIn("p", t.__annotations__) + + def test_typedef_of_pointer(self): + t = definition_to_type(""" + typedef int *intptr; + struct S { intptr p; }; + """) + self.assertIn("p", t.__annotations__) + + def test_typedef_chain(self): + t = definition_to_type(""" + typedef unsigned int u32; + typedef u32 mytype; + struct S { mytype x; }; + """) + self.assertIn("x", t.__annotations__) + + def test_typedef_inflate_and_read(self): + t = definition_to_type(""" + typedef unsigned int uint32_t; + struct S { uint32_t x; int y; }; + """) + memory = bytearray(8) + memory[0:4] = pystruct.pack(" offset 12 + memory += (20).to_bytes(4, "little") + memory += (0).to_bytes(8, "little") # next -> null + + node = Node.from_bytes(memory) + self.assertEqual(node.val.value, 10) + self.assertEqual(node.next.unwrap().val.value, 20) + + def test_tree_struct(self): + class TreeNode(struct): + data: c_uint + left: ptr["TreeNode"] + right: ptr["TreeNode"] + + # Single node, no children + # c_uint(4) + ptr(8) + ptr(8) = 20 bytes + memory = b"" + memory += (42).to_bytes(4, "little") + memory += (0).to_bytes(4, "little") # padding + memory += (0).to_bytes(8, "little") # left=null + memory += (0).to_bytes(8, "little") # right=null + + node = TreeNode.from_bytes(memory) + self.assertEqual(node.data.value, 42) + + +class SubscriptSyntaxTest(unittest.TestCase): + """Test the subscript syntax: enum[T], array[T, N], ptr[T].""" + + def test_enum_subscript(self): + """enum[MyEnum] works as a type annotation for struct fields.""" + class Color(IntEnum): + RED = 0 + GREEN = 1 + BLUE = 2 + + class s_t(struct): + color: enum[Color] + + memory = (1).to_bytes(4, "little") + s = s_t.from_bytes(memory) + self.assertEqual(s.color.value, Color.GREEN) + + def test_enum_subscript_custom_backing(self): + """enum[MyEnum, c_short] uses a custom backing type.""" + class Status(IntEnum): + OFF = 0 + ON = 1 + + class s_t(struct): + status: enum[Status, c_short] + + memory = (1).to_bytes(2, "little") + s = s_t.from_bytes(memory) + self.assertEqual(s.status.value, Status.ON) + from libdestruct import size_of + self.assertEqual(size_of(s_t), 2) + + def test_array_subscript(self): + """array[c_int, 3] works as a type annotation for struct fields.""" + class s_t(struct): + data: array[c_int, 3] + + memory = b"" + for v in [10, 20, 30]: + memory += v.to_bytes(4, "little") + + s = s_t.from_bytes(memory) + self.assertEqual(s.data[0].value, 10) + self.assertEqual(s.data[1].value, 20) + self.assertEqual(s.data[2].value, 30) + + def test_array_subscript_size(self): + """array[c_int, 3] has correct size.""" + class s_t(struct): + data: array[c_int, 3] + + from libdestruct import size_of + self.assertEqual(size_of(s_t), 12) + + def test_ptr_subscript(self): + """ptr[T] works as a type annotation (already supported).""" + class s_t(struct): + val: c_int + ref: ptr[c_int] + + memory = b"" + memory += (42).to_bytes(4, "little") + memory += (0).to_bytes(8, "little") + + s = s_t.from_bytes(memory) + self.assertEqual(s.val.value, 42) + + def test_mixed_subscript_struct(self): + """Struct mixing all subscript syntaxes.""" + class Direction(IntEnum): + UP = 0 + DOWN = 1 + + class s_t(struct): + dir: enum[Direction] + coords: array[c_int, 2] + next: ptr["s_t"] + + memory = b"" + memory += (1).to_bytes(4, "little") # dir = DOWN + memory += (10).to_bytes(4, "little") # coords[0] + memory += (20).to_bytes(4, "little") # coords[1] + memory += (0).to_bytes(8, "little") # next = null + + s = s_t.from_bytes(memory) + self.assertEqual(s.dir.value, Direction.DOWN) + self.assertEqual(s.coords[0].value, 10) + self.assertEqual(s.coords[1].value, 20) + + +class AnnotatedOffsetTest(unittest.TestCase): + """Test Annotated[type, offset(N)] syntax for explicit field offsets.""" + + def test_annotated_offset_basic(self): + """Annotated[c_int, offset(N)] places a field at the given offset.""" + class s_t(struct): + a: c_int + b: Annotated[c_int, offset(8)] + + from libdestruct import size_of + self.assertEqual(size_of(s_t), 12) # 8 + 4 + + def test_annotated_offset_read(self): + """Values are read correctly from Annotated offset positions.""" + import struct as pystruct + + class s_t(struct): + a: c_int + b: Annotated[c_int, offset(8)] + + memory = pystruct.pack(" 5) + self.assertFalse(x > 10) + + def test_int_lt_python_int(self): + x = c_int.from_bytes((3).to_bytes(4, "little")) + self.assertTrue(x < 5) + self.assertFalse(x < 3) + + def test_int_ge_le(self): + x = c_int.from_bytes((7).to_bytes(4, "little")) + self.assertTrue(x >= 7) + self.assertTrue(x >= 6) + self.assertFalse(x >= 8) + self.assertTrue(x <= 7) + self.assertTrue(x <= 8) + self.assertFalse(x <= 6) + + def test_int_eq_python_int(self): + x = c_int.from_bytes((42).to_bytes(4, "little")) + self.assertTrue(x == 42) + self.assertFalse(x == 43) + + def test_int_ne_python_int(self): + x = c_int.from_bytes((42).to_bytes(4, "little")) + self.assertTrue(x != 43) + self.assertFalse(x != 42) + + def test_float_gt_python_float(self): + x = c_float.from_bytes(pystruct.pack(" 3.0) + self.assertFalse(x > 4.0) + + def test_float_eq_python_float(self): + x = c_double.from_bytes(pystruct.pack(" a) + self.assertTrue(a != b) + self.assertFalse(a == b) + + def test_comparison_returns_not_implemented_for_incompatible(self): + x = c_int.from_bytes((1).to_bytes(4, "little")) + self.assertFalse(x == "hello") + self.assertTrue(x != "hello") + + def test_c_str_eq_bytes(self): + memory = bytearray(b"hello\x00") + lib = inflater(memory) + s = lib.inflate(c_str, 0) + self.assertEqual(s, b"hello") + + +class FloatIntConversionTest(unittest.TestCase): + def test_c_float_int(self): + x = c_float.from_bytes(pystruct.pack("