From 5b370979a1136914cf84aea99d746f588b8fa06a Mon Sep 17 00:00:00 2001 From: Lucas PASCAL Date: Thu, 12 May 2022 10:15:48 +0200 Subject: [PATCH 1/4] [ci] Deploy doc only on master merge --- .github/workflows/documentation.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 72a4f471..960b4f3b 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -2,6 +2,9 @@ name: Documentation generation & update on: push: + branches: + - develop + - master pull_request: branches: - develop @@ -28,7 +31,7 @@ jobs: name: Deploy the documentation on Github pages runs-on: ubuntu-latest needs: generate - # if: github.ref == 'refs/heads/master' + if: github.event_name == 'push' && github.ref == 'refs/heads/master' steps: - name: Download documentation bundle uses: actions/download-artifact@v2 From bfc36cd5790c747403d03c925585a7eb8c729f6b Mon Sep 17 00:00:00 2001 From: Lucas PASCAL Date: Thu, 12 May 2022 10:18:53 +0200 Subject: [PATCH 2/4] [add] Allow to filter error status (default is everything except 0x9000) --- src/ragger/backend/interface.py | 55 +++++++++++++++++++++----------- src/ragger/backend/ledgercomm.py | 15 +++++---- src/ragger/backend/speculos.py | 15 +++++---- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/src/ragger/backend/interface.py b/src/ragger/backend/interface.py index 5e9ad8ee..aa906ea8 100644 --- a/src/ragger/backend/interface.py +++ b/src/ragger/backend/interface.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from types import TracebackType -from typing import Optional, Type +from typing import Optional, Type, Iterable from ragger.utils import pack_APDU @@ -12,30 +12,39 @@ class RAPDU: data: bytes def __str__(self): - return f'[0x{self.status:02x}] {self.data}' + return f'[0x{self.status:02x}] {self.data.hex()}' class BackendInterface(ABC): - def __init__(self, host: str, port: int, raises: bool = False): + def __init__(self, + host: str, + port: int, + raises: bool = False, + valid_statuses: Iterable[int] = (0x9000, )): """Initializes the Backend :param host: The host where to reach the backend. :type host: str :param port: The port where to reach the backend :type port: int - :param raises: Weither the instance should raises on non-0x9000 response + :param raises: Weither the instance should raises on non-valid response statuses, or not. :type raises: bool + :param valid_statuses: a list of RAPDU statuses considered successfull + (default: [0x9000]) + :type valid_statuses: any iterable """ self._host = host self._port = port self._raises = raises + self._valid_statuses = valid_statuses @property def raises(self) -> bool: """ - :return: Weither the instance raises on non-0x9000 response statuses or not. + :return: Weither the instance raises on non-0x9000 response statuses or + not. :rtype: bool """ return self._raises @@ -48,6 +57,16 @@ def url(self) -> str: """ return f"{self._host}:{self._port}" + def is_valid(self, status: int) -> bool: + """ + :param status: A status to check + :type status: int + + :return: If the given status is considered valid or not + :rtype: bool + """ + return status in self._valid_statuses + @abstractmethod def __enter__(self) -> "BackendInterface": raise NotImplementedError @@ -67,8 +86,6 @@ def send(self, """ Formats then sends an APDU to the backend. - The length is automaticaly added to the APDU message. - :param cla: The application ID :type cla: int :param ins: The command ID @@ -80,8 +97,8 @@ def send(self, :param data: Command data :type data: bytes - :return: Nothing - :rtype: None + :return: None + :rtype: NoneType """ return self.send_raw(pack_APDU(cla, ins, p1, p2, data)) @@ -96,8 +113,8 @@ def send_raw(self, data: bytes = b"") -> None: :param data: The APDU message :type data: bytes - :return: Nothing - :rtype: None + :return: None + :rtype: NoneType """ raise NotImplementedError @@ -111,7 +128,7 @@ def receive(self) -> RAPDU: :raises ApduException: If the `raises` attribute is True, this method will raise if the backend returns a status code - different from 0x9000 + not registered a a `valid_statuses` :return: The APDU response :rtype: RAPDU @@ -162,7 +179,7 @@ def exchange_raw(self, data: bytes = b"") -> RAPDU: :raises ApduException: If the `raises` attribute is True, this method will raise if the backend returns a status code - different from 0x9000 + not registered a a `valid_statuses` :return: The APDU response :rtype: RAPDU @@ -181,8 +198,8 @@ def right_click(self) -> None: get stuck (on further call to `receive` for instance) until the expected action is performed on the device. - :return: Nothing - :rtype: None + :return: None + :rtype: NoneType """ raise NotImplementedError @@ -198,8 +215,8 @@ def left_click(self) -> None: get stuck (on further call to `receive` for instance) until the expected action is performed on the device. - :return: Nothing - :rtype: None + :return: None + :rtype: NoneType """ raise NotImplementedError @@ -215,7 +232,7 @@ def both_click(self) -> None: get stuck (on further call to `receive` for instance) until the expected action is performed on the device. - :return: Nothing - :rtype: None + :return: None + :rtype: NoneType """ raise NotImplementedError diff --git a/src/ragger/backend/ledgercomm.py b/src/ragger/backend/ledgercomm.py index 13447c06..a807740d 100644 --- a/src/ragger/backend/ledgercomm.py +++ b/src/ragger/backend/ledgercomm.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Iterable from ledgercomm import Transport from speculos.client import ApduException @@ -9,11 +9,10 @@ def manage_error(function): - def decoration(*args, **kwargs) -> RAPDU: - self: LedgerCommBackend = args[0] - rapdu: RAPDU = function(*args, **kwargs) + def decoration(self: 'LedgerCommBackend', *args, **kwargs) -> RAPDU: + rapdu: RAPDU = function(self, *args, **kwargs) logger.debug("Receiving '%s'", rapdu) - if rapdu.status == 0x9000 or not self.raises: + if not self.raises or self.is_valid(rapdu.status): return rapdu # else should raise raise ApduException(rapdu.status, rapdu.data) @@ -28,9 +27,13 @@ def __init__(self, port: int = 9999, raises: bool = False, interface: str = 'hid', + valid_statuses: Iterable[int] = (0x9000, ), *args, **kwargs): - super().__init__(host, port, raises=raises) + super().__init__(host, + port, + raises=raises, + valid_statuses=valid_statuses) self._client: Optional[Transport] = None kwargs['interface'] = interface self._args = (args, kwargs) diff --git a/src/ragger/backend/speculos.py b/src/ragger/backend/speculos.py index b0869824..e4f5a292 100644 --- a/src/ragger/backend/speculos.py +++ b/src/ragger/backend/speculos.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Optional, Iterable from speculos.client import SpeculosClient, ApduResponse, ApduException @@ -9,12 +9,11 @@ def manage_error(function): - def decoration(*args, **kwargs) -> RAPDU: - self: SpeculosBackend = args[0] + def decoration(self: 'SpeculosBackend', *args, **kwargs) -> RAPDU: try: - rapdu = function(*args, **kwargs) + rapdu = function(self, *args, **kwargs) except ApduException as error: - if self.raises: + if self.raises and not self.is_valid(error.sw): raise error rapdu = RAPDU(error.sw, error.data) logger.debug("Receiving '%s'", rapdu) @@ -30,8 +29,12 @@ def __init__(self, host: str = "127.0.0.1", port: int = 5000, raises: bool = False, + valid_statuses: Iterable[int] = (0x9000, ), **kwargs): - super().__init__(host, port, raises=raises) + super().__init__(host, + port, + raises=raises, + valid_statuses=valid_statuses) self._client: SpeculosClient = SpeculosClient(app=str(application), api_url=self.url, **kwargs) From 58c569f063a2810a27a7b9d2d20a4d6bc67828fa Mon Sep 17 00:00:00 2001 From: Lucas PASCAL Date: Thu, 12 May 2022 13:58:40 +0200 Subject: [PATCH 3/4] [add] context manager around 'exchange' methods to allow pre-response interaction --- src/ragger/backend/interface.py | 75 +++++++++++++++++++++++++++++++- src/ragger/backend/ledgercomm.py | 10 ++++- src/ragger/backend/speculos.py | 19 +++++++- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/ragger/backend/interface.py b/src/ragger/backend/interface.py index aa906ea8..18a9135a 100644 --- a/src/ragger/backend/interface.py +++ b/src/ragger/backend/interface.py @@ -1,7 +1,8 @@ from abc import ABC, abstractmethod +from contextlib import contextmanager from dataclasses import dataclass from types import TracebackType -from typing import Optional, Type, Iterable +from typing import Optional, Type, Iterable, Generator from ragger.utils import pack_APDU @@ -39,6 +40,17 @@ def __init__(self, self._port = port self._raises = raises self._valid_statuses = valid_statuses + self._last_async_response: Optional[RAPDU] = None + + @property + def last_async_response(self) -> Optional[RAPDU]: + """ + :return: The last RAPDU received after a call to `exchange_async` or + `exchange_async_raw`. `None` if no called was made, or if it + resulted into an exception throw. + :rtype: RAPDU + """ + return self._last_async_response @property def raises(self) -> bool: @@ -186,6 +198,67 @@ def exchange_raw(self, data: bytes = b"") -> RAPDU: """ raise NotImplementedError + @contextmanager + def exchange_async(self, + cla: int, + ins: int, + p1: int = 0, + p2: int = 0, + data: bytes = b"") -> Generator[None, None, None]: + """ + Formats and sends an APDU to the backend, then gives the control back to + the caller. + + This can be useful to still control a device when it has not responded + yet, for instance when a UI validation is requested. + + The context manager does not yield anything. Interaction through the + backend instance is still possible. + + :param cla: The application ID + :type cla: int + :param ins: The command ID + :type ins: int + :param p1: First instruction parameter, defaults to 0 + :type p1: int + :param p1: Second instruction parameter, defaults to 0 + :type p1: int + :param data: Command data + :type data: bytes + + :raises ApduException: If the `raises` attribute is True, this method + will raise if the backend returns a status code + not registered a a `valid_statuses` + + :return: None + :rtype: NoneType + """ + with self.exchange_async_raw(pack_APDU(cla, ins, p1, p2, data)): + yield + + @contextmanager + @abstractmethod + def exchange_async_raw(self, + data: bytes = b"") -> Generator[None, None, None]: + """ + Sends the given APDU to the backend, then gives the control back to the + caller. + + Every part of the APDU (including length) are the caller's + responsibility + + :param data: The APDU message + :type data: bytes + + :raises ApduException: If the `raises` attribute is True, this method + will raise if the backend returns a status code + not registered a a `valid_statuses` + + :return: None + :rtype: NoneType + """ + raise NotImplementedError + @abstractmethod def right_click(self) -> None: """ diff --git a/src/ragger/backend/ledgercomm.py b/src/ragger/backend/ledgercomm.py index a807740d..f4889bd5 100644 --- a/src/ragger/backend/ledgercomm.py +++ b/src/ragger/backend/ledgercomm.py @@ -1,4 +1,5 @@ -from typing import Optional, Iterable +from contextlib import contextmanager +from typing import Optional, Iterable, Generator from ledgercomm import Transport from speculos.client import ApduException @@ -70,6 +71,13 @@ def exchange_raw(self, data: bytes = b"") -> RAPDU: logger.debug("Exchange: receiving < '%s'", result) return result + @contextmanager + def exchange_async_raw(self, + data: bytes = b"") -> Generator[None, None, None]: + self.send_raw(data) + yield + self._last_async_response = self.receive() + def right_click(self) -> None: pass diff --git a/src/ragger/backend/speculos.py b/src/ragger/backend/speculos.py index e4f5a292..b80378b1 100644 --- a/src/ragger/backend/speculos.py +++ b/src/ragger/backend/speculos.py @@ -1,5 +1,6 @@ from pathlib import Path -from typing import Optional, Iterable +from contextlib import contextmanager +from typing import Optional, Iterable, Generator from speculos.client import SpeculosClient, ApduResponse, ApduException @@ -68,6 +69,22 @@ def exchange_raw(self, data: bytes = b"") -> RAPDU: logger.debug("Sending '%s'", data) return RAPDU(0x9000, self._client._apdu_exchange(data)) + @contextmanager + def exchange_async_raw(self, + data: bytes = b"") -> Generator[None, None, None]: + with self._client.apdu_exchange_nowait(cla=data[0], + ins=data[1], + p1=data[2], + p2=data[3], + data=data[5:]) as response: + yield + try: + self._last_async_response = response.receive() + except ApduException as error: + if self.raises and not self.is_valid(error.sw): + raise error + self._last_async_response = RAPDU(error.sw, error.data) + def right_click(self) -> None: self._client.press_and_release("right") From 4796f9ed5fc1593026de70f8079e641666f9e1cf Mon Sep 17 00:00:00 2001 From: Lucas PASCAL Date: Thu, 12 May 2022 14:41:56 +0200 Subject: [PATCH 4/4] [fix] Tiny test update --- .github/workflows/build_and_tests.yml | 2 +- README.md | 4 +-- tests/conftest.py | 38 +++++++++++++++++++++++++++ tests/test_speculos.py | 37 +++++++++++++++----------- 4 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 tests/conftest.py diff --git a/.github/workflows/build_and_tests.yml b/.github/workflows/build_and_tests.yml index 19b3f79a..31806d6c 100644 --- a/.github/workflows/build_and_tests.yml +++ b/.github/workflows/build_and_tests.yml @@ -25,7 +25,7 @@ jobs: uses: dawidd6/action-download-artifact@v2 with: repo: LedgerHQ/app-boilerplate - name: boilerplate-app-nanoS-debug + name: boilerplate-app-nanoS workflow: ci-workflow.yml path: tests/elfs/ - name: Check the downloaded files diff --git a/README.md b/README.md index a382f590..a6faf6a6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ following fixtures: ```python import pytest -from ragger.backends import SpeculosBackend, LedgerCommBackend +from ragger.backend import SpeculosBackend, LedgerCommBackend # adding an pytest CLI option "--live" def pytest_addoption(parser): @@ -35,7 +35,7 @@ def live(pytestconfig): # Depending on the "--live" option value, a different backend is instantiated, # and the tests will either run on Speculos or on a physical device -@pytest.fixture(scope="session") +@pytest.fixture def client(live): if live: backend = LedgerCommBackend(interface="hid", raises=True) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b1591995 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest + +from ragger.backend import SpeculosBackend, LedgerCommBackend + + +APPLICATION = Path(__file__).parent / 'elfs' / 'app.elf' + + +def pytest_addoption(parser): + parser.addoption("--live", action="store_true", default=False) + + +@pytest.fixture(scope="session") +def live(pytestconfig): + return pytestconfig.getoption("live") + + +def create_backend(live: bool, raises: bool = True): + if live: + backend = LedgerCommBackend(interface="hid", raises=raises) + else: + args = ['--model', 'nanos', '--sdk', '2.1'] + backend = SpeculosBackend(APPLICATION, args=args, raises=raises) + return backend + + +@pytest.fixture +def client(live): + with create_backend(live) as b: + yield b + + +@pytest.fixture +def client_no_raise(live): + with create_backend(live, raises=False) as b: + yield b diff --git a/tests/test_speculos.py b/tests/test_speculos.py index 253bed33..8a947e05 100644 --- a/tests/test_speculos.py +++ b/tests/test_speculos.py @@ -1,25 +1,30 @@ -from pathlib import Path +from requests.exceptions import ConnectionError import pytest -from ragger.backend import SpeculosBackend, RAPDU, ApduException +from ragger.backend import ApduException, RAPDU -APPLICATION = Path(__file__).parent / 'elfs' / 'app.elf' +def test_error_returns_not_raises(client_no_raise): + result = client_no_raise.exchange(0x01, 0x00) + assert isinstance(result, RAPDU) + assert result.status == 0x6e00 + assert not result.data -def test_error_returns_not_raises(): - with SpeculosBackend(APPLICATION, raises=False) as client: - result = client.exchange(0x01, 0x00) - assert isinstance(result, RAPDU) - assert result.status == 0x6e00 - assert not result.data +def test_error_raises_not_returns(client): + try: + client.exchange(0x01, 0x00) + except ApduException as e: + assert e.sw == 0x6e00 + assert not e.data -def test_error_raises_not_returns(): - with SpeculosBackend(APPLICATION, raises=True) as client: - try: - client.exchange(0x01, 0x00) - except ApduException as e: - assert e.sw == 0x6e00 - assert not e.data +def test_quit_app(client): + client.right_click() + client.right_click() + client.right_click() + + with pytest.raises(ConnectionError): + # clicking on "Quit", Speculos then stops and raises + client.both_click()