diff --git a/package-lock.json b/package-lock.json index 98e2c90..14e63dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.4", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.4", + "version": "0.1.3", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index ca9dca2..ac9f264 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.4", + "version": "0.1.3", "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 8207f6c..cabf016 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -56,8 +56,6 @@ import { UPDATE_REPO, buildInstallerScript, isTrustedDownloadUrl, - looksTruncated, - verifyArchiveGzip, isNewer, parseRelease, pickAsset, @@ -1118,53 +1116,42 @@ handleIpc("folio:performUpdate", async () => { app.getPath("temp"), `folio-update-${Date.now()}.tar.gz` ); - // 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 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; const { Readable } = await import("stream"); const { createWriteStream } = await import("fs"); const { pipeline } = await import("stream/promises"); - const total = Number(asset.size) || 0; - let lastError = ""; - let downloaded = false; - for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) { + 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"); try { - 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}`; + const buf = Buffer.alloc(2); + const n = fs.readSync(fd, buf, 0, 2, 0); + head = buf.subarray(0, n); + } finally { + fs.closeSync(fd); } + } catch { + head = Buffer.alloc(0); } - if (!downloaded) { + if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) { fs.rmSync(tarball, { force: true }); - return { - error: `${lastError || "Download failed."} The update was aborted; your current version is unchanged.`, - }; + return { error: "Downloaded file is not a valid archive; update aborted." }; } const script = path.join( app.getPath("temp"), diff --git a/src/main/update.ts b/src/main/update.ts index 1382ab8..0ea6423 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -116,43 +116,6 @@ 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 0f3b771..c53d984 100644 --- a/tests/run-update-installer-test.mjs +++ b/tests/run-update-installer-test.mjs @@ -118,32 +118,6 @@ 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.");