diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8a676..5fc9347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2024-03-26 + +### Added + +- ledger-binary: Adding an utilitary to parse embedded application ELF file metadatas. + +### Changed + +- Renamed 'Europa' with official product name 'Flex' + + ## [0.5.0] - 2024-03-11 ### Added diff --git a/doc/binary.md b/doc/binary.md new file mode 100644 index 0000000..cbb5025 --- /dev/null +++ b/doc/binary.md @@ -0,0 +1,44 @@ +# Binary parser + +Ledger embedded application ELF file contains metadata injected during the compilation. These +information are used by tools like [Speculos](https://speculos.ledger.com) to ease the manipulation +of the binaries (for instance: no need to explicit the device type, the SDK version, etc.). + +For easier integration in Python code, `Ledgered` integrates a small binary parser to extract these +data. This code is the `ledgered.binary` library, and `Ledgered` also provides a CLI entrypoint +(`ledger-binary`). + +## `ledger-binary` utilitary + +### Example + +Let's suppose we're compiling of the [Boilerplate application](https://github.com/LedgerHQ/app-boilerplate) +for Stax: + +```bash +bash-5.1# make -j BOLOS_SDK=$STAX_SDK +``` + +The resulting ELF file is stored at (relative path) `./build/stax/bin/app.elf`. Let's use +`ledger-binary` to inspect this file: + +```bash +$ ledger-binary build/stax/bin/app.elf +api_level 15 +app_name Boilerplate +app_version 2.1.0 +sdk_graphics bagl +sdk_hash a23bad84cbf39a5071644d2191b177191c089b23 +sdk_name ledger-secure-sdk +sdk_version v15.1.0 +target stax +target_id 0x33200004 +target_name TARGET_STAX +``` + +It is also possible to ask for a JSON-like output: + +```bash +$ ledger-binary build/stax/bin/app.elf -j +{'api_level': '15', 'app_name': 'Boilerplate', 'app_version': '2.1.0', 'sdk_graphics': 'bagl', 'sdk_hash': 'a23bad84cbf39a5071644d2191b177191c089b23', 'sdk_name': 'ledger-secure-sdk', 'sdk_version': 'v15.1.0', 'target': 'stax', 'target_id': '0x33200004', 'target_name': 'TARGET_STAX'} +``` diff --git a/doc/manifest.md b/doc/manifest.md index 971efbc..c977267 100644 --- a/doc/manifest.md +++ b/doc/manifest.md @@ -6,7 +6,7 @@ This manifest contains application metadata such as build directory, compatible and is used by several tools to know how to build or test the application. The `ledgered.utils.manifest` library is used to parse and manipulate application manifests -in Python code. `Ledgered` also provides a cli entrypoint (`ledger-manifest`) to parse, extract +in Python code. `Ledgered` also provides a CLI entrypoint (`ledger-manifest`) to parse, extract and check information from manifests. ## Manifest content diff --git a/pyproject.toml b/pyproject.toml index 3dbbf60..188a421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dynamic = [ "version" ] requires-python = ">=3.7" dependencies = [ "toml", + "pyelftools", ] [project.optional-dependencies] @@ -41,6 +42,7 @@ Home = "https://github.com/LedgerHQ/ledgered" [project.scripts] ledger-manifest = "ledgered.manifest.cli:main" +ledger-binary = "ledgered.binary:main" [tool.setuptools_scm] write_to = "src/ledgered/__version__.py" diff --git a/src/ledgered/binary.py b/src/ledgered/binary.py new file mode 100644 index 0000000..95725eb --- /dev/null +++ b/src/ledgered/binary.py @@ -0,0 +1,78 @@ +import logging +from argparse import ArgumentParser +from dataclasses import asdict, dataclass +from elftools.elf.elffile import ELFFile +from pathlib import Path +from typing import Optional, Union + +from ledgered.serializers import Jsonable + +LEDGER_PREFIX = "ledger." +DEFAULT_GRAPHICS = "bagl" + + +@dataclass +class Sections(Jsonable): + api_level: Optional[str] = None + app_name: Optional[str] = None + app_version: Optional[str] = None + sdk_graphics: str = DEFAULT_GRAPHICS + sdk_hash: Optional[str] = None + sdk_name: Optional[str] = None + sdk_version: Optional[str] = None + target: Optional[str] = None + target_id: Optional[str] = None + target_name: Optional[str] = None + + def __str__(self) -> str: + return "\n".join(f"{key} {value}" for key, value in sorted(asdict(self).items())) + + +class LedgerBinaryApp: + + def __init__(self, binary_path: Union[str, Path]): + if isinstance(binary_path, str): + binary_path = Path(binary_path) + self._path = binary_path = binary_path.resolve() + logging.info("Parsing binary '%s'", self._path) + with self._path.open("rb") as filee: + sections = { + s.name.replace(LEDGER_PREFIX, ""): s.data().decode() + for s in ELFFile(filee).iter_sections() if LEDGER_PREFIX in s.name + } + self._sections = Sections(**sections) + + @property + def sections(self) -> Sections: + return self._sections + + +def set_parser() -> ArgumentParser: + parser = ArgumentParser(prog="ledger-binary", + description="Utilitary to parse Ledger embedded application ELF file " + "and output metadatas") + parser.add_argument('-v', '--verbose', action='count', default=0) + parser.add_argument("binary", type=Path, help="The ledger embedded application ELF file") + parser.add_argument("-j", + "--json", + required=False, + action="store_true", + help="outputs as JSON rather than text") + return parser + + +def main() -> None: + args = set_parser().parse_args() + assert args.binary.is_file(), f"'{args.binary.resolve()}' does not appear to be a file." + + # verbosity + if args.verbose == 1: + logging.root.setLevel(logging.INFO) + elif args.verbose > 1: + logging.root.setLevel(logging.DEBUG) + + app = LedgerBinaryApp(args.binary) + if args.json: + print(app.sections.json) + else: + print(app.sections) diff --git a/src/ledgered/manifest/app.py b/src/ledgered/manifest/app.py index e85d279..5128ba3 100644 --- a/src/ledgered/manifest/app.py +++ b/src/ledgered/manifest/app.py @@ -2,8 +2,8 @@ from pathlib import Path from typing import Iterable, Union +from ledgered.serializers import Jsonable, JsonSet from .constants import EXISTING_DEVICES -from .types import Jsonable, JsonSet @dataclass diff --git a/src/ledgered/manifest/constants.py b/src/ledgered/manifest/constants.py index 6d1161e..94788cc 100644 --- a/src/ledgered/manifest/constants.py +++ b/src/ledgered/manifest/constants.py @@ -1,3 +1,3 @@ -EXISTING_DEVICES = ["nanos", "nanox", "nanos+", "stax", "europa"] +EXISTING_DEVICES = ["nanos", "nanox", "nanos+", "stax", "flex"] MANIFEST_FILE_NAME = "ledger_app.toml" DEFAULT_USE_CASE = "default" diff --git a/src/ledgered/manifest/manifest.py b/src/ledgered/manifest/manifest.py index 6bbdfed..2be3ca9 100644 --- a/src/ledgered/manifest/manifest.py +++ b/src/ledgered/manifest/manifest.py @@ -4,10 +4,10 @@ from pathlib import Path from typing import Dict, IO, Optional, Union +from ledgered.serializers import Jsonable from .app import AppConfig from .constants import MANIFEST_FILE_NAME from .tests import TestsConfig -from .types import Jsonable from .use_cases import UseCasesConfig diff --git a/src/ledgered/manifest/tests.py b/src/ledgered/manifest/tests.py index e4c3249..53cc031 100644 --- a/src/ledgered/manifest/tests.py +++ b/src/ledgered/manifest/tests.py @@ -3,9 +3,9 @@ from typing import Dict, List, Optional, Union from urllib.parse import urlparse +from ledgered.serializers import Jsonable, JsonDict, JsonSet from .constants import DEFAULT_USE_CASE from .errors import MissingField -from .types import Jsonable, JsonDict, JsonSet from .utils import getLogger APPLICATION_DIRECTORY_KEY = "application_directory" diff --git a/src/ledgered/manifest/use_cases.py b/src/ledgered/manifest/use_cases.py index 057c4ef..f3dcc3c 100644 --- a/src/ledgered/manifest/use_cases.py +++ b/src/ledgered/manifest/use_cases.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import Dict, Optional +from ledgered.serializers import Jsonable, JsonDict from .constants import DEFAULT_USE_CASE -from .types import Jsonable, JsonDict @dataclass diff --git a/src/ledgered/manifest/types.py b/src/ledgered/serializers.py similarity index 100% rename from src/ledgered/manifest/types.py rename to src/ledgered/serializers.py diff --git a/tests/_data/ledger_app.toml b/tests/_data/ledger_app.toml index 4c1991d..e412d2b 100644 --- a/tests/_data/ledger_app.toml +++ b/tests/_data/ledger_app.toml @@ -1,8 +1,8 @@ [app] sdk = "Rust" build_directory = "" -devices = ["nanos", "Stax", "EUROPA"] +devices = ["nanos", "Stax", "FLEX"] [tests] unit_directory = "unit" -pytest_directory = "pytest" \ No newline at end of file +pytest_directory = "pytest" diff --git a/tests/unit/manifest/test_manifest.py b/tests/unit/manifest/test_manifest.py index 2d7f5fa..673a687 100644 --- a/tests/unit/manifest/test_manifest.py +++ b/tests/unit/manifest/test_manifest.py @@ -10,7 +10,7 @@ class TestManifest(TestCase): def check_ledger_app_toml(self, manifest: Manifest) -> None: self.assertEqual(manifest.app.sdk, "rust") - self.assertEqual(manifest.app.devices, {"nanos", "stax", "europa"}) + self.assertEqual(manifest.app.devices, {"nanos", "stax", "flex"}) self.assertEqual(manifest.app.build_directory, Path("")) self.assertTrue(manifest.app.is_rust) self.assertFalse(manifest.app.is_c) @@ -19,7 +19,7 @@ def check_ledger_app_toml(self, manifest: Manifest) -> None: self.assertEqual(manifest.tests.pytest_directory, Path("pytest")) def test___init__ok(self): - app = {"sdk": "rust", "devices": ["NANOS", "stAX", "europa"], "build_directory": ""} + app = {"sdk": "rust", "devices": ["NANOS", "stAX", "flex"], "build_directory": ""} tests = {"unit_directory": "unit", "pytest_directory": "pytest"} self.check_ledger_app_toml(Manifest(app, tests)) diff --git a/tests/unit/test_binary.py b/tests/unit/test_binary.py new file mode 100644 index 0000000..000e874 --- /dev/null +++ b/tests/unit/test_binary.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass +from unittest import TestCase +from unittest.mock import MagicMock, patch +from pathlib import Path +from typing import Any + +from ledgered import binary as B + + +class TestSections(TestCase): + + def setUp(self): + self.inputs = { + "api_level": "api_level", + "app_name": "app_name", + "app_version": "app_version", + "sdk_graphics": "sdk_graphics", + "sdk_hash": "sdk_hash", + "sdk_name": "sdk_name", + "sdk_version": "sdk_version", + "target": "target", + "target_id": "target_id", + "target_name": "target_name" + } + + def test___init__empty(self): + sections = B.Sections() + self.assertIsNone(sections.api_level) + self.assertIsNone(sections.app_name) + self.assertIsNone(sections.app_version) + self.assertEqual(sections.sdk_graphics, B.DEFAULT_GRAPHICS) + self.assertIsNone(sections.sdk_hash) + self.assertIsNone(sections.sdk_name) + self.assertIsNone(sections.sdk_version) + self.assertIsNone(sections.target) + self.assertIsNone(sections.target_id) + self.assertIsNone(sections.target_name) + + def test___str__(self): + sections = B.Sections(**self.inputs) + self.assertEqual("\n".join(f"{k} {v}" + for k,v in sorted(self.inputs.items())), + str(sections)) + + def test_json(self): + sections = B.Sections(**self.inputs) + self.assertDictEqual(self.inputs, sections.json) + + +@dataclass +class Section: + name: str + _data: Any + def data(self) -> Any: + return self._data + + +class TestLedgerBinaryApp(TestCase): + + def test___init__(self): + path = Path("/dev/urandom") + api_level, sdk_hash = "something", "some hash" + expected = B.Sections(api_level=api_level, sdk_hash=sdk_hash) + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("unused", 1), + Section("ledger.api_level", api_level.encode()), + Section("ledger.sdk_hash", sdk_hash.encode()), + Section("still not used", b"some data") + ] + bin = B.LedgerBinaryApp(path) + self.assertEqual(bin.sections, expected) + + def test___init__from_str(self): + path = "/dev/urandom" + with patch("ledgered.binary.ELFFile") as elfmock: + B.LedgerBinaryApp(path) diff --git a/tests/unit/manifest/test_types.py b/tests/unit/test_serializers.py similarity index 95% rename from tests/unit/manifest/test_types.py rename to tests/unit/test_serializers.py index 9bd0a09..a52a3bc 100644 --- a/tests/unit/manifest/test_types.py +++ b/tests/unit/test_serializers.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from unittest import TestCase -from ledgered.manifest.types import Jsonable, JsonList, JsonSet, JsonDict +from ledgered.serializers import Jsonable, JsonList, JsonSet, JsonDict @dataclass