Compare commits
No commits in common. "8e371dd8278dab56cf59085322282f372ab6e2f9" and "170ef52154f7a923c39e2bf4ce96673e40dfc65c" have entirely different histories.
8e371dd827
...
170ef52154
5 changed files with 32 additions and 108 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -56,8 +56,6 @@ import {
|
|||
UPDATE_REPO,
|
||||
buildInstallerScript,
|
||||
isTrustedDownloadUrl,
|
||||
looksTruncated,
|
||||
verifyArchiveGzip,
|
||||
isNewer,
|
||||
parseRelease,
|
||||
pickAsset,
|
||||
|
|
@ -1118,28 +1116,17 @@ 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 { 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++) {
|
||||
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;
|
||||
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");
|
||||
let received = 0;
|
||||
const source = Readable.fromWeb(res.body as never);
|
||||
source.on("data", (chunk: Buffer) => {
|
||||
|
|
@ -1147,24 +1134,24 @@ handleIpc("folio:performUpdate", async () => {
|
|||
getWindow()?.webContents.send("folio:update-progress", { received, total });
|
||||
});
|
||||
await pipeline(source, createWriteStream(tarball));
|
||||
if (looksTruncated(received, total)) {
|
||||
lastError = `Download truncated (${received}/${total} bytes).`;
|
||||
continue;
|
||||
// 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 buf = Buffer.alloc(2);
|
||||
const n = fs.readSync(fd, buf, 0, 2, 0);
|
||||
head = buf.subarray(0, n);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
if (!(await verifyArchiveGzip(tarball))) {
|
||||
lastError = "Downloaded archive is corrupt (gzip stream incomplete).";
|
||||
continue;
|
||||
} catch {
|
||||
head = Buffer.alloc(0);
|
||||
}
|
||||
downloaded = true;
|
||||
} catch (e) {
|
||||
lastError = `Download failed: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
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"),
|
||||
|
|
|
|||
|
|
@ -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<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, `'\\''`)}'`;
|
||||
|
|
|
|||
|
|
@ -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, "<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();
|
||||
if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); }
|
||||
console.log("All installer checks passed.");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue