diff --git a/package-lock.json b/package-lock.json index 14e63dd..98e2c90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index ac9f264..ca9dca2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.3", + "version": "0.1.4", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", diff --git a/src/main/index.ts b/src/main/index.ts index cabf016..8207f6c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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"), diff --git a/src/main/update.ts b/src/main/update.ts index 0ea6423..1382ab8 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -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 { + 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, `'\\''`)}'`; diff --git a/tests/run-update-installer-test.mjs b/tests/run-update-installer-test.mjs index c53d984..0f3b771 100644 --- a/tests/run-update-installer-test.mjs +++ b/tests/run-update-installer-test.mjs @@ -118,6 +118,32 @@ function writeScript(box, spec) { 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, "error page"); + 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(); if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); } console.log("All installer checks passed.");