Skip to content

Commit f2bdbae

Browse files
committed
fix(update): fetch release assets without metadata
1 parent 2fe6f10 commit f2bdbae

6 files changed

Lines changed: 93 additions & 148 deletions

File tree

src/settings/SettingsModel.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
namespace settings {
1414

1515
inline constexpr std::string_view kMathFontTarget = "math";
16+
inline constexpr std::string_view kDefaultRepositoryOwner = "ionutdecebal";
1617

1718
enum class ReadingMode : uint8_t {
1819
rsvp,

src/ui/screens/NetworkSettingsScreen.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ namespace screens {
4343
const int16_t thirdRowY = static_cast<int16_t>(secondRowY + rowHeight + gap);
4444
const int16_t halfWidth = static_cast<int16_t>((content.w - gap) / 2);
4545
if (ui.setting({content.x, thirdRowY, halfWidth, rowHeight}, ui.text(UiText::OtaOwner),
46-
settings.updates.repositoryOwner.empty() ? ui.text(UiText::Default)
47-
: std::string_view{settings.updates.repositoryOwner})) {
46+
settings.updates.repositoryOwner.empty() ? settings::kDefaultRepositoryOwner
47+
: std::string_view{settings.updates.repositoryOwner})) {
4848
editField_ = EditField::Owner;
4949
editValue_ = settings.updates.repositoryOwner;
5050
keyboard_ = {};

src/update/OtaUpdater.cpp

Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
namespace {
1919

20-
constexpr size_t kMaxReleaseJsonBytes = 32768;
2120
constexpr const char* kStatusTitle = "OTA";
2221
const char* kRedirectHeaderKeys[] = {
2322
"Location",
@@ -75,7 +74,7 @@ namespace {
7574
}
7675

7776
ReleaseSource releaseSourceForSettings(const settings::UpdateSettings& settings) {
78-
ReleaseSource source{settings.repositoryOwner.empty() ? std::string{"ionutdecebal"}
77+
ReleaseSource source{settings.repositoryOwner.empty() ? std::string{settings::kDefaultRepositoryOwner}
7978
: std::string{AsciiText::trim(settings.repositoryOwner)},
8079
"rsvpnano", std::string{AsciiText::trim(settings.releaseTag)}};
8180

@@ -103,6 +102,12 @@ namespace {
103102
return std::string{prefix} + " " + std::string{detail.c_str(), detail.length()};
104103
}
105104

105+
bool isRedirectStatus(int statusCode) {
106+
return statusCode == HTTP_CODE_MOVED_PERMANENTLY || statusCode == HTTP_CODE_FOUND
107+
|| statusCode == HTTP_CODE_SEE_OTHER || statusCode == HTTP_CODE_TEMPORARY_REDIRECT
108+
|| statusCode == HTTP_CODE_PERMANENT_REDIRECT;
109+
}
110+
106111
std::string readBodyLimited(HTTPClient& http, size_t maxBytes) {
107112
WiFiClient* stream = http.getStreamPtr();
108113
if (stream == nullptr) {
@@ -178,57 +183,68 @@ std::string_view OtaUpdater::currentVersion() {
178183

179184
static void reportStatus(OtaUpdater::StatusCallback callback, void* context, const char* title, const char* line1,
180185
const char* line2, int progressPercent);
186+
static std::expected<std::string, std::string> resolveDownloadUrl(std::string_view assetUrl, std::string_view version,
187+
OtaUpdater::StatusCallback callback, void* context);
181188

182189
static std::expected<LatestRelease, std::string> fetchRelease(const settings::UpdateSettings& settings,
183-
OtaUpdater::StatusCallback callback, void* context) {
190+
OtaUpdater::StatusCallback callback, void* context) {
184191
const std::string installedVersion{OtaUpdater::currentVersion()};
185192
const ReleaseSource source = releaseSourceForSettings(settings);
186193
if (source.owner.empty() || source.repo.empty())
187194
return std::unexpected(std::string{"GitHub source missing"});
188195

189-
const std::string releasePath = source.tag.empty() ? "latest" : "tags/" + urlEncodePathSegment(source.tag);
190-
const std::string url =
191-
"https://api.github.com/repos/" + source.owner + "/" + source.repo + "/releases/" + releasePath;
192196
const std::string sourceLabel = source.tag.empty() ? source.repo : source.repo + ":" + source.tag;
193197

194198
reportStatus(callback, context, kStatusTitle, "Checking GitHub", sourceLabel.c_str(), 22);
195199

196200
WiFiClientSecure client;
197-
// GitHub release metadata and assets can redirect across multiple hosts, so
198-
// keep the transport flexible for now. A signed manifest is the best
199-
// follow-up hardening step.
201+
// GitHub release assets redirect across multiple hosts, so keep the
202+
// transport flexible for now. A signed manifest is the best follow-up
203+
// hardening step.
200204
client.setInsecure();
201205
client.setHandshakeTimeout(15);
202206

203207
HTTPClient http;
208+
http.collectHeaders(kRedirectHeaderKeys, 1);
204209
http.setUserAgent(userAgentForVersion(installedVersion).c_str());
205-
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
210+
http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
206211
http.setTimeout(15000);
207-
if (!http.begin(client, url.c_str()))
208-
return std::unexpected(std::string{"HTTP begin failed"});
209212

210-
http.addHeader("Accept", "application/vnd.github+json");
211-
const int statusCode = http.GET();
212-
if (statusCode != HTTP_CODE_OK) {
213-
const std::string errorDetail = statusCode == HTTP_CODE_NOT_FOUND
214-
? (source.tag.empty() ? "No published release" : "Release tag not found")
215-
: httpClientErrorDetail("GitHub", statusCode);
213+
const std::string assetName = urlEncodePathSegment(Board::Config::OTA_ASSET_NAME);
214+
std::string releaseTag = source.tag;
215+
std::string assetUrl = "https://github.com/" + source.owner + "/" + source.repo + "/releases/";
216+
if (releaseTag.empty())
217+
assetUrl += "latest/download/" + assetName;
218+
else
219+
assetUrl += "download/" + urlEncodePathSegment(releaseTag) + "/" + assetName;
220+
221+
if (releaseTag.empty()) {
222+
if (!http.begin(client, assetUrl.c_str()))
223+
return std::unexpected(std::string{"HTTP begin failed"});
224+
225+
http.addHeader("Accept", "application/octet-stream");
226+
const int statusCode = http.GET();
227+
if (!isRedirectStatus(statusCode)) {
228+
const std::string errorDetail = statusCode == HTTP_CODE_NOT_FOUND
229+
? "No latest OTA asset"
230+
: httpClientErrorDetail("GitHub", statusCode);
231+
http.end();
232+
return std::unexpected(errorDetail);
233+
}
234+
235+
const String location = http.header("Location");
216236
http.end();
217-
return std::unexpected(errorDetail);
237+
const std::string_view resolvedUrl{location.c_str(), static_cast<size_t>(location.length())};
238+
auto parsedTag = releaseparser::tagFromAssetLocation(resolvedUrl, Board::Config::OTA_ASSET_NAME);
239+
if (!parsedTag)
240+
return std::unexpected(std::string{"Release redirect invalid"});
241+
releaseTag = std::move(*parsedTag);
242+
assetUrl.assign(resolvedUrl);
218243
}
219244

220-
const std::string body = readBodyLimited(http, kMaxReleaseJsonBytes);
221-
http.end();
222-
223-
auto parsed = releaseparser::parse(body, Board::Config::OTA_ASSET_NAME);
224-
if (!parsed)
225-
return std::unexpected(std::string{"Release tag missing"});
226-
LatestRelease release;
227-
release.assetUrl = parsed->assetUrl;
228-
229-
reportStatus(callback, context, kStatusTitle, "Checking version", parsed->tagName.c_str(), 25);
245+
reportStatus(callback, context, kStatusTitle, "Checking version", releaseTag.c_str(), 25);
230246
const std::string commitUrl = "https://api.github.com/repos/" + source.owner + "/" + source.repo + "/commits/"
231-
+ urlEncodePathSegment(parsed->tagName);
247+
+ urlEncodePathSegment(releaseTag);
232248
if (!http.begin(client, commitUrl.c_str()))
233249
return std::unexpected(std::string{"Commit lookup failed"});
234250
http.addHeader("Accept", "application/vnd.github.sha");
@@ -240,22 +256,25 @@ static std::expected<LatestRelease, std::string> fetchRelease(const settings::Up
240256
}
241257
std::string commitSha = readBodyLimited(http, 64);
242258
http.end();
243-
auto releaseVersion = releaseparser::versionForCommit(parsed->tagName, commitSha);
259+
auto releaseVersion = releaseparser::versionForCommit(releaseTag, commitSha);
244260
if (!releaseVersion)
245261
return std::unexpected(std::string{"Tag commit invalid"});
246-
release.version = std::move(*releaseVersion);
247262

248-
if (release.assetUrl.empty())
249-
return std::unexpected(std::string{Board::Config::OTA_ASSET_NAME} + " missing");
263+
auto resolvedAssetUrl = resolveDownloadUrl(assetUrl, *releaseVersion, callback, context);
264+
if (!resolvedAssetUrl)
265+
return std::unexpected(std::move(resolvedAssetUrl.error()));
250266

251-
return release;
267+
return LatestRelease{
268+
.version = std::move(*releaseVersion),
269+
.assetUrl = std::move(*resolvedAssetUrl),
270+
};
252271
}
253272

254273
static std::expected<std::string, std::string> resolveDownloadUrl(std::string_view assetUrl, std::string_view version,
255274
OtaUpdater::StatusCallback callback, void* context) {
256275
const std::string assetUrlString{assetUrl};
257276
const std::string versionString{version};
258-
reportStatus(callback, context, kStatusTitle, "Resolving asset", versionString.c_str(), 29);
277+
reportStatus(callback, context, kStatusTitle, "Resolving asset", versionString.c_str(), 27);
259278

260279
WiFiClientSecure client;
261280
client.setInsecure();
@@ -276,8 +295,7 @@ static std::expected<std::string, std::string> resolveDownloadUrl(std::string_vi
276295
return std::string{assetUrl};
277296
}
278297

279-
if (statusCode == HTTP_CODE_MOVED_PERMANENTLY || statusCode == HTTP_CODE_FOUND || statusCode == HTTP_CODE_SEE_OTHER
280-
|| statusCode == HTTP_CODE_TEMPORARY_REDIRECT || statusCode == HTTP_CODE_PERMANENT_REDIRECT) {
298+
if (isRedirectStatus(statusCode)) {
281299
String resolvedUrl = http.header("Location");
282300
http.end();
283301
if (!resolvedUrl.isEmpty())
@@ -386,15 +404,6 @@ OtaUpdater::Result OtaUpdater::checkAndInstall(const settings::DeviceSettings& s
386404
const std::string detail = versionDetail(installedVersion, release->version);
387405
reportStatus(callback, context, kStatusTitle, "Preparing update", detail.c_str(), 28);
388406

389-
auto resolvedAssetUrl = resolveDownloadUrl(release->assetUrl, release->version, callback, context);
390-
if (!resolvedAssetUrl) {
391-
net::disconnect();
392-
return {
393-
.summary = "Asset failed",
394-
.detail = std::move(resolvedAssetUrl.error()),
395-
};
396-
}
397-
398407
WiFiClientSecure client;
399408
// Match the metadata request behavior until the update path gains certificate
400409
// pinning or signature verification above the transport layer.
@@ -422,7 +431,7 @@ OtaUpdater::Result OtaUpdater::checkAndInstall(const settings::DeviceSettings& s
422431
});
423432

424433
const String version = installedVersion.data();
425-
const String resolvedUrl = resolvedAssetUrl->c_str();
434+
const String resolvedUrl = release->assetUrl.c_str();
426435
const t_httpUpdate_return updateResult = updater.update(client, resolvedUrl, version, [version](HTTPClient* http) {
427436
http->setUserAgent(userAgentForVersion({version.c_str(), version.length()}).c_str());
428437
http->addHeader("Accept", "application/octet-stream");

src/update/ReleaseParser.cpp

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,27 @@
22

33
#include <algorithm>
44
#include <cctype>
5-
#include <vector>
6-
7-
#include <glaze/json.hpp>
85

96
#include "text/AsciiText.h"
107

118
namespace releaseparser {
12-
namespace {
13-
14-
struct Asset {
15-
std::string name;
16-
std::string browser_download_url;
17-
};
18-
19-
struct Release {
20-
std::string tag_name;
21-
std::vector<Asset> assets;
22-
};
23-
24-
} // namespace
25-
26-
std::expected<ReleaseInfo, std::error_code> parse(std::string_view json, std::string_view assetName) {
27-
Release release;
28-
if (glz::read<glz::opts{.error_on_unknown_keys = false}>(release, json) || release.tag_name.empty()) {
9+
std::expected<std::string, std::error_code> tagFromAssetLocation(std::string_view location,
10+
std::string_view assetName) {
11+
constexpr std::string_view marker = "/releases/download/";
12+
const size_t start = location.find(marker);
13+
if (start == std::string_view::npos || assetName.empty() || !location.ends_with(assetName)) {
2914
return std::unexpected(std::make_error_code(std::errc::invalid_argument));
3015
}
3116

32-
const auto asset = std::ranges::find(release.assets, assetName, &Asset::name);
33-
return ReleaseInfo{
34-
.tagName = std::move(release.tag_name),
35-
.assetUrl = asset == release.assets.end() ? std::string{} : std::move(asset->browser_download_url),
36-
};
17+
const size_t tagStart = start + marker.size();
18+
const size_t assetStart = location.size() - assetName.size();
19+
if (assetStart <= tagStart || location[assetStart - 1] != '/')
20+
return std::unexpected(std::make_error_code(std::errc::invalid_argument));
21+
22+
const std::string_view tag = location.substr(tagStart, assetStart - tagStart - 1);
23+
if (tag.empty() || tag.contains('/'))
24+
return std::unexpected(std::make_error_code(std::errc::invalid_argument));
25+
return std::string{tag};
3726
}
3827

3928
std::expected<std::string, std::error_code> versionForCommit(std::string_view tagName, std::string_view commitSha) {

src/update/ReleaseParser.h

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@
55
#include <string_view>
66
#include <system_error>
77

8-
// Pure parsing of a GitHub release JSON payload. No networking, no
9-
// SD access -- safe to unit test on the host.
8+
// Pure parsing of GitHub release metadata. No networking or SD access.
109
namespace releaseparser {
1110

12-
struct ReleaseInfo {
13-
std::string tagName; // empty if the payload had no usable tag_name
14-
std::string assetUrl; // empty if no asset matched assetName
15-
};
16-
17-
// Extracts the release tag and the browser_download_url of the matching asset.
18-
std::expected<ReleaseInfo, std::error_code> parse(std::string_view json, std::string_view assetName);
11+
std::expected<std::string, std::error_code> tagFromAssetLocation(std::string_view location,
12+
std::string_view assetName);
1913

2014
// Published builds use the release tag plus a stable abbreviated commit.
2115
std::expected<std::string, std::error_code> versionForCommit(std::string_view tagName, std::string_view commitSha);

test/test_release_parser/test_main.cpp

Lines changed: 16 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,70 +2,26 @@
22

33
#include "update/ReleaseParser.h"
44

5-
namespace {
6-
7-
const char* kAsset = "rsvp-nano-esp32-s3-touch-lcd-3.49-ota.bin";
8-
9-
releaseparser::ReleaseInfo parseJson(std::string_view json) {
10-
return releaseparser::parse(json, kAsset).value();
11-
}
12-
13-
} // namespace
14-
155
void setUp() {}
166

177
void tearDown() {}
188

19-
void test_extracts_tag_and_matching_asset_url() {
20-
constexpr std::string_view json = "{\"tag_name\":\"v0.0.6\",\"assets\":["
21-
"{\"name\":\"other.bin\",\"browser_download_url\":\"https://example.com/other.bin\"},"
22-
"{\"name\":\"rsvp-nano-esp32-s3-touch-lcd-3.49-ota.bin\",\"browser_download_url\":\"https://"
23-
"example.com/ota.bin\"}"
24-
"]}";
25-
const auto out = releaseparser::parse(json, kAsset);
26-
TEST_ASSERT_TRUE(out.has_value());
27-
TEST_ASSERT_EQUAL_STRING("v0.0.6", out->tagName.c_str());
28-
TEST_ASSERT_EQUAL_STRING("https://example.com/ota.bin", out->assetUrl.c_str());
29-
}
30-
31-
void test_returns_false_when_tag_missing() {
32-
constexpr std::string_view json = "{\"assets\":[]}";
33-
const auto out = releaseparser::parse(json, kAsset);
34-
TEST_ASSERT_FALSE(out.has_value());
35-
TEST_ASSERT_TRUE(out.error() == std::errc::invalid_argument);
36-
}
37-
38-
void test_asset_url_empty_when_no_asset_matches() {
39-
constexpr std::string_view json = "{\"tag_name\":\"v1.2.3\",\"assets\":["
40-
"{\"name\":\"wrong.bin\",\"browser_download_url\":\"https://example.com/wrong.bin\"}]}";
41-
const releaseparser::ReleaseInfo out = parseJson(json);
42-
TEST_ASSERT_EQUAL_STRING("v1.2.3", out.tagName.c_str());
43-
TEST_ASSERT_TRUE(out.assetUrl.empty());
44-
}
45-
46-
void test_handles_whitespace_after_colon() {
47-
constexpr std::string_view json = "{ \"tag_name\" : \"v9\" }";
48-
const releaseparser::ReleaseInfo out = parseJson(json);
49-
TEST_ASSERT_EQUAL_STRING("v9", out.tagName.c_str());
50-
}
51-
52-
void test_unescapes_forward_slashes_in_url() {
53-
constexpr std::string_view json = "{\"tag_name\":\"v2\",\"assets\":["
54-
"{\"name\":\"rsvp-nano-esp32-s3-touch-lcd-3.49-ota.bin\","
55-
"\"browser_download_url\":\"https:\\/\\/example.com\\/path\\/ota.bin\"}]}";
56-
const releaseparser::ReleaseInfo out = parseJson(json);
57-
TEST_ASSERT_EQUAL_STRING("https://example.com/path/ota.bin", out.assetUrl.c_str());
9+
void test_extracts_tag_from_asset_redirect() {
10+
constexpr std::string_view asset = "rsvp-nano-esp32-s3-touch-lcd-3.49-ota.bin";
11+
const auto tag = releaseparser::tagFromAssetLocation(
12+
"https://github.com/ionutdecebal/rsvpnano/releases/download/v0.0.9/"
13+
"rsvp-nano-esp32-s3-touch-lcd-3.49-ota.bin",
14+
asset);
15+
TEST_ASSERT_TRUE(tag.has_value());
16+
TEST_ASSERT_EQUAL_STRING("v0.0.9", tag->c_str());
5817
}
5918

60-
void test_picks_url_after_matching_name_not_a_neighbor() {
61-
// The matching asset is the second entry; its url must come from after its name.
62-
constexpr std::string_view json = "{\"tag_name\":\"v3\",\"assets\":["
63-
"{\"name\":\"a.bin\",\"browser_download_url\":\"https://example.com/a.bin\"},"
64-
"{\"name\":\"rsvp-nano-esp32-s3-touch-lcd-3.49-ota.bin\",\"browser_download_url\":\"https://"
65-
"example.com/correct.bin\"}"
66-
"]}";
67-
const releaseparser::ReleaseInfo out = parseJson(json);
68-
TEST_ASSERT_EQUAL_STRING("https://example.com/correct.bin", out.assetUrl.c_str());
19+
void test_rejects_invalid_asset_redirects() {
20+
TEST_ASSERT_FALSE(releaseparser::tagFromAssetLocation("https://github.com/releases/latest", "firmware.bin")
21+
.has_value());
22+
TEST_ASSERT_FALSE(releaseparser::tagFromAssetLocation("https://github.com/releases/download/v1/other.bin",
23+
"firmware.bin")
24+
.has_value());
6925
}
7026

7127
void test_builds_version_from_release_tag_and_commit() {
@@ -78,12 +34,8 @@ void test_builds_version_from_release_tag_and_commit() {
7834

7935
int main(void) {
8036
UNITY_BEGIN();
81-
RUN_TEST(test_extracts_tag_and_matching_asset_url);
82-
RUN_TEST(test_returns_false_when_tag_missing);
83-
RUN_TEST(test_asset_url_empty_when_no_asset_matches);
84-
RUN_TEST(test_handles_whitespace_after_colon);
85-
RUN_TEST(test_unescapes_forward_slashes_in_url);
86-
RUN_TEST(test_picks_url_after_matching_name_not_a_neighbor);
37+
RUN_TEST(test_extracts_tag_from_asset_redirect);
38+
RUN_TEST(test_rejects_invalid_asset_redirects);
8739
RUN_TEST(test_builds_version_from_release_tag_and_commit);
8840
return UNITY_END();
8941
}

0 commit comments

Comments
 (0)