diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e1abc..33c865d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. 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.15.0] - 2025-12-23 + +### Added + +- Load app metadata from companion app_.json file for rust binaries + ## [0.14.0] - 2025-12-03 ### Added diff --git a/src/ledgered/binary.py b/src/ledgered/binary.py index 053691f..267edfb 100644 --- a/src/ledgered/binary.py +++ b/src/ledgered/binary.py @@ -1,3 +1,4 @@ +import json import logging from argparse import ArgumentParser from dataclasses import asdict, dataclass @@ -5,6 +6,7 @@ from pathlib import Path from typing import Optional, Union +from ledgered.devices import Devices from ledgered.serializers import Jsonable LEDGER_PREFIX = "ledger." @@ -46,10 +48,122 @@ def __init__(self, binary_path: Union[str, Path]): } self._sections = Sections(**sections) + # Rust apps store app_name/app_version/app_flags in companion JSON file + if self.is_rust_app: + self._load_metadata_from_json() + + def _load_metadata_from_json(self) -> None: + """Load app metadata from companion app_.json file. + + Rust applications don't embed app_name, app_version, and app_flags in the ELF. + Instead, these are stored in a JSON file named app_.json in the same directory. + This method is called only for Rust applications. + + Note: The target name in the ELF may differ from the JSON filename. + For example, nanos2 -> nanosplus, nanos+ -> nanosplus. + """ + target = self._sections.target + if not target: + logging.warning( + "Rust app detected but no target found, cannot locate companion JSON file" + ) + return + + # Try multiple naming patterns to find the JSON file + json_path = self._find_json_file(target) + if not json_path: + logging.warning( + "Rust app detected but companion JSON file not found for target '%s' in %s", + target, + self._path.parent, + ) + return + + try: + logging.info("Loading Rust app metadata from %s", json_path) + with json_path.open("r") as f: + data = json.load(f) + + if "name" in data: + self._sections.app_name = data["name"] + logging.debug("Loaded app_name: %s", self._sections.app_name) + + if "version" in data: + self._sections.app_version = data["version"] + logging.debug("Loaded app_version: %s", self._sections.app_version) + + if "flags" in data: + self._sections.app_flags = data["flags"] + logging.debug("Loaded app_flags: %s", self._sections.app_flags) + + except (json.JSONDecodeError, OSError) as e: + logging.error("Failed to load companion JSON file %s: %s", json_path, e) + + def _find_json_file(self, target: str) -> Optional[Path]: + """Find the companion JSON file using multiple naming patterns. + + Tries different naming conventions: + 1. Exact target name (e.g., nanos2 -> app_nanos2.json) + 2. Canonical device name (e.g., nanos2 -> app_nanosp.json) + 3. All device aliases (e.g., nanos+, nanosplus for NanoS+) + + Args: + target: The target name from the ELF binary + + Returns: + Path to the JSON file if found, None otherwise + """ + candidates = [target] # Start with exact target name + + try: + device = Devices.get_by_name(target) + # Add canonical device name + candidates.append(device.name) + # Add all known aliases + candidates.extend(device.names) + except KeyError: + logging.debug("Unknown device '%s', trying exact name only", target) + + # Try all candidates + for candidate in candidates: + json_path = self._path.parent / f"app_{candidate}.json" + if json_path.exists(): + logging.debug("Found JSON file with pattern '%s': %s", candidate, json_path) + return json_path + + return None + + def _normalize_target_name(self, target: str) -> str: + """Normalize target name to match JSON filename conventions. + + Device names can vary (e.g., nanos2, nanos+, nanosplus all refer to the same device). + The JSON files use a canonical naming (nanosplus, flex, stax, etc.). + + Args: + target: The target name from the ELF binary + + Returns: + The normalized target name for JSON file lookup + """ + try: + device = Devices.get_by_name(target) + return device.name + except KeyError: + # If device is unknown, return the original target name + logging.debug("Unknown device '%s', using target name as-is", target) + return target + @property def sections(self) -> Sections: return self._sections + @property + def is_rust_app(self) -> bool: + """Returns True if this is a Rust application.""" + return ( + self._sections.rust_sdk_name is not None or self._sections.rust_sdk_version is not None + ) + def set_parser() -> ArgumentParser: parser = ArgumentParser( diff --git a/tests/unit/test_binary.py b/tests/unit/test_binary.py index 1232747..f6dbafe 100644 --- a/tests/unit/test_binary.py +++ b/tests/unit/test_binary.py @@ -1,3 +1,5 @@ +import json +import tempfile from dataclasses import dataclass from unittest import TestCase from unittest.mock import patch @@ -83,3 +85,311 @@ def test___init__from_str(self): path = "/dev/urandom" with patch("ledgered.binary.ELFFile"): B.LedgerBinaryApp(path) + + def test_c_app_detection(self): + """Test that C apps are correctly identified (no rust SDK fields).""" + path = Path("/dev/urandom") + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.app_name", b"Test App"), + Section("ledger.app_version", b"1.0.0"), + Section("ledger.target", b"stax"), + ] + bin = B.LedgerBinaryApp(path) + + self.assertFalse(bin.is_rust_app) + self.assertEqual(bin.sections.app_name, "Test App") + self.assertEqual(bin.sections.app_version, "1.0.0") + + def test_rust_app_detection(self): + """Test that Rust apps are correctly identified by rust_sdk_name.""" + path = Path("/dev/urandom") + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + Section("ledger.target", b"flex"), + ] + bin = B.LedgerBinaryApp(path) + + self.assertTrue(bin.is_rust_app) + + def test_rust_app_detection_by_version(self): + """Test that Rust apps are correctly identified by rust_sdk_version.""" + path = Path("/dev/urandom") + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_version", b"1.12.0"), + Section("ledger.target", b"stax"), + ] + bin = B.LedgerBinaryApp(path) + + self.assertTrue(bin.is_rust_app) + + def test_rust_app_loads_metadata_from_json(self): + """Test that Rust apps load app_name, app_version, and app_flags from JSON.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + binary_path = tmpdir_path / "app-boilerplate-rust" + binary_path.touch() + + # Create companion JSON file + json_data = { + "name": "Rust Boilerplate", + "version": "1.7.7", + "flags": "0x200", + "apiLevel": "25", + "targetId": "0x33300004", + } + json_path = tmpdir_path / "app_flex.json" + with json_path.open("w") as f: + json.dump(json_data, f) + + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + Section("ledger.rust_sdk_version", b"1.12.0"), + Section("ledger.target", b"flex"), + Section("ledger.api_level", b"25"), + ] + bin = B.LedgerBinaryApp(binary_path) + + self.assertTrue(bin.is_rust_app) + self.assertEqual(bin.sections.app_name, "Rust Boilerplate") + self.assertEqual(bin.sections.app_version, "1.7.7") + self.assertEqual(bin.sections.app_flags, "0x200") + self.assertEqual(bin.sections.api_level, "25") + + def test_rust_app_missing_json_file(self): + """Test that Rust apps handle missing JSON file gracefully.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + binary_path = tmpdir_path / "app-boilerplate-rust" + binary_path.touch() + + # No JSON file created + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + Section("ledger.target", b"flex"), + ] + with patch("ledgered.binary.logging") as log_mock: + bin = B.LedgerBinaryApp(binary_path) + + # Should warn about missing JSON + log_mock.warning.assert_called() + + self.assertTrue(bin.is_rust_app) + self.assertIsNone(bin.sections.app_name) + self.assertIsNone(bin.sections.app_version) + + def test_rust_app_no_target(self): + """Test that Rust apps without target field handle JSON loading gracefully.""" + path = Path("/dev/urandom") + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + ] + with patch("ledgered.binary.logging") as log_mock: + bin = B.LedgerBinaryApp(path) + + # Should warn about missing target + log_mock.warning.assert_called() + + self.assertTrue(bin.is_rust_app) + self.assertIsNone(bin.sections.app_name) + + def test_rust_app_malformed_json(self): + """Test that Rust apps handle malformed JSON gracefully.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + binary_path = tmpdir_path / "app-boilerplate-rust" + binary_path.touch() + + # Create malformed JSON file + json_path = tmpdir_path / "app_flex.json" + with json_path.open("w") as f: + f.write("{ this is not valid json }") + + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + Section("ledger.target", b"flex"), + ] + with patch("ledgered.binary.logging") as log_mock: + bin = B.LedgerBinaryApp(binary_path) + + # Should log error + log_mock.error.assert_called() + + self.assertTrue(bin.is_rust_app) + self.assertIsNone(bin.sections.app_name) + + def test_rust_app_partial_json_data(self): + """Test that Rust apps handle JSON with missing fields.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + binary_path = tmpdir_path / "app-boilerplate-rust" + binary_path.touch() + + # Create JSON file with only some fields + json_data = { + "name": "Rust App", + # Missing version and flags + } + json_path = tmpdir_path / "app_stax.json" + with json_path.open("w") as f: + json.dump(json_data, f) + + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + Section("ledger.target", b"stax"), + ] + bin = B.LedgerBinaryApp(binary_path) + + self.assertTrue(bin.is_rust_app) + self.assertEqual(bin.sections.app_name, "Rust App") + self.assertIsNone(bin.sections.app_version) + self.assertIsNone(bin.sections.app_flags) + + def test_c_app_does_not_load_json(self): + """Test that C apps do not attempt to load JSON file even if present.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + binary_path = tmpdir_path / "app.elf" + binary_path.touch() + + # Create JSON file that should be ignored + json_data = { + "name": "Should Be Ignored", + "version": "9.9.9", + } + json_path = tmpdir_path / "app_stax.json" + with json_path.open("w") as f: + json.dump(json_data, f) + + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.app_name", b"C App"), + Section("ledger.app_version", b"1.0.0"), + Section("ledger.target", b"stax"), + ] + bin = B.LedgerBinaryApp(binary_path) + + # Should use ELF data, not JSON + self.assertFalse(bin.is_rust_app) + self.assertEqual(bin.sections.app_name, "C App") + self.assertEqual(bin.sections.app_version, "1.0.0") + + def test_rust_app_device_name_mapping_nanos2_to_nanosplus(self): + """Test that nanos2 target maps to app_nanosplus.json.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + binary_path = tmpdir_path / "app-boilerplate-rust" + binary_path.touch() + + # Create JSON with normalized name + json_data = { + "name": "NanoS+ App", + "version": "1.0.0", + "flags": "0x200", + } + json_path = tmpdir_path / "app_nanosp.json" + with json_path.open("w") as f: + json.dump(json_data, f) + + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + Section("ledger.target", b"nanos2"), # ELF says nanos2 + ] + bin = B.LedgerBinaryApp(binary_path) + + # Should successfully load from app_nanosp.json + self.assertTrue(bin.is_rust_app) + self.assertEqual(bin.sections.app_name, "NanoS+ App") + self.assertEqual(bin.sections.app_version, "1.0.0") + + def test_rust_app_device_name_mapping_nanosplus_to_nanosplus(self): + """Test that nanosplus target maps to app_nanosplus.json.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + binary_path = tmpdir_path / "app-boilerplate-rust" + binary_path.touch() + + # Create JSON with normalized name + json_data = { + "name": "NanoS+ App", + "version": "2.0.0", + } + json_path = tmpdir_path / "app_nanosp.json" + with json_path.open("w") as f: + json.dump(json_data, f) + + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + Section("ledger.target", b"nanosplus"), # ELF says nanosplus + ] + bin = B.LedgerBinaryApp(binary_path) + + # Should successfully load from app_nanosp.json + self.assertTrue(bin.is_rust_app) + self.assertEqual(bin.sections.app_name, "NanoS+ App") + self.assertEqual(bin.sections.app_version, "2.0.0") + + def test_rust_app_device_name_mapping_nanos_plus_to_nanosplus(self): + """Test that nanos+ target maps to app_nanosplus.json.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + binary_path = tmpdir_path / "app-boilerplate-rust" + binary_path.touch() + + # Create JSON with normalized name + json_data = { + "name": "NanoS+ App", + "version": "3.0.0", + } + json_path = tmpdir_path / "app_nanosp.json" + with json_path.open("w") as f: + json.dump(json_data, f) + + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + Section("ledger.target", b"nanos+"), # ELF says nanos+ + ] + bin = B.LedgerBinaryApp(binary_path) + + # Should successfully load from app_nanosp.json + self.assertTrue(bin.is_rust_app) + self.assertEqual(bin.sections.app_name, "NanoS+ App") + self.assertEqual(bin.sections.app_version, "3.0.0") + + def test_rust_app_unknown_device_fallback(self): + """Test that unknown device names fallback to using the original target name.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + binary_path = tmpdir_path / "app-boilerplate-rust" + binary_path.touch() + + # Create JSON with the unknown device name + json_data = { + "name": "Future Device App", + "version": "1.0.0", + } + json_path = tmpdir_path / "app_futuredevice.json" + with json_path.open("w") as f: + json.dump(json_data, f) + + with patch("ledgered.binary.ELFFile") as elfmock: + elfmock().iter_sections.return_value = [ + Section("ledger.rust_sdk_name", b"ledger_secure_sdk_sys"), + Section("ledger.target", b"futuredevice"), # Unknown device + ] + bin = B.LedgerBinaryApp(binary_path) + + # Should use original name and successfully load + self.assertTrue(bin.is_rust_app) + self.assertEqual(bin.sections.app_name, "Future Device App") + self.assertEqual(bin.sections.app_version, "1.0.0")