From 342bb3093bdd13baa806397790d1db8356b27855 Mon Sep 17 00:00:00 2001 From: potato-20 Date: Tue, 2 Jun 2026 18:58:00 +0530 Subject: [PATCH 1/3] test: add initial pytest unit-test suite The project shipped without any automated tests. This adds a tests/ package with 34 unit tests covering the host-side pure-Python helpers: - reversec.common.list (chunk, flatten) - reversec.common.text (indent, wrap) - reversec.common.fs (read/write/touch round-trips) - drozer.util (parse_server, StoreZeroOrTwo) - drozer.android.Intent (validity, defaults, flag combination) pyproject.toml gains a [tool.pytest.ini_options] section (pythonpath = src) so the suite runs with a bare 'pytest' invocation, plus a 'test' optional dependency. No production code is changed. --- pyproject.toml | 11 ++++++- tests/test_android.py | 72 +++++++++++++++++++++++++++++++++++++++++++ tests/test_fs.py | 28 +++++++++++++++++ tests/test_list.py | 35 +++++++++++++++++++++ tests/test_text.py | 27 ++++++++++++++++ tests/test_util.py | 47 ++++++++++++++++++++++++++++ 6 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 tests/test_android.py create mode 100644 tests/test_fs.py create mode 100644 tests/test_list.py create mode 100644 tests/test_text.py create mode 100644 tests/test_util.py diff --git a/pyproject.toml b/pyproject.toml index fe951add..90b2587a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,4 +45,13 @@ reversec = "src/reversec" pysolar = "src/pysolar" [tool.setuptools.dynamic] -version = { attr = "drozer.__version__" } \ No newline at end of file +version = { attr = "drozer.__version__" } + +[project.optional-dependencies] +test = ["pytest"] + +[tool.pytest.ini_options] +# The package sources live under src/; expose them so the unit tests can be +# run directly (without an editable install) via `pytest`. +pythonpath = ["src"] +testpaths = ["tests"] \ No newline at end of file diff --git a/tests/test_android.py b/tests/test_android.py new file mode 100644 index 00000000..90c785e1 --- /dev/null +++ b/tests/test_android.py @@ -0,0 +1,72 @@ +"""Unit tests for drozer.android.Intent (host-side logic only). + +These cover the pure-Python behaviour of Intent that does not require a live +Android session: validity checks, default attributes and flag combination. +""" + +import argparse + +from drozer.android import Intent + + +class TestIntentValidity: + + def test_empty_intent_is_invalid(self): + # An Intent must carry at least an action or a component. + assert Intent().isValid() is False + + def test_action_makes_intent_valid(self): + assert Intent(action="android.intent.action.VIEW").isValid() is True + + def test_component_makes_intent_valid(self): + assert Intent(component=["com.example", "com.example.Activity"]).isValid() is True + + +class TestIntentDefaults: + + def test_attributes_default_to_none(self): + intent = Intent() + assert intent.action is None + assert intent.category is None + assert intent.component is None + assert intent.data_uri is None + assert intent.extras is None + assert intent.flags is None + assert intent.mimetype is None + + +class TestIntentFlags: + + # __build_flags is name-mangled; access it directly to unit-test the pure + # flag-combination logic without needing a Java context. + def _build(self, flags): + return Intent()._Intent__build_flags(flags) + + def test_single_named_flag(self): + assert self._build(["ACTIVITY_NEW_TASK"]) == 0x10000000 + + def test_named_flags_are_ored_together(self): + assert self._build(["ACTIVITY_NEW_TASK", "ACTIVITY_CLEAR_TOP"]) == 0x14000000 + + def test_hexadecimal_flag(self): + assert self._build(["0x10000000"]) == 0x10000000 + + def test_empty_flag_list_is_zero(self): + assert self._build([]) == 0x00000000 + + +class TestIntentFromParser: + + def test_builds_intent_from_namespace(self): + namespace = argparse.Namespace( + action="android.intent.action.MAIN", + category=None, + component=None, + data_uri=None, + extras=[], + flags=[], + mimetype=None, + ) + intent = Intent.fromParser(namespace) + assert intent.action == "android.intent.action.MAIN" + assert intent.isValid() is True diff --git a/tests/test_fs.py b/tests/test_fs.py new file mode 100644 index 00000000..629fea13 --- /dev/null +++ b/tests/test_fs.py @@ -0,0 +1,28 @@ +"""Unit tests for reversec.common.fs.""" + +from reversec.common import fs + + +class TestReadWrite: + + def test_write_then_read_round_trips(self, tmp_path): + target = str(tmp_path / "data.bin") + written = fs.write(target, b"hello world") + assert written == len(b"hello world") + assert fs.read(target) == b"hello world" + + def test_read_missing_file_returns_none(self, tmp_path): + assert fs.read(str(tmp_path / "does-not-exist")) is None + + def test_read_as_text(self, tmp_path): + target = str(tmp_path / "data.txt") + fs.write(target, b"plain text") + assert fs.read(target, "r") == "plain text" + + +class TestTouch: + + def test_touch_creates_empty_file(self, tmp_path): + target = str(tmp_path / "touched") + fs.touch(target) + assert fs.read(target) == b"" diff --git a/tests/test_list.py b/tests/test_list.py new file mode 100644 index 00000000..149d388d --- /dev/null +++ b/tests/test_list.py @@ -0,0 +1,35 @@ +"""Unit tests for reversec.common.list.""" + +from reversec.common.list import chunk, flatten + + +class TestChunk: + + def test_splits_into_even_chunks(self): + assert list(chunk([1, 2, 3, 4], 2)) == [[1, 2], [3, 4]] + + def test_final_chunk_may_be_short(self): + assert list(chunk([1, 2, 3, 4, 5], 2)) == [[1, 2], [3, 4], [5]] + + def test_empty_list_yields_nothing(self): + assert list(chunk([], 2)) == [] + + def test_chunk_larger_than_list(self): + assert list(chunk([1, 2], 10)) == [[1, 2]] + + +class TestFlatten: + + def test_flattens_nested_lists(self): + assert list(flatten([1, [2, 3], [4, [5, 6]]])) == [1, 2, 3, 4, 5, 6] + + def test_flattens_tuples(self): + assert list(flatten([1, (2, 3)])) == [1, 2, 3] + + def test_strings_are_not_flattened(self): + # Strings are iterable but must be treated as scalar values, otherwise + # they would be exploded into individual characters. + assert list(flatten(["ab", ["cd"]])) == ["ab", "cd"] + + def test_empty_list(self): + assert list(flatten([])) == [] diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 00000000..8a42027c --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,27 @@ +"""Unit tests for reversec.common.text.""" + +from reversec.common.text import indent, wrap + + +class TestIndent: + + def test_prefixes_every_line(self): + assert indent("a\nb\nc", " ") == " a\n b\n c" + + def test_single_line(self): + assert indent("hello", ">> ") == ">> hello" + + def test_empty_string_still_prefixed(self): + assert indent("", ">") == ">" + + +class TestWrap: + + def test_text_shorter_than_width_is_unchanged(self): + assert wrap("one two three", 1000) == "one two three" + + def test_wraps_on_word_boundaries(self): + assert wrap("aaa bbb ccc", 5) == "aaa\nbbb\nccc" + + def test_existing_newlines_are_preserved(self): + assert "\n" in wrap("first line\nsecond line", 1000) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..18c6fe9f --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,47 @@ +"""Unit tests for drozer.util.""" + +import argparse + +import pytest + +from drozer.util import DefaultHost, DefaultPort, StoreZeroOrTwo, parse_server + + +class TestParseServer: + + def test_none_uses_defaults(self): + host, port = parse_server(None) + # DefaultHost ("localhost") is resolved to an address by parse_server. + assert port == DefaultPort + assert host # a non-empty resolved address + + def test_host_only_uses_default_port(self): + assert parse_server("127.0.0.1") == ("127.0.0.1", DefaultPort) + + def test_host_and_port(self): + assert parse_server("10.0.0.5:9999") == ("10.0.0.5", 9999) + + def test_port_is_returned_as_int(self): + _, port = parse_server("127.0.0.1:1234") + assert isinstance(port, int) and port == 1234 + + +class TestStoreZeroOrTwo: + + def _action(self): + return StoreZeroOrTwo(option_strings=["--ssl"], dest="ssl") + + def test_accepts_zero_arguments(self): + action, namespace = self._action(), argparse.Namespace() + action(None, namespace, [], None) + assert namespace.ssl == [] + + def test_accepts_two_arguments(self): + action, namespace = self._action(), argparse.Namespace() + action(None, namespace, ["cert.pem", "key.pem"], None) + assert namespace.ssl == ["cert.pem", "key.pem"] + + def test_rejects_one_argument(self): + action, namespace = self._action(), argparse.Namespace() + with pytest.raises(argparse.ArgumentTypeError): + action(None, namespace, ["cert.pem"], None) From f6ea601c20f79c1f2ddb7f0cd8b5ad84167d9fee Mon Sep 17 00:00:00 2001 From: potato-20 Date: Tue, 2 Jun 2026 19:00:57 +0530 Subject: [PATCH 2/3] ci: run pytest on push and pull request Adds a GitHub Actions workflow that runs the unit test suite across Python 3.9-3.13 on every push and pull request. --- .github/workflows/tests.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..173eeaec --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: tests + +on: + push: + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "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 }} + + - name: Install pytest + run: python -m pip install --upgrade pip pytest + + - name: Run unit tests + # The suite imports only dependency-free host-side modules and resolves + # them via pythonpath = ["src"] (see pyproject.toml), so the full + # package install is not required to run it. + run: pytest From ec4a6fbd70de065ed7b9059af40399d221248cf8 Mon Sep 17 00:00:00 2001 From: potato-20 Date: Tue, 2 Jun 2026 19:04:49 +0530 Subject: [PATCH 3/3] fix: stop fs.md5sum/sha1sum looping forever on Python 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both functions read the file in binary mode but terminated their read loop on 'while line != ""' — comparing bytes to a str literal. In Python 3 b"" != "" is always True, so the loop never exited and the call hung indefinitely (reachable via tools.setup.minimalsu, which calls fs.md5sum). Replace the hand-rolled loop with a single 'with open(...)' read, matching the existing fs.read() helper. Also corrects sha1sum's copy-pasted docstring. Adds regression tests asserting the digests match hashlib for normal and empty files, and that a missing file returns None. --- src/reversec/common/fs.py | 26 +++++--------------------- tests/test_fs.py | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/reversec/common/fs.py b/src/reversec/common/fs.py index 8d6a0b60..e3cf0712 100644 --- a/src/reversec/common/fs.py +++ b/src/reversec/common/fs.py @@ -66,36 +66,20 @@ def md5sum(path): """ try: - f = open(path, 'rb') - line = data = f.read() - - while line != "": - line = f.read() - - data += line - - f.close() - return hashlib.md5(data).hexdigest() + with open(path, 'rb') as f: + return hashlib.md5(f.read()).hexdigest() except IOError: return None def sha1sum(path): """ - Utility method to get the md5sum of a file on the filesystem + Utility method to get the sha1sum of a file on the filesystem """ try: - f = open(path, 'rb') - line = data = f.read() - - while line != "": - line = f.read() - - data += line - - f.close() - return hashlib.sha1(data).hexdigest() + with open(path, 'rb') as f: + return hashlib.sha1(f.read()).hexdigest() except IOError: return None diff --git a/tests/test_fs.py b/tests/test_fs.py index 629fea13..fddf18a4 100644 --- a/tests/test_fs.py +++ b/tests/test_fs.py @@ -1,5 +1,7 @@ """Unit tests for reversec.common.fs.""" +import hashlib + from reversec.common import fs @@ -26,3 +28,28 @@ def test_touch_creates_empty_file(self, tmp_path): target = str(tmp_path / "touched") fs.touch(target) assert fs.read(target) == b"" + + +class TestHashing: + # Regression coverage: md5sum/sha1sum previously looped forever because the + # read loop compared bytes to the str literal "" (b"" != "" is always True + # in Python 3), so the loop never terminated on any readable file. + + def test_md5sum_matches_hashlib(self, tmp_path): + target = str(tmp_path / "data.bin") + fs.write(target, b"hello world") + assert fs.md5sum(target) == hashlib.md5(b"hello world").hexdigest() + + def test_sha1sum_matches_hashlib(self, tmp_path): + target = str(tmp_path / "data.bin") + fs.write(target, b"hello world") + assert fs.sha1sum(target) == hashlib.sha1(b"hello world").hexdigest() + + def test_hashing_empty_file(self, tmp_path): + target = str(tmp_path / "empty") + fs.touch(target) + assert fs.md5sum(target) == hashlib.md5(b"").hexdigest() + + def test_missing_file_returns_none(self, tmp_path): + assert fs.md5sum(str(tmp_path / "nope")) is None + assert fs.sha1sum(str(tmp_path / "nope")) is None