ota: motatool — portable C++ .mota packager + relay (build/verify/inspect/serve/keygen)
This commit is contained in:
1 parent
4680d1052f
commit
2092500d2b
15 files changed
+2116
No files matched your search
@@ -0,0 +1 @@
|
||||
build/
|
||||
@@ -0,0 +1,42 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(motatool LANGUAGES CXX)
|
||||
|
||||
# Self-contained MeshCore OTA host tool: build / verify / inspect / serve `.mota` containers.
|
||||
# Portable C++17 — builds on Ubuntu, Raspberry Pi, and any arch with a C++17 compiler + OpenSSL.
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
add_compile_options(-Wall -Wextra)
|
||||
|
||||
# OpenSSL libcrypto provides SHA-256 + Ed25519 (sign/verify/keygen). On Debian/Ubuntu/Pi:
|
||||
# sudo apt install libssl-dev
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
# Core logic (everything except the CLI front-end) — shared by the tool and the test runner.
|
||||
add_library(motatool_core STATIC
|
||||
src/mota.cpp
|
||||
src/crypto.cpp
|
||||
src/input.cpp
|
||||
src/serve.cpp
|
||||
)
|
||||
# `src` for the tool's own headers; the repo's helpers/ota for the shared, generated OtaTargets.h
|
||||
# (target_id -> env-name table — single source of truth with the firmware, no hand-maintained copy).
|
||||
target_include_directories(motatool_core PUBLIC
|
||||
src
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../../src/helpers/ota"
|
||||
)
|
||||
target_link_libraries(motatool_core PUBLIC OpenSSL::Crypto)
|
||||
|
||||
add_executable(motatool src/main.cpp)
|
||||
target_link_libraries(motatool PRIVATE motatool_core)
|
||||
install(TARGETS motatool RUNTIME DESTINATION bin)
|
||||
|
||||
# Unit tests (no external test framework — a tiny built-in harness keeps the project self-contained).
|
||||
# Run with: cmake --build build && ctest --test-dir build --output-on-failure
|
||||
# or: ./build/motatool_tests
|
||||
enable_testing()
|
||||
add_executable(motatool_tests tests/test_motatool.cpp)
|
||||
target_link_libraries(motatool_tests PRIVATE motatool_core)
|
||||
add_test(NAME motatool_tests COMMAND motatool_tests)
|
||||
@@ -0,0 +1,114 @@
|
||||
# motatool — self-contained MeshCore OTA host tool (C++)
|
||||
|
||||
A small, portable C++17 tool that **builds**, **verifies**, and **serves** MeshCore `.mota`
|
||||
firmware-update containers. It is an independent project (its own `CMakeLists.txt`) that compiles on a
|
||||
laptop, a Raspberry Pi, or any architecture with a C++17 compiler — so it can run as a lightweight
|
||||
folder-relay daemon on small hardware.
|
||||
|
||||
It implements the wire spec in [`../../docs/ota_protocol.md`](../../docs/ota_protocol.md) and is
|
||||
cross-checked byte-for-byte against the Python reference packager `tools/mota/motalib.py` (a full signed
|
||||
`.mota` built by `motatool` is identical to the reference's output).
|
||||
|
||||
## What it does
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `build` | Create a **full** or **delta** `.mota` from a firmware (local file **or** http(s) URL). |
|
||||
| `verify` | Validate one or more `.mota` (merkle tree, leaves vs payload, image hash, Ed25519 signature). `--pub` requires a specific signer; `--base` confirms a sequential delta rebuilds its image. |
|
||||
| `inspect`| Print every field of a `.mota`'s manifest (debugging). |
|
||||
| `serve` | Serve a **folder** of `.mota` to a node over USB serial. Invalid files are warned about and skipped — one corrupt file never sinks the rest. |
|
||||
| `keygen` | Generate an Ed25519 signing keypair (hex). |
|
||||
|
||||
Every command has detailed, example-rich help: `motatool <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 over its USB serial (recursive; skips non-.mota; warns on corrupt)
|
||||
$MT serve --dir ./motas --serial /dev/ttyUSB0 --baud 115200 -v
|
||||
```
|
||||
|
||||
`build` notes:
|
||||
- **Identity is self-described.** A firmware built by the project's `pio_endf.py` carries its
|
||||
`target_id`/`fw_version`/`hw_id` in its 56-byte `EndF` trailer; `build` reads them, so
|
||||
`--target-env`/`--target-id`, `--fw-version`, and `--hw-id` are **optional** (explicit flags override).
|
||||
- **Cross-hardware delta guard.** A delta is refused if the base and target firmware identities differ
|
||||
(read from their `EndF`, not filenames); pass `--force` to override.
|
||||
- **Delta codec** needs the `detools` encoder (the project's pinned codec — never reimplemented). Install
|
||||
it (`pip install detools`) and ensure it's on `PATH`, or pass `--detools <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.
|
||||
- **`SerialTransport` + `serve_serial()`** wrap it with the byte-stream framing (magic + XOR checksum +
|
||||
resync) for the unreliable USB-UART link (`MotaSeederProto.h`).
|
||||
|
||||
To serve the same folder over **BLE** (e.g. an Android phone relaying to a MeshCore node), implement the
|
||||
transport at the GATT layer — a request characteristic write hands `(op, args)` straight to
|
||||
`SeederCore::handle()` and the reply is sent as a notification. No byte-stream framing is needed there
|
||||
(BLE is reliable/segmented), and `SeederCore` + `Folder` are reused unchanged. OpenSSL builds for the
|
||||
Android NDK, so the validation path ports as-is.
|
||||
|
||||
## Relationship to `tools/mota/`
|
||||
|
||||
`motatool` is the user-facing CLI: it replaced the old Python `mota.py` (build/verify/inspect/keygen),
|
||||
`mota_seeder.py` (serve), and `dev_motas.py` (CI `.mota` packaging — its nRF52 `.hex` extraction is now a
|
||||
built-in `motatool` input format), all removed. The Python `tools/mota/` directory remains as the
|
||||
**reference implementation** (`motalib.py`, the spec oracle + unit tests) and the **firmware build/test
|
||||
glue** (`pio_endf.py` build hook, `gen_vectors.py` test vectors). Their `.mota` output is byte-identical
|
||||
(verified by cross-checks).
|
||||
@@ -0,0 +1,61 @@
|
||||
#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
|
||||
@@ -0,0 +1,28 @@
|
||||
// SHA-256 (multihash truncations) and Ed25519 — backed by OpenSSL libcrypto, so the tool is portable
|
||||
// across Ubuntu / Raspberry Pi / any arch that has OpenSSL (and Android NDK for a future BLE seeder).
|
||||
#pragma once
|
||||
#include <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
|
||||
@@ -0,0 +1,102 @@
|
||||
#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
|
||||
@@ -0,0 +1,15 @@
|
||||
// Read a firmware blob from a local file OR an http(s):// URL. URLs are fetched with the system `curl`
|
||||
// (or `wget`) binary — no link-time dependency, so the tool stays self-contained and builds anywhere.
|
||||
#pragma once
|
||||
#include <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
|
||||
@@ -0,0 +1,501 @@
|
||||
// motatool — build, verify, and serve MeshCore `.mota` firmware-update containers.
|
||||
// build create a full or delta .mota from a firmware (file or http(s) URL)
|
||||
// verify validate one or more .mota (merkle / hashes / signature)
|
||||
// serve serve a folder of .mota to a node (USB serial); invalid files are warned + skipped
|
||||
// keygen generate an Ed25519 signing keypair (64-char hex)
|
||||
#include "mota.h"
|
||||
#include "input.h"
|
||||
#include "serve.h"
|
||||
#include "crypto.h"
|
||||
#include "util.h"
|
||||
#include <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 (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.\n"
|
||||
"\n"
|
||||
"USAGE\n"
|
||||
" motatool serve --dir <folder> --serial <port> [options]\n"
|
||||
"\n"
|
||||
"It scans the folder for .mota files, validates each, and serves the valid ones to the node.\n"
|
||||
"Corrupt/invalid files are reported and skipped — one bad file never stops the rest. The node\n"
|
||||
"advertises them to the mesh as if it held them, and any node whose hardware matches can fetch\n"
|
||||
"them. The relay is trustless: fetchers verify every block, so this host never needs the keys.\n"
|
||||
"\n"
|
||||
"OPTIONS (--dir and --serial are required)\n"
|
||||
" --dir <folder> 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"
|
||||
" --baud <n> serial speed (default 115200).\n"
|
||||
" --no-recursive serve only the top folder; don't descend into sub-folders.\n"
|
||||
" --no-enable don't auto-send 'ota folder on'/'off' to the node (run them on its CLI yourself).\n"
|
||||
" -v, --verbose log each request the node makes (COUNT / DESCRIBE / READ).\n"
|
||||
"\n"
|
||||
"Leave it running; press Ctrl-C to stop (it tells the node to stop relaying first). It shares the\n"
|
||||
"same USB cable as the node's text console — the node only pulls bytes while actively fetching.\n"
|
||||
"\n"
|
||||
"EXAMPLE\n"
|
||||
" motatool serve --dir ./motas --serial /dev/ttyUSB0 -v\n";
|
||||
}
|
||||
|
||||
static void help_keygen() {
|
||||
std::cout <<
|
||||
"motatool keygen — generate an Ed25519 signing keypair.\n"
|
||||
"\n"
|
||||
"USAGE\n"
|
||||
" motatool keygen [--out <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; }
|
||||
if (!a.has("dir") || !a.has("serial")) {
|
||||
std::cerr << "error: --dir and --serial are required\n\n"; help_serve(); return 2;
|
||||
}
|
||||
bool recursive = !a.has("no-recursive");
|
||||
bool verbose = a.has("verbose");
|
||||
Folder folder;
|
||||
size_t n = folder.scan(a.get("dir"), recursive,
|
||||
[](const std::string& p, const std::string& why) {
|
||||
std::cerr << " ! skip " << p << " : " << why << "\n";
|
||||
});
|
||||
std::cout << "motatool serve: " << n << " valid .mota in " << a.get("dir")
|
||||
<< (recursive ? " (recursive)" : "") << "\n";
|
||||
for (const auto& s : folder.all()) {
|
||||
std::cout << " - " << fs::path(s.path).filename().string()
|
||||
<< " : mid=" << to_hex(s.m.merkle_root.data(), 4)
|
||||
<< " target=" << hex8(s.m.target_id) << " [" << target_label(s.m.target_id) << "]"
|
||||
<< " v" << version_str(s.m.fw_version)
|
||||
<< " " << (s.m.is_full() ? "full" : (s.m.codec_id == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta"))
|
||||
<< " " << (s.m.is_signed() ? "signed" : "unsigned")
|
||||
<< " blocks=" << s.m.block_count << " size=" << s.bytes.size() << "\n";
|
||||
}
|
||||
if (n == 0) std::cerr << " (nothing valid to serve)\n";
|
||||
|
||||
SerialTransport t;
|
||||
std::string e = t.open(a.get("serial"), std::atoi(a.get("baud", "115200").c_str()));
|
||||
if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; }
|
||||
|
||||
std::signal(SIGINT, on_sigint);
|
||||
bool enable = !a.has("no-enable");
|
||||
if (enable) { usleep(500000); t.write_str("ota folder on\r\n"); std::cout << "sent `ota folder on`\n"; }
|
||||
std::cout << "serving on " << a.get("serial") << " @ " << a.get("baud", "115200") << " — Ctrl-C to stop\n";
|
||||
|
||||
SeederCore core(folder);
|
||||
serve_serial(t, core, verbose,
|
||||
[](const std::string& l) { std::cout << " [dev] " << l << "\n"; }, &g_stop);
|
||||
|
||||
if (enable) { t.write_str("ota folder off\r\n"); usleep(200000); }
|
||||
std::cout << "\nbye\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) { help_top(); return 2; }
|
||||
std::string cmd = argv[1];
|
||||
if (cmd == "help" || cmd == "-h" || cmd == "--help") { help_top(); return 0; }
|
||||
|
||||
std::vector<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;
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
#include "mota.h"
|
||||
|
||||
#include "OtaTargets.h" // shared with the firmware (generated): target_id -> env name
|
||||
#include "crypto.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <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
|
||||
@@ -0,0 +1,83 @@
|
||||
// `.mota` container: parse, integrity-verify, and build (full / detools-delta). EndF identity helpers.
|
||||
// Mirrors tools/mota/motalib.py and src/helpers/ota/MotaContainer.cpp (the single source of truth).
|
||||
#pragma once
|
||||
#include "mota_format.h"
|
||||
#include <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
|
||||
@@ -0,0 +1,100 @@
|
||||
// MeshCore `.mota` on-wire constants and fixed layout — a C++-friendly mirror of
|
||||
// src/helpers/ota/OtaFormat.h. Keep byte-identical with that file and docs/ota_protocol.md.
|
||||
//
|
||||
// The format is FIXED-LAYOUT: every manifest field is at a constant offset and always present
|
||||
// (base_hash/signer_pubkey/signature are zero-filled when not applicable). Only leaves[] varies.
|
||||
#pragma once
|
||||
#include <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_STATUS_OK = 0x00;
|
||||
static constexpr uint8_t MS_STATUS_ERR = 0x01;
|
||||
static constexpr uint16_t MOTA_DESC_WIRE = 38;
|
||||
// MotaDesc wire (38 B): mid[4] target_id(4) fw_version(4) codec(1) flags(1) total_size(4)
|
||||
// leaves_off(4) block_count(4) payload_off(4) payload_size(4) [+2 reserved]
|
||||
|
||||
inline uint32_t rd_u32(const uint8_t* p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
inline void wr_u32(uint8_t* p, uint32_t v) {
|
||||
p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24);
|
||||
}
|
||||
|
||||
} // namespace mota
|
||||
@@ -0,0 +1,209 @@
|
||||
#include "serve.h"
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <termios.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/select.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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return false; // unknown op -> ignore (device retries)
|
||||
}
|
||||
|
||||
// ---- SerialTransport ------------------------------------------------------------------------------
|
||||
static speed_t baud_const(int b) {
|
||||
switch (b) {
|
||||
case 9600: return B9600; case 19200: return B19200; case 38400: return B38400;
|
||||
case 57600: return B57600; case 115200: return B115200; case 230400: return B230400;
|
||||
case 460800: return B460800; case 921600: return B921600; default: return B115200;
|
||||
}
|
||||
}
|
||||
|
||||
std::string SerialTransport::open(const std::string& dev, int baud) {
|
||||
fd_ = ::open(dev.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK);
|
||||
if (fd_ < 0) return "cannot open serial device: " + dev;
|
||||
termios t{};
|
||||
if (tcgetattr(fd_, &t) != 0) { ::close(fd_); fd_ = -1; return "tcgetattr failed"; }
|
||||
cfmakeraw(&t);
|
||||
speed_t s = baud_const(baud);
|
||||
cfsetispeed(&t, s); cfsetospeed(&t, s);
|
||||
t.c_cflag |= (CLOCAL | CREAD);
|
||||
t.c_cflag &= ~CRTSCTS; // no flow control (matches the daemon)
|
||||
t.c_cc[VMIN] = 0; t.c_cc[VTIME] = 0;
|
||||
if (tcsetattr(fd_, TCSANOW, &t) != 0) { ::close(fd_); fd_ = -1; return "tcsetattr failed"; }
|
||||
return "";
|
||||
}
|
||||
|
||||
SerialTransport::~SerialTransport() { if (fd_ >= 0) ::close(fd_); }
|
||||
|
||||
int SerialTransport::read_byte(int timeout_ms) {
|
||||
if (fd_ < 0) return -1;
|
||||
fd_set rs; FD_ZERO(&rs); FD_SET(fd_, &rs);
|
||||
timeval tv{ timeout_ms / 1000, (timeout_ms % 1000) * 1000 };
|
||||
int r = select(fd_ + 1, &rs, nullptr, nullptr, &tv);
|
||||
if (r <= 0) return -1;
|
||||
uint8_t b;
|
||||
ssize_t n = ::read(fd_, &b, 1);
|
||||
return n == 1 ? (int)b : -1;
|
||||
}
|
||||
|
||||
bool SerialTransport::write(const uint8_t* p, size_t n) {
|
||||
if (fd_ < 0) return false;
|
||||
size_t off = 0;
|
||||
while (off < n) {
|
||||
ssize_t w = ::write(fd_, p + off, n - off);
|
||||
if (w < 0) return false;
|
||||
off += (size_t)w;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- serial framing loop --------------------------------------------------------------------------
|
||||
static uint8_t xor_bytes(const uint8_t* p, size_t n, uint8_t seed = 0) {
|
||||
uint8_t x = seed; for (size_t i = 0; i < n; i++) x ^= p[i]; return x;
|
||||
}
|
||||
|
||||
static bool read_exact(Transport& t, uint8_t* buf, size_t n) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
int b = t.read_byte(500);
|
||||
if (b < 0) return false;
|
||||
buf[i] = (uint8_t)b;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void send_response(Transport& t, uint8_t op, uint8_t status, const std::vector<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_serial(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;
|
||||
size_t arglen = (op == MS_OP_DESCRIBE) ? 1 : (op == MS_OP_READ ? 7 : (op == MS_OP_COUNT ? 0 : SIZE_MAX));
|
||||
if (arglen == SIZE_MAX) continue; // unknown op
|
||||
uint8_t args[7];
|
||||
if (arglen && !read_exact(t, args, arglen)) continue;
|
||||
uint8_t xs;
|
||||
if (!read_exact(t, &xs, 1)) continue;
|
||||
if (xs != xor_bytes(args, arglen, op)) continue; // bad checksum -> ignore; device retries
|
||||
uint8_t status; std::vector<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
|
||||
@@ -0,0 +1,68 @@
|
||||
// Serve a folder of .mota to a MeshCore node. Split into a transport-AGNOSTIC protocol core (reusable
|
||||
// over USB-serial today and BLE/GATT in the future) and a serial byte-stream framing layer.
|
||||
#pragma once
|
||||
#include "mota.h"
|
||||
#include "mota_format.h"
|
||||
#include <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:
|
||||
explicit SeederCore(const Folder& f) : folder_(f) {}
|
||||
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);
|
||||
private:
|
||||
const Folder& folder_;
|
||||
};
|
||||
|
||||
// A bidirectional byte link (the serial seeder needs this; BLE would reuse SeederCore directly).
|
||||
struct Transport {
|
||||
virtual ~Transport() {}
|
||||
virtual int read_byte(int timeout_ms) = 0; // a byte 0..255, or -1 on timeout/closed
|
||||
virtual bool write(const uint8_t* p, size_t n) = 0;
|
||||
bool write_str(const std::string& s) { return write((const uint8_t*)s.data(), s.size()); }
|
||||
};
|
||||
|
||||
class SerialTransport : public Transport {
|
||||
public:
|
||||
std::string open(const std::string& dev, int baud); // "" on success
|
||||
~SerialTransport() override;
|
||||
int read_byte(int timeout_ms) override;
|
||||
bool write(const uint8_t* p, size_t n) override;
|
||||
private:
|
||||
int fd_ = -1;
|
||||
};
|
||||
|
||||
// Serial framing loop: resync on 'M''S', verify the request checksum, dispatch to `core`, frame the
|
||||
// reply. Device text/log lines sharing the wire are surfaced via `devline`. Runs until *stop becomes true.
|
||||
void serve_serial(Transport& t, const SeederCore& core, bool verbose,
|
||||
const std::function<void(const std::string&)>& devline,
|
||||
const volatile bool* stop);
|
||||
|
||||
} // namespace mota
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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
|
||||
@@ -0,0 +1,302 @@
|
||||
// Unit tests for motatool's core logic (no external framework — a tiny built-in harness keeps the
|
||||
// project self-contained). Run: ./build/motatool_tests or ctest --test-dir build --output-on-failure
|
||||
#include "mota.h"
|
||||
#include "crypto.h"
|
||||
#include "serve.h"
|
||||
#include "input.h"
|
||||
#include "util.h"
|
||||
#include "mota_format.h"
|
||||
#include <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);
|
||||
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
|
||||
int main() {
|
||||
t_crypto();
|
||||
t_version_target();
|
||||
t_intel_hex();
|
||||
t_endf();
|
||||
t_build_full();
|
||||
t_build_signed_and_tamper();
|
||||
t_corruption_and_approval();
|
||||
t_parse_rejects();
|
||||
t_delta();
|
||||
t_folder_and_seeder();
|
||||
|
||||
std::cout << (g_fail ? "FAILED " : "OK ") << (g_checks - g_fail) << "/" << g_checks << " checks passed\n";
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in new issue
Block a user