From 3b7fc428585ff4ce2361dfd29e16a25f489b983b Mon Sep 17 00:00:00 2001 From: Bo Layer Date: Sun, 23 Aug 2026 03:20:23 -0600 Subject: [PATCH] test(mod-api): gate Mew dock seams --- .../engine/mew_dock_private_artifact_gate.lua | 49 ++ tests/engine/mew_dock_seam_contract.lua | 428 ++++++++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 tests/engine/mew_dock_private_artifact_gate.lua create mode 100644 tests/engine/mew_dock_seam_contract.lua diff --git a/tests/engine/mew_dock_private_artifact_gate.lua b/tests/engine/mew_dock_private_artifact_gate.lua new file mode 100644 index 00000000..1402aefd --- /dev/null +++ b/tests/engine/mew_dock_private_artifact_gate.lua @@ -0,0 +1,49 @@ +-- Static privacy gate for the narrow Mew dock engine branch. It checks the +-- Git publication set, not ignored local imports: user ROMs and progress +-- saves may exist on a developer machine but must never become tracked files. + +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") + +local pipe = io.popen("git ls-files 2>" .. (package.config:sub(1, 1) == "\\" and "nul" or "/dev/null")) +local paths = {} +if pipe then + for path in pipe:lines() do paths[#paths + 1] = path:gsub("\\", "/") end + pipe:close() +end + +T.check(#paths > 100, + "privacy gate inspects a real Git publication set instead of passing vacuously") + +local forbiddenExtensions = { + gb = true, gbc = true, gba = true, sav = true, srm = true, + rom = true, z64 = true, v64 = true, n64 = true, nds = true, + sfc = true, smc = true, +} +local forbiddenRuntimeRoots = { + ["save.lua"] = true, + ["save_blue.lua"] = true, + ["save_yellow.lua"] = true, + ["save_gold.lua"] = true, + ["options.lua"] = true, +} + +local binaryLeaks, runtimeLeaks = {}, {} +for _, path in ipairs(paths) do + local lower = path:lower() + local ext = lower:match("%.([^./\\]+)$") + if forbiddenExtensions[ext] then binaryLeaks[#binaryLeaks + 1] = path end + if forbiddenRuntimeRoots[lower] + or lower:match("^saves/") + or lower:match("^imports/") + or lower:match("^mods%-data/") then + runtimeLeaks[#runtimeLeaks + 1] = path + end +end + +T.eq(#binaryLeaks, 0, + "tracked publication contains no ROM/save binaries: " .. table.concat(binaryLeaks, ", ")) +T.eq(#runtimeLeaks, 0, + "tracked publication contains no runtime save/import data: " .. table.concat(runtimeLeaks, ", ")) + +T.finish("mew dock private artifact gate") diff --git a/tests/engine/mew_dock_seam_contract.lua b/tests/engine/mew_dock_seam_contract.lua new file mode 100644 index 00000000..627dfca6 --- /dev/null +++ b/tests/engine/mew_dock_seam_contract.lua @@ -0,0 +1,428 @@ +-- Contract gate for the two narrow Gen 1 seams added for a composable +-- post-departure S.S. Anne dock mod. The suite is ROM-free: it drives the +-- real hook bus and WorldAPI against hand-written maps and save snapshots. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") +local WorldAPI = require("src.world.WorldAPI") + +local oldHooks = Runtime.hooks +local oldTextBox = package.loaded["src.render.TextBox"] + +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { text = text, done = done } end, +} + +local story = dofile("data/scripts/story3.lua") +local VERSIONS = { "red", "blue", "yellow" } + +local function newDock(version, wrappers) + local blocks, pushes, warps = {}, {}, {} + local rebuilds = 0 + local save = { + version = version, + flags = { EVENT_SS_ANNE_LEFT = true }, + marker = "save-must-not-change", + } + local map = { + id = "VERMILION_DOCK", + setBlock = function(_, bx, by, block) + blocks[bx .. "," .. by] = block + end, + renderer = { rebuild = function() rebuilds = rebuilds + 1 end }, + } + local game = { + save = save, + data = { text = { + _VermilionCitySailor1ShipSetSailText = "The ship set sail.", + } }, + stack = { push = function(_, value) pushes[#pushes + 1] = value end }, + } + local ow = { + map = map, + player = { cellX = 14, cellY = 2, facing = "down" }, + startWarpTo = function(_, ...) + warps[#warps + 1] = { ... } + end, + } + + local hooks = Hooks.new() + Runtime.hooks = hooks + for _, entry in ipairs(wrappers or {}) do + hooks:wrap("map.occupancy_allowed", entry.fn, entry.priority or 0, + entry.owner) + end + + story.VERMILION_DOCK.onEnter(game, ow) + if pushes[1] and pushes[1].done then pushes[1].done() end + return { + blocks = blocks, pushes = pushes, warps = warps, rebuilds = rebuilds, + save = save, map = map, game = game, ow = ow, hooks = hooks, + } +end + +local function allowedWrapper(owner, inspect) + return { + owner = owner, + fn = function(nextFn, game, ctx) + if inspect then inspect(game, ctx) end + local downstream = nextFn(game, ctx) + local ownClaim = true + return downstream == true or ownClaim == true + end, + } +end + +-- With no subscriber, the post-departure branch remains byte-for-byte +-- vanilla in effect: erase the ship, show its line, and eject the player. +do + local run = newDock("red") + T.eq(run.rebuilds, 1, "vanilla re-entry rebuilds the erased dock") + T.eq(run.blocks["5,1"], 1, "vanilla re-entry erases the upper hull") + T.eq(run.blocks["8,2"], 13, "vanilla re-entry erases the lower hull") + T.eq(#run.pushes, 1, "vanilla re-entry shows the ship-set-sail line") + T.eq(run.pushes[1].text, "The ship set sail.", + "vanilla re-entry preserves its dialogue") + T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY", + "vanilla re-entry ejects to Vermilion City") +end + +-- The new permission seam is post-departure only. An ordinary HM01 exit +-- must not invoke it or change the existing departure-script path. +do + local hookCalls, queued = 0, nil + local hooks = Hooks.new() + Runtime.hooks = hooks + hooks:wrap("map.occupancy_allowed", function(nextFn, game, ctx) + hookCalls = hookCalls + 1 + return nextFn(game, ctx) + end, 0, "must_not_run") + + local savedMusic = package.loaded["src.core.Music"] + package.loaded["src.core.Music"] = { + stop = function() end, + play = function() end, + } + local game = { + save = { version = "red", flags = { EVENT_GOT_HM01 = true } }, + data = {}, + } + local ow = { + player = { cellX = 14, cellY = 2 }, + startDustAnim = function(_, _, _, done) if done then done() end end, + queueScript = function(_, rows) queued = rows end, + } + story.VERMILION_DOCK.onEnter(game, ow) + package.loaded["src.core.Music"] = savedMusic + + T.eq(hookCalls, 0, "normal HM01 departure never calls the occupancy seam") + T.eq(game.save.flags.EVENT_SS_ANNE_LEFT, true, + "normal HM01 departure still sets the vanilla event flag") + T.check(type(queued) == "table" and #queued > 0, + "normal HM01 departure still queues its sail-away script") +end + +-- One cooperative claimant may permit occupancy. The callback receives a +-- detached data snapshot, not the live overworld, map, player, or save. +do + local seenGame, seenCtx + local run = newDock("red", { allowedWrapper("mew_fixture", function(game, ctx) + seenGame = game + seenCtx = { + mapId = ctx.mapId, reason = ctx.reason, gameVersion = ctx.gameVersion, + x = ctx.x, y = ctx.y, + } + ctx.mapId, ctx.x, ctx.y = "MUTATED", -1, -1 + end) }) + T.eq(seenGame, run.game, "occupancy callback receives the live game explicitly") + T.same(seenCtx, { + mapId = "VERMILION_DOCK", reason = "ss_anne_departed", + gameVersion = "red", x = 14, y = 2, + }, "occupancy context is the exact detached Red dock snapshot") + T.eq(run.ow.map.id, "VERMILION_DOCK", "context mutation cannot change the map") + T.eq(run.ow.player.cellX, 14, "context mutation cannot change player X") + T.eq(run.ow.player.cellY, 2, "context mutation cannot change player Y") + T.eq(run.save.marker, "save-must-not-change", "permission check does not mutate save") + T.same(run.save, { + version = "red", flags = { EVENT_SS_ANNE_LEFT = true }, + marker = "save-must-not-change", + }, "permission check preserves the complete save snapshot") + T.eq(#run.pushes, 0, "an exact true suppresses the vanilla rejection dialog") + T.eq(#run.warps, 0, "an exact true permits post-departure dock occupancy") + T.eq(run.blocks["5,1"], 1, "permitted occupancy still erases the departed ship") + T.eq(run.rebuilds, 1, "permitted occupancy still rebuilds the water layout") +end + +-- Standard hook composition also means a non-cooperative false/no-next +-- wrapper can suppress downstream claims. This remains safe because false +-- is denial; it cannot accidentally grant occupancy. +do + local downstreamCalls = 0 + local run = newDock("red", { + { + owner = "denier", priority = 10, + fn = function() return false end, + }, + { + owner = "unreached_claimant", priority = 0, + fn = function() + downstreamCalls = downstreamCalls + 1 + return true + end, + }, + }) + T.eq(downstreamCalls, 0, "no-next denial suppresses downstream by hook semantics") + T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY", + "non-cooperative false remains fail-closed") +end + +-- Cooperative peers all run through next(). A lower-priority peer claim is +-- preserved by a higher-priority peer that has no claim of its own. +do + local calls, contextIdentity = {}, nil + local run = newDock("blue", { + { + owner = "peer_high", priority = 10, + fn = function(nextFn, game, ctx) + calls[#calls + 1] = "high-before" + contextIdentity = ctx + local allowed = nextFn(game, ctx) + calls[#calls + 1] = "high-after" + return allowed == true or false + end, + }, + { + owner = "peer_low", priority = 0, + fn = function(nextFn, game, ctx) + calls[#calls + 1] = "low" + T.eq(ctx, contextIdentity, "peer wrappers share one detached snapshot instance") + local allowed = nextFn(game, ctx) + local ownClaim = true + return allowed == true or ownClaim == true + end, + }, + }) + T.same(calls, { "high-before", "low", "high-after" }, + "multiple peer handlers preserve hook-chain order") + T.eq(#run.warps, 0, "a cooperative peer claim survives the whole chain") +end + +-- Absent, throwing, or malformed callbacks fail closed. Only boolean true +-- can turn off ejection; truthy strings/tables/numbers do not grant access. +do + local malformed = { + { label = "nil", value = nil }, + { label = "false", value = false }, + { label = "string", value = "yes" }, + { label = "number", value = 1 }, + { label = "table", value = {} }, + } + for _, case in ipairs(malformed) do + local run = newDock("red", { { + owner = "malformed_" .. case.label, + fn = function() return case.value end, + } }) + T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY", + "malformed " .. case.label .. " permission fails closed") + end + + local beforeNext = newDock("red", { { + owner = "throws_before_next", + fn = function() error("fixture throws before next", 0) end, + } }) + T.eq(beforeNext.warps[1] and beforeNext.warps[1][1], "VERMILION_CITY", + "throwing callback before next fails closed") + + local afterNext = newDock("red", { { + owner = "throws_after_next", + fn = function(nextFn, game, ctx) + nextFn(game, ctx) + error("fixture throws after next", 0) + end, + } }) + T.eq(afterNext.warps[1] and afterNext.warps[1][1], "VERMILION_CITY", + "throwing callback after next keeps the downstream denial") +end + +-- The context carries one version only. A Red-only claimant must not leak +-- access into Blue or Yellow, and each call gets its own snapshot. +do + local contexts = {} + for _, version in ipairs(VERSIONS) do + local run = newDock(version, { { + owner = "red_only", + fn = function(nextFn, game, ctx) + contexts[#contexts + 1] = ctx + local downstream = nextFn(game, ctx) + return downstream == true or ctx.gameVersion == "red" + end, + } }) + T.eq(#run.warps == 0, version == "red", + version .. " occupancy is decided only by its own version context") + end + T.eq(contexts[1].gameVersion, "red", "Red context stays Red") + T.eq(contexts[2].gameVersion, "blue", "Blue context stays Blue") + T.eq(contexts[3].gameVersion, "yellow", "Yellow context stays Yellow") + T.check(contexts[1] ~= contexts[2] and contexts[2] ~= contexts[3], + "Red, Blue, and Yellow calls do not share context tables") +end + +-- Removing the owner is the engine's disable/uninstall path. It restores +-- vanilla denial immediately and leaves no save flag or serialized state. +do + local run = newDock("yellow", { allowedWrapper("removable") }) + T.eq(#run.warps, 0, "installed owner may grant Yellow dock occupancy") + run.hooks:removeOwner("removable") + local pushes, warps = {}, {} + run.game.stack.push = function(_, value) pushes[#pushes + 1] = value end + run.ow.startWarpTo = function(_, ... ) warps[#warps + 1] = { ... } end + story.VERMILION_DOCK.onEnter(run.game, run.ow) + if pushes[1] and pushes[1].done then pushes[1].done() end + T.eq(warps[1] and warps[1][1], "VERMILION_CITY", + "disabling the owner restores vanilla ejection") + T.eq(run.hooks.chains["map.occupancy_allowed"], nil, + "uninstall removes the occupancy chain itself") + T.eq(run.save.marker, "save-must-not-change", + "disable/uninstall writes no persistent permission state") + T.same(run.save, { + version = "yellow", flags = { EVENT_SS_ANNE_LEFT = true }, + marker = "save-must-not-change", + }, "disable/uninstall preserves the complete Yellow save snapshot") +end + +-- activeBlockAt is read-only and fail-closed. A successful call exposes +-- only one scalar from the active runtime layout, never its backing table. +local function blockApi(version, blockAt) + local backing = { 4, 5, 6, 8, 9, 10 } + local map = { + id = "VERMILION_DOCK", + def = { width = 3, height = 2, blocks = backing }, + blockAt = blockAt or function(_, bx, by) + return backing[by * 3 + bx + 1] + end, + } + local world = { isOverworld = true, map = map } + local game = { + save = { version = version }, + stack = { states = { world } }, + overworld = world, + } + return WorldAPI.new(game, "contract_fixture"), backing, game, map +end + +do + for _, version in ipairs(VERSIONS) do + local api, backing = blockApi(version) + local block, err = api:activeBlockAt("VERMILION_DOCK", 1, 0) + T.eq(block, 5, version .. " reads its active dock block") + T.eq(err, nil, version .. " valid active block has no error") + block = 99 + T.eq(backing[2], 5, version .. " scalar result cannot mutate the map") + end + + local api = blockApi("red") + local wrong, wrongErr = api:activeBlockAt("VERMILION_CITY", 1, 0) + T.eq(wrong, nil, "wrong map has no block result") + T.eq(wrongErr, "map is not active", "wrong map fails closed explicitly") + + local invalid = { + { "nil x", nil, 0 }, { "string x", "1", 0 }, { "table x", {}, 0 }, + { "fraction x", 0.5, 0 }, { "negative infinity x", -math.huge, 0 }, + { "infinity y", 0, math.huge }, { "NaN y", 0, 0 / 0 }, + } + for _, case in ipairs(invalid) do + local value, err = api:activeBlockAt("VERMILION_DOCK", case[2], case[3]) + T.eq(value, nil, case[1] .. " returns no block") + T.eq(err, "invalid block coordinates", case[1] .. " is rejected by type") + end + for _, coords in ipairs({ { -1, 0 }, { 0, -1 }, { 3, 0 }, { 0, 2 } }) do + local value, err = api:activeBlockAt("VERMILION_DOCK", coords[1], coords[2]) + T.eq(value, nil, "out-of-bounds coordinate returns no block") + T.eq(err, "block coordinates out of bounds", "bounds fail closed explicitly") + end +end + +do + local malformed = { + { label = "nil", get = function() return nil end }, + { label = "negative", get = function() return -1 end }, + { label = "fractional", get = function() return 1.5 end }, + { label = "infinite", get = function() return math.huge end }, + { label = "NaN", get = function() return 0 / 0 end }, + { label = "string", get = function() return "4" end }, + { label = "table", get = function() return {} end }, + { label = "throwing", get = function() error("bad map", 0) end }, + } + for _, case in ipairs(malformed) do + local api = blockApi("red", case.get) + local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "malformed active block " .. case.label .. " returns no value") + T.eq(err, "block unavailable", + "malformed active block " .. case.label .. " fails closed") + end +end + + +-- Invalid map shapes are untrusted runtime data too. None may escape as a +-- block or raise through the mod facade. +do + local badDefs = { + { label = "missing def", value = nil }, + { label = "missing width", value = { height = 2, blocks = {} } }, + { label = "string width", value = { width = "3", height = 2, blocks = {} } }, + { label = "fractional width", value = { width = 1.5, height = 2, blocks = {} } }, + { label = "nonpositive width", value = { width = 0, height = 2, blocks = {} } }, + { label = "infinite height", value = { width = 3, height = math.huge, blocks = {} } }, + { label = "missing blocks", value = { width = 3, height = 2 } }, + { label = "scalar blocks", value = { width = 3, height = 2, blocks = 4 } }, + } + for _, case in ipairs(badDefs) do + local api, _, _, map = blockApi("red") + map.def = case.value + local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, case.label .. " returns no block") + T.eq(err, "block unavailable", case.label .. " fails closed") + end + + local api, _, _, map = blockApi("red") + map.blockAt = nil + local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "missing blockAt returns no block") + T.eq(err, "block unavailable", "missing blockAt fails closed") + map.blockAt = "not a function" + block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "malformed blockAt returns no block") + T.eq(err, "block unavailable", "malformed blockAt fails closed") + + api, _, _, map = blockApi("red") + map.def.blocks = {} + block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "sparse stored block slot returns no block") + T.eq(err, "block unavailable", "sparse stored block slot fails closed") + + api, _, _, map = blockApi("red") + map.def.blocks[1] = "4" + block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "malformed stored block slot returns no block") + T.eq(err, "block unavailable", "malformed stored block slot fails closed") + + api, _, _, map = blockApi("red", function() return 5 end) + block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "stored/accessor mismatch returns no block") + T.eq(err, "block unavailable", "stored/accessor mismatch fails closed") +end + +do + local api = WorldAPI.new({ stack = { states = {} } }, "contract_fixture") + local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "no-overworld lookup returns no block") + T.eq(err, "no overworld", "no-overworld lookup reports its state") +end + +Runtime.hooks = oldHooks +package.loaded["src.render.TextBox"] = oldTextBox +T.finish("mew dock seam contract")