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, UPDATE_REPO,
buildInstallerScript, buildInstallerScript,
isTrustedDownloadUrl, isTrustedDownloadUrl,
looksTruncated,
verifyArchiveGzip,
isNewer, isNewer,
parseRelease, parseRelease,
pickAsset, pickAsset,
@ -1116,42 +1118,53 @@ handleIpc("folio:performUpdate", async () => {
app.getPath("temp"), app.getPath("temp"),
`folio-update-${Date.now()}.tar.gz` `folio-update-${Date.now()}.tar.gz`
); );
const res = await fetch(asset.browser_download_url, { // Downloads of the ~100MB asset have twice truncated near the end while
redirect: "follow", // the server copy stayed intact. The old 2-byte magic check could not
signal: AbortSignal.timeout(10 * 60 * 1000), // 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,
if (!res.ok || !res.body) { // require the advertised byte count, and gunzip the whole file before
return { error: `Download failed (HTTP ${res.status}).` }; // handing off. A corrupt file is deleted and the user gets a real error.
}
const total = Number(res.headers.get("content-length")) || asset.size || 0;
const { Readable } = await import("stream"); const { Readable } = await import("stream");
const { createWriteStream } = await import("fs"); const { createWriteStream } = await import("fs");
const { pipeline } = await import("stream/promises"); const { pipeline } = await import("stream/promises");
let received = 0; const total = Number(asset.size) || 0;
const source = Readable.fromWeb(res.body as never); let lastError = "";
source.on("data", (chunk: Buffer) => { let downloaded = false;
received += chunk.length; for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
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");
try { try {
const buf = Buffer.alloc(2); const res = await fetch(asset.browser_download_url, {
const n = fs.readSync(fd, buf, 0, 2, 0); redirect: "follow",
head = buf.subarray(0, n); signal: AbortSignal.timeout(10 * 60 * 1000),
} finally { });
fs.closeSync(fd); 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 }); 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( const script = path.join(
app.getPath("temp"), 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. // Single-quote a path for safe interpolation into /bin/sh scripts.
export function shQuote(p: string): string { export function shQuote(p: string): string {
return `'${String(p).replace(/'/g, `'\\''`)}'`; return `'${String(p).replace(/'/g, `'\\''`)}'`;

View file

@ -118,6 +118,32 @@ function writeScript(box, spec) {
fs.rmSync(box, { recursive: true, force: true }); fs.rmSync(box, { recursive: true, force: true });
} }
// --- archive integrity: verifyArchiveGzip + looksTruncated ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-gz-"));
const good = path.join(box, "good.tar.gz");
const zlib = await import("zlib");
fs.writeFileSync(good, zlib.gzipSync(Buffer.from("hello folio updater ".repeat(1000))));
ok(await u.verifyArchiveGzip(good), "complete gzip verifies");
// Truncate the good archive mid-stream: magic bytes present, payload short.
const full = fs.readFileSync(good);
const trunc = path.join(box, "trunc.tar.gz");
fs.writeFileSync(trunc, full.subarray(0, full.length - 50));
ok(!(await u.verifyArchiveGzip(trunc)), "truncated gzip REJECTED (the 0.1.2/0.1.3 bug)");
const notgz = path.join(box, "notgz.tar.gz");
fs.writeFileSync(notgz, "<html>error page</html>");
ok(!(await u.verifyArchiveGzip(notgz)), "non-gzip content rejected");
ok(!(await u.verifyArchiveGzip(path.join(box, "missing.tar.gz"))), "missing file rejected");
ok(u.looksTruncated(50, 100), "short byte count flagged");
ok(!u.looksTruncated(100, 100), "exact byte count passes");
ok(!u.looksTruncated(120, 100), "over-long passes");
ok(!u.looksTruncated(7, 0), "unknown total not flagged");
fs.rmSync(box, { recursive: true, force: true });
}
cleanupBundle(); cleanupBundle();
if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); } if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); }
console.log("All installer checks passed."); console.log("All installer checks passed.");