Merge pull request #1501 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-08-17 23:03:49 -04:00
committed by GitHub
14 changed files with 1124 additions and 90 deletions
+23 -19
View File
@@ -70,12 +70,12 @@ end
do
local host, guest = sessionPair()
guest:send({ type = "before", sequence = 1 })
guest:send({ type = "hello", sequence = 2 })
guest:send({ type = "greeting", sequence = 2 })
guest:send({ type = "after", sequence = 3 })
guest:send({ type = "hello", sequence = 4 })
guest:send({ type = "greeting", sequence = 4 })
host:update()
local hello = host:take("hello")
local hello = host:take("greeting")
T.eq(hello.sequence, 2, "take removes the first matching packet")
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
@@ -122,14 +122,14 @@ end
do
local transport = fakeTransport({ onUpdate = function(self)
self.inbox[#self.inbox + 1] = { type = "bye", final = true }
self.inbox[#self.inbox + 1] = { type = "bye" }
self.closed = true
end })
local session = Session.new(transport, { role = "host", kind = "link" })
session:update()
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
T.eq(session:take("bye").final, true, "final close packet remains observable")
T.check(session:take("bye") ~= nil, "final close packet remains observable")
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
T.eq(transport.closeCount, 1, "transport cleanup runs once")
end
@@ -182,21 +182,22 @@ do
} })
local session = Session.new(transport, { role = "host", kind = "link" })
session:update()
local reason = session:getFailure()
T.eq(reason, "protocol_error", "malformed packet fails as protocol_error")
T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix")
T.eq(session:getFailure(), nil, "a malformed packet is not a terminal failure")
T.eq(session:getStatus(), "paired", "the session stays usable after a bad packet")
local messages = session:poll()
T.eq(#messages, 1, "malformed value and untrusted tail are not exposed")
T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet")
T.eq(session:getStatus(), "failed", "protocol drain reaches failed")
T.eq(#messages, 2, "the malformed value is dropped, the rest is delivered")
T.eq(messages[1].sequence, 1, "packets before the malformed one survive")
T.eq(messages[2].sequence, 3, "packets after the malformed one survive")
T.eq(session.dropped, 1, "the drop is counted")
end
do
local transport = fakeTransport({ inbox = { { type = 7 } } })
local session = Session.new(transport, { role = "host", kind = "link" })
session:update()
T.eq(session:getFailure(), "protocol_error",
"table without string type is a protocol error")
T.eq(session:getFailure(), nil,
"a table without a string type is dropped, not a terminal failure")
T.eq(#session:poll(), 0, "...and never reaches the mode")
end
do
@@ -268,8 +269,9 @@ do
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
senderNet:send(false)
receiver:update()
T.eq(receiver:getFailure(), "protocol_error",
"loopback forwards decoded false to session validation")
T.eq(receiver:getFailure(), nil,
"a decoded scalar off the loopback is dropped, not fatal")
T.eq(#receiver:poll(), 0, "...and never reaches the mode")
end
do
@@ -284,8 +286,9 @@ do
}
local session = Session.new(transport, { role = "guest", kind = "link" })
session:update()
T.eq(session:getFailure(), "protocol_error",
"ENet forwards decoded false to session validation")
T.eq(session:getFailure(), nil,
"a decoded scalar off ENet is dropped, not fatal")
T.eq(#session:poll(), 0, "...and never reaches the mode")
end
do
@@ -294,8 +297,9 @@ do
T.check(pcall(transport.handleTCPLine, transport, "42"),
"TCP control handoff does not index a decoded scalar")
session:update()
T.eq(session:getFailure(), "protocol_error",
"decoded TCP scalar reaches session validation")
T.eq(session:getFailure(), nil,
"a decoded TCP scalar is dropped, not fatal")
T.eq(#session:poll(), 0, "...and never reaches the mode")
end
do
+54 -4
View File
@@ -66,13 +66,39 @@ table.sort(MOVES)
-- in the peer's inbox, so one side is mid-queue when the other's action
-- arrives (Net.loopbackPair on its own delivers instantly, which is the one
-- thing the real relay never does)
local function laggyPair(delayA, delayB)
-- mutation mode: a peer whose messages arrive with a random field (or the
-- type itself) at the wrong Lua type. What is delivered is what Session
-- would deliver -- Wire.sanitize's output, or nothing at all -- so a run
-- exercises the real receive path rather than a hand-written stand-in.
local Wire = require("src.link.Wire")
local HOSTILE = { {}, { 1, 2, 3 }, 0, -1, 999, "s", "", true, false, math.huge }
local function mutate(rnd, msg)
local keys = {}
for k in pairs(msg) do
if k ~= "type" then keys[#keys + 1] = k end
end
table.sort(keys)
if #keys == 0 or rnd(1, 100) <= 20 then
msg.type = HOSTILE[rnd(1, #HOSTILE)]
else
msg[keys[rnd(1, #keys)]] = HOSTILE[rnd(1, #HOSTILE)]
end
return msg
end
local function laggyPair(delayA, delayB, rnd, mutateRate)
local a, b = Net.loopbackPair()
a.wire, b.wire = {}, {}
a.delay, b.delay = delayA or 0, delayB or 0
a.mutateRate, b.mutateRate = mutateRate or 0, mutateRate or 0
local function send(self, msg)
if self.closed then return end
local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire
if decoded and self.mutateRate > 0 then
if rnd(1, 100) <= self.mutateRate then decoded = mutate(rnd, decoded) end
decoded = Wire.sanitize(decoded)
end
if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end
end
local function update(self)
@@ -143,7 +169,7 @@ local function firstMismatch(a, b)
end
-- Returns nil when the run agreed, or a description of how it split.
local function runOne(seed)
local function runOne(seed, mutateRate)
local rnd = makeRandom(seed)
-- the two sides are deliberately different clients
local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" }
@@ -155,7 +181,7 @@ local function runOne(seed)
gameA.save.party = randomParty(rnd, rnd(1, 4))
gameB.save.party = randomParty(rnd, rnd(1, 4))
local netA, netB = laggyPair(lagA, lagB)
local netA, netB = laggyPair(lagA, lagB, rnd, mutateRate)
local battleSeed = rnd(1, 2 ^ 30)
local battleA = LinkBattle.newHost(gameA, netA, {
myParty = Protocol.packParty(gameA.save.party),
@@ -230,6 +256,12 @@ local function runOne(seed)
end
end
local turn, part = firstMismatch(battleA, battleB)
if turn and mutateRate then
-- a corrupted action IS a divergence; what matters here is that the
-- match ends by its own rules (desync draw, forfeit, disconnect)
-- rather than throwing
return nil, battleA.turnCount or 0
end
if turn then
return ("seed %d: turn %d %s split (lag %d/%d, steps %d/%d)"):format(
seed, turn, part, lagA, lagB, stepsA, stepsB), battleA.turnCount or 0
@@ -237,7 +269,7 @@ local function runOne(seed)
end
-- a battle still running at the guard is a stalemate (two mons that cannot
-- KO each other), not a split; only a finished one can be checked mirrored
if guard < 60000
if not mutateRate and guard < 60000
and (battleA.player.mon.hp ~= battleB.enemy.mon.hp
or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then
return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format(
@@ -250,6 +282,8 @@ end
local RUNS = tonumber(arg and arg[1]) or 40
local FIRST = tonumber(arg and arg[2]) or 1
local MUTATION_RUNS = tonumber(arg and arg[3]) or math.max(4, math.floor(RUNS / 4))
local failures, turns = 0, 0
for seed = FIRST, FIRST + RUNS - 1 do
local ok, why, t = pcall(runOne, seed)
@@ -263,5 +297,21 @@ for seed = FIRST, FIRST + RUNS - 1 do
end
end
print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures))
local mutationFailures = 0
for seed = FIRST, FIRST + MUTATION_RUNS - 1 do
local ok, why = pcall(runOne, seed, 15)
if not ok then
mutationFailures = mutationFailures + 1
print("FAIL link mutation fuzz seed " .. seed .. ": " .. tostring(why))
elseif why then
mutationFailures = mutationFailures + 1
print("FAIL link mutation fuzz " .. why)
end
end
print(("link mutation fuzz: %d runs, %d failures"):format(
MUTATION_RUNS, mutationFailures))
assert(failures == 0, failures .. " lockstep run(s) diverged")
assert(mutationFailures == 0, mutationFailures .. " mutated run(s) threw")
return true
+396
View File
@@ -0,0 +1,396 @@
-- Hostile link traffic: every message type this build reads, with every
-- field replaced by every wrong Lua type, driven through the real Session
-- choke point and then into the real consumers (trade session, link battle,
-- spectator battle, tournament screen -- including its draw).
--
-- The three payloads from the "How to Troll Pokemon Players" writeup are
-- rows in the table below: action.slot as a table, hash.parts as a number,
-- and pick.index out of range.
--
-- Self-contained; run directly or via run_link_tests.lua:
-- luajit tests/link_hostile.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local Data = require("src.core.Data")
if not Data.pokemon then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local Input = require("src.core.Input")
Input:init()
local Json = require("src.link.Json")
local LinkBattle = require("src.link.LinkBattle")
local Net = require("src.link.Net")
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local Session = require("src.link.Session")
local Tournament = require("src.link.Tournament")
local Wire = require("src.link.Wire")
local failures = 0
local function check(cond, msg)
if cond then
print("ok " .. msg)
else
failures = failures + 1
print("FAIL " .. msg)
end
end
-- ---------------------------------------------------------------- corpus
local HOSTILE = { {}, { 1, 2, 3 }, { type = "x" }, 0, -1, 123, 1.5,
math.huge, "s", "", true, false }
local function copy(v)
if type(v) ~= "table" then return v end
local out = {}
for k, val in pairs(v) do out[k] = copy(val) end
return out
end
local packedMon = Protocol.packMon(Pokemon.new(Data, "PIKACHU", 12))
local TEMPLATES = {
{ type = "hello", protocol = 2, name = "RED", mode = "battle",
engineVersion = "1.0.0", apiVersion = "1", generation = 1,
fingerprint = "abc123", linkModified = false,
mods = { { id = "demo", version = "1.0", affectsLink = true } } },
{ type = "records", pokemon = { PIKACHU = "a" }, moves = { TACKLE = "b" },
heldItems = { BERRY = "c" } },
{ type = "party", mons = { copy(packedMon) }, seed = 1234, forceLevel = 50 },
{ type = "pick", index = 1 },
{ type = "confirm", ok = true },
{ type = "action", kind = "move", slot = 1, index = 1 },
{ type = "hash", turn = 1, value = "v",
parts = { actives = "a", volatile = "b", bench = "c" } },
{ type = "replace", index = 1 },
{ type = "bye" },
{ type = "forfeit" },
{ type = "spectate", side = "host",
msg = { type = "action", kind = "move", slot = 1 } },
{ type = "hosted", code = "ABCDEF" },
{ type = "paired" },
{ type = "peer_gone" },
{ type = "join_error", reason = "not_found" },
{ type = "tournament_hosted", code = "ABCDEF", turnLimit = 6,
requiredPartySize = 3, minLevel = 5, maxLevel = 50, forceLevel = 50,
participating = true },
{ type = "tournament_host_error", reason = "party_ineligible",
requiredPartySize = 3, minLevel = 5, maxLevel = 50 },
{ type = "tournament_join_error", reason = "party_ineligible",
requiredPartySize = 3, minLevel = 5, maxLevel = 50 },
{ type = "tournament_roster", players = { "RED", "BLUE" },
spectators = { "GREEN" }, turnLimit = 6, requiredPartySize = 3,
minLevel = 5, maxLevel = 50, forceLevel = 50 },
{ type = "bracket_update", tournament = { code = "ABCDEF", turnLimit = 6,
requiredPartySize = 3, minLevel = 5, maxLevel = 50, status = "active",
round = 1, champion = "RED",
rounds = { { round = 1, matches = { { a = "RED", b = "BLUE",
winner = "RED", bye = false, state = "live" } } } } } },
{ type = "match_start", opponent = "BLUE", round = 1, turnLimit = 6,
role = "host" },
{ type = "match_start_spectate", round = 1, playerHost = "RED",
playerGuest = "BLUE" },
{ type = "tournament_bye", round = 1 },
{ type = "tournament_over", champion = "RED" },
{ type = "a_type_this_build_has_never_heard_of", payload = { n = 1 } },
}
local NESTED = {
{ "hash", { "parts", "actives" } },
{ "party", { "mons", 1 } },
{ "party", { "mons", 1, "dvs" } },
{ "party", { "mons", 1, "moves" } },
{ "party", { "mons", 1, "moves", 1 } },
{ "party", { "mons", 1, "nickname" } },
{ "party", { "mons", 1, "level" } },
{ "party", { "mons", 1, "otId" } },
{ "hello", { "mods", 1 } },
{ "spectate", { "msg" } },
{ "spectate", { "msg", "slot" } },
{ "bracket_update", { "tournament", "rounds" } },
{ "bracket_update", { "tournament", "rounds", 1, "matches" } },
{ "bracket_update", { "tournament", "rounds", 1, "matches", 1, "a" } },
{ "tournament_roster", { "players", 1 } },
}
local function templateFor(kind)
for _, t in ipairs(TEMPLATES) do
if t.type == kind then return t end
end
end
local function setPath(root, path, value)
local node = root
for i = 1, #path - 1 do
node = node[path[i]]
if type(node) ~= "table" then return false end
end
node[path[#path]] = value
return true
end
local corpus = {}
local function add(msg) corpus[#corpus + 1] = msg end
add(false); add(true); add(123); add("string"); add({}); add({ 1, 2, 3 })
add({ type = 5 }); add({ type = {} }); add({ type = true })
add({ type = ("x"):rep(4096) })
for _, template in ipairs(TEMPLATES) do
add(copy(template))
for key in pairs(template) do
if key ~= "type" then
for _, bad in ipairs(HOSTILE) do
local m = copy(template)
m[key] = copy(bad)
add(m)
end
local missing = copy(template)
missing[key] = nil
add(missing)
end
end
end
for _, row in ipairs(NESTED) do
local template = templateFor(row[1])
for _, bad in ipairs(HOSTILE) do
local m = copy(template)
if setPath(m, row[2], copy(bad)) then add(m) end
end
local m = copy(template)
if setPath(m, row[2], nil) then add(m) end
end
print(("hostile corpus: %d messages"):format(#corpus))
-- ---------------------------------------------------------------- session
local function fakeTransport(inbox)
local transport = { paired = true, closed = false, error = nil,
inbox = inbox, sent = {} }
function transport:update() end
function transport:poll()
local messages = self.inbox
self.inbox = {}
return messages
end
function transport:send(m) table.insert(self.sent, m) end
function transport:close() self.closed = true end
return transport
end
local transport = fakeTransport(copy(corpus))
local session = Session.new(transport, { role = "guest", kind = "link" })
local okUpdate, updateErr = pcall(session.update, session)
check(okUpdate, "the whole hostile corpus goes through Session without throwing"
.. (okUpdate and "" or (": " .. tostring(updateErr))))
check(session:getFailure() == nil,
"a hostile peer cannot latch a terminal failure on the session")
check(session:getStatus() == "paired", "the session is still usable afterwards")
local survivors = session:poll()
check(#survivors > 0, "well-formed messages still get through")
check(session.dropped > 0, "malformed messages are counted as dropped")
for _, msg in ipairs(survivors) do
if type(msg.type) ~= "string" then
check(false, "every delivered message has a string type")
break
end
end
check(true, "every delivered message has a string type")
for _, msg in ipairs(survivors) do
local ok = pcall(Json.encode, msg)
if not ok then
check(false, "every delivered message is still encodable (" .. msg.type .. ")")
break
end
end
check(true, "every delivered message is still encodable")
-- ---------------------------------------------------------------- consumers
local function makeFakeGame(species, name)
local save = require("src.core.SaveData").newGame()
save.player.name = name or "RED"
table.insert(save.party, Pokemon.new(Data, species, 20))
local stack = { list = {} }
function stack:push(s, ...)
table.insert(self.list, s)
if s.enter then s:enter(...) end
end
function stack:pop() return table.remove(self.list) end
function stack:top() return self.list[#self.list] end
function stack:update(dt)
local t = self:top()
if t and t.update then t:update(dt) end
end
return { data = Data, input = Input, stack = stack, save = save }
end
do
local crashed
for _, msg in ipairs(survivors) do
local party = { Pokemon.new(Data, "KADABRA", 30) }
local t = Protocol.TradeSession.new(Data, party)
local ok, err = pcall(function()
t:handle({ type = "party", mons = Protocol.packParty({
Pokemon.new(Data, "MACHOKE", 32) }) })
t:handle(msg)
t:pick(1)
t:handle(msg)
t:confirm(true)
t:handle(msg)
if t.stage == "done" then t:apply(nil) end
end)
if not ok then crashed = ("%s: %s"):format(tostring(msg.type), tostring(err)) end
if crashed then break end
end
check(not crashed, "the trade session survives every hostile message"
.. (crashed and (": " .. crashed) or ""))
end
do
local party = { Pokemon.new(Data, "KADABRA", 30) }
local t = Protocol.TradeSession.new(Data, party)
t:handle({ type = "party",
mons = Protocol.packParty({ Pokemon.new(Data, "MACHOKE", 32) }) })
t:pick(1)
t:handle(Wire.sanitize({ type = "pick", index = 0 }))
t:confirm(true)
t:handle(Wire.sanitize({ type = "confirm", ok = true }))
check(t.stage ~= "done", "an out-of-range pick never reaches a committed trade")
check(t.stage == "cancelled", "...it cancels the trade instead")
end
do
local gameA = makeFakeGame("CHARIZARD", "RED")
local gameB = makeFakeGame("BLASTOISE", "BLUE")
local netA, netB = Net.loopbackPair()
local battleA = LinkBattle.newHost(gameA, netA, {
myParty = Protocol.packParty(gameA.save.party),
theirParty = Protocol.packParty(gameB.save.party),
theirName = "BLUE", seed = 4242 })
gameA.stack:push(battleA)
local crashed
for _, msg in ipairs(survivors) do
table.insert(netA.inbox, copy(msg))
local ok, err = pcall(function()
Input.pressed = {}
gameA.stack:update(1 / 60)
end)
if not ok then
crashed = ("%s: %s"):format(tostring(msg.type), tostring(err))
break
end
end
check(not crashed, "a link battle survives every hostile message"
.. (crashed and (": " .. crashed) or ""))
end
do
local gameSpec = makeFakeGame("RATTATA", "WATCHER")
local specInbox = {}
local specNet = {
closed = false,
update = function() end,
poll = function()
local msgs = specInbox
specInbox = {}
return msgs
end,
send = function() end,
close = function() end,
}
local battle = LinkBattle.newSpectator(gameSpec, specNet, {
hostParty = Protocol.packParty(makeFakeGame("CHARIZARD").save.party),
guestParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party),
hostName = "RED", guestName = "BLUE", seed = 99 })
gameSpec.stack:push(battle)
local crashed
for _, msg in ipairs(corpus) do
for _, side in ipairs({ "host", "guest", 5, {} }) do
local wrapped = Wire.sanitize({ type = "spectate", side = side,
msg = copy(msg) })
if wrapped then table.insert(specInbox, wrapped) end
end
local ok, err = pcall(function()
Input.pressed = {}
gameSpec.stack:update(1 / 60)
end)
if not ok then
crashed = tostring(err)
break
end
end
check(not crashed, "a spectator battle survives every hostile envelope"
.. (crashed and (": " .. crashed) or ""))
end
do
local game = makeFakeGame("PIKACHU", "RED")
local exits = 0
local t = setmetatable({
game = game,
stage = "bracket",
index = 1,
settingsIndex = 1,
settings = { turnLimit = 6, requiredPartySize = 3, minLevel = "ANY",
maxLevel = "ANY", forceLevel = "ANY", participating = true },
roster = {},
spectatorRoster = {},
isCreator = false,
net = { send = function() end, close = function() end,
take = function() return nil end,
poll = function() return {} end,
hasPending = function() return false end },
}, Tournament)
t.exitWith = function(self) exits = exits + 1 end
local crashed
for _, msg in ipairs(survivors) do
local ok, err = pcall(function()
t:handleMessage(msg)
t:draw()
end)
if not ok then
crashed = ("%s: %s"):format(tostring(msg.type), tostring(err))
break
end
if type(t.roster) ~= "table" or type(t.spectatorRoster) ~= "table" then
crashed = ("%s left a non-table roster"):format(tostring(msg.type))
break
end
end
check(not crashed, "the tournament screen survives every hostile message"
.. (crashed and (": " .. crashed) or ""))
end
-- ---------------------------------------------------------------- json
do
local deep = ("["):rep(4096) .. ("]"):rep(4096)
local value, err = Json.decode(deep)
check(value == nil and err ~= nil, "a deeply nested document is refused")
local long = '{"type":"hello","name":"' .. ("x"):rep(1024) .. '"}'
check(Json.decode(long, 256) == nil, "a document past the caller's cap is refused")
check(Json.decode(long) ~= nil, "...and the cap is opt-in for other callers")
end
-- ---------------------------------------------------------------- net caps
do
local n = Net.new()
n.rxBuf = ("x"):rep(Net.MAX_LINE + 1)
n:drainLines()
check(n.closed and n.error ~= nil,
"a peer that never sends a newline closes the connection")
check(#n.rxBuf == 0, "...and the buffer is released")
end
print(("\nlink hostile: %d messages, %d failures"):format(#corpus, failures))
assert(failures == 0, failures .. " hostile-input failure(s)")
return true
+8
View File
@@ -645,6 +645,14 @@ check(fxAdvances("messages", "linkNext"),
local fuzzOk, fuzzErr = pcall(dofile, "tests/link_desync_fuzz.lua")
check(fuzzOk, "lockstep desync fuzz" .. (fuzzOk and "" or (": " .. tostring(fuzzErr))))
-- ---------------------------------------------------------------- hostile wire
-- Every message type crossed with every wrong Lua type, through the real
-- Session choke point and into the real consumers. The regression net for
-- the remote-crash payloads; self-contained like the fuzz above.
local hostileOk, hostileErr = pcall(dofile, "tests/link_hostile.lua")
check(hostileOk, "hostile wire suite"
.. (hostileOk and "" or (": " .. tostring(hostileErr))))
-- ---------------------------------------------------------------- mod link compat
-- Self-contained like the tests/mod_*.lua suites: own bootstrap and
-- assert-based checks, so it lands here as a single pass/fail line.