Skip to content

Commit 29652dd

Browse files
authored
Add pretty print to hexdump (#155)
1 parent edf9f8e commit 29652dd

2 files changed

Lines changed: 148 additions & 27 deletions

File tree

dissect/cstruct/utils.py

Lines changed: 104 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import os
34
import pprint
45
import string
56
import sys
@@ -13,15 +14,29 @@
1314
from collections.abc import Iterator
1415
from typing import Literal
1516

16-
COLOR_RED = "\033[1;31m"
17-
COLOR_GREEN = "\033[1;32m"
18-
COLOR_YELLOW = "\033[1;33m"
19-
COLOR_BLUE = "\033[1;34m"
20-
COLOR_PURPLE = "\033[1;35m"
21-
COLOR_CYAN = "\033[1;36m"
22-
COLOR_WHITE = "\033[1;37m"
23-
COLOR_NORMAL = "\033[1;0m"
24-
17+
# Regular ANSI colors
18+
COLOR_RED = "\033[0;31m"
19+
COLOR_GREEN = "\033[0;32m"
20+
COLOR_YELLOW = "\033[0;93m"
21+
COLOR_BLUE = "\033[0;34m"
22+
COLOR_PURPLE = "\033[0;35m"
23+
COLOR_CYAN = "\033[0;36m"
24+
COLOR_WHITE = "\033[0;37m"
25+
COLOR_BLACK = "\033[0;30m"
26+
COLOR_GREY = "\033[0;90m"
27+
28+
# Bold ANSI colors
29+
COLOR_RED_BOLD = "\033[1;31m"
30+
COLOR_GREEN_BOLD = "\033[1;32m"
31+
COLOR_YELLOW_BOLD = "\033[1;33m"
32+
COLOR_BLUE_BOLD = "\033[1;34m"
33+
COLOR_PURPLE_BOLD = "\033[1;35m"
34+
COLOR_CYAN_BOLD = "\033[1;36m"
35+
COLOR_WHITE_BOLD = "\033[1;37m"
36+
COLOR_BLACK_BOLD = "\033[1;30m"
37+
COLOR_GREY_BOLD = "\033[1;90m"
38+
39+
# Background ANSI colors
2540
COLOR_BG_RED = "\033[1;41m\033[1;37m"
2641
COLOR_BG_GREEN = "\033[1;42m\033[1;37m"
2742
COLOR_BG_YELLOW = "\033[1;43m\033[1;37m"
@@ -30,6 +45,10 @@
3045
COLOR_BG_CYAN = "\033[1;46m\033[1;37m"
3146
COLOR_BG_WHITE = "\033[1;47m\033[1;30m"
3247

48+
# Reset ANSI codes
49+
COLOR_CLEAR = "\033[0m"
50+
COLOR_CLEAR_BOLD = "\033[1;0m"
51+
3352
PRINTABLE = string.digits + string.ascii_letters + string.punctuation + " "
3453

3554
ENDIANNESS_MAP: dict[str, Literal["big", "little"]] = {
@@ -44,18 +63,55 @@
4463
Palette = list[tuple[int, str]]
4564

4665

47-
def _hexdump(data: bytes, palette: Palette | None = None, offset: int = 0, prefix: str = "") -> Iterator[str]:
66+
def _human_colors() -> dict[str, str]:
67+
"""Generates a dictionary of characters with a human-readable ANSI color they should be in a hexdump.
68+
69+
Coloring logic implementation derived from HexFriend and ImHex.
70+
"""
71+
# Make all characters not in any rules below light green
72+
colors = {chr(char): COLOR_GREEN for char in range(256)}
73+
74+
# Make all ASCII extended characters yellow
75+
for char in colors:
76+
if ord(char) & 0x80 == 0:
77+
colors[char] = COLOR_YELLOW
78+
79+
# Make null bytes grey
80+
colors["\00"] = COLOR_GREY
81+
82+
# Make printable ASCII characters bold white (0x32-0x7E)
83+
for char in PRINTABLE:
84+
colors[char] = COLOR_WHITE_BOLD
85+
86+
# Make ASCII whitespace characters green bold (0x9, 0xA, 0xB, 0xC, 0xD, 0x20)
87+
for char in ("\t", "\n", "\11", "\12", "\r", "\20"):
88+
colors[char] = COLOR_GREEN_BOLD
89+
90+
return colors
91+
92+
93+
HUMAN_COLORS = _human_colors()
94+
95+
96+
def _hexdump(
97+
data: bytes, palette: Palette | None = None, offset: int = 0, prefix: str = "", pretty: bool | None = False
98+
) -> Iterator[str]:
4899
"""Hexdump some data.
49100
50101
Args:
51102
data: Bytes to hexdump.
103+
palette: Colorize the hexdump using this color pattern.
52104
offset: Byte offset of the hexdump.
53105
prefix: Optional prefix.
54-
palette: Colorize the hexdump using this color pattern.
106+
pretty: Use pretty colors, mutual exclusive with palette.
55107
"""
56108
if palette:
57109
palette = palette[::-1]
58110

111+
# only happy little accidents
112+
if pretty and palette:
113+
raise ValueError("Cannot use argument 'pretty' in combination with 'palette', please pick one")
114+
59115
remaining = 0
60116
active = None
61117

@@ -87,20 +143,24 @@ def _hexdump(data: bytes, palette: Palette | None = None, offset: int = 0, prefi
87143

88144
if active:
89145
values += f"{ord(char):02x}"
90-
chars.append(active + print_char + COLOR_NORMAL)
146+
chars.append(active + print_char + COLOR_CLEAR_BOLD)
91147
else:
92-
values += f"{ord(char):02x}"
93-
chars.append(print_char)
148+
if pretty and (color := HUMAN_COLORS.get(char, "")):
149+
values += f"{color}{ord(char):02x}{COLOR_CLEAR}"
150+
chars.append(color + print_char + COLOR_CLEAR)
151+
else:
152+
values += f"{ord(char):02x}"
153+
chars.append(print_char)
94154

95155
remaining -= 1
96156
if remaining == 0:
97157
active = None
98158

99159
if palette is not None:
100-
values += COLOR_NORMAL
160+
values += COLOR_CLEAR_BOLD
101161

102162
if j == 15 and palette is not None:
103-
values += COLOR_NORMAL
163+
values += COLOR_CLEAR_BOLD
104164

105165
values += " "
106166
if j == 7:
@@ -111,18 +171,36 @@ def _hexdump(data: bytes, palette: Palette | None = None, offset: int = 0, prefi
111171

112172

113173
def hexdump(
114-
data: bytes, palette: Palette | None = None, offset: int = 0, prefix: str = "", output: str = "print"
174+
data: bytes,
175+
palette: Palette | None = None,
176+
offset: int = 0,
177+
prefix: str = "",
178+
output: str = "print",
179+
pretty: bool | None = None,
115180
) -> Iterator[str] | str | None:
116181
"""Hexdump some data.
117182
183+
Uses colored ANSI output with output type "print" by default. Disable with ``pretty=False``
184+
or set the environment variable ``NO_COLOR``.
185+
118186
Args:
119187
data: Bytes to hexdump.
120188
palette: Colorize the hexdump using this color pattern.
121189
offset: Byte offset of the hexdump.
122190
prefix: Optional prefix.
123191
output: Output format, can be 'print', 'generator' or 'string'.
192+
pretty: Use pretty colors for improved human readability.
124193
"""
125-
generator = _hexdump(data, palette, offset, prefix)
194+
# Enable pretty colors by default if ...
195+
if (
196+
output == "print" # the output type is set to 'print'
197+
and not palette # no palette is given (structdump only)
198+
and pretty is not False # pretty was not explicitly set to False
199+
and not os.environ.get("NO_COLOR") # and the environment allows colors
200+
):
201+
pretty = True
202+
203+
generator = _hexdump(data, palette, offset, prefix, pretty)
126204
if output == "print":
127205
print("\n".join(generator))
128206
return None
@@ -142,13 +220,13 @@ def _dumpstruct(
142220
) -> str | None:
143221
palette = []
144222
colors = [
145-
(COLOR_RED, COLOR_BG_RED),
146-
(COLOR_GREEN, COLOR_BG_GREEN),
147-
(COLOR_YELLOW, COLOR_BG_YELLOW),
148-
(COLOR_BLUE, COLOR_BG_BLUE),
149-
(COLOR_PURPLE, COLOR_BG_PURPLE),
150-
(COLOR_CYAN, COLOR_BG_CYAN),
151-
(COLOR_WHITE, COLOR_BG_WHITE),
223+
(COLOR_RED_BOLD, COLOR_BG_RED),
224+
(COLOR_GREEN_BOLD, COLOR_BG_GREEN),
225+
(COLOR_YELLOW_BOLD, COLOR_BG_YELLOW),
226+
(COLOR_BLUE_BOLD, COLOR_BG_BLUE),
227+
(COLOR_PURPLE_BOLD, COLOR_BG_PURPLE),
228+
(COLOR_CYAN_BOLD, COLOR_BG_CYAN),
229+
(COLOR_WHITE_BOLD, COLOR_BG_WHITE),
152230
]
153231
ci = 0
154232
out = [f"struct {structure.__class__.__name__}:"]
@@ -172,7 +250,7 @@ def _dumpstruct(
172250
size = structure.__sizes__[field._name]
173251
palette.append((size, background))
174252
ci += 1
175-
out.append(f"- {foreground}{field._name}{COLOR_NORMAL}: {value}")
253+
out.append(f"- {foreground}{field._name}{COLOR_CLEAR_BOLD}: {value}")
176254
else:
177255
out.append(f"- {field._name}: {value}")
178256

tests/test_utils.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515

1616
def test_hexdump(capsys: pytest.CaptureFixture) -> None:
17-
utils.hexdump(b"\x00" * 16)
17+
utils.hexdump(b"\x00" * 16, pretty=False)
1818
captured = capsys.readouterr()
1919
assert captured.out == "00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................\n"
2020

@@ -30,6 +30,49 @@ def test_hexdump(capsys: pytest.CaptureFixture) -> None:
3030
utils.hexdump("b\x00", output="str")
3131

3232

33+
def test_hexdump_pretty(capsys: pytest.CaptureFixture) -> None:
34+
"""Check if we can create a pretty hexdump."""
35+
c = utils.COLOR_CLEAR
36+
g = utils.COLOR_GREY
37+
w = utils.COLOR_WHITE_BOLD
38+
y = utils.COLOR_YELLOW
39+
40+
utils.hexdump((b"\x00" * 5) + b"\x01\x02\x03abc" + (b"\x00" * 5), pretty=True)
41+
captured = capsys.readouterr()
42+
assert (
43+
captured.out
44+
== "00000000 "
45+
+ (f"{g}00{c} " * 5)
46+
+ f"{y}01{c} {y}02{c} {y}03{c} {w}61{c} {w}62{c} {w}63{c} "
47+
+ (f"{g}00{c} " * 5)
48+
+ " "
49+
+ (f"{g}.{c}" * 5)
50+
+ f"{y}.{c}{y}.{c}{y}.{c}{w}a{c}{w}b{c}{w}c{c}"
51+
+ (f"{g}.{c}" * 5)
52+
+ "\n"
53+
)
54+
55+
56+
def test_hexdump_pretty_print_conditions(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None:
57+
"""Test if we respec the ``NO_COLOR`` environment variable and ``pretty=False`` argument."""
58+
# Test regular print output behavior
59+
utils.hexdump(b"\x00" * 16)
60+
captured = capsys.readouterr()
61+
assert captured.out.startswith("00000000 \x1b[0;90m00")
62+
63+
# Test explicit disable using NO_COLOR
64+
with monkeypatch.context() as m:
65+
m.setenv("NO_COLOR", "1")
66+
utils.hexdump(b"\x00" * 16)
67+
captured = capsys.readouterr()
68+
assert captured.out.startswith("00000000 00")
69+
70+
# Test explicit disable using pretty=False
71+
utils.hexdump(b"\x00" * 16, pretty=False)
72+
captured = capsys.readouterr()
73+
assert captured.out.startswith("00000000 00")
74+
75+
3376
def test_dumpstruct(cs: cstruct, capsys: pytest.CaptureFixture, compiled: bool) -> None:
3477
cdef = """
3578
struct test {

0 commit comments

Comments
 (0)