Harden updater candidate fallback on Codeberg 404s

This commit is contained in:
Sucukdeluxe 2026-03-01 16:19:35 +01:00
parent 73cd2ea6b9
commit 20c32d39c8
2 changed files with 131 additions and 34 deletions

View File

@ -361,19 +361,67 @@ function uniqueStrings(values: string[]): string[] {
return out; return out;
} }
function deriveTagFromReleaseUrl(releaseUrl: string): string {
const raw = String(releaseUrl || "").trim();
if (!raw) {
return "";
}
try {
const parsed = new URL(raw);
const match = parsed.pathname.match(/\/releases\/tag\/([^/?#]+)/i);
return match?.[1] ? decodeURIComponent(match[1]) : "";
} catch {
return "";
}
}
function extractFileNameFromUrl(url: string): string {
const raw = String(url || "").trim();
if (!raw) {
return "";
}
try {
const parsed = new URL(raw);
const fileName = path.basename(parsed.pathname || "");
return fileName ? decodeURIComponent(fileName) : "";
} catch {
return "";
}
}
function deriveSetupNameVariants(setupAssetName: string, setupAssetUrl: string): string[] {
const directName = String(setupAssetName || "").trim();
const fromUrlName = extractFileNameFromUrl(setupAssetUrl);
const source = directName || fromUrlName;
if (!source) {
return [];
}
const ext = path.extname(source);
const stem = ext ? source.slice(0, -ext.length) : source;
const dashed = `${stem.replace(/\s+/g, "-")}${ext}`;
return uniqueStrings([source, fromUrlName, dashed]);
}
function buildDownloadCandidates(safeRepo: string, check: UpdateCheckResult): string[] { function buildDownloadCandidates(safeRepo: string, check: UpdateCheckResult): string[] {
const setupAssetName = String(check.setupAssetName || "").trim(); const setupAssetName = String(check.setupAssetName || "").trim();
const setupAssetUrl = String(check.setupAssetUrl || "").trim(); const setupAssetUrl = String(check.setupAssetUrl || "").trim();
const latestTag = String(check.latestTag || "").trim(); const latestTag = String(check.latestTag || "").trim() || deriveTagFromReleaseUrl(String(check.releaseUrl || ""));
const candidates = [setupAssetUrl]; const candidates = [setupAssetUrl];
if (setupAssetName) { const nameVariants = deriveSetupNameVariants(setupAssetName, setupAssetUrl);
const encodedName = encodeURIComponent(setupAssetName); if (latestTag && nameVariants.length > 0) {
candidates.push(`${UPDATE_WEB_BASE}/${safeRepo}/releases/latest/download/${encodedName}`); for (const name of nameVariants) {
if (latestTag) { const encodedName = encodeURIComponent(name);
candidates.push(`${UPDATE_WEB_BASE}/${safeRepo}/releases/download/${encodeURIComponent(latestTag)}/${encodedName}`); candidates.push(`${UPDATE_WEB_BASE}/${safeRepo}/releases/download/${encodeURIComponent(latestTag)}/${encodedName}`);
} }
} }
if (!latestTag && nameVariants.length > 0) {
for (const name of nameVariants) {
const encodedName = encodeURIComponent(name);
candidates.push(`${UPDATE_WEB_BASE}/${safeRepo}/releases/latest/download/${encodedName}`);
}
}
return uniqueStrings(candidates); return uniqueStrings(candidates);
} }
@ -922,7 +970,7 @@ export async function installLatestUpdate(
} }
} }
const candidates = buildDownloadCandidates(safeRepo, effectiveCheck); let candidates = buildDownloadCandidates(safeRepo, effectiveCheck);
if (candidates.length === 0) { if (candidates.length === 0) {
return { started: false, message: "Setup-Asset nicht gefunden" }; return { started: false, message: "Setup-Asset nicht gefunden" };
} }
@ -943,6 +991,10 @@ export async function installLatestUpdate(
} }
let verified = false; let verified = false;
let lastVerifyError: unknown = null; let lastVerifyError: unknown = null;
let integrityError: unknown = null;
for (let pass = 0; pass < 2 && !verified; pass += 1) {
logger.info(`Update-Download Kandidaten (${pass + 1}/2): ${candidates.join(" | ")}`);
lastVerifyError = null;
for (let index = 0; index < candidates.length; index += 1) { for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index]; const candidate = candidates[index];
try { try {
@ -962,19 +1014,64 @@ export async function installLatestUpdate(
break; break;
} catch (error) { } catch (error) {
lastVerifyError = error; lastVerifyError = error;
const errorText = compactErrorText(error).toLowerCase();
if (!integrityError && (errorText.includes("integrit") || errorText.includes("mismatch"))) {
integrityError = error;
}
try { try {
await fs.promises.rm(targetPath, { force: true }); await fs.promises.rm(targetPath, { force: true });
} catch { } catch {
// ignore // ignore
} }
if (index >= candidates.length - 1) { if (index < candidates.length - 1) {
throw error;
}
logger.warn(`Update-Kandidat ${index + 1}/${candidates.length} verworfen: ${compactErrorText(error)}`); logger.warn(`Update-Kandidat ${index + 1}/${candidates.length} verworfen: ${compactErrorText(error)}`);
} }
} }
}
if (verified) {
break;
}
const status = readHttpStatusFromError(lastVerifyError);
let shouldRetryAfterRefresh = false;
if (pass === 0 && status === 404) {
const refreshed = await resolveSetupAssetFromApi(safeRepo, effectiveCheck.latestTag);
if (refreshed) {
effectiveCheck = {
...effectiveCheck,
setupAssetUrl: refreshed.setupAssetUrl || effectiveCheck.setupAssetUrl,
setupAssetName: refreshed.setupAssetName || effectiveCheck.setupAssetName,
setupAssetDigest: refreshed.setupAssetDigest || effectiveCheck.setupAssetDigest
};
}
if (!effectiveCheck.setupAssetDigest && effectiveCheck.setupAssetUrl) {
const digestFromYml = await resolveSetupDigestFromLatestYml(safeRepo, effectiveCheck.latestTag, effectiveCheck.setupAssetName || "");
if (digestFromYml) {
effectiveCheck = {
...effectiveCheck,
setupAssetDigest: digestFromYml
};
logger.info("Update-Integritätsdigest aus latest.yml übernommen");
}
}
const refreshedCandidates = buildDownloadCandidates(safeRepo, effectiveCheck);
const changed = refreshedCandidates.length > 0
&& (refreshedCandidates.length !== candidates.length
|| refreshedCandidates.some((value, idx) => value !== candidates[idx]));
if (changed) {
logger.warn("Update-404 erkannt, Kandidatenliste aus API neu geladen");
candidates = refreshedCandidates;
shouldRetryAfterRefresh = true;
}
}
if (!shouldRetryAfterRefresh) {
break;
}
}
if (!verified) { if (!verified) {
throw lastVerifyError || new Error("Update-Download fehlgeschlagen"); throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen");
} }
safeEmitProgress(onProgress, { safeEmitProgress(onProgress, {
stage: "launching", stage: "launching",

View File

@ -95,7 +95,7 @@ describe("update", () => {
if (url.includes("stale-setup.exe")) { if (url.includes("stale-setup.exe")) {
return new Response("missing", { status: 404 }); return new Response("missing", { status: 404 });
} }
if (url.includes("/releases/latest/download/")) { if (url.includes("/releases/download/v9.9.9/")) {
return new Response(executablePayload, { return new Response(executablePayload, {
status: 200, status: 200,
headers: { "Content-Type": "application/octet-stream" } headers: { "Content-Type": "application/octet-stream" }
@ -117,7 +117,7 @@ describe("update", () => {
const result = await installLatestUpdate("owner/repo", prechecked); const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(true); expect(result.started).toBe(true);
expect(requestedUrls.some((url) => url.includes("/releases/latest/download/"))).toBe(true); expect(requestedUrls.some((url) => url.includes("/releases/download/v9.9.9/"))).toBe(true);
expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1); expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1);
}); });