diff --git a/scripts/fsp-server.py b/scripts/fsp-server.py index d6f0ca4..f9641c2 100755 --- a/scripts/fsp-server.py +++ b/scripts/fsp-server.py @@ -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") diff --git a/scripts/test-fsp-server.py b/scripts/test-fsp-server.py index 20cb2c7..017ae13 100755 --- a/scripts/test-fsp-server.py +++ b/scripts/test-fsp-server.py @@ -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()