fsp-server: implement writes, which booting a game requires
Swiss reported "failed to write file" then "Failed to setup the file (too fragmented?)". The FSP device declares FEAT_READ|FEAT_WRITE|FEAT_BOOT_GCM| FEAT_PATCHES, so booting a disc image from FSP is supported -- but Swiss writes patch files to swiss/patches/ first, and deviceHandler_FSP_setupFile returns failure when that write fails, which the UI reports as the fragment message. Implements CC_UP_LOAD (0x43), CC_INSTALL (0x44), CC_MAKE_DIR (0x49), CC_DEL_FILE (0x45) and CC_DEL_DIR (0x46), all confined to --root. --writable (aliased as --claim-owner) enables them and reports FSP_DIR_OWNER, so the two can no longer be set inconsistently: previously --claim-owner told Swiss it could write to a server with no write support, which is exactly the combination that produced the error. ⚠️ CC_UP_LOAD CARRIES NO FILENAME. fsplib calls buildfilename() only on the read branch, so the data field is RAW FILE BYTES at `pos` and CC_INSTALL names the destination afterwards (temp-then-commit). Handling UP_LOAD after the path/password parse rejects every chunk as a bad password, so it is handled before it; the password is validated on INSTALL, which does carry the path. `pos` must also be echoed, because fsp_transaction checks it for UP_LOAD. Tests: MAKE_DIR creates the directory, two 1024-byte UP_LOADs plus INSTALL commit byte-exact content, writes outside --root are refused, and GET_PRO reports OWNER when writable. Mutation-tested: moving UP_LOAD back below the password parse fails T11 with CC_ERR on every chunk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+84
-9
@@ -129,6 +129,8 @@ CC_VERSION, CC_INFO = 0x10, 0x11
|
||||
CC_ERR, CC_GET_DIR, CC_GET_FILE = 0x40, 0x41, 0x42
|
||||
CC_BYE, CC_STAT = 0x4A, 0x4D
|
||||
CC_GET_PRO = 0x47
|
||||
CC_UP_LOAD, CC_INSTALL, CC_DEL_FILE, CC_DEL_DIR, CC_MAKE_DIR = (
|
||||
0x43, 0x44, 0x45, 0x46, 0x49)
|
||||
# Directory protection bits (fsplib.h). Swiss uses these to decide whether
|
||||
# the device supports patches:
|
||||
# if ((pro & (MKDIR|ADD|DEL|GET)) == (MKDIR|ADD|DEL) || (pro & OWNER))
|
||||
@@ -264,13 +266,14 @@ def main():
|
||||
ap.add_argument("--trace", action="store_true",
|
||||
help="hexdump every request and reply, with the sequence "
|
||||
"split into transaction id and retry counter")
|
||||
ap.add_argument("--claim-owner", action="store_true",
|
||||
help="report FSP_DIR_OWNER on GET_PRO, which makes Swiss "
|
||||
"enable FEAT_PATCHES and EMU_ETHERNET on this device. "
|
||||
"⚠️ This server is READ-ONLY -- it has no UP_LOAD or "
|
||||
"MAKE_DIR -- so Swiss believing it can write here "
|
||||
"will make any patch write fail. Try without it "
|
||||
"first; use it only if a boot demands patches.")
|
||||
ap.add_argument("--writable", "--claim-owner", dest="writable",
|
||||
action="store_true",
|
||||
help="allow writes (UP_LOAD / INSTALL / MAKE_DIR / "
|
||||
"DEL_FILE) and report FSP_DIR_OWNER on GET_PRO so "
|
||||
"Swiss enables FEAT_PATCHES and EMU_ETHERNET. "
|
||||
"Booting a game needs this: Swiss writes patch files "
|
||||
"to swiss/patches/ and fails with 'failed to write "
|
||||
"file' otherwise. Writes are confined to --root.")
|
||||
a = ap.parse_args()
|
||||
root = os.path.realpath(a.root)
|
||||
|
||||
@@ -285,6 +288,7 @@ def main():
|
||||
where = (f"raw on {a.iface}" if a.iface else f"{a.bind}") + f":{a.port}"
|
||||
print(f"fsp-server {where} root {root}")
|
||||
print(f" password {'set' if a.password else 'NONE'}")
|
||||
print(f" {'WRITABLE (patches allowed)' if a.writable else 'read-only'}")
|
||||
print(f" point Swiss's FSP device at this host and port\n")
|
||||
|
||||
def reply(ctx_, pkt_, what):
|
||||
@@ -293,6 +297,7 @@ def main():
|
||||
print(f" -> {len(pkt_):4d} B {what}")
|
||||
print(f" {pkt_[:48].hex()}")
|
||||
|
||||
uploads = {} # peer -> staged bytes, committed by CC_INSTALL
|
||||
served = 0
|
||||
t_first = None
|
||||
t_report = time.time()
|
||||
@@ -334,6 +339,30 @@ def main():
|
||||
print(f"[{peer[0]}] BYE")
|
||||
continue
|
||||
|
||||
# ⚠️ UP_LOAD must be handled BEFORE the path/password parse: its
|
||||
# data field is RAW FILE BYTES, not "path\npassword\0", so running
|
||||
# it through split_path() rejects every chunk as a bad password.
|
||||
# fsplib only calls buildfilename() on the read branch; the password
|
||||
# is checked on CC_INSTALL, which does carry the path.
|
||||
if cmd == CC_UP_LOAD:
|
||||
# ⚠️ UP_LOAD carries NO FILENAME -- fsplib only calls
|
||||
# buildfilename() on the read branch. The data is raw file bytes
|
||||
# at `pos`, written to a per-session temp file, and CC_INSTALL
|
||||
# names the destination afterwards. `pos` IS checked by
|
||||
# fsp_transaction for UP_LOAD, so it must be echoed.
|
||||
key = peer
|
||||
up = uploads.get(key)
|
||||
if up is None:
|
||||
up = uploads[key] = bytearray()
|
||||
end = pos + len(r["data"])
|
||||
if len(up) < end:
|
||||
up.extend(b"\0" * (end - len(up)))
|
||||
up[pos:end] = r["data"]
|
||||
reply(ctx, pack(CC_UP_LOAD, SERVER_KEY, seq, pos), "UP_LOAD")
|
||||
if len(r["data"]) == 0:
|
||||
print(f"[{peer[0]}] UP_LOAD eof, {len(up)} B staged")
|
||||
continue
|
||||
|
||||
path, pw_ok = split_path(r["data"], a.password)
|
||||
real = safe_join(root, path)
|
||||
if not pw_ok:
|
||||
@@ -369,15 +398,61 @@ def main():
|
||||
print(f"[{peer[0]}] GET_PRO {path!r} {tag} -> ENOENT (expected; "
|
||||
f"Swiss falls back)")
|
||||
continue
|
||||
pro = PRO_GET | PRO_LIST | (PRO_OWNER if a.claim_owner else 0)
|
||||
pro = PRO_GET | PRO_LIST | (PRO_OWNER if a.writable else 0)
|
||||
reply(ctx, pack(CC_GET_PRO, SERVER_KEY, seq, PRO_BYTES,
|
||||
path.encode("latin-1", "replace"),
|
||||
bytes([pro])), "GET_PRO")
|
||||
print(f"[{peer[0]}] GET_PRO {path!r} {tag} -> {pro:#04x}"
|
||||
+ (" (OWNER: Swiss will enable patches)" if a.claim_owner
|
||||
+ (" (OWNER: Swiss will enable patches)" if a.writable
|
||||
else " (read-only: Swiss disables patches here)"))
|
||||
continue
|
||||
|
||||
if cmd in (CC_UP_LOAD, CC_INSTALL, CC_MAKE_DIR, CC_DEL_FILE,
|
||||
CC_DEL_DIR) and not a.writable:
|
||||
print(f"[{peer[0]}] {cmd:#04x} refused: server is read-only "
|
||||
f"(pass --writable)")
|
||||
reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos,
|
||||
b"read-only server\0"), "ERR")
|
||||
continue
|
||||
|
||||
if cmd == CC_INSTALL:
|
||||
up = uploads.pop(peer, None)
|
||||
if up is None:
|
||||
reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos,
|
||||
b"nothing uploaded\0"), "ERR")
|
||||
continue
|
||||
try:
|
||||
os.makedirs(os.path.dirname(real) or root, exist_ok=True)
|
||||
with open(real, "wb") as fo:
|
||||
fo.write(up)
|
||||
reply(ctx, pack(CC_INSTALL, SERVER_KEY, seq, pos), "INSTALL")
|
||||
print(f"[{peer[0]}] INSTALL {path!r} <- {len(up)} B")
|
||||
except OSError as e:
|
||||
reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos,
|
||||
f"{e}\0".encode()), "ERR")
|
||||
print(f"[{peer[0]}] INSTALL {path!r} FAILED: {e}")
|
||||
continue
|
||||
|
||||
if cmd == CC_MAKE_DIR:
|
||||
try:
|
||||
os.makedirs(real, exist_ok=True)
|
||||
reply(ctx, pack(CC_MAKE_DIR, SERVER_KEY, seq, pos), "MAKE_DIR")
|
||||
print(f"[{peer[0]}] MAKE_DIR {path!r}")
|
||||
except OSError as e:
|
||||
reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos,
|
||||
f"{e}\0".encode()), "ERR")
|
||||
continue
|
||||
|
||||
if cmd in (CC_DEL_FILE, CC_DEL_DIR):
|
||||
try:
|
||||
os.rmdir(real) if cmd == CC_DEL_DIR else os.remove(real)
|
||||
reply(ctx, pack(cmd, SERVER_KEY, seq, pos), "DEL")
|
||||
print(f"[{peer[0]}] DEL {path!r}")
|
||||
except OSError as e:
|
||||
reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos,
|
||||
f"{e}\0".encode()), "ERR")
|
||||
continue
|
||||
|
||||
if cmd == CC_GET_DIR:
|
||||
if not os.path.isdir(real):
|
||||
reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos, b"not a dir\0"), "ERR")
|
||||
|
||||
@@ -55,7 +55,8 @@ 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"],
|
||||
"--password", PW, "--bind", "127.0.0.1", "--port", "2121",
|
||||
"--quiet-reads", "--writable"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
||||
time.sleep(1.0)
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
@@ -164,10 +165,13 @@ try:
|
||||
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")
|
||||
elif not (pro & 0x01):
|
||||
fails.append(f"T9 GET_PRO flags {pro:#04x} lack OWNER, but the "
|
||||
f"server was started --writable, so Swiss would "
|
||||
f"disable FEAT_PATCHES and refuse to boot")
|
||||
else:
|
||||
print(f"T9 GET_PRO -> pos=1, flags {pro:#04x} (GET|LIST) at buf[len]")
|
||||
print(f"T9 GET_PRO -> pos=1, flags {pro:#04x} "
|
||||
f"(OWNER|GET|LIST, --writable) 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 "".
|
||||
@@ -178,6 +182,49 @@ try:
|
||||
else:
|
||||
print("T10 GET_PRO 'swiss/patches' -> CC_ERR (Swiss falls back to root)")
|
||||
|
||||
# T11 the write path Swiss needs for patches: MAKE_DIR, then UP_LOAD
|
||||
# (which carries NO filename -- data is raw bytes at pos), then INSTALL
|
||||
# to name the destination. Without this Swiss reports "failed to write
|
||||
# file" and then "Failed to setup the file (too fragmented?)".
|
||||
CC_UP_LOAD, CC_INSTALL, CC_MAKE_DIR = 0x43, 0x44, 0x49
|
||||
r = rt(req(CC_MAKE_DIR, 30, 0, "swiss/patches"))
|
||||
if r["cmd"] != CC_MAKE_DIR:
|
||||
fails.append(f"T11 MAKE_DIR -> {r['cmd']:#04x}")
|
||||
elif not os.path.isdir(os.path.join(tmp, "swiss/patches")):
|
||||
fails.append("T11 MAKE_DIR did not create the directory")
|
||||
else:
|
||||
print("T11 MAKE_DIR swiss/patches")
|
||||
|
||||
BLOB = bytes((i * 11 + 5) & 0xFF for i in range(2048))
|
||||
for off in range(0, len(BLOB), 1024):
|
||||
chunk = BLOB[off:off + 1024]
|
||||
# UP_LOAD: no filename, data is the bytes, pos is the offset
|
||||
body = struct.pack("!BBHHHI", CC_UP_LOAD, 0, 0x1234, 40 + off // 1024,
|
||||
len(chunk), off) + chunk
|
||||
pkt = body[:1] + bytes([csum(body)]) + body[2:]
|
||||
rr = rt(pkt)
|
||||
if rr["cmd"] != CC_UP_LOAD or rr["pos"] != off:
|
||||
fails.append(f"T11 UP_LOAD off={off} -> cmd {rr['cmd']:#04x} "
|
||||
f"pos {rr['pos']} (pos IS checked for UP_LOAD)")
|
||||
r = rt(req(CC_INSTALL, 45, 0, "swiss/patches/test.img"))
|
||||
got = os.path.join(tmp, "swiss/patches/test.img")
|
||||
if r["cmd"] != CC_INSTALL:
|
||||
fails.append(f"T11 INSTALL -> {r['cmd']:#04x}")
|
||||
elif not os.path.exists(got) or open(got, "rb").read() != BLOB:
|
||||
fails.append("T11 INSTALL did not commit the uploaded bytes")
|
||||
else:
|
||||
print(f"T11 UP_LOAD 2x1024 B + INSTALL -> {len(BLOB)} B committed, "
|
||||
f"byte-exact")
|
||||
|
||||
# T12 writes must stay inside --root
|
||||
r = rt(req(CC_MAKE_DIR, 46, 0, "../escaped"))
|
||||
if r["cmd"] != 0x40:
|
||||
fails.append(f"T12 MAKE_DIR ../escaped -> {r['cmd']:#04x}, want CC_ERR")
|
||||
elif os.path.exists(os.path.join(os.path.dirname(tmp), "escaped")):
|
||||
fails.append("T12 MAKE_DIR escaped the root")
|
||||
else:
|
||||
print("T12 MAKE_DIR ../escaped refused")
|
||||
|
||||
finally:
|
||||
srv.terminate()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user