Files
rebbarb/scripts/test-fsp-server.py
T
Roflin cb0df55699 fsp-server: reply checksums must use fsplib's VERIFIER formula, not its writer
Swiss hung on "setting up device" while the server logged perfectly reasonable
GET_PRO replies.  fsplib was dropping every one and retrying -- visible in the
log as the same request repeating with the low 3 sequence bits counting up,
since fsp_transaction encodes the retry number there.

FSP has TWO checksum formulas and they differ by the packet length:

  fsp_pkt_write (what a client SENDS):
      checksum = sum(bytes, sum byte zeroed) + used
      sum_byte = checksum + (checksum >> 8)

  fsp_pkt_read (what a client ACCEPTS):
      mysum = sum(bytes) - sum_byte           <- no length term at all
      ok if ((mysum + (mysum >> 8)) & 0xff) == sum_byte

cube/patches/bba/tcpip.c matches the WRITE form (`sum = size` to start) and
never verifies replies, so the in-game path tolerated our packets; the menu's
fsplib did not.  Replies are now built the way fsp_pkt_read verifies.
Incoming requests are accepted under either formula, since a tolerated request
is far easier to diagnose than a rejected one.

⚠️ The self-test passed throughout the bug, and that is the part worth
remembering: it verified replies with the SERVER'S OWN checksum function, so
the two agreed with each other and disagreed with Swiss.  Identical in shape to
the RX descriptor format, where our writer, our readers and our tests were all
self-consistent and all wrong.  The test now transcribes fsp_pkt_read literally
as fsplib_verify() and checks every reply against that, and mutating the server
back to the with-length formula fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 16:16:56 +02:00

187 lines
7.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Self-test for fsp-server.py, using a client that packs requests the way
Swiss's fsplib/tcpip.c do (path\\npassword\\0, 2-byte xlen, v2 checksum)."""
import os, socket, struct, subprocess, sys, tempfile, time
HSIZE = 12
CC_VERSION, CC_ERR, CC_GET_DIR, CC_GET_FILE, CC_STAT = 0x10, 0x40, 0x41, 0x42, 0x4D
PW = "alltheroms"
fails = []
def csum(pkt):
"""fsp_pkt_write / tcpip.c: INCLUDES the length. For requests."""
t = len(pkt) + sum(pkt)
t += t >> 8
return t & 0xFF
def fsplib_verify(pkt):
"""fsp_pkt_read, transcribed literally -- this is what Swiss's menu client
accepts, and it does NOT add the length:
mysum = -ptr[SUM]; for(i..recv_len) mysum += ptr[i];
mysum = (mysum + (mysum >> 8)) & 0xff;
ok if mysum == ptr[SUM]
⚠️ Verify replies with THIS, never with the server's own function: an
earlier version of this test used the server's symmetric checksum, passed
every assertion, and the real Swiss client rejected every reply."""
mysum = -pkt[1]
for b in pkt:
mysum += b
mysum = (mysum + (mysum >> 8)) & 0xFF
return mysum == pkt[1]
def req(cmd, seq, pos, path, pw=PW, extra=b""):
data = path.encode() + (b"\n" + pw.encode() if pw else b"") + b"\0"
body = struct.pack("!BBHHHI", cmd, 0, 0x1234, seq, len(data), pos) + data + extra
return body[:1] + bytes([csum(body)]) + body[2:]
def parse(pkt):
cmd, cs, key, seq, ln, pos = struct.unpack("!BBHHHI", pkt[:HSIZE])
# Replies are checked with FSPLIB's verifier, which is the only opinion
# that matters -- not with csum() above, which is the request formula.
return dict(cmd=cmd, seq=seq, pos=pos, data=pkt[HSIZE:HSIZE + ln],
csum_ok=fsplib_verify(pkt))
tmp = tempfile.mkdtemp()
PAYLOAD = bytes((i * 7 + 3) & 0xFF for i in range(8192))
open(os.path.join(tmp, "dvd.iso"), "wb").write(PAYLOAD)
os.mkdir(os.path.join(tmp, "subdir"))
srv = subprocess.Popen(
[sys.executable, "-I", "scripts/fsp-server.py", "--root", tmp,
"--password", PW, "--bind", "127.0.0.1", "--port", "2121", "--quiet-reads"],
stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
time.sleep(1.0)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3.0)
A = ("127.0.0.1", 2121)
def rt(pkt):
s.sendto(pkt, A)
raw = s.recv(2048)
if not fsplib_verify(raw):
fails.append(f"reply to cmd {pkt[0]:#04x} FAILS fsplib's verifier "
f"-- Swiss would drop it and retry")
return parse(raw)
try:
# T1 checksum both ways
r = rt(req(CC_VERSION, 1, 0, ""))
if r["cmd"] != CC_VERSION or not r["csum_ok"]:
fails.append(f"T1 VERSION: cmd {r['cmd']:#04x} csum_ok {r['csum_ok']}")
else:
print("T1 VERSION answered, reply checksum verifies")
# T2 STAT returns the real size
r = rt(req(CC_STAT, 2, 0, "/dvd.iso"))
t, sz, ty = struct.unpack("!IIB", r["data"][:9])
if sz != len(PAYLOAD) or ty != 0x01:
fails.append(f"T2 STAT size {sz} type {ty}, want {len(PAYLOAD)} / 1")
else:
print(f"T2 STAT -> size {sz}, type FILE")
# T3 GET_DIR lists the file and the subdir, and parses as fsplib does
r = rt(req(CC_GET_DIR, 3, 0, "/"))
d, i, found = r["data"], 0, {}
while i + 9 <= len(d):
ty = d[i + 8]
if ty == 0x00:
break
sz = struct.unpack("!I", d[i + 4:i + 8])[0]
end = d.index(b"\0", i + 9)
found[d[i + 9:end].decode()] = (ty, sz)
i = end + 1
i += (-i) % 4
if found.get("dvd.iso") != (0x01, len(PAYLOAD)) or found.get("subdir", (0,))[0] != 0x02:
fails.append(f"T3 GET_DIR entries wrong: {found}")
else:
print(f"T3 GET_DIR -> {found}")
if (len(r['data'])) % 4:
fails.append("T3 dir block not 4-byte aligned (fsplib rejects dirpos%4)")
# T4 second GET_DIR page must be EMPTY so the client stops looping
r2 = rt(req(CC_GET_DIR, 4, len(r["data"]), "/"))
if len(r2["data"]) != 0:
fails.append(f"T4 second dir page returned {len(r2['data'])} B, want 0")
else:
print("T4 second GET_DIR page empty -> client terminates")
# T5 GET_FILE: right bytes, and sequence+position ECHOED (the in-game
# client discards a reply whose seq or pos does not match)
for pos, want in ((0, 1024), (4096, 1024), (8000, 1024)):
r = rt(req(CC_GET_FILE, 10 + pos, pos, "/dvd.iso",
extra=struct.pack("!H", want)))
exp = PAYLOAD[pos:pos + want]
if r["data"] != exp:
fails.append(f"T5 GET_FILE pos={pos}: {len(r['data'])} B, "
f"want {len(exp)}, match={r['data']==exp}")
elif r["seq"] != 10 + pos or r["pos"] != pos:
fails.append(f"T5 seq/pos not echoed: seq {r['seq']} pos {r['pos']}")
print("T5 GET_FILE at 0 / 4096 / EOF-straddle: bytes exact, seq+pos echoed")
# T6 a 32-aligned request must give a 32-aligned reply, or the in-game
# client's OSRoundDown32B silently truncates and the transfer stalls
r = rt(req(CC_GET_FILE, 99, 0, "/dvd.iso", extra=struct.pack("!H", 1024)))
if len(r["data"]) % 32:
fails.append(f"T6 reply {len(r['data'])} B is not a multiple of 32")
else:
print(f"T6 1024 B request -> {len(r['data'])} B, 32-aligned")
# T7 wrong password must be refused
r = rt(req(CC_STAT, 7, 0, "/dvd.iso", pw="wrong"))
if r["cmd"] != CC_ERR:
fails.append(f"T7 wrong password accepted (cmd {r['cmd']:#04x})")
else:
print("T7 wrong password -> CC_ERR")
# T8 path traversal must not escape the root
r = rt(req(CC_STAT, 8, 0, "/../../etc/passwd"))
t, sz, ty = struct.unpack("!IIB", (r["data"] + b"\0" * 9)[:9])
if r["cmd"] != CC_ERR and ty != 0:
fails.append(f"T8 traversal escaped the root: type {ty} size {sz}")
else:
print("T8 ../../etc/passwd refused")
# T9 GET_PRO: flags go in the EXTRA region at buf[len], and pos must
# equal FSP_PRO_BYTES (1) or fsplib returns ENOMSG.
CC_GET_PRO = 0x47
r = rt(req(CC_GET_PRO, 20, 0, ""))
if r["cmd"] != CC_GET_PRO:
fails.append(f"T9 GET_PRO cmd {r['cmd']:#04x}")
elif r["pos"] != 1:
fails.append(f"T9 GET_PRO pos {r['pos']}, want 1 (FSP_PRO_BYTES) "
f"- fsplib returns ENOMSG otherwise")
else:
s.sendto(req(CC_GET_PRO, 21, 0, ""), A)
raw = s.recv(2048)
ln = struct.unpack("!H", raw[8:10])[0]
pro = raw[HSIZE + ln] # buf[len] -- the first extra byte
if not (pro & 0x10) or not (pro & 0x40):
fails.append(f"T9 GET_PRO flags {pro:#04x} lack GET|LIST")
elif pro & 0x01:
fails.append(f"T9 GET_PRO claims OWNER without --claim-owner")
else:
print(f"T9 GET_PRO -> pos=1, flags {pro:#04x} (GET|LIST) at buf[len]")
# T10 a missing directory must be CC_ERR, so fsplib reports ENOENT and
# Swiss falls back from "swiss/patches" to "swiss" to "".
r = rt(req(CC_GET_PRO, 22, 0, "swiss/patches"))
if r["cmd"] != CC_ERR:
fails.append(f"T10 GET_PRO on a missing dir returned {r['cmd']:#04x}, "
f"want CC_ERR -- Swiss's fallback chain needs ENOENT")
else:
print("T10 GET_PRO 'swiss/patches' -> CC_ERR (Swiss falls back to root)")
finally:
srv.terminate()
print("\nFAILURES:\n " + "\n ".join(fails) if fails else
"\nAll fsp-server self-tests passed.")
sys.exit(1 if fails else 0)