diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1aa4061..be87a86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,36 +7,50 @@ Thanks for your interest in contributing! ```bash python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate -pip install -e ".[dev]" +make install-dev # pip install -e ".[dev]" ``` The `dev` extra includes `pytest`, `pytest-cov`, `flake8`, `mypy`, `build` and `twine`. +The `Makefile` is the single source of truth for the dev commands below — run +`make help` to see every target (docs, build, coverage, etc.). The raw command +each target wraps is shown in parentheses if you'd rather run it directly. + ## Running the test suite The tests read and write the memory of the test process itself; they should run on any supported platform without elevated privileges. ```bash -pytest tests -v +make test # pytest tests -v ``` ## Linting ```bash -flake8 PyMemoryEditor tests +make lint # flake8 PyMemoryEditor tests ``` ## Type checking ```bash -mypy PyMemoryEditor +make type-check # mypy PyMemoryEditor +``` + +## Before you push + +Run lint, type-check and the test suite in one go: + +```bash +make pre-commit # lint + type-check + test ``` -The CI pipeline runs lint, mypy and tests, and blocks merges on failure. -macOS is intentionally not included in CI (free-tier runner congestion); -contributors with macOS hardware should run `pytest tests` locally before -submitting changes that touch the Mach backend. +The CI pipeline runs the same lint, mypy and tests, and blocks merges on failure. +The test matrix runs on Ubuntu, Windows and macOS across multiple Python versions, so +all three platform backends are exercised on every push. A dedicated job also +runs the suite with the `speed` extra (NumPy) to keep the vectorized scan path +covered. Even so, contributors touching a specific backend are encouraged to +run `pytest tests` locally on that platform before submitting. ## Project layout @@ -68,7 +82,7 @@ The public alias `OpenProcess` is chosen at import time in `__init__.py` based o 1. Open an issue first for bug reports or substantial features. 2. Branch from `main`. Keep commits focused. -3. Run lint + tests locally before pushing. +3. Run `make pre-commit` (lint + type-check + tests) locally before pushing. 4. Open a PR describing the change and how it was tested. ## Reporting bugs diff --git a/PyMemoryEditor/process/abstract.py b/PyMemoryEditor/process/abstract.py index 883496f..a69ba1e 100644 --- a/PyMemoryEditor/process/abstract.py +++ b/PyMemoryEditor/process/abstract.py @@ -17,7 +17,7 @@ ) from ..enums import ScanTypesEnum -from ..util import UNSET +from ..util import UNSET, _check_int_fits from .info import ProcessInfo from .module_info import ModuleInfo from .region import MemoryRegion, MemoryRegionSnapshot @@ -426,6 +426,10 @@ def _read_unsigned(self, address: int, size: int) -> int: return int.from_bytes(raw, sys.byteorder, signed=False) def _write_unsigned(self, address: int, size: int, value: int) -> int: + # Validate up front so an out-of-range value raises the same clear + # ValueError as the signed path, instead of int.to_bytes' cryptic + # OverflowError ("int too big to convert" / "can't convert negative"). + _check_int_fits(value, size, signed=False) raw = int(value).to_bytes(size, sys.byteorder, signed=False) self.write_process_memory(address, bytes, size, raw) return value diff --git a/PyMemoryEditor/util/__init__.py b/PyMemoryEditor/util/__init__.py index aa93f97..cbc1b23 100644 --- a/PyMemoryEditor/util/__init__.py +++ b/PyMemoryEditor/util/__init__.py @@ -2,6 +2,7 @@ from .convert import ( UNSET, + _check_int_fits, _validate_pytype, convert_from_byte_array, get_c_type_of, diff --git a/PyMemoryEditor/util/convert.py b/PyMemoryEditor/util/convert.py index 0b1f2c0..155fe93 100644 --- a/PyMemoryEditor/util/convert.py +++ b/PyMemoryEditor/util/convert.py @@ -50,6 +50,52 @@ def _validate_pytype(pytype: Type) -> None: } +def _check_int_fits(value: int, length: int, *, signed: bool = True) -> None: + """ + Reject an ``int`` write whose value does not fit in ``length`` bytes, + raising a clear ``ValueError`` instead of letting the value be corrupted or + a raw ``OverflowError`` leak out. Shared by both numeric write paths: + + * the generic / signed path (``write_process_memory(int, ...)`` and the + ``write_char/short/int/long/longlong`` helpers) routes through + ``prepare_write`` → ``get_c_type_of(int, length)``, whose fixed-width + ``c_int*`` ``.value`` setter **silently wraps** out-of-range values + (``2**40`` into a 4-byte slot stores ``0``) and would then report success + while having corrupted the target; + + * the unsigned helpers (``write_uchar/ushort/uint/ulong/ulonglong``) route + through ``AbstractProcess._write_unsigned`` → ``int.to_bytes(signed=False)``, + which already raises — but as a bare ``OverflowError`` with a cryptic + message. Validating here gives both paths the same explicit error. + + ``signed`` selects the accepted window for ``length`` bytes: + + * ``signed=True`` (default) accepts the **union** of the signed and unsigned + ranges — ``[-2**(bits-1), 2**bits - 1]`` — because the generic ``c_int*`` + slot stores either representation by the same bit pattern (``0xFFFFFFFF`` + in a 4-byte field is the bits of ``-1`` and stays allowed); + * ``signed=False`` accepts the strict unsigned range ``[0, 2**bits - 1]``, + matching the unsigned helpers' contract (a negative value is rejected). + + ``bool`` is a subclass of ``int`` but is written through its own ``c_bool`` + path, so it never reaches the signed call here. Non-int values for an + ``int`` write (e.g. a float) are left for the ctypes assignment to reject. + """ + if not isinstance(value, int) or isinstance(value, bool): + return + + bits = length * 8 + low = -(1 << (bits - 1)) if signed else 0 + high = (1 << bits) - 1 + if not (low <= value <= high): + kind = "integer" if signed else "unsigned integer" + raise ValueError( + "value %d does not fit in a %d-byte %s (allowed range %d..%d). " + "Use a wider bufflength to write a larger value." + % (value, length, kind, low, high) + ) + + def resolve_bufflength(pytype: Type, bufflength: Optional[int]) -> int: """ Return a concrete bufflength: the caller-provided value, or the default for @@ -157,7 +203,10 @@ def prepare_write( ) return bytes, len(raw), raw - return pytype, resolve_bufflength(pytype, bufflength), value + length = resolve_bufflength(pytype, bufflength) + if pytype is int: + _check_int_fits(value, length) + return pytype, length, value def convert_from_byte_array( @@ -191,7 +240,16 @@ def value_to_bytes(pytype: Type, bufflength: int, value) -> bytes: Strings are utf-8 encoded; bytes pass through; numerics are written into a ctypes value and cast back. Shared by the three platform backends to avoid duplicating ~10 lines per call site. + + An ``int`` target that does not fit in ``bufflength`` bytes is rejected here + (same check as the write path): otherwise the ``c_int*`` setter would wrap + it silently — e.g. ``search_by_value(int, value=2**40)`` with the default + 4-byte width would encode the target as ``0`` and quietly match every zeroed + slot in memory instead of erroring. """ + if pytype is int: + _check_int_fits(value, bufflength) + target_value = get_c_type_of(pytype, bufflength) target_value.value = value.encode() if isinstance(value, str) else value diff --git a/tests/test_app_cheat_entry.py b/tests/test_app_cheat_entry.py new file mode 100644 index 0000000..40528ef --- /dev/null +++ b/tests/test_app_cheat_entry.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- + +""" +Functional tests for the cheat-table persistence flow (CheatEntry to/from dict). + +The cheat table's JSON import/export — saving freeze targets and reloading them +across sessions — round-trips every row through ``CheatEntry.to_dict`` / +``from_dict``. That serialization is pure logic (no poll thread, so none of the +GUI-teardown flakes the smoke suite warns about), but it carries the contract +the on-disk format depends on: hex addresses, hex-encoded byte values, the +default-spec fallback and the legacy ``spec_label`` key. + +``cheat_entry`` imports ``_widgets``, which imports PySide6, so this is skipped +when the ``[app]`` extra isn't installed. +""" + +import os + +import pytest + + +pytest.importorskip("PySide6", reason="Cheat-table tests require PySide6 ([app] extra).") +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyMemoryEditor.app.cheat_entry import CheatEntry # noqa: E402 +from PyMemoryEditor.app.value_types import VALUE_TYPES # noqa: E402 + + +def test_round_trips_a_basic_int_entry(): + entry = CheatEntry( + description="HP", + address=0x140001000, + spec_label=VALUE_TYPES[0].label, + length=4, + frozen=True, + frozen_value=999, + ) + restored = CheatEntry.from_dict(entry.to_dict()) + + assert restored.description == "HP" + assert restored.address == 0x140001000 + assert restored.spec_label == VALUE_TYPES[0].label + assert restored.length == 4 + assert restored.frozen is True + assert restored.frozen_value == 999 + + +def test_address_is_serialized_as_hex_string(): + entry = CheatEntry("x", 0xDEAD, VALUE_TYPES[0].label, 4) + assert entry.to_dict()["address"] == "0xDEAD" + + +def test_byte_array_frozen_value_round_trips_via_hex(): + bytes_spec = next(s for s in VALUE_TYPES if s.pytype is bytes and not s.is_pattern) + entry = CheatEntry( + description="bytes", + address=0x1000, + spec_label=bytes_spec.label, + length=3, + frozen=True, + frozen_value=b"\xDE\xAD\xBE", + ) + payload = entry.to_dict() + assert payload["frozen_value"] == "deadbe" # hex-encoded for human-readable JSON + + restored = CheatEntry.from_dict(payload) + assert restored.frozen_value == b"\xDE\xAD\xBE" + + +def test_invalid_hex_address_raises(): + with pytest.raises(ValueError): + CheatEntry.from_dict({"address": "not-hex", "spec": VALUE_TYPES[0].label}) + + +def test_unknown_spec_falls_back_to_default(): + restored = CheatEntry.from_dict({"address": "0x10", "spec": "bogus-label"}) + assert restored.spec_label == VALUE_TYPES[0].label + + +def test_legacy_spec_label_key_is_accepted(): + # Older saves used "spec_label" instead of "spec". + restored = CheatEntry.from_dict( + {"address": "0x10", "spec_label": VALUE_TYPES[0].label} + ) + assert restored.spec_label == VALUE_TYPES[0].label diff --git a/tests/test_app_value_types.py b/tests/test_app_value_types.py new file mode 100644 index 0000000..a2b8b68 --- /dev/null +++ b/tests/test_app_value_types.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- + +""" +Functional tests for the scanner-panel input layer (PyMemoryEditor/app/value_types.py). + +This is the first step of the scan flow: it turns what the user types in the +"Value Type" combo + value box into the ``(pytype, length, value)`` the library +scans for. It is pure logic with no Qt dependency, so it runs even without the +``[app]`` extra installed — and it was previously uncovered, despite a parsing +bug here silently changing what every scan searches for. +""" + +import pytest + +from PyMemoryEditor.app.value_types import ( + VALUE_TYPES, + find_spec, + parse_value, +) + + +def _spec(*, pytype=None, length=None, pattern=False): + """Pick a value-type spec by shape rather than by its (typo-prone) label.""" + for spec in VALUE_TYPES: + if spec.is_pattern != pattern: + continue + if pytype is not None and spec.pytype is not pytype: + continue + if length is not None and spec.length != length: + continue + return spec + raise AssertionError(f"no spec for pytype={pytype} length={length} pattern={pattern}") + + +INT4 = _spec(pytype=int, length=4) +INT1 = _spec(pytype=int, length=1) +FLOAT = _spec(pytype=float, length=4) +BOOL = _spec(pytype=bool) +STR = _spec(pytype=str) +BYTES = _spec(pytype=bytes, pattern=False) +AOB = _spec(pattern=True) + + +# --- find_spec ------------------------------------------------------------- # + +def test_find_spec_known_and_unknown(): + assert find_spec(INT4.label) is INT4 + assert find_spec("not a real label") is None + + +def test_default_spec_is_first_entry(): + # cheat_entry/scanner fall back to VALUE_TYPES[0]; pin that it's the 4-byte int. + assert VALUE_TYPES[0] is INT4 + + +# --- integer parsing ------------------------------------------------------- # + +def test_parse_int_decimal_and_hex(): + assert INT4.parse("100") == 100 + assert INT4.parse("0x10") == 16 + assert INT4.parse(" 42 ") == 42 + + +def test_parse_int_rejects_out_of_range(): + # 1-byte signed int tops out at 127. + with pytest.raises(ValueError): + INT1.parse("200") + with pytest.raises(ValueError): + INT1.parse("-200") + + +def test_parse_int_empty_raises(): + with pytest.raises(ValueError): + INT4.parse("") + + +# --- float / bool ---------------------------------------------------------- # + +def test_parse_float_accepts_comma_decimal(): + assert FLOAT.parse("3.5") == pytest.approx(3.5) + assert FLOAT.parse("3,5") == pytest.approx(3.5) + + +@pytest.mark.parametrize("text, expected", [ + ("true", True), ("1", True), ("yes", True), ("on", True), + ("false", False), ("0", False), ("no", False), ("off", False), +]) +def test_parse_bool_variants(text, expected): + assert BOOL.parse(text) is expected + + +def test_parse_bool_invalid_raises(): + with pytest.raises(ValueError): + BOOL.parse("maybe") + + +# --- byte array ------------------------------------------------------------ # + +def test_parse_bytes_hex_with_spaces(): + assert BYTES.parse("DE AD BE EF") == b"\xDE\xAD\xBE\xEF" + + +def test_parse_bytes_rejects_odd_and_invalid(): + with pytest.raises(ValueError): + BYTES.parse("ABC") # odd number of hex digits + with pytest.raises(ValueError): + BYTES.parse("ZZ") # not hex + with pytest.raises(ValueError): + BYTES.parse("") # empty + + +# --- AOB pattern ----------------------------------------------------------- # + +def test_parse_pattern_valid_returns_verbatim(): + assert AOB.parse("48 8B ? ? 00") == "48 8B ? ? 00" + + +def test_parse_pattern_empty_and_malformed_raise(): + with pytest.raises(ValueError): + AOB.parse(" ") + with pytest.raises(ValueError): + AOB.parse("4G 8B") # invalid hex token + + +# --- parse_value: length inference (the part that decides scan width) ------ # + +def test_parse_value_int_passthrough_length(): + value, length = parse_value(INT4, "0x10") + assert (value, length) == (16, 4) + + +def test_parse_value_bytes_defaults_to_natural_length(): + value, length = parse_value(BYTES, "DE AD BE") + assert value == b"\xDE\xAD\xBE" + assert length == 3 + + +def test_parse_value_str_uses_utf8_byte_length_not_char_count(): + # "olá" is 3 characters but 4 UTF-8 bytes — under-allocating would truncate. + value, length = parse_value(STR, "olá") + assert value == "olá" + assert length == 4 + + +def test_parse_value_str_length_override_wins(): + value, length = parse_value(STR, "hi", length_override=10) + assert value == "hi" + assert length == 10 + + +def test_parse_value_pattern_reports_zero_length(): + # AOB width comes from the pattern itself; parse_value must not count chars. + value, length = parse_value(AOB, "48 8B ? ? 00") + assert value == "48 8B ? ? 00" + assert length == 0 + + +# --- format round-trips ---------------------------------------------------- # + +def test_format_round_trips(): + assert BYTES.format(b"\xDE\xAD") == "DE AD" + assert INT4.format(123) == "123" + assert BYTES.format(None) == "" + assert INT4.format(None) == "" diff --git a/tests/test_scan.py b/tests/test_scan.py index 413f89a..fdd907a 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -83,6 +83,57 @@ def test_scan_memory_value_between(): assert results == [4, 8] +def test_scan_memory_not_value_between(): + """NOT_VALUE_BETWEEN yields the exact complement of VALUE_BETWEEN. + + Exercises the bytewise fallback path (pytype not in int/float/bool, so no + struct shortcut) — the mirror of test_scan_memory_value_between. + """ + data = bytearray() + for value in (5, 15, 25, 35, 45): + data.extend(_pack(value)) + + results = list( + scan_memory( + data, + len(data), + (_pack(10), _pack(30)), + 4, + ScanTypesEnum.NOT_VALUE_BETWEEN, + False, + ) + ) + + # Everything except the in-range 15 (offset 4) and 25 (offset 8). + assert results == [0, 12, 16] + + +def test_scan_memory_not_value_between_signed_fast_path(): + """NOT_VALUE_BETWEEN on the typed-int fast path, including a negative value. + + pytype=int selects the struct (and, when installed, NumPy) fast path at the + top of scan_memory, and the negative endpoint checks that the range is + compared as a signed integer rather than its unsigned bit pattern. + """ + data = bytearray() + for value in (-50, 5, 15, 25, 35): + data.extend(_pack(value)) + + results = list( + scan_memory( + data, + len(data), + (_pack(0), _pack(30)), + 4, + ScanTypesEnum.NOT_VALUE_BETWEEN, + int, + ) + ) + + # Inside [0, 30]: 5, 15, 25. Outside: -50 (offset 0) and 35 (offset 16). + assert results == [0, 16] + + def test_scan_memory_for_exact_value_finds_all_matches(): target = _pack(7) data = bytearray(_pack(7)) + bytearray(_pack(0)) + bytearray(_pack(7)) diff --git a/tests/test_write_safety.py b/tests/test_write_safety.py new file mode 100644 index 0000000..b38bb75 --- /dev/null +++ b/tests/test_write_safety.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- + +""" +Write/encode-path safety tests. + +Failure modes that must never pass silently for a memory-editing tool: + +1. An ``int`` value too large for the requested byte width. ``ctypes`` would + wrap it (``write_int(addr, 2**40)`` storing ``0`` into a 4-byte slot) and + ``write_process_memory`` would report success — silent target corruption. + The check lives in ``util.convert._check_int_fits`` and guards every numeric + coercion point: the signed write path (``prepare_write``), the unsigned + helpers (``AbstractProcess._write_unsigned``), the public ``RemotePointer`` + setter, and the *search-target* encoder (``value_to_bytes``) — where the same + wrap would otherwise make ``search_by_value(int, value=2**40)`` quietly match + every zeroed slot instead of erroring. + +2. A write to an unmapped / read-only address. The OS rejects it and the backend + must surface that as an ``OSError`` rather than swallowing it. Runs against + the test process itself, so it exercises whichever backend the CI matrix is + running on (Win32 / Linux / macOS all covered across the matrix). +""" + +import ctypes +import os +import sys + +import pytest + +from PyMemoryEditor import OpenProcess +from PyMemoryEditor.util.convert import prepare_write + + +# --------------------------------------------------------------------------- # +# (1) int range validation — prepare_write is the single point all three +# backends route writes through, so unit-testing it here covers them all. +# --------------------------------------------------------------------------- # + +def test_prepare_write_rejects_int_too_large_for_width(): + # 2**40 needs 6 bytes; into a 4-byte field ctypes would silently store 0. + with pytest.raises(ValueError): + prepare_write(int, 4, 2**40) + + +def test_prepare_write_rejects_int_too_negative_for_width(): + # One below the signed 4-byte floor (-2**31) must be rejected. + with pytest.raises(ValueError): + prepare_write(int, 4, -(2**31) - 1) + + +def test_prepare_write_rejects_just_past_unsigned_ceiling(): + # 2**32 - 1 fits (see below); 2**32 does not. + with pytest.raises(ValueError): + prepare_write(int, 4, 2**32) + + +@pytest.mark.parametrize( + "length, value", + [ + (4, 0), + (4, 2**31 - 1), # signed max + (4, -(2**31)), # signed min + (4, 0xFFFFFFFF), # unsigned max — same bit pattern as -1, allowed + (1, 255), # unsigned byte max + (1, -128), # signed byte min + (8, 2**63 - 1), + (8, 2**64 - 1), + ], +) +def test_prepare_write_accepts_values_that_fit(length, value): + pytype, out_length, out_value = prepare_write(int, length, value) + assert pytype is int + assert out_length == length + assert out_value == value + + +def test_write_int_overflow_raises_instead_of_truncating(): + """Regression: write_int(addr, 2**40) used to wrap to 0 and report success.""" + process = OpenProcess(pid=os.getpid()) + try: + target = ctypes.c_int64(0) + address = ctypes.addressof(target) + + with pytest.raises(ValueError): + process.write_int(address, 2**40) + + # The bogus write must not have touched memory. + assert process.read_longlong(address) == 0 + + # A value that fits still round-trips unchanged. + process.write_int(address, 1234) + assert process.read_int(address) == 1234 + finally: + process.close() + + +@pytest.mark.parametrize("method, value", [ + ("write_char", 2**20), + ("write_short", 2**20), + ("write_longlong", 2**80), +]) +def test_signed_int_helpers_reject_out_of_range(method, value): + """Every signed convenience writer routes through the same validation.""" + process = OpenProcess(pid=os.getpid()) + try: + target = ctypes.c_int64(0) + address = ctypes.addressof(target) + with pytest.raises(ValueError): + getattr(process, method)(address, value) + # Nothing was written. + assert process.read_longlong(address) == 0 + finally: + process.close() + + +@pytest.mark.parametrize("method, value", [ + ("write_uchar", 2**20), # over the 1-byte unsigned ceiling + ("write_uint", 2**40), # over the 4-byte unsigned ceiling + ("write_uint", -1), # negative is invalid for an unsigned write + ("write_ulonglong", 2**80), +]) +def test_unsigned_int_helpers_raise_value_error_not_overflow(method, value): + """ + The unsigned helpers go through _write_unsigned (int.to_bytes), which used + to leak a bare OverflowError. They must now raise the same clear ValueError + as the signed path — and must not corrupt memory. + """ + process = OpenProcess(pid=os.getpid()) + try: + target = ctypes.c_int64(0) + address = ctypes.addressof(target) + with pytest.raises(ValueError): + getattr(process, method)(address, value) + assert process.read_longlong(address) == 0 + + # Unsigned high values that DO fit still round-trip (bit pattern intact). + process.write_uint(address, 0xFFFFFFFE) + assert process.read_uint(address) == 0xFFFFFFFE + finally: + process.close() + + +# --------------------------------------------------------------------------- # +# (1b) the SAME silent-truncation class on the search-target encoder. +# search_by_value(int, value=2**40) used to encode the target as 0 and +# quietly match every zeroed slot — value_to_bytes now rejects it. +# --------------------------------------------------------------------------- # + +def test_value_to_bytes_rejects_out_of_range_int(): + from PyMemoryEditor.util.convert import value_to_bytes + + with pytest.raises(ValueError): + value_to_bytes(int, 4, 2**40) + with pytest.raises(ValueError): + value_to_bytes(int, 1, 999) + # A value that fits is encoded normally. + assert value_to_bytes(int, 4, 1) == (1).to_bytes(4, sys.byteorder) + + +def test_search_by_value_out_of_range_raises_instead_of_matching_zero(): + process = OpenProcess(pid=os.getpid()) + try: + # The generator encodes the target eagerly enough that pulling the first + # item surfaces the validation error rather than a bogus match on zeros. + with pytest.raises(ValueError): + next(process.search_by_value(int, value=2**40)) + finally: + process.close() + + +def test_search_by_value_between_out_of_range_raises(): + """The range encoder routes each endpoint through value_to_bytes too.""" + process = OpenProcess(pid=os.getpid()) + try: + with pytest.raises(ValueError): + next(process.search_by_value_between(int, 4, 0, 2**40)) + finally: + process.close() + + +def test_remote_pointer_value_setter_rejects_out_of_range(): + """The public RemotePointer write surface inherits the same validation.""" + process = OpenProcess(pid=os.getpid()) + try: + target = ctypes.c_int32(0) + ptr = process.get_pointer(ctypes.addressof(target), pytype=int, bufflength=4) + with pytest.raises(ValueError): + ptr.value = 2**40 + # Memory untouched, and an in-range write still works. + assert target.value == 0 + ptr.value = 4242 + assert target.value == 4242 + finally: + process.close() + + +# --------------------------------------------------------------------------- # +# (2) write to an invalid address must raise, not silently no-op. +# --------------------------------------------------------------------------- # + +def test_write_to_unmapped_address_raises(): + """Page 0 is never mapped; a write there must surface an OSError.""" + process = OpenProcess(pid=os.getpid()) + try: + # 7 fits in 4 bytes, so this gets past range validation and reaches the + # OS write, which the kernel rejects for the unmapped low page. + with pytest.raises(OSError): + process.write_int(0x1, 7) + finally: + process.close()