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",
|
"name": "folio",
|
||||||
"version": "0.1.4",
|
"version": "0.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.4",
|
"version": "0.1.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/core": "^2.27.2",
|
"@tiptap/core": "^2.27.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.4",
|
"version": "0.1.3",
|
||||||
"description": "A simple, local, open-source desktop writing app.",
|
"description": "A simple, local, open-source desktop writing app.",
|
||||||
"productName": "Folio",
|
"productName": "Folio",
|
||||||
"author": "Folio Contributors",
|
"author": "Folio Contributors",
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,6 @@ import {
|
||||||
UPDATE_REPO,
|
UPDATE_REPO,
|
||||||
buildInstallerScript,
|
buildInstallerScript,
|
||||||
isTrustedDownloadUrl,
|
isTrustedDownloadUrl,
|
||||||
looksTruncated,
|
|
||||||
verifyArchiveGzip,
|
|
||||||
isNewer,
|
isNewer,
|
||||||
parseRelease,
|
parseRelease,
|
||||||
pickAsset,
|
pickAsset,
|
||||||
|
|
@ -1118,53 +1116,42 @@ handleIpc("folio:performUpdate", async () => {
|
||||||
app.getPath("temp"),
|
app.getPath("temp"),
|
||||||
`folio-update-${Date.now()}.tar.gz`
|
`folio-update-${Date.now()}.tar.gz`
|
||||||
);
|
);
|
||||||
// Downloads of the ~100MB asset have twice truncated near the end while
|
const res = await fetch(asset.browser_download_url, {
|
||||||
// the server copy stayed intact. The old 2-byte magic check could not
|
redirect: "follow",
|
||||||
// see that, so the detached installer failed after the app had quit and
|
signal: AbortSignal.timeout(10 * 60 * 1000),
|
||||||
// the update silently no-oped. Now: retry the download up to 3 times,
|
});
|
||||||
// require the advertised byte count, and gunzip the whole file before
|
if (!res.ok || !res.body) {
|
||||||
// handing off. A corrupt file is deleted and the user gets a real error.
|
return { error: `Download failed (HTTP ${res.status}).` };
|
||||||
|
}
|
||||||
|
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");
|
||||||
const total = Number(asset.size) || 0;
|
let received = 0;
|
||||||
let lastError = "";
|
const source = Readable.fromWeb(res.body as never);
|
||||||
let downloaded = false;
|
source.on("data", (chunk: Buffer) => {
|
||||||
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
|
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 {
|
try {
|
||||||
const res = await fetch(asset.browser_download_url, {
|
const buf = Buffer.alloc(2);
|
||||||
redirect: "follow",
|
const n = fs.readSync(fd, buf, 0, 2, 0);
|
||||||
signal: AbortSignal.timeout(10 * 60 * 1000),
|
head = buf.subarray(0, n);
|
||||||
});
|
} finally {
|
||||||
if (!res.ok || !res.body) {
|
fs.closeSync(fd);
|
||||||
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 (!downloaded) {
|
if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) {
|
||||||
fs.rmSync(tarball, { force: true });
|
fs.rmSync(tarball, { force: true });
|
||||||
return {
|
return { error: "Downloaded file is not a valid archive; update aborted." };
|
||||||
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,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.
|
// 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,32 +118,6 @@ 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