Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion PyMemoryEditor/process/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions PyMemoryEditor/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .convert import (
UNSET,
_check_int_fits,
_validate_pytype,
convert_from_byte_array,
get_c_type_of,
Expand Down
60 changes: 59 additions & 1 deletion PyMemoryEditor/util/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down
85 changes: 85 additions & 0 deletions tests/test_app_cheat_entry.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading