This commit is contained in:
bryanthaboi
2026-08-17 22:52:33 -04:00
parent 4e1ab1879b
commit 7b1e796c48
14 changed files with 1124 additions and 90 deletions
+2 -1
View File
@@ -354,7 +354,8 @@ request with real detail is one that can actually get built.
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
— edit party, boxes, items, events, and Pokédex flags outside the game.
- `docs/architecture.md` — runtime details;
`docs/behavior-porting-notes.md` — formula provenance.
`docs/behavior-porting-notes.md` — formula provenance;
`docs/link-security.md` — what link play defends against, and what it doesn't.
+120
View File
@@ -0,0 +1,120 @@
# Link play: threat model and what the code actually guarantees
Link play is the only part of this game that reads bytes written by
somebody else. This is what it defends against, what it does not, and
where each guarantee lives.
## The boundary
Everything a peer or the relay sends arrives as one JSON object per line.
There is exactly one place it becomes a message:
src/link/Net.lua reads bytes, frames lines, decodes JSON
src/link/Wire.lua rebuilds each line as a typed message
src/link/Session.lua the only path from a transport into a mode
`Session:update` runs `Wire.sanitize` on every message before anything
else sees it. A schema returns a **new** table holding only the fields it
names, at the Lua types it names, so the rest of `src/link/` can read
`msg.slot`, `msg.parts.actives` or `msg.mons[i].dvs.hp` directly and be
right by construction. A message with no schema (a mod's, or a future
build's) keeps a bounded, scalar-only copy of its payload instead of
being dropped.
A message that fails its schema is **dropped and logged**, never fatal.
Latching a terminal failure would hand a hostile peer a cheaper
disconnect than sending nothing at all.
### Why the bounds are loose
Wire's numeric bounds are deliberately wider than the game's own clamps in
`Protocol.unpackMon`. Both peers run identical clamps over identical
packets; a bound that bit an honest value would change one side's copy of
a mon and desync the lockstep. Wire's job is types and sizes. Rules are
`Protocol`'s job, and it keeps its own clamps for the callers that reach
it without a Session (the mod API, `tests/`).
### Containment behind it
Assume something still gets through:
- `Game:step` pcalls the link pump, and pcalls `stack:update` **only
while a link session is active**. On a throw, `Game:breakLink` closes
the connection, unwinds to the overworld and says "The link was
broken." Outside link play the stack is unguarded on purpose: a blanket
pcall would swallow real engine bugs and leave the game silently wrong
instead of loudly broken.
- `Net` caps `rxBuf` at 256KB and its per-frame read at 512KB, so a peer
that never sends a newline ends as a clean disconnect.
- `Json.decode` refuses documents nested past 64 levels, and takes an
optional length cap that the link path passes and the mod-manifest path
does not.
## The relay (`../pokeserver`)
- A line that is not a JSON **object** with a string `type` is dropped
before any handler runs, and `onLine` is wrapped in try/catch.
`server.js` installs `uncaughtException`/`unhandledRejection` handlers:
one bad packet must never take every live match down with the process.
- Line buffers are capped, lines per second are capped, connections per
IP and in total are capped, and an unbound connection that never hosts
or joins is swept after 30s.
- `SERVER_ONLY` is the set of message types the server is the only
legitimate author of (`peer_gone`, `bracket_update`, `match_start`,
`tournament_over`, `spectate`, ...). A peer that sends one has them
dropped rather than forwarded, so a bracket opponent cannot forge a
tournament result or fake "your opponent left".
- Trainer names are reduced to a printable subset and capped at the same
10 characters the game enforces, on the way in, because they are
rendered by the dashboard and broadcast to every participant.
`pokeserver/test/hostile.js` is the regression net for all of that.
## What is NOT defended
**Party legality is trust-the-client.** Online play meets strangers, and
`Handshake.onlineAllowed` is a Lua function in the same VM the mods load
into. It cannot be made tamper-proof in-process, and pretending otherwise
would only cost honest mod authors. What lockstep and
`Protocol.unpackMon`'s recompute-from-species-data *do* guarantee is that
a cheater cannot invent stats, moves, or a shiny: every derived value is
rebuilt locally from real species data. They can send a legal party they
farmed or edited. That is the honest boundary.
What the relay does instead is **observe and record**. It already sees
every `hello`, so it keeps each connection's self-reported
`engineVersion`, `fingerprint` and `linkModified`, compares the two sides
of a room or a live tournament match, and logs and surfaces a
`modded` / `fingerprint_mismatch` / `version_skew` flag on the dashboard.
A patched client can still lie; what it cannot do is lie without the
tournament organizer having a record of it.
Client-side attestation is deliberately not built. This is an
open-source Lua game: it would be theater, and it would break honest
mods.
**The relay has no TLS.** Port 7778 is plaintext, so party contents,
trades and trainer names are visible to anyone on the network path. There
is nothing secret in a Pokemon party, but it is a real property of the
system and not an oversight. Fixing it means a TLS terminator in front of
the relay and a client that speaks it, which is a version break for every
shipped build.
**The dashboard has no default password.** `DASHBOARD_PASSWORD` is
required; with it unset the relay runs and the dashboard simply does not
start. It is still Basic Auth over plain HTTP, so it belongs behind an
IP restriction or an SSH tunnel (`pokeserver/DEPLOY.md`).
## Tests
luajit tests/link_hostile.lua every message type x every wrong type
luajit tests/link_desync_fuzz.lua lockstep fuzz, plus a mutation mode
luajit tests/run_link_tests.lua both of the above, plus the rest
cd ../pokeserver && npm test relay smoke, 16-player bracket, hostile
`tests/link_hostile.lua` builds its corpus from a template per message
type, replaces each field (and several nested ones) with every wrong Lua
type, and drives the survivors through the real trade session, a real
lockstep battle, a real spectator battle, and the tournament screen
**including its draw** -- because the two nastiest payloads are
delayed-fuse ones that crash on render rather than on receipt.
+35 -2
View File
@@ -222,6 +222,26 @@ function Game:touchSkinHotkey(action, pressed)
end
end
function Game:breakLink(err)
Logger.error("link: torn down after an error\n%s", tostring(err))
self.linkSession = nil
local net = self.linkNet
self.linkNet = nil
if net then pcall(net.close, net) end
pcall(ModRuntime.emit, "link.ended", { reason = "error" })
local stack = self.stack
local guard = 0
while #stack.states > 1 and stack:top() ~= self.overworld and guard < 64 do
guard = guard + 1
pcall(stack.pop, stack)
end
pcall(function()
local Strings = require("src.core.Strings")
local TextBox = require("src.render.TextBox")
stack:push(TextBox.new(self, Strings("The link was\nbroken.")))
end)
end
function Game:step(dt)
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
-- the same fixed-step boundary as a physical controller. Run them before
@@ -249,9 +269,22 @@ function Game:step(dt)
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
-- on top of BattleState (see LinkBattle.new)
if self.linkNet and not self.linkNet.closed then
self.linkNet:update()
local ok, err = pcall(self.linkNet.update, self.linkNet)
if not ok then
self:breakLink(err)
return
end
end
if self.linkSession or self.linkNet then
local ok, err = xpcall(function() self.stack:update(dt) end,
function(e) return debug.traceback(tostring(e), 2) end)
if not ok then
self:breakLink(err)
return
end
else
self.stack:update(dt)
end
self.stack:update(dt)
-- play time for the trainer card / save screen
self.save.playTime = (self.save.playTime or 0) + dt
-- Music.update is NOT serviced here: it decrements fade counters and
+6 -2
View File
@@ -270,7 +270,10 @@ end
local function index(mods)
local byId = {}
for _, mod in ipairs(mods or {}) do byId[tostring(mod.id)] = mod end
if type(mods) ~= "table" then return byId end
for _, mod in ipairs(mods) do
if type(mod) == "table" then byId[tostring(mod.id)] = mod end
end
return byId
end
@@ -327,7 +330,8 @@ end
-- lines for the incompatibility screen: what differs, then what still works
function Handshake.describe(localHello, remoteHello, verdict, mode)
local lines = {}
local peer = (remoteHello and remoteHello.name) or "THEY"
local peerName = remoteHello and remoteHello.name
local peer = type(peerName) == "string" and peerName or "THEY"
if verdict == "refused" then
-- checked before the v1 arm for the same reason checkCompat checks it
-- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet
+13 -5
View File
@@ -4,6 +4,8 @@
local Json = {}
Json.MAX_DEPTH = 64
local function encodeValue(v, out)
local t = type(v)
if v == nil then
@@ -109,7 +111,9 @@ local function decodeString(s, i)
error("unterminated string")
end
decodeValue = function(s, i)
decodeValue = function(s, i, depth)
depth = (depth or 0) + 1
assert(depth <= Json.MAX_DEPTH, "json nested too deeply")
i = skipWs(s, i)
local c = s:sub(i, i)
if c == '"' then
@@ -124,7 +128,7 @@ decodeValue = function(s, i)
i = skipWs(s, i)
assert(s:sub(i, i) == ":", "expected :")
local val
val, i = decodeValue(s, i + 1)
val, i = decodeValue(s, i + 1, depth)
obj[key] = val
i = skipWs(s, i)
local d = s:sub(i, i)
@@ -138,7 +142,7 @@ decodeValue = function(s, i)
if s:sub(i, i) == "]" then return arr, i + 1 end
while true do
local val
val, i = decodeValue(s, i)
val, i = decodeValue(s, i, depth)
arr[#arr + 1] = val
i = skipWs(s, i)
local d = s:sub(i, i)
@@ -162,9 +166,13 @@ decodeValue = function(s, i)
end
end
function Json.decode(s)
function Json.decode(s, maxLength)
if type(s) ~= "string" then return nil, "json input is not a string" end
if maxLength and #s > maxLength then
return nil, ("json input is %d bytes (max %d)"):format(#s, maxLength)
end
local ok, v = pcall(function()
local val = select(1, decodeValue(s, 1))
local val = select(1, decodeValue(s, 1, 0))
return val
end)
if ok then return v end
+3 -1
View File
@@ -29,7 +29,9 @@ local LinkBattle = {}
-- Deterministic Park-Miller PRNG: both sides must roll identical
-- streams, so love.math.random can't be used.
local function makeRng(seed)
local s = seed % 2147483647
local s = tonumber(seed) or 1
if s ~= s or s == math.huge or s == -math.huge then s = 1 end
s = math.floor(s) % 2147483647
if s <= 0 then s = s + 2147483646 end
return function(a, b)
s = (s * 16807) % 2147483647
+19 -5
View File
@@ -40,6 +40,10 @@ Net.__index = Net
Net.DEFAULT_PORT = 7777
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
Net.MAX_LINE = 256 * 1024
Net.MAX_RX_PER_FRAME = 512 * 1024
Net.ENET_BANDWIDTH = 256 * 1024
function Net.available()
return enet ~= nil
end
@@ -115,7 +119,8 @@ function Net:host(port)
return false
end
port = tonumber(port) or Net.defaultPort()
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1)
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1,
Net.ENET_BANDWIDTH, Net.ENET_BANDWIDTH)
if not ok or not h then
self.error = ("can't open UDP port %d (%s)"):format(
port, tostring(ok and err or h))
@@ -255,7 +260,7 @@ local function handleGenericRelayControl(self, msg)
end
function Net:handleTCPLine(line)
local msg = Json.decode(line)
local msg = Json.decode(line, Net.MAX_LINE)
if msg == nil then
Logger.warn("link: bad relay message %q", line:sub(1, 60))
return
@@ -277,6 +282,11 @@ function Net:drainLines()
self.rxBuf = self.rxBuf:sub(nl + 1)
if #line > 0 then self:handleTCPLine(line) end
end
if #self.rxBuf > Net.MAX_LINE then
self.rxBuf = ""
self.error = Strings("The other side\nsent bad data.")
self.closed = true
end
end
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
@@ -300,10 +310,14 @@ function Net:updateTCP()
return
end
end
while true do
local budget = Net.MAX_RX_PER_FRAME
while budget > 0 do
local data, err, partial = sock:receive(8192)
local chunk = data or partial or ""
if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end
if #chunk > 0 then
self.rxBuf = self.rxBuf .. chunk
budget = budget - #chunk
end
if err == "closed" then
self.closed = true
break
@@ -350,7 +364,7 @@ function Net:update()
for _, msg in ipairs(queued) do self:send(msg) end
end
elseif event.type == "receive" then
local msg = Json.decode(event.data)
local msg = Json.decode(event.data, Net.MAX_LINE)
if msg ~= nil then
table.insert(self.inbox, msg)
else
+89 -42
View File
@@ -17,6 +17,25 @@ local Runtime = require("src.mods.Runtime")
local Protocol = {}
local MAX_WIRE_NAME = 40
local function num(v, default)
local n = tonumber(v)
if n == nil or n ~= n or n == math.huge or n == -math.huge then
return default
end
return n
end
local function tbl(v)
return type(v) == "table" and v or {}
end
local function text(v)
if type(v) ~= "string" then return nil end
return v:sub(1, MAX_WIRE_NAME)
end
Protocol.hello = Handshake.hello
Protocol.checkCompat = Handshake.checkCompat
@@ -78,6 +97,10 @@ function Protocol.unpackMon(data, packed, opts)
local Stats = require("src.pokemon.Stats")
local Growth = require("src.pokemon.Growth")
local strict = opts and opts.strict
if type(packed) ~= "table" then
if strict then return nil, "unknown POKéMON" end
return nil
end
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
-- the LinkState/Tournament side (see levelForWire) that must mean "no
@@ -91,7 +114,7 @@ function Protocol.unpackMon(data, packed, opts)
if strict then return nil, "unknown POKéMON" end
return nil
end
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
local level = math.max(2, math.min(100, math.floor(num(packed.level, 5))))
-- "auto-level" tournaments/matches: every participant's real level is
-- ignored and everyone rebuilds at the same fixed level instead, so a
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
@@ -99,25 +122,27 @@ function Protocol.unpackMon(data, packed, opts)
if forceLevel then
level = math.max(2, math.min(100, math.floor(forceLevel)))
end
local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp)
local dvs = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
dvs[k] = math.max(0, math.min(15, math.floor(num(packedDvs[k], 0))))
end
local statExp = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
statExp[k] = math.max(0, math.min(65535, math.floor((packed.statExp or {})[k] or 0)))
statExp[k] = math.max(0, math.min(65535, math.floor(num(packedStatExp[k], 0))))
end
local stats = Stats.calc(def, level, dvs, statExp)
local moves = {}
for _, mv in ipairs(packed.moves or {}) do
for _, entry in ipairs(tbl(packed.moves)) do
local mv = tbl(entry)
local mdef = data.moves[mv.id]
if mdef and #moves < 4 then
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0))))
local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5)
local entry = { id = mv.id,
pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) }
if mv.ppUps ~= nil then entry.ppUps = ppUps end
table.insert(moves, entry)
local move = { id = mv.id,
pp = math.max(0, math.min(maxPP, math.floor(num(mv.pp, 0)))) }
if mv.ppUps ~= nil then move.ppUps = ppUps end
table.insert(moves, move)
end
end
if #moves == 0 then
@@ -132,27 +157,29 @@ function Protocol.unpackMon(data, packed, opts)
-- same as a standardized tournament format would
local forced = forceLevel
local hp = forced and stats.hp
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
local status = forced and nil or packed.status
or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp))))
local status = forced and nil or text(packed.status)
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
-- becomes a no-op so the sender's identity survives save/reload (#215).
local otId = packed.otId
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
local packedOtId = num(packed.otId)
local otId = packedOtId
and math.max(0, math.min(65535, math.floor(packedOtId))) or nil
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
return {
species = packed.species,
level = level,
exp = math.max(0, math.floor(packed.exp or Growth.expForLevel(def.growthRate, level))),
exp = math.max(0, math.floor(num(packed.exp,
Growth.expForLevel(def.growthRate, level)))),
dvs = dvs,
statExp = statExp,
stats = stats,
hp = hp,
status = status,
nickname = packed.nickname,
nickname = text(packed.nickname),
ot = ot,
otId = otId,
moves = moves,
@@ -246,6 +273,10 @@ end
function Protocol.unpackMon2(data, packed, opts)
local Mon = require("src.battle.gen2.Mon")
local strict = opts and opts.strict
if type(packed) ~= "table" then
if strict then return nil, "unknown POKéMON" end
return nil
end
local forceLevel = opts and tonumber(opts.forceLevel) or nil
local def = data and data.pokemon and data.pokemon[packed.species]
if not def then
@@ -253,31 +284,33 @@ function Protocol.unpackMon2(data, packed, opts)
return nil
end
local level = math.max(1, math.min(Mon.MAX_LEVEL,
math.floor(packed.level or 5)))
math.floor(num(packed.level, 5))))
if forceLevel then
level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel)))
end
local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp)
local dvs = {}
for _, k in ipairs(GEN2_DVS) do
dvs[k] = math.max(0, math.min(Mon.MAX_DV,
math.floor((packed.dvs or {})[k] or 0)))
math.floor(num(packedDvs[k], 0))))
end
-- derived, never taken from the packet (see GEN2_DVS above)
dvs.hp = Mon.hpDV(dvs)
local statExp = {}
for _, k in ipairs(GEN2_STAT_EXP) do
statExp[k] = math.max(0, math.min(65535,
math.floor((packed.statExp or {})[k] or 0)))
math.floor(num(packedStatExp[k], 0))))
end
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
local moves = {}
for _, mv in ipairs(packed.moves or {}) do
for _, packedMove in ipairs(tbl(packed.moves)) do
local mv = tbl(packedMove)
local mdef = data.moves and data.moves[mv.id]
if mdef and #moves < 4 then
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0))))
local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5)
local entry = { id = mv.id, maxPp = maxPp,
pp = math.max(0, math.min(maxPp, math.floor(mv.pp or 0))) }
pp = math.max(0, math.min(maxPp, math.floor(num(mv.pp, 0)))) }
if mv.ppUps ~= nil then entry.ppUps = ppUps end
table.insert(moves, entry)
end
@@ -296,26 +329,27 @@ function Protocol.unpackMon2(data, packed, opts)
-- an item the peer cannot represent), and the Gen 2 arm of
-- Protocol.eligibleParty is what keeps it from ever reaching here on a
-- negotiated trade.
local item = packed.item
local item = type(packed.item) == "string" and packed.item or nil
if item ~= nil and not (data.items and data.items[item]) then
if strict then return nil, "unknown item" end
item = nil
end
local forced = forceLevel
local hp = forced and stats.hp
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
local status = forced and nil or packed.status
local otId = packed.otId
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp))))
local status = forced and nil or text(packed.status)
local packedOtId = num(packed.otId)
local otId = packedOtId
and math.max(0, math.min(65535, math.floor(packedOtId))) or nil
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
local growth = Mon.growthFor(data, def.growthRate)
local mon = {
species = packed.species,
name = def.name or packed.species,
nickname = packed.nickname,
nickname = text(packed.nickname),
level = level,
experience = math.max(0, math.floor(packed.experience
or Mon.experienceForLevel(growth, level))),
experience = math.max(0, math.floor(num(packed.experience,
Mon.experienceForLevel(growth, level)))),
dvs = dvs,
statExp = statExp,
stats = stats,
@@ -328,10 +362,10 @@ function Protocol.unpackMon2(data, packed, opts)
-- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon
-- keeps what it arrived with, clamped to the byte the cart stores it in
happiness = math.max(0, math.min(255,
math.floor(packed.happiness or 70))),
pokerus = math.max(0, math.min(255, math.floor(packed.pokerus or 0))),
math.floor(num(packed.happiness, 70)))),
pokerus = math.max(0, math.min(255, math.floor(num(packed.pokerus, 0)))),
caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL,
math.floor(packed.caughtLevel or level))),
math.floor(num(packed.caughtLevel, level)))),
ot = ot,
otName = ot,
otId = otId,
@@ -339,7 +373,7 @@ function Protocol.unpackMon2(data, packed, opts)
}
if packed.isEgg then
mon.isEgg = true
mon.eggSteps = math.max(0, math.floor(packed.eggSteps or 0))
mon.eggSteps = math.max(0, math.floor(num(packed.eggSteps, 0)))
end
-- Derived from the DVs on the RECEIVING side, exactly as they were derived on
-- the sending one: shininess, gender and an Unown's letter are all functions
@@ -401,17 +435,19 @@ end
-- which slots are in play and a pick can never land on a different mon.
function Protocol.eligibleParty(party, myRecords, theirRecords)
local eligible, reasons = {}, {}
theirRecords = theirRecords or {}
local theirSpecies = theirRecords.pokemon or {}
local theirMoves = theirRecords.moves or {}
local mySpecies = (myRecords or {}).pokemon or {}
local myMoves = (myRecords or {}).moves or {}
theirRecords = tbl(theirRecords)
myRecords = tbl(myRecords)
local theirSpecies = tbl(theirRecords.pokemon)
local theirMoves = tbl(theirRecords.moves)
local mySpecies = tbl(myRecords.pokemon)
local myMoves = tbl(myRecords.moves)
-- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps
-- the loop below unchanged for Red: a mon with no `item` never reaches the
-- held-item arm at all.
local theirHeld = theirRecords.heldItems
local myHeld = (myRecords or {}).heldItems or {}
for i, mon in ipairs(party or {}) do
local theirHeld = theirRecords.heldItems ~= nil
and tbl(theirRecords.heldItems) or nil
local myHeld = tbl(myRecords.heldItems)
for i, mon in ipairs(tbl(party)) do
local reason
if not theirSpecies[mon.species] then
reason = "not on the other game"
@@ -551,7 +587,7 @@ function TradeSession:handle(msg)
end
if self.stage == "waitParty" then self.stage = "picking" end
elseif msg.type == "pick" then
self.theirPick = msg.index
self.theirPick = num(msg.index)
self:advance()
elseif msg.type == "confirm" then
self.theirConfirm = msg.ok
@@ -583,7 +619,18 @@ function TradeSession:confirm(ok)
return { type = "confirm", ok = ok }
end
function TradeSession:pickResolves()
local index = self.theirPick
if type(index) ~= "number" then return false end
return self.theirParty ~= nil and self.theirParty[index] ~= nil
end
function TradeSession:advance()
if self.theirPick ~= nil and self.theirParty and not self:pickResolves() then
self.stage = "cancelled"
self.error = "the other game picked a POKéMON that isn't there"
return
end
if self.stage == "picking" and self.myPick then
self.stage = self.theirPick and "confirming" or "waitPick"
elseif self.stage == "waitPick" and self.theirPick then
+12 -9
View File
@@ -1,3 +1,6 @@
local Logger = require("src.core.Logger")
local Wire = require("src.link.Wire")
local Session = {}
Session.__index = Session
@@ -23,6 +26,7 @@ function Session.new(transport, options)
_status = "connecting",
_terminal = nil,
_transportCloseCalled = false,
dropped = 0,
paired = false,
closed = false,
error = nil,
@@ -120,16 +124,15 @@ function Session:update()
end
else
for index = 1, #messages do
local message = messages[index]
if type(message) ~= "table" or type(message.type) ~= "string" then
if not failureReason then
failureReason = "protocol_error"
failureDetail = ("message %d must be a table with string type")
:format(index)
end
break
local raw = messages[index]
local ok, message = pcall(Wire.sanitize, raw)
if ok and message then
self._inbox[#self._inbox + 1] = message
else
self.dropped = (self.dropped or 0) + 1
local label = type(raw) == "table" and tostring(raw.type) or type(raw)
Logger.warn("link: dropped malformed message (%s)", label)
end
self._inbox[#self._inbox + 1] = message
end
end
+344
View File
@@ -0,0 +1,344 @@
local Wire = {}
local MAX_INT = 2147483647
local MAX_STRING = 64
local MAX_NAME = 40
local MAX_LIST = 64
local MAX_PARTY = 32
local MAX_MOVES = 8
local MAX_MODS = 256
local MAX_RECORDS = 4096
local MAX_EXTRA_DEPTH = 8
local MAX_ROUNDS = 16
local MAX_MATCHES = 128
function Wire.num(v, default, min, max)
local n = tonumber(v)
if n == nil or n ~= n then return default end
min = min or -MAX_INT
max = max or MAX_INT
if n < min then return min end
if n > max then return max end
return math.floor(n)
end
function Wire.str(v, default, maxLen)
if type(v) ~= "string" then return default end
maxLen = maxLen or MAX_STRING
if #v > maxLen then return v:sub(1, maxLen) end
return v
end
function Wire.bool(v, default)
if type(v) == "boolean" then return v end
return default
end
function Wire.list(v, maxN, fn)
local out = {}
if type(v) ~= "table" then return out end
local n = math.min(#v, maxN or MAX_LIST)
for i = 1, n do
local entry = fn(v[i])
if entry ~= nil then out[#out + 1] = entry end
end
return out
end
function Wire.records(v)
local out = {}
if type(v) ~= "table" then return out end
local n = 0
for k, val in pairs(v) do
if type(k) == "string" then
out[k] = Wire.str(val, nil, MAX_STRING) or tostring(Wire.num(val, 0))
n = n + 1
if n >= MAX_RECORDS then break end
end
end
return out
end
function Wire.plain(v, depth)
if type(v) ~= "table" then return nil end
depth = depth or 0
if depth > MAX_EXTRA_DEPTH then return nil end
local out = {}
for k, val in pairs(v) do
local kt, vt = type(k), type(val)
if kt == "string" or kt == "number" then
if vt == "string" then out[k] = Wire.str(val, nil, MAX_STRING)
elseif vt == "number" or vt == "boolean" then out[k] = val
elseif vt == "table" then out[k] = Wire.plain(val, depth + 1) end
end
end
return out
end
local STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
local function statMap(v)
local out = {}
if type(v) ~= "table" then return out end
for _, k in ipairs(STAT_KEYS) do
out[k] = Wire.num(v[k], nil, 0, 65535)
end
return out
end
local function move(v)
if type(v) ~= "table" then return { id = nil } end
return {
id = Wire.str(v.id, nil, MAX_STRING),
pp = Wire.num(v.pp, nil, 0, 255),
ppUps = Wire.num(v.ppUps, nil, 0, 255),
maxPp = Wire.num(v.maxPp, nil, 0, 255),
}
end
local function mon(v)
if type(v) ~= "table" then return {} end
return {
species = Wire.str(v.species, nil, MAX_STRING),
level = Wire.num(v.level, nil, 0, 65535),
exp = Wire.num(v.exp, nil, 0, MAX_INT),
experience = Wire.num(v.experience, nil, 0, MAX_INT),
hp = Wire.num(v.hp, nil, 0, 65535),
status = Wire.str(v.status, nil, MAX_STRING),
nickname = Wire.str(v.nickname, nil, MAX_NAME),
dvs = statMap(v.dvs),
statExp = statMap(v.statExp),
moves = Wire.list(v.moves, MAX_MOVES, move),
ot = Wire.str(v.ot, nil, MAX_NAME),
otId = Wire.num(v.otId, nil, 0, MAX_INT),
item = Wire.str(v.item, nil, MAX_STRING),
happiness = Wire.num(v.happiness, nil, 0, 65535),
pokerus = Wire.num(v.pokerus, nil, 0, 65535),
caughtLevel = Wire.num(v.caughtLevel, nil, 0, 65535),
isEgg = Wire.bool(v.isEgg, nil),
eggSteps = Wire.num(v.eggSteps, nil, 0, MAX_INT),
extra = Wire.plain(v.extra),
}
end
local function modEntry(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
version = Wire.str(v.version, nil, MAX_NAME)
or Wire.num(v.version, nil, 0, MAX_INT),
affectsLink = Wire.bool(v.affectsLink, nil),
language = Wire.bool(v.language, nil),
}
end
local function name(v)
return Wire.str(v, nil, MAX_NAME)
end
local sanitize
local SCHEMAS = {}
SCHEMAS.hello = function(m)
return {
protocol = Wire.num(m.protocol, nil, 0, MAX_INT),
name = name(m.name),
mode = Wire.str(m.mode, nil, MAX_STRING),
engineVersion = Wire.str(m.engineVersion, nil, MAX_STRING),
apiVersion = Wire.str(m.apiVersion, nil, MAX_STRING),
generation = Wire.num(m.generation, nil, 0, 255),
fingerprint = Wire.str(m.fingerprint, nil, MAX_STRING),
linkModified = Wire.bool(m.linkModified, nil),
mods = Wire.list(m.mods, MAX_MODS, modEntry),
}
end
SCHEMAS.records = function(m)
return {
pokemon = Wire.records(m.pokemon),
moves = Wire.records(m.moves),
heldItems = m.heldItems ~= nil and Wire.records(m.heldItems) or nil,
}
end
SCHEMAS.party = function(m)
return {
mons = Wire.list(m.mons, MAX_PARTY, mon),
seed = Wire.num(m.seed, nil, 0, MAX_INT),
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
}
end
SCHEMAS.pick = function(m)
return { index = Wire.num(m.index, nil, -MAX_INT, MAX_INT) }
end
SCHEMAS.confirm = function(m)
return { ok = Wire.bool(m.ok, false) }
end
SCHEMAS.action = function(m)
return {
kind = Wire.str(m.kind, "", MAX_STRING),
slot = Wire.num(m.slot, nil, 1, MAX_MOVES),
index = Wire.num(m.index, nil, 1, MAX_PARTY),
}
end
SCHEMAS.hash = function(m)
local parts
if type(m.parts) == "table" then
parts = {
actives = Wire.str(m.parts.actives, nil, MAX_STRING),
volatile = Wire.str(m.parts.volatile, nil, MAX_STRING),
bench = Wire.str(m.parts.bench, nil, MAX_STRING),
}
end
return {
turn = Wire.num(m.turn, 0, 0, MAX_INT),
value = Wire.str(m.value, nil, MAX_STRING),
parts = parts,
}
end
SCHEMAS.replace = function(m)
return { index = Wire.num(m.index, 1, 1, MAX_PARTY) }
end
SCHEMAS.bye = function() return {} end
SCHEMAS.forfeit = function() return {} end
SCHEMAS.hosted = function(m)
return { code = Wire.str(m.code, nil, MAX_NAME) }
end
SCHEMAS.paired = function() return {} end
SCHEMAS.peer_gone = function() return {} end
SCHEMAS.join_error = function(m)
return { reason = Wire.str(m.reason, "", MAX_STRING) }
end
local function rule(m)
return {
requiredPartySize = Wire.num(m.requiredPartySize, nil, 0, 255),
minLevel = Wire.num(m.minLevel, nil, 0, 65535),
maxLevel = Wire.num(m.maxLevel, nil, 0, 65535),
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
}
end
SCHEMAS.tournament_hosted = function(m)
local out = rule(m)
out.code = Wire.str(m.code, nil, MAX_NAME)
out.participating = Wire.bool(m.participating, nil)
return out
end
SCHEMAS.tournament_host_error = function(m)
local out = rule(m)
out.reason = Wire.str(m.reason, "", MAX_STRING)
return out
end
SCHEMAS.tournament_join_error = SCHEMAS.tournament_host_error
SCHEMAS.tournament_roster = function(m)
local out = rule(m)
out.players = Wire.list(m.players, MAX_MATCHES, name)
out.spectators = Wire.list(m.spectators, MAX_MATCHES, name)
return out
end
local function match(v)
if type(v) ~= "table" then return nil end
return {
a = name(v.a), b = name(v.b), winner = name(v.winner),
bye = Wire.bool(v.bye, false),
state = Wire.str(v.state, nil, MAX_STRING),
}
end
local function round(v)
if type(v) ~= "table" then return nil end
return {
round = Wire.num(v.round, 0, 0, MAX_ROUNDS),
matches = Wire.list(v.matches, MAX_MATCHES, match),
}
end
SCHEMAS.bracket_update = function(m)
local t = type(m.tournament) == "table" and m.tournament or {}
local out = rule(t)
out.code = Wire.str(t.code, nil, MAX_NAME)
out.status = Wire.str(t.status, nil, MAX_STRING)
out.round = Wire.num(t.round, 0, 0, MAX_ROUNDS)
out.champion = name(t.champion)
out.rounds = Wire.list(t.rounds, MAX_ROUNDS, round)
return { tournament = out }
end
SCHEMAS.match_start = function(m)
return {
opponent = name(m.opponent),
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
role = Wire.str(m.role, "", MAX_STRING),
}
end
SCHEMAS.match_start_spectate = function(m)
return {
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
playerHost = name(m.playerHost),
playerGuest = name(m.playerGuest),
}
end
SCHEMAS.tournament_bye = function(m)
return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS) }
end
SCHEMAS.tournament_over = function(m)
return { champion = name(m.champion) }
end
local SPECTATABLE = {
action = true, replace = true, bye = true, forfeit = true,
hello = true, party = true, hash = true,
}
SCHEMAS.spectate = function(m)
if type(m.msg) ~= "table" or not SPECTATABLE[m.msg.type] then return nil end
local inner = sanitize(m.msg)
if not inner then return nil end
return { side = Wire.str(m.side, "", MAX_STRING), msg = inner }
end
Wire.SCHEMAS = SCHEMAS
local function passthrough(m)
local out = Wire.plain(m) or {}
out.type = nil
return out
end
sanitize = function(msg)
if type(msg) ~= "table" then return nil end
local kind = msg.type
if type(kind) ~= "string" or #kind > MAX_STRING then return nil end
local schema = SCHEMAS[kind]
local out
if schema then
out = schema(msg)
if not out then return nil end
else
out = passthrough(msg)
end
out.type = kind
return out
end
Wire.sanitize = sanitize
return Wire
+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.