fix(updater): verify download integrity (retry x3, byte-count, full gunzip) — truncated updates now error instead of silently no-oping

This commit is contained in:
avi 2026-10-01 14:52:42 -05:00
commit d052c3abc9
3 changed files with 105 additions and 29 deletions

View file

@ -56,6 +56,8 @@ import {
UPDATE_REPO,
buildInstallerScript,
isTrustedDownloadUrl,
looksTruncated,
verifyArchiveGzip,
isNewer,
parseRelease,
pickAsset,
@ -1116,42 +1118,53 @@ handleIpc("folio:performUpdate", async () => {
app.getPath("temp"),
`folio-update-${Date.now()}.tar.gz`
);
const res = await fetch(asset.browser_download_url, {
redirect: "follow",
signal: AbortSignal.timeout(10 * 60 * 1000),
});
if (!res.ok || !res.body) {
return { error: `Download failed (HTTP ${res.status}).` };
}
const total = Number(res.headers.get("content-length")) || asset.size || 0;
// Downloads of the ~100MB asset have twice truncated near the end while
// the server copy stayed intact. The old 2-byte magic check could not
// see that, so the detached installer failed after the app had quit and
// the update silently no-oped. Now: retry the download up to 3 times,
// require the advertised byte count, and gunzip the whole file before
// handing off. A corrupt file is deleted and the user gets a real error.
const { Readable } = await import("stream");
const { createWriteStream } = await import("fs");
const { pipeline } = await import("stream/promises");
let received = 0;
const source = Readable.fromWeb(res.body as never);
source.on("data", (chunk: Buffer) => {
received += chunk.length;
getWindow()?.webContents.send("folio:update-progress", { received, total });
});
await pipeline(source, createWriteStream(tarball));
// Verify the gzip magic (2 bytes) without loading a ~100MB tarball into
// memory. Anything else means a truncated download or an HTML error page.
let head = Buffer.alloc(0);
try {
const fd = fs.openSync(tarball, "r");
const total = Number(asset.size) || 0;
let lastError = "";
let downloaded = false;
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
try {
const buf = Buffer.alloc(2);
const n = fs.readSync(fd, buf, 0, 2, 0);
head = buf.subarray(0, n);
} finally {
fs.closeSync(fd);
const res = await fetch(asset.browser_download_url, {
redirect: "follow",
signal: AbortSignal.timeout(10 * 60 * 1000),
});
if (!res.ok || !res.body) {
lastError = `Download failed (HTTP ${res.status}).`;
continue;
}
let received = 0;
const source = Readable.fromWeb(res.body as never);
source.on("data", (chunk: Buffer) => {
received += chunk.length;
getWindow()?.webContents.send("folio:update-progress", { received, total });
});
await pipeline(source, createWriteStream(tarball));
if (looksTruncated(received, total)) {
lastError = `Download truncated (${received}/${total} bytes).`;
continue;
}
if (!(await verifyArchiveGzip(tarball))) {
lastError = "Downloaded archive is corrupt (gzip stream incomplete).";
continue;
}
downloaded = true;
} catch (e) {
lastError = `Download failed: ${(e as Error).message}`;
}
} catch {
head = Buffer.alloc(0);
}
if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) {
if (!downloaded) {
fs.rmSync(tarball, { force: true });
return { error: "Downloaded file is not a valid archive; update aborted." };
return {
error: `${lastError || "Download failed."} The update was aborted; your current version is unchanged.`,
};
}
const script = path.join(
app.getPath("temp"),

View file

@ -116,6 +116,43 @@ export function isTrustedDownloadUrl(url: string, base: string): boolean {
}
}
// Archive-integrity checks for the updater, as pure functions so they are
// unit-testable without network or an app instance.
//
// A gzip stream is only provably complete when the decompressor reaches its
// end without error: a download truncated near the end still carries the
// 1f 8b magic, so magic-byte checks alone let corrupt updates through (this
// bit us twice: 0.1.2 and 0.1.3 both downloaded truncated and the detached
// installer silently kept the old app). verifyArchiveGzip streams the file
// through a real gunzip and requires the stream to finish cleanly.
import { createReadStream } from "fs";
import { createGunzip } from "zlib";
export function verifyArchiveGzip(file: string): Promise<boolean> {
return new Promise((resolve) => {
const rs = createReadStream(file);
const gz = createGunzip();
let settled = false;
const done = (v: boolean) => {
if (!settled) {
settled = true;
resolve(v);
}
};
rs.on("error", () => done(false));
gz.on("error", () => done(false));
gz.on("end", () => done(true));
// Discard output; we only care whether the stream completes.
gz.resume();
rs.pipe(gz);
});
}
// True when a byte-count is known and the download fell short of it.
export function looksTruncated(received: number, total: number): boolean {
return total > 0 && received < total;
}
// Single-quote a path for safe interpolation into /bin/sh scripts.
export function shQuote(p: string): string {
return `'${String(p).replace(/'/g, `'\\''`)}'`;