Skip to content

Commit 69be136

Browse files
committed
feat: implement hexdump functionality for all objects
1 parent ca3487f commit 69be136

4 files changed

Lines changed: 120 additions & 0 deletions

File tree

libdestruct/common/hexdump.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#
2+
# This file is part of libdestruct (https://github.com/mrindeciso/libdestruct).
3+
# Copyright (c) 2026 Roberto Alessandro Bertolini. All rights reserved.
4+
# Licensed under the MIT license. See LICENSE file in the project root for details.
5+
#
6+
7+
from __future__ import annotations
8+
9+
10+
def format_hexdump(
11+
data: bytes,
12+
base_address: int = 0,
13+
annotations: dict[int, str] | None = None,
14+
) -> str:
15+
"""Format a classic hex dump of the given data.
16+
17+
Args:
18+
data: The bytes to dump.
19+
base_address: The starting address shown in the offset column.
20+
annotations: Optional mapping from byte offset to field name, shown in the margin.
21+
22+
Returns:
23+
A formatted hex dump string.
24+
"""
25+
lines = []
26+
for offset in range(0, len(data), 16):
27+
chunk = data[offset : offset + 16]
28+
addr = base_address + offset
29+
30+
hex_parts = " ".join(f"{b:02x}" for b in chunk)
31+
# Pad to full 16-byte width
32+
hex_parts = hex_parts.ljust(47)
33+
34+
ascii_parts = "".join(chr(b) if chr(b).isprintable() and b < 128 else "." for b in chunk) # noqa: PLR2004
35+
36+
line = f"{addr:08x} {hex_parts} |{ascii_parts}|"
37+
38+
# Add field annotations for this line
39+
if annotations:
40+
fields_on_line = [
41+
name for byte_offset, name in sorted(annotations.items()) if offset <= byte_offset < offset + 16
42+
]
43+
if fields_on_line:
44+
line += " " + ", ".join(fields_on_line)
45+
46+
lines.append(line)
47+
48+
return "\n".join(lines)

libdestruct/common/obj.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from abc import ABC, abstractmethod
1010
from typing import TYPE_CHECKING, Generic, TypeVar
1111

12+
from libdestruct.common.hexdump import format_hexdump
13+
1214
if TYPE_CHECKING: # pragma: no cover
1315
from libdestruct.backing.resolver import Resolver
1416

@@ -135,6 +137,11 @@ def __eq__(self: obj, value: object) -> bool:
135137

136138
return self.get() == value.get()
137139

140+
def hexdump(self: obj) -> str:
141+
"""Return a hex dump of this object's bytes."""
142+
address = self.address if not self._frozen else 0
143+
return format_hexdump(self.to_bytes(), address)
144+
138145
def __bytes__(self: obj) -> bytes:
139146
"""Return the serialized object."""
140147
return self.to_bytes()

libdestruct/common/struct/struct_impl.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from libdestruct.common.bitfield.bitfield_field import BitfieldField
1515
from libdestruct.common.bitfield.bitfield_tracker import BitfieldTracker
1616
from libdestruct.common.field import Field
17+
from libdestruct.common.hexdump import format_hexdump
1718
from libdestruct.common.obj import obj
1819
from libdestruct.common.struct import struct
1920
from libdestruct.common.type_registry import TypeRegistry
@@ -210,6 +211,17 @@ def to_bytes(self: struct_impl) -> bytes:
210211
"""Return the serialized representation of the struct."""
211212
return b"".join(member.to_bytes() for member in self._members.values())
212213

214+
def hexdump(self: struct_impl) -> str:
215+
"""Return a hex dump of this struct's bytes with field annotations."""
216+
annotations = {}
217+
offset = 0
218+
for name, member in self._members.items():
219+
annotations[offset] = name
220+
offset += len(member.to_bytes())
221+
222+
address = struct_impl.address.fget(self) if not self._frozen else 0
223+
return format_hexdump(self.to_bytes(), address, annotations)
224+
213225
def _set(self: struct_impl, _: str) -> None:
214226
"""Set the value of the struct to the given value."""
215227
raise RuntimeError("Cannot set the value of a struct.")

test/scripts/types_unit_test.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,5 +353,58 @@ class outer(struct):
353353
self.assertEqual(size_of(outer), 8)
354354

355355

356+
class HexdumpTest(unittest.TestCase):
357+
"""Pretty hex dump."""
358+
359+
def test_hexdump_primitive(self):
360+
data = (0x2a).to_bytes(4, "little")
361+
obj = c_int.from_bytes(data)
362+
result = obj.hexdump()
363+
self.assertIn("2a 00 00 00", result)
364+
365+
def test_hexdump_struct(self):
366+
class test_t(struct):
367+
a: c_int
368+
b: c_int
369+
370+
memory = b""
371+
memory += (1).to_bytes(4, "little")
372+
memory += (2).to_bytes(4, "little")
373+
test = test_t.from_bytes(memory)
374+
result = test.hexdump()
375+
# Should contain field name annotations
376+
self.assertIn("a", result)
377+
self.assertIn("b", result)
378+
379+
def test_hexdump_returns_string(self):
380+
obj = c_int.from_bytes((0).to_bytes(4, "little"))
381+
self.assertIsInstance(obj.hexdump(), str)
382+
383+
def test_hexdump_offset_column(self):
384+
obj = c_int.from_bytes((0).to_bytes(4, "little"))
385+
result = obj.hexdump()
386+
self.assertIn("00000000", result)
387+
388+
def test_hexdump_ascii_column(self):
389+
memory = bytearray(b"ABCD")
390+
lib = inflater(memory)
391+
obj = lib.inflate(c_int, 0)
392+
result = obj.hexdump()
393+
self.assertIn("ABCD", result)
394+
395+
def test_hexdump_multiline(self):
396+
"""More than 16 bytes should produce multiple lines."""
397+
class big_t(struct):
398+
a: c_long
399+
b: c_long
400+
c: c_long
401+
402+
memory = b"\x00" * 24
403+
test = big_t.from_bytes(memory)
404+
result = test.hexdump()
405+
lines = [l for l in result.strip().split("\n") if l.strip()]
406+
self.assertGreater(len(lines), 1)
407+
408+
356409
if __name__ == "__main__":
357410
unittest.main()

0 commit comments

Comments
 (0)