From b0e822694371ef1bc68f623238404eb9411ac6c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 15:11:58 +0200 Subject: [PATCH] Updates from Gitea, step 1: the model (host-tested) A streaming JSON scanner (a 33 KB list of releases costs a few hundred bytes), the release reader built on it, HTTP response heads and chunked bodies, URLs, and the decisions: which release is an update, whether to announce it, and which download URLs the device takes. Tested against the real answers of git.twis.la. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- lib/release/src/http_head.cpp | 158 +++++++++++++++++++ lib/release/src/http_head.h | 62 ++++++++ lib/release/src/json_scan.cpp | 202 +++++++++++++++++++++++++ lib/release/src/json_scan.h | 57 +++++++ lib/release/src/release_info.cpp | 80 ++++++++++ lib/release/src/release_info.h | 52 +++++++ lib/release/src/update_check.cpp | 15 ++ lib/release/src/update_check.h | 38 +++++ test/test_http/test_http.cpp | 121 +++++++++++++++ test/test_json_scan/test_json_scan.cpp | 125 +++++++++++++++ test/test_release/fixtures/latest.json | 1 + test/test_release/fixtures/list3.json | 1 + test/test_release/test_release.cpp | 146 ++++++++++++++++++ 13 files changed, 1058 insertions(+) create mode 100644 lib/release/src/http_head.cpp create mode 100644 lib/release/src/http_head.h create mode 100644 lib/release/src/json_scan.cpp create mode 100644 lib/release/src/json_scan.h create mode 100644 lib/release/src/release_info.cpp create mode 100644 lib/release/src/release_info.h create mode 100644 lib/release/src/update_check.cpp create mode 100644 lib/release/src/update_check.h create mode 100644 test/test_http/test_http.cpp create mode 100644 test/test_json_scan/test_json_scan.cpp create mode 100644 test/test_release/fixtures/latest.json create mode 100644 test/test_release/fixtures/list3.json create mode 100644 test/test_release/test_release.cpp diff --git a/lib/release/src/http_head.cpp b/lib/release/src/http_head.cpp new file mode 100644 index 0000000..05b385c --- /dev/null +++ b/lib/release/src/http_head.cpp @@ -0,0 +1,158 @@ +#include "http_head.h" + +#include +#include +#include + +namespace roro::release { + +namespace { +std::string lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} +} // namespace + +size_t HttpHeadParser::feed(const char* data, size_t len) { + size_t used = 0; + while (used < len && !complete_ && !failed_) { + char c = data[used++]; + total_++; + if (total_ > kMaxBytes) { + failed_ = true; + break; + } + if (c == '\n') { + if (!line_.empty() && line_.back() == '\r') line_.pop_back(); + line(); + line_.clear(); + } else { + line_ += c; + } + } + return used; +} + +void HttpHeadParser::line() { + if (first_) { // "HTTP/1.1 200 OK" + first_ = false; + if (line_.compare(0, 5, "HTTP/") != 0) { + failed_ = true; + return; + } + size_t space = line_.find(' '); + head_.status = space == std::string::npos ? 0 : std::atoi(line_.c_str() + space + 1); + if (head_.status < 100 || head_.status > 599) failed_ = true; + return; + } + if (line_.empty()) { + complete_ = true; + return; + } + size_t colon = line_.find(':'); + if (colon == std::string::npos) return; // not a header: ignored + std::string name = lower(line_.substr(0, colon)); + size_t from = line_.find_first_not_of(" \t", colon + 1); + std::string value = from == std::string::npos ? "" : line_.substr(from); + if (name == "content-length") head_.contentLength = std::atol(value.c_str()); + else if (name == "transfer-encoding") head_.chunked = lower(value).find("chunked") != std::string::npos; + else if (name == "location") head_.location = value; + else if (name == "content-type") head_.contentType = value; +} + +size_t ChunkedDecoder::decode(const uint8_t* in, size_t len, uint8_t* out) { + size_t written = 0; + for (size_t i = 0; i < len && !failed_ && state_ != State::Done; i++) { + uint8_t c = in[i]; + switch (state_) { + case State::Size: { + int digit = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1; + if (digit >= 0) { + if (remaining_ > (SIZE_MAX >> 5)) failed_ = true; + remaining_ = remaining_ * 16 + digit; + anyDigit_ = true; + } else if (c == ';' && anyDigit_) { + state_ = State::Extension; + } else if (c == '\r' && anyDigit_) { + state_ = State::SizeLf; + } else { + failed_ = true; + } + break; + } + case State::Extension: + if (c == '\r') state_ = State::SizeLf; + break; + case State::SizeLf: + if (c != '\n') { + failed_ = true; + } else if (remaining_ == 0) { + state_ = State::Trailer; + trailerLine_ = 0; + } else { + state_ = State::Data; + } + break; + case State::Data: { + size_t take = std::min(remaining_, len - i); + for (size_t k = 0; k < take; k++) out[written + k] = in[i + k]; + written += take; + remaining_ -= take; + i += take - 1; + if (remaining_ == 0) state_ = State::DataCr; + break; + } + case State::DataCr: + if (c != '\r') failed_ = true; + else state_ = State::DataLf; + break; + case State::DataLf: + if (c != '\n') { + failed_ = true; + } else { + state_ = State::Size; + anyDigit_ = false; + } + break; + case State::Trailer: // header lines after the last chunk, until an empty one + if (c == '\n') { + if (trailerLine_ == 0) state_ = State::Done; + trailerLine_ = 0; + } else if (c != '\r') { + trailerLine_++; + } + break; + case State::Done: break; + } + } + return written; +} + +Url parseUrl(const std::string& url) { + Url u; + size_t scheme = url.find("://"); + if (scheme == std::string::npos) return u; + std::string s = lower(url.substr(0, scheme)); + if (s != "http" && s != "https") return u; + u.https = s == "https"; + size_t hostStart = scheme + 3, pathStart = url.find('/', hostStart); + std::string authority = url.substr(hostStart, pathStart == std::string::npos ? std::string::npos : pathStart - hostStart); + u.path = pathStart == std::string::npos ? "/" : url.substr(pathStart); + if (authority.empty() || authority.find('@') != std::string::npos) return u; // no credentials in a URL + size_t colon = authority.rfind(':'); + u.port = u.https ? 443 : 80; + if (colon != std::string::npos) { + u.port = std::atoi(authority.c_str() + colon + 1); + authority.resize(colon); + if (u.port <= 0 || u.port > 65535) return u; + } + for (unsigned char c : authority) + if (!(std::isalnum(c) || c == '.' || c == '-')) return u; + for (unsigned char c : u.path) + if (c <= ' ' || c == 0x7F) return u; + u.host = lower(authority); + u.ok = !u.host.empty(); + return u; +} + +} // namespace roro::release diff --git a/lib/release/src/http_head.h b/lib/release/src/http_head.h new file mode 100644 index 0000000..d9243e2 --- /dev/null +++ b/lib/release/src/http_head.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include + +namespace roro::release { + +// The status line and headers of an HTTP/1.1 response, read as bytes arrive. +struct HttpHead { + int status = 0; + long contentLength = -1; // -1: not given + bool chunked = false; + std::string location, contentType; +}; + +class HttpHeadParser { + public: + static constexpr size_t kMaxBytes = 4096; + + // Takes bytes up to and including the blank line that ends the head; returns how many it used. + // What follows is the body. + size_t feed(const char* data, size_t len); + bool complete() const { return complete_; } + bool failed() const { return failed_; } + const HttpHead& head() const { return head_; } + + private: + void line(); + + HttpHead head_; + std::string line_; + size_t total_ = 0; + bool first_ = true, complete_ = false, failed_ = false; +}; + +// Takes the chunks of "Transfer-Encoding: chunked" apart as they arrive. +class ChunkedDecoder { + public: + // `out` has room for `len` bytes (the body is never longer than what carried it). + size_t decode(const uint8_t* in, size_t len, uint8_t* out); + bool done() const { return state_ == State::Done; } + bool failed() const { return failed_; } + + private: + enum class State : uint8_t { Size, Extension, SizeLf, Data, DataCr, DataLf, Trailer, Done }; + State state_ = State::Size; + size_t remaining_ = 0; + bool anyDigit_ = false, failed_ = false; + size_t trailerLine_ = 0; +}; + +// "https://git.twis.la/twisla/x/releases/download/v1/a.ota" taken apart. Only http and https. +struct Url { + bool ok = false; + bool https = false; + std::string host, path; // path from the first "/", with its query; "/" if none + int port = 0; +}; +Url parseUrl(const std::string& url); + +} // namespace roro::release diff --git a/lib/release/src/json_scan.cpp b/lib/release/src/json_scan.cpp new file mode 100644 index 0000000..8c279fc --- /dev/null +++ b/lib/release/src/json_scan.cpp @@ -0,0 +1,202 @@ +#include "json_scan.h" + +namespace roro::release { + +namespace { +bool space(char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; } +bool continuation(char c) { return (static_cast(c) & 0xC0) == 0x80; } +} // namespace + +void JsonScanner::feed(const char* data, size_t len) { + for (size_t i = 0; i < len && !failed_; i++) step(data[i]); +} + +std::string JsonScanner::path() const { + std::string p; + for (const Frame& f : stack_) { + if (f.isObject) { + if (!p.empty()) p += '.'; + p += f.key; + } else { + p += '[' + std::to_string(f.index) + ']'; + } + } + return p; +} + +void JsonScanner::append(const char* bytes, size_t n) { + if (text_.size() + n > max_) { + truncated_ = true; + return; + } + text_.append(bytes, n); +} + +void JsonScanner::appendCodePoint(uint32_t cp) { + char b[4]; + size_t n; + if (cp < 0x80) { + b[0] = static_cast(cp); + n = 1; + } else if (cp < 0x800) { + b[0] = static_cast(0xC0 | (cp >> 6)); + b[1] = static_cast(0x80 | (cp & 0x3F)); + n = 2; + } else if (cp < 0x10000) { + b[0] = static_cast(0xE0 | (cp >> 12)); + b[1] = static_cast(0x80 | ((cp >> 6) & 0x3F)); + b[2] = static_cast(0x80 | (cp & 0x3F)); + n = 3; + } else { + b[0] = static_cast(0xF0 | (cp >> 18)); + b[1] = static_cast(0x80 | ((cp >> 12) & 0x3F)); + b[2] = static_cast(0x80 | ((cp >> 6) & 0x3F)); + b[3] = static_cast(0x80 | (cp & 0x3F)); + n = 4; + } + append(b, n); +} + +void JsonScanner::startString(bool key) { + inString_ = true; + isKey_ = key; + escape_ = false; + unicodeLeft_ = 0; + highSurrogate_ = 0; + truncated_ = false; + text_.clear(); +} + +void JsonScanner::stringChar(char c) { + if (unicodeLeft_ > 0) { + int digit = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1; + if (digit < 0) return fail(); + unicode_ = unicode_ * 16 + digit; + if (--unicodeLeft_ == 0) { + if (unicode_ >= 0xD800 && unicode_ < 0xDC00) { + highSurrogate_ = unicode_; // the low half comes next + } else if (unicode_ >= 0xDC00 && unicode_ < 0xE000 && highSurrogate_) { + appendCodePoint(0x10000 + ((highSurrogate_ - 0xD800) << 10) + (unicode_ - 0xDC00)); + highSurrogate_ = 0; + } else { + appendCodePoint(unicode_); + highSurrogate_ = 0; + } + } + return; + } + if (escape_) { + escape_ = false; + switch (c) { + case 'n': append("\n", 1); break; + case 't': append("\t", 1); break; + case 'r': append("\r", 1); break; + case 'b': append("\b", 1); break; + case 'f': append("\f", 1); break; + case 'u': unicodeLeft_ = 4; unicode_ = 0; break; + default: append(&c, 1); break; // \" \\ \/ + } + return; + } + if (c == '\\') { + escape_ = true; + } else if (c == '"') { + endString(); + } else { + append(&c, 1); + } +} + +void JsonScanner::endString() { + inString_ = false; + // A cut can leave half a character at the end. + while (truncated_ && !text_.empty()) { + size_t k = text_.size(); + while (k > 0 && continuation(text_[k - 1])) k--; + if (k == 0) break; + uint8_t lead = static_cast(text_[k - 1]); + size_t want = lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : lead >= 0xC0 ? 2 : 1; + if (text_.size() - (k - 1) < want) text_.resize(k - 1); + break; + } + if (isKey_) { + stack_.back().key = text_; + expect_ = Expect::Colon; + return; + } + sink_(path(), text_, true, truncated_); + valueDone(); +} + +void JsonScanner::endLiteral() { + inLiteral_ = false; + sink_(path(), text_, false, false); + valueDone(); +} + +void JsonScanner::valueDone() { + if (stack_.empty()) { + done_ = true; + return; + } + expect_ = Expect::CommaOrEnd; +} + +void JsonScanner::step(char c) { + if (inString_) return stringChar(c); + if (inLiteral_) { + if (space(c) || c == ',' || c == '}' || c == ']') { + endLiteral(); // and the character that ended it is read again below + } else { + if (text_.size() < max_) text_ += c; + return; + } + } + if (space(c)) return; + switch (expect_) { + case Expect::Value: + if (c == '{') { + stack_.push_back({true, "", 0}); + expect_ = Expect::KeyOrEnd; + } else if (c == '[') { + stack_.push_back({false, "", 0}); + expect_ = Expect::Value; + } else if (c == ']' && !stack_.empty() && !stack_.back().isObject && stack_.back().index == 0) { + stack_.pop_back(); // an empty array + valueDone(); + } else if (c == '"') { + startString(false); + } else if (c == '-' || (c >= '0' && c <= '9') || c == 't' || c == 'f' || c == 'n') { + inLiteral_ = true; + text_.assign(1, c); + } else { + fail(); + } + return; + case Expect::KeyOrEnd: + if (c == '"') startString(true); + else if (c == '}') { + stack_.pop_back(); + valueDone(); + } else fail(); + return; + case Expect::Colon: + if (c == ':') expect_ = Expect::Value; + else fail(); + return; + case Expect::CommaOrEnd: + if (c == ',') { + if (stack_.back().isObject) expect_ = Expect::KeyOrEnd; + else { + stack_.back().index++; + expect_ = Expect::Value; + } + } else if ((c == '}' && stack_.back().isObject) || (c == ']' && !stack_.back().isObject)) { + stack_.pop_back(); + valueDone(); + } else fail(); + return; + } +} + +} // namespace roro::release diff --git a/lib/release/src/json_scan.h b/lib/release/src/json_scan.h new file mode 100644 index 0000000..17569c0 --- /dev/null +++ b/lib/release/src/json_scan.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace roro::release { + +// Reads JSON as it arrives, a chunk at a time, and reports each plain value (a string, a number, +// true, false, null) with where it was found: "tag_name", "assets[1].name", "[2].draft". Nothing +// is kept but the path, so a 33 KB list of releases costs a few hundred bytes. A value longer than +// `maxValueBytes` is cut (never in the middle of a character) and reported as truncated. +// Meant for a server's answer, not for validating JSON: it only refuses what it can't follow. +class JsonScanner { + public: + using Sink = std::function; + + explicit JsonScanner(Sink sink, size_t maxValueBytes = 300) : sink_(std::move(sink)), max_(maxValueBytes) {} + + void feed(const char* data, size_t len); + bool failed() const { return failed_; } + bool done() const { return done_ && !failed_; } // the top-level value is complete + + private: + enum class Expect : uint8_t { Value, KeyOrEnd, Colon, CommaOrEnd }; + struct Frame { + bool isObject; + std::string key; + int index; + }; + + void step(char c); + void startString(bool key); + void stringChar(char c); + void appendCodePoint(uint32_t cp); + void endString(); + void endLiteral(); + void valueDone(); + void append(const char* bytes, size_t n); + std::string path() const; + void fail() { failed_ = true; } + + Sink sink_; + size_t max_; + std::vector stack_; + Expect expect_ = Expect::Value; + bool inString_ = false, isKey_ = false, escape_ = false, inLiteral_ = false; + int unicodeLeft_ = 0; + uint32_t unicode_ = 0, highSurrogate_ = 0; + bool truncated_ = false; + std::string text_; + bool failed_ = false, done_ = false; +}; + +} // namespace roro::release diff --git a/lib/release/src/release_info.cpp b/lib/release/src/release_info.cpp new file mode 100644 index 0000000..4d450d3 --- /dev/null +++ b/lib/release/src/release_info.cpp @@ -0,0 +1,80 @@ +#include "release_info.h" + +#include + +namespace roro::release { + +namespace { +bool endsWith(const std::string& s, const char* tail) { + size_t n = std::char_traits::length(tail); + return s.size() >= n && s.compare(s.size() - n, n, tail) == 0; +} +} // namespace + +std::string firstParagraph(const std::string& text, bool truncated) { + size_t end = text.find("\n\n"); + size_t crlf = text.find("\r\n\r\n"); + if (crlf != std::string::npos && (end == std::string::npos || crlf < end)) end = crlf; + std::string p = text.substr(0, end); + size_t first = p.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return ""; + p.erase(0, first); + while (!p.empty() && (p.back() == ' ' || p.back() == '\t' || p.back() == '\r' || p.back() == '\n')) p.pop_back(); + if (truncated && end == std::string::npos) p += "..."; + return p; +} + +ReleaseReader::ReleaseReader(size_t maxReleases) + : scanner_([this](const std::string& path, const std::string& text, bool isString, bool truncated) { + value(path, text, isString, truncated); + }), + max_(maxReleases) {} + +void ReleaseReader::end() { flushAsset(); } + +void ReleaseReader::flushAsset() { + if (assetRelease_ >= 0 && assetRelease_ < static_cast(releases_.size()) && endsWith(assetName_, ".ota") && !assetUrl_.empty()) { + releases_[assetRelease_].otaUrl = assetUrl_; + releases_[assetRelease_].otaSize = assetSize_; + } + assetRelease_ = assetIndex_ = -1; + assetName_.clear(); + assetUrl_.clear(); + assetSize_ = 0; +} + +void ReleaseReader::value(const std::string& path, const std::string& text, bool isString, bool truncated) { + size_t index = 0; + std::string rest = path; + if (!path.empty() && path[0] == '[') { // a list: "[2].tag_name" + char* end; + index = static_cast(std::strtol(path.c_str() + 1, &end, 10)); + rest = *end == ']' ? std::string(end + 1) : ""; + if (!rest.empty() && rest[0] == '.') rest.erase(0, 1); + } + if (index >= max_) return; + if (releases_.size() <= index) releases_.resize(index + 1); + Release& r = releases_[index]; + + if (rest == "tag_name") r.tag = text; + else if (rest == "published_at") r.published = text; + else if (rest == "body") r.notes = firstParagraph(text, truncated); + else if (rest == "draft") r.draft = text == "true"; + else if (rest == "prerelease") r.prerelease = text == "true"; + else if (rest.compare(0, 7, "assets[") == 0) { + char* end; + int j = static_cast(std::strtol(rest.c_str() + 7, &end, 10)); + if (static_cast(index) != assetRelease_ || j != assetIndex_) { + flushAsset(); + assetRelease_ = static_cast(index); + assetIndex_ = j; + } + std::string field = *end == ']' && end[1] == '.' ? std::string(end + 2) : ""; + if (field == "name") assetName_ = text; + else if (field == "size") assetSize_ = static_cast(std::strtoul(text.c_str(), nullptr, 10)); + else if (field == "browser_download_url") assetUrl_ = text; + } + (void)isString; +} + +} // namespace roro::release diff --git a/lib/release/src/release_info.h b/lib/release/src/release_info.h new file mode 100644 index 0000000..0225306 --- /dev/null +++ b/lib/release/src/release_info.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +#include "json_scan.h" + +namespace roro::release { + +// What the device needs to know about one Gitea release (docs/milestones/R1.md, #6). +struct Release { + std::string tag; // "v0.10.0" + std::string published; // "2026-10-06T08:11:31Z" + std::string notes; // the first paragraph of the text: the tag's message + std::string otaUrl; // where the signed Update File is + uint32_t otaSize = 0; + bool draft = false, prerelease = false; + + // Something that can be installed and that the project meant to publish (drafts and + // pre-releases are left out for now: a release channel is #53). + bool usable() const { return !draft && !prerelease && !tag.empty() && !otaUrl.empty(); } + std::string date() const { return published.substr(0, 10); } // "2026-10-06" +}; + +// Reads Gitea's answer as it arrives: `releases/latest` (one object) or `releases?limit=N` (a list). +class ReleaseReader { + public: + explicit ReleaseReader(size_t maxReleases = 10); + + void feed(const char* data, size_t len) { scanner_.feed(data, len); } + void end(); // after the last chunk + bool ok() const { return !scanner_.failed(); } + bool complete() const { return scanner_.done(); } + const std::vector& releases() const { return releases_; } + + private: + void value(const std::string& path, const std::string& text, bool isString, bool truncated); + void flushAsset(); + + JsonScanner scanner_; + size_t max_; + std::vector releases_; + int assetRelease_ = -1, assetIndex_ = -1; + std::string assetName_, assetUrl_; + uint32_t assetSize_ = 0; +}; + +// The first paragraph of a release's text, trimmed; "..." if the text was cut before it ended. +std::string firstParagraph(const std::string& text, bool truncated); + +} // namespace roro::release diff --git a/lib/release/src/update_check.cpp b/lib/release/src/update_check.cpp new file mode 100644 index 0000000..9da1516 --- /dev/null +++ b/lib/release/src/update_check.cpp @@ -0,0 +1,15 @@ +#include "update_check.h" + +#include "http_head.h" + +namespace roro::release { + +bool trustedAssetUrl(const std::string& url, const std::string& host, const std::string& repo) { + Url u = parseUrl(url); + if (!u.ok || !u.https || u.port != 443 || u.host != host) return false; + std::string prefix = "/" + repo + "/releases/download/"; + return u.path.compare(0, prefix.size(), prefix) == 0 && u.path.find("..") == std::string::npos && + u.path.find('?') == std::string::npos && u.path.find('#') == std::string::npos; +} + +} // namespace roro::release diff --git a/lib/release/src/update_check.h b/lib/release/src/update_check.h new file mode 100644 index 0000000..67672b7 --- /dev/null +++ b/lib/release/src/update_check.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include "release_info.h" +#include "version_compare.h" + +namespace roro::release { + +// Where the project's releases are (Q164). A fork changes these, and its own signing key. +constexpr const char* kGiteaHost = "git.twis.la"; +constexpr const char* kGiteaRepo = "twisla/roro9stack"; + +inline bool versionNewer(const std::string& a, const std::string& b) { return versionOlder(b, a); } + +// "v0.10.0+debug": the Debug Build says so wherever the version shows (scripts/version.py). +inline bool isDebugBuild(const std::string& version) { + static const std::string tail = "+debug"; + return version.size() >= tail.size() && version.compare(version.size() - tail.size(), tail.size(), tail) == 0; +} + +// Is `release` a newer one than what runs? +inline bool isNewer(const Release& release, const std::string& running) { + return release.usable() && versionNewer(release.tag, running); +} + +// Should the background check say so (Q166, Q168)? Not for a version that failed on this device +// before (it rolled back), and not twice for the same one. +inline bool shouldAnnounce(const Release& latest, const std::string& running, const std::string& failed, const std::string& announced) { + return isNewer(latest, running) && latest.tag != failed && latest.tag != announced; +} + +// Is `url` a download this device takes from the project's server, for this repository? Whatever +// the API says, the device only fetches from where it asked (a redirected or rewritten answer +// can't send it elsewhere). +bool trustedAssetUrl(const std::string& url, const std::string& host = kGiteaHost, const std::string& repo = kGiteaRepo); + +} // namespace roro::release diff --git a/test/test_http/test_http.cpp b/test/test_http/test_http.cpp new file mode 100644 index 0000000..6e6139d --- /dev/null +++ b/test/test_http/test_http.cpp @@ -0,0 +1,121 @@ +#include + +#include + +#include "http_head.h" + +using namespace roro::release; + +void setUp() {} +void tearDown() {} + +void test_a_head_in_pieces() { + std::string response = + "HTTP/1.1 200 OK\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\n" + "X-Other: a: b\r\n\r\n5\r\nhello\r\n0\r\n\r\n"; + for (size_t piece : {size_t(1), size_t(9), response.size()}) { + HttpHeadParser p; + size_t used = 0; + for (size_t at = 0; at < response.size() && !p.complete(); at += piece) + used += p.feed(response.data() + at, std::min(piece, response.size() - at)); + TEST_ASSERT_TRUE(p.complete()); + TEST_ASSERT_FALSE(p.failed()); + TEST_ASSERT_EQUAL_INT(200, p.head().status); + TEST_ASSERT_TRUE(p.head().chunked); + TEST_ASSERT_EQUAL_STRING("application/json;charset=utf-8", p.head().contentType.c_str()); + TEST_ASSERT_EQUAL(std::string("HTTP/1.1 200 OK\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\nX-Other: a: b\r\n\r\n").size(), used); + } +} + +void test_a_download_head() { + std::string head = "HTTP/1.1 200 OK\r\ncontent-length: 1885712\r\nCONTENT-DISPOSITION: inline; filename=a.ota\r\n\r\n\x01\x02"; + HttpHeadParser p; + size_t used = p.feed(head.data(), head.size()); + TEST_ASSERT_EQUAL_size_t(head.size() - 2, used); // the body isn't touched + TEST_ASSERT_EQUAL_INT(200, p.head().status); + TEST_ASSERT_EQUAL_INT(1885712, p.head().contentLength); + TEST_ASSERT_FALSE(p.head().chunked); + std::string redirect = "HTTP/1.1 302 Found\r\nLocation: https://elsewhere.example/x\r\n\r\n"; + HttpHeadParser r; + r.feed(redirect.data(), redirect.size()); + TEST_ASSERT_EQUAL_INT(302, r.head().status); + TEST_ASSERT_EQUAL_STRING("https://elsewhere.example/x", r.head().location.c_str()); +} + +void test_a_head_that_is_not_http() { + HttpHeadParser p; + std::string junk = "\x16\x03\x01 not http at all\r\n\r\n"; + p.feed(junk.data(), junk.size()); + TEST_ASSERT_TRUE(p.failed()); + HttpHeadParser big; + std::string endless = "HTTP/1.1 200 OK\r\n" + std::string(5000, 'x'); + big.feed(endless.data(), endless.size()); + TEST_ASSERT_TRUE(big.failed()); + HttpHeadParser odd; + std::string status = "HTTP/1.1 999 What\r\n\r\n"; + odd.feed(status.data(), status.size()); + TEST_ASSERT_TRUE(odd.failed()); +} + +static std::string unchunk(const std::string& in, size_t piece, bool* done = nullptr, bool* failed = nullptr) { + ChunkedDecoder d; + std::string out; + for (size_t at = 0; at < in.size(); at += piece) { + size_t n = std::min(piece, in.size() - at); + std::string buf(n, '\0'); + size_t w = d.decode(reinterpret_cast(in.data() + at), n, reinterpret_cast(&buf[0])); + out.append(buf, 0, w); + } + if (done) *done = d.done(); + if (failed) *failed = d.failed(); + return out; +} + +void test_chunked_bodies() { + std::string body = "5\r\nhello\r\n6;ext=1\r\n, worl\r\n1\r\nd\r\n0\r\nTrailer: x\r\n\r\n"; + for (size_t piece : {size_t(1), size_t(2), size_t(5), body.size()}) { + bool done, failed; + TEST_ASSERT_EQUAL_STRING("hello, world", unchunk(body, piece, &done, &failed).c_str()); + TEST_ASSERT_TRUE(done); + TEST_ASSERT_FALSE(failed); + } + bool done; + TEST_ASSERT_EQUAL_STRING("abc12345", unchunk("3\r\nabc\r\nA\r\n12345", 4, &done).c_str()); // cut short: what came is given + TEST_ASSERT_FALSE(done); + std::string hex = "1F\r\n" + std::string(31, 'x') + "\r\n0\r\n\r\n"; + TEST_ASSERT_EQUAL_size_t(31, unchunk(hex, 3).size()); + bool failed; + unchunk("zz\r\n", 1, nullptr, &failed); + TEST_ASSERT_TRUE(failed); + unchunk("3\r\nabcXX", 1, nullptr, &failed); // no CRLF after the data + TEST_ASSERT_TRUE(failed); + unchunk("FFFFFFFFFFFFFFFFFFFF\r\n", 1, nullptr, &failed); + TEST_ASSERT_TRUE(failed); +} + +void test_urls() { + Url u = parseUrl("https://git.twis.la/api/v1/repos/x?limit=10"); + TEST_ASSERT_TRUE(u.ok); + TEST_ASSERT_TRUE(u.https); + TEST_ASSERT_EQUAL_STRING("git.twis.la", u.host.c_str()); + TEST_ASSERT_EQUAL_STRING("/api/v1/repos/x?limit=10", u.path.c_str()); + TEST_ASSERT_EQUAL_INT(443, u.port); + u = parseUrl("http://Example.COM:8080"); + TEST_ASSERT_TRUE(u.ok); + TEST_ASSERT_EQUAL_STRING("example.com", u.host.c_str()); + TEST_ASSERT_EQUAL_STRING("/", u.path.c_str()); + TEST_ASSERT_EQUAL_INT(8080, u.port); + for (const char* bad : {"", "git.twis.la/x", "ftp://a/b", "https:///x", "https://u:p@host/x", "https://host:0/x", "https://ho st/x", "https://host/a b", + "https://host:99999/x", "https://ho_st/x"}) + TEST_ASSERT_FALSE_MESSAGE(parseUrl(bad).ok, bad); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_a_head_in_pieces); + RUN_TEST(test_a_download_head); + RUN_TEST(test_a_head_that_is_not_http); + RUN_TEST(test_chunked_bodies); + RUN_TEST(test_urls); + return UNITY_END(); +} diff --git a/test/test_json_scan/test_json_scan.cpp b/test/test_json_scan/test_json_scan.cpp new file mode 100644 index 0000000..1f9405b --- /dev/null +++ b/test/test_json_scan/test_json_scan.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include +#include + +#include "json_scan.h" + +using namespace roro::release; + +void setUp() {} +void tearDown() {} + +struct Seen { + std::vector paths; + std::map values; + std::map strings, cut; +}; + +static Seen scan(const std::string& json, size_t piece, size_t max = 300, bool* failed = nullptr, bool* done = nullptr) { + Seen seen; + JsonScanner s( + [&](const std::string& path, const std::string& value, bool isString, bool truncated) { + seen.paths.push_back(path); + seen.values[path] = value; + seen.strings[path] = isString; + seen.cut[path] = truncated; + }, + max); + for (size_t at = 0; at < json.size(); at += piece) s.feed(json.data() + at, std::min(piece, json.size() - at)); + if (failed) *failed = s.failed(); + if (done) *done = s.done(); + return seen; +} + +void test_paths_and_kinds() { + std::string json = R"({"tag_name":"v0.10.0","draft":false,"size":1885712,"ratio":-1.5e3,"none":null, + "assets":[{"name":"a.ota","size":12},{"name":"b.bin","size":3,"tags":[]},{"name":"c","nested":{"deep":[true,"x"]}}], + "empty":{},"last":"end"})"; + for (size_t piece : {size_t(1), size_t(3), size_t(7), json.size()}) { + bool failed, done; + Seen s = scan(json, piece, 300, &failed, &done); + TEST_ASSERT_FALSE(failed); + TEST_ASSERT_TRUE(done); + TEST_ASSERT_EQUAL_STRING("v0.10.0", s.values["tag_name"].c_str()); + TEST_ASSERT_TRUE(s.strings["tag_name"]); + TEST_ASSERT_EQUAL_STRING("false", s.values["draft"].c_str()); + TEST_ASSERT_FALSE(s.strings["draft"]); + TEST_ASSERT_EQUAL_STRING("1885712", s.values["size"].c_str()); + TEST_ASSERT_EQUAL_STRING("-1.5e3", s.values["ratio"].c_str()); + TEST_ASSERT_EQUAL_STRING("null", s.values["none"].c_str()); + TEST_ASSERT_EQUAL_STRING("a.ota", s.values["assets[0].name"].c_str()); + TEST_ASSERT_EQUAL_STRING("3", s.values["assets[1].size"].c_str()); + TEST_ASSERT_EQUAL_STRING("true", s.values["assets[2].nested.deep[0]"].c_str()); + TEST_ASSERT_EQUAL_STRING("x", s.values["assets[2].nested.deep[1]"].c_str()); + TEST_ASSERT_EQUAL_STRING("end", s.values["last"].c_str()); + TEST_ASSERT_EQUAL_size_t(0, s.values.count("assets[1].tags")); // empty containers report nothing + TEST_ASSERT_EQUAL_size_t(0, s.values.count("empty")); + } +} + +void test_top_level_array_and_scalars() { + Seen s = scan(R"([{"a":1},{"a":2},"x"])", 4); + TEST_ASSERT_EQUAL_STRING("2", s.values["[1].a"].c_str()); + TEST_ASSERT_EQUAL_STRING("x", s.values["[2]"].c_str()); + bool done; + s = scan("42", 1, 300, nullptr, &done); // a number ends only when something follows it + TEST_ASSERT_FALSE(done); + s = scan("42 ", 1, 300, nullptr, &done); + TEST_ASSERT_TRUE(done); + TEST_ASSERT_EQUAL_STRING("42", s.values[""].c_str()); +} + +void test_escapes_and_unicode() { + std::string json = R"({"s":"line1\nline2\t\"q\" \\ \/ caf\u00e9 \u20ac \ud83d\ude00 end"})"; + for (size_t piece : {size_t(1), size_t(5), json.size()}) { + Seen s = scan(json, piece); + TEST_ASSERT_EQUAL_STRING("line1\nline2\t\"q\" \\ / caf\xC3\xA9 \xE2\x82\xAC \xF0\x9F\x98\x80 end", s.values["s"].c_str()); + } + std::string key = R"({"a\"b":1})"; + TEST_ASSERT_EQUAL_STRING("1", scan(key, 2).values["a\"b"].c_str()); +} + +void test_long_values_are_cut_on_a_character() { + std::string json = "{\"body\":\"" + std::string(20, 'a') + "\xC3\xA9\xC3\xA9\"}"; // 24 bytes + Seen s = scan(json, 3, 21); // room for 21: the é would be cut + TEST_ASSERT_EQUAL_STRING(std::string(20, 'a').c_str(), s.values["body"].c_str()); + TEST_ASSERT_TRUE(s.cut["body"]); + s = scan(json, 3, 22); + TEST_ASSERT_EQUAL_size_t(22, s.values["body"].size()); + s = scan(json, 3, 100); + TEST_ASSERT_FALSE(s.cut["body"]); + // What follows a cut value is still read. + s = scan(R"({"a":"0123456789","b":"ok"})", 2, 4); + TEST_ASSERT_EQUAL_STRING("0123", s.values["a"].c_str()); + TEST_ASSERT_EQUAL_STRING("ok", s.values["b"].c_str()); +} + +void test_what_it_can_not_follow() { + bool failed; + scan("{\"a\" 1}", 1, 300, &failed); + TEST_ASSERT_TRUE(failed); + scan("{\"a\":1,,}", 1, 300, &failed); + TEST_ASSERT_TRUE(failed); + scan("[1,2}", 1, 300, &failed); + TEST_ASSERT_TRUE(failed); + scan("not json", 1, 300, &failed); + TEST_ASSERT_TRUE(failed); + scan("{\"a\":\"\\u12G4\"}", 1, 300, &failed); + TEST_ASSERT_TRUE(failed); + bool done; + scan("{\"a\":[1,2", 2, 300, &failed, &done); // cut short: not an error, not done + TEST_ASSERT_FALSE(failed); + TEST_ASSERT_FALSE(done); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_paths_and_kinds); + RUN_TEST(test_top_level_array_and_scalars); + RUN_TEST(test_escapes_and_unicode); + RUN_TEST(test_long_values_are_cut_on_a_character); + RUN_TEST(test_what_it_can_not_follow); + return UNITY_END(); +} diff --git a/test/test_release/fixtures/latest.json b/test/test_release/fixtures/latest.json new file mode 100644 index 0000000..8304107 --- /dev/null +++ b/test/test_release/fixtures/latest.json @@ -0,0 +1 @@ +{"id":18,"tag_name":"v0.10.0","target_commitish":"","name":"roro9stack v0.10.0","body":"v0.10.0: the Notes App (plain text notes in /notes, an editor that saves by itself, up to 16 KB); e in the Storage App's text viewer; keys sent through the Debug Console are no longer swallowed now and then\n\n\n## Files\n\n- `roro9stack-v0.10.0.ota`: the signed Update File. Copy it to `/updates` on the SD card and install it from Settings > Firmware or the Storage App, or push it over Wi-Fi with `scripts/ota_push.py`.\n- `roro9stack-v0.10.0-factory.bin`: the whole flash image, for a first install over USB at offset 0.\n- `roro9stack-v0.10.0.elf.gz`: the symbols, to decode a crash report from this build.\n- `SHA256SUMS`: checksums of the three.\n\n## Changes since v0.9.0\n\n- F1 plan: Notes ships as v0.10.0\n- Notes: plain text notes on the SD card, with an editor that saves by itself (#19)\n- A key sent through the Debug Console could turn the screen \"off\" for a tick\n- F1 plan: the Notes design round (Q141-Q150)\n","url":"https://git.twis.la/api/v1/repos/twisla/roro9stack/releases/18","html_url":"https://git.twis.la/twisla/roro9stack/releases/tag/v0.10.0","tarball_url":"https://git.twis.la/twisla/roro9stack/archive/v0.10.0.tar.gz","zipball_url":"https://git.twis.la/twisla/roro9stack/archive/v0.10.0.zip","upload_url":"https://git.twis.la/api/v1/repos/twisla/roro9stack/releases/18/assets","draft":false,"prerelease":false,"created_at":"2026-10-06T08:11:31Z","published_at":"2026-10-06T08:11:31Z","author":{"id":1,"login":"twisla","login_name":"","source_id":0,"full_name":"Cl\u00e9ment Martin","email":"1+twisla@noreply.git.twis.la","avatar_url":"https://git.twis.la/avatars/db0ad25f6a366bc836e69290433c81ec","html_url":"https://git.twis.la/twisla","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2021-08-17T07:22:50Z","restricted":false,"active":false,"prohibit_login":false,"location":"Earth","website":"https://git.twis.la","description":"Owner of this nice gitea instance.","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"twisla"},"assets":[{"id":2,"name":"SHA256SUMS","size":278,"download_count":2,"created_at":"2026-10-06T10:24:35Z","uuid":"b72c1ea7-af35-4563-917a-9ab56a649219","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.10.0/SHA256SUMS"},{"id":3,"name":"roro9stack-v0.10.0-factory.bin","size":1951088,"download_count":1,"created_at":"2026-10-06T10:24:35Z","uuid":"75163ae2-f495-4d5d-9883-ff773321c688","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.10.0/roro9stack-v0.10.0-factory.bin"},{"id":4,"name":"roro9stack-v0.10.0.elf.gz","size":14345572,"download_count":0,"created_at":"2026-10-06T10:24:35Z","uuid":"8b04e7fb-fee0-46bd-8be4-3bd67a8f8804","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.10.0/roro9stack-v0.10.0.elf.gz"},{"id":5,"name":"roro9stack-v0.10.0.ota","size":1885712,"download_count":2,"created_at":"2026-10-06T10:24:36Z","uuid":"4b827c6e-a57a-4436-b6e5-b8947e66d5ae","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.10.0/roro9stack-v0.10.0.ota"}]} \ No newline at end of file diff --git a/test/test_release/fixtures/list3.json b/test/test_release/fixtures/list3.json new file mode 100644 index 0000000..6d7bb86 --- /dev/null +++ b/test/test_release/fixtures/list3.json @@ -0,0 +1 @@ +[{"id":18,"tag_name":"v0.10.0","target_commitish":"","name":"roro9stack v0.10.0","body":"v0.10.0: the Notes App (plain text notes in /notes, an editor that saves by itself, up to 16 KB); e in the Storage App's text viewer; keys sent through the Debug Console are no longer swallowed now and then\n\n\n## Files\n\n- `roro9stack-v0.10.0.ota`: the signed Update File. Copy it to `/updates` on the SD card and install it from Settings > Firmware or the Storage App, or push it over Wi-Fi with `scripts/ota_push.py`.\n- `roro9stack-v0.10.0-factory.bin`: the whole flash image, for a first install over USB at offset 0.\n- `roro9stack-v0.10.0.elf.gz`: the symbols, to decode a crash report from this build.\n- `SHA256SUMS`: checksums of the three.\n\n## Changes since v0.9.0\n\n- F1 plan: Notes ships as v0.10.0\n- Notes: plain text notes on the SD card, with an editor that saves by itself (#19)\n- A key sent through the Debug Console could turn the screen \"off\" for a tick\n- F1 plan: the Notes design round (Q141-Q150)\n","url":"https://git.twis.la/api/v1/repos/twisla/roro9stack/releases/18","html_url":"https://git.twis.la/twisla/roro9stack/releases/tag/v0.10.0","tarball_url":"https://git.twis.la/twisla/roro9stack/archive/v0.10.0.tar.gz","zipball_url":"https://git.twis.la/twisla/roro9stack/archive/v0.10.0.zip","upload_url":"https://git.twis.la/api/v1/repos/twisla/roro9stack/releases/18/assets","draft":false,"prerelease":false,"created_at":"2026-10-06T08:11:31Z","published_at":"2026-10-06T08:11:31Z","author":{"id":1,"login":"twisla","login_name":"","source_id":0,"full_name":"Cl\u00e9ment Martin","email":"1+twisla@noreply.git.twis.la","avatar_url":"https://git.twis.la/avatars/db0ad25f6a366bc836e69290433c81ec","html_url":"https://git.twis.la/twisla","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2021-08-17T07:22:50Z","restricted":false,"active":false,"prohibit_login":false,"location":"Earth","website":"https://git.twis.la","description":"Owner of this nice gitea instance.","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"twisla"},"assets":[{"id":2,"name":"SHA256SUMS","size":278,"download_count":2,"created_at":"2026-10-06T10:24:35Z","uuid":"b72c1ea7-af35-4563-917a-9ab56a649219","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.10.0/SHA256SUMS"},{"id":3,"name":"roro9stack-v0.10.0-factory.bin","size":1951088,"download_count":1,"created_at":"2026-10-06T10:24:35Z","uuid":"75163ae2-f495-4d5d-9883-ff773321c688","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.10.0/roro9stack-v0.10.0-factory.bin"},{"id":4,"name":"roro9stack-v0.10.0.elf.gz","size":14345572,"download_count":0,"created_at":"2026-10-06T10:24:35Z","uuid":"8b04e7fb-fee0-46bd-8be4-3bd67a8f8804","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.10.0/roro9stack-v0.10.0.elf.gz"},{"id":5,"name":"roro9stack-v0.10.0.ota","size":1885712,"download_count":2,"created_at":"2026-10-06T10:24:36Z","uuid":"4b827c6e-a57a-4436-b6e5-b8947e66d5ae","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.10.0/roro9stack-v0.10.0.ota"}]},{"id":17,"tag_name":"v0.9.0","target_commitish":"","name":"roro9stack v0.9.0","body":"v0.9.0: the Storage App (browse, copy, move, rename, delete, viewers for text, hex, pcap, gpx and ota; Maintenance replaces Settings > Storage); files dated by the Clock whatever its source; a panic at Wi-Fi join fixed (#46)\n\n\n## Files\n\n- `roro9stack-v0.9.0.ota`: the signed Update File. Copy it to `/updates` on the SD card and install it from Settings > Firmware or the Storage App, or push it over Wi-Fi with `scripts/ota_push.py`.\n- `roro9stack-v0.9.0-factory.bin`: the whole flash image, for a first install over USB at offset 0.\n- `roro9stack-v0.9.0.elf.gz`: the symbols, to decode a crash report from this build.\n- `SHA256SUMS`: checksums of the three.\n\n## Changes since v0.8.1\n\n- F1 plan: the Storage App ships as v0.9.0; the SNTP panic is #46\n- Wi-Fi: SNTP could be started twice at a join, and ESP-IDF asserts on that\n- F1 steps 2-6: the Storage App, its viewers, and Maintenance moved in (#3)\n- F1 step 1: names, read-only rules and the packed listing (host-tested)\n- F1 plan: the Storage App's design round (Q128-Q140)\n","url":"https://git.twis.la/api/v1/repos/twisla/roro9stack/releases/17","html_url":"https://git.twis.la/twisla/roro9stack/releases/tag/v0.9.0","tarball_url":"https://git.twis.la/twisla/roro9stack/archive/v0.9.0.tar.gz","zipball_url":"https://git.twis.la/twisla/roro9stack/archive/v0.9.0.zip","upload_url":"https://git.twis.la/api/v1/repos/twisla/roro9stack/releases/17/assets","draft":false,"prerelease":false,"created_at":"2026-10-06T07:03:50Z","published_at":"2026-10-06T07:03:50Z","author":{"id":1,"login":"twisla","login_name":"","source_id":0,"full_name":"Cl\u00e9ment Martin","email":"1+twisla@noreply.git.twis.la","avatar_url":"https://git.twis.la/avatars/db0ad25f6a366bc836e69290433c81ec","html_url":"https://git.twis.la/twisla","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2021-08-17T07:22:50Z","restricted":false,"active":false,"prohibit_login":false,"location":"Earth","website":"https://git.twis.la","description":"Owner of this nice gitea instance.","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"twisla"},"assets":[{"id":50,"name":"SHA256SUMS","size":275,"download_count":1,"created_at":"2026-10-06T11:47:02Z","uuid":"673917e0-a8fd-4579-9291-6a8d56c841ca","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.9.0/SHA256SUMS"},{"id":51,"name":"roro9stack-v0.9.0-factory.bin","size":1927200,"download_count":0,"created_at":"2026-10-06T11:47:02Z","uuid":"d578a4b1-5ada-4b6d-b7cb-2c3fadd507f4","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.9.0/roro9stack-v0.9.0-factory.bin"},{"id":52,"name":"roro9stack-v0.9.0.elf.gz","size":14009891,"download_count":0,"created_at":"2026-10-06T11:47:02Z","uuid":"a376ddc3-d100-4161-ac1f-5921e4345616","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.9.0/roro9stack-v0.9.0.elf.gz"},{"id":53,"name":"roro9stack-v0.9.0.ota","size":1861824,"download_count":1,"created_at":"2026-10-06T11:47:02Z","uuid":"a16ed6ec-8f60-48eb-b92b-d24cab43b77c","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.9.0/roro9stack-v0.9.0.ota"}]},{"id":16,"tag_name":"v0.8.1","target_commitish":"","name":"roro9stack v0.8.1","body":"v0.8.1: the main loop rests between passes (core 1 from 100 % to 1 %); Pause GNSS for LoRa (the receiver costs the radio 8 dB); lora noise test\n\n\n## Files\n\n- `roro9stack-v0.8.1.ota`: the signed Update File. Copy it to `/updates` on the SD card and install it from Settings > Firmware or the Storage App, or push it over Wi-Fi with `scripts/ota_push.py`.\n- `roro9stack-v0.8.1-factory.bin`: the whole flash image, for a first install over USB at offset 0.\n- `roro9stack-v0.8.1.elf.gz`: the symbols, to decode a crash report from this build.\n- `SHA256SUMS`: checksums of the three.\n\n## Changes since v0.8.0\n\n- S1 plan: the resting loop and the GNSS pause ship in v0.8.1\n- The radio's noise: the GNSS receiver costs 8 dB; a setting pauses it (#20)\n- The main loop rests between passes (#40)\n","url":"https://git.twis.la/api/v1/repos/twisla/roro9stack/releases/16","html_url":"https://git.twis.la/twisla/roro9stack/releases/tag/v0.8.1","tarball_url":"https://git.twis.la/twisla/roro9stack/archive/v0.8.1.tar.gz","zipball_url":"https://git.twis.la/twisla/roro9stack/archive/v0.8.1.zip","upload_url":"https://git.twis.la/api/v1/repos/twisla/roro9stack/releases/16/assets","draft":false,"prerelease":false,"created_at":"2026-10-06T04:42:33Z","published_at":"2026-10-06T04:42:33Z","author":{"id":1,"login":"twisla","login_name":"","source_id":0,"full_name":"Cl\u00e9ment Martin","email":"1+twisla@noreply.git.twis.la","avatar_url":"https://git.twis.la/avatars/db0ad25f6a366bc836e69290433c81ec","html_url":"https://git.twis.la/twisla","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2021-08-17T07:22:50Z","restricted":false,"active":false,"prohibit_login":false,"location":"Earth","website":"https://git.twis.la","description":"Owner of this nice gitea instance.","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"twisla"},"assets":[{"id":46,"name":"SHA256SUMS","size":275,"download_count":1,"created_at":"2026-10-06T11:41:08Z","uuid":"45391ffd-6672-4eb5-8351-07d770b6518e","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.8.1/SHA256SUMS"},{"id":47,"name":"roro9stack-v0.8.1-factory.bin","size":1867776,"download_count":0,"created_at":"2026-10-06T11:41:08Z","uuid":"6b6ea5f7-7e18-4103-8925-138da9e24d48","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.8.1/roro9stack-v0.8.1-factory.bin"},{"id":48,"name":"roro9stack-v0.8.1.elf.gz","size":13267616,"download_count":0,"created_at":"2026-10-06T11:41:08Z","uuid":"82dce3f5-b63e-45dd-be25-778985206516","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.8.1/roro9stack-v0.8.1.elf.gz"},{"id":49,"name":"roro9stack-v0.8.1.ota","size":1802400,"download_count":1,"created_at":"2026-10-06T11:41:08Z","uuid":"d87a86db-19f4-475e-b3b6-b116143d6b62","browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.8.1/roro9stack-v0.8.1.ota"}]}] \ No newline at end of file diff --git a/test/test_release/test_release.cpp b/test/test_release/test_release.cpp new file mode 100644 index 0000000..045235e --- /dev/null +++ b/test/test_release/test_release.cpp @@ -0,0 +1,146 @@ +#include + +#include +#include + +#include "update_check.h" + +using namespace roro::release; + +void setUp() {} +void tearDown() {} + +static std::string file(const char* name) { + std::string path = std::string("test/test_release/fixtures/") + name; + FILE* f = std::fopen(path.c_str(), "rb"); + TEST_ASSERT_NOT_NULL_MESSAGE(f, path.c_str()); + std::string s; + char buf[4096]; + size_t n; + while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n); + std::fclose(f); + return s; +} + +static std::vector read(const std::string& json, size_t piece, size_t max = 10, bool* complete = nullptr) { + ReleaseReader r(max); + for (size_t at = 0; at < json.size(); at += piece) r.feed(json.data() + at, std::min(piece, json.size() - at)); + r.end(); + TEST_ASSERT_TRUE(r.ok()); + if (complete) *complete = r.complete(); + return r.releases(); +} + +// The answers of git.twis.la, as they were on 2026-10-06. +void test_latest_as_the_server_sends_it() { + std::string json = file("latest.json"); + for (size_t piece : {size_t(1), size_t(17), size_t(1000), json.size()}) { + bool complete; + auto releases = read(json, piece, 10, &complete); + TEST_ASSERT_TRUE(complete); + TEST_ASSERT_EQUAL_size_t(1, releases.size()); + const Release& r = releases[0]; + TEST_ASSERT_EQUAL_STRING("v0.10.0", r.tag.c_str()); + TEST_ASSERT_EQUAL_STRING("2026-10-06", r.date().c_str()); + TEST_ASSERT_EQUAL_STRING("https://git.twis.la/twisla/roro9stack/releases/download/v0.10.0/roro9stack-v0.10.0.ota", r.otaUrl.c_str()); + TEST_ASSERT_EQUAL_UINT32(1885712, r.otaSize); + TEST_ASSERT_TRUE(r.notes.rfind("v0.10.0: the Notes App", 0) == 0); + TEST_ASSERT_TRUE(r.notes.find("\n\n") == std::string::npos); // the tag's message only + TEST_ASSERT_TRUE(r.usable()); + TEST_ASSERT_TRUE(trustedAssetUrl(r.otaUrl)); + } +} + +void test_a_list() { + std::string json = file("list3.json"); + for (size_t piece : {size_t(1), size_t(64), json.size()}) { + auto releases = read(json, piece); + TEST_ASSERT_EQUAL_size_t(3, releases.size()); + TEST_ASSERT_EQUAL_STRING("v0.10.0", releases[0].tag.c_str()); + TEST_ASSERT_EQUAL_STRING("v0.9.0", releases[1].tag.c_str()); + TEST_ASSERT_EQUAL_STRING("v0.8.1", releases[2].tag.c_str()); + for (auto& r : releases) { + TEST_ASSERT_TRUE(r.usable()); + TEST_ASSERT_TRUE(r.otaUrl.find("/" + r.tag + "/roro9stack-" + r.tag + ".ota") != std::string::npos); + TEST_ASSERT_TRUE(r.otaSize > 1000000); + } + } + TEST_ASSERT_EQUAL_size_t(2, read(json, 100, 2).size()); // no more than asked for +} + +void test_what_makes_a_release_usable() { + auto releases = read(R"([ + {"tag_name":"v1.0.0","draft":true,"prerelease":false,"assets":[{"name":"roro9stack-v1.0.0.ota","size":5,"browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v1.0.0/roro9stack-v1.0.0.ota"}]}, + {"tag_name":"v0.9.0-rc1","draft":false,"prerelease":true,"assets":[{"name":"roro9stack-v0.9.0-rc1.ota","size":5,"browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.9.0-rc1/roro9stack-v0.9.0-rc1.ota"}]}, + {"tag_name":"v0.8.0","draft":false,"prerelease":false,"assets":[{"size":9,"browser_download_url":"https://git.twis.la/x/SHA256SUMS","name":"SHA256SUMS"}]}, + {"assets":[{"browser_download_url":"https://git.twis.la/twisla/roro9stack/releases/download/v0.7.0/roro9stack-v0.7.0.ota","size":7,"name":"roro9stack-v0.7.0.ota"},{"name":"x.bin","size":1,"browser_download_url":"https://git.twis.la/x.bin"}],"draft":false,"tag_name":"v0.7.0","prerelease":false} + ])", 5); + TEST_ASSERT_EQUAL_size_t(4, releases.size()); + TEST_ASSERT_FALSE(releases[0].usable()); // a draft + TEST_ASSERT_FALSE(releases[1].usable()); // a pre-release + TEST_ASSERT_FALSE(releases[2].usable()); // nothing to install in it + TEST_ASSERT_TRUE(releases[3].usable()); // the fields in any order + TEST_ASSERT_EQUAL_UINT32(7, releases[3].otaSize); +} + +void test_notes() { + TEST_ASSERT_EQUAL_STRING("one two", firstParagraph(" one two \n\nsecond", false).c_str()); + TEST_ASSERT_EQUAL_STRING("one\ntwo", firstParagraph("one\ntwo\r\n\r\nthree", false).c_str()); + TEST_ASSERT_EQUAL_STRING("only", firstParagraph("only", false).c_str()); + TEST_ASSERT_EQUAL_STRING("cut...", firstParagraph("cut", true).c_str()); + TEST_ASSERT_EQUAL_STRING("whole", firstParagraph("whole\n\nrest", true).c_str()); + TEST_ASSERT_EQUAL_STRING("", firstParagraph("\n\n", false).c_str()); +} + +void test_which_releases_are_updates() { + Release r; + r.tag = "v0.11.0"; + r.otaUrl = "https://git.twis.la/twisla/roro9stack/releases/download/v0.11.0/roro9stack-v0.11.0.ota"; + TEST_ASSERT_TRUE(isNewer(r, "v0.10.0")); + TEST_ASSERT_TRUE(isNewer(r, "v0.10.0-14-gabc1234-dirty+debug")); // between releases: the next one is still newer + TEST_ASSERT_FALSE(isNewer(r, "v0.11.0")); + TEST_ASSERT_FALSE(isNewer(r, "v0.11.0-3-gabc1234")); // a build after the release isn't older than it + TEST_ASSERT_FALSE(isNewer(r, "v0.12.0")); + TEST_ASSERT_FALSE(isNewer(r, "unknown")); // a build that doesn't know its version isn't offered anything + r.tag = "v0.10.1"; + TEST_ASSERT_TRUE(isNewer(r, "v0.10.0")); + r.tag = "v0.9.9"; + TEST_ASSERT_FALSE(isNewer(r, "v0.10.0")); + r.tag = "v0.11.0"; + r.draft = true; + TEST_ASSERT_FALSE(isNewer(r, "v0.10.0")); + r.draft = false; + + TEST_ASSERT_TRUE(shouldAnnounce(r, "v0.10.0", "", "")); + TEST_ASSERT_FALSE(shouldAnnounce(r, "v0.10.0", "v0.11.0", "")); // it rolled back here before + TEST_ASSERT_FALSE(shouldAnnounce(r, "v0.10.0", "", "v0.11.0")); // already said so + TEST_ASSERT_TRUE(shouldAnnounce(r, "v0.10.0", "v0.10.5", "v0.10.9")); + TEST_ASSERT_TRUE(isDebugBuild("v0.10.0-3-gabc+debug")); + TEST_ASSERT_FALSE(isDebugBuild("v0.10.0")); +} + +void test_only_the_projects_downloads_are_fetched() { + const char* good = "https://git.twis.la/twisla/roro9stack/releases/download/v0.11.0/roro9stack-v0.11.0.ota"; + TEST_ASSERT_TRUE(trustedAssetUrl(good)); + TEST_ASSERT_TRUE(trustedAssetUrl("https://GIT.twis.la/twisla/roro9stack/releases/download/v1/a.ota")); + TEST_ASSERT_FALSE(trustedAssetUrl("http://git.twis.la/twisla/roro9stack/releases/download/v1/a.ota")); + TEST_ASSERT_FALSE(trustedAssetUrl("https://git.twis.la:8443/twisla/roro9stack/releases/download/v1/a.ota")); + TEST_ASSERT_FALSE(trustedAssetUrl("https://evil.example/twisla/roro9stack/releases/download/v1/a.ota")); + TEST_ASSERT_FALSE(trustedAssetUrl("https://git.twis.la.evil.example/twisla/roro9stack/releases/download/v1/a.ota")); + TEST_ASSERT_FALSE(trustedAssetUrl("https://git.twis.la@evil.example/twisla/roro9stack/releases/download/v1/a.ota")); + TEST_ASSERT_FALSE(trustedAssetUrl("https://git.twis.la/other/repo/releases/download/v1/a.ota")); + TEST_ASSERT_FALSE(trustedAssetUrl("https://git.twis.la/twisla/roro9stack/releases/download/../../../x")); + TEST_ASSERT_FALSE(trustedAssetUrl("https://git.twis.la/twisla/roro9stack/releases/download/v1/a.ota?x=1")); + TEST_ASSERT_FALSE(trustedAssetUrl("")); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_latest_as_the_server_sends_it); + RUN_TEST(test_a_list); + RUN_TEST(test_what_makes_a_release_usable); + RUN_TEST(test_notes); + RUN_TEST(test_which_releases_are_updates); + RUN_TEST(test_only_the_projects_downloads_are_fetched); + return UNITY_END(); +}