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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 15:11:58 +02:00
co-authored by Claude Sonnet 5.5
parent 6b7e90765f
commit b0e8226943
13 changed files with 1058 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
#include "http_head.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
namespace roro::release {
namespace {
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(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
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
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
+202
View File
@@ -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<uint8_t>(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<char>(cp);
n = 1;
} else if (cp < 0x800) {
b[0] = static_cast<char>(0xC0 | (cp >> 6));
b[1] = static_cast<char>(0x80 | (cp & 0x3F));
n = 2;
} else if (cp < 0x10000) {
b[0] = static_cast<char>(0xE0 | (cp >> 12));
b[1] = static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
b[2] = static_cast<char>(0x80 | (cp & 0x3F));
n = 3;
} else {
b[0] = static_cast<char>(0xF0 | (cp >> 18));
b[1] = static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
b[2] = static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
b[3] = static_cast<char>(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<uint8_t>(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
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
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<void(const std::string& path, const std::string& value, bool isString, bool truncated)>;
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<Frame> 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
+80
View File
@@ -0,0 +1,80 @@
#include "release_info.h"
#include <cstdlib>
namespace roro::release {
namespace {
bool endsWith(const std::string& s, const char* tail) {
size_t n = std::char_traits<char>::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<int>(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<size_t>(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<int>(std::strtol(rest.c_str() + 7, &end, 10));
if (static_cast<int>(index) != assetRelease_ || j != assetIndex_) {
flushAsset();
assetRelease_ = static_cast<int>(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<uint32_t>(std::strtoul(text.c_str(), nullptr, 10));
else if (field == "browser_download_url") assetUrl_ = text;
}
(void)isString;
}
} // namespace roro::release
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#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<Release>& 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<Release> 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
+15
View File
@@ -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
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <string>
#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
+121
View File
@@ -0,0 +1,121 @@
#include <unity.h>
#include <string>
#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<const uint8_t*>(in.data() + at), n, reinterpret_cast<uint8_t*>(&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();
}
+125
View File
@@ -0,0 +1,125 @@
#include <unity.h>
#include <map>
#include <string>
#include <vector>
#include "json_scan.h"
using namespace roro::release;
void setUp() {}
void tearDown() {}
struct Seen {
std::vector<std::string> paths;
std::map<std::string, std::string> values;
std::map<std::string, bool> 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("<html>not json</html>", 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();
}
+1
View File
@@ -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"}]}
File diff suppressed because one or more lines are too long
+146
View File
@@ -0,0 +1,146 @@
#include <unity.h>
#include <cstdio>
#include <string>
#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<Release> 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();
}