diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9e2f82c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.11', '3.12', '3.13']
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements-dev.txt
+
+ - name: Run test suite
+ run: pytest
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..3818f5d
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,5 @@
+[pytest]
+testpaths = tests
+python_files = test_*.py
+pythonpath = .
+addopts = -ra
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..c941202
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,2 @@
+-r requirements.txt
+pytest >= 7.0
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..971886e
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,146 @@
+'''
+This file is part of rop3 (https://github.com/reverseame/rop3).
+
+rop3 is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+rop3 is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with rop3. If not, see .
+'''
+
+import struct
+
+import capstone
+import pytest
+
+from rop3.arch import arch_singleton
+from rop3.archs.x86_arch import X86_Architecture, X64_Architecture
+from rop3.gadget import Gadget
+
+
+@pytest.fixture(autouse=True)
+def reset_arch():
+ '''
+ The architecture is a process-global singleton; reset it around every
+ test so cases are independent and can pick their own architecture.
+ '''
+ arch_singleton._arch = None
+ yield
+ arch_singleton._arch = None
+
+
+@pytest.fixture
+def x64():
+ arch_singleton._arch = None
+ arch_singleton.initialize(X64_Architecture())
+ return arch_singleton.arch
+
+
+@pytest.fixture
+def x86():
+ arch_singleton._arch = None
+ arch_singleton.initialize(X86_Architecture())
+ return arch_singleton.arch
+
+
+def make_gadget(code: bytes, vaddr: int, mode=capstone.CS_MODE_64,
+ filename='test') -> Gadget:
+ ''' Build a Gadget by disassembling raw bytes with capstone. '''
+ md = capstone.Cs(capstone.CS_ARCH_X86, mode)
+ md.detail = True
+ decodes = list(md.disasm(code, vaddr))
+ return Gadget(
+ filename=filename,
+ arch=capstone.CS_ARCH_X86,
+ mode=mode,
+ vaddr=vaddr,
+ decodes=decodes,
+ bytes=code,
+ )
+
+
+# --- Minimal in-memory ELF builder (no committed binary blobs) ------------
+
+EM_386 = 3
+EM_X86_64 = 62
+ET_EXEC = 2
+ET_DYN = 3
+SHT_PROGBITS = 1
+SHT_STRTAB = 3
+SHF_ALLOC = 0x2
+SHF_EXECINSTR = 0x4
+
+
+def build_minimal_elf(elfclass: int, machine: int, text_bytes: bytes,
+ text_addr: int, e_type: int = ET_DYN) -> bytes:
+ '''
+ Produce a tiny but valid ELF that pyelftools can parse: an ELF header, a
+ `.text` PROGBITS section flagged executable at `text_addr`, and a
+ `.shstrtab`. Enough to exercise architecture detection, executable-section
+ extraction and --base relocation. ET_DYN keeps the image base at 0.
+ '''
+ is64 = elfclass == 64
+ shstrtab = b'\x00.text\x00.shstrtab\x00'
+ name_text = shstrtab.index(b'.text\x00')
+ name_shstr = shstrtab.index(b'.shstrtab\x00')
+
+ ehsize = 64 if is64 else 52
+ shentsize = 64 if is64 else 40
+ n_sections = 3 # NULL, .text, .shstrtab
+
+ text_off = ehsize
+ shstr_off = text_off + len(text_bytes)
+ shoff = shstr_off + len(shstrtab)
+
+ # e_ident
+ ei_class = 2 if is64 else 1
+ e_ident = b'\x7fELF' + bytes([ei_class, 1, 1, 0]) + b'\x00' * 8
+
+ if is64:
+ header = e_ident + struct.pack(
+ '.
+'''
+
+import capstone
+import pytest
+
+from rop3.arch import arch_singleton, ArchitectureSingleton
+from rop3.archs.x86_arch import (
+ X86_Architecture, X64_Architecture, REG_BY_WIDTH,
+)
+
+
+def test_singleton_initialize_and_matches():
+ s = ArchitectureSingleton()
+ assert not s.is_initialized()
+ s.initialize(X64_Architecture())
+ assert s.is_initialized()
+ assert s.matches(X64_Architecture())
+ assert not s.matches(X86_Architecture())
+
+
+def test_singleton_initialize_is_sticky():
+ ''' initialize() is a no-op once set (single-arch run guarantee). '''
+ s = ArchitectureSingleton()
+ s.initialize(X64_Architecture())
+ s.initialize(X86_Architecture())
+ assert s.matches(X64_Architecture())
+
+
+def test_arch_accessed_before_init_raises():
+ s = ArchitectureSingleton()
+ with pytest.raises(RuntimeError):
+ _ = s.arch
+
+
+def test_arch_modes_and_pointers():
+ assert X86_Architecture().mode == capstone.CS_MODE_32
+ assert X64_Architecture().mode == capstone.CS_MODE_64
+ assert (X86_Architecture().sp, X86_Architecture().bp) == ('esp', 'ebp')
+ assert (X64_Architecture().sp, X64_Architecture().bp) == ('rsp', 'rbp')
+
+
+@pytest.mark.parametrize('alias,expected', [
+ ('rax', 'eax'), ('eax', 'eax'), ('ax', 'eax'), ('al', 'eax'),
+ ('rbp', 'ebp'), ('r8', 'r8d'), ('r8d', 'r8d'), ('r14', 'r14d'),
+])
+def test_normalize_reg_x86_is_32bit(alias, expected):
+ assert X86_Architecture().normalize_reg(alias) == expected
+
+
+@pytest.mark.parametrize('alias,expected', [
+ ('rax', 'rax'), ('eax', 'rax'), ('al', 'rax'),
+ ('rbp', 'rbp'), ('r8d', 'r8'), ('r14', 'r14'),
+])
+def test_normalize_reg_x64_is_64bit(alias, expected):
+ assert X64_Architecture().normalize_reg(alias) == expected
+
+
+def test_normalize_reg_passthrough_unknown():
+ ''' Abstract / unknown register names are returned unchanged. '''
+ assert X64_Architecture().normalize_reg('REG1') == 'REG1'
+ assert X86_Architecture().normalize_reg('dst') == 'dst'
+
+
+def test_reg_by_width_table():
+ assert REG_BY_WIDTH['rax'] == {8: 'rax', 4: 'eax'}
+ assert REG_BY_WIDTH['r8'] == {8: 'r8', 4: 'r8d'}
+
+
+def test_is_valid_abstract_reg_width():
+ assert X64_Architecture().is_valid_abstract_reg('rax')
+ assert not X64_Architecture().is_valid_abstract_reg('eax')
+ assert X86_Architecture().is_valid_abstract_reg('eax')
+ assert not X86_Architecture().is_valid_abstract_reg('rax')
diff --git a/tests/test_elf.py b/tests/test_elf.py
new file mode 100644
index 0000000..a42d26c
--- /dev/null
+++ b/tests/test_elf.py
@@ -0,0 +1,73 @@
+'''
+This file is part of rop3 (https://github.com/reverseame/rop3).
+
+rop3 is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+rop3 is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with rop3. If not, see .
+'''
+
+import pytest
+
+import rop3.binaries.elf as elfmod
+import rop3.binary as binary
+from rop3.archs.x86_arch import X86_Architecture, X64_Architecture
+
+from conftest import build_minimal_elf, EM_386, EM_X86_64, ET_DYN
+
+TEXT = b'\x58\xc3' # pop rax ; ret
+
+
+def test_elf_detects_x64():
+ data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN)
+ assert isinstance(elfmod.ELF(data, None).get_arch(), X64_Architecture)
+
+
+def test_elf_detects_x86():
+ data = build_minimal_elf(32, EM_386, TEXT, 0x8048000, ET_DYN)
+ assert isinstance(elfmod.ELF(data, None).get_arch(), X86_Architecture)
+
+
+def test_elf_exec_section_extraction():
+ data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN)
+ secs = elfmod.ELF(data, None).get_exec_sections()
+ assert len(secs) == 1
+ assert secs[0]['vaddr'] == 0x1000
+ assert secs[0]['opcodes'] == TEXT
+
+
+def test_elf_base_relocation_pie():
+ ''' Issue #13: --base must relocate ELF (ET_DYN image base is 0). '''
+ data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN)
+ secs = elfmod.ELF(data, '0x400000').get_exec_sections()
+ assert secs[0]['vaddr'] == 0x401000
+
+
+def test_elf_no_base_keeps_addresses():
+ data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN)
+ secs = elfmod.ELF(data, None).get_exec_sections()
+ assert secs[0]['vaddr'] == 0x1000
+
+
+def test_binary_dispatches_elf_by_magic(tmp_path):
+ data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN)
+ path = tmp_path / 'fake.elf'
+ path.write_bytes(data)
+ b = binary.Binary(str(path), None)
+ assert isinstance(b.get_arch(), X64_Architecture)
+ assert b.get_exec_sections()[0]['opcodes'] == TEXT
+
+
+def test_binary_unknown_format_raises(tmp_path):
+ path = tmp_path / 'junk.bin'
+ path.write_bytes(b'not a real binary header')
+ with pytest.raises(binary.BinaryException):
+ binary.Binary(str(path), None)
diff --git a/tests/test_gadfinder.py b/tests/test_gadfinder.py
new file mode 100644
index 0000000..cc2bea7
--- /dev/null
+++ b/tests/test_gadfinder.py
@@ -0,0 +1,97 @@
+'''
+This file is part of rop3 (https://github.com/reverseame/rop3).
+
+rop3 is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+rop3 is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with rop3. If not, see .
+'''
+
+import capstone
+
+import rop3.gadfinder as gadfinder
+from rop3.archs.x86_arch import X86_Architecture
+
+
+def test_avoid_bytes_defaults_to_canary():
+ f = gadfinder.GadFinder()
+ assert f._avoid_bytes(None) == set(gadfinder.CANARY_BYTES)
+ assert f._avoid_bytes(['0x00', '0x0a', '0x0d', '0xff']) == {0x00, 0x0a, 0x0d, 0xff}
+
+
+def test_avoid_bytes_uses_user_badchars():
+ f = gadfinder.GadFinder()
+ assert f._avoid_bytes(['0x41', '0x42']) == {0x41, 0x42}
+
+
+def test_addr_canary_score(x86):
+ from conftest import make_gadget
+ f = gadfinder.GadFinder()
+ avoid = set(gadfinder.CANARY_BYTES)
+ clean = make_gadget(b'\xc3', 0x12345620, mode=capstone.CS_MODE_32)
+ dirty = make_gadget(b'\xc3', 0x1234560a, mode=capstone.CS_MODE_32) # low byte 0x0a
+ assert f._addr_canary_score(clean, avoid) == 0
+ assert f._addr_canary_score(dirty, avoid) == 1
+
+
+class _FakeBinary:
+ def __init__(self, vaddr, opcodes, filename='fake'):
+ self.filename = filename
+ self._vaddr = vaddr
+ self._opcodes = opcodes
+
+ def get_exec_sections(self):
+ return [{'vaddr': self._vaddr, 'opcodes': self._opcodes}]
+
+ def get_arch(self):
+ return X86_Architecture()
+
+
+def _run_find(flags, buf, base_vaddr):
+ f = gadfinder.GadFinder(flags=flags)
+ f._open_binary = lambda fn, b: _FakeBinary(base_vaddr, bytes(buf))
+ return f.find(['fake'])
+
+
+def test_dedup_prefers_canary_free_address(x86):
+ ''' Issue #5: among duplicates keep the address with fewest canary bytes. '''
+ base = 0x12345600
+ buf = bytearray(0x40)
+ buf[0x0a] = 0xc3 # ret at ...0a (canary)
+ buf[0x20] = 0xc3 # ret at ...20 (clean)
+
+ flags = gadfinder.ROP | gadfinder.AVOID_CANARY
+ rets = [g for g in _run_find(flags, buf, base) if g.text_repr == 'ret']
+ assert len(rets) == 1
+ assert rets[0].vaddr == 0x12345620
+ assert rets[0].count == 2
+
+
+def test_dedup_keep_canary_keeps_first_seen(x86):
+ base = 0x12345600
+ buf = bytearray(0x40)
+ buf[0x0a] = 0xc3
+ buf[0x20] = 0xc3
+
+ rets = [g for g in _run_find(gadfinder.ROP, buf, base) if g.text_repr == 'ret']
+ assert len(rets) == 1
+ assert rets[0].vaddr == 0x1234560a # first seen, no canary preference
+ assert rets[0].count == 2
+
+
+def test_results_sorted_by_address(x86):
+ base = 0x10000
+ buf = bytearray(0x40)
+ for off in (0x30, 0x05, 0x18):
+ buf[off] = 0xc3
+ gadgets = _run_find(gadfinder.ROP, buf, base)
+ vaddrs = [g.vaddr for g in gadgets]
+ assert vaddrs == sorted(vaddrs)
diff --git a/tests/test_gadget.py b/tests/test_gadget.py
new file mode 100644
index 0000000..7a11ef3
--- /dev/null
+++ b/tests/test_gadget.py
@@ -0,0 +1,84 @@
+'''
+This file is part of rop3 (https://github.com/reverseame/rop3).
+
+rop3 is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+rop3 is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with rop3. If not, see .
+'''
+
+import capstone
+
+import rop3.gadget as gadget_mod
+from rop3.gadget import heuristic_basic_count
+
+from conftest import make_gadget
+
+
+def test_text_repr_and_equality():
+ g1 = make_gadget(b'\x58\xc3', 0x1000) # pop rax ; ret
+ g2 = make_gadget(b'\x58\xc3', 0x2000) # same text, different addr
+ g3 = make_gadget(b'\x5b\xc3', 0x1000) # pop rbx ; ret
+ assert g1.text_repr == 'pop rax ; ret'
+ assert g1 == g2 # equality is text-based
+ assert hash(g1) == hash(g2)
+ assert g1 != g3
+
+
+def test_calculate_side_effects_excludes_dst_src_sp(x64):
+ # inc rcx ; pop rbp ; ret
+ g = make_gadget(b'\x48\xff\xc1\x5d\xc3', 0x1000)
+ g.calculate_side_effects()
+ assert 'rcx' in g.side_regs
+ assert 'rbp' in g.side_regs
+
+
+def test_calculate_side_effects_x86_uses_32bit_names(x86):
+ # inc ecx ; pop ebp ; ret
+ g = make_gadget(b'\x41\x5d\xc3', 0x1000, mode=capstone.CS_MODE_32)
+ g.calculate_side_effects()
+ assert 'ecx' in g.side_regs and 'ebp' in g.side_regs
+ # no 64-bit names leak into a 32-bit context
+ assert not ({'rcx', 'rbp'} & g.side_regs)
+
+
+def test_subsumes(x64):
+ base = make_gadget(b'\x58\xc3', 0x1000) # pop rax ; ret
+ noisy = make_gadget(b'\x58\x5b\xc3', 0x2000) # pop rax ; pop rbx ; ret
+ base.side_regs = set()
+ noisy.side_regs = {'rbx'}
+ assert base.subsumes(noisy)
+ assert not noisy.subsumes(base)
+
+
+def test_heuristic_basic_count(x64):
+ g = make_gadget(b'\x58\xc3', 0x1000)
+ g.side_regs = {'rbx'}
+ # 1 side reg (<<2 = 4) + 2 instructions (<<1 = 4)
+ assert heuristic_basic_count(g) == 8
+
+
+def test_str_no_color_when_not_tty(x64, monkeypatch, capsys):
+ ''' Regression for issue #14: no ANSI escapes on non-TTY output. '''
+ monkeypatch.setattr('sys.stdout.isatty', lambda: False, raising=False)
+ g = make_gadget(b'\x48\xff\xc1\xc3', 0x1000) # inc rcx ; ret
+ g.calculate_side_effects()
+ text = str(g)
+ assert 'modifies' in text
+ assert '\033' not in text
+
+
+def test_colorize_honors_tty_and_no_color(monkeypatch):
+ monkeypatch.setattr('sys.stdout.isatty', lambda: True, raising=False)
+ monkeypatch.delenv('NO_COLOR', raising=False)
+ assert '\033' in gadget_mod._colorize('x')
+ monkeypatch.setenv('NO_COLOR', '1')
+ assert '\033' not in gadget_mod._colorize('x')
diff --git a/tests/test_operation.py b/tests/test_operation.py
new file mode 100644
index 0000000..951d44e
--- /dev/null
+++ b/tests/test_operation.py
@@ -0,0 +1,56 @@
+'''
+This file is part of rop3 (https://github.com/reverseame/rop3).
+
+rop3 is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+rop3 is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with rop3. If not, see .
+'''
+
+import rop3.operation as operation
+
+from conftest import make_gadget
+
+
+def test_lc_matches_pop_reg(x64):
+ ''' lc (load constant) matches `pop ; ret`. '''
+ gadgets = [
+ make_gadget(b'\x58\xc3', 0x1000), # pop rax ; ret
+ make_gadget(b'\x5b\xc3', 0x1010), # pop rbx ; ret
+ make_gadget(b'\x90\xc3', 0x1020), # nop ; ret (no match)
+ ]
+ matched = operation.Operation('lc').filter_gadgets(gadgets)
+ texts = {g.text_repr for g in matched}
+ assert 'pop rax ; ret' in texts
+ assert 'pop rbx ; ret' in texts
+ assert 'nop ; ret' not in texts
+
+
+def test_lc_with_dst_filter(x64):
+ gadgets = [
+ make_gadget(b'\x58\xc3', 0x1000), # pop rax ; ret
+ make_gadget(b'\x5b\xc3', 0x1010), # pop rbx ; ret
+ ]
+ matched = operation.Operation('lc', dst='rax').filter_gadgets(gadgets)
+ assert [g.text_repr for g in matched] == ['pop rax ; ret']
+ assert matched[0].dst == 'rax'
+
+
+def test_filter_gadgets_empty_input(x64):
+ assert operation.Operation('lc').filter_gadgets([]) == []
+
+
+def test_operand_parse_imm_supports_hex_and_negative(x64):
+ ''' Regression: immediates parsed with int(x, 0). '''
+ op = operation.Operand('rax')
+ assert op._parse_imm('0xffffffff') == 0xffffffff
+ assert op._parse_imm('-1') == -1
+ assert op._parse_imm(42) == 42
diff --git a/tests/test_parser.py b/tests/test_parser.py
new file mode 100644
index 0000000..0d22c9e
--- /dev/null
+++ b/tests/test_parser.py
@@ -0,0 +1,57 @@
+'''
+This file is part of rop3 (https://github.com/reverseame/rop3).
+
+rop3 is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+rop3 is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with rop3. If not, see .
+'''
+
+import pytest
+
+import rop3.parser as parser
+
+
+def test_get_ops_loads_roplang(x64):
+ ops = parser.Parser().get_ops()
+ names = {getattr(o, 'name', None) for o in ops}
+ # a representative subset that must always be present
+ for expected in ('mov', 'lc', 'jmp', 'lsd', 'not', 'xor'):
+ assert expected in names
+
+
+def test_get_op_unknown_raises(x64):
+ with pytest.raises(parser.ParserException):
+ parser.Parser().get_op('definitely_not_an_op')
+
+
+def test_composite_op_is_recognised(x64):
+ ''' lsd is a composite (compose:) operation. '''
+ resolved = parser.Parser().get_op('lsd')
+ assert isinstance(resolved, parser.CompositeOperation)
+ assert resolved.steps
+
+
+def _jmp_mov_op1():
+ ''' jmp is a composite whose first step is `mov REG_BP, src`. '''
+ jmp = parser.Parser().get_op('jmp')
+ assert isinstance(jmp, parser.CompositeOperation)
+ mov = next(s for s in jmp.steps if s['operation'] == 'mov')
+ return mov['op1']
+
+
+def test_reg_aliases_resolved_per_arch_x64(x64):
+ ''' REG_BP must resolve to rbp on x64 (not stay as the alias). '''
+ assert _jmp_mov_op1() == 'rbp'
+
+
+def test_reg_aliases_resolved_per_arch_x86(x86):
+ assert _jmp_mov_op1() == 'ebp'
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..49ae113
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,42 @@
+'''
+This file is part of rop3 (https://github.com/reverseame/rop3).
+
+rop3 is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+rop3 is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with rop3. If not, see .
+'''
+
+import capstone
+import pytest
+
+import rop3.utils as utils
+
+
+def test_pretty_addr_padding():
+ # padding is the total field width including the '0x' prefix
+ assert utils.pretty_addr(0x1000, capstone.CS_MODE_32) == '0x001000'
+ assert utils.pretty_addr(0x1000, capstone.CS_MODE_64) == '0x00000000001000'
+ assert len(utils.pretty_addr(0x1000, capstone.CS_MODE_32)) == 8
+ assert len(utils.pretty_addr(0x1000, capstone.CS_MODE_64)) == 16
+
+
+def test_pack_addr_endianness_and_width():
+ assert utils.pack_addr(0x41424344, capstone.CS_MODE_32) == b'\x44\x43\x42\x41'
+ assert utils.pack_addr(0x41424344, capstone.CS_MODE_64) == \
+ b'\x44\x43\x42\x41\x00\x00\x00\x00'
+
+
+@pytest.mark.parametrize('fn', [utils.pretty_addr, utils.pack_addr])
+def test_addr_helpers_reject_unknown_mode(fn):
+ ''' Regression for issue #15: unbound local on unsupported mode. '''
+ with pytest.raises(ValueError):
+ fn(0x1000, mode=999)