From efd11d6d42c6282cf044ae1c7de123fcb6b5ca87 Mon Sep 17 00:00:00 2001 From: Brandon Philips's Clanker Date: Thu, 30 Jul 2026 18:34:09 -0700 Subject: [PATCH] Cap composited .spd image size to avoid OOM on a corrupted/outlier tile toImage() sized its output against the tile-grid bounding box across every tile in the file, with no sanity check at all. A single tile whose tid is far from the rest (a corrupted file, or a stray mark placed far off on Atelier's much larger virtual canvas - see TILE_ID_STRIDE's doc comment) blows that bounding box out arbitrarily far, and toImage() would allocate accordingly - gigabytes for what should be a small image. On desktop that's slow or an OOM in one tab. On a memory-constrained mobile WebView it's a hard crash, not a catchable one - this is the root cause of supernote-obsidian-plugin#147's iOS "boot loop": since the host app restores the last-open file on relaunch, one bad .spd file crashes it again immediately, every time, with no way out short of clearing workspace state. Adds a 32-megapixel ceiling (real device files observed so far top out around 1920x2560, ~4.9 megapixels - see atelier.test.ts) and throws a descriptive error instead of allocating past it. The main plugin's SupernoteAtelierView already catches errors from this path and renders them in-view (see updateImage()'s try/catch), so this turns a native crash into a normal, visible "couldn't open this file" error. --- src/atelier.ts | 27 +++++++++++++++++++- tests/atelier.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/atelier.ts b/src/atelier.ts index 8ce1acc..a7e2011 100644 --- a/src/atelier.ts +++ b/src/atelier.ts @@ -68,6 +68,21 @@ const SURFACE_TABLE_PATTERN = /^surface_\d+$/; * tiles happen to sit. */ const TILE_ID_STRIDE = 4096; +/** Hard ceiling on a composited image's total pixel count. `toImage()` sizes + * its output against the tile-grid bounding box across *every* tile in the + * file (see its doc comment) with no other check - a single tile whose `tid` + * is far from the rest (a corrupted file, or a stray mark placed far off on + * Atelier's much larger virtual canvas, see `TILE_ID_STRIDE`'s doc comment) + * blows that bounding box out arbitrarily far, and `toImage()` would + * otherwise allocate accordingly. On a memory-constrained host (verified: + * this is what caused supernote-obsidian-plugin#147, a hard iOS crash on + * open, not a catchable one - WKWebView's per-process memory ceiling is far + * stricter than desktop's) that's an out-of-memory crash rather than a slow + * tab. Real device-generated files observed so far top out around + * 1920x2560 (~4.9 megapixels; see atelier.test.ts) - this leaves generous + * headroom above that while still keeping the worst case bounded. */ +const MAX_COMPOSITE_PIXELS = 32_000_000; + /** Parsed Supernote Atelier `.spd` file. */ export class SupernoteAtelier { declare fmtVer?: number; @@ -207,7 +222,17 @@ export class SupernoteAtelier { const tileWidth = decoded[0].image.width; const tileHeight = decoded[0].image.height; - const output = new Image((maxCol - minCol + 1) * tileWidth, (maxRow - minRow + 1) * tileHeight, { + const width = (maxCol - minCol + 1) * tileWidth; + const height = (maxRow - minRow + 1) * tileHeight; + if (width * height > MAX_COMPOSITE_PIXELS) { + throw new Error( + `Refusing to composite "${surfaceName}": computed size ${width}x${height} ` + + `(${(width * height).toLocaleString()} px) exceeds the ${MAX_COMPOSITE_PIXELS.toLocaleString()}px ` + + 'safety limit. This usually means one tile is positioned far from the rest ' + + '(a corrupted or out-of-range tile id) rather than a genuinely huge canvas.', + ); + } + const output = new Image(width, height, { colorModel: ImageColorModel.RGBA, }); // image-js defaults a new image's alpha channel to fully opaque (and diff --git a/tests/atelier.test.ts b/tests/atelier.test.ts index 4ff6a4c..a73922e 100644 --- a/tests/atelier.test.ts +++ b/tests/atelier.test.ts @@ -1,5 +1,6 @@ import * as fs from "fs-extra" import * as imagejs from "image-js" +import initSqlJs from "sql.js" import { describe, test, expect } from 'vitest' import { SupernoteAtelier } from "../src/atelier" @@ -15,6 +16,31 @@ function readFileToUint8Array(filePath: string): Promise { }); } +// Minimal `.spd` SQLite database, built directly (not via a real device +// export) so a test can place a tile wherever it wants - including +// positions no real file would ever have, to exercise toImage()'s size +// guard. Only what `_parseConfig`/`_parseSurfaces` actually read: a `config` +// table (left empty here - canvasSize/layers/etc. are all optional) and one +// `tid`/`tile` table per surface named in `tiles`. +async function buildSpdBuffer(tiles: { surface: string; tid: number }[]): Promise { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run("CREATE TABLE config (name TEXT, value BLOB)"); + + const tilePng = imagejs.encodePng(new imagejs.Image(4, 4, { colorModel: imagejs.ImageColorModel.RGBA })); + const surfaceNames = new Set(tiles.map((t) => t.surface)); + for (const surface of surfaceNames) { + db.run(`CREATE TABLE ${surface} (tid INTEGER, tile BLOB)`); + } + for (const { surface, tid } of tiles) { + db.run(`INSERT INTO ${surface} (tid, tile) VALUES (?, ?)`, [tid, tilePng]); + } + + const buffer = db.export(); + db.close(); + return buffer; +} + describe("atelier", () => { test("parses config and surfaces from a .spd file", async () => { const note = await SupernoteAtelier.open(await readFileToUint8Array("sample.spd")); @@ -222,4 +248,36 @@ describe("atelier real device file", () => { expect(composite).not.toBeNull(); expect(composite!.getPixel(0, 0)).toEqual(background!.getPixel(0, 0)); }) + + // Regression test for supernote-obsidian-plugin#147: a tile positioned far + // from the rest (tid encodes grid column in its upper bits, see + // TILE_ID_STRIDE's doc comment in atelier.ts) used to make toImage() size + // its output against that outlier with no check at all, allocating + // gigabytes for what should be a tiny image - fine on desktop, a hard OOM + // crash on a memory-constrained mobile WebView. + test("toImage refuses to composite when a tile sits far outside the rest", async () => { + // tid = col * 4096 + row (TILE_ID_STRIDE). One ordinary tile at the + // origin, one 10 million columns away - a stand-in for a corrupted/ + // out-of-range tid, since no real device drawing spans that far. With + // these 4x4 tiles that's a (10,000,001 * 4) x 4 output, comfortably over + // the 32-megapixel safety limit. + const buffer = await buildSpdBuffer([ + { surface: "surface_1", tid: 0 }, + { surface: "surface_1", tid: 10_000_000 * 4096 }, + ]); + const note = await SupernoteAtelier.open(buffer); + await expect(note.toImage("surface_1")).rejects.toThrow(/exceeds the .* safety limit/); + }) + + test("toImage composites normally when tiles stay within the safety limit", async () => { + const buffer = await buildSpdBuffer([ + { surface: "surface_1", tid: 0 }, + { surface: "surface_1", tid: 1 * 4096 + 1 }, // one tile over, one down + ]); + const note = await SupernoteAtelier.open(buffer); + const image = await note.toImage("surface_1"); + expect(image).not.toBeNull(); + expect(image!.width).toEqual(4 * 2); + expect(image!.height).toEqual(4 * 2); + }) })