Public Access
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:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user