├── tests
├── __init__.py
├── unit
│ ├── __init__.py
│ ├── utils
│ │ ├── __init__.py
│ │ ├── test_structs.py
│ │ ├── test_path.py
│ │ ├── test_packing.py
│ │ └── test_misc.py
│ ├── backend
│ │ ├── __init__.py
│ │ ├── test_stub.py
│ │ ├── test_interface.py
│ │ ├── test_ledgercomm.py
│ │ ├── test_ledgerwallet.py
│ │ ├── test_physical_backend.py
│ │ └── test_speculos.py
│ ├── conftests
│ │ ├── __init__.py
│ │ └── test_base_conftest.py
│ ├── navigator
│ │ ├── __init__.py
│ │ ├── test_nano_navigator.py
│ │ ├── test_touch_navigator.py
│ │ └── test_navigation_scenario.py
│ ├── helpers.py
│ ├── test_error_ApplicationError.py
│ ├── firmware
│ │ ├── test_structs_Firmware.py
│ │ └── touch
│ │ │ ├── test_element.py
│ │ │ ├── test_screen_MetaScreen.py
│ │ │ └── test_screen_FullScreen.py
│ └── bip
│ │ └── test_path.py
├── functional
│ ├── __init__.py
│ ├── backend
│ │ ├── __init__.py
│ │ └── test_speculos.py
│ ├── navigator
│ │ └── __init__.py
│ ├── conftest.py
│ └── test_boilerplate.py
├── pytest.ini
├── snapshots
│ ├── nanos
│ │ ├── generic
│ │ │ ├── 00000.png
│ │ │ ├── 00001.png
│ │ │ ├── 00002.png
│ │ │ ├── 00003.png
│ │ │ └── 00004.png
│ │ ├── test_navigate_and_compare
│ │ │ ├── 00000.png
│ │ │ ├── 00001.png
│ │ │ ├── 00002.png
│ │ │ ├── 00003.png
│ │ │ ├── 00004.png
│ │ │ ├── 00005.png
│ │ │ ├── 00006.png
│ │ │ └── 00007.png
│ │ ├── test_navigate_and_compare_no_golden
│ │ │ └── 00000.png
│ │ ├── test_navigate_until_text_and_compare
│ │ │ ├── 00000.png
│ │ │ ├── 00001.png
│ │ │ ├── 00002.png
│ │ │ └── 00003.png
│ │ └── test_navigate_and_compare_wrong_golden
│ │ │ ├── 00000.png
│ │ │ └── 00001.png
│ ├── flex
│ │ └── waiting_screen
│ │ │ ├── 00000.png
│ │ │ ├── 00001.png
│ │ │ ├── 00002.png
│ │ │ ├── 00003.png
│ │ │ └── 00004.png
│ ├── stax
│ │ └── waiting_screen
│ │ │ ├── 00000.png
│ │ │ ├── 00001.png
│ │ │ ├── 00002.png
│ │ │ ├── 00003.png
│ │ │ └── 00004.png
│ └── apex_p
│ │ └── waiting_screen
│ │ ├── 00000.png
│ │ ├── 00001.png
│ │ ├── 00002.png
│ │ ├── 00003.png
│ │ └── 00004.png
└── stubs.py
├── src
└── ragger
│ ├── py.typed
│ ├── conftest
│ ├── __init__.py
│ └── configuration.py
│ ├── gui
│ ├── assets
│ │ ├── nanos_body.png
│ │ ├── nanox_body.png
│ │ ├── stax_body.png
│ │ ├── nanosp_body.png
│ │ ├── touch_action.png
│ │ ├── nanos_leftbutton.png
│ │ ├── nanos_rightbutton.png
│ │ ├── nanosp_leftbutton.png
│ │ ├── nanox_leftbutton.png
│ │ ├── nanox_rightbutton.png
│ │ ├── swipe_left_action.png
│ │ ├── nanosp_rightbutton.png
│ │ └── swipe_right_action.png
│ ├── __init__.py
│ └── process.py
│ ├── firmware
│ ├── touch
│ │ ├── __init__.py
│ │ ├── element.py
│ │ ├── layouts.py
│ │ └── use_cases.py
│ ├── __init__.py
│ └── structs.py
│ ├── __init__.py
│ ├── utils
│ ├── packing.py
│ ├── __init__.py
│ └── structs.py
│ ├── bip
│ ├── __init__.py
│ ├── path.py
│ └── seed.py
│ ├── navigator
│ ├── __init__.py
│ ├── nano_navigator.py
│ ├── instruction.py
│ └── touch_navigator.py
│ ├── backend
│ ├── __init__.py
│ ├── stub.py
│ ├── ledgerwallet.py
│ ├── ledgercomm.py
│ └── physical_backend.py
│ ├── logger.py
│ └── error.py
├── .gitattributes
├── MANIFEST.in
├── .codeql-config.yaml
├── doc
├── images
│ ├── ragger.png
│ ├── stax_infos.png
│ ├── stax_welcome.png
│ ├── navigate.draw
│ └── usage.draw
├── _static
│ └── layout.css
├── _templates
│ └── layout.html
├── architecture.rst
├── tutorial.rst
├── tutorial_installation.rst
├── index.rst
├── Makefile
├── installation.rst
├── source.rst
├── faq.rst
├── conf.py
├── glossary.rst
└── rationale.rst
├── template
├── .dispatch_to_your_test_folder
├── conftest.py
└── usage.md
├── ledger_app.toml
├── .gitignore
├── .github
└── workflows
│ ├── force-rebase.yml
│ ├── documentation.yml
│ ├── fast-checks.yml
│ ├── codeql-analysis.yml
│ └── build_and_tests.yml
└── pyproject.toml
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ragger/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/unit/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/functional/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/unit/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ragger/conftest/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/unit/backend/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/unit/conftests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/unit/navigator/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/functional/backend/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/functional/navigator/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | CHANGELOG.md text merge=union
2 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include src/ragger/gui/assets/*.png
2 |
--------------------------------------------------------------------------------
/.codeql-config.yaml:
--------------------------------------------------------------------------------
1 | paths:
2 | - src
3 | - doc
4 |
--------------------------------------------------------------------------------
/doc/images/ragger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/doc/images/ragger.png
--------------------------------------------------------------------------------
/doc/images/stax_infos.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/doc/images/stax_infos.png
--------------------------------------------------------------------------------
/template/.dispatch_to_your_test_folder:
--------------------------------------------------------------------------------
1 | Dispatch all files from this directory to your test folder
2 |
--------------------------------------------------------------------------------
/doc/images/stax_welcome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/doc/images/stax_welcome.png
--------------------------------------------------------------------------------
/tests/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | markers =
3 | use_on_backend: skip test if not on the specified backend
4 |
--------------------------------------------------------------------------------
/src/ragger/gui/assets/nanos_body.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanos_body.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/nanox_body.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanox_body.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/stax_body.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/stax_body.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/nanosp_body.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanosp_body.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/touch_action.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/touch_action.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/generic/00000.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00000.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/generic/00001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00001.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/generic/00002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00002.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/generic/00003.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00003.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/generic/00004.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00004.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/nanos_leftbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanos_leftbutton.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/nanos_rightbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanos_rightbutton.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/nanosp_leftbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanosp_leftbutton.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/nanox_leftbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanox_leftbutton.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/nanox_rightbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanox_rightbutton.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/swipe_left_action.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/swipe_left_action.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/nanosp_rightbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanosp_rightbutton.png
--------------------------------------------------------------------------------
/src/ragger/gui/assets/swipe_right_action.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/swipe_right_action.png
--------------------------------------------------------------------------------
/tests/snapshots/flex/waiting_screen/00000.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00000.png
--------------------------------------------------------------------------------
/tests/snapshots/flex/waiting_screen/00001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00001.png
--------------------------------------------------------------------------------
/tests/snapshots/flex/waiting_screen/00002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00002.png
--------------------------------------------------------------------------------
/tests/snapshots/flex/waiting_screen/00003.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00003.png
--------------------------------------------------------------------------------
/tests/snapshots/flex/waiting_screen/00004.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00004.png
--------------------------------------------------------------------------------
/tests/snapshots/stax/waiting_screen/00000.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00000.png
--------------------------------------------------------------------------------
/tests/snapshots/stax/waiting_screen/00001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00001.png
--------------------------------------------------------------------------------
/tests/snapshots/stax/waiting_screen/00002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00002.png
--------------------------------------------------------------------------------
/tests/snapshots/stax/waiting_screen/00003.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00003.png
--------------------------------------------------------------------------------
/tests/snapshots/stax/waiting_screen/00004.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00004.png
--------------------------------------------------------------------------------
/tests/snapshots/apex_p/waiting_screen/00000.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00000.png
--------------------------------------------------------------------------------
/tests/snapshots/apex_p/waiting_screen/00001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00001.png
--------------------------------------------------------------------------------
/tests/snapshots/apex_p/waiting_screen/00002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00002.png
--------------------------------------------------------------------------------
/tests/snapshots/apex_p/waiting_screen/00003.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00003.png
--------------------------------------------------------------------------------
/tests/snapshots/apex_p/waiting_screen/00004.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00004.png
--------------------------------------------------------------------------------
/doc/_static/layout.css:
--------------------------------------------------------------------------------
1 | .body {
2 | max-width: 1600px !important;
3 | }
4 |
5 | .wy-nav-content {
6 | max-width: 1200px !important;
7 | }
8 |
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare/00000.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00000.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare/00001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00001.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare/00002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00002.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare/00003.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00003.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare/00004.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00004.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare/00005.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00005.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare/00006.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00006.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare/00007.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00007.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare_no_golden/00000.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare_no_golden/00000.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_until_text_and_compare/00000.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_until_text_and_compare/00000.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_until_text_and_compare/00001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_until_text_and_compare/00001.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_until_text_and_compare/00002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_until_text_and_compare/00002.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_until_text_and_compare/00003.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_until_text_and_compare/00003.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare_wrong_golden/00000.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare_wrong_golden/00000.png
--------------------------------------------------------------------------------
/tests/snapshots/nanos/test_navigate_and_compare_wrong_golden/00001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare_wrong_golden/00001.png
--------------------------------------------------------------------------------
/ledger_app.toml:
--------------------------------------------------------------------------------
1 | # Fake boilerplate application manifest for ragger tests
2 | [app]
3 | build_directory = "./"
4 | sdk = "C"
5 | devices = ["nanos", "nanos+", "nanox", "stax", "flex", "apex_p"]
6 |
--------------------------------------------------------------------------------
/doc/_templates/layout.html:
--------------------------------------------------------------------------------
1 | {% extends "!layout.html" %}
2 | {% block extrahead %}
3 |
4 | {% endblock %}
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *pyc
2 | *egg-info/
3 | *~
4 | .coverage*
5 | coverage.xml
6 | build/
7 | dist/
8 | __version__.py
9 | .python-version
10 |
11 | tests/snapshots-tmp/
12 |
13 | # doc
14 | doc/images/*generated*
15 |
--------------------------------------------------------------------------------
/tests/unit/helpers.py:
--------------------------------------------------------------------------------
1 | from contextlib import contextmanager
2 | from pathlib import Path
3 | from tempfile import TemporaryDirectory
4 |
5 |
6 | @contextmanager
7 | def temporary_directory():
8 | with TemporaryDirectory() as dir_path:
9 | yield Path(dir_path).resolve()
10 |
--------------------------------------------------------------------------------
/doc/architecture.rst:
--------------------------------------------------------------------------------
1 | Architecture
2 | ============
3 |
4 | .. thumbnail:: images/packages_generated_ragger_architecture.svg
5 | :align: center
6 | :title: Package relations
7 | :show_caption: true
8 |
9 | .. thumbnail:: images/classes_generated_ragger_architecture.svg
10 | :align: center
11 | :title: Class relations
12 | :show_caption: true
13 |
--------------------------------------------------------------------------------
/tests/unit/utils/test_structs.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from ragger.utils.structs import RAPDU
4 |
5 |
6 | class TestRAPDU(TestCase):
7 |
8 | def test_raw(self):
9 | status = 0x9000
10 | data = bytes.fromhex('0123456789abcdef')
11 | expected = data + bytes.fromhex('9000')
12 | self.assertEqual(RAPDU(status, data).raw, expected)
13 |
--------------------------------------------------------------------------------
/doc/tutorial.rst:
--------------------------------------------------------------------------------
1 | .. _Tutorial:
2 |
3 | Tutorials
4 | =========
5 |
6 | In this tutorial, we are going to develop glue code to leverage the capabilities
7 | of ``Ragger`` in order to write tests which are able to run either on
8 | :term:`Speculos` or on a physical device.
9 |
10 | .. toctree::
11 | :maxdepth: 2
12 |
13 | tutorial_installation
14 | tutorial_conftest
15 | tutorial_screen
16 |
--------------------------------------------------------------------------------
/tests/unit/test_error_ApplicationError.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from ragger.error import ExceptionRAPDU
4 |
5 |
6 | class TestExceptionRAPDU(TestCase):
7 |
8 | def test___str__(self):
9 | status, data = 99, b"data"
10 | error = ExceptionRAPDU(status, data)
11 | for word in [f"0x{status:x}", str(data)]:
12 | self.assertIn(word, str(error))
13 |
--------------------------------------------------------------------------------
/tests/unit/firmware/test_structs_Firmware.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from ragger.firmware import Firmware
4 |
5 |
6 | class TestFirmware(TestCase):
7 |
8 | def test_firmware_not_existing_version(self):
9 | with self.assertRaises(ValueError):
10 | Firmware(10)
11 |
12 | def test_is_nano(self):
13 | self.assertTrue(Firmware.NANOSP.is_nano)
14 | self.assertFalse(Firmware.STAX.is_nano)
15 |
--------------------------------------------------------------------------------
/tests/unit/utils/test_path.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from ragger.utils import structs
4 |
5 |
6 | class TestStructsRAPDU(TestCase):
7 |
8 | def test___str__data(self):
9 | status = 19
10 | data = "012345"
11 | rapdu = structs.RAPDU(status, bytes.fromhex(data))
12 | self.assertEqual(f"[0x{status:x}] " + data, str(rapdu))
13 |
14 | def test___str__no_data(self):
15 | status = 19
16 | rapdu = structs.RAPDU(status, None)
17 | self.assertEqual(f"[0x{status:x}] ", str(rapdu))
18 |
--------------------------------------------------------------------------------
/template/conftest.py:
--------------------------------------------------------------------------------
1 | from ragger.conftest import configuration
2 |
3 | ###########################
4 | ### CONFIGURATION START ###
5 | ###########################
6 |
7 | # You can configure optional parameters by overriding the value of ragger.configuration.OPTIONAL_CONFIGURATION
8 | # Please refer to ragger/conftest/configuration.py for their descriptions and accepted values
9 |
10 | #########################
11 | ### CONFIGURATION END ###
12 | #########################
13 |
14 | # Pull all features from the base ragger conftest using the overridden configuration
15 | pytest_plugins = ("ragger.conftest.base_conftest", )
16 |
--------------------------------------------------------------------------------
/doc/tutorial_installation.rst:
--------------------------------------------------------------------------------
1 | ``Ragger`` installation
2 | =======================
3 |
4 | First step of all is the installation of ``Ragger``. At first, we are going to
5 | test it with :term:`Speculos`, so we are going to install ``Ragger`` with Speculos
6 | dependencies:
7 |
8 | .. code-block:: bash
9 |
10 | $ pip install ragger[speculos]
11 |
12 | ``ragger[speculos]`` means than Speculos and its dependencies will also be
13 | installed. ``Ragger`` tries to uncouple its dependencies so that only what is
14 | needed is installed. All the extras can be seen in the `setup.cfg
15 | `_ file.
16 |
--------------------------------------------------------------------------------
/tests/functional/conftest.py:
--------------------------------------------------------------------------------
1 | from ragger.conftest import configuration
2 |
3 | ###########################
4 | ### CONFIGURATION START ###
5 | ###########################
6 |
7 | # You can configure optional parameters by overriding the value of ragger.configuration.OPTIONAL_CONFIGURATION
8 | # Please refer to ragger/conftest/configuration.py for their descriptions and accepted values
9 |
10 | configuration.OPTIONAL.BACKEND_SCOPE = "function"
11 |
12 | #########################
13 | ### CONFIGURATION END ###
14 | #########################
15 |
16 | # Pull all features from the base ragger conftest using the overridden configuration
17 | pytest_plugins = ("ragger.conftest.base_conftest", )
18 |
--------------------------------------------------------------------------------
/tests/unit/firmware/touch/test_element.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from unittest.mock import MagicMock, patch
3 |
4 | from ragger.firmware.touch.element import Element
5 |
6 |
7 | class TestElement(TestCase):
8 |
9 | def test___init__(self):
10 | client = MagicMock()
11 | device = MagicMock()
12 | positions = MagicMock()
13 | with patch("ragger.firmware.touch.element.POSITIONS", {Element.__name__: positions}):
14 | element = Element(client, device)
15 | self.assertEqual(element.device, device)
16 | self.assertEqual(element.client, client)
17 | self.assertEqual(element.positions, positions[device.type])
18 |
--------------------------------------------------------------------------------
/doc/index.rst:
--------------------------------------------------------------------------------
1 | The Ragger framework
2 | ====================
3 |
4 | ``Ragger`` is a Python framework which aims at simplifying the test and overall
5 | automatic manipulation of applications running on Ledger devices.
6 |
7 | It is composed of a tiny wrapper around other Ledger's libraries which abstract
8 | and/or provide access to Ledger's cold wallet, and higher-level classes allowing
9 | to develop complex behaviors on devices.
10 |
11 | .. toctree::
12 | :maxdepth: 2
13 | :caption: How is this doc structured:
14 |
15 | installation
16 | rationale
17 | tutorial
18 | architecture
19 | source
20 | faq
21 | glossary
22 |
23 |
24 | Indices and tables
25 | ==================
26 |
27 | * :ref:`genindex`
28 | * :ref:`modindex`
29 | * :ref:`search`
30 |
--------------------------------------------------------------------------------
/src/ragger/firmware/touch/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from .screen import MetaScreen, FullScreen
17 |
18 | __all__ = ["MetaScreen", "FullScreen"]
19 |
--------------------------------------------------------------------------------
/src/ragger/firmware/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from .structs import DEPRECATION_MESSAGE, Firmware
17 |
18 | __all__ = ["DEPRECATION_MESSAGE", "Firmware"]
19 |
--------------------------------------------------------------------------------
/src/ragger/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | try:
17 | from ragger.__version__ import __version__ # noqa
18 | except ImportError:
19 | __version__ = "unknown version" # noqa
20 |
--------------------------------------------------------------------------------
/src/ragger/utils/packing.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from struct import pack
17 |
18 |
19 | def pack_APDU(cla: int, ins: int, p1: int = 0, p2: int = 0, data: bytes = b"") -> bytes:
20 | return pack(">BBBBB", cla, ins, p1, p2, len(data)) + data
21 |
--------------------------------------------------------------------------------
/tests/unit/navigator/test_nano_navigator.py:
--------------------------------------------------------------------------------
1 | from functools import partial
2 | from unittest import TestCase
3 | from ledgered.devices import DeviceType, Devices
4 |
5 | from ragger.navigator.nano_navigator import NanoNavigator
6 | from ragger.backend import LedgerCommBackend, LedgerWalletBackend, SpeculosBackend
7 |
8 |
9 | class TestNanoNavigator(TestCase):
10 |
11 | def test___init__ok(self):
12 | for backend_cls in [
13 | partial(SpeculosBackend, "some app"), LedgerCommBackend, LedgerWalletBackend
14 | ]:
15 | backend = backend_cls(Devices.get_by_type(DeviceType.NANOS))
16 | NanoNavigator(backend, Devices.get_by_type(DeviceType.NANOS))
17 |
18 | def test___init__nok(self):
19 | with self.assertRaises(ValueError):
20 | NanoNavigator("whatever", Devices.get_by_type(DeviceType.STAX))
21 |
--------------------------------------------------------------------------------
/tests/unit/navigator/test_touch_navigator.py:
--------------------------------------------------------------------------------
1 | from functools import partial
2 | from ledgered.devices import DeviceType, Devices
3 | from unittest import TestCase
4 |
5 | from ragger.navigator.touch_navigator import TouchNavigator
6 | from ragger.backend import LedgerCommBackend, LedgerWalletBackend, SpeculosBackend
7 |
8 |
9 | class TestTouchNavigator(TestCase):
10 |
11 | def test___init__ok(self):
12 | for backend_cls in [
13 | partial(SpeculosBackend, "some app"), LedgerCommBackend, LedgerWalletBackend
14 | ]:
15 | backend = backend_cls(Devices.get_by_type(DeviceType.STAX))
16 | TouchNavigator(backend, Devices.get_by_type(DeviceType.STAX))
17 |
18 | def test___init__nok(self):
19 | with self.assertRaises(ValueError):
20 | TouchNavigator("whatever", Devices.get_by_type(DeviceType.NANOS))
21 |
--------------------------------------------------------------------------------
/doc/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = .
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | gen_resources:
18 | @pyreverse --output svg --colorized --project generated_ragger_architecture --output-directory "$(SOURCEDIR)"/images/ "$(SOURCEDIR)"/../src/ragger
19 |
20 | # Catch-all target: route all unknown targets to Sphinx using the new
21 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
22 | %: Makefile
23 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
24 |
--------------------------------------------------------------------------------
/tests/unit/utils/test_packing.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from ragger.utils import packing
4 |
5 |
6 | class TestPacking(TestCase):
7 |
8 | def test_pack_APDU(self):
9 | cla, ins, p1, p2, data = 1, 2, 3, 4, b"data"
10 | expected = bytes.fromhex("0102030404") + data
11 | self.assertEqual(expected, packing.pack_APDU(cla, ins, p1, p2, data))
12 | expected = bytes.fromhex("0102030400")
13 | self.assertEqual(expected, packing.pack_APDU(cla, ins, p1, p2))
14 | expected = bytes.fromhex("0102030004") + data
15 | self.assertEqual(expected, packing.pack_APDU(cla, ins, p1, data=data))
16 | expected = bytes.fromhex("0102000404") + data
17 | self.assertEqual(expected, packing.pack_APDU(cla, ins, p2=p2, data=data))
18 | expected = bytes.fromhex("0102000000")
19 | self.assertEqual(expected, packing.pack_APDU(cla, ins))
20 |
--------------------------------------------------------------------------------
/doc/installation.rst:
--------------------------------------------------------------------------------
1 | .. _Installation:
2 |
3 | Installation
4 | ============
5 |
6 | ``Ragger`` is currently available in the test ``pypi`` repository. It can be
7 | installed with ``pip``:
8 |
9 | .. code-block:: bash
10 |
11 | pip install ragger
12 |
13 |
14 | By default - to avoid wasting useless time and space - the ``ragger`` package
15 | comes with no :term:`backend`. Specific backend can be installed with extras:
16 | ``ragger[speculos]``, ``ragger[ledgercomm]``, ``ragger[ledgerwallet]``.
17 | All backends can be installed with ``ragger[all_backends]``.
18 |
19 |
20 | .. _Installation-Apt:
21 |
22 | .. note::
23 |
24 | ``Speculos`` uses the ``qemu-arm-static`` executable under the hood, so
25 | you will need to install a system package for this. On Debian-based system,
26 | this executable lies in the ``qemu-user-static`` package, which can be
27 | installed it with:
28 |
29 | .. code-block:: bash
30 |
31 | sudo apt-get update && sudo apt-get install qemu-user-static
32 |
--------------------------------------------------------------------------------
/src/ragger/bip/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from .path import BtcDerivationPathFormat, pack_derivation_path, bitcoin_pack_derivation_path
17 | from .seed import CurveChoice, calculate_public_key_and_chaincode
18 |
19 | __all__ = [
20 | "bitcoin_pack_derivation_path",
21 | "BtcDerivationPathFormat",
22 | "calculate_public_key_and_chaincode",
23 | "CurveChoice",
24 | "pack_derivation_path",
25 | ]
26 |
--------------------------------------------------------------------------------
/src/ragger/navigator/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from .instruction import BaseNavInsID, NavInsID, NavIns
17 | from .navigator import Navigator
18 | from .touch_navigator import TouchNavigator
19 | from .nano_navigator import NanoNavigator
20 | from .navigation_scenario import NavigateWithScenario
21 |
22 | __all__ = [
23 | "BaseNavInsID", "NavInsID", "NavIns", "Navigator", "TouchNavigator", "NanoNavigator",
24 | "NavigateWithScenario"
25 | ]
26 |
--------------------------------------------------------------------------------
/tests/unit/firmware/touch/test_screen_MetaScreen.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from unittest.mock import MagicMock
3 |
4 | from ragger.firmware.touch import MetaScreen
5 |
6 |
7 | class TestMetaScreen(TestCase):
8 |
9 | def setUp(self):
10 | self.layout = MagicMock()
11 |
12 | class Test(metaclass=MetaScreen):
13 | layout_one = self.layout
14 |
15 | def __init__(self, client, device, some_argument, other=None):
16 | self.some = some_argument
17 | self.other = other
18 |
19 | self.cls = Test
20 | self.assertEqual(self.layout.call_count, 0)
21 |
22 | def test___init__(self):
23 | client, device = MagicMock(), MagicMock()
24 | args = (client, device, "some")
25 | test = self.cls(*args)
26 | self.assertEqual(self.layout.call_count, 1)
27 | self.assertEqual(self.layout.call_args, ((client, device), ))
28 | self.assertEqual(test.one, self.layout())
29 | self.assertEqual(test.some, args[-1])
30 | self.assertIsNone(test.other)
31 |
--------------------------------------------------------------------------------
/src/ragger/gui/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | try:
17 | from .process import RaggerGUI
18 | except ImportError as e:
19 | # Can be 'QtCore' or 'QtWidgets'
20 | if e.name is None or not e.name.startswith("Qt"):
21 | raise e
22 |
23 | def RaggerGUI(*args, **kwatgs): # type: ignore
24 | raise ImportError(
25 | "This feature needs PyQt6. Please install this package (run `pip install pyqt6`)")
26 |
27 |
28 | __all__ = ["RaggerGUI"]
29 |
--------------------------------------------------------------------------------
/src/ragger/utils/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from .structs import RAPDU, Crop
17 | from .packing import pack_APDU
18 | from .misc import find_library_application, prefix_with_len, find_project_root_dir
19 | from .misc import create_currency_config, split_message, find_application
20 |
21 | __all__ = [
22 | "find_library_application", "create_currency_config", "Crop", "pack_APDU", "prefix_with_len",
23 | "RAPDU", "split_message", "find_project_root_dir", "find_application"
24 | ]
25 |
--------------------------------------------------------------------------------
/.github/workflows/force-rebase.yml:
--------------------------------------------------------------------------------
1 | name: Force rebased
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | force-rebase:
7 | runs-on: ubuntu-latest
8 | steps:
9 |
10 | - name: 'PR commits + 1'
11 | id: pr_commits
12 | run: echo "pr_fetch_depth=$(( ${{ github.event.pull_request.commits }} + 1 ))" >> "${GITHUB_OUTPUT}"
13 |
14 | - name: 'Checkout PR branch and all PR commits'
15 | uses: actions/checkout@v4
16 | with:
17 | ref: ${{ github.event.pull_request.head.sha }}
18 | fetch-depth: ${{ steps.pr_commits.outputs.pr_fetch_depth }}
19 |
20 | - name: Check if PR branch is rebased on target branch
21 | shell: bash
22 | run: |
23 | git merge-base --is-ancestor ${{ github.event.pull_request.base.sha }} HEAD
24 |
25 | - name: Check if PR branch contains merge commits
26 | shell: bash
27 | run: |
28 | merges=$(git log --oneline HEAD~${{ github.event.pull_request.commits }}...HEAD --merges ); \
29 | echo "--- Merges ---"; \
30 | echo ${merges}; \
31 | [[ -z "${merges}" ]]
32 |
--------------------------------------------------------------------------------
/tests/unit/backend/test_stub.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from ragger.backend import BackendInterface
4 | from ragger.backend.stub import StubBackend
5 | from ragger.utils.structs import RAPDU
6 |
7 |
8 | class TestStubBackend(TestCase):
9 |
10 | def setUp(self):
11 | self.stub = StubBackend(None)
12 |
13 | def test_can_instantiate(self):
14 | self.assertIsInstance(self.stub, BackendInterface)
15 |
16 | def test_emtpy_methods(self):
17 | for func in (self.stub.handle_usb_reset, self.stub.send_raw, self.stub.right_click,
18 | self.stub.left_click, self.stub.both_click, self.stub.finger_touch,
19 | self.stub.wait_for_screen_change, self.stub.get_current_screen_content,
20 | self.stub.pause_ticker, self.stub.resume_ticker, self.stub.send_tick):
21 | self.assertIsNone(func())
22 | for func in (self.stub.receive, self.stub.exchange_raw):
23 | self.assertEqual(func(), RAPDU(0x9000, b""))
24 | for func in (self.stub.compare_screen_with_snapshot, self.stub.compare_screen_with_text):
25 | self.assertTrue(func(None))
26 |
--------------------------------------------------------------------------------
/tests/unit/navigator/test_navigation_scenario.py:
--------------------------------------------------------------------------------
1 | from ledgered.devices import DeviceType, Devices
2 | from pathlib import Path
3 | from tempfile import TemporaryDirectory
4 | from unittest import TestCase
5 | from unittest.mock import MagicMock
6 |
7 | from ragger.navigator import NavigateWithScenario
8 |
9 |
10 | class TestNavigationScenario(TestCase):
11 |
12 | def setUp(self):
13 | self.directory = TemporaryDirectory()
14 | self.backend = MagicMock()
15 | self.device = Devices.get_by_type(DeviceType.NANOS)
16 | self.callbacks = dict()
17 | self.navigator = MagicMock()
18 | self.navigate_with_scenario = NavigateWithScenario(self.backend, self.navigator,
19 | self.device, "test_name", self.directory)
20 |
21 | def tearDown(self):
22 | self.directory.cleanup()
23 |
24 | @property
25 | def pathdir(self) -> Path:
26 | return Path(self.directory.name)
27 |
28 | def test_navigation_scenario(self):
29 | self.assertIsNone(self.navigate_with_scenario.review_approve())
30 | self.assertIsNone(self.navigate_with_scenario.review_reject())
31 | self.assertIsNone(self.navigate_with_scenario.address_review_approve())
32 | self.assertIsNone(self.navigate_with_scenario.address_review_reject())
33 |
--------------------------------------------------------------------------------
/src/ragger/firmware/touch/element.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2024 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from ledgered.devices import Device
17 |
18 | from ragger.backend import BackendInterface
19 | from .positions import POSITIONS
20 |
21 |
22 | class Element:
23 |
24 | def __init__(self, client: BackendInterface, device: Device):
25 | self._client = client
26 | self._device = device
27 |
28 | @property
29 | def client(self) -> BackendInterface:
30 | return self._client
31 |
32 | @property
33 | def device(self) -> Device:
34 | return self._device
35 |
36 | @property
37 | def positions(self):
38 | return POSITIONS[str(self.__class__.__name__)][self.device.type]
39 |
40 |
41 | class Center(Element):
42 |
43 | def swipe_left(self):
44 | self.client.finger_swipe(*self.positions, direction="left")
45 |
46 | def swipe_right(self):
47 | self.client.finger_swipe(*self.positions, direction="right")
48 |
--------------------------------------------------------------------------------
/src/ragger/utils/structs.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from dataclasses import dataclass
17 |
18 |
19 | @dataclass(frozen=True)
20 | class Crop:
21 | left: int = 0
22 | upper: int = 0
23 | right: int = 0
24 | lower: int = 0
25 |
26 |
27 | @dataclass(frozen=True)
28 | class RAPDU:
29 | """
30 | The dataclass containing the application's response of an APDU from the
31 | client to the application.
32 |
33 | It is composed of two attributes:
34 |
35 | - ``status`` (``int``): from the two last bytes of the payload. Common
36 | values are 0x9000 for success, other being errors.
37 | - ``data`` (``bytes``): the rest of the response (the entire payload without
38 | the two last bytes)
39 | """
40 | status: int
41 | data: bytes
42 |
43 | def __str__(self):
44 | return f'[0x{self.status:02x}] {self.data.hex() if self.data else ""}'
45 |
46 | @property
47 | def raw(self):
48 | return self.data + self.status.to_bytes(2, 'big')
49 |
--------------------------------------------------------------------------------
/doc/images/navigate.draw:
--------------------------------------------------------------------------------
1 | 7VpZc+MoEP41rso+jEsCSXYex3Yy1+5ktvKwk0csYZkNFloJx/b++gUJdCGvHUe+plKVqkDTIPrrg25wD44X608Jiud/sADTHrCCdQ9OegDYDgA9+WcFm5wytKycECYkUEwl4ZH8ixVRsy1JgNMaI2eMchLXiT6LIuzzGg0lCVvV2WaM1r8aoxAbhEcfUZP6Fwn4XEnhWiX9MybhXH/Z1vItkGZWhHSOAraqkOBdD44TxnjeWqzHmErwNC75vPsto8XGEhzxfSb4s2+rh4eH5z+Dr5h+cbzvg0/fPqhVXhBdKoG/oxcSIs4StWu+0VAkbBkFWK5m9+BoNSccP8bIl6MroXxBm/MFVcMzQumYUbGMnAsnzp197wp6yhP2jCsjnj/E05kYUTvBCcfrrSLaBXDC4jBbYJ5sBIueoLEujC3vrkrNFSzzitY0H1LGEhYrl3iKhoL0FfBCA94vkUBg6XPCIiGL1YMfpfEiSqfIfxYUj4o9jaYCfS+UrQo/MPjB//JDgx+a/P1+31D061QbuHgYOG2qHYIp9LxuVAsbqr1tUa3XolrvWKp1DNUaOAqPj2VzRvH6o4xFAgocBao58SlKU+LXwS2dzMq4xVZ/qqGs8yRH+q7uTtZVzslG9RoqwrZQ0qBNRbfeAKJSRTgwomFDQUJAtkx8vNvoOUpCzHfFHlPhFY26LQrVtARTxMlLfbttWlZf+MGIEKSwJ6dhT7AZA3Ix1axqWG0uNGgsBBsL5TgYC2VGV4h9uB0OdtthxeYiFgniKEDpvIjkFeuT9B+Ic5xEGQVYsLAaffwBw0r3tZ2z6bquIftQVXvWjoWOrOrCAyuqFaA/qi5L+JyFLEL0rqQ2VFXy/M5YrLT+N+Z8o1IvtOSsbhN4TfjPMuqI3lNlpAxAsqPjj2lxgnJPpLj1uFYJZU+1SNYe1zqMUsN9o9QW032jTTaNElqntaXh9sSvH+Ut/AtkgEX2vSsFPFqecNsSn/MkLCAvNXi9f5ayFhjVWqH6jxYS3miaxlnfaiO5I7202Gm2ejH99R/MZqQxilqnyGwyzMzhg59rUuaZJCKcIPoWGQ4i3dSz3nFW+4Wp/VvWLiDJpalL2AFSJxELlGKBhljXtn+o9t+Yfl3y2KU8zrXo41JIWc15UrjcybYPvq70RZSEMjOmeMbNI+48lTDQmUP1iNOZafWIs5spRmdnnL6su8xiuEhge2X6Wiaz7QnsWQvo2z1T0+FFFdBOw7oOLaCLq51TVVUtBfOlVlWHVU27qrEOTde297RdG76xrHpbwDLvvYs70hudOnZY/AQID2d+W/zotPjxGi65b/HTdN3OcAbw3be68629L1ads/qW+ehRvFfc6Prl+n1rcHbfct59qzvfcvb0LbAl6TqRb5mvTnXfgr+CbzlnP7ds18C57aH0+qB1zw4t8AxoW541KCVxineDitI4/9XHjKylIrrAzK1DZtsmZG2IgaMhZj7xtZQsF4RYYTxnQ+z2/Wjs7GgEe7+Uuec8GoH5olX8rOZGX9Neffx2jph2im75a7T8dqX8TR+8+w8=
--------------------------------------------------------------------------------
/src/ragger/navigator/nano_navigator.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from time import sleep
17 | from typing import Callable, Dict
18 | from ledgered.devices import Device
19 |
20 | from ragger.backend import BackendInterface
21 | from .navigator import BaseNavInsID, Navigator, NavInsID
22 |
23 |
24 | class NanoNavigator(Navigator):
25 |
26 | def __init__(self, backend: BackendInterface, device: Device, golden_run: bool = False):
27 | if device.touchable:
28 | raise ValueError(f"'{self.__class__.__name__}' does not work on touchable devices")
29 | callbacks: Dict[BaseNavInsID, Callable] = {
30 | NavInsID.WAIT: sleep,
31 | NavInsID.WAIT_FOR_SCREEN_CHANGE: backend.wait_for_screen_change,
32 | NavInsID.WAIT_FOR_HOME_SCREEN: backend.wait_for_home_screen,
33 | NavInsID.WAIT_FOR_TEXT_ON_SCREEN: backend.wait_for_text_on_screen,
34 | NavInsID.WAIT_FOR_TEXT_NOT_ON_SCREEN: backend.wait_for_text_not_on_screen,
35 | NavInsID.RIGHT_CLICK: backend.right_click,
36 | NavInsID.LEFT_CLICK: backend.left_click,
37 | NavInsID.BOTH_CLICK: backend.both_click
38 | }
39 | super().__init__(backend, device, callbacks, golden_run)
40 |
--------------------------------------------------------------------------------
/.github/workflows/documentation.yml:
--------------------------------------------------------------------------------
1 | name: Documentation generation & update
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | branches:
8 | - develop
9 | - master
10 | pull_request:
11 | branches:
12 | - develop
13 | - master
14 | # Cancel previous runs on this reference
15 | concurrency:
16 | group: ${{ github.workflow }}-${{ github.ref }}
17 | cancel-in-progress: true
18 |
19 |
20 | jobs:
21 | generate:
22 | name: Generate the documentation
23 | runs-on: ubuntu-latest
24 | steps:
25 | - name: Clone
26 | uses: actions/checkout@v4
27 | with:
28 | fetch-depth: 0
29 | - name: Install APT dependencies
30 | run: |
31 | sudo apt-get update
32 | sudo apt-get install graphviz
33 | - name: Install Python dependencies
34 | run: |
35 | pip install -U pip
36 | pip install .[doc]
37 | - name: Generating automatic resources
38 | run: (cd doc && make gen_resources)
39 | - name: Generate the documentation
40 | run: (cd doc && make html)
41 | - name: Upload documentation bundle
42 | uses: actions/upload-artifact@v4
43 | with:
44 | name: documentation
45 | path: doc/build/html/
46 |
47 | deploy:
48 | name: Deploy the documentation on Github pages
49 | runs-on: ubuntu-latest
50 | needs: generate
51 | if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/'))
52 | steps:
53 | - name: Download documentation bundle
54 | uses: actions/download-artifact@v4
55 | - name: Deploy documentation on pages
56 | uses: peaceiris/actions-gh-pages@v4
57 | with:
58 | github_token: ${{ secrets.GITHUB_TOKEN }}
59 | publish_dir: documentation/
60 |
--------------------------------------------------------------------------------
/.github/workflows/fast-checks.yml:
--------------------------------------------------------------------------------
1 | name: Fast checks
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - develop
8 | - master
9 | pull_request:
10 | # Cancel previous runs on this reference
11 | concurrency:
12 | group: ${{ github.workflow }}-${{ github.ref }}
13 | cancel-in-progress: true
14 |
15 |
16 | jobs:
17 | lint:
18 | name: Linting
19 | runs-on: ubuntu-latest
20 | steps:
21 | - name: Clone
22 | uses: actions/checkout@v4
23 | - run: pip install flake8 flake8-pyproject
24 | - name: Flake8 lint Python code
25 | run: flake8 src/
26 |
27 | yapf:
28 | name: Formatting
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Clone
32 | uses: actions/checkout@v4
33 | - run: pip install yapf toml
34 | - name: Yapf source formatting
35 | run: |
36 | yapf src/ --recursive -d
37 | yapf tests/ --recursive -d
38 | yapf template/ --recursive -d
39 |
40 | mypy:
41 | name: Type checking
42 | runs-on: ubuntu-latest
43 | steps:
44 | - name: Clone
45 | uses: actions/checkout@v4
46 | - run: pip install mypy types-toml
47 | - name: Mypy type checking
48 | run: mypy src
49 |
50 | bandit:
51 | name: Security checking
52 | runs-on: ubuntu-latest
53 | steps:
54 | - name: Clone
55 | uses: actions/checkout@v4
56 | - run: pip install bandit
57 | - name: Bandit security checking
58 | run: bandit -r src -ll
59 |
60 | misspell:
61 | name: Check misspellings
62 | runs-on: ubuntu-latest
63 | steps:
64 | - name: Clone
65 | uses: actions/checkout@v4
66 | - name: Check misspellings
67 | uses: codespell-project/actions-codespell@v2
68 | with:
69 | builtin: clear,rare
70 | check_filenames: true
71 | ignore_words_list: assertIn,crate,
72 |
--------------------------------------------------------------------------------
/src/ragger/bip/path.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from bip_utils import Bip32Utils
17 | from enum import IntEnum
18 |
19 |
20 | class BtcDerivationPathFormat(IntEnum):
21 | LEGACY = 0x00
22 | P2SH = 0x01
23 | BECH32 = 0x02
24 | CASHADDR = 0x03 # Deprecated
25 | BECH32M = 0x04
26 |
27 |
28 | def pack_derivation_path(derivation_path: str) -> bytes:
29 | split = derivation_path.split("/")
30 |
31 | if split[0] != "m":
32 | raise ValueError("Error master expected")
33 |
34 | path_bytes: bytes = (len(split) - 1).to_bytes(1, byteorder='big')
35 | for value in split[1:]:
36 | if value == "":
37 | raise ValueError(f'Error missing value in split list "{split}"')
38 | if value.endswith('\''):
39 | path_bytes += Bip32Utils.HardenIndex(int(value[:-1])).to_bytes(4, byteorder='big')
40 | else:
41 | path_bytes += int(value).to_bytes(4, byteorder='big')
42 | return path_bytes
43 |
44 |
45 | def bitcoin_pack_derivation_path(format: BtcDerivationPathFormat, derivation_path: str) -> bytes:
46 | if not isinstance(format, BtcDerivationPathFormat):
47 | raise ValueError(f'"{format}" must be a BtcDerivationPathFormat enum')
48 | return format.to_bytes(1, "big") + pack_derivation_path(derivation_path)
49 |
--------------------------------------------------------------------------------
/src/ragger/bip/seed.py:
--------------------------------------------------------------------------------
1 | from typing import Tuple, Sequence
2 | from enum import Enum, auto
3 |
4 | from bip_utils import Bip32Secp256k1
5 | from bip_utils import Bip32Nist256p1
6 | from bip_utils import Bip32Ed25519Slip
7 | from bip_utils import Bip32Ed25519Kholaw
8 | from bip_utils import Bip32Ed25519Blake2bSlip
9 | from bip_utils import Bip39SeedGenerator
10 |
11 |
12 | class CurveChoice(Enum):
13 | Secp256k1 = auto()
14 | Nist256p1 = auto()
15 | Ed25519Slip = auto()
16 | Ed25519Kholaw = auto()
17 | Ed25519Blake2bSlip = auto()
18 |
19 |
20 | GET_CURVE_OBJ = {
21 | CurveChoice.Secp256k1: Bip32Secp256k1,
22 | CurveChoice.Nist256p1: Bip32Nist256p1,
23 | CurveChoice.Ed25519Slip: Bip32Ed25519Slip,
24 | CurveChoice.Ed25519Kholaw: Bip32Ed25519Kholaw,
25 | CurveChoice.Ed25519Blake2bSlip: Bip32Ed25519Blake2bSlip,
26 | }
27 |
28 | SPECULOS_MNEMONIC = (
29 | "glory promote mansion idle axis finger extra february uncover one trip resource "
30 | "lawn turtle enact monster seven myth punch hobby comfort wild raise skin")
31 |
32 |
33 | def calculate_public_key_and_chaincode(curve: CurveChoice,
34 | path: str,
35 | mnemonic: Sequence[str] = SPECULOS_MNEMONIC,
36 | compress_public_key: bool = False) -> Tuple[str, str]:
37 | if not isinstance(curve, CurveChoice):
38 | raise ValueError(f'"{curve}" must be a CurveChoice enum')
39 |
40 | seed = Bip39SeedGenerator(mnemonic).Generate()
41 | root_node = GET_CURVE_OBJ[curve].FromSeed(seed_bytes=seed)
42 | child_node = root_node.DerivePath(path=path)
43 | public_key = child_node.PublicKey()
44 | chaincode = child_node.ChainCode()
45 |
46 | if compress_public_key:
47 | raw_public_key = public_key.RawCompressed()
48 | else:
49 | raw_public_key = public_key.RawUncompressed()
50 | return raw_public_key.ToHex(), chaincode.ToHex()
51 |
--------------------------------------------------------------------------------
/doc/images/usage.draw:
--------------------------------------------------------------------------------
1 | 7VtRd6I6EP41Pu49hADiY2273T3b3tNbe7btY4SIbIFwILbaX3+DJAgkqF0Rbe2TZEhinO/7ZoZRe/A8nF8lKJ7eEBcHPV1z5z140dN1XbN09pJZFrkFAEvLLV7iu9y2Moz8N8yNYtrMd3FamUgJCagfV40OiSLs0IoNJQl5rU6bkKD6rjHysGQYOSiQrQ++S6e51Ta1lf0H9r2peGeg8TshEpO5IZ0il7yWTPCyB88TQmh+Fc7PcZB5T/glX/e94W5xsARHdJsFd7+f7kkE8Q209OHl7JHET1ffdJMfji7EJ07ILHJxtkjrwSFJ6JR4JELBNSExMwJm/IMpXXCs0IwSZprSMOB38dynj6Xrp2yrf0w+upjznZeDhRhENFk8lgelVdlwtWw5EutkJ3C/pGSWOHjNJ4ecTCjxMF0zb5DPw26FJ9zFV5iEmJ2HTUhwgKj/UqUN4uzzinkrgNgFx+g9eNkSXtnJRnwYkYi9DE8Ewv6WEOr6cWE4+MKwcIZ9MAz50lviszPrGk9ZUOQnnrCgSERii/ykfFWNCcUx/p4cYL3AVzy4XFlPhSuDLbkiIDsSvfNjv6Bgxt/px/397Ya8+zr1KR7FaOmQV1ZcVQGb+EFwTgKSLNdCDFwT95k9pQl5xqU7A6sPkbXO7S84oXi+1k8N2gAGH7+uKiNhmpaKIkvbk2fh8UbSv2d5++xVRzpLq6Kpdx3pNEkXoxg7s4CkzJpLREtxwvi5Rivg3VqZ2A52HJVWxrZpmGvB214rhrFZK0DvUiwFAT9OZgFdZRYgHi03ppb9lCFnSYIWpQlxJrq0Wbt9S63d71vOB6ZW41N+gnYFLkSjFDgOZ8xDpGVtT7Cl1rbbH4y1lrRdj5zAPrS2BS1Lrj6L48B3GAdJ1LKLJ3qDi62xZbZUakgu1g/tYvgVPpvpt20zBRhHGT6h+b7wqQ+6CJ9Q0vTtdJEyTS9brPjFZ5h8/OAJD65sQcpPGzwL1x0ueGqSG489eHbW1RCdrc3BE7YdPHfDVO5UfSGmroCPBDFx7nIn6ufFBhQ/QiNKlUO6bUTJnpUDXuSeZV9I9orGlIvSaZFISj6tAtDor42sKnnDVHhD2HbsIxl6FQxDq3k515PUR9q4UZGiOmpIQbngOg98vDxi6rtyscXYSqvIocD3InbtsFWYEX+YcTqr2M74jdB33Ty54dR/Q+PlVhnKvGRl+5rDnnmR7cWiY5oHSiAJijOorD5uakFeumFWgLBNSV22gk9wb+qSC7QR7wueFjAGPDJgrNMNezaoFdlmS2FP2mjPYU8kyhKIl8venOrh57Mqq19r1+uKiqJTaRly8/QOr3vy/7TI1GJeIY8SMoMukTFPuNaDRku1Xn2jrms9U1HrkTCcRUXXxwoyOY1ZjWF52dU1Wii+iPysqoP6xkoDAAXPjBZUBx7u/3uiN7//TW/iEXx7Dt9+zb7J4VDCwmNaihs+aPEDUe7vXhHgJTc1+6RG2b4ciPS+wifF10Ptdy4VTrFQmD2iR+M0e6k0MjWZ1fX5+aRNJoc/FjU2DrbojAqdBHhC5T6Ci7A9UTZKLcfG4wm7E5IXDiaoiSmPqxTR0tjFAS6PseuXh1V2ALWI1vFys7IORxNVwqoCeoc8b9efWWwA1MS2a6gANS9szRhUANUU0bEMqFYDVKsCqkmANuTW3QAVBUotLCh67ar6BOyvGSXBLb52HyLnmZUmu+F8MsCC+oOZAlm4pxy47vfOJWCvs7owySqXL2h3gVaRyruF1m6A9gEFzGVf4O4CrvK3Iy2hy4arf/DkDyerP0LBy/8B
--------------------------------------------------------------------------------
/src/ragger/firmware/structs.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from enum import IntEnum
17 | from ledgered.devices import Devices, DeviceType
18 | from warnings import warn
19 |
20 |
21 | DEPRECATION_MESSAGE = "`ragger.firmware.Firmware` is deprecated, use `ledgered.devices.Devices` " \
22 | "or `ledgered.devices.DeviceType` instead"
23 |
24 |
25 | class Firmware(IntEnum):
26 | NANOS = DeviceType.NANOS
27 | NANOSP = DeviceType.NANOSP
28 | NANOX = DeviceType.NANOX
29 | STAX = DeviceType.STAX
30 | FLEX = DeviceType.FLEX
31 | APEX_P = DeviceType.APEX_P
32 | APEX_M = DeviceType.APEX_M
33 |
34 | @staticmethod
35 | def deprec_warning() -> None:
36 | warn(DEPRECATION_MESSAGE)
37 |
38 | @property
39 | def name(self) -> str:
40 | """
41 | Returns the name of the current firmware's device
42 | """
43 | self.deprec_warning()
44 | return Devices.get_by_type(self).name
45 |
46 | @property
47 | def device(self) -> str:
48 | """
49 | A proxy property for :attr:`.Firmware.name`.
50 | This property is deprecated. It is advise to not use it.
51 | """
52 | self.deprec_warning()
53 | return self.name
54 |
55 | @property
56 | def is_nano(self):
57 | """
58 | States if the firmware's name starts with 'nano' or not.
59 | """
60 | self.deprec_warning()
61 | return Devices.get_by_type(self).is_nano
62 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | - develop
8 | pull_request:
9 | # The branches below must be a subset of the branches above
10 | branches:
11 | - master
12 | - develop
13 | schedule:
14 | - cron: '35 0 * * 5'
15 | # Cancel previous runs on this reference
16 | concurrency:
17 | group: ${{ github.workflow }}-${{ github.ref }}
18 | cancel-in-progress: true
19 |
20 |
21 | jobs:
22 | analyze:
23 | name: Analyze
24 | runs-on: ubuntu-latest
25 | permissions:
26 | actions: read
27 | contents: read
28 | security-events: write
29 |
30 | steps:
31 | - name: Checkout repository
32 | uses: actions/checkout@v4
33 |
34 | # Initializes the CodeQL tools for scanning.
35 | - name: Initialize CodeQL
36 | uses: github/codeql-action/init@v3
37 | with:
38 | languages: "python"
39 | # If you wish to specify custom queries, you can do so here or in a config file.
40 | # By default, queries listed here will override any specified in a config file.
41 | # Prefix the list here with "+" to use these queries and those in the config file.
42 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
43 | queries: +security-and-quality
44 |
45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
46 | # If this step fails, then you should remove it and run the build manually (see below)
47 | - name: Autobuild
48 | uses: github/codeql-action/autobuild@v3
49 |
50 | # ℹ️ Command-line programs to run using the OS shell.
51 | # 📚 https://git.io/JvXDl
52 |
53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
54 | # and modify them (or add more) to build your code if your project
55 | # uses a compiled language
56 |
57 | #- run: |
58 | # make bootstrap
59 | # make release
60 |
61 | - name: Perform CodeQL Analysis
62 | uses: github/codeql-action/analyze@v3
63 | with:
64 | config-file: .codeql-config.yaml
65 |
--------------------------------------------------------------------------------
/src/ragger/backend/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from .interface import BackendInterface, RaisePolicy
17 | from .stub import StubBackend
18 |
19 | ERROR_MSG = ("This backend needs {}. Please install this package (run `pip install ragger[{}]` or "
20 | "check this address: '{}')")
21 |
22 | try:
23 | from .speculos import SpeculosBackend
24 | except ImportError as e:
25 | if "speculos" not in str(e):
26 | raise e
27 |
28 | def SpeculosBackend(*args, **kwargs): # type: ignore
29 | raise ImportError(
30 | ERROR_MSG.format("Speculos", "speculos", "https://github.com/LedgerHQ/speculos/"))
31 |
32 |
33 | try:
34 | from .ledgercomm import LedgerCommBackend
35 | except ImportError as e:
36 | if "ledgercomm" not in str(e):
37 | raise e
38 |
39 | def LedgerCommBackend(*args, **kwargs): # type: ignore
40 | raise ImportError(
41 | ERROR_MSG.format("LedgerComm", "ledgercomm", "https://github.com/LedgerHQ/ledgercomm/"))
42 |
43 |
44 | try:
45 | from .ledgerwallet import LedgerWalletBackend
46 | except ImportError as e:
47 | if "ledgerwallet" not in str(e):
48 | raise e
49 |
50 | def LedgerWalletBackend(*args, **kwargs): # type: ignore
51 | raise ImportError(
52 | ERROR_MSG.format("LedgerWallet", "ledgerwallet",
53 | "https://github.com/LedgerHQ/ledgerctl/"))
54 |
55 |
56 | __all__ = [
57 | "SpeculosBackend", "LedgerCommBackend", "LedgerWalletBackend", "BackendInterface",
58 | "RaisePolicy", "StubBackend"
59 | ]
60 |
--------------------------------------------------------------------------------
/src/ragger/logger.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 |
17 | import atexit
18 | import logging
19 | from pathlib import Path
20 |
21 | SHORT_FORMAT = '[%(levelname)s] %(name)s - %(message)s'
22 | LONG_FORMAT = '[%(asctime)s][%(levelname)s] %(name)s - %(message)s'
23 |
24 |
25 | def get_default_logger():
26 | return logging.getLogger("ragger.logger")
27 |
28 |
29 | def get_conf_logger():
30 | return logging.getLogger("ragger.configuration")
31 |
32 |
33 | def get_apdu_logger():
34 | return logging.getLogger("ragger.apdu_logger")
35 |
36 |
37 | def get_gui_logger():
38 | return logging.getLogger("ragger.gui")
39 |
40 |
41 | def _init_logger(logger: logging.Logger, level, format: str):
42 | logger.handlers.clear()
43 | logger.setLevel(level=level)
44 | handler = logging.StreamHandler()
45 | handler.setFormatter(logging.Formatter(format))
46 | logger.addHandler(handler)
47 |
48 |
49 | def init_loggers(level):
50 | if level == logging.DEBUG:
51 | display_format = LONG_FORMAT
52 | else:
53 | display_format = SHORT_FORMAT
54 | for logger in [get_default_logger(), get_apdu_logger(), get_gui_logger()]:
55 | _init_logger(logger, level, display_format)
56 |
57 |
58 | def standalone_conf_logger():
59 | logger = get_conf_logger()
60 | _init_logger(logger, logging.DEBUG, SHORT_FORMAT)
61 | return logger
62 |
63 |
64 | def set_apdu_logger_file(log_apdu_file: Path):
65 | apdu_logger = get_apdu_logger()
66 |
67 | # Only one file handler supported
68 | for handler in apdu_logger.handlers:
69 | if isinstance(handler, logging.FileHandler):
70 | apdu_logger.removeHandler(handler)
71 |
72 | apdu_handler = logging.FileHandler(filename=log_apdu_file, mode='w', delay=True)
73 | apdu_handler.setFormatter(logging.Formatter('%(message)s'))
74 | apdu_logger.addHandler(apdu_handler)
75 |
76 | def cleanup():
77 | for handler in apdu_logger.handlers:
78 | if isinstance(handler, logging.FileHandler):
79 | handler.close()
80 |
81 | atexit.register(cleanup)
82 |
83 |
84 | # Runs on import of logger module to enable the default log template
85 | init_loggers(logging.INFO)
86 |
--------------------------------------------------------------------------------
/tests/unit/bip/test_path.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from ragger.bip import path as p
4 |
5 | MAX_VALUE = 2**31 - 1
6 |
7 | HARDENED_INDEX = 2**31
8 |
9 |
10 | class TestPath(TestCase):
11 |
12 | def _test_level_n(self, n: int):
13 | for variant in [0, 255, 81845, 887587, MAX_VALUE]:
14 | for hardening in [False, True]:
15 | hardening_char = '\'' if hardening else ''
16 | previous_levels = "0/" * (n - 1)
17 | path = f'm/{previous_levels}{variant}{hardening_char}'
18 | packed = p.pack_derivation_path(path)
19 | self.assertEqual(packed[0], n)
20 | self.assertEqual(len(packed), 1 + n * 4)
21 | last_value = int.from_bytes(packed[len(packed) - 4:len(packed)], byteorder="big")
22 | self.assertEqual(last_value, variant | (HARDENED_INDEX if hardening else 0))
23 |
24 | def test_errors(self):
25 | with self.assertRaises(ValueError):
26 | p.pack_derivation_path("")
27 | with self.assertRaises(ValueError):
28 | p.pack_derivation_path("m/0/")
29 | with self.assertRaises(ValueError):
30 | p.pack_derivation_path("m//0")
31 | with self.assertRaises(ValueError):
32 | p.pack_derivation_path("m/a")
33 |
34 | def test_level_path_0(self):
35 | packed = p.pack_derivation_path("m")
36 | self.assertEqual(packed[0], 0)
37 | self.assertEqual(len(packed), 1)
38 |
39 | def test_level_path_1(self):
40 | self._test_level_n(1)
41 |
42 | def test_level_path_2(self):
43 | self._test_level_n(2)
44 |
45 | def test_level_path_3(self):
46 | self._test_level_n(3)
47 |
48 | def test_level_path_4(self):
49 | self._test_level_n(4)
50 |
51 | def test_level_path_5(self):
52 | self._test_level_n(5)
53 |
54 | def test_level_path_6(self):
55 | self._test_level_n(6)
56 |
57 | def test_level_path_7(self):
58 | self._test_level_n(7)
59 |
60 | def test_level_path_8(self):
61 | self._test_level_n(8)
62 |
63 | def test_level_path_9(self):
64 | self._test_level_n(9)
65 |
66 | def test_level_path_10(self):
67 | self._test_level_n(10)
68 |
69 | def test_bitcoin_pack_derivation_path(self):
70 | base = b"\x03\x80\x00\x00\x2c\x80\x00\x00\x00\x00\x00\x00\x00"
71 | prefix = [b"\x00", b"\x01", b"\x02", b"\x03", b"\x04"]
72 | for i, format in enumerate(p.BtcDerivationPathFormat):
73 | self.assertEqual(prefix[i] + base, p.bitcoin_pack_derivation_path(format, "m/44'/0'/0"))
74 |
75 | def test_bitcoin_pack_derivation_path_nok(self):
76 | with self.assertRaises(ValueError):
77 | p.bitcoin_pack_derivation_path(max(p.BtcDerivationPathFormat) + 1, "")
78 |
--------------------------------------------------------------------------------
/doc/source.rst:
--------------------------------------------------------------------------------
1 | Code documentation
2 | ==================
3 |
4 | .. contents::
5 | :local:
6 | :backlinks: none
7 |
8 |
9 | ``ragger.backend``
10 | ------------------
11 |
12 | ``ragger.backend.interface``
13 | ++++++++++++++++++++++++++++
14 |
15 | Interface contract
16 | ''''''''''''''''''
17 |
18 | The contract a backend must respect:
19 |
20 | .. autoclass:: ragger.backend.BackendInterface
21 | :members:
22 |
23 | Response management policy
24 | ''''''''''''''''''''''''''
25 |
26 | To change the behavior of the backend on :term:`response APDU `, one can
27 | tinker with the ``RaisePolicy``:
28 |
29 | .. autoclass:: ragger.backend.interface.RaisePolicy
30 | :members:
31 | :undoc-members:
32 |
33 | Concrete backends
34 | +++++++++++++++++
35 |
36 | Speculos backend (emulator)
37 | '''''''''''''''''''''''''''
38 |
39 | .. autoclass:: ragger.backend.SpeculosBackend
40 | :members:
41 |
42 | Physical backends
43 | '''''''''''''''''
44 |
45 | .. autoclass:: ragger.backend.LedgerCommBackend
46 | :members:
47 |
48 | .. autoclass:: ragger.backend.LedgerWalletBackend
49 | :members:
50 |
51 | ``ragger.error``
52 | ----------------
53 |
54 | .. autoclass:: ragger.error.ExceptionRAPDU
55 |
56 |
57 | ``ragger.firmware``
58 | -------------------
59 |
60 | Most ``Ragger`` high-level class needs to know which :term:`Firmware` they
61 | should expect. This is declared with this class:
62 |
63 | .. autoclass:: ragger.firmware.Firmware
64 | :members:
65 | :show-inheritance:
66 |
67 | .. autoattribute:: NANOS
68 |
69 | .. autoattribute:: NANOSP
70 |
71 | .. autoattribute:: NANOX
72 |
73 | .. autoattribute:: STAX
74 |
75 | .. autoattribute:: FLEX
76 |
77 | .. autoattribute:: APEX_P
78 |
79 | .. autoattribute:: APEX_M
80 |
81 | ``ragger.firmware.touch``
82 | ++++++++++++++++++++++++
83 |
84 | ``ragger.firmware.touch.screen``
85 | '''''''''''''''''''''''''''''''
86 |
87 | .. automodule:: ragger.firmware.touch.screen
88 | :members:
89 |
90 | ``ragger.firmware.touch.layouts``
91 | '''''''''''''''''''''''''''''''''
92 |
93 | .. automodule:: ragger.firmware.touch.layouts
94 | :members:
95 | :undoc-members:
96 |
97 | ``ragger.firmware.touch.use_cases``
98 | ''''''''''''''''''''''''''''''''''
99 |
100 | .. automodule:: ragger.firmware.touch.use_cases
101 | :members:
102 | :undoc-members:
103 |
104 | ``ragger.navigator``
105 | --------------------
106 |
107 | Interface and instructions
108 | ++++++++++++++++++++++++++
109 |
110 | .. automodule:: ragger.navigator.navigator
111 | :members:
112 | :undoc-members:
113 |
114 | ``ragger.utils``
115 | ----------------
116 |
117 | ``ragger.utils.structs``
118 | ++++++++++++++++++++++++
119 |
120 | .. autoclass:: ragger.utils.structs.RAPDU
121 |
122 | ``ragger.utils.misc``
123 | +++++++++++++++++++++
124 |
125 | .. autofunction:: ragger.utils.misc.app_path_from_app_name
126 |
--------------------------------------------------------------------------------
/doc/faq.rst:
--------------------------------------------------------------------------------
1 | Frequently Asked Questions
2 | ==========================
3 |
4 | .. contents::
5 | :local:
6 | :backlinks: none
7 |
8 | Installation / integration
9 | --------------------------
10 |
11 | Why all my tests are raising a ``ConnectionError`` when using SpeculosBackend?
12 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 |
14 | This indicates that :term:`Speculos` could not reach its server, generally
15 | because the emulator could not be started.
16 |
17 | With verbose options, you should be able to find in the logs the Speculos
18 | command line, which should look like:
19 |
20 | .. code-block:: bash
21 |
22 | /usr/bin/python3 -m speculos --model nanos --sdk 2.1 --display headless //.elf
23 |
24 | Try and launch this command line by itself to try and see the original error. It
25 | could be that:
26 |
27 | - the application ELF file does not exists
28 | - the ``model`` or ``sdk`` version does not exists
29 | - Speculos is already started elsewhere, and the network port are not available
30 | - Speculos is not installed
31 | - ``qemu-arm-static`` (used by Speculos under the hook) is not installed
32 |
33 | ...and if I'm getting a ``NotADirectoryError``?
34 | +++++++++++++++++++++++++++++++++++++++++++++++
35 |
36 | This is a rare case that can occur if the user's ``$PATH`` contains
37 | dubiously-formed directories. (Re-)Installing ``qemu-arm-static`` (see
38 | :ref:`here `) seems to solve the issue.
39 |
40 | Architecture / code
41 | -------------------
42 |
43 | Can I control how the backend behaves when receiving a response from the application?
44 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
45 |
46 | Backends can be instantiated with a
47 | :py:class:`RaisePolicy `, which controls
48 | how a backend should react when receiving a response from the
49 | :term:`application` it is communicating with.
50 |
51 | By default, this policy is ``RaisePolicy.RAISE_ALL_BUT_0x9000``, which means the
52 | backend will raise an :py:class:`ExceptionRAPDU `
53 | if the :term:`APDU` returned by the :term:`application` does not end with
54 | ``b'0x9000'``, else returns a :py:class:`RAPDU `
55 | instance. This behavior can be change with the three other options:
56 |
57 | - ``RaisePolicy.RAISE_NOTHING``, where the backend will never raise, and always
58 | returns a proper :py:class:`RAPDU `.
59 | - ``RaisePolicy.RAISE_ALL``, where the backend will always raise a
60 | :py:class:`ExceptionAPDU `, whatever the status.
61 | - ``RaisePolicy.RAISE_CUSTOM``, where the backend will raise a
62 | :py:class:`ExceptionAPDU `, for :term:`APDU` ending with
63 | status defined in ``whitelisted_status``.
64 |
65 | From that, every higher-level error management can be performed on top of
66 | ``Ragger``.
67 |
--------------------------------------------------------------------------------
/src/ragger/navigator/instruction.py:
--------------------------------------------------------------------------------
1 | from enum import auto, Enum
2 | from typing import Any, Dict
3 |
4 |
5 | class BaseNavInsID(Enum):
6 | """
7 | Base NavInsID class, allowing to define NavInsID specific to one's application
8 | while being compatible with all Navigator methods.
9 | """
10 | pass
11 |
12 |
13 | class NavInsID(BaseNavInsID):
14 | """
15 | Pre-defined instruction ID to navigate into a device UI.
16 | """
17 | WAIT = auto()
18 |
19 | # Navigation instructions that embedded a call to
20 | # wait_for_screen_change()
21 | WAIT_FOR_SCREEN_CHANGE = auto()
22 | WAIT_FOR_HOME_SCREEN = auto()
23 | WAIT_FOR_TEXT_ON_SCREEN = auto()
24 | WAIT_FOR_TEXT_NOT_ON_SCREEN = auto()
25 |
26 | # Navigation instructions for Nano devices
27 | RIGHT_CLICK = auto()
28 | LEFT_CLICK = auto()
29 | BOTH_CLICK = auto()
30 |
31 | # Navigation instructions for Stax devices
32 | TOUCH = auto()
33 | SWIPE = auto()
34 | SWIPE_CENTER_TO_LEFT = auto()
35 | SWIPE_CENTER_TO_RIGHT = auto()
36 |
37 | # possible headers
38 | RIGHT_HEADER_TAP = auto()
39 | EXIT_HEADER_TAP = auto()
40 | INFO_HEADER_TAP = auto()
41 | LEFT_HEADER_TAP = auto()
42 | NAVIGATION_HEADER_TAP = auto()
43 | # possible centers
44 | CHOICE_CHOOSE = auto()
45 | SUGGESTION_CHOOSE = auto()
46 | TAPPABLE_CENTER_TAP = auto()
47 | KB_LETTER_ONLY_WRITE = auto()
48 | KB_LETTERS_WRITE = auto()
49 | KB_SPECIAL_CHAR_1_WRITE = auto()
50 | KB_SPECIAL_CHAR_2_WRITE = auto()
51 | # possible footers
52 | CENTERED_FOOTER_TAP = auto()
53 | CANCEL_FOOTER_TAP = auto()
54 | EXIT_FOOTER_TAP = auto()
55 | INFO_FOOTER_TAP = auto()
56 | # use cases
57 | USE_CASE_HOME_INFO = auto()
58 | USE_CASE_HOME_SETTINGS = auto()
59 | USE_CASE_HOME_QUIT = auto()
60 | USE_CASE_SETTINGS_SINGLE_PAGE_EXIT = auto()
61 | USE_CASE_SETTINGS_MULTI_PAGE_EXIT = auto()
62 | USE_CASE_SETTINGS_PREVIOUS = auto()
63 | USE_CASE_SETTINGS_NEXT = auto()
64 | USE_CASE_SUB_SETTINGS_EXIT = auto()
65 | USE_CASE_SUB_SETTINGS_PREVIOUS = auto()
66 | USE_CASE_SUB_SETTINGS_NEXT = auto()
67 | USE_CASE_CHOICE_CONFIRM = auto()
68 | USE_CASE_CHOICE_REJECT = auto()
69 | USE_CASE_STATUS_DISMISS = auto()
70 | USE_CASE_REVIEW_TAP = auto()
71 | USE_CASE_REVIEW_NEXT = auto()
72 | USE_CASE_REVIEW_PREVIOUS = auto()
73 | USE_CASE_REVIEW_REJECT = auto()
74 | USE_CASE_REVIEW_CONFIRM = auto()
75 | USE_CASE_VIEW_DETAILS_EXIT = auto()
76 | USE_CASE_VIEW_DETAILS_PREVIOUS = auto()
77 | USE_CASE_VIEW_DETAILS_NEXT = auto()
78 | USE_CASE_ADDRESS_CONFIRMATION_TAP = auto()
79 | USE_CASE_ADDRESS_CONFIRMATION_EXIT_QR = auto()
80 | USE_CASE_ADDRESS_CONFIRMATION_CONFIRM = auto()
81 | USE_CASE_ADDRESS_CONFIRMATION_CANCEL = auto()
82 |
83 |
84 | class NavIns:
85 |
86 | def __init__(self, id: BaseNavInsID, args=(), kwargs: Dict[str, Any] = {}):
87 | self.id = id
88 | self.args = args
89 | self.kwargs = kwargs
90 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = [
3 | "setuptools>=75",
4 | "setuptools_scm[toml]>=6.2",
5 | "wheel"
6 | ]
7 | build-backend = "setuptools.build_meta"
8 |
9 | [project]
10 | name = "ragger"
11 | authors = [{name = "Ledger", email = "hello@ledger.fr"}]
12 | description = "Testing framework using Speculos and LedgerComm as backends"
13 | classifiers = [
14 | "Programming Language :: Python :: 3",
15 | "Programming Language :: Python :: 3.9",
16 | "Programming Language :: Python :: 3.10",
17 | "Programming Language :: Python :: 3.11",
18 | "Programming Language :: Python :: 3.12",
19 | "Programming Language :: Python :: 3.13",
20 | "License :: OSI Approved :: Apache Software License",
21 | "Operating System :: POSIX :: Linux",
22 | "Operating System :: MacOS :: MacOS X",
23 | ]
24 | requires-python = ">=3.9"
25 | dependencies = [
26 | "bip_utils>=2.4.0",
27 | "py-sr25519-bindings>=0.2.0,<0.3.0",
28 | "ledgered>=0.10.1",
29 | "toml"
30 | ]
31 | dynamic = ["version"]
32 |
33 | [project.readme]
34 | file = "README.md"
35 | content-type = "text/markdown"
36 |
37 | [project.urls]
38 | Homepage = "https://github.com/LedgerHQ/ragger"
39 | "Bug Tracker" = "https://github.com/LedgerHQ/ragger/issues"
40 |
41 | [project.optional-dependencies]
42 | tests = [
43 | "pytest",
44 | "pytest-cov",
45 | ]
46 | checkers = [
47 | "yapf",
48 | "toml",
49 | "flake8",
50 | "flake8-pyproject",
51 | "mypy",
52 | "types-toml",
53 | "bandit",
54 | ]
55 | doc = [
56 | # embeds pyreverse
57 | "pylint",
58 | "sphinx",
59 | "sphinx-rtd-theme",
60 | "sphinxcontrib-images",
61 | "sphinx-copybutton",
62 | "Jinja2>=3.0",
63 | # higher versions trigger build bugs with the RTD theme
64 | "docutils==0.16",
65 | ]
66 | speculos = [
67 | "speculos>=0.13.1",
68 | "mnemonic",
69 | ]
70 | ledgercomm = [
71 | "ledgercomm>=1.2.1",
72 | "ledgercomm[hid]>=1.2.1",
73 | "pyqt6",
74 | "pytesseract",
75 | "pillow",
76 | ]
77 | ledgerwallet = [
78 | "ledgerwallet>=0.4.0",
79 | "pyqt6",
80 | "pytesseract",
81 | "pillow",
82 | ]
83 | all_backends = [
84 | "ragger[speculos]",
85 | "ragger[ledgercomm]",
86 | "ragger[ledgerwallet]",
87 | ]
88 |
89 | [tool.setuptools]
90 | package-dir = {"" = "src"}
91 | include-package-data = true
92 |
93 | [tool.setuptools.packages.find]
94 | where = ["src"]
95 | exclude = ["tests"]
96 | namespaces = false
97 |
98 | [tool.setuptools.package-data]
99 | ragger = ["py.typed"]
100 |
101 | [tool.setuptools_scm]
102 | write_to = "src/ragger/__version__.py"
103 | local_scheme = "no-local-version"
104 |
105 | [tool.mypy]
106 | ignore_missing_imports = true
107 |
108 | [tool.yapf]
109 | based_on_style = "pep8"
110 | column_limit = 100
111 |
112 | [tool.coverage.report]
113 | show_missing = true
114 | exclude_lines = [
115 | "@abstractmethod",
116 | "pragma: no cover"
117 | ]
118 |
119 | [tool.flake8]
120 | max-line-length = 120
121 | extend-ignore = "E502"
122 |
--------------------------------------------------------------------------------
/src/ragger/firmware/touch/layouts.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | import logging
17 |
18 | from .element import Element
19 |
20 | # Center
21 | ########
22 |
23 |
24 | class ChoiceList(Element):
25 |
26 | def choose(self, index: int):
27 | assert 1 <= index <= 6, "Choice index must be in [1, 6]"
28 | self.client.finger_touch(*self.positions[index])
29 |
30 |
31 | class Suggestions(Element):
32 |
33 | def choose(self, index: int):
34 | assert 1 <= index <= 4, "Suggestion index must be in [1, 4]"
35 | self.client.finger_touch(*self.positions[index])
36 |
37 |
38 | # Keyboards
39 | class _GenericKeyboard(Element):
40 |
41 | def write(self, word: str):
42 | for letter in word.lower():
43 | logging.info("Writing letter '%s', position '%s'", letter, self.positions[letter])
44 | self.client.finger_touch(*self.positions[letter])
45 |
46 | def back(self):
47 | self.client.finger_touch(*self.positions["back"])
48 |
49 |
50 | class LetterOnlyKeyboard(_GenericKeyboard):
51 | pass
52 |
53 |
54 | class _FullKeyboard(_GenericKeyboard):
55 |
56 | def change_layout(self):
57 | self.client.finger_touch(*self.positions["change_layout"])
58 |
59 |
60 | class FullKeyboardLetters(_FullKeyboard):
61 |
62 | def change_case(self):
63 | self.client.finger_touch(*self.positions["change_case"])
64 |
65 |
66 | class _FullKeyboardSpecialCharacters(_FullKeyboard):
67 |
68 | def more_specials(self):
69 | self.client.finger_touch(*self.positions["more_specials"])
70 |
71 |
72 | class FullKeyboardSpecialCharacters1(_FullKeyboardSpecialCharacters):
73 | pass
74 |
75 |
76 | class FullKeyboardSpecialCharacters2(_FullKeyboardSpecialCharacters):
77 | pass
78 |
79 |
80 | class _TappableElement(Element):
81 |
82 | def tap(self):
83 | self.client.finger_touch(*self.positions)
84 |
85 |
86 | # Center Info
87 | class TappableCenter(_TappableElement):
88 | pass
89 |
90 |
91 | # Headers
92 | #########
93 | class RightHeader(_TappableElement):
94 | pass
95 |
96 |
97 | ExitHeader = RightHeader
98 | InfoHeader = RightHeader
99 |
100 |
101 | class LeftHeader(_TappableElement):
102 | pass
103 |
104 |
105 | NavigationHeader = LeftHeader
106 |
107 |
108 | # Footers
109 | #########
110 | class CenteredFooter(_TappableElement):
111 | pass
112 |
113 |
114 | class LeftFooter(_TappableElement):
115 | pass
116 |
117 |
118 | class CancelFooter(_TappableElement):
119 | pass
120 |
121 |
122 | ExitFooter = CancelFooter
123 | InfoFooter = CancelFooter
124 | SettingsFooter = CancelFooter
125 |
--------------------------------------------------------------------------------
/doc/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | # import os
14 | # import sys
15 | # sys.path.insert(0, os.path.abspath('.'))
16 |
17 |
18 | # -- Project information -----------------------------------------------------
19 |
20 | project = 'Ragger'
21 | copyright = '2022, bow'
22 | author = 'bow'
23 |
24 |
25 | # -- General configuration ---------------------------------------------------
26 |
27 | html_favicon = "images/ragger.png"
28 |
29 | # Add any Sphinx extension module names here, as strings. They can be
30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
31 | # ones.
32 | extensions = [
33 | 'sphinx.ext.autodoc',
34 | 'sphinx_copybutton',
35 | 'sphinxcontrib.images',
36 | ]
37 |
38 | images_config = {
39 | "default_image_width": "90%",
40 | }
41 |
42 | # Add any paths that contain templates here, relative to this directory.
43 | templates_path = ['_templates']
44 |
45 | html_sidebars = { '**': ['globaltoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'] }
46 |
47 | # List of patterns, relative to source directory, that match files and
48 | # directories to ignore when looking for source files.
49 | # This pattern also affects html_static_path and html_extra_path.
50 | exclude_patterns = ['build', 'Thumbs.db', '.DS_Store']
51 |
52 | import os
53 | import sys
54 |
55 | sys.path.insert(0, os.path.abspath('../src/'))
56 |
57 | from importlib.metadata import version as get_version
58 | release = get_version('ragger')
59 | version = '.'.join(release.split('.')[:2])
60 |
61 | ## Autodoc conf ##
62 |
63 | # Do not skip __init__ methods by default
64 | def skip(app, what, name, obj, would_skip, options):
65 | if name == "__init__":
66 | return False
67 | return would_skip
68 |
69 | # Remove every module documentation string.
70 | # Prevents to integrate the Licence when using automodule.
71 | # It is possible to limit the impacted module by filtering with the 'name'
72 | # argument:
73 | # `if what == "module" and name in ["ragger.firmware.stax.layouts", ...]:`
74 | def remove_module_docstring(app, what, name, obj, options, lines):
75 | if what == "module":
76 | del lines[:]
77 |
78 | ## Setup ##
79 |
80 | def setup(app):
81 | app.connect("autodoc-process-docstring", remove_module_docstring)
82 | app.connect("autodoc-skip-member", skip)
83 |
84 | # -- Options for HTML output -------------------------------------------------
85 |
86 | # The theme to use for HTML and HTML Help pages. See the documentation for
87 | # a list of builtin themes.
88 | #
89 | html_theme = 'sphinx_rtd_theme'
90 |
91 | # Add any paths that contain custom static files (such as style sheets) here,
92 | # relative to this directory. They are copied after the builtin static files,
93 | # so a file named "default.css" will overwrite the builtin "default.css".
94 | html_static_path = ['_static']
95 |
--------------------------------------------------------------------------------
/src/ragger/conftest/configuration.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import Optional, List
3 |
4 |
5 | @dataclass
6 | class OptionalOptions:
7 | APP_NAME: str
8 | MAIN_APP_DIR: Optional[str]
9 | SIDELOADED_APPS: dict
10 | SIDELOADED_APPS_DIR: Optional[str]
11 | BACKEND_SCOPE: str
12 | CUSTOM_SEED: str
13 | ALLOWED_SETUPS: List[str]
14 |
15 |
16 | OPTIONAL = OptionalOptions(
17 | # Use this parameter if you want physical Ragger backends (LedgerWallet and LedgerComm) to start
18 | # your application from the Dashboard at test start.
19 | APP_NAME=str(),
20 |
21 | # If not None, the application being tested with Ragger should be loaded as a library and not as
22 | # a standalone application. This parameter points to the repository holding the "main app", i.e
23 | # the application started from the Dashboard, which will then use the "local app" as a library.
24 | # Example use case: your application is an Ethereum plugin, which will be used as a library by
25 | # the Ethereum application.
26 | # MAIN_APP_DIR is the directory where the Ethereum application is cloned.
27 | #
28 | # example: configuration.OPTIONAL.MAIN_APP_DIR = "tests/.test_dependencies/"
29 | # Speculos will then start "tests/.test_dependencies/ethereum/build//bin/app.elf"
30 | # There must be exactly one application cloned inside this directory.
31 | MAIN_APP_DIR=None,
32 |
33 | # Deprecated
34 | SIDELOADED_APPS=dict(),
35 |
36 | # Relative path to the directory that will store library applications needed for the test.
37 | # They will be sideloaded by Speculos and can then be called by the main application. This
38 | # emulates the applications being installed on the device alongside the main application.
39 | #
40 | # example: configuration.OPTIONAL.SIDELOADED_APPS_DIR = "tests/.test_dependencies/libraries"
41 | # Speculos will then sideload "tests/.test_dependencies/libraries/*/build//bin/app.elf"
42 | SIDELOADED_APPS_DIR=None,
43 |
44 | # As the backend instantiation may take some time, Ragger supports multiple backend scopes.
45 | # You can choose to share the backend instance between {session / module / class / function}
46 | # When using "session" all your tests will share a single backend instance (faster)
47 | # When using "function" each test will have its independent backend instance (no collusion)
48 | BACKEND_SCOPE="class",
49 |
50 | # Use this parameter if you want speculos to use a custom seed instead of the default one.
51 | # This would result in speculos being launched with --seed
52 | # If a seed is provided through the "--seed" pytest command line option, it will override this one.
53 | # /!\ DO NOT USE SEEDS WITH REAL FUNDS /!\
54 | CUSTOM_SEED=str(),
55 |
56 | # /!\ DEPRECATED /!\
57 | # Use this parameter if you want ragger to handle running different test suites depending on setup
58 | # Useful when some tests need certain build options and other tests need other build options, or a
59 | # different Speculos command line
60 | # Adding a setup will allow you to decorate your tests with it using the following syntax
61 | # @pytest.mark.needs_setup('')
62 | # And run marked tests and only them using the --setup
63 | #
64 | # "default" setup is always allowed, all tests without explicit decoration depend on default
65 | # and the --setup option defaults to "default"
66 | # /!\ DEPRECATED /!\
67 | ALLOWED_SETUPS=["default"],
68 | )
69 |
--------------------------------------------------------------------------------
/src/ragger/firmware/touch/use_cases.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from .element import Element
17 |
18 |
19 | class UseCaseHome(Element):
20 |
21 | def info(self):
22 | self.client.finger_touch(*self.positions["info"])
23 |
24 | def settings(self):
25 | self.client.finger_touch(*self.positions["settings"])
26 |
27 | def quit(self):
28 | self.client.finger_touch(*self.positions["quit"])
29 |
30 |
31 | class UseCaseHomeExt(UseCaseHome):
32 |
33 | def action(self):
34 | self.client.finger_touch(*self.positions["action"])
35 |
36 |
37 | class UseCaseSettings(Element):
38 |
39 | def single_page_exit(self):
40 | self.client.finger_touch(*self.positions["single_page_exit"])
41 |
42 | def multi_page_exit(self):
43 | self.client.finger_touch(*self.positions["multi_page_exit"])
44 |
45 | def previous(self):
46 | self.client.finger_touch(*self.positions["previous"])
47 |
48 | def next(self):
49 | self.client.finger_touch(*self.positions["next"])
50 |
51 |
52 | class UseCaseSubSettings(Element):
53 |
54 | def exit(self):
55 | self.client.finger_touch(*self.positions["exit"])
56 |
57 | def previous(self):
58 | self.client.finger_touch(*self.positions["previous"])
59 |
60 | def next(self):
61 | self.client.finger_touch(*self.positions["next"])
62 |
63 |
64 | class UseCaseChoice(Element):
65 |
66 | def confirm(self):
67 | self.client.finger_touch(*self.positions["confirm"])
68 |
69 | def reject(self):
70 | self.client.finger_touch(*self.positions["reject"])
71 |
72 |
73 | class UseCaseStatus(Element):
74 |
75 | def dismiss(self):
76 | self.client.finger_touch(*self.positions["dismiss"])
77 |
78 |
79 | class UseCaseReview(Element):
80 |
81 | def tap(self):
82 | self.client.finger_touch(*self.positions["tap"])
83 |
84 | def previous(self):
85 | self.client.finger_touch(*self.positions["previous"])
86 |
87 | def reject(self):
88 | self.client.finger_touch(*self.positions["reject"])
89 |
90 | def confirm(self):
91 | # SDK needs at least 2.4s for long press.
92 | self.client.finger_touch(*self.positions["confirm"], 3.0)
93 |
94 |
95 | class UseCaseViewDetails(Element):
96 |
97 | def exit(self):
98 | self.client.finger_touch(*self.positions["exit"])
99 |
100 | def previous(self):
101 | self.client.finger_touch(*self.positions["previous"])
102 |
103 | def next(self):
104 | self.client.finger_touch(*self.positions["next"])
105 |
106 |
107 | class UseCaseAddressConfirmation(Element):
108 |
109 | def tap(self):
110 | self.client.finger_touch(*self.positions["tap"])
111 |
112 | def exit_qr(self):
113 | self.client.finger_touch(*self.positions["exit_qr"])
114 |
115 | def confirm(self):
116 | self.client.finger_touch(*self.positions["confirm"])
117 |
118 | def cancel(self):
119 | self.client.finger_touch(*self.positions["cancel"])
120 |
--------------------------------------------------------------------------------
/template/usage.md:
--------------------------------------------------------------------------------
1 | # How to use the Ragger test framework
2 |
3 | This framework allows testing the application on the Speculos emulator or on a real device using LedgerComm or LedgerWallet
4 |
5 |
6 | ## Quickly get started with Ragger and Speculos
7 |
8 | ### Install ragger and dependencies
9 |
10 | ```
11 | sudo apt-get update && sudo apt-get install qemu-user-static
12 | pip install .[speculos]
13 | ```
14 |
15 | ### Compile the application
16 |
17 | The application to test must be compiled for all required devices.
18 | You can use for this the container `ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite`:
19 | ```
20 | docker pull ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite:latest
21 | cd # replace with the name of your app, (eg boilerplate)
22 | docker run --user "$(id -u)":"$(id -g)" --rm -ti -v "$(realpath .):/app" --privileged -v "/dev/bus/usb:/dev/bus/usb" ledger-app-builder-lite:latest
23 | make clean && make BOLOS_SDK=$_SDK # replace with one of [NANOS, NANOX, NANOSP, STAX]
24 | exit
25 | ```
26 |
27 | ### Run a simple test using the Speculos emulator
28 |
29 | You can use the following command to get your first experience with Ragger and Speculos
30 | ```
31 | pytest -v --tb=short --device nanox --display
32 | ```
33 | Or you can refer to the section `Available pytest options` to configure the options you want to use
34 |
35 |
36 | ### Run a simple test using a real device
37 |
38 | The application to test must be loaded and started on a Ledger device plugged in USB.
39 | You can use for this the container `ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite`:
40 | ```
41 | docker pull ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite:latest
42 | cd app-/ # replace with the name of your app, (eg boilerplate)
43 | docker run --user "$(id -u)":"$(id -g)" --rm -ti -v "$(realpath .):/app" --privileged -v "/dev/bus/usb:/dev/bus/usb" ledger-app-builder-lite:latest
44 | make clean && make BOLOS_SDK=$_SDK load # replace with one of [NANOS, NANOX, NANOSP, STAX]
45 | exit
46 | ```
47 |
48 | You can use the following command to get your first experience with Ragger and Ledgerwallet on a NANOX.
49 | Make sure that the device is plugged, unlocked, and that the tested application is open.
50 | ```
51 | pytest -v --tb=short --device nanox --backend ledgerwallet
52 | ```
53 | Or you can refer to the section `Available pytest options` to configure the options you want to use
54 |
55 |
56 | ## Available pytest options
57 |
58 | Standard useful pytest options
59 | ```
60 | -v formats the test summary in a readable way
61 | -s enable logs for successful tests, on Speculos it will enable app logs if compiled with DEBUG=1
62 | -k only run the tests that contain in their names
63 | --tb=short in case of errors, formats the test traceback in a readable way
64 | ```
65 |
66 | Custom pytest options
67 | ```
68 | --device run the test on the specified device [nanos,nanox,nanosp,stax,all]. This parameter is mandatory
69 | --backend run the tests against the backend [speculos, ledgercomm, ledgerwallet]. Speculos is the default
70 | --display on Speculos, enables the display of the app screen using QT
71 | --golden_run on Speculos, screen comparison functions will save the current screen instead of comparing
72 | --log_apdu_file log all apdu exchanges to the file in parameter. The previous file content is erased
73 | --seed on Speculos, use the seed (mnemonic) provided.
74 | ```
75 |
--------------------------------------------------------------------------------
/src/ragger/backend/stub.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2023 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from contextlib import contextmanager
17 | from pathlib import Path
18 | from types import TracebackType
19 | from typing import Any, Generator, Optional, Type
20 |
21 | from ragger.error import StatusWords
22 | from ragger.utils.structs import Crop, RAPDU
23 | from .interface import BackendInterface
24 |
25 |
26 | class StubBackend(BackendInterface):
27 | """
28 | A stub implementation of the BackendInterface.
29 |
30 | Could be used everywhere a stub is needed, for instance when injecting a backend of various
31 | type in a broader service, which could use the backend, or not, without cluttering the code
32 | flow with conditional branches.
33 | """
34 |
35 | def __enter__(self):
36 | pass
37 |
38 | def __exit__(self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException],
39 | exc_tb: Optional[TracebackType]):
40 | pass
41 |
42 | def handle_usb_reset(self) -> None:
43 | pass
44 |
45 | def send_raw(self, data: bytes = b""):
46 | pass
47 |
48 | def receive(self) -> RAPDU:
49 | return RAPDU(StatusWords.SWO_SUCCESS, b"")
50 |
51 | def exchange_raw(self, data: bytes = b"", tick_timeout: int = 5 * 60 * 10) -> RAPDU:
52 | return RAPDU(StatusWords.SWO_SUCCESS, b"")
53 |
54 | @contextmanager
55 | def exchange_async_raw(self, data: bytes = b"") -> Generator[None, None, None]:
56 | yield
57 |
58 | def right_click(self) -> None:
59 | pass
60 |
61 | def left_click(self) -> None:
62 | pass
63 |
64 | def both_click(self) -> None:
65 | pass
66 |
67 | def finger_touch(self, x: int = 0, y: int = 0, delay: float = 0.5) -> None:
68 | pass
69 |
70 | def finger_swipe(self,
71 | x: int = 0,
72 | y: int = 0,
73 | direction: str = "left",
74 | delay: float = 0.5) -> None:
75 | pass
76 |
77 | def compare_screen_with_snapshot(self,
78 | golden_snap_path: Path,
79 | crop: Optional[Crop] = None,
80 | tmp_snap_path: Optional[Path] = None,
81 | golden_run: bool = False) -> bool:
82 | return True
83 |
84 | def wait_for_home_screen(self, timeout: float = 10.0) -> None:
85 | return
86 |
87 | def wait_for_screen_change(self, timeout: float = 10.0) -> None:
88 | pass
89 |
90 | def wait_for_text_on_screen(self, text: str, timeout: float = 10.0) -> None:
91 | return
92 |
93 | def wait_for_text_not_on_screen(self, text: str, timeout: float = 10.0) -> None:
94 | return
95 |
96 | def compare_screen_with_text(self, text: str) -> bool:
97 | return True
98 |
99 | def get_current_screen_content(self) -> Any:
100 | pass
101 |
102 | def pause_ticker(self) -> None:
103 | pass
104 |
105 | def resume_ticker(self) -> None:
106 | pass
107 |
108 | def send_tick(self) -> None:
109 | pass
110 |
--------------------------------------------------------------------------------
/src/ragger/backend/ledgerwallet.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from contextlib import contextmanager
17 | from time import sleep
18 | from typing import Generator, Optional
19 |
20 | from ledgered.devices import Device
21 | from ledgerwallet.client import LedgerClient, CommException
22 | from ledgerwallet.transport import HidDevice
23 |
24 | from ragger.utils import RAPDU
25 | from ragger.error import ExceptionRAPDU
26 | from .physical_backend import PhysicalBackend
27 |
28 |
29 | def raise_policy_enforcer(function):
30 |
31 | def decoration(self: 'LedgerWalletBackend', *args, **kwargs) -> RAPDU:
32 | # Catch backend raise
33 | try:
34 | rapdu: RAPDU = function(self, *args, **kwargs)
35 | except CommException as error:
36 | rapdu = RAPDU(error.sw, error.data)
37 |
38 | self.apdu_logger.info("<= %s%4x", rapdu.data.hex(), rapdu.status)
39 |
40 | if self.is_raise_required(rapdu):
41 | raise ExceptionRAPDU(rapdu.status, rapdu.data)
42 | else:
43 | return rapdu
44 |
45 | return decoration
46 |
47 |
48 | class LedgerWalletBackend(PhysicalBackend):
49 |
50 | def __init__(self, device: Device, *args, with_gui: bool = False, **kwargs):
51 | super().__init__(device, *args, with_gui=with_gui, **kwargs)
52 | self._client: Optional[LedgerClient] = None
53 |
54 | def __enter__(self) -> "LedgerWalletBackend":
55 | self.logger.info(f"Starting {self.__class__.__name__} stream")
56 | try:
57 | self._client = LedgerClient()
58 | except Exception:
59 | # Give some time for the USB stack to power up and to be enumerated
60 | # Might be needed in successive tests where app is exited at the end of the test
61 | sleep(1)
62 | self._client = LedgerClient()
63 | return self
64 |
65 | def __exit__(self, *args):
66 | super().__exit__(*args)
67 | assert self._client is not None
68 | self._client.close()
69 |
70 | def handle_usb_reset(self) -> None:
71 | self.logger.info(f"Re-starting {self.__class__.__name__} stream")
72 | self.__exit__()
73 | self.__enter__()
74 |
75 | def send_raw(self, data: bytes = b"") -> None:
76 | self.apdu_logger.info("=> %s", data.hex())
77 | assert self._client is not None
78 | self._client.device.write(data)
79 |
80 | @raise_policy_enforcer
81 | def receive(self) -> RAPDU:
82 | assert self._client is not None
83 | # TODO: remove this checked with LedgerWallet > 0.1.3
84 | if isinstance(self._client.device, HidDevice):
85 | raw_result = self._client.device.read(1000)
86 | else:
87 | raw_result = self._client.device.read()
88 | status, payload = int.from_bytes(raw_result[-2:], "big"), raw_result[:-2] or b""
89 | result = RAPDU(status, payload)
90 | return result
91 |
92 | @raise_policy_enforcer
93 | def exchange_raw(self, data: bytes = b"", tick_timeout: int = 0) -> RAPDU:
94 | self.apdu_logger.info("=> %s", data.hex())
95 | assert self._client is not None
96 | raw_result = self._client.raw_exchange(data)
97 | result = RAPDU(int.from_bytes(raw_result[-2:], "big"), raw_result[:-2] or b"")
98 | return result
99 |
100 | @contextmanager
101 | def exchange_async_raw(self, data: bytes = b"") -> Generator[bool, None, None]:
102 | self.send_raw(data)
103 | yield True
104 | self._last_async_response = self.receive()
105 |
--------------------------------------------------------------------------------
/src/ragger/backend/ledgercomm.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from contextlib import contextmanager
17 | from pathlib import Path
18 | from time import sleep
19 | from typing import Optional, Generator
20 |
21 | from ledgercomm import Transport
22 | from ledgered.devices import Device
23 |
24 | from ragger.utils import RAPDU
25 | from ragger.error import ExceptionRAPDU
26 | from .physical_backend import PhysicalBackend
27 |
28 |
29 | def raise_policy_enforcer(function):
30 |
31 | def decoration(self: 'LedgerCommBackend', *args, **kwargs) -> RAPDU:
32 | rapdu: RAPDU = function(self, *args, **kwargs)
33 |
34 | self.apdu_logger.info("<= %s%4x", rapdu.data.hex(), rapdu.status)
35 |
36 | if self.is_raise_required(rapdu):
37 | raise ExceptionRAPDU(rapdu.status, rapdu.data)
38 | else:
39 | return rapdu
40 |
41 | return decoration
42 |
43 |
44 | class LedgerCommBackend(PhysicalBackend):
45 |
46 | def __init__(self,
47 | device: Device,
48 | *args,
49 | host: str = "127.0.0.1",
50 | port: int = 9999,
51 | interface: str = 'hid',
52 | log_apdu_file: Optional[Path] = None,
53 | with_gui: bool = False,
54 | **kwargs):
55 | super().__init__(device, *args, log_apdu_file=log_apdu_file, with_gui=with_gui, **kwargs)
56 | self._host = host
57 | self._port = port
58 | self._client: Optional[Transport] = None
59 | kwargs['interface'] = interface
60 | self._args = (args, kwargs)
61 |
62 | def __enter__(self) -> "LedgerCommBackend":
63 | self.logger.info(f"Starting {self.__class__.__name__} stream")
64 |
65 | try:
66 | self._client = Transport(server=self._host,
67 | port=self._port,
68 | *self._args[0],
69 | **self._args[1])
70 | except Exception:
71 | # Give some time for the USB stack to power up and to be enumerated
72 | # Might be needed in successive tests where app is exited at the end of the test
73 | sleep(1)
74 | self._client = Transport(server=self._host,
75 | port=self._port,
76 | *self._args[0],
77 | **self._args[1])
78 | return self
79 |
80 | def __exit__(self, *args):
81 | super().__exit__(*args)
82 | assert self._client is not None
83 | self._client.close()
84 |
85 | def handle_usb_reset(self) -> None:
86 | self.logger.info(f"Re-starting {self.__class__.__name__} stream")
87 | self.__exit__()
88 | self.__enter__()
89 |
90 | def send_raw(self, data: bytes = b"") -> None:
91 | self.apdu_logger.info("=> %s", data.hex())
92 | assert self._client is not None
93 | self._client.send_raw(data)
94 |
95 | @raise_policy_enforcer
96 | def receive(self) -> RAPDU:
97 | assert self._client is not None
98 | result = RAPDU(*self._client.recv())
99 | return result
100 |
101 | @raise_policy_enforcer
102 | def exchange_raw(self, data: bytes = b"", tick_timeout: int = 0) -> RAPDU:
103 | self.apdu_logger.info("=> %s", data.hex())
104 | assert self._client is not None
105 | result = RAPDU(*self._client.exchange_raw(data))
106 | return result
107 |
108 | @contextmanager
109 | def exchange_async_raw(self, data: bytes = b"") -> Generator[bool, None, None]:
110 | self.send_raw(data)
111 | yield True
112 | self._last_async_response = self.receive()
113 |
--------------------------------------------------------------------------------
/tests/stubs.py:
--------------------------------------------------------------------------------
1 | from enum import IntEnum
2 | from io import BytesIO
3 | from multiprocessing import Process
4 | from pathlib import Path
5 |
6 | from flask import Flask, request
7 | from PIL import Image
8 | from requests import get
9 | from requests.exceptions import ConnectionError
10 |
11 |
12 | class APDUStatus(IntEnum):
13 | SUCCESS = 0x9000
14 | ERROR = 0x8000
15 |
16 |
17 | class EndPoint:
18 | ROOT = "01"
19 | APDU = "02"
20 | EVENTS = "03"
21 |
22 |
23 | class Events:
24 | back = [{"text": "Back", "x": 51, "y": 19}]
25 | info = [{
26 | "text": "Boilerplate App",
27 | "x": 20,
28 | "y": 3
29 | }, {
30 | "text": "(c) 2020 Ledger",
31 | "x": 26,
32 | "y": 17
33 | }]
34 | home = [{"text": "Boilerplate", "x": 41, "y": 3}, {"text": "is ready", "x": 41, "y": 17}]
35 | version = [{"text": "Version", "x": 43, "y": 3}, {"text": "1.0.1", "x": 52, "y": 17}]
36 | about = [{"text": "About", "x": 47, "y": 19}]
37 | indexed = [home, version, about, info, back]
38 |
39 |
40 | # can't use lambdas: Flask stores functions using their names (and lambdas have none, so they'll
41 | # override each other)
42 | # Returned value must be a JSON, embedding a 'data' field with an hexa string ending with 9000 for
43 | # success
44 | def root(*args):
45 | return {"data": EndPoint.ROOT + f"{APDUStatus.SUCCESS:x}"}
46 |
47 |
48 | def apdu(*args):
49 | apdu = bytes.fromhex(request.get_json().get("data", "00"))
50 | status = f"{APDUStatus.SUCCESS:x}" if apdu[0] == 0x00 else f"{APDUStatus.ERROR:x}"
51 | return {"data": EndPoint.APDU + status}
52 |
53 |
54 | class Actions:
55 |
56 | def __init__(self):
57 | self.idx = 0
58 |
59 | def button(self, *args):
60 | path = request.path
61 | if "right" in path:
62 | if self.idx == 2:
63 | self.idx = 0
64 | elif self.idx == 4:
65 | self.idx = 3
66 | else:
67 | self.idx = self.idx + 1
68 | elif "left" in path:
69 | if self.idx == 0:
70 | self.idx = 2
71 | elif self.idx == 3:
72 | self.idx = 4
73 | else:
74 | self.idx = self.idx - 1
75 | elif "both" in path:
76 | if self.idx == 2:
77 | self.idx = 3
78 | if self.idx == 4:
79 | self.idx = 0
80 |
81 | return {}
82 |
83 | def events(self, *args):
84 | return {"events": Events.indexed[self.idx]}, 200
85 |
86 | def screenshot(self, *args):
87 | path = Path(
88 | __file__).parent.resolve() / "snapshots/nanos/generic" / f"{str(self.idx).zfill(5)}.png"
89 | img_temp = Image.open(path)
90 | iobytes = BytesIO()
91 | img_temp.save(iobytes, format="PNG")
92 | return iobytes.getvalue(), 200
93 |
94 | def ticker(self, *args):
95 | return {}
96 |
97 |
98 | class SpeculosServerStub:
99 |
100 | def __init__(self):
101 | actions = Actions()
102 | self.app = Flask('stub')
103 | self.app.add_url_rule("/", view_func=root)
104 | self.app.add_url_rule("/apdu", methods=["GET", "POST"], view_func=apdu)
105 | self.app.add_url_rule("/button/right", methods=["GET", "POST"], view_func=actions.button)
106 | self.app.add_url_rule("/button/left", methods=["GET", "POST"], view_func=actions.button)
107 | self.app.add_url_rule("/button/both", methods=["GET", "POST"], view_func=actions.button)
108 | self.app.add_url_rule("/events", view_func=actions.events)
109 | self.app.add_url_rule("/screenshot", methods=["GET"], view_func=actions.screenshot)
110 | self.app.add_url_rule("/ticker", methods=["GET", "POST"], view_func=actions.ticker)
111 | self.process = None
112 |
113 | def __enter__(self):
114 | self.process = Process(target=self.app.run)
115 | self.process.start()
116 | started = False
117 | while not started:
118 | try:
119 | get("http://127.0.0.1:5000")
120 | started = True
121 | except ConnectionError:
122 | pass
123 |
124 | def __exit__(self, *args, **kwargs):
125 | self.process.terminate()
126 | self.process.join()
127 | self.process.close()
128 | self.process = None
129 |
--------------------------------------------------------------------------------
/.github/workflows/build_and_tests.yml:
--------------------------------------------------------------------------------
1 | name: Build, test and deploy Ragger
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | tags:
7 | - '*'
8 | branches:
9 | - master
10 | - develop
11 | pull_request:
12 | branches:
13 | - master
14 | - develop
15 |
16 | # Cancel previous runs on this reference
17 | concurrency:
18 | group: ${{ github.workflow }}-${{ github.ref }}
19 | cancel-in-progress: true
20 |
21 | jobs:
22 |
23 | build_boilerplate_application:
24 | name: Build boilerplate application using the reusable workflow
25 | uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_build.yml@v1
26 | with:
27 | app_repository: LedgerHQ/app-boilerplate
28 | app_branch_name: master
29 | upload_app_binaries_artifact: boilerplate_binaries
30 |
31 | build_boilerplate_application_nanos:
32 | name: Build boilerplate application for Nanos S using the reusable workflow
33 | uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_build.yml@v1
34 | with:
35 | app_repository: LedgerHQ/app-boilerplate
36 | app_branch_name: nanos_baseline
37 | upload_app_binaries_artifact: boilerplate_binaries_nanos
38 |
39 | prepare_matrix:
40 | name: Prepare matrix from TOML file
41 | runs-on: ubuntu-latest
42 | outputs:
43 | devices: ${{ steps.get_devices.outputs.devices }}
44 | env:
45 | APP_MANIFEST: "ledger_app.toml"
46 |
47 | steps:
48 | - name: Clone
49 | uses: actions/checkout@v4
50 | - name: Install ledgered
51 | run: pip install ledgered
52 | - name: Get devices from TOML
53 | id: get_devices
54 | run: |
55 | # get device list as a single line json formatted value
56 | compatible_devices="$(ledger-manifest --output-devices "${APP_MANIFEST}" -j | jq -rc '.devices')"
57 |
58 | echo "devices=${compatible_devices}" | sed 's/+/p/' >> "${GITHUB_OUTPUT}"
59 |
60 | build_install_test:
61 | name: Install and test the library
62 | needs: [prepare_matrix, build_boilerplate_application, build_boilerplate_application_nanos]
63 | runs-on: ubuntu-latest
64 | strategy:
65 | fail-fast: false
66 | matrix:
67 | python_version: ['3.9', '3.10', '3.11', '3.12', '3.13']
68 | device: ${{ fromJson(needs.prepare_matrix.outputs.devices) }}
69 |
70 | steps:
71 | - name: Clone
72 | uses: actions/checkout@v4
73 | with:
74 | fetch-depth: 0
75 |
76 | - name: Setup Python version
77 | uses: actions/setup-python@v5
78 | with:
79 | python-version: ${{ matrix.python_version }}
80 |
81 | - name: Speculos dependencies
82 | run: sudo apt-get update && sudo apt-get install -y qemu-user-static tesseract-ocr libtesseract-dev python3-pyqt6
83 |
84 | - name: Build & install
85 | run: |
86 | pip install -U .[tests,all_backends]
87 | pip install -U "click>=8"
88 |
89 | - name: Install speculos for Nanos
90 | if: ${{ matrix.device == 'nanos' }}
91 | run: |
92 | pip install -U speculos==v0.25.5
93 |
94 | - name: Download app binaries
95 | if: ${{ matrix.device != 'nanos' }}
96 | uses: actions/download-artifact@v4
97 | with:
98 | name: boilerplate_binaries
99 | path: ./build/
100 |
101 | - name: Download app binaries for Nano S
102 | if: ${{ matrix.device == 'nanos' }}
103 | uses: actions/download-artifact@v4
104 | with:
105 | name: boilerplate_binaries_nanos
106 | path: ./build/
107 |
108 | - name: Check the downloaded files
109 | run: tree .
110 |
111 | - name: Run unit tests and generate coverage
112 | run: pytest -v --tb=short tests/unit --cov ragger --cov-report xml
113 |
114 | - name: Run functional tests and generate coverage
115 | run: pytest -v --tb=short tests/functional --cov ragger --cov-report xml --cov-append --device ${{ matrix.device }}
116 |
117 | - name: Upload to codecov.io
118 | uses: codecov/codecov-action@v5
119 | env:
120 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
121 | with:
122 | name: codecov-ragger
123 | flags: ${{ matrix.device }}-py${{ matrix.python_version }}
124 |
125 | package_and_deploy:
126 | name: Build and deploy the Ragger Python package
127 | needs: [build_install_test]
128 | uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_pypi_deployment.yml@v1
129 | with:
130 | package_name: ragger
131 | publish: ${{ startsWith(github.ref, 'refs/tags/') }}
132 | secrets:
133 | pypi_token: ${{ secrets.PYPI_PUBLIC_API_TOKEN }}
134 |
--------------------------------------------------------------------------------
/src/ragger/navigator/touch_navigator.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | from time import sleep
17 | from typing import Callable, Dict
18 | from ledgered.devices import Device
19 |
20 | from ragger.backend import BackendInterface
21 | from ragger.firmware.touch import FullScreen
22 | from .navigator import Navigator
23 | from .instruction import BaseNavInsID, NavInsID
24 |
25 |
26 | class TouchNavigator(Navigator):
27 |
28 | def __init__(self, backend: BackendInterface, device: Device, golden_run: bool = False):
29 | if not device.touchable:
30 | raise ValueError(f"'{self.__class__.__name__}' only works with touchable devices")
31 | screen = FullScreen(backend, device)
32 | callbacks: Dict[BaseNavInsID, Callable] = {
33 | NavInsID.WAIT: sleep,
34 | NavInsID.WAIT_FOR_SCREEN_CHANGE: backend.wait_for_screen_change,
35 | NavInsID.WAIT_FOR_HOME_SCREEN: backend.wait_for_home_screen,
36 | NavInsID.WAIT_FOR_TEXT_ON_SCREEN: backend.wait_for_text_on_screen,
37 | NavInsID.WAIT_FOR_TEXT_NOT_ON_SCREEN: backend.wait_for_text_not_on_screen,
38 | NavInsID.TOUCH: backend.finger_touch,
39 | NavInsID.SWIPE: backend.finger_swipe,
40 | NavInsID.SWIPE_CENTER_TO_LEFT: screen.center.swipe_left,
41 | NavInsID.SWIPE_CENTER_TO_RIGHT: screen.center.swipe_right,
42 | # possible headers
43 | NavInsID.RIGHT_HEADER_TAP: screen.right_header.tap,
44 | NavInsID.EXIT_HEADER_TAP: screen.exit_header.tap,
45 | NavInsID.INFO_HEADER_TAP: screen.info_header.tap,
46 | NavInsID.LEFT_HEADER_TAP: screen.left_header.tap,
47 | NavInsID.NAVIGATION_HEADER_TAP: screen.navigation_header.tap,
48 | # possible centers
49 | NavInsID.CHOICE_CHOOSE: screen.choice_list.choose,
50 | NavInsID.SUGGESTION_CHOOSE: screen.suggestions.choose,
51 | NavInsID.TAPPABLE_CENTER_TAP: screen.tappable_center.tap,
52 | NavInsID.KB_LETTER_ONLY_WRITE: screen.letter_only_keyboard.write,
53 | NavInsID.KB_LETTERS_WRITE: screen.full_keyboard_letters.write,
54 | NavInsID.KB_SPECIAL_CHAR_1_WRITE: screen.full_keyboard_special_characters_1.write,
55 | NavInsID.KB_SPECIAL_CHAR_2_WRITE: screen.full_keyboard_special_characters_2.write,
56 | # possible footers
57 | NavInsID.CENTERED_FOOTER_TAP: screen.centered_footer.tap,
58 | NavInsID.CANCEL_FOOTER_TAP: screen.cancel_footer.tap,
59 | NavInsID.EXIT_FOOTER_TAP: screen.exit_footer.tap,
60 | NavInsID.INFO_FOOTER_TAP: screen.info_footer.tap,
61 | # possible use cases
62 | NavInsID.USE_CASE_HOME_INFO: screen.home.info,
63 | NavInsID.USE_CASE_HOME_SETTINGS: screen.home.settings,
64 | NavInsID.USE_CASE_HOME_QUIT: screen.home.quit,
65 | NavInsID.USE_CASE_SETTINGS_SINGLE_PAGE_EXIT: screen.settings.single_page_exit,
66 | NavInsID.USE_CASE_SETTINGS_MULTI_PAGE_EXIT: screen.settings.multi_page_exit,
67 | NavInsID.USE_CASE_SETTINGS_PREVIOUS: screen.settings.previous,
68 | NavInsID.USE_CASE_SETTINGS_NEXT: screen.settings.next,
69 | NavInsID.USE_CASE_SUB_SETTINGS_EXIT: screen.sub_settings.exit,
70 | NavInsID.USE_CASE_SUB_SETTINGS_PREVIOUS: screen.sub_settings.previous,
71 | NavInsID.USE_CASE_SUB_SETTINGS_NEXT: screen.sub_settings.next,
72 | NavInsID.USE_CASE_CHOICE_CONFIRM: screen.choice.confirm,
73 | NavInsID.USE_CASE_CHOICE_REJECT: screen.choice.reject,
74 | NavInsID.USE_CASE_STATUS_DISMISS: screen.status.dismiss,
75 | NavInsID.USE_CASE_REVIEW_TAP: screen.review.tap,
76 | NavInsID.USE_CASE_REVIEW_NEXT: screen.center.swipe_left,
77 | NavInsID.USE_CASE_REVIEW_PREVIOUS: screen.review.previous,
78 | NavInsID.USE_CASE_REVIEW_REJECT: screen.review.reject,
79 | NavInsID.USE_CASE_REVIEW_CONFIRM: screen.review.confirm,
80 | NavInsID.USE_CASE_VIEW_DETAILS_EXIT: screen.view_details.exit,
81 | NavInsID.USE_CASE_VIEW_DETAILS_PREVIOUS: screen.view_details.previous,
82 | NavInsID.USE_CASE_VIEW_DETAILS_NEXT: screen.view_details.next,
83 | NavInsID.USE_CASE_ADDRESS_CONFIRMATION_TAP: screen.address_confirmation.tap,
84 | NavInsID.USE_CASE_ADDRESS_CONFIRMATION_EXIT_QR: screen.address_confirmation.exit_qr,
85 | NavInsID.USE_CASE_ADDRESS_CONFIRMATION_CONFIRM: screen.address_confirmation.confirm,
86 | NavInsID.USE_CASE_ADDRESS_CONFIRMATION_CANCEL: screen.address_confirmation.cancel,
87 | }
88 | super().__init__(backend, device, callbacks, golden_run)
89 |
--------------------------------------------------------------------------------
/tests/unit/backend/test_interface.py:
--------------------------------------------------------------------------------
1 | import struct
2 | import tempfile
3 | from ledgered.devices import DeviceType, Devices
4 | from pathlib import Path
5 | from unittest import TestCase
6 | from unittest.mock import MagicMock
7 |
8 | from ragger.error import ExceptionRAPDU
9 | from ragger.backend import BackendInterface
10 | from ragger.backend import RaisePolicy
11 |
12 |
13 | class DummyBackend(BackendInterface):
14 |
15 | def __init__(self, *args, **kwargs):
16 | super().__init__(*args, **kwargs)
17 | self.mock = MagicMock()
18 |
19 | def __enter__(self):
20 | yield self
21 |
22 | def __exit__(self, *args, **kwargs):
23 | pass
24 |
25 | def both_click(self):
26 | self.mock.both_click()
27 |
28 | def right_click(self):
29 | self.mock.right_click()
30 |
31 | def left_click(self):
32 | self.mock.left_click()
33 |
34 | def exchange_async_raw(self, *args, **kwargs):
35 | return self.mock.exchange_async_raw(*args, **kwargs)
36 |
37 | def exchange_raw(self, data: bytes, tick_timeout: int):
38 | return self.mock.exchange_raw(data, tick_timeout)
39 |
40 | def receive(self):
41 | return self.mock.receive()
42 |
43 | def send_raw(self, *args, **kwargs):
44 | self.mock.send_raw(*args, **kwargs)
45 |
46 | def finger_touch(self, *args, **kwargs):
47 | self.mock.finger_touch(*args, **kwargs)
48 |
49 | def finger_swipe(self, *args, **kwargs):
50 | self.mock.finger_touch(*args, **kwargs)
51 |
52 | def compare_screen_with_snapshot(self, snap_path, crop=None) -> bool:
53 | return self.mock.compare_screen_with_snapshot()
54 |
55 | def save_screen_snapshot(self, path) -> None:
56 | self.mock.save_screen_snapshot()
57 |
58 | def wait_for_home_screen(self, timeout: float = 10.0, context: list = []):
59 | return self.mock.wait_for_home_screen()
60 |
61 | def wait_for_screen_change(self, timeout: float = 10.0, context: list = []):
62 | return self.mock.wait_for_screen_change()
63 |
64 | def wait_for_text_on_screen(self, text: str, timeout: float = 10.0) -> None:
65 | self.mock.wait_for_text_on_screen()
66 |
67 | def wait_for_text_not_on_screen(self, text: str, timeout: float = 10.0) -> None:
68 | self.mock.wait_for_text_not_on_screen()
69 |
70 | def compare_screen_with_text(self, text: str):
71 | return self.mock.compare_screen_with_text()
72 |
73 | def get_current_screen_content(self):
74 | return self.mock.get_current_screen_content()
75 |
76 | def pause_ticker(self) -> None:
77 | pass
78 |
79 | def resume_ticker(self) -> None:
80 | pass
81 |
82 | def send_tick(self) -> None:
83 | pass
84 |
85 |
86 | class TestBackendInterface(TestCase):
87 |
88 | def setUp(self):
89 | self.device = Devices.get_by_type(DeviceType.NANOS)
90 | self.errors = (ExceptionRAPDU(0x8888, "ERROR1"), ExceptionRAPDU(0x7777, "ERROR2"))
91 | self.valid_statuses = (0x9000, 0x9001, 0x9002)
92 | self.backend = DummyBackend(device=self.device)
93 |
94 | def test_init(self):
95 | self.assertEqual(self.backend.device, self.device)
96 | self.assertIsNone(self.backend.last_async_response)
97 |
98 | # Default value
99 | self.assertEqual(self.backend.raise_policy, RaisePolicy.RAISE_ALL_BUT_0x9000)
100 |
101 | def test_send(self):
102 | cla, ins, p1, p2 = 1, 2, 3, 4
103 | expected = struct.pack(">BBBBB", cla, ins, p1, p2, 0)
104 | self.assertFalse(self.backend.mock.send_raw.called)
105 | self.backend.send(cla, ins, p1, p2)
106 | self.assertTrue(self.backend.mock.send_raw.called)
107 | self.assertEqual(self.backend.mock.send_raw.call_args, ((expected, ), ))
108 |
109 | def test_exchange(self):
110 | cla, ins, p1, p2 = 1, 2, 3, 4
111 | expected = struct.pack(">BBBBB", cla, ins, p1, p2, 0)
112 | self.assertFalse(self.backend.mock.send_raw.called)
113 | result = self.backend.exchange(cla, ins, p1, p2)
114 | self.assertTrue(self.backend.mock.exchange_raw.called)
115 | self.assertEqual(self.backend.mock.exchange_raw.call_args, ((expected, 5 * 60 * 10), ))
116 | self.assertEqual(result, self.backend.mock.exchange_raw())
117 |
118 | def test_exchange_async(self):
119 | cla, ins, p1, p2 = 1, 2, 3, 4
120 | expected = struct.pack(">BBBBB", cla, ins, p1, p2, 0)
121 | self.assertFalse(self.backend.mock.send_raw.called)
122 | with self.backend.exchange_async(cla, ins, p1, p2):
123 | pass
124 | self.assertTrue(self.backend.mock.exchange_async_raw.called)
125 | self.assertEqual(self.backend.mock.exchange_async_raw.call_args, ((expected, ), ))
126 |
127 |
128 | class TestBackendInterfaceLogging(TestCase):
129 |
130 | def test_log_apdu(self):
131 | self.device = Devices.get_by_type(DeviceType.NANOS)
132 | with tempfile.TemporaryDirectory() as td:
133 | test_file = (Path(td) / "test_log_file.log").resolve()
134 | self.backend = DummyBackend(device=self.device, log_apdu_file=test_file)
135 | ref_lines = ["Test logging", "hello world", "Lorem Ipsum"]
136 | for l in ref_lines:
137 | self.backend.apdu_logger.info(l)
138 | with open(test_file, mode='r') as fp:
139 | read_lines = [l.strip() for l in fp.readlines()]
140 | self.assertEqual(read_lines, ref_lines)
141 |
--------------------------------------------------------------------------------
/tests/unit/backend/test_ledgercomm.py:
--------------------------------------------------------------------------------
1 | from ledgered.devices import DeviceType, Devices
2 | from unittest import TestCase
3 | from unittest.mock import patch
4 |
5 | from ragger.error import ExceptionRAPDU
6 | from ragger.utils import RAPDU
7 | from ragger.backend import LedgerCommBackend
8 | from ragger.backend import RaisePolicy
9 |
10 |
11 | class TestLedgerCommbackend(TestCase):
12 | """
13 | Test patterns explained:
14 |
15 | ```
16 | def test_something(self):
17 | # patches the HID transport layer used in LedgerComm to communicate with the USB
18 | with patch("ledgercomm.transport.HID") as mock:
19 | # saving the mock somewhere to check .open .send .recv methods
20 | self.hid = mock
21 | # starts the backend
22 | with self.backend:
23 | ```
24 | """
25 |
26 | def setUp(self):
27 | self.device = Devices.get_by_type(DeviceType.NANOS)
28 | self.backend = LedgerCommBackend(self.device)
29 |
30 | def check_rapdu(self, rapdu: RAPDU, status: int = 0x9000, payload: bytes = None):
31 | self.assertIsInstance(rapdu, RAPDU)
32 | self.assertEqual(rapdu.status, status)
33 | self.assertEqual(rapdu.data, payload)
34 |
35 | def test_contextmanager(self):
36 | with patch("ledgercomm.transport.HID") as mock:
37 | self.hid = mock
38 | with self.backend:
39 | self.assertTrue(self.hid.called)
40 | self.assertTrue(self.hid().open.called)
41 | self.assertFalse(self.hid().close.called)
42 | self.assertTrue(self.hid().close.called)
43 | self.assertEqual(self.backend._client.com, self.hid())
44 |
45 | def test_send_raw(self):
46 | with patch("ledgercomm.transport.HID") as mock:
47 | self.hid = mock
48 | with self.backend:
49 | self.assertFalse(self.hid().send.called)
50 | self.backend.send_raw(bytes.fromhex("00000000"))
51 | self.assertTrue(self.hid().send.called)
52 |
53 | def test_receive_ok(self):
54 | success, payload = 0x9000, b"something"
55 | with patch("ledgercomm.transport.HID") as mock:
56 | self.hid = mock
57 | self.hid().recv.return_value = (success, payload)
58 | with self.backend:
59 | rapdu = self.backend.receive()
60 | self.check_rapdu(rapdu, payload=payload)
61 |
62 | def test_receive_ok_raises(self):
63 | failure, payload = 0x9000, b"something"
64 | with patch("ledgercomm.transport.HID") as mock:
65 | self.hid = mock
66 | self.hid().recv.return_value = (failure, payload)
67 | with self.backend:
68 | self.backend.raise_policy = RaisePolicy.RAISE_ALL
69 | with self.assertRaises(ExceptionRAPDU) as error:
70 | self.backend.receive()
71 | self.assertEqual(error.exception.status, failure)
72 |
73 | def test_receive_nok(self):
74 | failure, payload = 0x8000, b"something"
75 | with patch("ledgercomm.transport.HID") as mock:
76 | self.hid = mock
77 | self.hid().recv.return_value = (failure, payload)
78 | with self.backend:
79 | self.backend.raise_policy = RaisePolicy.RAISE_NOTHING
80 | rapdu = self.backend.receive()
81 | self.check_rapdu(rapdu, status=failure, payload=payload)
82 |
83 | def test_receive_nok_raises(self):
84 | failure, payload = 0x8000, b"something"
85 | with patch("ledgercomm.transport.HID") as mock:
86 | self.hid = mock
87 | self.hid().recv.return_value = (failure, payload)
88 | with self.backend:
89 | with self.assertRaises(ExceptionRAPDU) as error:
90 | self.backend.receive()
91 | self.assertEqual(error.exception.status, failure)
92 |
93 | def test_exchange_raw(self):
94 | success, payload = 0x9000, b"something"
95 | with patch("ledgercomm.transport.HID") as mock:
96 | self.hid = mock
97 | self.hid().exchange.return_value = (success, payload)
98 | with self.backend:
99 | self.assertFalse(self.hid().exchange.called)
100 | rapdu = self.backend.exchange_raw(b"")
101 | self.assertTrue(self.hid().exchange.called)
102 | self.check_rapdu(rapdu, payload=payload)
103 |
104 | def test_exchange_raw_raises(self):
105 | failure, payload = 0x8000, b"something"
106 | with patch("ledgercomm.transport.HID") as mock:
107 | self.hid = mock
108 | self.hid().exchange.return_value = (failure, payload)
109 | with self.backend:
110 | with self.assertRaises(ExceptionRAPDU):
111 | self.backend.exchange_raw(b"")
112 |
113 | def test_exchange_async_raw(self):
114 | success, payload = 0x9000, b"something"
115 | with patch("ledgercomm.transport.HID") as mock:
116 | self.hid = mock
117 | self.hid().recv.return_value = (success, payload)
118 | with self.backend:
119 | self.assertIsNone(self.backend.last_async_response)
120 | with self.backend.exchange_async_raw(b""):
121 | self.backend.right_click()
122 | self.backend.left_click()
123 | self.backend.both_click()
124 | self.assertIsNotNone(self.backend.last_async_response)
125 | self.check_rapdu(self.backend.last_async_response, payload=payload)
126 |
--------------------------------------------------------------------------------
/tests/functional/test_boilerplate.py:
--------------------------------------------------------------------------------
1 | from requests.exceptions import ConnectionError
2 |
3 | import pytest
4 | import time
5 | from pathlib import Path
6 |
7 | from ragger.error import ExceptionRAPDU
8 | from ragger.utils import RAPDU
9 | from ragger.backend import RaisePolicy
10 | from ragger.navigator import NavInsID, NavIns
11 |
12 | ROOT_SCREENSHOT_PATH = Path(__file__).parent.parent.resolve()
13 |
14 |
15 | def test_error_returns_not_raises(backend):
16 | backend.raise_policy = RaisePolicy.RAISE_NOTHING
17 | result = backend.exchange(0x01, 0x00)
18 | assert isinstance(result, RAPDU)
19 | assert result.status == 0x6e00
20 | assert not result.data
21 |
22 |
23 | def test_error_raises_not_returns(backend):
24 | try:
25 | backend.exchange(0x01, 0x00)
26 | except ExceptionRAPDU as e:
27 | assert e.status == 0x6e00
28 | assert not e.data
29 |
30 |
31 | @pytest.mark.use_on_backend("speculos")
32 | def test_quit_app(backend, device, navigator):
33 | if not device.touchable:
34 | right_clicks = {'nanos': 3, 'nanox': 3, 'nanosp': 3}
35 | backend.get_current_screen_content()
36 |
37 | for _ in range(right_clicks[device.name]):
38 | backend.right_click()
39 | backend.wait_for_screen_change()
40 |
41 | with pytest.raises(ConnectionError):
42 | # clicking on "Quit", Speculos then stops
43 | backend.both_click()
44 | time.sleep(1)
45 |
46 | # Then a new dummy click should raise a ConnectionError
47 | backend.right_click()
48 |
49 | else:
50 | backend.get_current_screen_content()
51 |
52 | with pytest.raises(ConnectionError):
53 | # clicking on "Quit", Speculos then stops and raises
54 | navigator.navigate([NavInsID.USE_CASE_HOME_QUIT],
55 | screen_change_before_first_instruction=False,
56 | screen_change_after_last_instruction=False)
57 | time.sleep(1)
58 |
59 | # Then a new dummy touch should raise a ConnectionError
60 | backend.finger_touch()
61 |
62 |
63 | @pytest.mark.use_on_backend("speculos")
64 | def test_waiting_screen(backend, device, navigator):
65 | if not device.touchable:
66 | pytest.skip("Don't apply")
67 |
68 | prep_tx_apdu = bytes.fromhex("e006008015058000002c80000001800000000000000000000000")
69 |
70 | sign_tx_apdu = bytes.fromhex("e0060100310000000000000001de0b29"
71 | "5669a9fd93d5f28d9ec85e40f4cb697b"
72 | "ae000000000000029a0c466f72207520"
73 | "457468446576")
74 |
75 | # Test multiple way to wait for the return for the Home screen after a review.
76 |
77 | # Using USE_CASE_STATUS_DISMISS instruction
78 | backend.exchange_raw(prep_tx_apdu)
79 | with backend.exchange_async_raw(sign_tx_apdu):
80 | navigator.navigate_until_text_and_compare(
81 | NavInsID.USE_CASE_REVIEW_NEXT,
82 | [NavInsID.USE_CASE_REVIEW_CONFIRM, NavInsID.USE_CASE_STATUS_DISMISS], "Hold to sign",
83 | ROOT_SCREENSHOT_PATH, "waiting_screen")
84 |
85 | # Using WAIT_FOR_HOME_SCREEN instruction
86 | backend.exchange_raw(prep_tx_apdu)
87 | with backend.exchange_async_raw(sign_tx_apdu):
88 | navigator.navigate_until_text_and_compare(
89 | NavInsID.USE_CASE_REVIEW_NEXT,
90 | [NavInsID.USE_CASE_REVIEW_CONFIRM, NavInsID.WAIT_FOR_HOME_SCREEN], "Hold to sign",
91 | ROOT_SCREENSHOT_PATH, "waiting_screen")
92 |
93 | # Using WAIT_FOR_TEXT_ON_SCREEN instruction
94 | backend.exchange_raw(prep_tx_apdu)
95 | with backend.exchange_async_raw(sign_tx_apdu):
96 | navigator.navigate_until_text_and_compare(NavInsID.USE_CASE_REVIEW_NEXT, [
97 | NavInsID.USE_CASE_REVIEW_CONFIRM,
98 | NavIns(NavInsID.WAIT_FOR_TEXT_ON_SCREEN, ("This app enables signing", ))
99 | ], "Hold to sign", ROOT_SCREENSHOT_PATH, "waiting_screen")
100 |
101 | # Using WAIT_FOR_TEXT_NOT_ON_SCREEN instruction
102 | backend.exchange_raw(prep_tx_apdu)
103 | with backend.exchange_async_raw(sign_tx_apdu):
104 | navigator.navigate_until_text_and_compare(NavInsID.USE_CASE_REVIEW_NEXT, [
105 | NavInsID.USE_CASE_REVIEW_CONFIRM,
106 | NavIns(NavInsID.WAIT_FOR_TEXT_NOT_ON_SCREEN, ("Transaction", ))
107 | ], "Hold to sign", ROOT_SCREENSHOT_PATH, "waiting_screen")
108 |
109 | # Verify the error flow of WAIT_FOR_TEXT_ON_SCREEN instruction
110 | backend.exchange_raw(prep_tx_apdu)
111 | with backend.exchange_async_raw(sign_tx_apdu):
112 | with pytest.raises(TimeoutError) as error:
113 | navigator.navigate_until_text_and_compare(NavInsID.USE_CASE_REVIEW_NEXT, [
114 | NavInsID.USE_CASE_REVIEW_CONFIRM,
115 | NavIns(NavInsID.WAIT_FOR_TEXT_ON_SCREEN, ("WILL NOT BE FOUND", ))
116 | ], "Hold to sign", ROOT_SCREENSHOT_PATH, "waiting_screen")
117 | assert "Timeout waiting for screen change" in str(error.value)
118 |
119 | # Verify the error flow of WAIT_FOR_TEXT_ON_SCREEN instruction
120 | backend.exchange_raw(prep_tx_apdu)
121 | with backend.exchange_async_raw(sign_tx_apdu):
122 | with pytest.raises(TimeoutError) as error:
123 | navigator.navigate_until_text_and_compare(NavInsID.USE_CASE_REVIEW_NEXT, [
124 | NavInsID.USE_CASE_REVIEW_CONFIRM,
125 | NavIns(NavInsID.WAIT_FOR_TEXT_NOT_ON_SCREEN, ("T", ))
126 | ], "Hold to sign", ROOT_SCREENSHOT_PATH, "waiting_screen")
127 | assert "Timeout waiting for screen change" in str(error.value)
128 |
--------------------------------------------------------------------------------
/tests/unit/backend/test_ledgerwallet.py:
--------------------------------------------------------------------------------
1 | from ledgered.devices import DeviceType, Devices
2 | from typing import Optional
3 | from unittest import TestCase
4 | from unittest.mock import patch, MagicMock
5 |
6 | from ragger.error import ExceptionRAPDU
7 | from ragger.utils import RAPDU
8 | from ragger.backend import LedgerWalletBackend
9 | from ragger.backend import RaisePolicy
10 |
11 |
12 | class TestLedgerWalletBackend(TestCase):
13 |
14 | def setUp(self):
15 | self.device = MagicMock()
16 | self.backend = LedgerWalletBackend(Devices.get_by_type(DeviceType.NANOS))
17 |
18 | def check_rapdu(self, rapdu: RAPDU, status: int = 0x9000, payload: Optional[bytes] = None):
19 | self.assertIsInstance(rapdu, RAPDU)
20 | self.assertEqual(rapdu.status, status)
21 | self.assertEqual(rapdu.data, payload)
22 |
23 | def test_contextmanager(self):
24 | with patch("ledgerwallet.client.enumerate_devices") as devices:
25 | devices.return_value = [MagicMock()]
26 | self.assertIsNone(self.backend._client)
27 | with self.backend as backend:
28 | self.assertEqual(self.backend, backend)
29 | self.assertIsNotNone(self.backend._client)
30 |
31 | def test_send_raw(self):
32 | with patch("ledgerwallet.client.enumerate_devices") as devices:
33 | devices.return_value = [MagicMock()]
34 | with self.backend:
35 | self.assertIsNone(self.backend.send_raw(b""))
36 |
37 | def test_receive_ok(self):
38 | status, payload = 0x9000, b"something"
39 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}")
40 | with patch("ledgerwallet.client.enumerate_devices") as devices:
41 | devices.return_value = [self.device]
42 | with self.backend:
43 | rapdu = self.backend.receive()
44 | self.check_rapdu(rapdu, payload=payload)
45 |
46 | def test_receive_ok_raises(self):
47 | status, payload = 0x9000, b"something"
48 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}")
49 | with patch("ledgerwallet.client.enumerate_devices") as devices:
50 | devices.return_value = [self.device]
51 | with self.backend:
52 | self.backend.raise_policy = RaisePolicy.RAISE_ALL
53 | with self.assertRaises(ExceptionRAPDU) as error:
54 | self.backend.receive()
55 | self.assertEqual(error.exception.status, status)
56 |
57 | def test_receive_nok_no_raises(self):
58 | status, payload = 0x8000, b"something"
59 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}")
60 | with patch("ledgerwallet.client.enumerate_devices") as devices:
61 | devices.return_value = [self.device]
62 | with self.backend:
63 | self.backend.raise_policy = RaisePolicy.RAISE_NOTHING
64 | rapdu = self.backend.receive()
65 | self.check_rapdu(rapdu, status=status, payload=payload)
66 |
67 | def test_receive_nok_raises(self):
68 | status, payload = 0x8000, b"something"
69 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}")
70 | with patch("ledgerwallet.client.enumerate_devices") as devices:
71 | devices.return_value = [self.device]
72 | with self.backend:
73 | with self.assertRaises(ExceptionRAPDU) as error:
74 | self.backend.receive()
75 | self.assertEqual(error.exception.status, status)
76 |
77 | def test_exchange_raw_ok(self):
78 | status, payload = 0x9000, b"something"
79 | self.device.exchange.return_value = payload + bytes.fromhex(f"{status:x}")
80 | with patch("ledgerwallet.client.enumerate_devices") as devices:
81 | devices.return_value = [self.device]
82 | with self.backend:
83 | rapdu = self.backend.exchange_raw(b"")
84 | self.check_rapdu(rapdu, payload=payload)
85 |
86 | def test_exchange_raw_nok_no_raises(self):
87 | status, payload = 0x8000, b"something"
88 | self.device.exchange.return_value = payload + bytes.fromhex(f"{status:x}")
89 | with patch("ledgerwallet.client.enumerate_devices") as devices:
90 | devices.return_value = [self.device]
91 | with self.backend:
92 | self.backend.raise_policy = RaisePolicy.RAISE_NOTHING
93 | rapdu = self.backend.exchange_raw(b"")
94 | self.check_rapdu(rapdu, status=status, payload=payload)
95 |
96 | def test_exchange_raw_nok_raises(self):
97 | status, payload = 0x8000, b"something"
98 | self.device.exchange.return_value = payload + bytes.fromhex(f"{status:x}")
99 | with patch("ledgerwallet.client.enumerate_devices") as devices:
100 | devices.return_value = [self.device]
101 | with self.backend:
102 | with self.assertRaises(ExceptionRAPDU) as error:
103 | self.backend.exchange_raw(b"")
104 | self.assertEqual(error.exception.status, status)
105 |
106 | def test_exchange_async_raw_ok(self):
107 | status, payload = 0x9000, b"something"
108 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}")
109 | with patch("ledgerwallet.client.enumerate_devices") as devices:
110 | devices.return_value = [self.device]
111 | with self.backend:
112 | self.assertIsNone(self.backend.last_async_response)
113 | with self.backend.exchange_async_raw(b""):
114 | self.backend.right_click()
115 | self.backend.left_click()
116 | self.backend.both_click()
117 | self.check_rapdu(self.backend.last_async_response, payload=payload)
118 |
--------------------------------------------------------------------------------
/tests/unit/backend/test_physical_backend.py:
--------------------------------------------------------------------------------
1 | from contextlib import contextmanager
2 | from ledgered.devices import DeviceType, Devices
3 | from typing import Generator
4 | from unittest import TestCase
5 | from unittest.mock import MagicMock
6 |
7 | from ragger.backend import BackendInterface
8 | from ragger.backend.physical_backend import PhysicalBackend
9 | from ragger.gui import RaggerGUI
10 | from ragger.navigator.instruction import NavInsID
11 | from ragger.utils.structs import RAPDU
12 |
13 |
14 | class StubPhysicalBackend(PhysicalBackend):
15 |
16 | def __enter__(self):
17 | pass
18 |
19 | def send_raw(self, data: bytes = b""):
20 | pass
21 |
22 | def receive(self) -> RAPDU:
23 | return RAPDU(0x9000, b"")
24 |
25 | def exchange_raw(self, data: bytes = b"", tick_timeout: int = 0) -> RAPDU:
26 | return RAPDU(0x9000, b"")
27 |
28 | @contextmanager
29 | def exchange_async_raw(self, data: bytes = b"") -> Generator[None, None, None]:
30 | yield
31 |
32 |
33 | class TestPhysicalBackend(TestCase):
34 |
35 | def setUp(self):
36 | self.device = Devices.get_by_type(DeviceType.NANOS)
37 | self.backend = StubPhysicalBackend(self.device, with_gui=True)
38 | # patching the `start` function to avoid triggering a real interface
39 | self.backend._ui.start = MagicMock()
40 |
41 | def test___init__no_gui(self):
42 | backend = StubPhysicalBackend(self.device)
43 | self.assertIsInstance(backend, BackendInterface)
44 | self.assertIsNone(backend._ui)
45 |
46 | def test__init__with_gui(self):
47 | self.assertIsInstance(self.backend._ui, RaggerGUI)
48 | self.assertFalse(self.backend._ui.is_alive())
49 |
50 | def test_init_gui_no_ui(self):
51 | backend = StubPhysicalBackend(self.device)
52 | with self.assertRaises(AssertionError):
53 | backend.init_gui()
54 |
55 | def test_init_gui_with_gui(self):
56 | self.assertIsNone(self.backend.init_gui())
57 | self.assertTrue(self.backend._ui.start.called)
58 |
59 | def test_navigation_methods_no_gui_None(self):
60 | backend = StubPhysicalBackend(self.device)
61 | for method in [
62 | backend.right_click, backend.left_click, backend.both_click, backend.finger_touch
63 | ]:
64 | self.assertIsNone(method())
65 |
66 | def test_click_methods_with_gui(self):
67 | for (method, expected_arg) in [(self.backend.right_click, NavInsID.RIGHT_CLICK),
68 | (self.backend.left_click, NavInsID.LEFT_CLICK),
69 | (self.backend.both_click, NavInsID.BOTH_CLICK)]:
70 | # mocking the underlying called method
71 | self.backend._ui.ask_for_click_action = MagicMock()
72 |
73 | self.assertIsNone(method())
74 | self.assertTrue(self.backend._ui.ask_for_click_action.called)
75 | self.assertEqual(self.backend._ui.ask_for_click_action.call_args, ((expected_arg, ), ))
76 |
77 | def test_finger_touch_with_gui(self):
78 | x, y = 3, 7
79 | # mocking the underlying called method
80 | self.backend._ui.ask_for_touch_action = MagicMock()
81 |
82 | self.assertIsNone(self.backend.finger_touch(x, y))
83 | self.assertTrue(self.backend._ui.ask_for_touch_action.called)
84 | self.assertEqual(self.backend._ui.ask_for_touch_action.call_args, ((x, y), ))
85 |
86 | def test_compare_methods_no_gui_bool(self):
87 | backend = StubPhysicalBackend(self.device)
88 | self.assertTrue(backend.compare_screen_with_snapshot(None))
89 | self.assertTrue(backend.compare_screen_with_text(None))
90 |
91 | def test_compare_screen_with_snapshot_with_gui(self):
92 | # mocking the underlying called method
93 | oracle = MagicMock()
94 | oracle.return_value = True
95 | self.backend._ui.check_screenshot = oracle
96 |
97 | path = "some/patch"
98 | self.assertIsNone(self.backend._last_valid_snap_path)
99 | self.assertTrue(self.backend.compare_screen_with_snapshot(path))
100 | self.assertTrue(oracle.called)
101 | self.assertEqual(self.backend._last_valid_snap_path, path)
102 |
103 | # comparing same snapshot, the underlying check function is not called
104 | oracle.reset_mock()
105 | self.assertTrue(self.backend.compare_screen_with_snapshot(path))
106 | self.assertFalse(oracle.called)
107 | self.assertEqual(self.backend._last_valid_snap_path, path)
108 |
109 | def test_compare_screen_with_snapshot_with_gui_error(self):
110 | # mocking the underlying called method
111 | oracle = MagicMock()
112 | oracle.return_value = False
113 | self.backend._ui.check_screenshot = oracle
114 |
115 | path = "some/patch"
116 | self.backend._last_valid_snap_path = None
117 | self.assertFalse(self.backend.compare_screen_with_snapshot(path))
118 | self.assertTrue(oracle.called)
119 | self.assertIsNone(self.backend._last_valid_snap_path)
120 |
121 | def test_compare_screen_with_text_with_gui_last_valid_snap_path_exists(self):
122 | path = "tests/snapshots/nanos/generic/00000.png"
123 | text = "Boilerplate"
124 | self.backend._last_valid_snap_path = path
125 |
126 | self.assertTrue(self.backend.compare_screen_with_text(text))
127 | self.assertFalse(self.backend.compare_screen_with_text("this text does not exist here"))
128 |
129 | def test_compare_screen_with_text_with_gui_last_valid_snap_path_does_not_exist(self):
130 | # mocking the underlying called method
131 | oracle = MagicMock()
132 | oracle.return_value = True
133 | self.backend._ui.check_text = oracle
134 |
135 | text = "sometext"
136 | self.assertTrue(self.backend.compare_screen_with_text(text))
137 | self.assertTrue(oracle.called)
138 | self.assertEqual(oracle.call_args, ((text, ), ))
139 |
140 | def test_wait_for_screen_change(self):
141 | self.assertIsNone(self.backend.wait_for_screen_change())
142 |
143 | def test_get_current_screen_content(self):
144 | self.assertListEqual(self.backend.get_current_screen_content(), list())
145 |
--------------------------------------------------------------------------------
/src/ragger/gui/process.py:
--------------------------------------------------------------------------------
1 | """
2 | Copyright 2022 Ledger SAS
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | """
16 | import sys
17 | from multiprocessing import Process, Queue
18 | from pathlib import Path
19 | from PyQt6.QtCore import QObject, QThread, pyqtSignal
20 | from PyQt6.QtWidgets import QApplication
21 | from typing import Tuple, Any
22 |
23 | from .interface import RaggerMainWindow
24 | from ragger.logger import get_gui_logger
25 | from ragger.navigator.instruction import NavInsID
26 |
27 | NAVIGATION_ACTIONS = {
28 | NavInsID.RIGHT_CLICK: "right button",
29 | NavInsID.LEFT_CLICK: "left button",
30 | NavInsID.BOTH_CLICK: "both buttons"
31 | }
32 |
33 |
34 | class ProcessCommunicationWorker(QObject):
35 | finished = pyqtSignal()
36 | progress = pyqtSignal(int)
37 |
38 | def __init__(self, queues: Tuple[Queue, Queue], main_window: RaggerMainWindow):
39 | super().__init__()
40 | self.logger = get_gui_logger().getChild("CommunicationThread")
41 | self._queues = queues
42 | self._main_window = main_window
43 | self.logger.info("Initiated")
44 |
45 | def _send(self, obj: Any):
46 | self._queues[1].put(obj)
47 |
48 | def _receive(self) -> Any:
49 | return self._queues[0].get()
50 |
51 | def run(self):
52 | self.logger.info("Worker: started")
53 | while True:
54 | self.logger.debug("Waiting instruction...")
55 | command, argument = self._receive()
56 | if command == "screenshot":
57 | self.logger.debug("Image received")
58 | self._main_window.display_screenshot(argument)
59 | elif command == "text_search":
60 | self.logger.debug(f"Text search received : '{argument}'")
61 | self._main_window.display_text_search(argument)
62 | elif command == "click_action":
63 | self.logger.debug("Click action required")
64 | self._main_window.display_action(NAVIGATION_ACTIONS[argument])
65 | elif command == "touch_action":
66 | self.logger.debug("Touch action required")
67 | self._main_window.display_action("touch", *argument)
68 | elif command == "swipe_left_action":
69 | self.logger.debug("Swipe left action required")
70 | self._main_window.display_action("swipe_left", *argument)
71 | elif command == "swipe_right_action":
72 | self.logger.debug("Swipe right action required")
73 | self._main_window.display_action("swipe_right", *argument)
74 | elif command == "action_done":
75 | self.logger.debug("Action done")
76 | self._main_window.action_done()
77 | elif command == "kill":
78 | self.logger.debug("Kill object")
79 | self._main_window.close()
80 |
81 | self.logger.debug("Interface updated")
82 |
83 | def __del__(self):
84 | self.finished.emit()
85 |
86 |
87 | class RaggerGUI(Process):
88 |
89 | def __init__(self, device: str, args=None):
90 | super().__init__(name="RaggerGUI")
91 | self.thread: QThread
92 | self.worker: ProcessCommunicationWorker
93 | self.logger = get_gui_logger().getChild("RaggerGUI")
94 | self._queues: Tuple[Queue, Queue] = (Queue(), Queue())
95 | self._device = device
96 | self.logger.info("Initiated")
97 |
98 | def _send(self, obj: Any):
99 | self.logger.debug("Sending '%s'", obj)
100 | self._queues[0].put(obj)
101 |
102 | def _receive(self) -> Any:
103 | result = self._queues[1].get()
104 | self.logger.debug("Receiving '%s'", result)
105 | return result
106 |
107 | def kill(self):
108 | self.logger.info("Killing the interface and myself")
109 | self._send(("kill", None))
110 | if self.is_alive():
111 | super().kill()
112 |
113 | def check_screenshot(self, image: Path):
114 | self._send(("screenshot", image))
115 | return self._receive()
116 |
117 | def check_text(self, text: str):
118 | self._send(("text_search", text))
119 | return self._receive()
120 |
121 | def ask_for_click_action(self, ins_id: NavInsID):
122 | self._send(("click_action", ins_id))
123 | return self._receive()
124 |
125 | def ask_for_touch_action(self, x: int = 0, y: int = 0):
126 | self._send(("touch_action", (x, y)))
127 | return self._receive()
128 |
129 | def ask_for_swipe_action(self, x: int = 0, y: int = 0, direction: str = "left"):
130 | if direction == "left":
131 | self._send(("swipe_left_action", (x, y)))
132 | else:
133 | self._send(("swipe_right_action", (x, y)))
134 | return self._receive()
135 |
136 | def _configure_worker(self):
137 | self.thread = QThread()
138 | self.worker = ProcessCommunicationWorker(self._queues, self._main_window)
139 | self.worker.moveToThread(self.thread)
140 | self.thread.started.connect(self.worker.run)
141 | self.worker.finished.connect(self.thread.quit)
142 | self.thread.start()
143 | self.logger.info("Worker started")
144 |
145 | def _button_cb(self, obj: Any):
146 | self._queues[1].put(obj)
147 | self._send(("action_done", ""))
148 |
149 | def run(self):
150 | self._app = QApplication([])
151 | self._app.setStyle("Fusion")
152 | self._main_window = RaggerMainWindow(device=self._device)
153 | self._main_window.set_button_cb(self._button_cb)
154 | self._configure_worker()
155 | self.logger.info("Starting the interface...")
156 | sys.exit(self._app.exec())
157 | self.logger.info("Interface ended")
158 |
--------------------------------------------------------------------------------
/tests/unit/backend/test_speculos.py:
--------------------------------------------------------------------------------
1 | from io import BytesIO
2 | from ledgered.devices import DeviceType, Devices
3 | from typing import List
4 | from unittest import TestCase
5 | from unittest.mock import MagicMock, patch
6 |
7 | from ragger.backend import SpeculosBackend
8 |
9 | APPNAME = "some app"
10 |
11 |
12 | def get_next_in_list(the_list: List, elt: str) -> str:
13 | index = the_list.index(elt)
14 | return the_list[index + 1]
15 |
16 |
17 | class TestSpeculosBackend(TestCase):
18 |
19 | maxDiff = None
20 |
21 | def setUp(self):
22 | self.nanos = Devices.get_by_type(DeviceType.NANOS)
23 |
24 | def test___init__ok(self):
25 | b = SpeculosBackend(APPNAME, self.nanos)
26 | self.assertEqual(b._api_port, b._DEFAULT_API_PORT)
27 | self.assertEqual(b._apdu_port, b._DEFAULT_API_PORT + 1)
28 |
29 | def test___init__ports_ok(self):
30 | api_port, apdu_port = 4567, 9876
31 | b1 = SpeculosBackend(APPNAME,
32 | self.nanos,
33 | args=["--api-port",
34 | str(api_port), "--apdu-port",
35 | str(apdu_port)])
36 | self.assertEqual(b1._api_port, api_port)
37 | self.assertEqual(b1._apdu_port, apdu_port)
38 |
39 | b2 = SpeculosBackend(APPNAME, self.nanos, args=["--api-port", str(api_port)])
40 | self.assertEqual(b2._api_port, api_port)
41 | self.assertEqual(b2._apdu_port, api_port + 1)
42 |
43 | b3 = SpeculosBackend(APPNAME, self.nanos, args=["--apdu-port", str(apdu_port)])
44 | self.assertEqual(b3._api_port, b3._DEFAULT_API_PORT)
45 | self.assertEqual(b3._apdu_port, apdu_port)
46 |
47 | def test___init__args_ok(self):
48 | SpeculosBackend(APPNAME, self.nanos, args=["some", "specific", "arguments"])
49 |
50 | def test___init__args_nok(self):
51 | with self.assertRaises(AssertionError):
52 | SpeculosBackend(APPNAME, self.nanos, args="not a list")
53 |
54 | def test_context_manager(self):
55 | expected_image = b"1234"
56 | with patch("ragger.backend.speculos.SpeculosClient"):
57 | backend = SpeculosBackend(APPNAME, self.nanos)
58 | self.assertIsNone(backend._last_screenshot)
59 | self.assertIsNone(backend._home_screenshot)
60 | # patching SpeculosClient.get_screenshot
61 | backend._client.get_screenshot.return_value = expected_image
62 | # patching method so that __enter__ is not stuck for 20s
63 | backend._retrieve_client_screen_content = lambda: {"events": True}
64 | with backend as yielded:
65 | self.assertTrue(backend._client.__enter__.called)
66 | self.assertEqual(backend, yielded)
67 | self.assertEqual(backend._last_screenshot, backend._home_screenshot)
68 | self.assertEqual(backend._last_screenshot.getvalue(),
69 | BytesIO(expected_image).getvalue())
70 | self.assertFalse(backend._client.__exit__.called)
71 | self.assertTrue(backend._client.__exit__.called)
72 |
73 | def test_batch_ok(self):
74 | with patch("ragger.backend.speculos.SpeculosClient") as patched_client:
75 | client_number = 2
76 | arg_api_port = 1234
77 | arg_apdu_port = 4321
78 |
79 | clients = SpeculosBackend.batch(
80 | APPNAME,
81 | self.nanos,
82 | client_number,
83 | different_attestation=True,
84 | args=['--apdu-port', arg_apdu_port, '--api-port', arg_api_port])
85 |
86 | self.assertEqual(len(clients), client_number)
87 | self.assertEqual(patched_client.call_count, client_number)
88 | all_client_args = patched_client.call_args_list
89 |
90 | client_seeds = set()
91 | client_rngs = set()
92 | client_priv_keys = set()
93 | client_attestations = set()
94 | client_api_ports = set()
95 | client_apdu_ports = set()
96 |
97 | for index, client in enumerate(clients):
98 | args, kwargs = all_client_args[index]
99 | self.assertEqual(args, ())
100 | self.assertEqual(kwargs["app"], APPNAME)
101 | self.assertEqual(kwargs["api_url"], f"http://127.0.0.1:{client._api_port}")
102 | speculos_args = kwargs["args"]
103 | client_seeds.add(get_next_in_list(speculos_args, "--seed"))
104 | client_rngs.add(get_next_in_list(speculos_args, "--deterministic-rng"))
105 | client_priv_keys.add(get_next_in_list(speculos_args, "--user-private-key"))
106 | client_attestations.add(get_next_in_list(speculos_args, "--attestation-key"))
107 | api_port = int(get_next_in_list(speculos_args, "--api-port"))
108 | client_api_ports.add(client._api_port)
109 | apdu_port = int(get_next_in_list(speculos_args, "--apdu-port"))
110 | client_apdu_ports.add(client._api_port)
111 |
112 | # ports given as original arguments are overridden
113 | self.assertNotEqual(api_port, arg_api_port)
114 | self.assertNotEqual(apdu_port, arg_api_port)
115 | # API port has been overridden by a generated port
116 | # (APDU port too, but it is not stored into SpeculosBackend)
117 | self.assertEqual(client._api_port, api_port)
118 |
119 | self.assertIsInstance(client, SpeculosBackend)
120 | self.assertIsInstance(client._client, MagicMock)
121 |
122 | # all API ports are different
123 | self.assertEqual(len(client_api_ports), client_number)
124 | # all APDU ports are different
125 | self.assertEqual(len(client_apdu_ports), client_number)
126 | # all seeds ports are different
127 | self.assertEqual(len(client_seeds), client_number)
128 | # all RNGs are different
129 | self.assertEqual(len(client_rngs), client_number)
130 | # all private keys are different
131 | self.assertEqual(len(client_priv_keys), client_number)
132 | # all attestations are different
133 | self.assertEqual(len(client_attestations), client_number)
134 |
--------------------------------------------------------------------------------
/tests/unit/firmware/touch/test_screen_FullScreen.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from unittest.mock import MagicMock
3 | from ledgered.devices import Devices, DeviceType
4 |
5 | from ragger.firmware.touch import FullScreen
6 | from ragger.firmware.touch.positions import POSITIONS
7 |
8 |
9 | class TestFullScreen(TestCase):
10 |
11 | def setUp(self):
12 | self.backend = MagicMock()
13 | self.device = Devices.get_by_type(DeviceType.STAX)
14 | self.screen = FullScreen(self.backend, self.device)
15 |
16 | def test_non_variable_layouts(self):
17 | # all of this layouts only have a 'tap' method with no argument,
18 | # which translate to a backend.touch_finger on a fixed position
19 | layout_positions = [
20 | (self.screen.right_header, POSITIONS["RightHeader"][self.device.type]),
21 | (self.screen.exit_header, POSITIONS["RightHeader"][self.device.type]),
22 | (self.screen.info_header, POSITIONS["RightHeader"][self.device.type]),
23 | (self.screen.left_header, POSITIONS["LeftHeader"][self.device.type]),
24 | (self.screen.navigation_header, POSITIONS["LeftHeader"][self.device.type]),
25 | (self.screen.tappable_center, POSITIONS["TappableCenter"][self.device.type]),
26 | (self.screen.centered_footer, POSITIONS["CenteredFooter"][self.device.type]),
27 | (self.screen.cancel_footer, POSITIONS["CancelFooter"][self.device.type]),
28 | (self.screen.exit_footer, POSITIONS["CancelFooter"][self.device.type]),
29 | (self.screen.info_footer, POSITIONS["CancelFooter"][self.device.type]),
30 | (self.screen.settings_footer, POSITIONS["CancelFooter"][self.device.type]),
31 | ]
32 | call_number = 0
33 | self.assertEqual(self.backend.finger_touch.call_count, call_number)
34 | for (layout, position) in layout_positions:
35 | # each of this
36 | layout.tap()
37 | call_number += 1
38 | self.assertEqual(self.backend.finger_touch.call_count, call_number)
39 | self.assertEqual(self.backend.finger_touch.call_args, ((*position, ), ))
40 |
41 | def test_choosing_layouts(self):
42 | layout_index_positions = [
43 | (self.screen.choice_list, 1, POSITIONS["ChoiceList"][self.device.type]),
44 | (self.screen.suggestions, 2, POSITIONS["Suggestions"][self.device.type]),
45 | ]
46 | call_number = 0
47 | self.assertEqual(self.backend.finger_touch.call_count, call_number)
48 | for (layout, index, position) in layout_index_positions:
49 | # each of this
50 | layout.choose(index)
51 | call_number += 1
52 | self.assertEqual(self.backend.finger_touch.call_count, call_number)
53 | self.assertEqual(self.backend.finger_touch.call_args, ((*position[index], ), ))
54 |
55 | def test_keyboards_common_functions(self):
56 | layouts_word_positions = [
57 | (self.screen.letter_only_keyboard, "basicword",
58 | POSITIONS["LetterOnlyKeyboard"][self.device.type]),
59 | (self.screen.full_keyboard_letters, "still basic",
60 | POSITIONS["FullKeyboardLetters"][self.device.type]),
61 | (self.screen.full_keyboard_special_characters_1, "12)&@'.",
62 | POSITIONS["FullKeyboardSpecialCharacters1"][self.device.type]),
63 | (self.screen.full_keyboard_special_characters_2, "[$?~+*|",
64 | POSITIONS["FullKeyboardSpecialCharacters2"][self.device.type]),
65 | ]
66 | self.assertEqual(self.backend.finger_touch.call_count, 0)
67 | for (layout, word, positions) in layouts_word_positions:
68 |
69 | layout.write(word)
70 | argument_list = [((*positions[letter], ), ) for letter in word]
71 | call_number = len(word)
72 | self.assertEqual(self.backend.finger_touch.call_count, call_number)
73 | self.assertEqual(self.backend.finger_touch.call_args_list, argument_list)
74 |
75 | layout.back()
76 | self.assertEqual(self.backend.finger_touch.call_count, call_number + 1)
77 | self.assertEqual(self.backend.finger_touch.call_args, ((*positions["back"], ), ))
78 |
79 | self.backend.finger_touch.reset_mock()
80 |
81 | def test_keyboards_change_layout(self):
82 | layouts_positions = [
83 | (self.screen.full_keyboard_letters, POSITIONS["FullKeyboardLetters"][self.device.type]),
84 | (self.screen.full_keyboard_special_characters_1,
85 | POSITIONS["FullKeyboardSpecialCharacters1"][self.device.type]),
86 | (self.screen.full_keyboard_special_characters_2,
87 | POSITIONS["FullKeyboardSpecialCharacters2"][self.device.type]),
88 | ]
89 | call_number = 0
90 | self.assertEqual(self.backend.finger_touch.call_count, call_number)
91 | for layout, positions in layouts_positions:
92 | layout.change_layout()
93 | call_number += 1
94 | self.assertEqual(self.backend.finger_touch.call_count, call_number)
95 | self.assertEqual(self.backend.finger_touch.call_args,
96 | ((*positions["change_layout"], ), ))
97 |
98 | def test_keyboards_change_case(self):
99 | self.assertEqual(self.backend.finger_touch.call_count, 0)
100 | self.screen.full_keyboard_letters.change_case()
101 | self.assertEqual(self.backend.finger_touch.call_count, 1)
102 | self.assertEqual(self.backend.finger_touch.call_args,
103 | ((*POSITIONS["FullKeyboardLetters"][self.device.type]["change_case"], ), ))
104 |
105 | def test_keyboards_change_special_characters(self):
106 | layouts_positions = [
107 | (self.screen.full_keyboard_special_characters_1,
108 | POSITIONS["FullKeyboardSpecialCharacters2"][self.device.type]),
109 | (self.screen.full_keyboard_special_characters_2,
110 | POSITIONS["FullKeyboardSpecialCharacters2"][self.device.type]),
111 | ]
112 | call_number = 0
113 | self.assertEqual(self.backend.finger_touch.call_count, call_number)
114 | for layout, positions in layouts_positions:
115 | layout.more_specials()
116 | call_number += 1
117 | self.assertEqual(self.backend.finger_touch.call_count, call_number)
118 | self.assertEqual(self.backend.finger_touch.call_args,
119 | ((*positions["more_specials"], ), ))
120 |
--------------------------------------------------------------------------------
/doc/glossary.rst:
--------------------------------------------------------------------------------
1 | .. _Glossary:
2 |
3 | Glossary
4 | ========
5 |
6 | .. glossary::
7 |
8 | APDU
9 | **APDU** stands for
10 | "`Application Protocol Data Unit `_".
11 | It designate a message which is transmitted from a :term:`client` to an
12 | :term:`application`, or the other way around. APDUs transit through USB or
13 | Bluetooth.
14 |
15 | Application
16 | In this documentation, an **application** is a piece of software, build
17 | around the Ledger Secure SDK, which runs on a Ledger cold wallet device,
18 | such as a NanoS, NanoS+ or NanoX.
19 |
20 | Backend
21 | In ``Ragger``, a **backend** is a library which allows to communicate with
22 | a Ledger cold wallet device, either a real one (like a physical
23 | NanoS/S+/X), or an emulated one (thanks to ``Speculos``).
24 |
25 | BAGL
26 | **BAGL** stands for "BOLOS Application Graphics Library", and is a library
27 | integrated into the C SDK managing the UI of the NanoS, NanoX and NanoS+
28 | devices. It is embedded into the :term:`SDK`
29 |
30 | BOLOS
31 | **BOLOS** is `the operating system running on all Ledger hardware wallets
32 | `_.
33 | Its code is not open-source. Its capabilities can be used through the
34 | open-source :term:`SDK`.
35 |
36 | Client
37 | A client is any piece of software accessing a service made available by a
38 | server; but in this documentation, a **client** refers to a piece of code
39 | able to communicate with an :term:`application`.
40 |
41 | Typically, the client of an application programmatically enables the
42 | capabilities of its application: signing a payload, triggering a
43 | transaction, changing the configuration, ...
44 |
45 | Firmware
46 | A **firmware** usually designates the Ledger device *device type* and SDK
47 | *version*. Currently, *device types* are NanoS, NanoS+ and NanoX. The
48 | *version* depends on the type.
49 |
50 | ``Ragger`` stores this information in the
51 | :py:class:`Firmware ` class.
52 |
53 | See also :term:`SDK`.
54 |
55 | Golden snapshot
56 | In ``Ragger``, **golden snapshots** are :term:`application` screen's
57 | snapshots which are considered as references. They are used when testing an
58 | application to check the application behaves as expected, by comparing them
59 | if actual snapshots.
60 |
61 | Layout
62 | In the :term:`Stax` SDK, a **Layout** refers to an element displayed on a
63 | Stax screen. Examples of Layouts can be a button, on a specific
64 | location, a keyboard, or just a centered text. This name is used in
65 | ``Ragger`` to designate the class allowing to interact with a displayed
66 | layout.
67 |
68 | Layouts are used to create :term:`Pages ` and
69 | :term:`Use Cases