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:
Valentin Kivachuk Burda
2026-07-11 14:47:11 +02:00
parent ee6e548746
commit 872413231d
18 changed files with 14 additions and 2386 deletions
+3 -3
View File
@@ -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:
+7 -6
View File
@@ -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 -4
View File
@@ -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
View File
@@ -1 +0,0 @@
build/
-42
View File
@@ -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)
-127
View File
@@ -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).
-61
View File
@@ -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
-28
View File
@@ -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
-102
View File
@@ -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
-15
View File
@@ -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
-545
View File
@@ -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;
}
-413
View File
@@ -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
-83
View File
@@ -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
-106
View File
@@ -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
-344
View File
@@ -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
-95
View File
@@ -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
-77
View File
@@ -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
-334
View File
@@ -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;
}