Skip to content

Commit 9f12b1e

Browse files
authored
Add autoskip option to hexdump and dumpstruct (#158)
1 parent 29652dd commit 9f12b1e

2 files changed

Lines changed: 124 additions & 6 deletions

File tree

dissect/cstruct/utils.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,13 @@ def _human_colors() -> dict[str, str]:
9494

9595

9696
def _hexdump(
97-
data: bytes, palette: Palette | None = None, offset: int = 0, prefix: str = "", pretty: bool | None = False
97+
data: bytes,
98+
*,
99+
palette: Palette | None = None,
100+
offset: int = 0,
101+
prefix: str = "",
102+
pretty: bool | None = False,
103+
autoskip: bool = False,
98104
) -> Iterator[str]:
99105
"""Hexdump some data.
100106
@@ -104,6 +110,7 @@ def _hexdump(
104110
offset: Byte offset of the hexdump.
105111
prefix: Optional prefix.
106112
pretty: Use pretty colors, mutual exclusive with palette.
113+
autoskip: A single '*' replaces NUL-lines in the output.
107114
"""
108115
if palette:
109116
palette = palette[::-1]
@@ -114,6 +121,9 @@ def _hexdump(
114121

115122
remaining = 0
116123
active = None
124+
in_null_run = False
125+
in_collapsed_null_run = False
126+
last_offset = len(data) - 16
117127

118128
for i in range(0, len(data), 16):
119129
values = ""
@@ -166,17 +176,32 @@ def _hexdump(
166176
if j == 7:
167177
values += " "
168178

179+
if autoskip and 0 < i < last_offset and data[i : i + 16] == b"\x00" * 16:
180+
if in_null_run:
181+
if not in_collapsed_null_run:
182+
yield "*"
183+
in_collapsed_null_run = True
184+
continue
185+
186+
# Keep the first interior NUL line visible, collapse from the second onwards.
187+
in_null_run = True
188+
else:
189+
in_null_run = False
190+
in_collapsed_null_run = False
191+
169192
chars = "".join(chars)
170193
yield f"{prefix}{offset + i:08x} {values:48s} {chars}"
171194

172195

173196
def hexdump(
174197
data: bytes,
198+
*,
175199
palette: Palette | None = None,
176200
offset: int = 0,
177201
prefix: str = "",
178202
output: str = "print",
179203
pretty: bool | None = None,
204+
autoskip: bool = False,
180205
) -> Iterator[str] | str | None:
181206
"""Hexdump some data.
182207
@@ -190,6 +215,7 @@ def hexdump(
190215
prefix: Optional prefix.
191216
output: Output format, can be 'print', 'generator' or 'string'.
192217
pretty: Use pretty colors for improved human readability.
218+
autoskip: A single '*' replaces NUL-lines in the output.
193219
"""
194220
# Enable pretty colors by default if ...
195221
if (
@@ -200,7 +226,7 @@ def hexdump(
200226
):
201227
pretty = True
202228

203-
generator = _hexdump(data, palette, offset, prefix, pretty)
229+
generator = _hexdump(data, palette=palette, offset=offset, prefix=prefix, pretty=pretty, autoskip=autoskip)
204230
if output == "print":
205231
print("\n".join(generator))
206232
return None
@@ -217,6 +243,7 @@ def _dumpstruct(
217243
offset: int,
218244
color: bool,
219245
output: str,
246+
autoskip: bool,
220247
) -> str | None:
221248
palette = []
222249
colors = [
@@ -258,11 +285,11 @@ def _dumpstruct(
258285

259286
if output == "print":
260287
print()
261-
hexdump(data, palette, offset=offset)
288+
hexdump(data, palette=palette, offset=offset, autoskip=autoskip)
262289
print()
263290
print(out)
264291
elif output == "string":
265-
return f"\n{hexdump(data, palette, offset=offset, output='string')}\n\n{out}"
292+
return f"\n{hexdump(data, palette=palette, offset=offset, output='string', autoskip=autoskip)}\n\n{out}"
266293
return None
267294

268295

@@ -271,6 +298,7 @@ def dumpstruct(
271298
data: bytes | None = None,
272299
offset: int = 0,
273300
color: bool = True,
301+
autoskip: bool = False,
274302
output: str = "print",
275303
) -> str | None:
276304
"""Dump a structure or parsed structure instance.
@@ -281,15 +309,17 @@ def dumpstruct(
281309
obj: Structure to dump.
282310
data: Bytes to parse the Structure on, if obj is not a parsed Structure already.
283311
offset: Byte offset of the hexdump.
312+
color: Colorize the hexdump and structure output.
313+
autoskip: A single '*' replaces NUL-lines in the output.
284314
output: Output format, can be 'print' or 'string'.
285315
"""
286316
if output not in ("print", "string"):
287317
raise ValueError(f"Invalid output argument: {output!r} (should be 'print' or 'string').")
288318

289319
if isinstance(obj, Structure):
290-
return _dumpstruct(obj, obj.dumps(), offset, color, output)
320+
return _dumpstruct(obj, obj.dumps(), offset, color, output, autoskip)
291321
if issubclass(obj, Structure) and data is not None:
292-
return _dumpstruct(obj(data), data, offset, color, output)
322+
return _dumpstruct(obj(data), data, offset, color, output, autoskip)
293323
raise ValueError("Invalid arguments")
294324

295325

tests/test_utils.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,94 @@ def test_hexdump_pretty(capsys: pytest.CaptureFixture) -> None:
5353
)
5454

5555

56+
def test_hexdump_autoskip_collapses_middle_null_run() -> None:
57+
"""Keep first interior NUL line, then collapse the rest of that run to '*'."""
58+
# Layout: [A data line] [3 NUL lines] [B data line]
59+
# Expected: [A line] [first NUL line] [*] [B line] = 4 lines
60+
data = (b"A" * 16) + (b"\x00" * 48) + (b"B" * 16)
61+
62+
out = utils.hexdump(data, output="string", pretty=False, autoskip=True)
63+
assert out is not None
64+
65+
lines = out.splitlines()
66+
assert len(lines) == 4
67+
assert lines[0].startswith("00000000") # A line
68+
assert lines[1].startswith("00000010") # First NUL line is kept
69+
assert lines[2] == "*" # Remaining NUL lines collapsed
70+
assert lines[3].startswith("00000040") # B line
71+
72+
73+
def test_hexdump_autoskip_keeps_edge_null_lines() -> None:
74+
"""Do not collapse first/last hexdump lines even when they are all NUL bytes."""
75+
# Layout: [3 NUL lines]
76+
# Expected: all lines are kept (only one interior line, so nothing is repeated there)
77+
data = b"\x00" * 48
78+
79+
out = utils.hexdump(data, output="string", pretty=False, autoskip=True)
80+
assert out is not None
81+
82+
lines = out.splitlines()
83+
assert len(lines) == 3
84+
assert lines[0].startswith("00000000") # First line kept (edge)
85+
assert lines[1].startswith("00000010") # Single interior NUL line is kept
86+
assert lines[2].startswith("00000020") # Last line kept (edge)
87+
88+
89+
def test_hexdump_autoskip_separate_null_runs() -> None:
90+
"""Emit one '*' per interior NUL run, after keeping each run's first interior NUL line."""
91+
# Layout: [A data] [2 NUL lines] [B data] [2 NUL lines] [C data]
92+
# Expected: [A line] [NUL line] [*] [B line] [NUL line] [*] [C line] = 7 lines
93+
data = (b"A" * 16) + (b"\x00" * 32) + (b"B" * 16) + (b"\x00" * 32) + (b"C" * 16)
94+
95+
out = utils.hexdump(data, output="string", pretty=False, autoskip=True)
96+
assert out is not None
97+
98+
lines = out.splitlines()
99+
assert len(lines) == 7
100+
assert lines[0].startswith("00000000") # A line
101+
assert lines[1].startswith("00000010") # First NUL line of first run kept
102+
assert lines[2] == "*" # Remaining NUL lines of first run collapsed
103+
assert lines[3].startswith("00000030") # B line
104+
assert lines[4].startswith("00000040") # First NUL line of second run kept
105+
assert lines[5] == "*" # Remaining NUL lines of second run collapsed
106+
assert lines[6].startswith("00000060") # C line
107+
108+
109+
def test_hexdump_autoskip_single_interior_null_line_is_not_collapsed() -> None:
110+
"""Keep a single interior all-NUL line visible when autoskip is enabled."""
111+
# Layout: [A data line] [1 NUL line] [B data line]
112+
# Expected: [A line] [NUL line] [B line] = 3 lines (no repeated interior NUL line)
113+
data = (b"A" * 16) + (b"\x00" * 16) + (b"B" * 16)
114+
115+
out = utils.hexdump(data, output="string", pretty=False, autoskip=True)
116+
assert out is not None
117+
118+
lines = out.splitlines()
119+
assert len(lines) == 3
120+
assert lines[0].startswith("00000000") # A line
121+
assert lines[1].startswith("00000010") # Single NUL line is kept
122+
assert lines[2].startswith("00000020") # B line
123+
124+
125+
def test_hexdump_autoskip_false_does_not_collapse() -> None:
126+
"""Keep all lines expanded and never emit '*' when autoskip is disabled."""
127+
# Layout: [A data line] [3 NUL lines] [B data line]
128+
# Expected: [A line] [NUL line] [NUL line] [NUL line] [B line] = 5 lines (no * when disabled)
129+
data = (b"A" * 16) + (b"\x00" * 48) + (b"B" * 16)
130+
131+
out = utils.hexdump(data, output="string", pretty=False, autoskip=False)
132+
assert out is not None
133+
134+
lines = out.splitlines()
135+
assert len(lines) == 5
136+
assert all(line != "*" for line in lines) # No * when autoskip disabled
137+
assert lines[0].startswith("00000000") # A line
138+
assert lines[1].startswith("00000010") # First NUL line (expanded)
139+
assert lines[2].startswith("00000020") # Second NUL line (expanded)
140+
assert lines[3].startswith("00000030") # Third NUL line (expanded)
141+
assert lines[4].startswith("00000040") # B line
142+
143+
56144
def test_hexdump_pretty_print_conditions(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None:
57145
"""Test if we respec the ``NO_COLOR`` environment variable and ``pretty=False`` argument."""
58146
# Test regular print output behavior

0 commit comments

Comments
 (0)