mirror of
https://github.com/vk496/MeshCore.git
synced 2026-09-09 20:25:59 +00:00
ota: extract the motatool CLI to its own repo (github.com/vk496/motatool)
The host-side build/verify/inspect/serve CLI now lives as a standalone Rust project at https://github.com/vk496/motatool. Remove the in-tree C++ copy (tools/motatool/) and repoint the docs (ota_protocol.md, ota_user_guide.md, tools/mota/README.md) at the standalone repo. No cross-dependency either direction: MeshCore's firmware build never invoked motatool (only tools/mota/ Python glue runs in the build), and motatool depends only on the shared .mota wire spec in docs/ota_protocol.md. tools/mota/ (pio_endf.py EndF hook, motalib.py reference lib, gen_targets.py) is unchanged.
This commit is contained in:
@@ -33,7 +33,7 @@ where a section names a source file, that file is the authoritative reference fo
|
||||
| Staging stores | `OtaStore.h`, `OtaStoreFlashNrf52.*`, `OtaStoreFlashEsp32.*` |
|
||||
| Apply | `OtaApply.*`, bootloader `Adafruit_nRF52_Bootloader_OTAFIX` |
|
||||
| Device glue (CLI/context) | `OtaCli.cpp`, `OtaContext.h` |
|
||||
| Host tooling | `tools/motatool/` (C++ CLI: build/verify/inspect/serve); `tools/mota/` (Python reference lib `motalib.py` + build/test glue) |
|
||||
| Host tooling | [`motatool`](https://github.com/vk496/motatool) (standalone Rust CLI: build/verify/inspect/serve); `tools/mota/` (Python reference lib `motalib.py` + build/test glue) |
|
||||
|
||||
---
|
||||
|
||||
@@ -540,8 +540,8 @@ blocks) and streams payload blocks from the source on demand; proofs are generat
|
||||
|
||||
A `MotaSource` is fed by a host that serves a folder over the device's **USB serial** (the same console the
|
||||
CLI uses — no extra hardware) or, on an ESP32 WiFi companion, over **WiFi (TCP)**. The host is the
|
||||
self-contained C++ tool `tools/motatool/` (`motatool serve --serial <port>` / `--tcp <host[:port]>`, which
|
||||
also builds + validates `.mota` and runs on small hardware). The device only emits request frames *while
|
||||
standalone Rust tool [`motatool`](https://github.com/vk496/motatool) (`motatool serve --serial <port>` /
|
||||
`--tcp <host[:port]>`, which also builds + verifies + inspects `.mota`). The device only emits request frames *while
|
||||
actively serving a fetch*, and reads the reply synchronously, so over the shared USB console binary frames
|
||||
coexist with the text CLI/logs (resync on magic + checksum). Little-endian, XOR-checksummed:
|
||||
|
||||
|
||||
@@ -203,19 +203,20 @@ folder of firmware files to the mesh — without storing them itself. Useful for
|
||||
remote area.
|
||||
|
||||
1. Put the firmware files (`.mota` files — see below) in a folder on the computer.
|
||||
2. Build the helper tool once (`tools/motatool/`), then point it at your node and the folder — over the
|
||||
node's **USB serial**, or over **WiFi** if it's an ESP32 companion on your network:
|
||||
2. Install the helper tool once — the standalone `motatool` CLI (<https://github.com/vk496/motatool>) —
|
||||
then point it at your node and the folder — over the node's **USB serial**, or over **WiFi** if it's
|
||||
an ESP32 companion on your network:
|
||||
```
|
||||
cmake -S tools/motatool -B tools/motatool/build && cmake --build tools/motatool/build
|
||||
git clone https://github.com/vk496/motatool && cargo install --path ./motatool
|
||||
# over USB serial:
|
||||
./tools/motatool/build/motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v
|
||||
motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v
|
||||
# …or over WiFi (ESP32 companion): the seeder is on a DEDICATED port (5001), separate from the
|
||||
# phone-app port (5000), so a phone can stay connected while you serve:
|
||||
./tools/motatool/build/motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v
|
||||
motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v
|
||||
```
|
||||
It answers the node's requests; your node then advertises those updates to neighbours, who can
|
||||
`ota get` them like any other. (A WiFi node prints its IP + seeder port to the serial log on connect.
|
||||
Details: [tools/motatool/README.md](../tools/motatool/README.md).)
|
||||
Details: <https://github.com/vk496/motatool>.)
|
||||
|
||||
To stop, just stop the daemon — over WiFi the node auto-detaches when the connection closes; over USB you
|
||||
can also run `ota folder off` on the node. `ota folder` on its own lists what your node is offering.
|
||||
|
||||
@@ -4,10 +4,10 @@ The Python side of MeshCore's `.mota` OTA system. It is the **reference implemen
|
||||
spec ([`docs/ota_protocol.md`](../../docs/ota_protocol.md)) and the **build + test infrastructure** — it is
|
||||
no longer a user-facing CLI.
|
||||
|
||||
> **Want to build / verify / inspect / serve `.mota` from the command line?** Use the self-contained C++
|
||||
> tool [`tools/motatool/`](../motatool/). It supersedes the old `mota.py` / `mota_seeder.py` (now removed),
|
||||
> produces byte-identical containers, and runs on small hardware. The files here are the spec oracle and
|
||||
> the firmware build/test glue.
|
||||
> **Want to build / verify / inspect / serve `.mota` from the command line?** Use the standalone
|
||||
> [`motatool`](https://github.com/vk496/motatool) Rust CLI (its own repository). It supersedes the old
|
||||
> `mota.py` / `mota_seeder.py` (now removed) and produces byte-identical containers. The files here are
|
||||
> the spec oracle and the firmware build/test glue.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
build/
|
||||
@@ -1,42 +0,0 @@
|
||||
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)
|
||||
@@ -1,127 +0,0 @@
|
||||
# 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 (`--serial`) or WiFi (`--tcp`). Invalid files are warned about and skipped — one corrupt file never sinks the rest. The same link also **stores** a node's `ota pull … folder` captures into that folder as `<id>.mota` — pull a device's exact firmware off the mesh to build deltas against firmware you don't otherwise have. |
|
||||
| `keygen` | Generate an Ed25519 signing keypair (hex). |
|
||||
|
||||
Every command has detailed, example-rich help: `motatool <command> --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 (recursive; skips non-.mota; warns on corrupt)
|
||||
$MT serve --dir ./motas --serial /dev/ttyUSB0 --baud 115200 -v # over USB serial
|
||||
$MT serve --dir ./motas --tcp 192.168.1.50:5001 -v # …or over WiFi (ESP32 companion, port 5001)
|
||||
|
||||
# 5b. warm-start a capture: seed the pull with a SIMILAR build, then `ota pull <#> folder validate` on the
|
||||
# node pulls only the blocks that differ (fast capture of a non-deterministic rebuild of the same fw)
|
||||
$MT serve --dir ./captured --tcp 192.168.1.50:5001 --seed ./similar_build.mota -v
|
||||
```
|
||||
|
||||
`serve` doubles as the **pull-to-folder storage**: a node's `ota pull <#> folder` captures the firmware it's
|
||||
fetching off-mesh into `--dir` as `<mid>.mota`. With `--seed <similar.mota>`, that seed's payload is staged
|
||||
into each capture so a `ota pull <#> folder validate` on the node bulk-fetches the target's merkle leaves,
|
||||
keeps every block the seed already matches, and transfers over LoRa only the differing ones — a byte-exact,
|
||||
verifiable capture in seconds instead of a full-image download (see [`../../docs/ota_protocol.md`](../../docs/ota_protocol.md) §8, "warm-start").
|
||||
|
||||
`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 <path>`. **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:
|
||||
`<hw>_<target>_v<ver>_<full|seqdelta|ipdelta>_<mid>.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.
|
||||
- **`Transport` + `serve_loop()`** wrap it with the byte-stream framing (magic + XOR checksum + resync,
|
||||
`MotaSeederProto.h`). Two transports ship: **`SerialTransport`** (`--serial`, USB-UART) and
|
||||
**`TcpTransport`** (`--tcp <host[:port]>`, WiFi — connects to the node's dedicated seeder port, default
|
||||
`5001`, which the ESP32 companion runs alongside its phone-app port so both work at once).
|
||||
|
||||
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).
|
||||
@@ -1,61 +0,0 @@
|
||||
#include "crypto.h"
|
||||
#include <cstring>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
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
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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 <cstdint>
|
||||
#include <cstddef>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<uint8_t,4> mh4(const uint8_t* d, size_t n) { std::array<uint8_t,4> o; sha256_trunc(o.data(),4,d,n); return o; }
|
||||
inline std::array<uint8_t,8> mh8(const uint8_t* d, size_t n) { std::array<uint8_t,8> o; sha256_trunc(o.data(),8,d,n); return o; }
|
||||
inline std::array<uint8_t,32> mh32(const uint8_t* d, size_t n) { std::array<uint8_t,32> 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
|
||||
@@ -1,102 +0,0 @@
|
||||
#include "input.h"
|
||||
#include "util.h"
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <unistd.h>
|
||||
|
||||
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<uint8_t>& in, std::vector<uint8_t>& 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<uint8_t> data; };
|
||||
std::vector<Seg> 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<uint8_t> 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<uint8_t>& out) {
|
||||
std::vector<uint8_t> 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
|
||||
@@ -1,15 +0,0 @@
|
||||
// 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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<uint8_t>& out);
|
||||
|
||||
bool is_url(const std::string& s);
|
||||
|
||||
} // namespace mota
|
||||
@@ -1,545 +0,0 @@
|
||||
// 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 <cctype>
|
||||
#include <csignal>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
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<std::string,std::string> opt;
|
||||
std::vector<std::string> 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<std::string>& 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<uint8_t>& priv) {
|
||||
std::vector<uint8_t> 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<uint8_t> 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 <command> [options]\n"
|
||||
" motatool <command> --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 or WiFi (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 <file|url> [--base <file|url>] [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 <file|url> 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 <file|url> 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 <env> PlatformIO env name (e.g. RAK_4631_repeater); hashed into the target id.\n"
|
||||
" --target-id <hex> raw target id instead of --target-env (e.g. 0x04D413FD).\n"
|
||||
" --fw-version <x.y.z> firmware version (e.g. 1.16.0).\n"
|
||||
" --hw-id <tag> 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 <n> nRF52 in-place workspace size (default 0xAE000).\n"
|
||||
" --inplace-segment <n> nRF52 in-place erase/segment size (default 4096).\n"
|
||||
" --detools <path> 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 <keyfile> 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 <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"
|
||||
" <hw>_<target>_v<version>_<full|seqdelta|ipdelta>_<id>.mota\n"
|
||||
" --out <file> 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 <file.mota> [more.mota ...] [--pub <keyfile>] [--base <file|url>]\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 <reasons>' 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 <keyfile> require the container to be signed by THIS public key (hex/raw, *.pub from keygen).\n"
|
||||
" --base <file|url> 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 <file.mota>\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 or WiFi (TCP).\n"
|
||||
"\n"
|
||||
"USAGE\n"
|
||||
" motatool serve --dir <folder> --serial <port> [options] # over USB serial\n"
|
||||
" motatool serve --dir <folder> --tcp <host[:port]> [options] # over WiFi (ESP32 companion)\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 one of --serial / --tcp are required)\n"
|
||||
" --dir <folder> folder of .mota to serve (searched recursively by default).\n"
|
||||
" --serial <port> the node's USB serial port (e.g. /dev/ttyUSB0 or /dev/ttyACM0).\n"
|
||||
" --tcp <host[:port]> the node's WiFi seeder address (default port 5001). This is a DEDICATED\n"
|
||||
" port, separate from the companion port (5000), so serving doesn't disturb a\n"
|
||||
" phone app connected to the node. The node auto-enables relaying on connect.\n"
|
||||
" --baud <n> serial speed (default 115200; --serial only).\n"
|
||||
" --no-recursive serve only the top folder; don't descend into sub-folders.\n"
|
||||
" --no-enable (--serial only) don't auto-send 'ota folder on'/'off' on the node's CLI.\n"
|
||||
" --seed <file.mota> warm-start: stage this (similar) build's payload into each captured .part so an\n"
|
||||
" `ota pull <#> folder validate` diffs it against the target's merkle leaves and pulls\n"
|
||||
" only the differing blocks — capture a non-deterministic rebuild in seconds.\n"
|
||||
" -v, --verbose log each request the node makes (COUNT / DESCRIBE / READ).\n"
|
||||
"\n"
|
||||
"Leave it running; press Ctrl-C to stop. Over serial it shares the USB cable with the node's text\n"
|
||||
"console; over TCP it uses the node's dedicated seeder port. The node only pulls while fetching.\n"
|
||||
"\n"
|
||||
"EXAMPLES\n"
|
||||
" motatool serve --dir ./motas --serial /dev/ttyUSB0 -v\n"
|
||||
" motatool serve --dir ./motas --tcp 192.168.4.234 -v\n";
|
||||
}
|
||||
|
||||
static void help_keygen() {
|
||||
std::cout <<
|
||||
"motatool keygen — generate an Ed25519 signing keypair.\n"
|
||||
"\n"
|
||||
"USAGE\n"
|
||||
" motatool keygen [--out <keyfile>]\n"
|
||||
"\n"
|
||||
"Prints the public key. With --out it writes the private key to <keyfile> and the public key to\n"
|
||||
"<keyfile>.pub (hex). Sign updates with the private key ('build --sign <keyfile>'); 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<uint8_t> 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<uint8_t> base_img;
|
||||
if (a.has("base")) {
|
||||
std::vector<uint8_t> b; std::string e = read_input(a.get("base"), b);
|
||||
if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; }
|
||||
std::array<uint8_t,8> bh; base_img = ensure_endf(b, parse_endf_ident(b), bh);
|
||||
}
|
||||
|
||||
int bad = 0;
|
||||
for (const auto& f : a.pos) {
|
||||
std::vector<uint8_t> 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<uint8_t> patch(blob.begin() + m.payload_off(), blob.begin() + m.payload_off() + m.payload_size);
|
||||
std::vector<uint8_t> 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<uint8_t> 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<n;i++) if (p[i]) return false; return true; };
|
||||
std::cout
|
||||
<< "total_size : " << blob.size() << "\n"
|
||||
<< "format_ver : " << (int)m.format_ver << "\n"
|
||||
<< "flags : 0x" << [&]{char x[3];std::snprintf(x,3,"%02x",m.flags);return std::string(x);}()
|
||||
<< " FULL=" << (m.is_full()?"true":"false") << " SIGNED=" << (m.is_signed()?"true":"false") << "\n"
|
||||
<< "hash_algo : 0x12 (sha2-256)\n"
|
||||
<< "target_id : 0x" << [&]{char x[9];std::snprintf(x,9,"%08x",m.target_id);return std::string(x);}()
|
||||
<< " (" << target_label(m.target_id) << ")\n"
|
||||
<< "fw_version : " << version_str(m.fw_version) << " (0x"
|
||||
<< [&]{char x[9];std::snprintf(x,9,"%08x",m.fw_version);return std::string(x);}() << ")\n"
|
||||
<< "image_size : " << m.image_size << "\n"
|
||||
<< "payload_size : " << m.payload_size << "\n"
|
||||
<< "block_size : " << m.block_size() << " (log2=" << (int)m.block_size_log2 << ") block_count=" << m.block_count << "\n"
|
||||
<< "codec_id : " << (int)m.codec_id << " ("
|
||||
<< (m.codec_id==CODEC_FULL?"full":m.codec_id==CODEC_DETOOLS_SEQUENTIAL?"detools-sequential":
|
||||
m.codec_id==CODEC_DETOOLS_INPLACE?"detools-in-place":"?") << ")\n"
|
||||
<< "merkle_root : " << to_hex(m.merkle_root.data(), 4) << "\n"
|
||||
<< "image_hash : " << to_hex(m.image_hash.data(), 32) << "\n"
|
||||
<< "hw_id : " << (m.hw_id_str().empty()?"(none)":m.hw_id_str()) << "\n";
|
||||
if (!m.is_full()) std::cout << "base_hash : " << to_hex(m.base_hash.data(), 8)
|
||||
<< (z(m.base_hash.data(),8) ? " (zero)" : "") << "\n";
|
||||
if (m.is_signed()) {
|
||||
std::cout << "signer_pubkey : " << to_hex(m.signer.data(), 32) << "\n"
|
||||
<< "signature : " << to_hex(m.signature.data(), 64) << "\n";
|
||||
}
|
||||
bool approved = std::memcmp(m.approval.data(), APPROVAL_YES, 4) == 0;
|
||||
std::cout << "approval : " << to_hex(m.approval.data(), 4) << " (" << (approved?"APPROVED":"not approved") << ")\n"
|
||||
<< "leaves[] : " << m.block_count << " x 4 bytes\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// infer the apply codec for a delta from the firmware's hardware tag (nRF52 -> 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<uint8_t> 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; }
|
||||
bool use_tcp = a.has("tcp");
|
||||
if (!a.has("dir") || (!a.has("serial") && !use_tcp)) {
|
||||
std::cerr << "error: --dir and one of --serial / --tcp 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";
|
||||
|
||||
// Pick the transport: a serial port, or a TCP connection to the node's WiFi seeder port (host[:port],
|
||||
// default port 5001). The node runs the seeder on a DEDICATED port, separate from its companion port,
|
||||
// so serving over WiFi doesn't disturb a phone app connected to the companion.
|
||||
SerialTransport st;
|
||||
TcpTransport tt;
|
||||
Transport* t = nullptr;
|
||||
std::string target;
|
||||
if (use_tcp) {
|
||||
std::string hp = a.get("tcp");
|
||||
size_t colon = hp.rfind(':');
|
||||
std::string host = (colon == std::string::npos) ? hp : hp.substr(0, colon);
|
||||
int port = (colon == std::string::npos) ? 5001 : std::atoi(hp.substr(colon + 1).c_str());
|
||||
std::string e = tt.open(host, port);
|
||||
if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; }
|
||||
t = &tt; target = host + ":" + std::to_string(port);
|
||||
} else {
|
||||
std::string e = st.open(a.get("serial"), std::atoi(a.get("baud", "115200").c_str()));
|
||||
if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; }
|
||||
t = &st; target = a.get("serial") + " @ " + a.get("baud", "115200");
|
||||
}
|
||||
|
||||
std::signal(SIGINT, on_sigint);
|
||||
// The CLI auto-enable only applies to the serial console; the TCP seeder port auto-enables relaying on
|
||||
// the node side when this connection opens (and stops when it closes), so there's nothing to send.
|
||||
bool enable = !use_tcp && !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 " << target << " — Ctrl-C to stop\n";
|
||||
|
||||
SeederCore core(folder, a.get("dir")); // same folder doubles as "pull to folder" storage
|
||||
// Optional warm-start seed: a *similar* build's .mota. Its payload is dropped into each captured `.part`
|
||||
// so a `ota pull … folder validate` diffs it against the target's leaves and pulls only the differing
|
||||
// blocks (huge airtime saving when capturing a non-deterministic rebuild of the same firmware).
|
||||
if (a.has("seed")) {
|
||||
std::vector<uint8_t> sb;
|
||||
if (!read_file(a.get("seed"), sb)) { std::cerr << "error: cannot read seed " << a.get("seed") << "\n"; return 1; }
|
||||
Manifest sm; std::string e = parse(sb, sm);
|
||||
if (!e.empty()) { std::cerr << "error: bad seed .mota: " << e << "\n"; return 1; }
|
||||
uint32_t poff = sm.payload_off();
|
||||
if ((uint64_t)poff + sm.payload_size > sb.size()) { std::cerr << "error: seed payload out of range\n"; return 1; }
|
||||
core.set_seed(std::vector<uint8_t>(sb.begin() + poff, sb.begin() + poff + sm.payload_size), sm.block_count);
|
||||
std::cout << "seed: " << fs::path(a.get("seed")).filename().string()
|
||||
<< " mid=" << to_hex(sm.merkle_root.data(), 4)
|
||||
<< " blocks=" << sm.block_count << " payload=" << sm.payload_size
|
||||
<< " (staged into each capture for `ota pull … validate`)\n";
|
||||
}
|
||||
serve_loop(*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<std::string> 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;
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
#include "mota.h"
|
||||
|
||||
#include "OtaTargets.h" // shared with the firmware (generated): target_id -> env name
|
||||
#include "crypto.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
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<uint8_t> leaf_hashes(const uint8_t *payload, uint32_t size, uint32_t bs) {
|
||||
uint32_t bc = (size + bs - 1) / bs;
|
||||
std::vector<uint8_t> 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<uint8_t> &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<std::string> verify(const std::vector<uint8_t> &b) {
|
||||
std::vector<std::string> 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<uint8_t> 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<uint8_t> &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<uint8_t> &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<uint8_t> ensure_endf(const std::vector<uint8_t> &img, const FwIdent &id,
|
||||
std::array<uint8_t, 8> &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<uint8_t> 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<uint8_t> &base_img, const std::vector<uint8_t> &new_img,
|
||||
uint32_t mem, uint32_t seg, std::vector<uint8_t> &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<std::string> 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 <path>)";
|
||||
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<uint8_t> &base_img,
|
||||
const std::vector<uint8_t> &patch, std::vector<uint8_t> &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); // <from> <patch> <to>
|
||||
if (rc == 127)
|
||||
err = "could not run detools (install it / pass --detools <path>)";
|
||||
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<uint8_t> &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<uint8_t, 8> new_bh{}, base_bh{};
|
||||
std::vector<uint8_t> new_img = ensure_endf(o.fw, ident, new_bh);
|
||||
|
||||
uint8_t codec = o.codec;
|
||||
std::vector<uint8_t> payload;
|
||||
std::array<uint8_t, 8> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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: <hw|fw>_<target8>_v<ver>_<kind>_<mid8>.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
|
||||
@@ -1,83 +0,0 @@
|
||||
// `.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 <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<uint8_t,4> merkle_root{};
|
||||
std::array<uint8_t,32> image_hash{};
|
||||
std::array<uint8_t,32> hw_id{}; // raw 32 bytes (NUL-padded)
|
||||
std::array<uint8_t,8> base_hash{};
|
||||
std::array<uint8_t,32> signer{};
|
||||
std::array<uint8_t,64> signature{};
|
||||
std::array<uint8_t,4> 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<uint8_t>& 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<std::string> verify(const std::vector<uint8_t>& blob);
|
||||
|
||||
// ---- EndF identity (fixed 56-byte trailer) ----
|
||||
bool has_endf(const std::vector<uint8_t>& image);
|
||||
FwIdent parse_endf_ident(const std::vector<uint8_t>& image); // zeros if no EndF
|
||||
// Append a 56-byte EndF (with identity) if absent; returns the image and sets body_hash8. Idempotent.
|
||||
std::vector<uint8_t> ensure_endf(const std::vector<uint8_t>& image, const FwIdent& id,
|
||||
std::array<uint8_t,8>& 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<uint8_t> fw; // NEW firmware (raw or already-EndF'd)
|
||||
std::vector<uint8_t> 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<uint8_t> 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<uint8_t>& 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<uint8_t>& base_img,
|
||||
const std::vector<uint8_t>& patch, std::vector<uint8_t>& out);
|
||||
|
||||
} // namespace mota
|
||||
@@ -1,106 +0,0 @@
|
||||
// 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 <cstdint>
|
||||
#include <cstddef>
|
||||
|
||||
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_OP_STAT = 0x04; // storage: mid(4) -> present(1) total(4)
|
||||
static constexpr uint8_t MS_OP_BEGIN = 0x05; // storage: mid(4) total(4) -> OK (create 0xFF-filled)
|
||||
static constexpr uint8_t MS_OP_WRITE = 0x06; // storage: mid(4) off(4) len(2) data(len) -> OK
|
||||
static constexpr uint8_t MS_OP_SREAD = 0x07; // storage: mid(4) off(4) len(2) -> bytes (0xFF=unwritten)
|
||||
static constexpr uint8_t MS_OP_FIN = 0x08; // storage: mid(4) -> OK (validate + make servable)
|
||||
static constexpr uint8_t MS_STATUS_OK = 0x00;
|
||||
static constexpr uint8_t MS_STATUS_ERR = 0x01;
|
||||
static constexpr uint16_t MOTA_DESC_WIRE = 38;
|
||||
static constexpr uint16_t MOTA_SEEDER_WRITE_MAX = 512; // max data bytes per OP_WRITE/OP_SREAD
|
||||
// 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
|
||||
@@ -1,344 +0,0 @@
|
||||
#include "serve.h"
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <termios.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netdb.h>
|
||||
#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<void(const std::string&, const std::string&)>& 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<uint8_t, MOTA_DESC_WIRE> SeederCore::describe(const ServedMota& s) {
|
||||
std::array<uint8_t, MOTA_DESC_WIRE> 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;
|
||||
}
|
||||
|
||||
std::string SeederCore::store_path(const uint8_t mid[4], bool part) const {
|
||||
static const char* H = "0123456789abcdef";
|
||||
std::string name;
|
||||
for (int i = 0; i < 4; i++) { name += H[mid[i] >> 4]; name += H[mid[i] & 0xF]; }
|
||||
name += part ? ".mota.part" : ".mota";
|
||||
return store_dir_ + "/" + name;
|
||||
}
|
||||
|
||||
bool SeederCore::handle(uint8_t op, const uint8_t* args, size_t arglen,
|
||||
uint8_t& status, std::vector<uint8_t>& 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;
|
||||
}
|
||||
// --- STORAGE ops: "pull to folder" — capture a .mota the device is fetching off-mesh into <store_dir>.
|
||||
// All keyed by mid[4]; a partial pull is <midhex>.mota.part, published to <midhex>.mota on OP_FIN. ---
|
||||
if (op == MS_OP_STAT || op == MS_OP_BEGIN || op == MS_OP_WRITE || op == MS_OP_SREAD || op == MS_OP_FIN) {
|
||||
if (store_dir_.empty() || arglen < 4) { status = MS_STATUS_ERR; return true; }
|
||||
const std::string part = store_path(args, true), done = store_path(args, false);
|
||||
|
||||
if (op == MS_OP_STAT) { // present + size (completed wins over partial)
|
||||
struct stat sx{}; uint8_t present = 0; uint32_t total = 0;
|
||||
if (::stat(done.c_str(), &sx) == 0) { present = 1; total = (uint32_t)sx.st_size; }
|
||||
else if (::stat(part.c_str(), &sx) == 0) { present = 1; total = (uint32_t)sx.st_size; }
|
||||
payload.push_back(present);
|
||||
uint8_t tb[4]; wr_u32(tb, total); payload.insert(payload.end(), tb, tb + 4);
|
||||
return true;
|
||||
}
|
||||
if (op == MS_OP_BEGIN) { // fresh 0xFF-filled partial (start from 0)
|
||||
if (arglen < 8) { status = MS_STATUS_ERR; return true; }
|
||||
uint32_t total = rd_u32(args + 4);
|
||||
int fd = ::open(part.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd < 0) { status = MS_STATUS_ERR; return true; }
|
||||
uint8_t ff[4096]; memset(ff, 0xFF, sizeof ff);
|
||||
uint32_t left = total; bool ok = true;
|
||||
while (left && ok) { uint32_t c = left < sizeof ff ? left : (uint32_t)sizeof ff; ok = ::write(fd, ff, c) == (ssize_t)c; left -= c; }
|
||||
// Warm-start seed: drop a similar build's payload into the payload region so the device's leaf-diff
|
||||
// (`ota pull … validate`) keeps the blocks that match the target and pulls only the differing ones.
|
||||
// Header + leaves stay 0xFF (the device writes the real manifest; leaves mark "present" as they pass).
|
||||
if (ok && !seed_payload_.empty()) {
|
||||
uint32_t payload_off = 8 + MOTA_MFL + seed_bc_ * 4;
|
||||
uint32_t n = (uint32_t)seed_payload_.size();
|
||||
if (payload_off + n > total) n = (payload_off < total) ? total - payload_off : 0; // clamp to the file
|
||||
if (n) ok = ::pwrite(fd, seed_payload_.data(), n, payload_off) == (ssize_t)n;
|
||||
}
|
||||
::close(fd);
|
||||
status = ok ? MS_STATUS_OK : MS_STATUS_ERR;
|
||||
return true;
|
||||
}
|
||||
if (op == MS_OP_WRITE) {
|
||||
if (arglen < 10) { status = MS_STATUS_ERR; return true; }
|
||||
uint32_t off = rd_u32(args + 4);
|
||||
uint16_t len = (uint16_t)(args[8] | (args[9] << 8));
|
||||
if (arglen < (size_t)10 + len) { status = MS_STATUS_ERR; return true; }
|
||||
int fd = ::open(part.c_str(), O_WRONLY);
|
||||
if (fd < 0) { status = MS_STATUS_ERR; return true; }
|
||||
bool ok = ::pwrite(fd, args + 10, len, off) == (ssize_t)len;
|
||||
::close(fd);
|
||||
status = ok ? MS_STATUS_OK : MS_STATUS_ERR;
|
||||
return true;
|
||||
}
|
||||
if (op == MS_OP_SREAD) { // read back (resume: recompute missing blocks)
|
||||
if (arglen < 10) { status = MS_STATUS_ERR; return true; }
|
||||
uint32_t off = rd_u32(args + 4);
|
||||
uint16_t len = (uint16_t)(args[8] | (args[9] << 8));
|
||||
const std::string src = (::access(part.c_str(), F_OK) == 0) ? part : done;
|
||||
int fd = ::open(src.c_str(), O_RDONLY);
|
||||
if (fd < 0) { status = MS_STATUS_ERR; return true; }
|
||||
payload.resize(len);
|
||||
bool ok = ::pread(fd, payload.data(), len, off) == (ssize_t)len;
|
||||
::close(fd);
|
||||
if (!ok) { payload.clear(); status = MS_STATUS_ERR; }
|
||||
return true;
|
||||
}
|
||||
if (op == MS_OP_FIN) { // light-validate (MAGIC + size) then publish
|
||||
struct stat sx{};
|
||||
if (::stat(part.c_str(), &sx) != 0) { status = MS_STATUS_ERR; return true; }
|
||||
uint8_t hd[8] = {0}; int fd = ::open(part.c_str(), O_RDONLY);
|
||||
bool got = fd >= 0 && ::read(fd, hd, 8) == 8;
|
||||
if (fd >= 0) ::close(fd);
|
||||
bool magic = got && hd[0] == 'm' && hd[1] == 'O' && hd[2] == 'T' && hd[3] == 'A';
|
||||
if (!magic || rd_u32(hd + 4) != (uint32_t)sx.st_size) { status = MS_STATUS_ERR; return true; }
|
||||
std::error_code ec; fs::rename(part, done, ec);
|
||||
status = ec ? MS_STATUS_ERR : MS_STATUS_OK;
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- TcpTransport ---------------------------------------------------------------------------------
|
||||
std::string TcpTransport::open(const std::string& host, int port) {
|
||||
addrinfo hints{}; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM;
|
||||
addrinfo* res = nullptr;
|
||||
std::string portstr = std::to_string(port);
|
||||
if (getaddrinfo(host.c_str(), portstr.c_str(), &hints, &res) != 0 || !res)
|
||||
return "cannot resolve host: " + host;
|
||||
std::string err = "cannot connect to " + host + ":" + portstr;
|
||||
for (addrinfo* p = res; p; p = p->ai_next) {
|
||||
int fd = ::socket(p->ai_family, p->ai_socktype, p->ai_protocol);
|
||||
if (fd < 0) continue;
|
||||
if (::connect(fd, p->ai_addr, p->ai_addrlen) == 0) { fd_ = fd; err.clear(); break; }
|
||||
::close(fd);
|
||||
}
|
||||
freeaddrinfo(res);
|
||||
return err;
|
||||
}
|
||||
|
||||
TcpTransport::~TcpTransport() { if (fd_ >= 0) ::close(fd_); }
|
||||
|
||||
int TcpTransport::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 };
|
||||
if (select(fd_ + 1, &rs, nullptr, nullptr, &tv) <= 0) return -1;
|
||||
uint8_t b;
|
||||
ssize_t n = ::recv(fd_, &b, 1, 0);
|
||||
return n == 1 ? (int)b : -1; // 0 == peer closed -> treated as timeout/closed
|
||||
}
|
||||
|
||||
bool TcpTransport::write(const uint8_t* p, size_t n) {
|
||||
if (fd_ < 0) return false;
|
||||
size_t off = 0;
|
||||
while (off < n) {
|
||||
ssize_t w = ::send(fd_, p + off, n - off, 0);
|
||||
if (w < 0) return false;
|
||||
off += (size_t)w;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- seeder 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<uint8_t>& payload) {
|
||||
std::vector<uint8_t> 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_loop(Transport& t, const SeederCore& core, bool verbose,
|
||||
const std::function<void(const std::string&)>& 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;
|
||||
// fixed header bytes per op; OP_WRITE additionally reads `len` data bytes after its 10-byte header
|
||||
size_t hdr = (op == MS_OP_COUNT) ? 0 : (op == MS_OP_DESCRIBE) ? 1 : (op == MS_OP_READ) ? 7
|
||||
: (op == MS_OP_STAT || op == MS_OP_FIN) ? 4 : (op == MS_OP_BEGIN) ? 8
|
||||
: (op == MS_OP_SREAD || op == MS_OP_WRITE) ? 10 : SIZE_MAX;
|
||||
if (hdr == SIZE_MAX) continue; // unknown op
|
||||
uint8_t args[10 + MOTA_SEEDER_WRITE_MAX];
|
||||
if (hdr && !read_exact(t, args, hdr)) continue;
|
||||
size_t arglen = hdr;
|
||||
if (op == MS_OP_WRITE) { // variable payload: header(10) + data(len)
|
||||
uint16_t dlen = (uint16_t)(args[8] | (args[9] << 8));
|
||||
if (dlen > MOTA_SEEDER_WRITE_MAX) continue; // guard a runaway frame
|
||||
if (dlen && !read_exact(t, args + 10, dlen)) continue;
|
||||
arglen = (size_t)10 + dlen;
|
||||
}
|
||||
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<uint8_t> 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
|
||||
@@ -1,95 +0,0 @@
|
||||
// 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 <array>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mota {
|
||||
|
||||
struct ServedMota {
|
||||
std::string path;
|
||||
std::vector<uint8_t> 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<void(const std::string&, const std::string&)>& 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<ServedMota>& all() const { return motas_; }
|
||||
private:
|
||||
std::vector<ServedMota> 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:
|
||||
// `store_dir` (optional): folder where the "pull to folder" storage ops capture a `.mota` a device is
|
||||
// fetching off-mesh — the SAME --dir as serving. A partial pull is `<midhex>.mota.part`; OP_FIN publishes
|
||||
// it as `<midhex>.mota`. Empty store_dir = storage ops are refused (serve-only).
|
||||
explicit SeederCore(const Folder& f, std::string store_dir = "")
|
||||
: folder_(f), store_dir_(std::move(store_dir)) {}
|
||||
bool handle(uint8_t op, const uint8_t* args, size_t arglen,
|
||||
uint8_t& status, std::vector<uint8_t>& payload) const;
|
||||
static std::array<uint8_t, MOTA_DESC_WIRE> describe(const ServedMota& s);
|
||||
// Warm-start seed (motatool folder-capture): a *similar* build's payload. On OP_BEGIN, after the 0xFF-fill,
|
||||
// the seed payload is written into the new `.part` at its payload region so the device can validate matching
|
||||
// blocks against the target's merkle leaves and pull DATA only for the ones that differ (`ota pull … validate`).
|
||||
void set_seed(std::vector<uint8_t> payload, uint32_t block_count) {
|
||||
seed_payload_ = std::move(payload); seed_bc_ = block_count;
|
||||
}
|
||||
private:
|
||||
std::string store_path(const uint8_t mid[4], bool part) const; // <store_dir>/<midhex>.mota[.part]
|
||||
const Folder& folder_;
|
||||
std::string store_dir_;
|
||||
std::vector<uint8_t> seed_payload_; // similar-build payload injected on OP_BEGIN (empty = no seed)
|
||||
uint32_t seed_bc_ = 0; // seed's block_count (fixes payload_off = 8 + MOTA_MFL + bc*4)
|
||||
};
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// TCP client transport: connect to a node's WiFi seeder port (a dedicated port, NOT the companion port),
|
||||
// then run the same seeder framing over the socket. Lets `serve` feed a WiFi node the same way as serial.
|
||||
class TcpTransport : public Transport {
|
||||
public:
|
||||
std::string open(const std::string& host, int port); // "" on success
|
||||
~TcpTransport() override;
|
||||
int read_byte(int timeout_ms) override;
|
||||
bool write(const uint8_t* p, size_t n) override;
|
||||
private:
|
||||
int fd_ = -1;
|
||||
};
|
||||
|
||||
// Seeder framing loop (transport-agnostic): 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` (serial
|
||||
// only; the TCP seeder port carries no log text). Runs until *stop becomes true.
|
||||
void serve_loop(Transport& t, const SeederCore& core, bool verbose,
|
||||
const std::function<void(const std::string&)>& devline,
|
||||
const volatile bool* stop);
|
||||
|
||||
} // namespace mota
|
||||
@@ -1,77 +0,0 @@
|
||||
// Small host-side helpers: file I/O, subprocess exec (no shell), hex. Header-only.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <fstream>
|
||||
#include <fcntl.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace mota {
|
||||
|
||||
inline bool read_file(const std::string& path, std::vector<uint8_t>& out) {
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) return false;
|
||||
out.assign(std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>());
|
||||
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<std::string>& 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<char*> a;
|
||||
for (auto& s : argv) a.push_back(const_cast<char*>(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<uint8_t>& 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
|
||||
@@ -1,334 +0,0 @@
|
||||
// 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 <cstring>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
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<uint8_t> body(unsigned seed, size_t n) {
|
||||
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t,8> 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<uint8_t,8> 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<uint8_t,8> 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<uint8_t>{1,2,3})); // too short
|
||||
}
|
||||
|
||||
static BuildOpts full_opts(const std::vector<uint8_t>& fw, const std::vector<uint8_t>& 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<uint8_t> 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<uint8_t> pv(priv, priv + 32);
|
||||
auto fw = body(4, 3000);
|
||||
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> blob; std::string name; build(full_opts(fw), blob, name);
|
||||
Manifest m;
|
||||
CHECK(!parse(std::vector<uint8_t>(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<uint8_t> 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<uint8_t,8> bh{};
|
||||
auto base_img = ensure_endf(base_body, FwIdent{}, bh);
|
||||
std::vector<uint8_t> patch(blob.begin() + m.payload_off(), blob.begin() + m.payload_off() + m.payload_size);
|
||||
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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);
|
||||
|
||||
// --- STORAGE ops ("pull to folder"): STAT -> BEGIN -> WRITE -> SREAD -> FIN round-trip ---
|
||||
fs::path sdir = dir / "store"; fs::create_directories(sdir);
|
||||
SeederCore store(folder, sdir.string());
|
||||
uint8_t mid[4] = {0xDE, 0xAD, 0xBE, 0xEF};
|
||||
auto midpath = [&](bool part) {
|
||||
static const char* H = "0123456789abcdef"; std::string nm;
|
||||
for (int i = 0; i < 4; i++) { nm += H[mid[i] >> 4]; nm += H[mid[i] & 0xF]; }
|
||||
nm += part ? ".mota.part" : ".mota"; return (sdir / nm).string();
|
||||
};
|
||||
// STAT before anything -> present=0
|
||||
CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && status == MS_STATUS_OK && pl.size() == 5 && pl[0] == 0);
|
||||
// BEGIN total=64 -> 0xFF-filled partial
|
||||
uint8_t beg[8]; std::memcpy(beg, mid, 4); wr_u32(beg + 4, 64);
|
||||
CHECK(store.handle(MS_OP_BEGIN, beg, 8, status, pl) && status == MS_STATUS_OK);
|
||||
CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && pl[0] == 1 && rd_u32(pl.data() + 1) == 64);
|
||||
// WRITE the header (MAGIC + total) + a blob at offset 0
|
||||
const uint8_t blob[16] = {'m','O','T','A', 64,0,0,0, 1,2,3,4,5,6,7,8};
|
||||
uint8_t wr[10 + 16]; std::memcpy(wr, mid, 4); wr_u32(wr + 4, 0); wr[8] = 16; wr[9] = 0; std::memcpy(wr + 10, blob, 16);
|
||||
CHECK(store.handle(MS_OP_WRITE, wr, 10 + 16, status, pl) && status == MS_STATUS_OK);
|
||||
// SREAD back the 16 written bytes; an unwritten region reads 0xFF
|
||||
uint8_t srd[10]; std::memcpy(srd, mid, 4); wr_u32(srd + 4, 0); srd[8] = 16; srd[9] = 0;
|
||||
CHECK(store.handle(MS_OP_SREAD, srd, 10, status, pl) && pl.size() == 16 && std::memcmp(pl.data(), blob, 16) == 0);
|
||||
wr_u32(srd + 4, 32); srd[8] = 8; srd[9] = 0;
|
||||
CHECK(store.handle(MS_OP_SREAD, srd, 10, status, pl) && pl.size() == 8 && pl[0] == 0xFF && pl[7] == 0xFF);
|
||||
// FIN -> validates + publishes <mid>.mota
|
||||
CHECK(fs::exists(midpath(true)) && !fs::exists(midpath(false)));
|
||||
CHECK(store.handle(MS_OP_FIN, mid, 4, status, pl) && status == MS_STATUS_OK);
|
||||
CHECK(!fs::exists(midpath(true)) && fs::exists(midpath(false)) && fs::file_size(midpath(false)) == 64);
|
||||
CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && pl[0] == 1 && rd_u32(pl.data() + 1) == 64);
|
||||
// storage refused on a serve-only core (empty store_dir)
|
||||
CHECK(core.handle(MS_OP_STAT, mid, 4, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user