From 326e864264b7d9c886d73fbc4a3a14c0444e0693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sat, 3 Oct 2026 21:30:13 +0200 Subject: [PATCH] OTA step 1: Update File format and streaming parser (host-tested) lib/ota: a 160-byte header (magic, format, image size and SHA-256, version, ECDSA signature over the first 80 bytes) then the image. UpdateParser checks the header and signature before writing anything, hashes the image as it streams into an UpdateSink, and only finishes the sink when the hash matches. Downgrades are flagged, not refused. Includes a dependency-free SHA-256 and semver comparison. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- lib/ota/src/sha256.cpp | 85 +++++++++++ lib/ota/src/sha256.h | 30 ++++ lib/ota/src/update_parser.cpp | 87 ++++++++++++ lib/ota/src/update_parser.h | 80 +++++++++++ lib/ota/src/version_compare.h | 33 +++++ test/test_ota/test_ota.cpp | 259 ++++++++++++++++++++++++++++++++++ 6 files changed, 574 insertions(+) create mode 100644 lib/ota/src/sha256.cpp create mode 100644 lib/ota/src/sha256.h create mode 100644 lib/ota/src/update_parser.cpp create mode 100644 lib/ota/src/update_parser.h create mode 100644 lib/ota/src/version_compare.h create mode 100644 test/test_ota/test_ota.cpp diff --git a/lib/ota/src/sha256.cpp b/lib/ota/src/sha256.cpp new file mode 100644 index 0000000..8a1e78b --- /dev/null +++ b/lib/ota/src/sha256.cpp @@ -0,0 +1,85 @@ +#include "sha256.h" + +#include + +namespace roro { + +namespace { +const uint32_t K[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2}; + +inline uint32_t rotr(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); } +} // namespace + +Sha256::Sha256() { + const uint32_t init[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; + std::memcpy(h_, init, sizeof(h_)); +} + +void Sha256::block(const uint8_t* p) { + uint32_t w[64]; + for (int i = 0; i < 16; i++) + w[i] = (uint32_t(p[4 * i]) << 24) | (uint32_t(p[4 * i + 1]) << 16) | (uint32_t(p[4 * i + 2]) << 8) | p[4 * i + 3]; + for (int i = 16; i < 64; i++) { + uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + uint32_t a = h_[0], b = h_[1], c = h_[2], d = h_[3], e = h_[4], f = h_[5], g = h_[6], h = h_[7]; + for (int i = 0; i < 64; i++) { + uint32_t t1 = h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]; + uint32_t t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c)); + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + h_[0] += a; h_[1] += b; h_[2] += c; h_[3] += d; + h_[4] += e; h_[5] += f; h_[6] += g; h_[7] += h; +} + +void Sha256::update(const uint8_t* data, size_t len) { + bits_ += static_cast(len) * 8; + while (len > 0) { + size_t take = 64 - used_ < len ? 64 - used_ : len; + std::memcpy(buf_ + used_, data, take); + used_ += take; + data += take; + len -= take; + if (used_ == 64) { + block(buf_); + used_ = 0; + } + } +} + +void Sha256::finish(uint8_t out[32]) { + uint64_t bits = bits_; + uint8_t pad = 0x80; + update(&pad, 1); + uint8_t zero = 0; + while (used_ != 56) update(&zero, 1); + uint8_t len[8]; + for (int i = 0; i < 8; i++) len[i] = static_cast(bits >> (56 - 8 * i)); + update(len, 8); + for (int i = 0; i < 8; i++) { + out[4 * i] = h_[i] >> 24; + out[4 * i + 1] = h_[i] >> 16; + out[4 * i + 2] = h_[i] >> 8; + out[4 * i + 3] = h_[i]; + } +} + +} // namespace roro diff --git a/lib/ota/src/sha256.h b/lib/ota/src/sha256.h new file mode 100644 index 0000000..80f19e0 --- /dev/null +++ b/lib/ota/src/sha256.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace roro { + +// Plain SHA-256 (FIPS 180-4), streaming. Small and dependency-free, so the same code runs in the +// PC tests and on the device. +class Sha256 { + public: + Sha256(); + void update(const uint8_t* data, size_t len); + void finish(uint8_t out[32]); + + static void hash(const uint8_t* data, size_t len, uint8_t out[32]) { + Sha256 s; + s.update(data, len); + s.finish(out); + } + + private: + void block(const uint8_t* p); + uint32_t h_[8]; + uint8_t buf_[64]; + size_t used_ = 0; + uint64_t bits_ = 0; +}; + +} // namespace roro diff --git a/lib/ota/src/update_parser.cpp b/lib/ota/src/update_parser.cpp new file mode 100644 index 0000000..0cae285 --- /dev/null +++ b/lib/ota/src/update_parser.cpp @@ -0,0 +1,87 @@ +#include "update_parser.h" + +#include + +#include "version_compare.h" + +namespace roro { + +namespace { +uint16_t u16(const uint8_t* p) { return p[0] | (p[1] << 8); } +uint32_t u32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (uint32_t(p[3]) << 24); } +} // namespace + +void UpdateParser::fail(const std::string& why) { + if (state_ == State::Image) sink_.abort(); + state_ = State::Failed; + error_ = why; +} + +void UpdateParser::parseHeader() { + const uint8_t* h = header_; + if (std::memcmp(h, update::kMagic, 8) != 0) return fail("not an update file"); + if (u16(h + 8) != update::kFormat || u16(h + 10) != update::kHeaderSize) return fail("unsupported update format"); + imageSize_ = u32(h + 12); + if (imageSize_ == 0 || imageSize_ > maxImage_) return fail("image doesn't fit the update slot"); + std::memcpy(expectedHash_, h + 16, 32); + version_.assign(reinterpret_cast(h + 48), strnlen(reinterpret_cast(h + 48), 32)); + + size_t sigLen = u16(h + 80); + if (sigLen == 0 || sigLen > update::kMaxSignature) return fail("missing signature"); + uint8_t digest[32]; + Sha256::hash(h, update::kSignedBytes, digest); + if (!verifier_.verify(digest, h + 82, sigLen)) return fail("bad signature: not signed with this device's key"); + + downgrade_ = versionOlder(version_, installed_); + if (!sink_.begin(imageSize_)) return fail("could not prepare the update slot"); + state_ = State::Image; +} + +void UpdateParser::feed(const uint8_t* data, size_t len) { + while (len > 0 && (state_ == State::Header || state_ == State::Image)) { + if (state_ == State::Header) { + size_t take = std::min(len, update::kHeaderSize - headerUsed_); + std::memcpy(header_ + headerUsed_, data, take); + headerUsed_ += take; + data += take; + len -= take; + if (headerUsed_ == update::kHeaderSize) parseHeader(); + } else { + size_t take = std::min(len, imageSize_ - received_); + if (take == 0) return fail("data after the end of the image"); + hash_.update(data, take); + if (!sink_.write(data, take)) return fail("writing the update failed"); + received_ += take; + data += take; + len -= take; + } + } + if (len > 0 && state_ == State::Image) fail("data after the end of the image"); +} + +bool UpdateParser::end() { + if (state_ == State::Done) return true; + if (state_ != State::Image) { + if (state_ == State::Header) fail("update file too short"); + return false; + } + if (received_ != imageSize_) { + fail("update file too short"); + return false; + } + uint8_t got[32]; + hash_.finish(got); + if (std::memcmp(got, expectedHash_, 32) != 0) { + fail("image corrupted (hash mismatch)"); + return false; + } + if (!sink_.finish()) { + state_ = State::Failed; + error_ = "could not switch to the new image"; + return false; + } + state_ = State::Done; + return true; +} + +} // namespace roro diff --git a/lib/ota/src/update_parser.h b/lib/ota/src/update_parser.h new file mode 100644 index 0000000..0c6114c --- /dev/null +++ b/lib/ota/src/update_parser.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +#include "sha256.h" + +namespace roro { + +// The Update File (see CONTEXT.md): a 160-byte header, then the firmware image. All integers are +// little-endian. +// 0 magic "RORO-OTA" 8 u16 format (1) 10 u16 header size (160) 12 u32 image size +// 16 image SHA-256[32] 48 version, NUL-padded [32] +// 80 u16 signature length 82 signature (DER, up to 72 bytes) 154 reserved +// The signature covers SHA-256 of bytes 0..79, which include the image's hash, so a bad signature +// is caught before anything is written, and a bad image when its hash is checked at the end. +namespace update { +constexpr char kMagic[] = "RORO-OTA"; +constexpr uint16_t kFormat = 1; +constexpr size_t kHeaderSize = 160; +constexpr size_t kSignedBytes = 80; +constexpr size_t kMaxSignature = 72; +} // namespace update + +class SignatureVerifier { + public: + virtual ~SignatureVerifier() = default; + virtual bool verify(const uint8_t digest[32], const uint8_t* signature, size_t len) = 0; +}; + +// Where the image goes (the inactive app slot on the device). +class UpdateSink { + public: + virtual ~UpdateSink() = default; + virtual bool begin(size_t imageSize) = 0; + virtual bool write(const uint8_t* data, size_t len) = 0; + virtual bool finish() = 0; // make it the image to boot next + virtual void abort() = 0; +}; + +// Streams an Update File into a sink: checks the header and signature first, then hashes the image +// as it passes through, and only finishes the sink if everything matches. +class UpdateParser { + public: + enum class State { Header, Image, Done, Failed }; + + UpdateParser(SignatureVerifier& verifier, UpdateSink& sink, size_t maxImageSize, std::string installedVersion) + : verifier_(verifier), sink_(sink), maxImage_(maxImageSize), installed_(std::move(installedVersion)) {} + + void feed(const uint8_t* data, size_t len); + bool end(); // no more data: true if the update was installed + + State state() const { return state_; } + const std::string& error() const { return error_; } + const std::string& version() const { return version_; } + bool isDowngrade() const { return downgrade_; } + int percent() const { return imageSize_ ? static_cast(received_ * 100 / imageSize_) : 0; } + + private: + void parseHeader(); + void fail(const std::string& why); + + SignatureVerifier& verifier_; + UpdateSink& sink_; + size_t maxImage_; + std::string installed_; + + State state_ = State::Header; + uint8_t header_[update::kHeaderSize]; + size_t headerUsed_ = 0; + uint8_t expectedHash_[32]; + size_t imageSize_ = 0; + size_t received_ = 0; + Sha256 hash_; + std::string version_, error_; + bool downgrade_ = false; +}; + +} // namespace roro diff --git a/lib/ota/src/version_compare.h b/lib/ota/src/version_compare.h new file mode 100644 index 0000000..f794f85 --- /dev/null +++ b/lib/ota/src/version_compare.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +namespace roro { + +// True if `a` is an older release than `b`, comparing "vMAJOR.MINOR.PATCH" and ignoring any +// git-describe suffix ("-3-gabc1234-dirty"). Anything that doesn't parse is never called older. +inline bool parseVersion(const std::string& s, int out[3]) { + size_t pos = s.size() > 0 && s[0] == 'v' ? 1 : 0; + for (int i = 0; i < 3; i++) { + if (pos >= s.size() || s[pos] < '0' || s[pos] > '9') return false; + char* end; + out[i] = static_cast(std::strtol(s.c_str() + pos, &end, 10)); + pos = end - s.c_str(); + if (i < 2) { + if (pos >= s.size() || s[pos] != '.') return false; + pos++; + } + } + return true; +} + +inline bool versionOlder(const std::string& a, const std::string& b) { + int x[3], y[3]; + if (!parseVersion(a, x) || !parseVersion(b, y)) return false; + for (int i = 0; i < 3; i++) + if (x[i] != y[i]) return x[i] < y[i]; + return false; +} + +} // namespace roro diff --git a/test/test_ota/test_ota.cpp b/test/test_ota/test_ota.cpp new file mode 100644 index 0000000..afbe3c1 --- /dev/null +++ b/test/test_ota/test_ota.cpp @@ -0,0 +1,259 @@ +#include + +#include +#include +#include + +#include "sha256.h" +#include "update_parser.h" +#include "version_compare.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +// ---- helpers ---------------------------------------------------------------- + +static std::string hex(const uint8_t* d, size_t n) { + static const char* h = "0123456789abcdef"; + std::string s; + for (size_t i = 0; i < n; i++) { + s += h[d[i] >> 4]; + s += h[d[i] & 15]; + } + return s; +} + +// Accepts a signature equal to the first bytes of the digest it is asked about ("signed" by us). +struct FakeVerifier : SignatureVerifier { + bool verify(const uint8_t digest[32], const uint8_t* sig, size_t len) override { + calls++; + return len == 8 && std::memcmp(digest, sig, 8) == 0; + } + int calls = 0; +}; + +struct MemorySink : UpdateSink { + bool begin(size_t size) override { + began = true; + expected = size; + return true; + } + bool write(const uint8_t* d, size_t n) override { + data.insert(data.end(), d, d + n); + return true; + } + bool finish() override { return finished = true; } + void abort() override { aborted = true; } + bool began = false, finished = false, aborted = false; + size_t expected = 0; + std::vector data; +}; + +// Builds an Update File the way make_ota.py does, signing with FakeVerifier's scheme. +static std::vector makeFile(const std::string& version, const std::vector& image, bool goodSig = true) { + std::vector h(update::kHeaderSize, 0); + std::memcpy(h.data(), update::kMagic, 8); + h[8] = update::kFormat & 0xFF; + h[10] = update::kHeaderSize & 0xFF; + h[11] = update::kHeaderSize >> 8; + uint32_t n = static_cast(image.size()); + for (int i = 0; i < 4; i++) h[12 + i] = (n >> (8 * i)) & 0xFF; + Sha256::hash(image.data(), image.size(), &h[16]); + std::strncpy(reinterpret_cast(&h[48]), version.c_str(), 31); + uint8_t digest[32]; + Sha256::hash(h.data(), update::kSignedBytes, digest); + h[80] = 8; + std::memcpy(&h[82], digest, 8); + if (!goodSig) h[82] ^= 0xFF; + h.insert(h.end(), image.begin(), image.end()); + return h; +} + +static std::vector image(size_t n) { + std::vector v(n); + for (size_t i = 0; i < n; i++) v[i] = static_cast(i * 7 + 3); + return v; +} + +// ---- SHA-256 ---------------------------------------------------------------- + +void test_sha256_known_vectors() { + uint8_t d[32]; + Sha256::hash(reinterpret_cast(""), 0, d); + TEST_ASSERT_EQUAL_STRING("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hex(d, 32).c_str()); + Sha256::hash(reinterpret_cast("abc"), 3, d); + TEST_ASSERT_EQUAL_STRING("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hex(d, 32).c_str()); + const char* two = "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"; + Sha256::hash(reinterpret_cast(two), std::strlen(two), d); + TEST_ASSERT_EQUAL_STRING("248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", hex(d, 32).c_str()); +} + +void test_sha256_streaming_matches_one_shot() { + auto img = image(1000); + uint8_t a[32], b[32]; + Sha256::hash(img.data(), img.size(), a); + Sha256 s; + for (size_t i = 0; i < img.size(); i += 37) s.update(img.data() + i, std::min(37, img.size() - i)); + s.finish(b); + TEST_ASSERT_EQUAL_MEMORY(a, b, 32); +} + +// ---- parser ----------------------------------------------------------------- + +void test_valid_file_in_one_chunk_is_installed() { + FakeVerifier v; + MemorySink sink; + auto img = image(5000); + auto file = makeFile("v0.3.0", img); + UpdateParser p(v, sink, 100000, "v0.2.1"); + p.feed(file.data(), file.size()); + TEST_ASSERT_TRUE(p.end()); + TEST_ASSERT_EQUAL(static_cast(UpdateParser::State::Done), static_cast(p.state())); + TEST_ASSERT_TRUE(sink.finished); + TEST_ASSERT_TRUE(sink.data == img); + TEST_ASSERT_EQUAL_STRING("v0.3.0", p.version().c_str()); + TEST_ASSERT_FALSE(p.isDowngrade()); +} + +void test_valid_file_byte_by_byte() { + FakeVerifier v; + MemorySink sink; + auto img = image(700); + auto file = makeFile("v0.3.0", img); + UpdateParser p(v, sink, 100000, "v0.2.1"); + for (auto b : file) p.feed(&b, 1); + TEST_ASSERT_TRUE(p.end()); + TEST_ASSERT_TRUE(sink.data == img); +} + +void test_progress_counts_image_bytes() { + FakeVerifier v; + MemorySink sink; + auto file = makeFile("v0.3.0", image(1000)); + UpdateParser p(v, sink, 100000, "v0.2.1"); + p.feed(file.data(), update::kHeaderSize + 250); + TEST_ASSERT_EQUAL(25, p.percent()); +} + +void test_not_an_update_file_is_refused_before_writing() { + FakeVerifier v; + MemorySink sink; + auto file = makeFile("v0.3.0", image(100)); + file[0] = 'X'; + UpdateParser p(v, sink, 100000, "v0.2.1"); + p.feed(file.data(), file.size()); + TEST_ASSERT_EQUAL(static_cast(UpdateParser::State::Failed), static_cast(p.state())); + TEST_ASSERT_FALSE(sink.began); + TEST_ASSERT_FALSE(p.error().empty()); +} + +void test_bad_signature_is_refused_before_writing() { + FakeVerifier v; + MemorySink sink; + auto file = makeFile("v0.3.0", image(100), false); + UpdateParser p(v, sink, 100000, "v0.2.1"); + p.feed(file.data(), file.size()); + TEST_ASSERT_EQUAL(static_cast(UpdateParser::State::Failed), static_cast(p.state())); + TEST_ASSERT_FALSE(sink.began); + TEST_ASSERT_TRUE(p.error().find("signature") != std::string::npos); +} + +void test_tampered_version_breaks_the_signature() { + FakeVerifier v; + MemorySink sink; + auto file = makeFile("v0.3.0", image(100)); + file[48 + 3] = '9'; // v0.3.0 -> v0.9.0 after signing + UpdateParser p(v, sink, 100000, "v0.2.1"); + p.feed(file.data(), file.size()); + TEST_ASSERT_EQUAL(static_cast(UpdateParser::State::Failed), static_cast(p.state())); +} + +void test_corrupted_image_is_aborted_at_the_end() { + FakeVerifier v; + MemorySink sink; + auto file = makeFile("v0.3.0", image(500)); + file[update::kHeaderSize + 123] ^= 1; + UpdateParser p(v, sink, 100000, "v0.2.1"); + p.feed(file.data(), file.size()); + TEST_ASSERT_FALSE(p.end()); + TEST_ASSERT_TRUE(sink.aborted); + TEST_ASSERT_FALSE(sink.finished); +} + +void test_truncated_file_is_aborted() { + FakeVerifier v; + MemorySink sink; + auto file = makeFile("v0.3.0", image(500)); + UpdateParser p(v, sink, 100000, "v0.2.1"); + p.feed(file.data(), file.size() - 10); + TEST_ASSERT_FALSE(p.end()); + TEST_ASSERT_TRUE(sink.aborted); +} + +void test_trailing_bytes_are_refused() { + FakeVerifier v; + MemorySink sink; + auto file = makeFile("v0.3.0", image(100)); + file.push_back(0); + UpdateParser p(v, sink, 100000, "v0.2.1"); + p.feed(file.data(), file.size()); + TEST_ASSERT_FALSE(p.end()); + TEST_ASSERT_FALSE(sink.finished); +} + +void test_image_larger_than_the_slot_is_refused_before_writing() { + FakeVerifier v; + MemorySink sink; + auto file = makeFile("v0.3.0", image(2000)); + UpdateParser p(v, sink, 1000, "v0.2.1"); + p.feed(file.data(), file.size()); + TEST_ASSERT_EQUAL(static_cast(UpdateParser::State::Failed), static_cast(p.state())); + TEST_ASSERT_FALSE(sink.began); +} + +void test_downgrade_is_flagged_but_allowed() { + FakeVerifier v; + MemorySink sink; + auto file = makeFile("v0.2.0", image(100)); + UpdateParser p(v, sink, 100000, "v0.2.1"); + p.feed(file.data(), file.size()); + TEST_ASSERT_TRUE(p.end()); + TEST_ASSERT_TRUE(p.isDowngrade()); +} + +// ---- versions --------------------------------------------------------------- + +void test_version_comparison() { + TEST_ASSERT_TRUE(versionOlder("v0.2.0", "v0.2.1")); + TEST_ASSERT_TRUE(versionOlder("v0.9.9", "v0.10.0")); + TEST_ASSERT_TRUE(versionOlder("v0.2.1", "v1.0.0")); + TEST_ASSERT_FALSE(versionOlder("v0.2.1", "v0.2.1")); + TEST_ASSERT_FALSE(versionOlder("v0.3.0", "v0.2.1")); + // build suffixes from git describe compare on the release part + TEST_ASSERT_FALSE(versionOlder("v0.2.1-3-gabc1234", "v0.2.1")); + TEST_ASSERT_TRUE(versionOlder("v0.2.1-3-gabc1234-dirty", "v0.3.0")); + // unknown versions are never called older + TEST_ASSERT_FALSE(versionOlder("unknown", "v0.2.1")); + TEST_ASSERT_FALSE(versionOlder("v0.2.1", "4c0be89")); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_sha256_known_vectors); + RUN_TEST(test_sha256_streaming_matches_one_shot); + RUN_TEST(test_valid_file_in_one_chunk_is_installed); + RUN_TEST(test_valid_file_byte_by_byte); + RUN_TEST(test_progress_counts_image_bytes); + RUN_TEST(test_not_an_update_file_is_refused_before_writing); + RUN_TEST(test_bad_signature_is_refused_before_writing); + RUN_TEST(test_tampered_version_breaks_the_signature); + RUN_TEST(test_corrupted_image_is_aborted_at_the_end); + RUN_TEST(test_truncated_file_is_aborted); + RUN_TEST(test_trailing_bytes_are_refused); + RUN_TEST(test_image_larger_than_the_slot_is_refused_before_writing); + RUN_TEST(test_downgrade_is_flagged_but_allowed); + RUN_TEST(test_version_comparison); + return UNITY_END(); +}