From 6bb2e078c05b774f614f0c3dcd1fef8c256919fd Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Sat, 1 Aug 2026 14:22:26 -0400 Subject: [PATCH] Scrub mod manifest strings to valid UTF-8 A manifest whose name, version, description, or category carries invalid UTF-8 (a BOM, Latin-1 bytes) crashed the launcher's MODS panel, since love.graphics.printf raises on invalid UTF-8. Manifest.validate now drops invalid bytes and a leading BOM from those strings, in place so the badge's raw.category read agrees. --- src/mods/Manifest.lua | 50 ++++++++++++++++++++++++++++ tests/engine/launcher_mods_tests.lua | 31 +++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index d1f1545b..12ff83a7 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -86,8 +86,58 @@ local function mergeConflictLists(conflicts, incompatible) return out end +-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs, +-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises +-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a +-- panel may draw must be scrubbed here -- the one place every mod manifest +-- passes through -- or a single mangled description crashes the whole MODS +-- panel instead of misrendering one card. +local function scrubUtf8(s) + if type(s) ~= "string" then return s end + s = s:gsub("^\239\187\191", "") + local out, i, n = {}, 1, #s + while i <= n do + local b = s:byte(i) + local len + if b < 0x80 then len = 1 + elseif b >= 0xC2 and b <= 0xDF then len = 2 + elseif b >= 0xE0 and b <= 0xEF then len = 3 + elseif b >= 0xF0 and b <= 0xF4 then len = 4 + end + local ok = len ~= nil and i + len - 1 <= n + if ok and len > 1 then + for j = i + 1, i + len - 1 do + local c = s:byte(j) + if c < 0x80 or c > 0xBF then ok = false; break end + end + if ok then + -- boundary lead bytes narrow their second byte: no overlongs + -- (E0/F0), no surrogates (ED), nothing past U+10FFFF (F4) + local b2 = s:byte(i + 1) + if (b == 0xE0 and b2 < 0xA0) or (b == 0xED and b2 > 0x9F) + or (b == 0xF0 and b2 < 0x90) or (b == 0xF4 and b2 > 0x8F) then + ok = false + end + end + end + if ok then + out[#out + 1] = s:sub(i, i + len - 1) + i = i + len + else + i = i + 1 + end + end + return table.concat(out) +end + function Manifest.validate(raw, path) assert(type(raw) == "table", "manifest must be an object") + -- scrubbed in place so every later reader agrees, including the launcher's + -- badge derivation, which reads raw.category rather than the validated copy + raw.name = scrubUtf8(raw.name) + raw.version = scrubUtf8(raw.version) + raw.description = scrubUtf8(raw.description) + raw.category = scrubUtf8(raw.category) assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"), "manifest id must contain only letters, numbers, _ or -") assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required") diff --git a/tests/engine/launcher_mods_tests.lua b/tests/engine/launcher_mods_tests.lua index d2a2a5f0..02118482 100644 --- a/tests/engine/launcher_mods_tests.lua +++ b/tests/engine/launcher_mods_tests.lua @@ -297,4 +297,35 @@ do eq(rows[1].name, "bare", "a nameless row falls back to its id") end +-- ------- manifest strings are scrubbed to valid UTF-8 (MODS panel crash: +-- LÖVE's printf raises "Invalid UTF-8" on a mangled name/description, so +-- validate must drop bad bytes before any panel draws them) + +do + local m = mf({ id = "utf", entry = "m.lua", + -- BOM-prefixed name (a real manifest shipped this way), a Latin-1 e-acute + -- (\233, invalid as UTF-8) in the description, and a lone continuation + -- byte in the version + name = "\239\187\191Run Mode", + version = "1.0\128.0", + description = "caf\233 latt\233", + category = "UI\255" }) + eq(m.name, "Run Mode", "a leading BOM is stripped from the name") + eq(m.version, "1.0.0", "invalid bytes are dropped from the version") + eq(m.description, "caf latt", "Latin-1 bytes are dropped, not replaced") + eq(m.raw.category, "UI", "raw.category is scrubbed in place for the badge") + + local ok2 = mf({ id = "utf2", name = "Vers\195\163oVermelha", version = "1.0.0", + entry = "m.lua", description = "Pok\195\169mon \240\159\148\165" }) + eq(ok2.name, "Vers\195\163oVermelha", "valid two-byte sequences survive") + eq(ok2.description, "Pok\195\169mon \240\159\148\165", + "valid three- and four-byte sequences survive") + + -- surrogate half (ED A0 80) and overlong slash (C0 AF) are invalid even + -- though their lead bytes look plausible + local bad = mf({ id = "utf3", name = "a\237\160\128b\192\175c", + version = "1.0.0", entry = "m.lua" }) + eq(bad.name, "abc", "surrogates and overlongs are dropped") +end + T.finish("launcher_mods")