diff --git a/tools/motatool/.gitignore b/tools/motatool/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/tools/motatool/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/tools/motatool/CMakeLists.txt b/tools/motatool/CMakeLists.txt new file mode 100644 index 00000000..4a153063 --- /dev/null +++ b/tools/motatool/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.16) +project(motatool LANGUAGES CXX) + +# Self-contained MeshCore OTA host tool: build / verify / inspect / serve `.mota` containers. +# Portable C++17 — builds on Ubuntu, Raspberry Pi, and any arch with a C++17 compiler + OpenSSL. +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() +add_compile_options(-Wall -Wextra) + +# OpenSSL libcrypto provides SHA-256 + Ed25519 (sign/verify/keygen). On Debian/Ubuntu/Pi: +# sudo apt install libssl-dev +find_package(OpenSSL REQUIRED) + +# Core logic (everything except the CLI front-end) — shared by the tool and the test runner. +add_library(motatool_core STATIC + src/mota.cpp + src/crypto.cpp + src/input.cpp + src/serve.cpp +) +# `src` for the tool's own headers; the repo's helpers/ota for the shared, generated OtaTargets.h +# (target_id -> env-name table — single source of truth with the firmware, no hand-maintained copy). +target_include_directories(motatool_core PUBLIC + src + "${CMAKE_CURRENT_SOURCE_DIR}/../../src/helpers/ota" +) +target_link_libraries(motatool_core PUBLIC OpenSSL::Crypto) + +add_executable(motatool src/main.cpp) +target_link_libraries(motatool PRIVATE motatool_core) +install(TARGETS motatool RUNTIME DESTINATION bin) + +# Unit tests (no external test framework — a tiny built-in harness keeps the project self-contained). +# Run with: cmake --build build && ctest --test-dir build --output-on-failure +# or: ./build/motatool_tests +enable_testing() +add_executable(motatool_tests tests/test_motatool.cpp) +target_link_libraries(motatool_tests PRIVATE motatool_core) +add_test(NAME motatool_tests COMMAND motatool_tests) diff --git a/tools/motatool/README.md b/tools/motatool/README.md new file mode 100644 index 00000000..d934b8c4 --- /dev/null +++ b/tools/motatool/README.md @@ -0,0 +1,114 @@ +# motatool — self-contained MeshCore OTA host tool (C++) + +A small, portable C++17 tool that **builds**, **verifies**, and **serves** MeshCore `.mota` +firmware-update containers. It is an independent project (its own `CMakeLists.txt`) that compiles on a +laptop, a Raspberry Pi, or any architecture with a C++17 compiler — so it can run as a lightweight +folder-relay daemon on small hardware. + +It implements the wire spec in [`../../docs/ota_protocol.md`](../../docs/ota_protocol.md) and is +cross-checked byte-for-byte against the Python reference packager `tools/mota/motalib.py` (a full signed +`.mota` built by `motatool` is identical to the reference's output). + +## What it does + +| Command | Purpose | +|---|---| +| `build` | Create a **full** or **delta** `.mota` from a firmware (local file **or** http(s) URL). | +| `verify` | Validate one or more `.mota` (merkle tree, leaves vs payload, image hash, Ed25519 signature). `--pub` requires a specific signer; `--base` confirms a sequential delta rebuilds its image. | +| `inspect`| Print every field of a `.mota`'s manifest (debugging). | +| `serve` | Serve a **folder** of `.mota` to a node over USB serial. Invalid files are warned about and skipped — one corrupt file never sinks the rest. | +| `keygen` | Generate an Ed25519 signing keypair (hex). | + +Every command has detailed, example-rich help: `motatool --help`. + +## Build + +Dependencies: a C++17 compiler, CMake, and **OpenSSL** (libcrypto: SHA-256 + Ed25519). For URL input +and delta creation, see the notes below. + +```bash +sudo apt install build-essential cmake libssl-dev # Debian / Ubuntu / Raspberry Pi OS +cmake -S tools/motatool -B tools/motatool/build +cmake --build tools/motatool/build -j +# -> tools/motatool/build/motatool +``` + +### Tests + +Unit tests (a tiny built-in harness — no external framework) cover the crypto, EndF, build/verify, +merkle, parse rejection, the folder scanner + seeder protocol, and a real detools delta round-trip +(auto-skipped if `detools` isn't installed): + +```bash +ctest --test-dir tools/motatool/build --output-on-failure +# or directly: tools/motatool/build/motatool_tests +``` + +## Usage + +```bash +MT=tools/motatool/build/motatool + +# 1. signing keypair (64-char hex) +$MT keygen --out signer.key # writes signer.key + signer.key.pub + +# 2a. FULL .mota — payload IS the flashable image (identity is auto-read from the firmware's EndF) +$MT build --fw firmware.bin --sign signer.key --out-dir ./motas/ + +# 2b. FULL .mota straight from a URL +$MT build --fw https://example.org/Heltec_v3_repeater.bin --sign signer.key --out-dir ./motas/ + +# 2c. DELTA .mota against a previous release (codec auto-selected from the hardware tag: +# nRF52 -> in-place, ESP32 -> sequential; override with --codec) +$MT build --fw new.bin --base old.bin --sign signer.key --out-dir ./motas/ +$MT build --fw new.bin --base old.bin --codec sequential --out-dir ./motas/ + +# 3. validate a folder of .mota (optionally require a signer / check a delta against its base) +$MT verify ./motas/*.mota +$MT verify update.mota --pub signer.key.pub +$MT verify delta.mota --base old_firmware.bin + +# 4. dump a single .mota's manifest fields +$MT inspect ./motas/RAK4631_04D413FD_v1.16.0_full_ABCD1234.mota + +# 5. serve a folder to a node over its USB serial (recursive; skips non-.mota; warns on corrupt) +$MT serve --dir ./motas --serial /dev/ttyUSB0 --baud 115200 -v +``` + +`build` notes: +- **Identity is self-described.** A firmware built by the project's `pio_endf.py` carries its + `target_id`/`fw_version`/`hw_id` in its 56-byte `EndF` trailer; `build` reads them, so + `--target-env`/`--target-id`, `--fw-version`, and `--hw-id` are **optional** (explicit flags override). +- **Cross-hardware delta guard.** A delta is refused if the base and target firmware identities differ + (read from their `EndF`, not filenames); pass `--force` to override. +- **Delta codec** needs the `detools` encoder (the project's pinned codec — never reimplemented). Install + it (`pip install detools`) and ensure it's on `PATH`, or pass `--detools `. **Full** builds, + **verify**, and **serve** need no detools. In-place defaults: `--inplace-memory 0xAE000` + (nRF52 workspace), `--inplace-segment 4096`. +- **URL input** uses the system `curl` (falling back to `wget`) — no link-time dependency. +- All output is written into one folder (`--out-dir`, default `.`) with a descriptive, unique name: + `__v__.mota`. + +## Serving is transport-agnostic (USB serial today, BLE later) + +The protocol is split so the serving logic is reusable across links: + +- **`SeederCore`** (`src/serve.{h,cpp}`) is transport-free: it maps a request `(op, args)` to a response + `(status, payload)` — `COUNT` / `DESCRIBE(idx)` / `READ(idx, off, len)` over the validated catalog. +- **`SerialTransport` + `serve_serial()`** wrap it with the byte-stream framing (magic + XOR checksum + + resync) for the unreliable USB-UART link (`MotaSeederProto.h`). + +To serve the same folder over **BLE** (e.g. an Android phone relaying to a MeshCore node), implement the +transport at the GATT layer — a request characteristic write hands `(op, args)` straight to +`SeederCore::handle()` and the reply is sent as a notification. No byte-stream framing is needed there +(BLE is reliable/segmented), and `SeederCore` + `Folder` are reused unchanged. OpenSSL builds for the +Android NDK, so the validation path ports as-is. + +## Relationship to `tools/mota/` + +`motatool` is the user-facing CLI: it replaced the old Python `mota.py` (build/verify/inspect/keygen), +`mota_seeder.py` (serve), and `dev_motas.py` (CI `.mota` packaging — its nRF52 `.hex` extraction is now a +built-in `motatool` input format), all removed. The Python `tools/mota/` directory remains as the +**reference implementation** (`motalib.py`, the spec oracle + unit tests) and the **firmware build/test +glue** (`pio_endf.py` build hook, `gen_vectors.py` test vectors). Their `.mota` output is byte-identical +(verified by cross-checks). diff --git a/tools/motatool/src/crypto.cpp b/tools/motatool/src/crypto.cpp new file mode 100644 index 00000000..a4076a9c --- /dev/null +++ b/tools/motatool/src/crypto.cpp @@ -0,0 +1,61 @@ +#include "crypto.h" +#include +#include +#include + +namespace mota { + +void sha256_trunc(uint8_t* out, size_t out_len, const uint8_t* data, size_t len) { + uint8_t full[32]; + SHA256(data, len, full); + if (out_len > 32) out_len = 32; + std::memcpy(out, full, out_len); +} + +bool ed25519_verify(const uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t pub[32]) { + EVP_PKEY* key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, pub, 32); + if (!key) return false; + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, nullptr, nullptr, key) == 1 && + EVP_DigestVerify(ctx, sig, 64, msg, msg_len) == 1; + if (ctx) EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + return ok; +} + +bool ed25519_sign(uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t priv[32]) { + EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, priv, 32); + if (!key) return false; + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + size_t siglen = 64; + bool ok = ctx && EVP_DigestSignInit(ctx, nullptr, nullptr, nullptr, key) == 1 && + EVP_DigestSign(ctx, sig, &siglen, msg, msg_len) == 1 && siglen == 64; + if (ctx) EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + return ok; +} + +bool ed25519_pub_from_priv(uint8_t pub[32], const uint8_t priv[32]) { + EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, priv, 32); + if (!key) return false; + size_t len = 32; + bool ok = EVP_PKEY_get_raw_public_key(key, pub, &len) == 1 && len == 32; + EVP_PKEY_free(key); + return ok; +} + +bool ed25519_keygen(uint8_t priv[32], uint8_t pub[32]) { + EVP_PKEY* key = nullptr; + EVP_PKEY_CTX* pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, nullptr); + bool ok = pctx && EVP_PKEY_keygen_init(pctx) == 1 && EVP_PKEY_keygen(pctx, &key) == 1; + if (ok) { + size_t lp = 32, lk = 32; + ok = EVP_PKEY_get_raw_private_key(key, priv, &lp) == 1 && lp == 32 && + EVP_PKEY_get_raw_public_key(key, pub, &lk) == 1 && lk == 32; + } + if (key) EVP_PKEY_free(key); + if (pctx) EVP_PKEY_CTX_free(pctx); + return ok; +} + +} // namespace mota diff --git a/tools/motatool/src/crypto.h b/tools/motatool/src/crypto.h new file mode 100644 index 00000000..eac5d8d3 --- /dev/null +++ b/tools/motatool/src/crypto.h @@ -0,0 +1,28 @@ +// SHA-256 (multihash truncations) and Ed25519 — backed by OpenSSL libcrypto, so the tool is portable +// across Ubuntu / Raspberry Pi / any arch that has OpenSSL (and Android NDK for a future BLE seeder). +#pragma once +#include +#include +#include +#include +#include + +namespace mota { + +// SHA-256 of `data`, truncated to out_len (sha2-256:N). out_len <= 32. +void sha256_trunc(uint8_t* out, size_t out_len, const uint8_t* data, size_t len); + +inline std::array mh4(const uint8_t* d, size_t n) { std::array o; sha256_trunc(o.data(),4,d,n); return o; } +inline std::array mh8(const uint8_t* d, size_t n) { std::array o; sha256_trunc(o.data(),8,d,n); return o; } +inline std::array mh32(const uint8_t* d, size_t n) { std::array o; sha256_trunc(o.data(),32,d,n); return o; } + +// Ed25519. Keys are raw 32-byte (private seed / public key), matching motalib / the device. +bool ed25519_verify(const uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t pub[32]); +// Sign `msg` with a 32-byte raw private seed -> 64-byte signature. Returns false on error. +bool ed25519_sign(uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t priv[32]); +// Derive the 32-byte raw public key from a 32-byte raw private seed. +bool ed25519_pub_from_priv(uint8_t pub[32], const uint8_t priv[32]); +// Generate a fresh keypair (raw 32-byte each). +bool ed25519_keygen(uint8_t priv[32], uint8_t pub[32]); + +} // namespace mota diff --git a/tools/motatool/src/input.cpp b/tools/motatool/src/input.cpp new file mode 100644 index 00000000..bb0a4b12 --- /dev/null +++ b/tools/motatool/src/input.cpp @@ -0,0 +1,102 @@ +#include "input.h" +#include "util.h" +#include +#include +#include + +namespace mota { + +bool is_url(const std::string& s) { + return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0; +} + +static bool ends_with_ci(const std::string& s, const std::string& suf) { + if (s.size() < suf.size()) return false; + for (size_t i = 0; i < suf.size(); i++) + if (std::tolower((unsigned char)s[s.size() - suf.size() + i]) != std::tolower((unsigned char)suf[i])) return false; + return true; +} + +// Parse Intel HEX text into the flat binary it represents (min..max address, gaps filled 0xFF). +// nRF52/STM32 PlatformIO builds emit firmware.hex; pio_endf appends the EndF inside it, so the extracted +// binary IS the OTA image (BODY||EndF) starting at the app base — exactly what `build` wants. +static std::string parse_intel_hex(const std::vector& in, std::vector& out) { + const std::string s((const char*)in.data(), in.size()); + auto nib = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + struct Seg { uint64_t addr; std::vector data; }; + std::vector segs; + uint64_t base = 0, lo = UINT64_MAX, hi = 0; + size_t i = 0; + bool saw_eof = false; + while (i < s.size()) { + if (s[i] != ':') { i++; continue; } // tolerate CR/LF/whitespace between records + size_t p = i + 1; + auto rb = [&](uint8_t& v) -> bool { + if (p + 2 > s.size()) return false; + int h = nib(s[p]), l = nib(s[p + 1]); + if (h < 0 || l < 0) return false; + v = (uint8_t)((h << 4) | l); p += 2; return true; + }; + uint8_t len, ah, al, type; + if (!rb(len) || !rb(ah) || !rb(al) || !rb(type)) return "malformed Intel HEX record"; + uint8_t sum = (uint8_t)(len + ah + al + type); + std::vector data(len); + for (uint8_t k = 0; k < len; k++) { if (!rb(data[k])) return "truncated Intel HEX data"; sum += data[k]; } + uint8_t cks; + if (!rb(cks)) return "missing Intel HEX checksum"; + if ((uint8_t)(sum + cks) != 0) return "Intel HEX checksum error"; + uint16_t addr = (uint16_t)((ah << 8) | al); + if (type == 0x00) { // data + uint64_t a = base + addr; + if (a < lo) lo = a; + if (a + len > hi) hi = a + len; + segs.push_back({a, std::move(data)}); + } else if (type == 0x04) { // extended linear address (upper 16 bits) + if (len != 2) return "bad Intel HEX ELA record"; + base = (uint64_t)((data[0] << 8) | data[1]) << 16; + } else if (type == 0x02) { // extended segment address + if (len != 2) return "bad Intel HEX ESA record"; + base = (uint64_t)((data[0] << 8) | data[1]) << 4; + } else if (type == 0x01) { // EOF + saw_eof = true; break; + } // 0x03/0x05 (start address) ignored + i = p; + } + if (segs.empty()) return "no data records in Intel HEX"; + if (!saw_eof) return "Intel HEX missing EOF record"; + out.assign((size_t)(hi - lo), 0xFF); + for (auto& sg : segs) std::memcpy(out.data() + (size_t)(sg.addr - lo), sg.data.data(), sg.data.size()); + return ""; +} + +std::string read_input(const std::string& src, std::vector& out) { + std::vector raw; + if (!is_url(src)) { + if (!read_file(src, raw)) return "cannot read file: " + src; + } else { + // URL: download to a temp file via curl (fallback wget), then read it back. + char tmp[] = "/tmp/motadlXXXXXX"; + int fd = mkstemp(tmp); + if (fd < 0) return "mkstemp failed"; + close(fd); + int rc = run_argv({"curl", "-fLsS", "-o", tmp, src}); // -f fail, -L follow, -sS silent+show-errors + if (rc == 127) rc = run_argv({"wget", "-q", "-O", tmp, src}); + std::string err; + if (rc == 127) err = "neither curl nor wget is available to fetch " + src; + else if (rc != 0) err = "download failed (exit " + std::to_string(rc) + "): " + src; + else if (!read_file(tmp, raw)) err = "could not read the downloaded file: " + src; + unlink(tmp); + if (!err.empty()) return err; + } + if (raw.empty()) return "input is empty: " + src; + if (ends_with_ci(src, ".hex")) return parse_intel_hex(raw, out); // Intel HEX -> flat image + out = std::move(raw); + return ""; +} + +} // namespace mota diff --git a/tools/motatool/src/input.h b/tools/motatool/src/input.h new file mode 100644 index 00000000..ff92f5fd --- /dev/null +++ b/tools/motatool/src/input.h @@ -0,0 +1,15 @@ +// Read a firmware blob from a local file OR an http(s):// URL. URLs are fetched with the system `curl` +// (or `wget`) binary — no link-time dependency, so the tool stays self-contained and builds anywhere. +#pragma once +#include +#include +#include + +namespace mota { + +// Returns "" on success (fills `out`), else an error string. `what` is a label for error messages. +std::string read_input(const std::string& path_or_url, std::vector& out); + +bool is_url(const std::string& s); + +} // namespace mota diff --git a/tools/motatool/src/main.cpp b/tools/motatool/src/main.cpp new file mode 100644 index 00000000..7e8bb0b6 --- /dev/null +++ b/tools/motatool/src/main.cpp @@ -0,0 +1,501 @@ +// motatool — build, verify, and serve MeshCore `.mota` firmware-update containers. +// build create a full or delta .mota from a firmware (file or http(s) URL) +// verify validate one or more .mota (merkle / hashes / signature) +// serve serve a folder of .mota to a node (USB serial); invalid files are warned + skipped +// keygen generate an Ed25519 signing keypair (64-char hex) +#include "mota.h" +#include "input.h" +#include "serve.h" +#include "crypto.h" +#include "util.h" +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace mota; + +static volatile bool g_stop = false; +static void on_sigint(int) { g_stop = true; } + +// minimal flag parser: --key value / --flag +struct Args { + std::map opt; + std::vector pos; + bool has(const std::string& k) const { return opt.count(k); } + std::string get(const std::string& k, const std::string& d = "") const { + auto it = opt.find(k); return it == opt.end() ? d : it->second; + } +}; +static Args parse_args(int argc, char** argv, int start, + const std::vector& bool_flags) { + Args a; + for (int i = start; i < argc; i++) { + std::string s = argv[i]; + if (s == "-h" || s == "--help" || s == "help") { a.opt["help"] = "1"; continue; } + if (s == "-v") { a.opt["verbose"] = "1"; continue; } + if (s.rfind("--", 0) == 0) { + std::string k = s.substr(2); + if (std::find(bool_flags.begin(), bool_flags.end(), k) != bool_flags.end()) a.opt[k] = "1"; + else if (i + 1 < argc) a.opt[k] = argv[++i]; + else a.opt[k] = ""; + } else a.pos.push_back(s); + } + return a; +} + +static bool load_priv(const std::string& path, std::vector& priv) { + std::vector raw; + if (!read_file(path, raw)) return false; + std::string txt((const char*)raw.data(), raw.size()); // try hex (mota.py format) first + while (!txt.empty() && (std::isspace((unsigned char)txt.back()))) txt.pop_back(); + std::vector hx; + if (from_hex(txt, hx) && hx.size() == 32) { priv = hx; return true; } + if (raw.size() == 32) { priv = raw; return true; } // else accept a raw 32-byte file + return false; +} + +static std::string version_str(uint32_t v) { + char b[24]; std::snprintf(b, sizeof(b), "%u.%u.%u", (v>>24)&0xFF, (v>>16)&0xFF, (v>>8)&0xFF); + return b; +} + +// ---- help text (the tool is meant to be usable from --help alone) --------------------------------- +static void help_top() { + std::cout << +"motatool — build, verify, and serve MeshCore .mota firmware-update containers.\n" +"\n" +"A .mota is a signed, self-verifying package of a firmware update that MeshCore nodes fetch\n" +"over LoRa, block by block. This tool makes those packages, checks them, and serves a folder\n" +"of them to a node over USB.\n" +"\n" +"USAGE\n" +" motatool [options]\n" +" motatool --help detailed help + examples for a command\n" +"\n" +"COMMANDS\n" +" build Package a firmware as a .mota (a full image, or a small delta vs a previous build).\n" +" verify Check that .mota files are valid (block hashes, image hash, signature).\n" +" inspect Print every field of a .mota's manifest (debugging).\n" +" serve Serve a folder of .mota to a node over USB serial (corrupt files are skipped).\n" +" keygen Generate an Ed25519 signing keypair.\n" +"\n" +"TYPICAL WORKFLOW\n" +" 1. Make a signing key once:\n" +" motatool keygen --out signer.key\n" +" 2. Package a firmware (identity is read from the firmware itself):\n" +" motatool build --fw firmware.bin --sign signer.key --out-dir ./motas\n" +" 3. Serve the folder to a node plugged in over USB:\n" +" motatool serve --dir ./motas --serial /dev/ttyUSB0\n"; +} + +static void help_build() { + std::cout << +"motatool build — package a firmware as a .mota update container.\n" +"\n" +"USAGE\n" +" motatool build --fw [--base ] [options]\n" +"\n" +"WHAT IT DOES\n" +" With only --fw: builds a FULL update (the payload is the whole firmware image).\n" +" With --base too: builds a DELTA (a small patch from the base firmware to the new one),\n" +" which is far smaller to send over LoRa. The delta codec is picked for the target hardware\n" +" automatically: nRF52 (single-slot) -> in-place, ESP32 (A/B slots) -> sequential.\n" +"\n" +" Firmware identity (target, version, hardware tag) is read automatically from the firmware's\n" +" EndF trailer, so you normally don't pass --target-*/--fw-version/--hw-id. Pass them only to\n" +" override, or for a raw .bin that has no EndF identity.\n" +"\n" +"INPUT (--fw is required)\n" +" --fw NEW firmware. A local path OR an http(s):// URL (downloaded with curl/wget).\n" +" A .bin is used as-is; a .hex (nRF52/STM32 build) is parsed to its flat image first.\n" +" --base previous firmware to diff against -> makes a delta (omit it for a full image).\n" +"\n" +"IDENTITY (optional — auto-read from the firmware's EndF)\n" +" --target-env PlatformIO env name (e.g. RAK_4631_repeater); hashed into the target id.\n" +" --target-id raw target id instead of --target-env (e.g. 0x04D413FD).\n" +" --fw-version firmware version (e.g. 1.16.0).\n" +" --hw-id hardware tag (e.g. RAK4631, Heltec_v3). Same tag = bootable-compatible;\n" +" a node refuses a .mota whose hw-id is for different hardware (brick-safety).\n" +"\n" +"DELTA OPTIONS\n" +" --codec full|sequential|inplace force the codec (default: full with no --base, else auto-from-hw).\n" +" --inplace-memory nRF52 in-place workspace size (default 0xAE000).\n" +" --inplace-segment nRF52 in-place erase/segment size (default 4096).\n" +" --detools the detools encoder to call (default: 'detools' on PATH). Needed only for deltas.\n" +" --force build the delta even if base and target hardware identities differ.\n" +"\n" +"SIGNING\n" +" --sign Ed25519 private key (hex or raw 32 bytes, from 'motatool keygen'). Signing lets a\n" +" node auto-install the update if it trusts the matching public key. Unsigned still works\n" +" for manual installs.\n" +"\n" +"OUTPUT\n" +" --out-dir where to write the .mota (default: current directory). Keep all your .mota in one\n" +" folder and point 'serve' at it. The file is auto-named:\n" +" __v__.mota\n" +" --out write to exactly this path instead (overrides --out-dir and the auto-name).\n" +"\n" +"EXAMPLES\n" +" # full image, signed, into ./motas\n" +" motatool build --fw firmware.bin --sign signer.key --out-dir ./motas\n" +"\n" +" # full image fetched straight from a release URL\n" +" motatool build --fw https://example.org/RAK_4631_repeater.bin --sign signer.key --out-dir ./motas\n" +"\n" +" # delta from the previous release (codec auto-selected from the hardware)\n" +" motatool build --fw new.bin --base old.bin --sign signer.key --out-dir ./motas\n" +"\n" +" # raw .bin with no EndF identity: supply it explicitly\n" +" motatool build --fw app.bin --hw-id Heltec_v3 --target-env Heltec_v3_repeater --fw-version 1.16.0\n"; +} + +static void help_verify() { + std::cout << +"motatool verify — check that .mota files are valid.\n" +"\n" +"USAGE\n" +" motatool verify [more.mota ...] [--pub ] [--base ]\n" +"\n" +"For each file it checks the structure, that the per-block hashes match the payload, the merkle\n" +"root, the full-image hash (for full images), and the Ed25519 signature (for signed containers).\n" +"It prints 'OK' or 'FAIL ' per file; the exit code is non-zero if any file fails. This is\n" +"the same validation 'serve' runs on a folder before serving.\n" +"\n" +"OPTIONS\n" +" --pub require the container to be signed by THIS public key (hex/raw, *.pub from keygen).\n" +" --base for a sequential delta, apply it to this base and confirm it rebuilds the image.\n" +" (Applies to every file given; full images ignore it; in-place deltas are skipped.)\n" +"\n" +"EXAMPLES\n" +" motatool verify ./motas/*.mota\n" +" motatool verify update.mota --pub signer.key.pub\n" +" motatool verify delta.mota --base old_firmware.bin\n"; +} + +static void help_inspect() { + std::cout << +"motatool inspect — print every field of a .mota's manifest.\n" +"\n" +"USAGE\n" +" motatool inspect \n" +"\n" +"Dumps the parsed manifest (versions, sizes, target/hardware, codec, merkle root, image hash,\n" +"base hash, signer key + signature, approval state, block count) — handy for debugging a package.\n" +"It does not validate integrity; use 'verify' for that.\n" +"\n" +"EXAMPLE\n" +" motatool inspect ./motas/RAK4631_04D413FD_v1.16.0_full_ABCD1234.mota\n"; +} + +static void help_serve() { + std::cout << +"motatool serve — serve a folder of .mota to a MeshCore node over USB serial.\n" +"\n" +"USAGE\n" +" motatool serve --dir --serial [options]\n" +"\n" +"It scans the folder for .mota files, validates each, and serves the valid ones to the node.\n" +"Corrupt/invalid files are reported and skipped — one bad file never stops the rest. The node\n" +"advertises them to the mesh as if it held them, and any node whose hardware matches can fetch\n" +"them. The relay is trustless: fetchers verify every block, so this host never needs the keys.\n" +"\n" +"OPTIONS (--dir and --serial are required)\n" +" --dir folder of .mota to serve (searched recursively by default).\n" +" --serial the node's USB serial port (e.g. /dev/ttyUSB0 or /dev/ttyACM0).\n" +" --baud serial speed (default 115200).\n" +" --no-recursive serve only the top folder; don't descend into sub-folders.\n" +" --no-enable don't auto-send 'ota folder on'/'off' to the node (run them on its CLI yourself).\n" +" -v, --verbose log each request the node makes (COUNT / DESCRIBE / READ).\n" +"\n" +"Leave it running; press Ctrl-C to stop (it tells the node to stop relaying first). It shares the\n" +"same USB cable as the node's text console — the node only pulls bytes while actively fetching.\n" +"\n" +"EXAMPLE\n" +" motatool serve --dir ./motas --serial /dev/ttyUSB0 -v\n"; +} + +static void help_keygen() { + std::cout << +"motatool keygen — generate an Ed25519 signing keypair.\n" +"\n" +"USAGE\n" +" motatool keygen [--out ]\n" +"\n" +"Prints the public key. With --out it writes the private key to and the public key to\n" +".pub (hex). Sign updates with the private key ('build --sign '); trust the\n" +"public key on a node to let it auto-install updates signed by you.\n" +"\n" +"EXAMPLE\n" +" motatool keygen --out signer.key\n"; +} + +static std::string hex8(uint32_t v) { char x[9]; std::snprintf(x, 9, "%08X", v); return x; } + +// human-readable label for a target_id (its PlatformIO env name), or "N/A" if not in the known table +static std::string target_label(uint32_t t) { + std::string n = target_env_name(t); + return n.empty() ? "N/A" : n; +} + +static int cmd_verify(const Args& a) { + if (a.has("help")) { help_verify(); return 0; } + if (a.pos.empty()) { help_verify(); return 2; } + + std::vector expect_pub; + if (a.has("pub") && !load_priv(a.get("pub"), expect_pub)) { // load_priv accepts a 32-byte hex/raw key + std::cerr << "cannot load --pub key (expect 32-byte hex or raw)\n"; return 2; + } + std::vector base_img; + if (a.has("base")) { + std::vector b; std::string e = read_input(a.get("base"), b); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + std::array bh; base_img = ensure_endf(b, parse_endf_ident(b), bh); + } + + int bad = 0; + for (const auto& f : a.pos) { + std::vector blob; + if (!read_file(f, blob)) { std::cout << "FAIL " << f << " : cannot read\n"; bad++; continue; } + auto probs = verify(blob); + Manifest m; bool parsed = parse(blob, m).empty(); + if (parsed && !expect_pub.empty()) { // --pub: must be signed by this exact key + if (!m.is_signed()) probs.push_back("not signed (but --pub was given)"); + else if (std::memcmp(m.signer.data(), expect_pub.data(), 32) != 0) probs.push_back("signed by a different key than --pub"); + } + if (parsed && !base_img.empty() && !m.is_full()) { // --base: prove a delta rebuilds the image + if (m.codec_id == CODEC_DETOOLS_SEQUENTIAL) { + std::vector patch(blob.begin() + m.payload_off(), blob.begin() + m.payload_off() + m.payload_size); + std::vector recon; + std::string e = detools_apply_seq(a.get("detools", "detools"), base_img, patch, recon); + if (!e.empty()) probs.push_back("delta apply: " + e); + else { auto h = mota::mh32(recon.data(), recon.size()); + if (std::memcmp(h.data(), m.image_hash.data(), 32) != 0) probs.push_back("delta does not rebuild image_hash against --base"); } + } else { + std::cout << "note " << f << " : in-place delta — --base apply-check skipped (bootloader-applied)\n"; + } + } + if (probs.empty()) { + std::cout << "OK " << f << " : " << (m.is_full() ? "full" : "delta") + << " target=" << hex8(m.target_id) << " [" << target_label(m.target_id) << "]" + << " v" << version_str(m.fw_version) << " hw=" << (m.hw_id_str().empty()? "?":m.hw_id_str()) + << " " << (m.is_signed() ? "signed" : "unsigned") + << " blocks=" << m.block_count << " size=" << blob.size() << "\n"; + } else { + bad++; + std::cout << "FAIL " << f << " :"; + for (auto& p : probs) std::cout << " [" << p << "]"; + std::cout << "\n"; + } + } + return bad ? 1 : 0; +} + +static int cmd_inspect(const Args& a) { + if (a.has("help")) { help_inspect(); return 0; } + if (a.pos.empty()) { help_inspect(); return 2; } + std::vector blob; + if (!read_file(a.pos[0], blob)) { std::cerr << "cannot read " << a.pos[0] << "\n"; return 1; } + Manifest m; + std::string e = parse(blob, m); + if (!e.empty()) { std::cerr << "not a valid .mota: " << e << "\n"; return 1; } + auto z = [](const uint8_t* p, size_t n){ for (size_t i=0;i in-place, else sequential) +static int infer_codec(const std::string& hw) { + std::string h; for (char c : hw) h.push_back((char)std::tolower((unsigned char)c)); + if (h.rfind("rak", 0) == 0 || h.find("nrf") != std::string::npos || h.find("nordic") != std::string::npos) + return CODEC_DETOOLS_INPLACE; + if (h.find("heltec") != std::string::npos || h.find("esp32") != std::string::npos || + h.find("xiao") != std::string::npos || h.find("tbeam") != std::string::npos || + h.find("tlora") != std::string::npos || h.find("tdeck") != std::string::npos) + return CODEC_DETOOLS_SEQUENTIAL; + return -1; // unknown +} + +static int cmd_build(const Args& a) { + if (a.has("help")) { help_build(); return 0; } + if (!a.has("fw")) { std::cerr << "error: --fw is required\n\n"; help_build(); return 2; } + BuildOpts o; + std::string e = read_input(a.get("fw"), o.fw); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + if (a.has("base")) { + e = read_input(a.get("base"), o.base); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + } + if (a.has("target-id")) { o.have_target = true; o.target_id = (uint32_t)std::strtoul(a.get("target-id").c_str(), nullptr, 0); } + else if (a.has("target-env")) { o.have_target = true; o.target_id = target_id_for_env(a.get("target-env")); } + if (a.has("fw-version")) { + uint32_t v; if (!pack_version(a.get("fw-version"), v)) { std::cerr << "bad --fw-version\n"; return 2; } + o.have_fwver = true; o.fw_version = v; + } + if (a.has("hw-id")) o.hw_id = a.get("hw-id"); + o.force = a.has("force"); + if (a.has("detools")) o.detools = a.get("detools"); + if (a.has("inplace-memory")) o.inplace_memory = (uint32_t)std::strtoul(a.get("inplace-memory").c_str(), nullptr, 0); + if (a.has("inplace-segment")) o.inplace_segment = (uint32_t)std::strtoul(a.get("inplace-segment").c_str(), nullptr, 0); + if (a.has("sign")) { + if (!load_priv(a.get("sign"), o.sign_priv)) { std::cerr << "cannot load signing key (expect 32-byte hex or raw)\n"; return 1; } + } + + bool is_delta = !o.base.empty(); + // resolve hw (flags override EndF) to pick the codec + FwIdent fid = parse_endf_ident(o.fw); + std::string hw = o.hw_id.empty() ? fid.hw_id : o.hw_id; + std::string codec = a.get("codec", is_delta ? "auto" : "full"); + if (!is_delta) o.codec = CODEC_FULL; + else if (codec == "sequential") o.codec = CODEC_DETOOLS_SEQUENTIAL; + else if (codec == "inplace") o.codec = CODEC_DETOOLS_INPLACE; + else if (codec == "full") { std::cerr << "a --base delta cannot use --codec full\n"; return 2; } + else { // auto + int c = infer_codec(hw); + if (c < 0) { std::cerr << "cannot infer codec from hw '" << hw << "' — pass --codec sequential|inplace\n"; return 2; } + o.codec = (uint8_t)c; + std::cerr << "note: codec auto-selected = " << (c == CODEC_DETOOLS_INPLACE ? "inplace" : "sequential") + << " (from hw '" << (hw.empty()?"?":hw) << "')\n"; + } + + std::vector blob; std::string name; + e = build(o, blob, name); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + + // sanity: the tool's own output must verify + auto probs = verify(blob); + if (!probs.empty()) { + std::cerr << "internal error: built .mota fails verification:"; + for (auto& p : probs) std::cerr << " [" << p << "]"; + std::cerr << "\n"; return 1; + } + + std::string outpath; + if (a.has("out")) { // explicit output path (overrides --out-dir + auto-name) + outpath = a.get("out"); + fs::path parent = fs::path(outpath).parent_path(); + if (!parent.empty()) { std::error_code ec; fs::create_directories(parent, ec); } + } else { + std::string outdir = a.get("out-dir", "."); + std::error_code ec; fs::create_directories(outdir, ec); + outpath = (fs::path(outdir) / name).string(); + } + if (!write_file(outpath, blob.data(), blob.size())) { std::cerr << "cannot write " << outpath << "\n"; return 1; } + + Manifest m; parse(blob, m); + std::cout << "wrote " << outpath << "\n" + << " " << (m.is_full() ? "full" : (m.codec_id == CODEC_DETOOLS_INPLACE ? "in-place delta" : "sequential delta")) + << " target=" << [&]{char x[9];std::snprintf(x,9,"%08X",m.target_id);return std::string(x);}() + << " v" << version_str(m.fw_version) << " hw=" << (m.hw_id_str().empty()?"?":m.hw_id_str()) + << " " << (m.is_signed()?"signed":"unsigned") << "\n" + << " image=" << m.image_size << "B payload=" << m.payload_size << "B blocks=" << m.block_count + << " total=" << blob.size() << "B\n"; + return 0; +} + +static int cmd_keygen(const Args& a) { + if (a.has("help")) { help_keygen(); return 0; } + uint8_t priv[32], pub[32]; + if (!ed25519_keygen(priv, pub)) { std::cerr << "keygen failed\n"; return 1; } + std::string out = a.get("out", a.get("out-priv")); + std::string ph = to_hex(priv, 32), kh = to_hex(pub, 32); + if (!out.empty()) { + std::string pp = ph + "\n", kp = kh + "\n"; + if (!write_file(out, (const uint8_t*)pp.data(), pp.size()) || + !write_file(out + ".pub", (const uint8_t*)kp.data(), kp.size())) { std::cerr << "write failed\n"; return 1; } + std::cout << "private -> " << out << "\npublic -> " << out << ".pub\n"; + } + std::cout << "pubkey: " << kh << "\n"; + return 0; +} + +static int cmd_serve(const Args& a) { + if (a.has("help")) { help_serve(); return 0; } + if (!a.has("dir") || !a.has("serial")) { + std::cerr << "error: --dir and --serial are required\n\n"; help_serve(); return 2; + } + bool recursive = !a.has("no-recursive"); + bool verbose = a.has("verbose"); + Folder folder; + size_t n = folder.scan(a.get("dir"), recursive, + [](const std::string& p, const std::string& why) { + std::cerr << " ! skip " << p << " : " << why << "\n"; + }); + std::cout << "motatool serve: " << n << " valid .mota in " << a.get("dir") + << (recursive ? " (recursive)" : "") << "\n"; + for (const auto& s : folder.all()) { + std::cout << " - " << fs::path(s.path).filename().string() + << " : mid=" << to_hex(s.m.merkle_root.data(), 4) + << " target=" << hex8(s.m.target_id) << " [" << target_label(s.m.target_id) << "]" + << " v" << version_str(s.m.fw_version) + << " " << (s.m.is_full() ? "full" : (s.m.codec_id == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta")) + << " " << (s.m.is_signed() ? "signed" : "unsigned") + << " blocks=" << s.m.block_count << " size=" << s.bytes.size() << "\n"; + } + if (n == 0) std::cerr << " (nothing valid to serve)\n"; + + SerialTransport t; + std::string e = t.open(a.get("serial"), std::atoi(a.get("baud", "115200").c_str())); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + + std::signal(SIGINT, on_sigint); + bool enable = !a.has("no-enable"); + if (enable) { usleep(500000); t.write_str("ota folder on\r\n"); std::cout << "sent `ota folder on`\n"; } + std::cout << "serving on " << a.get("serial") << " @ " << a.get("baud", "115200") << " — Ctrl-C to stop\n"; + + SeederCore core(folder); + serve_serial(t, core, verbose, + [](const std::string& l) { std::cout << " [dev] " << l << "\n"; }, &g_stop); + + if (enable) { t.write_str("ota folder off\r\n"); usleep(200000); } + std::cout << "\nbye\n"; + return 0; +} + +int main(int argc, char** argv) { + if (argc < 2) { help_top(); return 2; } + std::string cmd = argv[1]; + if (cmd == "help" || cmd == "-h" || cmd == "--help") { help_top(); return 0; } + + std::vector bools = {"force","verbose","no-recursive","no-enable"}; + Args a = parse_args(argc, argv, 2, bools); + if (cmd == "build") return cmd_build(a); + if (cmd == "verify") return cmd_verify(a); + if (cmd == "inspect") return cmd_inspect(a); + if (cmd == "serve") return cmd_serve(a); + if (cmd == "keygen") return cmd_keygen(a); + std::cerr << "unknown command: " << cmd << "\n\n"; + help_top(); + return 2; +} diff --git a/tools/motatool/src/mota.cpp b/tools/motatool/src/mota.cpp new file mode 100644 index 00000000..87478bb1 --- /dev/null +++ b/tools/motatool/src/mota.cpp @@ -0,0 +1,413 @@ +#include "mota.h" + +#include "OtaTargets.h" // shared with the firmware (generated): target_id -> env name +#include "crypto.h" +#include "util.h" + +#include +#include +#include +#include + +namespace mota { + +// ---- merkle (mirror of src/helpers/ota/MerkleTree.cpp) -------------------------------------------- +static void merkle_leaf(uint8_t out[4], const uint8_t *block, size_t len) { + sha256_trunc(out, 4, block, len); +} +static void merkle_combine(uint8_t out[4], const uint8_t *left, const uint8_t *right) { + uint8_t buf[8]; + std::memcpy(buf, left, 4); + std::memcpy(buf + 4, right, 4); + sha256_trunc(out, 4, buf, 8); +} +// Root via binary-counter / Merkle-Mountain-Range with right-to-left bagging (must stay byte-identical to +// src/helpers/ota/MerkleTree.cpp; the native tests cross-check both against the Python reference). +// peaks[k] holds the root of a complete 2^k-leaf subtree; adding a leaf "carries" upward like incrementing +// a binary counter, then the leftover peaks are bagged right-to-left into the final root. +static void merkle_root(uint8_t out[4], const uint8_t *leaves, uint32_t count) { + if (count == 0) { + std::memset(out, 0, 4); + return; + } + if (count == 1) { + std::memcpy(out, leaves, 4); + return; + } + uint8_t peaks[32][4]; + bool valid[32] = { false }; + for (uint32_t i = 0; i < count; i++) { + uint8_t cur[4]; + std::memcpy(cur, leaves + (size_t)i * 4, 4); + uint32_t level = 0; + while (valid[level]) { // carry: combine with the pending peak at this level + merkle_combine(cur, peaks[level], cur); // peak is earlier (left), cur is right + valid[level] = false; + level++; + } + std::memcpy(peaks[level], cur, 4); + valid[level] = true; + } + // bag peaks right-to-left: acc starts at the lowest set level (rightmost peak) + int level = 0; + while (level < 32 && !valid[level]) + level++; + uint8_t acc[4]; + std::memcpy(acc, peaks[level], 4); + for (int l = level + 1; l < 32; l++) + if (valid[l]) merkle_combine(acc, peaks[l], acc); // higher peak is left, acc is right + std::memcpy(out, acc, 4); +} +// leaves[] over the payload (last block short, no padding) +static std::vector leaf_hashes(const uint8_t *payload, uint32_t size, uint32_t bs) { + uint32_t bc = (size + bs - 1) / bs; + std::vector leaves(bc * 4); + for (uint32_t i = 0; i < bc; i++) { + uint32_t off = i * bs, blen = (off + bs <= size) ? bs : (size - off); + merkle_leaf(leaves.data() + (size_t)i * 4, payload + off, blen); + } + return leaves; +} + +std::string Manifest::hw_id_str() const { + size_t n = 0; + while (n < hw_id.size() && hw_id[n]) + n++; + return std::string((const char *)hw_id.data(), n); +} + +// ---- parse ----------------------------------------------------------------------------------------- +std::string parse(const std::vector &b, Manifest &m) { + m = Manifest(); + if (b.size() < 8 + MOTA_MFL + 5) return "too small for a .mota"; + if (std::memcmp(b.data(), MOTA_MAGIC, 4) != 0) return "bad MAGIC (not a .mota)"; + uint32_t total = rd_u32(b.data() + 4); + if (total != b.size()) return "MOTA_TOTAL_SIZE != file length"; + if (std::memcmp(b.data() + b.size() - 5, MOTA_TRAILER, 5) != 0) return "bad TRAILER"; + const uint8_t *mf = b.data() + 8; // manifest start + m.format_ver = mf[M_OFF_FORMAT_VER]; + if (m.format_ver != FORMAT_VER) return "unsupported format_ver"; + m.flags = mf[M_OFF_FLAGS]; + m.hash_algo = mf[M_OFF_HASH_ALGO]; + m.target_id = rd_u32(mf + M_OFF_TARGET_ID); + m.fw_version = rd_u32(mf + M_OFF_FW_VERSION); + m.image_size = rd_u32(mf + M_OFF_IMAGE_SIZE); + m.payload_size = rd_u32(mf + M_OFF_PAYLOAD_SIZE); + m.block_size_log2 = mf[M_OFF_BLOCK_SIZE_LOG2]; + std::memcpy(m.merkle_root.data(), mf + M_OFF_MERKLE_ROOT, 4); + std::memcpy(m.image_hash.data(), mf + M_OFF_IMAGE_HASH, 32); + m.codec_id = mf[M_OFF_CODEC_ID]; + std::memcpy(m.hw_id.data(), mf + M_OFF_HW_ID, 32); + std::memcpy(m.base_hash.data(), mf + M_OFF_BASE_HASH, 8); + std::memcpy(m.signer.data(), mf + M_OFF_SIGNER, 32); + std::memcpy(m.signature.data(), mf + M_OFF_SIGNATURE, 64); + std::memcpy(m.approval.data(), mf + M_OFF_APPROVAL, 4); + if (m.block_size_log2 == 0 || m.block_size_log2 > 24 || m.payload_size == 0) + return "bad block_size/payload"; + m.block_count = (m.payload_size + m.block_size() - 1) / m.block_size(); + if (m.block_count == 0 || m.block_count > 0xFFFFu) return "block_count out of range"; + if (m.total_size() != b.size()) return "geometry (leaves+payload) != file length"; + return ""; +} + +// ---- verify ---------------------------------------------------------------------------------------- +std::vector verify(const std::vector &b) { + std::vector probs; + Manifest m; + std::string e = parse(b, m); + if (!e.empty()) { + probs.push_back(e); + return probs; + } // unparseable: a single, fatal problem + + const uint8_t *leaves = b.data() + m.leaves_off(); + const uint8_t *payload = b.data() + m.payload_off(); + + // recompute leaves[] from the payload -> catches payload corruption + std::vector calc = leaf_hashes(payload, m.payload_size, m.block_size()); + if (calc.size() != m.block_count * 4u || std::memcmp(calc.data(), leaves, calc.size()) != 0) + probs.push_back("leaves[] do not match the payload (corruption)"); + + uint8_t root[4]; + merkle_root(root, leaves, m.block_count); + if (std::memcmp(root, m.merkle_root.data(), 4) != 0) probs.push_back("merkle_root mismatch"); + + if (m.is_full()) { + auto h = mh32(payload, m.payload_size); // full: payload IS the image + if (std::memcmp(h.data(), m.image_hash.data(), 32) != 0) + probs.push_back("image_hash mismatch (full image)"); + } + // (delta image_hash needs the base image -> not checked at relay time; payload integrity is covered above) + + if (m.is_signed()) { + if (!ed25519_verify(m.signature.data(), b.data() + 8, MOTA_SIGNED_LEN, m.signer.data())) + probs.push_back("Ed25519 signature INVALID"); + } + // a distributed .mota must not be pre-approved + if (std::memcmp(m.approval.data(), APPROVAL_YES, 4) == 0) + probs.push_back("container is pre-approved (must be FF FF FF FF on the wire)"); + return probs; +} + +// ---- EndF ----------------------------------------------------------------------------------------- +bool has_endf(const std::vector &img) { + if (img.size() < ENDF_LEN) return false; + const uint8_t *t = img.data() + img.size() - ENDF_LEN; + if (std::memcmp(t, ENDF_MAGIC, 4) != 0) return false; + if (rd_u32(t + 4) != img.size() - ENDF_LEN) return false; + auto h = mh8(img.data(), img.size() - ENDF_LEN); + return std::memcmp(h.data(), t + 8, 8) == 0; +} + +FwIdent parse_endf_ident(const std::vector &img) { + FwIdent id; + if (!has_endf(img)) return id; + const uint8_t *t = img.data() + img.size() - ENDF_LEN; + id.fw_version = rd_u32(t + ENDF_OFF_FWVER); + id.target_id = rd_u32(t + ENDF_OFF_TARGET); + size_t n = 0; + while (n < HW_ID_LEN && t[ENDF_OFF_HWID + n]) + n++; + id.hw_id.assign((const char *)t + ENDF_OFF_HWID, n); + return id; +} + +std::vector ensure_endf(const std::vector &img, const FwIdent &id, + std::array &body_hash8) { + if (has_endf(img)) { // already trailed: keep its identity, read its hash + const uint8_t *t = img.data() + img.size() - ENDF_LEN; + std::memcpy(body_hash8.data(), t + 8, 8); + return img; + } + body_hash8 = mh8(img.data(), img.size()); + std::vector out = img; + out.insert(out.end(), ENDF_MAGIC, ENDF_MAGIC + 4); + uint8_t u[4]; + wr_u32(u, (uint32_t)img.size()); + out.insert(out.end(), u, u + 4); + out.insert(out.end(), body_hash8.begin(), body_hash8.end()); + wr_u32(u, id.fw_version); + out.insert(out.end(), u, u + 4); + wr_u32(u, id.target_id); + out.insert(out.end(), u, u + 4); + uint8_t hw[HW_ID_LEN] = { 0 }; + std::memcpy(hw, id.hw_id.data(), id.hw_id.size() < HW_ID_LEN ? id.hw_id.size() : HW_ID_LEN); + out.insert(out.end(), hw, hw + HW_ID_LEN); + return out; +} + +uint32_t target_id_for_env(const std::string &env) { + auto h = mh4((const uint8_t *)env.data(), env.size()); + return rd_u32(h.data()); +} + +std::string target_env_name(uint32_t target_id) { + // Exhaustive target_id -> env-name table, shared verbatim with the firmware and generated from the live + // PlatformIO config (every ENABLE_OTA env) by tools/mota/gen_targets.py. See OtaTargets.h. + const char *env = mesh::ota::ota_target_env_name(target_id); + return env ? env : ""; +} + +bool pack_version(const std::string &s, uint32_t &out) { + uint32_t parts[4] = { 0, 0, 0, 0 }; + int n = 0; + bool any = false; + std::stringstream ss(s); + std::string tok; + while (std::getline(ss, tok, '.') && n < 4) { + if (tok.empty()) return false; + for (char c : tok) + if (c < '0' || c > '9') return false; + parts[n++] = (uint32_t)std::strtoul(tok.c_str(), nullptr, 10); + any = true; + } + if (!any) return false; + out = ((parts[0] & 0xFF) << 24) | ((parts[1] & 0xFF) << 16) | ((parts[2] & 0xFF) << 8) | (parts[3] & 0xFF); + return true; +} + +// ---- build ---------------------------------------------------------------------------------------- +// detools delta encode: write base/new images to temp files, run the detools CLI, read the patch back. +static std::string detools_delta(const std::string &detools, uint8_t codec, + const std::vector &base_img, const std::vector &new_img, + uint32_t mem, uint32_t seg, std::vector &patch) { + char fb[] = "/tmp/motaXXXXXX", fn[] = "/tmp/motaXXXXXX", fp[] = "/tmp/motaXXXXXX"; + int a = mkstemp(fb), c = mkstemp(fn), d = mkstemp(fp); + if (a < 0 || c < 0 || d < 0) return "mkstemp failed"; + close(a); + close(c); + close(d); + std::string err; + if (!write_file(fb, base_img.data(), base_img.size()) || !write_file(fn, new_img.data(), new_img.size())) { + err = "writing temp images failed"; + } else { + std::vector argv; + if (codec == CODEC_DETOOLS_SEQUENTIAL) + argv = { detools, "create_patch", "-c", "crle", "-t", "sequential", fb, fn, fp }; + else + argv = { detools, + "create_patch_in_place", + "-c", + "crle", + "--memory-size", + std::to_string(mem), + "--segment-size", + std::to_string(seg), + fb, + fn, + fp }; + int rc = run_argv(argv, /*quiet=*/true); // hide detools' success chatter; errors keep stderr + if (rc == 127) + err = "could not run detools (install it / pass --detools )"; + else if (rc != 0) + err = "detools exited with code " + std::to_string(rc); + else if (!read_file(fp, patch) || patch.empty()) + err = "reading detools patch failed"; + } + unlink(fb); + unlink(fn); + unlink(fp); + return err; +} + +std::string detools_apply_seq(const std::string &detools, const std::vector &base_img, + const std::vector &patch, std::vector &out) { + char ff[] = "/tmp/motaXXXXXX", fpp[] = "/tmp/motaXXXXXX", ft[] = "/tmp/motaXXXXXX"; + int a = mkstemp(ff), c = mkstemp(fpp), d = mkstemp(ft); + if (a < 0 || c < 0 || d < 0) return "mkstemp failed"; + close(a); + close(c); + close(d); + std::string err; + if (!write_file(ff, base_img.data(), base_img.size()) || !write_file(fpp, patch.data(), patch.size())) { + err = "writing temp files failed"; + } else { + int rc = run_argv({ detools, "apply_patch", ff, fpp, ft }, /*quiet=*/true); // + if (rc == 127) + err = "could not run detools (install it / pass --detools )"; + else if (rc != 0) + err = "detools apply_patch exited with code " + std::to_string(rc); + else if (!read_file(ft, out) || out.empty()) + err = "reading reconstructed image failed"; + } + unlink(ff); + unlink(fpp); + unlink(ft); + return err; +} + +std::string build(const BuildOpts &o, std::vector &out, std::string &suggested_name) { + bool is_delta = !o.base.empty(); + + // resolve identity: explicit flags override the firmware's self-describing EndF + FwIdent fid = parse_endf_ident(o.fw); + uint32_t target = o.have_target ? o.target_id : fid.target_id; + uint32_t fwver = o.have_fwver ? o.fw_version : fid.fw_version; + std::string hw = !o.hw_id.empty() ? o.hw_id : fid.hw_id; + FwIdent ident{ fwver, target, hw }; + + std::array new_bh{}, base_bh{}; + std::vector new_img = ensure_endf(o.fw, ident, new_bh); + + uint8_t codec = o.codec; + std::vector payload; + std::array base_hash{}; + + if (!is_delta) { + codec = CODEC_FULL; + payload = new_img; // full: payload IS the image + } else { + if (codec == CODEC_FULL) return "a base image was given but --codec is full"; + FwIdent base_id = parse_endf_ident(o.base); + std::vector base_img = ensure_endf(o.base, base_id, base_bh); + base_hash = base_bh; + // cross-hardware delta guard (read from EndF identity, not filenames) + if (!o.force && base_id.any() && fid.any()) { + bool hw_ok = base_id.hw_id.empty() || fid.hw_id.empty() || base_id.hw_id == fid.hw_id; + bool tgt_ok = !base_id.target_id || !fid.target_id || base_id.target_id == fid.target_id; + if (!hw_ok || !tgt_ok) + return "base/target firmware identity differ (hw '" + base_id.hw_id + "' vs '" + fid.hw_id + + "') — refusing cross-hardware delta (use --force to override)"; + } + std::string e = + detools_delta(o.detools, codec, base_img, new_img, o.inplace_memory, o.inplace_segment, payload); + if (!e.empty()) return e; + } + + uint32_t bs = o.block_size, image_size = (uint32_t)new_img.size(); + std::vector leaves = leaf_hashes(payload.data(), (uint32_t)payload.size(), bs); + uint32_t bc = (uint32_t)leaves.size() / 4; + if (bc == 0 || bc > 0xFFFFu) return "payload yields an invalid block count"; + uint8_t root[4]; + merkle_root(root, leaves.data(), bc); + auto image_hash = mh32(new_img.data(), new_img.size()); + + bool signed_ = !o.sign_priv.empty(); + uint8_t flags = (is_delta ? 0 : MFLAG_FULL) | (signed_ ? MFLAG_SIGNED : 0); + + // assemble the fixed 197-byte manifest-minus-leaves + std::vector mf(MOTA_MFL, 0); + mf[M_OFF_FORMAT_VER] = FORMAT_VER; + mf[M_OFF_FLAGS] = flags; + mf[M_OFF_HASH_ALGO] = HASH_ALGO_SHA256; + wr_u32(mf.data() + M_OFF_TARGET_ID, target); + wr_u32(mf.data() + M_OFF_FW_VERSION, fwver); + wr_u32(mf.data() + M_OFF_IMAGE_SIZE, image_size); + wr_u32(mf.data() + M_OFF_PAYLOAD_SIZE, (uint32_t)payload.size()); + // block_size_log2 + { + uint32_t v = bs, l = 0; + while (v > 1) { + v >>= 1; + l++; + } + mf[M_OFF_BLOCK_SIZE_LOG2] = (uint8_t)l; + } + std::memcpy(mf.data() + M_OFF_MERKLE_ROOT, root, 4); + std::memcpy(mf.data() + M_OFF_IMAGE_HASH, image_hash.data(), 32); + mf[M_OFF_CODEC_ID] = codec; + std::memcpy(mf.data() + M_OFF_HW_ID, hw.data(), hw.size() < HW_ID_LEN ? hw.size() : HW_ID_LEN); + if (is_delta) std::memcpy(mf.data() + M_OFF_BASE_HASH, base_hash.data(), 8); // zero for full + if (signed_) { + uint8_t pub[32]; + if (o.sign_priv.size() != 32) return "signing key must be a 32-byte raw private seed"; + if (!ed25519_pub_from_priv(pub, o.sign_priv.data())) return "bad signing key"; + std::memcpy(mf.data() + M_OFF_SIGNER, pub, 32); + uint8_t sig[64]; + if (!ed25519_sign(sig, mf.data(), MOTA_SIGNED_LEN, o.sign_priv.data())) return "signing failed"; + std::memcpy(mf.data() + M_OFF_SIGNATURE, sig, 64); + } + std::memcpy(mf.data() + M_OFF_APPROVAL, APPROVAL_NOT, 4); + + // container = MAGIC(4) total(4) manifest leaves[] payload trailer(5) + uint32_t total = 8 + MOTA_MFL + bc * 4 + (uint32_t)payload.size() + 5; + // nRF52 in-place: the staged container sits below the apply workspace; if it's too big it overruns the + // workspace and the bootloader apply fails (DETOOLS_IO_FAILED). Warn so it's caught before shipping. + if (is_delta && codec == CODEC_DETOOLS_INPLACE && total > NRF52_MAX_INPLACE_MOTA) + std::fprintf(stderr, "warning: in-place delta is %u B, exceeding the nRF52 staging room (%u B) — it will " + "NOT apply on the device. Shrink the delta (smaller change) or use a smaller image.\n", + total, NRF52_MAX_INPLACE_MOTA); + out.clear(); + out.reserve(total); + out.insert(out.end(), MOTA_MAGIC, MOTA_MAGIC + 4); + uint8_t u[4]; + wr_u32(u, total); + out.insert(out.end(), u, u + 4); + out.insert(out.end(), mf.begin(), mf.end()); + out.insert(out.end(), leaves.begin(), leaves.end()); + out.insert(out.end(), payload.begin(), payload.end()); + out.insert(out.end(), MOTA_TRAILER, MOTA_TRAILER + 5); + + // suggested name: __v__.mota (descriptive + unique, one folder) + const char *kind = !is_delta ? "full" : (codec == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta"); + char tgt[9]; + std::snprintf(tgt, sizeof(tgt), "%08X", target); + char vbuf[24]; + std::snprintf(vbuf, sizeof(vbuf), "%u.%u.%u", (fwver >> 24) & 0xFF, (fwver >> 16) & 0xFF, + (fwver >> 8) & 0xFF); + suggested_name = (hw.empty() ? std::string("fw") : hw) + "_" + tgt + "_v" + vbuf + "_" + kind + "_" + + to_hex(root, 4) + ".mota"; + return ""; +} + +} // namespace mota diff --git a/tools/motatool/src/mota.h b/tools/motatool/src/mota.h new file mode 100644 index 00000000..e3024027 --- /dev/null +++ b/tools/motatool/src/mota.h @@ -0,0 +1,83 @@ +// `.mota` container: parse, integrity-verify, and build (full / detools-delta). EndF identity helpers. +// Mirrors tools/mota/motalib.py and src/helpers/ota/MotaContainer.cpp (the single source of truth). +#pragma once +#include "mota_format.h" +#include +#include +#include + +namespace mota { + +struct FwIdent { + uint32_t fw_version = 0; + uint32_t target_id = 0; + std::string hw_id; // NUL-trimmed + bool any() const { return fw_version || target_id || !hw_id.empty(); } +}; + +struct Manifest { + uint8_t format_ver = 0, flags = 0, hash_algo = 0, codec_id = 0, block_size_log2 = 0; + uint32_t target_id = 0, fw_version = 0, image_size = 0, payload_size = 0, block_count = 0; + std::array merkle_root{}; + std::array image_hash{}; + std::array hw_id{}; // raw 32 bytes (NUL-padded) + std::array base_hash{}; + std::array signer{}; + std::array signature{}; + std::array approval{}; + + bool is_full() const { return flags & MFLAG_FULL; } + bool is_signed() const { return flags & MFLAG_SIGNED; } + uint32_t block_size() const { return 1u << block_size_log2; } + uint32_t leaves_off() const { return 8 + MOTA_MFL; } + uint32_t payload_off() const { return leaves_off() + block_count * 4; } + uint32_t total_size() const { return payload_off() + payload_size + 5; } + std::string hw_id_str() const; +}; + +// Parse + validate framing and the fixed layout. Returns "" on success (fills `out`), else an error. +std::string parse(const std::vector& blob, Manifest& out); + +// Content-integrity check: recompute leaves[] from the payload vs merkle_root, the merkle root, the +// FULL image_hash, and (if signed) the Ed25519 signature against the embedded signer key. Returns a list +// of problems (empty => valid). A delta's image_hash is not checked here (it needs the base image). +std::vector verify(const std::vector& blob); + +// ---- EndF identity (fixed 56-byte trailer) ---- +bool has_endf(const std::vector& image); +FwIdent parse_endf_ident(const std::vector& image); // zeros if no EndF +// Append a 56-byte EndF (with identity) if absent; returns the image and sets body_hash8. Idempotent. +std::vector ensure_endf(const std::vector& image, const FwIdent& id, + std::array& body_hash8); + +uint32_t target_id_for_env(const std::string& env); // sha2-256:4(env) as LE uint32 +bool pack_version(const std::string& s, uint32_t& out); // "1.16.0[.pre]" -> packed uint32 + +// Reverse-lookup a target_id to its PlatformIO env name from a static table of known OTA-capable envs +// (target_id = sha2-256:4(env_name)). Returns "" if not in the table. +std::string target_env_name(uint32_t target_id); + +// ---- build ---- +struct BuildOpts { + std::vector fw; // NEW firmware (raw or already-EndF'd) + std::vector base; // base image for a delta (empty => full) + uint8_t codec = CODEC_FULL; // CODEC_FULL / _SEQUENTIAL / _INPLACE + bool have_target = false; uint32_t target_id = 0; // overrides EndF + bool have_fwver = false; uint32_t fw_version = 0; // overrides EndF + std::string hw_id; // override; else from EndF + std::vector sign_priv; // 32-byte raw private seed (empty => unsigned) + uint32_t block_size = DEFAULT_BLOCK_SIZE; + uint32_t inplace_memory = NRF52_INPLACE_MEMORY, inplace_segment = NRF52_INPLACE_SEGMENT; + std::string detools = "detools"; // detools CLI path (delta encoding only) + bool force = false; // override the cross-hardware delta guard +}; +// Returns "" + fills `out` and a suggested file name on success; else an error string. +std::string build(const BuildOpts& o, std::vector& out, std::string& suggested_name); + +// Apply a SEQUENTIAL delta `patch` to `base_img` via the detools CLI -> reconstructed image in `out`. +// Used by `verify --base` to confirm a delta actually rebuilds the expected image (matches mota.py: +// in-place deltas aren't apply-checked here — the bootloader host-harness covers that path). +std::string detools_apply_seq(const std::string& detools, const std::vector& base_img, + const std::vector& patch, std::vector& out); + +} // namespace mota diff --git a/tools/motatool/src/mota_format.h b/tools/motatool/src/mota_format.h new file mode 100644 index 00000000..d26ac64e --- /dev/null +++ b/tools/motatool/src/mota_format.h @@ -0,0 +1,100 @@ +// MeshCore `.mota` on-wire constants and fixed layout — a C++-friendly mirror of +// src/helpers/ota/OtaFormat.h. Keep byte-identical with that file and docs/ota_protocol.md. +// +// The format is FIXED-LAYOUT: every manifest field is at a constant offset and always present +// (base_hash/signer_pubkey/signature are zero-filled when not applicable). Only leaves[] varies. +#pragma once +#include +#include + +namespace mota { + +// container framing +static constexpr uint8_t MOTA_MAGIC[4] = {'m','O','T','A'}; +static constexpr uint8_t MOTA_TRAILER[5] = {'v','k','4','9','6'}; +static constexpr uint8_t ENDF_MAGIC[4] = {'E','n','d','F'}; + +static constexpr uint8_t HASH_ALGO_SHA256 = 0x12; +static constexpr uint8_t FORMAT_VER = 0x02; + +static constexpr uint8_t MFLAG_FULL = 0x01; +static constexpr uint8_t MFLAG_SIGNED = 0x02; + +static constexpr uint8_t CODEC_FULL = 0; +static constexpr uint8_t CODEC_DETOOLS_SEQUENTIAL = 1; // ESP32 A/B +static constexpr uint8_t CODEC_DETOOLS_INPLACE = 2; // nRF52 single-slot + +// hash truncations (sha2-256:N = first N bytes of the SHA-256 digest) +static constexpr size_t MH4 = 4, MH8 = 8, MH32 = 32; + +static constexpr uint8_t APPROVAL_NOT[4] = {0xFF,0xFF,0xFF,0xFF}; +static constexpr uint8_t APPROVAL_YES[4] = {'A','P','R','V'}; + +// EndF trailer (fixed 56 bytes): marker(4) body_len(4) body_hash8(8) fw_version(4) target_id(4) hw_id(32) +static constexpr uint32_t ENDF_LEN = 56; +static constexpr uint32_t ENDF_OFF_FWVER = 16; +static constexpr uint32_t ENDF_OFF_TARGET = 20; +static constexpr uint32_t ENDF_OFF_HWID = 24; + +static constexpr uint8_t HW_ID_LEN = 32; + +// manifest fixed offsets (within the manifest, i.e. container offset = 8 + these) +static constexpr uint32_t M_OFF_FORMAT_VER = 0; +static constexpr uint32_t M_OFF_FLAGS = 1; +static constexpr uint32_t M_OFF_HASH_ALGO = 2; +static constexpr uint32_t M_OFF_TARGET_ID = 3; +static constexpr uint32_t M_OFF_FW_VERSION = 7; +static constexpr uint32_t M_OFF_IMAGE_SIZE = 11; +static constexpr uint32_t M_OFF_PAYLOAD_SIZE = 15; +static constexpr uint32_t M_OFF_BLOCK_SIZE_LOG2 = 19; +static constexpr uint32_t M_OFF_MERKLE_ROOT = 20; // 4 +static constexpr uint32_t M_OFF_IMAGE_HASH = 24; // 32 +static constexpr uint32_t M_OFF_CODEC_ID = 56; // 1 +static constexpr uint32_t M_OFF_HW_ID = 57; // 32 +static constexpr uint32_t M_OFF_BASE_HASH = 89; // 8 (zero if FULL) +static constexpr uint32_t M_OFF_SIGNER = 97; // 32 (zero if unsigned) +static constexpr uint32_t M_OFF_SIGNATURE = 129; // 64 (zero if unsigned) +static constexpr uint32_t M_OFF_APPROVAL = 193; // 4 +static constexpr uint32_t MOTA_MFL = 197; // manifest-minus-leaves length (constant) +static constexpr uint32_t MOTA_SIGNED_LEN = 129; // signature covers manifest[0, 129) + +static constexpr uint32_t DEFAULT_BLOCK_SIZE = 1024; +static constexpr uint8_t DEFAULT_BLOCK_SIZE_LOG2 = 10; + +// nRF52 in-place apply workspace — MUST match src/helpers/ota/OtaFlashLayout_nrf52.h +// (MOTA_NRF52_INPLACE_MEMORY). It is NOT the full [APP_BASE, FS_START) span: the staged .mota itself sits +// just below FS_START, so the bootloader's workspace ends at the staged container (ws_hi = mota_addr), not +// at FS_START. The workspace is [APP_BASE, 0xBE000) = 0x98000, leaving 0xBE000..0xD4000 (~88 KB) for the +// staged delta. A patch built with a larger memory_size overruns the workspace at apply -> DETOOLS_IO_FAILED +// (the apply silently fails and the device just reboots). Reproduced + verified by the bootloader apply +// simulation (Adafruit_nRF52_Bootloader_OTAFIX/test/apply_sim). +static constexpr uint32_t NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB: [APP_BASE, 0xBE000) +static constexpr uint32_t NRF52_INPLACE_SEGMENT = 4096; +// The staged .mota sits in [APP_BASE+memory, FS_START); a larger container would push its start below the +// workspace end and break the apply the same way. Warn (motatool) / fail (bootloader) past this. +static constexpr uint32_t NRF52_FLASH_SPAN = 0x000D4000u - 0x00026000u; // 0xAE000 +static constexpr uint32_t NRF52_MAX_INPLACE_MOTA = NRF52_FLASH_SPAN - NRF52_INPLACE_MEMORY; // 0x16000 (~90 KB) +static_assert(NRF52_INPLACE_MEMORY < NRF52_FLASH_SPAN, "in-place workspace must leave room below FS_START for the staged .mota"); + +// ---- mota-seeder transport protocol (mirror of src/helpers/ota/MotaSeederProto.h) ---- +// Request (client -> server): 'M' 'S' op(1) args... xsum(1 = XOR of op+args) +// Response (server -> client): 'm' 's' op(1) status(1) payload... xsum(1 = XOR of all prior) +static constexpr uint8_t MS_REQ_MAGIC0 = 'M', MS_REQ_MAGIC1 = 'S'; +static constexpr uint8_t MS_RSP_MAGIC0 = 'm', MS_RSP_MAGIC1 = 's'; +static constexpr uint8_t MS_OP_COUNT = 0x01; // -> count(1) +static constexpr uint8_t MS_OP_DESCRIBE = 0x02; // idx(1) -> MotaDesc(38) +static constexpr uint8_t MS_OP_READ = 0x03; // idx(1) off(4) len(2) -> bytes +static constexpr uint8_t MS_STATUS_OK = 0x00; +static constexpr uint8_t MS_STATUS_ERR = 0x01; +static constexpr uint16_t MOTA_DESC_WIRE = 38; +// MotaDesc wire (38 B): mid[4] target_id(4) fw_version(4) codec(1) flags(1) total_size(4) +// leaves_off(4) block_count(4) payload_off(4) payload_size(4) [+2 reserved] + +inline uint32_t rd_u32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} +inline void wr_u32(uint8_t* p, uint32_t v) { + p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); +} + +} // namespace mota diff --git a/tools/motatool/src/serve.cpp b/tools/motatool/src/serve.cpp new file mode 100644 index 00000000..5812f0a9 --- /dev/null +++ b/tools/motatool/src/serve.cpp @@ -0,0 +1,209 @@ +#include "serve.h" +#include +#include +#include +#include +#include +#include +#include "util.h" + +namespace fs = std::filesystem; + +namespace mota { + +// ---- Folder: recursive scan + per-file validation ------------------------------------------------- +size_t Folder::scan(const std::string& dir, bool recursive, + const std::function& warn) { + motas_.clear(); + std::error_code ec; + auto consider = [&](const fs::path& p) { + if (p.extension() != ".mota") return; // skip non-.mota files + ServedMota sm; sm.path = p.string(); + if (!read_file(sm.path, sm.bytes)) { warn(sm.path, "cannot read"); return; } + auto probs = verify(sm.bytes); // merkle + image_hash + signature + if (!probs.empty()) { + std::string why = probs[0]; + for (size_t i = 1; i < probs.size(); i++) why += "; " + probs[i]; + warn(sm.path, why); // warn + exclude (don't sink the rest) + return; + } + parse(sm.bytes, sm.m); // already validated above + motas_.push_back(std::move(sm)); + }; + if (recursive) { + for (auto it = fs::recursive_directory_iterator(dir, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) + if (it->is_regular_file(ec)) consider(it->path()); + } else { + for (auto it = fs::directory_iterator(dir, ec); + !ec && it != fs::directory_iterator(); it.increment(ec)) + if (it->is_regular_file(ec)) consider(it->path()); + } + // stable, deterministic catalog order (indices are how the device addresses motas) + std::sort(motas_.begin(), motas_.end(), + [](const ServedMota& a, const ServedMota& b) { return a.path < b.path; }); + return motas_.size(); +} + +// ---- SeederCore (transport-agnostic) -------------------------------------------------------------- +std::array SeederCore::describe(const ServedMota& s) { + std::array w{}; + std::memcpy(w.data(), s.m.merkle_root.data(), 4); // mid + wr_u32(w.data() + 4, s.m.target_id); + wr_u32(w.data() + 8, s.m.fw_version); + w[12] = s.m.codec_id; + w[13] = s.m.flags; + wr_u32(w.data() + 14, (uint32_t)s.bytes.size()); // total_size + wr_u32(w.data() + 18, s.m.leaves_off()); + wr_u32(w.data() + 22, s.m.block_count); + wr_u32(w.data() + 26, s.m.payload_off()); + wr_u32(w.data() + 30, s.m.payload_size); + // [34..38) reserved 0 + return w; +} + +bool SeederCore::handle(uint8_t op, const uint8_t* args, size_t arglen, + uint8_t& status, std::vector& payload) const { + payload.clear(); + status = MS_STATUS_OK; + if (op == MS_OP_COUNT) { + payload.push_back((uint8_t)(folder_.count() > 255 ? 255 : folder_.count())); + return true; + } + if (op == MS_OP_DESCRIBE) { + if (arglen < 1) return false; + const ServedMota* s = folder_.at(args[0]); + if (!s) { status = MS_STATUS_ERR; return true; } + auto w = describe(*s); + payload.assign(w.begin(), w.end()); + return true; + } + if (op == MS_OP_READ) { + if (arglen < 7) return false; + const ServedMota* s = folder_.at(args[0]); + uint32_t off = rd_u32(args + 1); + uint16_t len = (uint16_t)(args[5] | (args[6] << 8)); + if (!s || (uint64_t)off + len > s->bytes.size()) { status = MS_STATUS_ERR; return true; } + payload.assign(s->bytes.begin() + off, s->bytes.begin() + off + len); + return true; + } + return false; // unknown op -> ignore (device retries) +} + +// ---- SerialTransport ------------------------------------------------------------------------------ +static speed_t baud_const(int b) { + switch (b) { + case 9600: return B9600; case 19200: return B19200; case 38400: return B38400; + case 57600: return B57600; case 115200: return B115200; case 230400: return B230400; + case 460800: return B460800; case 921600: return B921600; default: return B115200; + } +} + +std::string SerialTransport::open(const std::string& dev, int baud) { + fd_ = ::open(dev.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK); + if (fd_ < 0) return "cannot open serial device: " + dev; + termios t{}; + if (tcgetattr(fd_, &t) != 0) { ::close(fd_); fd_ = -1; return "tcgetattr failed"; } + cfmakeraw(&t); + speed_t s = baud_const(baud); + cfsetispeed(&t, s); cfsetospeed(&t, s); + t.c_cflag |= (CLOCAL | CREAD); + t.c_cflag &= ~CRTSCTS; // no flow control (matches the daemon) + t.c_cc[VMIN] = 0; t.c_cc[VTIME] = 0; + if (tcsetattr(fd_, TCSANOW, &t) != 0) { ::close(fd_); fd_ = -1; return "tcsetattr failed"; } + return ""; +} + +SerialTransport::~SerialTransport() { if (fd_ >= 0) ::close(fd_); } + +int SerialTransport::read_byte(int timeout_ms) { + if (fd_ < 0) return -1; + fd_set rs; FD_ZERO(&rs); FD_SET(fd_, &rs); + timeval tv{ timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; + int r = select(fd_ + 1, &rs, nullptr, nullptr, &tv); + if (r <= 0) return -1; + uint8_t b; + ssize_t n = ::read(fd_, &b, 1); + return n == 1 ? (int)b : -1; +} + +bool SerialTransport::write(const uint8_t* p, size_t n) { + if (fd_ < 0) return false; + size_t off = 0; + while (off < n) { + ssize_t w = ::write(fd_, p + off, n - off); + if (w < 0) return false; + off += (size_t)w; + } + return true; +} + +// ---- serial framing loop -------------------------------------------------------------------------- +static uint8_t xor_bytes(const uint8_t* p, size_t n, uint8_t seed = 0) { + uint8_t x = seed; for (size_t i = 0; i < n; i++) x ^= p[i]; return x; +} + +static bool read_exact(Transport& t, uint8_t* buf, size_t n) { + for (size_t i = 0; i < n; i++) { + int b = t.read_byte(500); + if (b < 0) return false; + buf[i] = (uint8_t)b; + } + return true; +} + +static void send_response(Transport& t, uint8_t op, uint8_t status, const std::vector& payload) { + std::vector frame; + frame.reserve(4 + payload.size() + 1); + frame.push_back(MS_RSP_MAGIC0); frame.push_back(MS_RSP_MAGIC1); + frame.push_back(op); frame.push_back(status); + frame.insert(frame.end(), payload.begin(), payload.end()); + frame.push_back(xor_bytes(frame.data(), frame.size())); // xsum over all prior bytes (incl. magic) + t.write(frame.data(), frame.size()); +} + +void serve_serial(Transport& t, const SeederCore& core, bool verbose, + const std::function& devline, + const volatile bool* stop) { + std::string line; + int prev = -1; + auto flush_line = [&]() { + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + if (!line.empty() && devline) devline(line); + line.clear(); + }; + while (!stop || !*stop) { + int b = t.read_byte(200); + if (b < 0) continue; + if (prev == MS_REQ_MAGIC0 && b == MS_REQ_MAGIC1) { // 'M''S' -> a request follows + prev = -1; + uint8_t op; + if (!read_exact(t, &op, 1)) continue; + size_t arglen = (op == MS_OP_DESCRIBE) ? 1 : (op == MS_OP_READ ? 7 : (op == MS_OP_COUNT ? 0 : SIZE_MAX)); + if (arglen == SIZE_MAX) continue; // unknown op + uint8_t args[7]; + if (arglen && !read_exact(t, args, arglen)) continue; + uint8_t xs; + if (!read_exact(t, &xs, 1)) continue; + if (xs != xor_bytes(args, arglen, op)) continue; // bad checksum -> ignore; device retries + uint8_t status; std::vector payload; + if (!core.handle(op, args, arglen, status, payload)) continue; + send_response(t, op, status, payload); + if (verbose) { + if (op == MS_OP_COUNT) devline("COUNT -> " + std::to_string(payload.empty() ? 0 : payload[0])); + else if (op == MS_OP_DESCRIBE) devline("DESCRIBE " + std::to_string(args[0]) + (status ? " ERR" : " OK")); + else if (op == MS_OP_READ) devline("READ " + std::to_string(args[0]) + " @" + + std::to_string(rd_u32(args + 1)) + (status ? " ERR" : " OK")); + } + continue; + } + if (prev >= 0) { // confirmed device text (not a frame start) + line.push_back((char)prev); + if (prev == '\n') flush_line(); + if (line.size() > 512) flush_line(); // guard runaway lines + } + prev = b; + } +} + +} // namespace mota diff --git a/tools/motatool/src/serve.h b/tools/motatool/src/serve.h new file mode 100644 index 00000000..7bff708e --- /dev/null +++ b/tools/motatool/src/serve.h @@ -0,0 +1,68 @@ +// Serve a folder of .mota to a MeshCore node. Split into a transport-AGNOSTIC protocol core (reusable +// over USB-serial today and BLE/GATT in the future) and a serial byte-stream framing layer. +#pragma once +#include "mota.h" +#include "mota_format.h" +#include +#include +#include +#include + +namespace mota { + +struct ServedMota { + std::string path; + std::vector bytes; + Manifest m; +}; + +// Recursively collect every *.mota under `dir`, validate each, keep only the valid ones. Corrupt/invalid +// files are reported through `warn(path, reason)` and excluded — one bad file never sinks the rest. +class Folder { +public: + size_t scan(const std::string& dir, bool recursive, + const std::function& warn); + size_t count() const { return motas_.size(); } + const ServedMota* at(size_t i) const { return i < motas_.size() ? &motas_[i] : nullptr; } + const std::vector& all() const { return motas_; } +private: + std::vector motas_; +}; + +// Transport-agnostic seeder: turns a (op, args) request into a (status, payload) response. The BLE path +// would call this directly from a characteristic-write handler and notify the reply — no framing needed. +class SeederCore { +public: + explicit SeederCore(const Folder& f) : folder_(f) {} + bool handle(uint8_t op, const uint8_t* args, size_t arglen, + uint8_t& status, std::vector& payload) const; + static std::array describe(const ServedMota& s); +private: + const Folder& folder_; +}; + +// A bidirectional byte link (the serial seeder needs this; BLE would reuse SeederCore directly). +struct Transport { + virtual ~Transport() {} + virtual int read_byte(int timeout_ms) = 0; // a byte 0..255, or -1 on timeout/closed + virtual bool write(const uint8_t* p, size_t n) = 0; + bool write_str(const std::string& s) { return write((const uint8_t*)s.data(), s.size()); } +}; + +class SerialTransport : public Transport { +public: + std::string open(const std::string& dev, int baud); // "" on success + ~SerialTransport() override; + int read_byte(int timeout_ms) override; + bool write(const uint8_t* p, size_t n) override; +private: + int fd_ = -1; +}; + +// Serial framing loop: resync on 'M''S', verify the request checksum, dispatch to `core`, frame the +// reply. Device text/log lines sharing the wire are surfaced via `devline`. Runs until *stop becomes true. +void serve_serial(Transport& t, const SeederCore& core, bool verbose, + const std::function& devline, + const volatile bool* stop); + +} // namespace mota diff --git a/tools/motatool/src/util.h b/tools/motatool/src/util.h new file mode 100644 index 00000000..9e5e51af --- /dev/null +++ b/tools/motatool/src/util.h @@ -0,0 +1,77 @@ +// Small host-side helpers: file I/O, subprocess exec (no shell), hex. Header-only. +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mota { + +inline bool read_file(const std::string& path, std::vector& out) { + std::ifstream f(path, std::ios::binary); + if (!f) return false; + out.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); + return (bool)f || f.eof(); +} + +inline bool write_file(const std::string& path, const uint8_t* data, size_t len) { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.write((const char*)data, (std::streamsize)len); + return (bool)f; +} + +// Run argv[0] with argv (no shell, so no quoting pitfalls). Returns the child exit code, or -1 to spawn. +// `quiet` redirects the child's stdout to /dev/null (its chatter), keeping stderr so errors still show. +inline int run_argv(const std::vector& argv, bool quiet = false) { + if (argv.empty()) return -1; + pid_t pid = fork(); + if (pid < 0) return -1; + if (pid == 0) { + if (quiet) { + int n = ::open("/dev/null", O_WRONLY); + if (n >= 0) { dup2(n, 1); if (n > 2) ::close(n); } + } + std::vector a; + for (auto& s : argv) a.push_back(const_cast(s.c_str())); + a.push_back(nullptr); + execvp(a[0], a.data()); + _exit(127); // exec failed + } + int st = 0; + if (waitpid(pid, &st, 0) < 0) return -1; + return WIFEXITED(st) ? WEXITSTATUS(st) : -1; +} + +inline std::string to_hex(const uint8_t* p, size_t n) { + static const char* H = "0123456789ABCDEF"; + std::string s; s.reserve(n * 2); + for (size_t i = 0; i < n; i++) { s.push_back(H[p[i] >> 4]); s.push_back(H[p[i] & 0xF]); } + return s; +} + +// Parse a hex string (optionally 0x-prefixed) into bytes. Returns false on odd length / bad char. +inline bool from_hex(const std::string& in, std::vector& out) { + std::string s = in; + if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s = s.substr(2); + if (s.size() % 2) return false; + out.clear(); + auto nib = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + for (size_t i = 0; i < s.size(); i += 2) { + int hi = nib(s[i]), lo = nib(s[i + 1]); + if (hi < 0 || lo < 0) return false; + out.push_back((uint8_t)((hi << 4) | lo)); + } + return true; +} + +} // namespace mota diff --git a/tools/motatool/tests/test_motatool.cpp b/tools/motatool/tests/test_motatool.cpp new file mode 100644 index 00000000..87b7bd97 --- /dev/null +++ b/tools/motatool/tests/test_motatool.cpp @@ -0,0 +1,302 @@ +// Unit tests for motatool's core logic (no external framework — a tiny built-in harness keeps the +// project self-contained). Run: ./build/motatool_tests or ctest --test-dir build --output-on-failure +#include "mota.h" +#include "crypto.h" +#include "serve.h" +#include "input.h" +#include "util.h" +#include "mota_format.h" +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace mota; + +static int g_checks = 0, g_fail = 0; +static const char* g_test = ""; +#define CHECK(cond) do { g_checks++; if (!(cond)) { g_fail++; \ + std::cerr << " FAIL [" << g_test << "] " << __LINE__ << ": " #cond "\n"; } } while (0) + +// deterministic synthetic firmware body +static std::vector body(unsigned seed, size_t n) { + std::vector v(n); + uint32_t s = seed * 2654435761u + 1; + for (size_t i = 0; i < n; i++) { s = s * 1103515245u + 12345u; v[i] = (uint8_t)(s >> 16); } + return v; +} + +static bool detools_available() { + return run_argv({"detools", "--help"}, /*quiet=*/true) != 127; // 127 = exec failed (not installed) +} + +// --------------------------------------------------------------------------- +static void t_crypto() { + g_test = "crypto"; + // sha256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + const char* abc = "abc"; + auto h4 = mh4((const uint8_t*)abc, 3); + auto h8 = mh8((const uint8_t*)abc, 3); + auto h32 = mh32((const uint8_t*)abc, 3); + std::vector exp; + from_hex("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", exp); + CHECK(std::memcmp(h4.data(), exp.data(), 4) == 0); + CHECK(std::memcmp(h8.data(), exp.data(), 8) == 0); + CHECK(std::memcmp(h32.data(), exp.data(), 32) == 0); + + uint8_t priv[32], pub[32], pub2[32]; + CHECK(ed25519_keygen(priv, pub)); + CHECK(ed25519_pub_from_priv(pub2, priv)); + CHECK(std::memcmp(pub, pub2, 32) == 0); + auto msg = body(1, 100); + uint8_t sig[64]; + CHECK(ed25519_sign(sig, msg.data(), msg.size(), priv)); + CHECK(ed25519_verify(sig, msg.data(), msg.size(), pub)); + msg[0] ^= 0xFF; // tampered message -> reject + CHECK(!ed25519_verify(sig, msg.data(), msg.size(), pub)); + msg[0] ^= 0xFF; + uint8_t other[32], opub[32]; ed25519_keygen(other, opub); + CHECK(!ed25519_verify(sig, msg.data(), msg.size(), opub)); // wrong key -> reject +} + +static void t_version_target() { + g_test = "version/target"; + uint32_t v; + CHECK(pack_version("1.16.0", v) && v == 0x01100000u); + CHECK(pack_version("2.0.0", v) && v == 0x02000000u); + CHECK(pack_version("1.16.0.2", v) && (v & 0xFF) == 2); + CHECK(!pack_version("", v)); + CHECK(!pack_version("x.y", v)); + // known target ids from the firmware builds (ties motatool's hashing to the device) + CHECK(target_id_for_env("Heltec_v3_repeater") == 0xd1b29b18u); + CHECK(target_id_for_env("RAK_4631_repeater") == 0x04d413fdu); + // reverse lookup table (human-readable target_id) -> env name, "" when unknown + CHECK(target_env_name(0xd1b29b18u) == "Heltec_v3_repeater"); + CHECK(target_env_name(0x04d413fdu) == "RAK_4631_repeater"); + CHECK(target_env_name(0xDEADBEEFu).empty()); + // the shared (generated) table must round-trip with our own hashing for a spread of OTA envs — + // this catches any drift between the table's stored ids and sha2-256:4(env) + for (const char* e : {"Tbeam_SX1262_repeater", "Xiao_C3_repeater", "ThinkNode_M2_room_server", + "Ebyte_EoRa-S3_companion_radio_ble", "Heltec_v3_companion_radio_usb"}) + CHECK(target_env_name(target_id_for_env(e)) == e); +} + +static void t_intel_hex() { + g_test = "intel hex"; + fs::path hx = fs::temp_directory_path() / "motatool_test.hex"; + // 3 data bytes (01 02 03) at addr 0, then EOF (checksum F7 = two's-complement of the byte sum) + std::string ok = ":03000000010203F7\n:00000001FF\n"; + write_file(hx.string(), (const uint8_t*)ok.data(), ok.size()); + std::vector out; + CHECK(read_input(hx.string(), out).empty()); + CHECK(out.size() == 3 && out[0] == 1 && out[1] == 2 && out[2] == 3); + // wrong checksum -> rejected + std::string bad = ":03000000010203F8\n:00000001FF\n"; + write_file(hx.string(), (const uint8_t*)bad.data(), bad.size()); + std::vector o2; + CHECK(!read_input(hx.string(), o2).empty()); + fs::remove(hx); +} + +static void t_endf() { + g_test = "endf"; + auto b = body(2, 1500); + std::array bh{}; + FwIdent id{0x01100000u, 0x04d413fdu, "RAK4631"}; + auto img = ensure_endf(b, id, bh); + CHECK(img.size() == b.size() + ENDF_LEN); + CHECK(has_endf(img)); + CHECK(std::memcmp(bh.data(), mh8(b.data(), b.size()).data(), 8) == 0); // body_hash is over BODY only + auto gi = parse_endf_ident(img); + CHECK(gi.fw_version == 0x01100000u && gi.target_id == 0x04d413fdu && gi.hw_id == "RAK4631"); + // idempotent: feeding an already-EndF'd image keeps it + reads the same hash + std::array bh2{}; + auto img2 = ensure_endf(img, FwIdent{}, bh2); + CHECK(img2 == img && bh2 == bh); + // zero identity -> still a 56-byte trailer, identity reads empty + std::array bz{}; + auto z = ensure_endf(b, FwIdent{}, bz); + CHECK(z.size() == b.size() + ENDF_LEN); + auto zi = parse_endf_ident(z); + CHECK(zi.fw_version == 0 && zi.target_id == 0 && zi.hw_id.empty()); + CHECK(!has_endf(std::vector{1,2,3})); // too short +} + +static BuildOpts full_opts(const std::vector& fw, const std::vector& priv = {}) { + BuildOpts o; o.fw = fw; o.codec = CODEC_FULL; + o.have_target = true; o.target_id = 0x04d413fdu; + o.have_fwver = true; o.fw_version = 0x01100000u; + o.hw_id = "RAK4631"; o.sign_priv = priv; + return o; +} + +static void t_build_full() { + g_test = "build full"; + auto fw = body(3, 5 * 1024 + 100); // 6 blocks @1024 + std::vector blob; std::string name; + CHECK(build(full_opts(fw), blob, name).empty()); + Manifest m; + CHECK(parse(blob, m).empty()); + CHECK(m.format_ver == FORMAT_VER && m.is_full() && !m.is_signed()); + CHECK(m.codec_id == CODEC_FULL); + CHECK(m.target_id == 0x04d413fdu && m.fw_version == 0x01100000u && m.hw_id_str() == "RAK4631"); + CHECK(m.block_count == 6); + // fixed layout offsets + CHECK(m.leaves_off() == 8 + MOTA_MFL && m.leaves_off() == 205); + CHECK(m.payload_off() == 205 + m.block_count * 4); + CHECK(m.total_size() == blob.size()); + // payload IS the image (body+EndF); image_hash matches + uint32_t poff = m.payload_off(); + auto img_h = mh32(blob.data() + poff, m.payload_size); + CHECK(std::memcmp(img_h.data(), m.image_hash.data(), 32) == 0); + CHECK(m.image_size == m.payload_size); + CHECK(verify(blob).empty()); // clean + CHECK(name.find(".mota") != std::string::npos && name.find("full") != std::string::npos); +} + +static void t_build_signed_and_tamper() { + g_test = "build signed/tamper"; + uint8_t priv[32], pub[32]; ed25519_keygen(priv, pub); + std::vector pv(priv, priv + 32); + auto fw = body(4, 3000); + std::vector blob; std::string name; + CHECK(build(full_opts(fw, pv), blob, name).empty()); + Manifest m; CHECK(parse(blob, m).empty()); + CHECK(m.is_signed()); + CHECK(std::memcmp(m.signer.data(), pub, 32) == 0); + CHECK(verify(blob).empty()); + // tamper a payload byte -> integrity fails + auto t1 = blob; t1[m.payload_off() + 10] ^= 0xFF; + CHECK(!verify(t1).empty()); + // tamper a signed-region byte (target_id @ manifest+3 = blob+11) -> signature invalid + auto t2 = blob; t2[8 + M_OFF_TARGET_ID] ^= 0xFF; + bool sig_flagged = false; + for (auto& p : verify(t2)) if (p.find("signature") != std::string::npos) sig_flagged = true; + CHECK(sig_flagged); +} + +static void t_corruption_and_approval() { + g_test = "corruption/approval"; + auto fw = body(5, 4096); + std::vector blob; std::string name; + build(full_opts(fw), blob, name); + Manifest m; parse(blob, m); + // flip a payload byte -> leaves/merkle/image_hash problems reported + auto c = blob; c[m.payload_off() + 1] ^= 0xFF; + CHECK(!verify(c).empty()); + // a pre-approved container must be flagged (approval is outside the signed region) + auto ap = blob; std::memcpy(ap.data() + 8 + M_OFF_APPROVAL, APPROVAL_YES, 4); + bool approved_flagged = false; + for (auto& p : verify(ap)) if (p.find("approved") != std::string::npos) approved_flagged = true; + CHECK(approved_flagged); +} + +static void t_parse_rejects() { + g_test = "parse rejects"; + auto fw = body(6, 2048); + std::vector blob; std::string name; build(full_opts(fw), blob, name); + Manifest m; + CHECK(!parse(std::vector(10, 0), m).empty()); // too small + auto bad = blob; bad[0] ^= 0xFF; CHECK(!parse(bad, m).empty()); // bad magic + bad = blob; bad[bad.size() - 1] ^= 0xFF; CHECK(!parse(bad, m).empty()); // bad trailer + bad = blob; bad[4] ^= 0xFF; CHECK(!parse(bad, m).empty()); // wrong total_size + bad = blob; bad[8 + M_OFF_FORMAT_VER] = 9; CHECK(!parse(bad, m).empty()); // bad format_ver +} + +static void t_delta() { + g_test = "delta"; + if (!detools_available()) { std::cerr << " SKIP [delta] detools not on PATH\n"; return; } + auto base_body = body(10, 4000); + auto new_body = base_body; + for (int i : {100, 101, 2000, 3999}) new_body[i] ^= 0x33; + auto tail = body(11, 250); + new_body.insert(new_body.end(), tail.begin(), tail.end()); + + for (uint8_t codec : {CODEC_DETOOLS_SEQUENTIAL, CODEC_DETOOLS_INPLACE}) { + BuildOpts o; o.fw = new_body; o.base = base_body; o.codec = codec; + o.have_target = true; o.target_id = 0x04d413fdu; o.have_fwver = true; o.fw_version = 0x02000000u; + o.hw_id = "RAK4631"; + std::vector blob; std::string name; + std::string e = build(o, blob, name); + CHECK(e.empty()); + if (!e.empty()) continue; + Manifest m; CHECK(parse(blob, m).empty()); + CHECK(!m.is_full() && m.codec_id == codec); + // base_hash == mh8 of the base BODY + CHECK(std::memcmp(m.base_hash.data(), mh8(base_body.data(), base_body.size()).data(), 8) == 0); + CHECK(verify(blob).empty()); + if (codec == CODEC_DETOOLS_SEQUENTIAL) { // round-trip: apply -> rebuilds the new image + std::array bh{}; + auto base_img = ensure_endf(base_body, FwIdent{}, bh); + std::vector patch(blob.begin() + m.payload_off(), blob.begin() + m.payload_off() + m.payload_size); + std::vector recon; + CHECK(detools_apply_seq("detools", base_img, patch, recon).empty()); + CHECK(std::memcmp(mh32(recon.data(), recon.size()).data(), m.image_hash.data(), 32) == 0); + } + } +} + +static void t_folder_and_seeder() { + g_test = "folder/seeder"; + fs::path dir = fs::temp_directory_path() / "motatool_test_folder"; + fs::remove_all(dir); fs::create_directories(dir / "sub"); + + // two valid motas (one in a sub-folder, to exercise recursion) + one corrupt + std::vector a, b; std::string n; + build(full_opts(body(20, 3000)), a, n); write_file((dir / "a.mota").string(), a.data(), a.size()); + build(full_opts(body(21, 6000)), b, n); write_file((dir / "sub" / "b.mota").string(), b.data(), b.size()); + auto bad = a; bad[a.size() / 2] ^= 0xFF; write_file((dir / "bad.mota").string(), bad.data(), bad.size()); + write_file((dir / "ignore.txt").string(), a.data(), 10); // non-.mota -> skipped silently + + int warns = 0; std::string warned_path; + Folder folder; + size_t kept = folder.scan(dir.string(), true, + [&](const std::string& p, const std::string&){ warns++; warned_path = p; }); + CHECK(kept == 2); // two valid kept + CHECK(warns == 1 && warned_path.find("bad.mota") != std::string::npos); // corrupt warned + excluded + + SeederCore core(folder); + uint8_t status; std::vector pl; + CHECK(core.handle(MS_OP_COUNT, nullptr, 0, status, pl) && status == MS_STATUS_OK && pl.size() == 1 && pl[0] == 2); + + uint8_t arg0[1] = {0}; + CHECK(core.handle(MS_OP_DESCRIBE, arg0, 1, status, pl) && status == MS_STATUS_OK && pl.size() == MOTA_DESC_WIRE); + const ServedMota* s0 = folder.at(0); + CHECK(std::memcmp(pl.data(), s0->m.merkle_root.data(), 4) == 0); // mid + CHECK(rd_u32(pl.data() + 4) == s0->m.target_id); + CHECK(rd_u32(pl.data() + 14) == (uint32_t)s0->bytes.size()); // total_size + CHECK(rd_u32(pl.data() + 18) == s0->m.leaves_off() && rd_u32(pl.data() + 18) == 205); + CHECK(rd_u32(pl.data() + 22) == s0->m.block_count); + CHECK(rd_u32(pl.data() + 26) == s0->m.payload_off()); + CHECK(rd_u32(pl.data() + 30) == s0->m.payload_size); + + uint8_t bad_idx[1] = {99}; + CHECK(core.handle(MS_OP_DESCRIBE, bad_idx, 1, status, pl) && status == MS_STATUS_ERR); + + // READ idx=0 off=0 len=8 -> first 8 bytes (MAGIC + total_size) + uint8_t rd[7] = {0, 0,0,0,0, 8,0}; + CHECK(core.handle(MS_OP_READ, rd, 7, status, pl) && status == MS_STATUS_OK && pl.size() == 8); + CHECK(std::memcmp(pl.data(), s0->bytes.data(), 8) == 0); + // READ past EOF -> error + uint8_t reof[7]; reof[0] = 0; wr_u32(reof + 1, (uint32_t)s0->bytes.size()); reof[5] = 16; reof[6] = 0; + CHECK(core.handle(MS_OP_READ, reof, 7, status, pl) && status == MS_STATUS_ERR); + + fs::remove_all(dir); +} + +int main() { + t_crypto(); + t_version_target(); + t_intel_hex(); + t_endf(); + t_build_full(); + t_build_signed_and_tamper(); + t_corruption_and_approval(); + t_parse_rejects(); + t_delta(); + t_folder_and_seeder(); + + std::cout << (g_fail ? "FAILED " : "OK ") << (g_checks - g_fail) << "/" << g_checks << " checks passed\n"; + return g_fail ? 1 : 0; +}