aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_blob_ui.py
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-26 09:21:52 +0200
committerblasty <blasty@local>2026-07-26 09:21:52 +0200
commit3ba7518a600fceeffaa9c077a8f2855490f92ac7 (patch)
tree3783324bff28c07e7c468d96b1a1cf66e3793945 /tests/test_blob_ui.py
parentloading: a blob that analyses to nothing is still openable (diff)
downloadida-tui-3ba7518a600fceeffaa9c077a8f2855490f92ac7.tar.gz
ida-tui-3ba7518a600fceeffaa9c077a8f2855490f92ac7.tar.xz
ida-tui-3ba7518a600fceeffaa9c077a8f2855490f92ac7.zip
listing: make every undefined byte its own row, so you can carve anywhere
Loading a blob and pressing `c` at 0 disassembles one instruction; everything after it collapsed into a single row — "db 2044 dup(?)" — with no cursor position anywhere inside it. There was no way to start a second instruction stream at an arbitrary offset, which is most of what carving a firmware image IS. In IDA every undefined byte is its own line and you just put the cursor on one. The collapse existed for a real reason (see the comment in the heads tool): a .bss or a fresh blob would otherwise be millions of one-byte rows, and this model materialises what it walks. Expanding physically would also make `g <far address>` walk every byte in between. So the run stays ONE physical head and PRESENTS as N logical rows. _row_at is a prefix sum over heads, _phys() maps a row back to (head, byte offset), and the text for an interior row is synthesised on demand — "db 4Ah", the actual value, because the byte values are the whole point when you're looking for a stream. Memory is unchanged (libcrypto: 1 head for its 80-byte .bss, 61MB RSS), and index_of_ea into the middle of a run is 0.01ms via bisect. Now: cursor on any byte, `c`, and you get an instruction; the bytes before it stay individually addressable. Two bugs found on the way: * IDAToolError takes (tool, message) and five call sites in domain.py passed one string. Every one of those error paths raised TypeError INSTEAD of the real error — "define code @ 0x4020: Failed to create instruction" reached the user as "IDAToolError.__init__() missing 1 required positional argument". Fixed all five; the message that finally came through is what identified the next issue. * Searching now walks one row per undefined byte, so _index_for_search is capped at 400k lines and says when it truncated, rather than grinding through a multi-megabyte blob nobody wants to text-search. tests: test_blob_ui.py +8 — a run presents one row per byte, each is a single addressable byte showing its value, an interior address resolves to its own row, `c` on a chosen byte carves there, the carved row spans the instruction, and neighbouring bytes stay addressable. Uses PLANTED A64 instructions, because whether random bytes decode is chance and a test that depends on chance is worthless on the run where it fails. 19/0 blob, 202/0 scenarios, 30/0 project UI.
Diffstat (limited to 'tests/test_blob_ui.py')
-rw-r--r--tests/test_blob_ui.py64
1 files changed, 63 insertions, 1 deletions
diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py
index e8d2533..400c9ab 100644
--- a/tests/test_blob_ui.py
+++ b/tests/test_blob_ui.py
@@ -44,9 +44,22 @@ async def wait(pred, pilot, t=240.0):
async def run() -> int:
with tempfile.TemporaryDirectory() as tmp:
+ # Random bytes so IDA finds no functions... but with REAL AArch64
+ # instructions planted at a known offset. Whether arbitrary random bytes
+ # happen to decode is chance, and a test that depends on chance tells you
+ # nothing on the run where it fails.
+ data = bytearray(os.urandom(64 * 1024))
+ # -parm puts IDA in AArch64 mode, so these are A64 encodings; the ARM32
+ # spelling of a nop (0xE1A00000) is NOT decodable there and made this
+ # test fail for a reason that had nothing to do with what it checks.
+ planted = 0x40 # file offset -> ea 0x4040
+ for k, insn in enumerate((0xD503201F, # nop
+ 0xD503201F, # nop
+ 0xD65F03C0)): # ret
+ data[planted + k * 4:planted + k * 4 + 4] = insn.to_bytes(4, "little")
blob = os.path.join(tmp, "rnd.bin")
with open(blob, "wb") as f:
- f.write(os.urandom(64 * 1024)) # random: IDA will find no code
+ f.write(bytes(data))
# Skip the dialog by answering up front; this test is about what
# happens AFTER a described blob turns out to contain nothing.
@@ -85,6 +98,55 @@ async def run() -> int:
check("the hint survives navigating", "no functions" in status2,
status2[:90])
+ # -- byte-granular carving ---------------------------------- #
+ # An undefined run arrives as ONE head ("db N dup(?)"). It has to
+ # PRESENT as one row per byte or there is no cursor position to
+ # press `c` at, which is how IDA works and the only way to find an
+ # instruction stream that doesn't start at the run's first byte.
+ m = lst.model
+ check("an undefined run presents one row per byte",
+ m.loaded() >= 4096 and len(m._heads) < 64,
+ f"rows={m.loaded()} physical heads={len(m._heads)}")
+ rows = m.window(0, 4)
+ check("each row is a single addressable byte",
+ [h.ea for h in rows] == [0x4000, 0x4001, 0x4002, 0x4003]
+ and all(h.size == 1 for h in rows),
+ f"{[(hex(h.ea), h.size) for h in rows]}")
+ check("and shows its value, not a placeholder",
+ all(h.text.startswith("db ") and "dup" not in h.text
+ for h in rows), f"{[h.text for h in rows]}")
+
+ # The point of all this: land on an arbitrary byte and convert it.
+ i = m.index_of_ea(0x4021)
+ check("an address inside the run resolves to its own row",
+ i >= 0 and m.get(i).ea == 0x4021,
+ f"row={i} ea={m.get(i).ea if i >= 0 else None}")
+
+ target = 0x4000 + planted # a NOP we put there ourselves
+ lst.cursor = m.index_of_ea(target)
+ lst._scroll_cursor_into_view()
+ await pilot.pause(0.1)
+ check("the cursor sits on the byte we aimed at",
+ lst._cursor_ea() == target,
+ f"{lst._cursor_ea():#x} want {target:#x}")
+ await pilot.press("c")
+ await pilot.pause(2.0)
+ # Defining an item REBUILDS the listing model, so re-read it from the
+ # view: holding the old object shows the pre-edit rows and looks
+ # exactly like the edit silently failing.
+ m = lst.model
+ h = m.get(m.index_of_ea(target))
+ check("`c` on a chosen byte carves an instruction there",
+ h is not None and h.kind == "code",
+ f"kind={h.kind if h else None} text={h.text if h else None!r}")
+ if h is not None and h.kind == "code":
+ print(f" carved {target:#x}: {h.text}")
+ check("the carved row spans the instruction, not one byte",
+ h.size == 4, f"size={h.size}")
+ check("bytes before it stay individually addressable",
+ m.get(m.index_of_ea(target - 1)).size == 1
+ and m.get(m.index_of_ea(target - 1)).ea == target - 1)
+
await pilot.press("ctrl+l")
opened = await wait(lambda: isinstance(app.screen, ConfirmScreen), pilot, 20)
check("Ctrl+L offers to reload with different options", opened,