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:
parent
170ef52154
commit
d052c3abc9
3 changed files with 105 additions and 29 deletions
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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, `'\\''`)}'`;
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue