mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+59
-16
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user