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:
2026-10-10 16:32:36 +02:00
parent 7b94da0be5
commit 2172a85f0e
2 changed files with 135 additions and 13 deletions
+51 -4
View File
@@ -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()