Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/ipc/adapters/modScan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -118,7 +118,11 @@ function readModArchive(archivePath: string): Promise<ModArchiveResult> {
}

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,
Expand All @@ -127,7 +131,8 @@ function readModArchive(archivePath: string): Promise<ModArchiveResult> {
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()
Expand Down
28 changes: 21 additions & 7 deletions tests/domain/mods/scanInstalled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }) }
Expand All @@ -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 }
]
)
})
Expand Down
9 changes: 5 additions & 4 deletions tests/fixtures/build-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -301,7 +302,7 @@ write(
name: "modicon.png",
method: METHOD_DEFLATE,
realBytes: Buffer.from("irrelevant", "utf8"),
declaredUncompressedSize: 9 * 1024 * 1024
declaredUncompressedSize: 1 * 1024 * 1024
}
])
)
Expand Down
11 changes: 8 additions & 3 deletions tests/ipc/modScan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading