From 1bfc89fdd4b774d619579f966cdc0697e3300a29 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Wed, 5 Aug 2026 15:15:44 -0300 Subject: [PATCH] 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. --- .../host/test_ota_protocol.c | 50 +++++++ native/switch-ota-launcher/src/ota_protocol.c | 87 +++++++---- src/update/SwitchOta.lua | 75 ++++++++-- test/switch-nro-ota.spec.test.js | 137 +++++++++++++++--- 4 files changed, 289 insertions(+), 60 deletions(-) diff --git a/native/switch-ota-launcher/host/test_ota_protocol.c b/native/switch-ota-launcher/host/test_ota_protocol.c index 9f20fe15..2e432f2a 100644 --- a/native/switch-ota-launcher/host/test_ota_protocol.c +++ b/native/switch-ota-launcher/host/test_ota_protocol.c @@ -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); diff --git a/native/switch-ota-launcher/src/ota_protocol.c b/native/switch-ota-launcher/src/ota_protocol.c index 4e943ec8..ba41b121 100644 --- a/native/switch-ota-launcher/src/ota_protocol.c +++ b/native/switch-ota-launcher/src/ota_protocol.c @@ -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,31 +152,36 @@ 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); - 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) { - if (find_json_string(scan, "browser_download_url", url, sizeof(url))) { - u = url; - break; - } - scan += 21; - } - if (!u || !*u) { - set_reason(out->reason, sizeof(out->reason), "missing_download_url"); - return 0; - } - snprintf(out->download_url, sizeof(out->download_url), "%s", u); - out->ok = 1; - return 1; + if (!ota_is_ota_asset_name(name)) { + cursor += 6; + continue; } - cursor += 6; + 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 < 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; + } + scan += 21; + } + if (!u || !*u) { + 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; } set_reason(out->reason, sizeof(out->reason), "missing_ota_asset"); return 0; diff --git a/src/update/SwitchOta.lua b/src/update/SwitchOta.lua index ba87dd35..db03b22a 100644 --- a/src/update/SwitchOta.lua +++ b/src/update/SwitchOta.lua @@ -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,24 +94,31 @@ 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 + 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 = name, + downloadUrl = downloadUrl, + } + end + end end + cursor = nameKeyPos + 6 end - if not assetName then return nil, "missing_ota_asset" end - if not downloadUrl or downloadUrl == "" then return nil, "missing_download_url" end - - return { - tag = tag, - version = version, - assetName = assetName, - downloadUrl = downloadUrl, - } + return nil, "missing_ota_asset" end -- Decide check outcome given installed version and parsed release. diff --git a/test/switch-nro-ota.spec.test.js b/test/switch-nro-ota.spec.test.js index b9f28e7d..5103bd22 100644 --- a/test/switch-nro-ota.spec.test.js +++ b/test/switch-nro-ota.spec.test.js @@ -50,28 +50,72 @@ 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 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: name, + downloadUrl, + }; + } + } + } + cursor = nameKeyPos + 6; } - 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' }; - return { - ok: true, - tag, - version, - assetName: asset.name, - downloadUrl: asset.browser_download_url, - }; + return { ok: false, reason: 'missing_ota_asset' }; } function decideUpdate(installed, release) { @@ -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', () => {