From 5718a1265aa2fd8aa9d348f6340f5dca9eb92c3f Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:48:49 -0300 Subject: [PATCH 1/2] fix(mods): lower per-icon size limit from 8 MiB to 512 KiB MAX_MOD_IMAGE_BYTES was 8 MiB. The total cache budget is 64 MiB, so eight icons at the maximum legal size filled the entire cache by themselves. No real mod icon is anywhere near 8 MiB: typical modicon.png files weigh tens of kilobytes. Drop the threshold to 512 KiB. Any PNG above that is not a reasonable icon. This keeps the cache budget meaningful: 128 maximum-size icons fit now instead of 8. Fixes #146 --- src/ipc/adapters/modScan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ipc/adapters/modScan.ts b/src/ipc/adapters/modScan.ts index 75108d3d..bb092b0b 100644 --- a/src/ipc/adapters/modScan.ts +++ b/src/ipc/adapters/modScan.ts @@ -21,7 +21,7 @@ const MODICON_ENTRY = "modicon.png" const MAX_MODINFO_BYTES = 1 * 1024 * 1024 /** Past this, a mod icon is not an icon any more. */ -const MAX_MOD_IMAGE_BYTES = 8 * 1024 * 1024 +const MAX_MOD_IMAGE_BYTES = 512 * 1024 /** Folder mod icons are cached under, inside the launcher's own user data. */ function modImagesFolder(): string { From dcf39c09030513323b28cd5bda1ac08319d4eb80 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:35:02 -0300 Subject: [PATCH 2/2] fix(mods): skip the icon instead of erroring the mod when it exceeds the size cap An oversized modicon.png now costs the mod its picture, not its place in the list. The limit drops from 8 MiB to 512 KiB (a reasonable ceiling for a mod icon), and both the declared-size guard and the runtime-size callback advance past the icon entry instead of settling the archive as an error. Tests updated: the oversized-declared-icon fixture now expects a successful read with icon undefined, and the domain-level test asserts the mod lands in mods without an icon rather than in errors. --- src/ipc/adapters/modScan.ts | 9 ++++++-- tests/domain/mods/scanInstalled.test.ts | 28 ++++++++++++++++++------- tests/fixtures/build-fixtures.ts | 9 ++++---- tests/ipc/modScan.test.ts | 11 +++++++--- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/ipc/adapters/modScan.ts b/src/ipc/adapters/modScan.ts index bb092b0b..1f3b53b0 100644 --- a/src/ipc/adapters/modScan.ts +++ b/src/ipc/adapters/modScan.ts @@ -118,7 +118,11 @@ function readModArchive(archivePath: string): Promise { } if (entry.fileName === MODICON_ENTRY && content.icon === undefined) { - if (!declaredSizeAllowed(entry, MAX_MOD_IMAGE_BYTES)) return settle({ ok: false, problem: "icon-too-large" }) + if (!declaredSizeAllowed(entry, MAX_MOD_IMAGE_BYTES)) { + // An oversized icon costs the mod its picture, never its place in + // the list: skip the icon and keep extracting metadata. + return advance() + } return collect( entry, @@ -127,7 +131,8 @@ function readModArchive(archivePath: string): Promise { content.icon = bytes advance() }, - () => settle({ ok: false, problem: "icon-too-large" }), + // Runtime size exceeds the limit: skip the icon, keep the mod. + () => advance(), // An icon that will not read costs the mod its picture, never its // place in the list: the metadata may still be perfectly readable. () => advance() diff --git a/tests/domain/mods/scanInstalled.test.ts b/tests/domain/mods/scanInstalled.test.ts index de5ab538..a293a6a5 100644 --- a/tests/domain/mods/scanInstalled.test.ts +++ b/tests/domain/mods/scanInstalled.test.ts @@ -289,7 +289,6 @@ describe("scanInstalledMods problem kinds", () => { fakePorts({ "gone.zip": { problem: "unreadable-archive" }, "huge-info.zip": { problem: "modinfo-too-large" }, - "huge-icon.zip": { problem: "icon-too-large" }, "resources.zip": {}, "broken.zip": { modinfo: "{ not json" }, "nameless.zip": { modinfo: JSON.stringify({ version: "1.0.0" }) } @@ -299,13 +298,28 @@ describe("scanInstalledMods problem kinds", () => { assert.deepEqual( result.errors.map((archive) => `${archive.zipname}:${archive.problem}`), + ["gone.zip:unreadable-archive", "huge-info.zip:modinfo-too-large", "resources.zip:modinfo-missing", "broken.zip:modinfo-invalid", "nameless.zip:modinfo-incomplete"] + ) + }) + + it("lists a mod with an oversized declared icon without an icon, not as an error", async () => { + const result = await scanInstalledMods( + fakePorts({ + "huge-icon.zip": { modinfo: modinfoText({ modid: "hugeicon" }) }, + "normal.zip": { modinfo: modinfoText({ modid: "normal" }), icon: ICON } + }), + { folder: FOLDER } + ) + + assert.deepEqual( + result.errors.map((e) => e.zipname), + [] + ) + assert.deepEqual( + result.mods.map((m) => ({ id: m.modid, hasIcon: m.image !== undefined })), [ - "gone.zip:unreadable-archive", - "huge-info.zip:modinfo-too-large", - "huge-icon.zip:icon-too-large", - "resources.zip:modinfo-missing", - "broken.zip:modinfo-invalid", - "nameless.zip:modinfo-incomplete" + { id: "hugeicon", hasIcon: false }, + { id: "normal", hasIcon: true } ] ) }) diff --git a/tests/fixtures/build-fixtures.ts b/tests/fixtures/build-fixtures.ts index ade71614..8868e10a 100644 --- a/tests/fixtures/build-fixtures.ts +++ b/tests/fixtures/build-fixtures.ts @@ -290,9 +290,10 @@ write( // --- oversized-declared-icon.zip ------------------------------------------- // Same idea as oversized-declared-modinfo.zip, aimed at the icon's own, -// larger cap instead (MAX_MOD_IMAGE_BYTES, 8 MiB in modScan.ts). modinfo.json -// is valid and under its cap, so the archive gets as far as trying the icon -// and declaredSizeAllowed() rejects it there. +// larger cap instead (MAX_MOD_IMAGE_BYTES, 512 KiB in modScan.ts). modinfo.json +// is valid and under its cap, so the archive gets as far as trying the icon. +// The declared size exceeds the limit, so the icon is skipped and the mod +// appears without a picture rather than as an error. write( "oversized-declared-icon.zip", assembleZip([ @@ -301,7 +302,7 @@ write( name: "modicon.png", method: METHOD_DEFLATE, realBytes: Buffer.from("irrelevant", "utf8"), - declaredUncompressedSize: 9 * 1024 * 1024 + declaredUncompressedSize: 1 * 1024 * 1024 } ]) ) diff --git a/tests/ipc/modScan.test.ts b/tests/ipc/modScan.test.ts index 4295f017..366a6059 100644 --- a/tests/ipc/modScan.test.ts +++ b/tests/ipc/modScan.test.ts @@ -96,15 +96,20 @@ describe("readModArchive", () => { assert.deepEqual(result, { ok: false, problem: "modinfo-too-large" }) }) - it("refuses a modicon.png whose declared size already exceeds the 8 MiB cap", async () => { + it("skips the icon when its declared size exceeds the 512 KiB cap", async () => { // modinfo.json is valid and under its own cap here, so the archive reaches - // the icon and declaredSizeAllowed() rejects that entry's header instead. + // the icon. The declared size exceeds the limit, so the icon is skipped and + // the mod appears without a picture rather than failing the archive. const { createModArchiveReaderPort } = await import("../../src/ipc/adapters/modScan") const readModArchive = createModArchiveReaderPort().read const result = await readModArchive(fixturePath("oversized-declared-icon.zip")) - assert.deepEqual(result, { ok: false, problem: "icon-too-large" }) + assert.equal(result.ok, true) + if (result.ok) { + assert.notEqual(result.content.modinfo, undefined) + assert.equal(result.content.icon, undefined) + } }) it("refuses a file with no zip structure in it at all", async () => {