Fix Switch OTA parser for GitHub releases/latest JSON.

Scan asset objects by brace bounds instead of an 800-byte window so
browser_download_url survives fat uploader blocks; mirror logic in Lua
and add realistic API fixtures.
This commit is contained in:
Andrew Quenehen
2026-08-05 15:15:44 -03:00
parent ae5276b2d1
commit 1bfc89fdd4
4 changed files with 289 additions and 60 deletions
@@ -48,6 +48,56 @@ int main(void) {
"missing ota asset");
expect(strcmp(bad.reason, "missing_ota_asset") == 0, "missing_ota_asset reason");
/* GitHub releases/latest shape: release-level name + fat uploader before browser_download_url. */
const char *github_json =
"{"
"\"tag_name\":\"v0.1.70\","
"\"name\":\"0.1.70\","
"\"assets\":["
"{"
"\"url\":\"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880\","
"\"id\":502823880,"
"\"name\":\"gen1recomp-0.1.70-switch.zip\","
"\"label\":\"\","
"\"uploader\":{"
"\"login\":\"github-actions[bot]\","
"\"id\":41898282,"
"\"node_id\":\"MDM6Qm90NDE4OTgyODI=\","
"\"avatar_url\":\"https://avatars.githubusercontent.com/in/15368?v=4\","
"\"gravatar_id\":\"\","
"\"url\":\"https://api.github.com/users/github-actions%5Bbot%5D\","
"\"html_url\":\"https://github.com/apps/github-actions\","
"\"followers_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/followers\","
"\"following_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}\","
"\"gists_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}\","
"\"starred_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}\","
"\"subscriptions_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions\","
"\"organizations_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/orgs\","
"\"repos_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/repos\","
"\"events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}\","
"\"received_events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/received_events\","
"\"type\":\"Bot\","
"\"user_view_type\":\"public\","
"\"site_admin\":false"
"},"
"\"content_type\":\"application/zip\","
"\"state\":\"uploaded\","
"\"size\":9000573,"
"\"browser_download_url\":\"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip\""
"}"
"]"
"}";
ota_release_t gh;
expect(ota_parse_release(github_json, &gh) == 1, "parse github-shaped release");
expect(strcmp(gh.version, "0.1.70") == 0, "github release version");
expect(strcmp(gh.asset_name, "gen1recomp-0.1.70-switch.zip") == 0, "github release asset");
expect(strcmp(gh.download_url,
"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip") ==
0,
"github release download url");
ota_decide_update("0.1.69", &gh, &d);
expect(strcmp(d.status, "available") == 0, "github release decide 0.1.69->0.1.70");
const char *sums = "abc123 gen1recomp-1.5.0-switch.zip\n";
ota_verify_t v;
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", sums, &v);
+49 -12
View File
@@ -95,6 +95,38 @@ static const char *find_json_string(const char *json, const char *key, char *out
return out;
}
/* Opening { of the JSON object that contains pos (walk backward). */
static const char *find_json_object_start(const char *pos, const char *json_start) {
if (!pos || !json_start || pos < json_start) return NULL;
int depth = 0;
const char *p = pos;
while (p >= json_start) {
if (*p == '}') depth++;
else if (*p == '{') {
if (depth == 0) return p;
depth--;
}
p--;
}
return NULL;
}
/* Pointer just past the closing } of the object that starts at object_start. */
static const char *find_json_object_end(const char *object_start) {
if (!object_start || *object_start != '{') return NULL;
int depth = 1;
const char *p = object_start + 1;
while (*p) {
if (*p == '{') depth++;
else if (*p == '}') {
depth--;
if (depth == 0) return p + 1;
}
p++;
}
return NULL;
}
int ota_parse_release(const char *json_text, ota_release_t *out) {
memset(out, 0, sizeof(*out));
if (!json_text || !*json_text) {
@@ -112,7 +144,7 @@ int ota_parse_release(const char *json_text, ota_release_t *out) {
}
snprintf(out->version, sizeof(out->version), "%d.%d.%d", sv.major, sv.minor, sv.patch);
/* Scan for OTA asset name then its browser_download_url in the same object-ish window. */
/* Scan for OTA asset name then browser_download_url inside the same asset object. */
const char *cursor = json_text;
while ((cursor = strstr(cursor, "\"name\"")) != NULL) {
char name[128];
@@ -120,16 +152,22 @@ int ota_parse_release(const char *json_text, ota_release_t *out) {
cursor += 6;
continue;
}
if (ota_is_ota_asset_name(name)) {
snprintf(out->asset_name, sizeof(out->asset_name), "%s", name);
/* Prefer URL after this name key within a limited window. */
const char *window_end = cursor + 800;
if (window_end > json_text + strlen(json_text)) window_end = json_text + strlen(json_text);
if (!ota_is_ota_asset_name(name)) {
cursor += 6;
continue;
}
const char *asset_start = find_json_object_start(cursor, json_text);
const char *asset_end =
asset_start ? find_json_object_end(asset_start) : NULL;
if (!asset_start || !asset_end || asset_end <= cursor) {
cursor += 6;
continue;
}
char url[512];
const char *u = NULL;
const char *scan = cursor;
while (scan < window_end && (scan = strstr(scan, "\"browser_download_url\"")) != NULL &&
scan < window_end) {
while (scan < asset_end &&
(scan = strstr(scan, "\"browser_download_url\"")) != NULL && scan < asset_end) {
if (find_json_string(scan, "browser_download_url", url, sizeof(url))) {
u = url;
break;
@@ -137,15 +175,14 @@ int ota_parse_release(const char *json_text, ota_release_t *out) {
scan += 21;
}
if (!u || !*u) {
set_reason(out->reason, sizeof(out->reason), "missing_download_url");
return 0;
cursor += 6;
continue;
}
snprintf(out->asset_name, sizeof(out->asset_name), "%s", name);
snprintf(out->download_url, sizeof(out->download_url), "%s", u);
out->ok = 1;
return 1;
}
cursor += 6;
}
set_reason(out->reason, sizeof(out->reason), "missing_ota_asset");
return 0;
}
+56 -13
View File
@@ -47,7 +47,43 @@ function SwitchOta.versionFromOtaAsset(name)
return name:match(SwitchOta.OTA_ASSET_PATTERN)
end
-- Parse GitHub releases/latest JSON (minimal: tag_name + assets[].name/browser_download_url).
local function findJsonObjectStart(jsonText, pos)
if type(jsonText) ~= "string" or not pos or pos < 1 then return nil end
local depth = 0
local p = pos
while p >= 1 do
local c = jsonText:sub(p, p)
if c == "}" then
depth = depth + 1
elseif c == "{" then
if depth == 0 then return p end
depth = depth - 1
end
p = p - 1
end
return nil
end
local function findJsonObjectEnd(jsonText, objectStart)
if type(jsonText) ~= "string" or not objectStart then return nil end
if jsonText:sub(objectStart, objectStart) ~= "{" then return nil end
local depth = 1
local p = objectStart + 1
local len = #jsonText
while p <= len do
local c = jsonText:sub(p, p)
if c == "{" then
depth = depth + 1
elseif c == "}" then
depth = depth - 1
if depth == 0 then return p + 1 end
end
p = p + 1
end
return nil
end
-- Parse GitHub releases/latest JSON (tag_name + assets[].name/browser_download_url).
-- Returns { tag, version, assetName, downloadUrl } or nil + reason.
function SwitchOta.parseRelease(jsonText)
if type(jsonText) ~= "string" or jsonText == "" then
@@ -58,25 +94,32 @@ function SwitchOta.parseRelease(jsonText)
local version = tag:match("^v?(%d+%.%d+%.%d+)$")
if not version then return nil, "bad_tag" end
local assetName, downloadUrl
for block in jsonText:gmatch("%b{}") do
local name = block:match('"name"%s*:%s*"(.-)"')
local cursor = 1
while true do
local nameKeyPos = jsonText:find('"name"', cursor, true)
if not nameKeyPos then break end
local tail = jsonText:sub(nameKeyPos)
local name = tail:match('"name"%s*:%s*"(.-)"')
if name and SwitchOta.isOtaAssetName(name) then
assetName = name
downloadUrl = block:match('"browser_download_url"%s*:%s*"(.-)"')
break
end
end
if not assetName then return nil, "missing_ota_asset" end
if not downloadUrl or downloadUrl == "" then return nil, "missing_download_url" end
local assetStart = findJsonObjectStart(jsonText, nameKeyPos)
local assetEnd = assetStart and findJsonObjectEnd(jsonText, assetStart)
if assetStart and assetEnd and assetEnd > nameKeyPos then
local assetBlock = jsonText:sub(assetStart, assetEnd - 1)
local downloadUrl = assetBlock:match('"browser_download_url"%s*:%s*"(.-)"')
if downloadUrl and downloadUrl ~= "" then
return {
tag = tag,
version = version,
assetName = assetName,
assetName = name,
downloadUrl = downloadUrl,
}
end
end
end
cursor = nameKeyPos + 6
end
return nil, "missing_ota_asset"
end
-- Decide check outcome given installed version and parsed release.
-- Returns status: uptodate | available | error
+114 -15
View File
@@ -50,29 +50,73 @@ function createSwitchOta() {
return typeof name === 'string' && OTA_RE.test(name);
}
function findJsonObjectStart(jsonText, pos) {
if (!jsonText || pos < 1) return null;
let depth = 0;
for (let p = pos; p >= 1; p--) {
const c = jsonText[p - 1];
if (c === '}') depth += 1;
else if (c === '{') {
if (depth === 0) return p;
depth -= 1;
}
}
return null;
}
function findJsonObjectEnd(jsonText, objectStart) {
if (!jsonText || objectStart < 1) return null;
if (jsonText[objectStart - 1] !== '{') return null;
let depth = 1;
for (let p = objectStart + 1; p <= jsonText.length; p++) {
const c = jsonText[p - 1];
if (c === '{') depth += 1;
else if (c === '}') {
depth -= 1;
if (depth === 0) return p + 1;
}
}
return null;
}
function parseRelease(jsonText) {
if (!jsonText) return { ok: false, reason: 'empty_json' };
let obj;
try {
obj = JSON.parse(jsonText);
} catch {
return { ok: false, reason: 'bad_json' };
}
const tag = obj.tag_name;
if (!tag) return { ok: false, reason: 'missing_tag' };
const version = String(tag).match(/^v?(\d+\.\d+\.\d+)$/)?.[1];
if (!version) return { ok: false, reason: 'bad_tag' };
const asset = (obj.assets || []).find((a) => isOtaAssetName(a.name));
if (!asset) return { ok: false, reason: 'missing_ota_asset' };
if (!asset.browser_download_url) return { ok: false, reason: 'missing_download_url' };
const tagM = jsonText.match(/"tag_name"\s*:\s*"([^"]+)"/);
if (!tagM) return { ok: false, reason: 'missing_tag' };
const tag = tagM[1];
const versionM = tag.match(/^v?(\d+\.\d+\.\d+)$/);
if (!versionM) return { ok: false, reason: 'bad_tag' };
const version = versionM[1];
let cursor = 0;
while (true) {
const nameKeyPos = jsonText.indexOf('"name"', cursor);
if (nameKeyPos === -1) break;
const tail = jsonText.slice(nameKeyPos);
const nameM = tail.match(/"name"\s*:\s*"([^"]+)"/);
const name = nameM?.[1];
if (name && isOtaAssetName(name)) {
const assetStart = findJsonObjectStart(jsonText, nameKeyPos + 1);
const assetEnd = assetStart ? findJsonObjectEnd(jsonText, assetStart) : null;
if (assetStart && assetEnd && assetEnd > nameKeyPos + 1) {
const assetBlock = jsonText.slice(assetStart - 1, assetEnd - 1);
const urlM = assetBlock.match(/"browser_download_url"\s*:\s*"([^"]+)"/);
const downloadUrl = urlM?.[1];
if (downloadUrl) {
return {
ok: true,
tag,
version,
assetName: asset.name,
downloadUrl: asset.browser_download_url,
assetName: name,
downloadUrl,
};
}
}
}
cursor = nameKeyPos + 6;
}
return { ok: false, reason: 'missing_ota_asset' };
}
function decideUpdate(installed, release) {
if (!parseSemver(installed)) return { status: 'error', reason: 'bad_installed_version' };
@@ -168,6 +212,51 @@ function createSwitchOta() {
const M = createSwitchOta();
const GITHUB_UPLOADER =
'"login":"github-actions[bot]",' +
'"id":41898282,' +
'"node_id":"MDM6Qm90NDE4OTgyODI=",' +
'"avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4",' +
'"gravatar_id":"",' +
'"url":"https://api.github.com/users/github-actions%5Bbot%5D",' +
'"html_url":"https://github.com/apps/github-actions",' +
'"followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers",' +
'"following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}",' +
'"gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}",' +
'"starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}",' +
'"subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions",' +
'"organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs",' +
'"repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos",' +
'"events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}",' +
'"received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events",' +
'"type":"Bot",' +
'"user_view_type":"public",' +
'"site_admin":false';
function buildGithubReleaseJson() {
return (
'{' +
'"tag_name":"v0.1.70",' +
'"name":"0.1.70",' +
'"assets":[' +
'{' +
'"url":"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880",' +
'"id":502823880,' +
'"name":"gen1recomp-0.1.70-switch.zip",' +
'"label":"",' +
'"uploader":{' +
GITHUB_UPLOADER +
'},' +
'"content_type":"application/zip",' +
'"state":"uploaded",' +
'"size":9000573,' +
'"browser_download_url":"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip"' +
'}' +
']' +
'}'
);
}
const sampleRelease = M.parseRelease(
JSON.stringify({
tag_name: 'v1.5.0',
@@ -197,6 +286,16 @@ test('AC-001: Launcher nativo verifica release no GitHub @spec:AC-001', () => {
const missing = M.parseRelease(JSON.stringify({ tag_name: 'v1.5.0', assets: [] }));
assert.equal(missing.ok, false);
assert.equal(missing.reason, 'missing_ota_asset');
const githubRelease = M.parseRelease(buildGithubReleaseJson());
assert.equal(githubRelease.ok, true);
assert.equal(githubRelease.version, '0.1.70');
assert.equal(githubRelease.assetName, 'gen1recomp-0.1.70-switch.zip');
assert.equal(
githubRelease.downloadUrl,
'https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip'
);
assert.equal(M.decideUpdate('0.1.69', githubRelease).status, 'available');
});
test('AC-002: Download com verificação SHA-256 @spec:AC-002', () => {