This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for the emulator sidecar control surface.
|
||||
|
||||
`resolve_game_path` is the last defence between an authenticated control request and a
|
||||
process launch inside the sidecar, and `desktop_display` decides whether a launch reaches
|
||||
the live desktop at all. Both are covered here so a future sidecar (Azahar, xemu, Cemu,
|
||||
Vita3K) inherits proven behaviour instead of a copied one.
|
||||
|
||||
Run with: python -m unittest discover -s tests/controllers -t .
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
|
||||
sys.path.insert(0, str(DEPLOY))
|
||||
|
||||
import ludarium_sidecar as sidecar # noqa: E402
|
||||
|
||||
|
||||
def load_controller(module_name, file_name, token_variable, token="test-token"):
|
||||
"""Import a controller script under a controlled games root and control token."""
|
||||
import importlib.util
|
||||
root = tempfile.mkdtemp(prefix=f"ludarium-{module_name}-")
|
||||
environment = {token_variable: token, "LUDARIUM_GAMES_ROOT": root}
|
||||
with mock.patch.dict(os.environ, environment, clear=False):
|
||||
spec = importlib.util.spec_from_file_location(module_name, DEPLOY / file_name)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module, Path(root)
|
||||
|
||||
|
||||
class ResolveGamePathTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp(prefix="ludarium-games-"))
|
||||
(self.root / "gamecube").mkdir()
|
||||
self.game = self.root / "gamecube" / "title.iso"
|
||||
self.game.write_bytes(b"\0" * 16)
|
||||
self.extensions = {".iso", ".rvz"}
|
||||
self.platforms = {"gamecube", "wii"}
|
||||
|
||||
def resolve(self, value, platforms=None):
|
||||
return sidecar.resolve_game_path(value, self.root, self.extensions, platforms)
|
||||
|
||||
def test_accepts_an_allowlisted_file_inside_the_mount(self):
|
||||
self.assertEqual(self.resolve("gamecube/title.iso", self.platforms), self.game.resolve())
|
||||
|
||||
def test_rejects_an_absolute_path(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("/etc/passwd")
|
||||
|
||||
def test_rejects_an_empty_path(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("")
|
||||
|
||||
def test_rejects_parent_traversal(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("gamecube/../../etc/passwd", self.platforms)
|
||||
|
||||
def test_normalises_a_current_directory_segment(self):
|
||||
# PurePosixPath drops "." segments, so the path still resolves inside the mount.
|
||||
self.assertEqual(self.resolve("gamecube/./title.iso", self.platforms), self.game.resolve())
|
||||
|
||||
def test_rejects_a_leading_parent_segment(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("../gamecube/title.iso", self.platforms)
|
||||
|
||||
def test_rejects_an_unlisted_platform_directory(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("switch/title.iso", self.platforms)
|
||||
|
||||
def test_rejects_a_bare_file_when_a_platform_directory_is_required(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("title.iso", self.platforms)
|
||||
|
||||
def test_rejects_a_disallowed_extension(self):
|
||||
(self.root / "gamecube" / "notes.txt").write_text("x")
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("gamecube/notes.txt", self.platforms)
|
||||
|
||||
def test_extension_matching_is_case_insensitive(self):
|
||||
upper = self.root / "gamecube" / "Upper.ISO"
|
||||
upper.write_bytes(b"\0" * 16)
|
||||
self.assertEqual(self.resolve("gamecube/Upper.ISO", self.platforms), upper.resolve())
|
||||
|
||||
def test_rejects_a_missing_file(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("gamecube/absent.iso", self.platforms)
|
||||
|
||||
def test_rejects_a_directory(self):
|
||||
(self.root / "gamecube" / "disc.iso").mkdir()
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("gamecube/disc.iso", self.platforms)
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "symlink") and sys.platform != "win32",
|
||||
"symlink creation needs privileges on Windows")
|
||||
def test_rejects_a_symlink_that_escapes_the_mount(self):
|
||||
outside = Path(tempfile.mkdtemp(prefix="ludarium-outside-")) / "secret.iso"
|
||||
outside.write_bytes(b"\0" * 16)
|
||||
os.symlink(outside, self.root / "gamecube" / "link.iso")
|
||||
with self.assertRaises(ValueError):
|
||||
self.resolve("gamecube/link.iso", self.platforms)
|
||||
|
||||
def test_accepts_a_bare_file_when_no_platform_directory_is_required(self):
|
||||
game = self.root / "title.rvz"
|
||||
game.write_bytes(b"\0" * 16)
|
||||
self.assertEqual(self.resolve("title.rvz"), game.resolve())
|
||||
|
||||
|
||||
class DesktopDisplayTests(unittest.TestCase):
|
||||
"""The live Eden defect: after a restart the newest socket is not the active display."""
|
||||
|
||||
def test_prefers_the_display_published_by_the_running_process(self):
|
||||
def run(argv, **kwargs):
|
||||
if argv[0].endswith("pgrep"):
|
||||
return mock.Mock(returncode=0, stdout="4242")
|
||||
return mock.Mock(returncode=0, stdout=b"HOME=/config\0DISPLAY=:7\0LANG=C\0")
|
||||
with mock.patch.object(sidecar.subprocess, "run", side_effect=run):
|
||||
self.assertEqual(sidecar.desktop_display("eden"), ":7")
|
||||
|
||||
def test_falls_back_to_the_newest_socket_when_no_process_is_running(self):
|
||||
with mock.patch.object(sidecar.subprocess, "run",
|
||||
return_value=mock.Mock(returncode=1, stdout="")), \
|
||||
mock.patch.object(sidecar.Path, "glob", autospec=True) as glob:
|
||||
sockets = []
|
||||
for number, mtime in ((0, 100), (9, 50)):
|
||||
socket = mock.Mock()
|
||||
socket.name = f"X{number}"
|
||||
socket.stat.return_value = mock.Mock(st_mtime_ns=mtime)
|
||||
sockets.append(socket)
|
||||
glob.return_value = sockets
|
||||
# The newest socket wins over the highest number, matching the live fallback.
|
||||
self.assertEqual(sidecar.desktop_display("eden"), ":0")
|
||||
|
||||
def test_falls_back_to_the_environment_when_nothing_is_discoverable(self):
|
||||
with mock.patch.object(sidecar.subprocess, "run",
|
||||
return_value=mock.Mock(returncode=1, stdout="")), \
|
||||
mock.patch.object(sidecar.Path, "glob", autospec=True, return_value=[]), \
|
||||
mock.patch.dict(os.environ, {"DISPLAY": ":3"}, clear=False):
|
||||
self.assertEqual(sidecar.desktop_display("eden"), ":3")
|
||||
|
||||
def test_ignores_a_malformed_published_display(self):
|
||||
def run(argv, **kwargs):
|
||||
if argv[0].endswith("pgrep"):
|
||||
return mock.Mock(returncode=0, stdout="4242")
|
||||
return mock.Mock(returncode=0, stdout=b"DISPLAY=:not-a-number\0")
|
||||
with mock.patch.object(sidecar.subprocess, "run", side_effect=run), \
|
||||
mock.patch.object(sidecar.Path, "glob", autospec=True, return_value=[]), \
|
||||
mock.patch.dict(os.environ, {"DISPLAY": ":1"}, clear=False):
|
||||
self.assertEqual(sidecar.desktop_display("eden"), ":1")
|
||||
|
||||
def test_survives_a_failing_process_lookup(self):
|
||||
with mock.patch.object(sidecar.subprocess, "run", side_effect=OSError("no pgrep")), \
|
||||
mock.patch.object(sidecar.Path, "glob", autospec=True, return_value=[]), \
|
||||
mock.patch.dict(os.environ, {"DISPLAY": ":2"}, clear=False):
|
||||
self.assertEqual(sidecar.desktop_display("eden"), ":2")
|
||||
|
||||
|
||||
class VisibleWindowTests(unittest.TestCase):
|
||||
def test_prefers_the_launched_process_id(self):
|
||||
calls = []
|
||||
|
||||
def run(argv, **kwargs):
|
||||
calls.append(argv)
|
||||
return mock.Mock(returncode=0, stdout="123\n")
|
||||
with mock.patch.object(sidecar.subprocess, "run", side_effect=run):
|
||||
self.assertEqual(sidecar.visible_windows({}, pid=99, name="Dolphin"), ["123"])
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("--pid", calls[0])
|
||||
|
||||
def test_falls_back_to_the_window_name(self):
|
||||
def run(argv, **kwargs):
|
||||
return mock.Mock(returncode=0, stdout="" if "--pid" in argv else "456\n")
|
||||
with mock.patch.object(sidecar.subprocess, "run", side_effect=run):
|
||||
self.assertEqual(sidecar.visible_windows({}, pid=99, name="Dolphin"), ["456"])
|
||||
|
||||
def test_returns_nothing_when_no_window_matches(self):
|
||||
with mock.patch.object(sidecar.subprocess, "run",
|
||||
return_value=mock.Mock(returncode=1, stdout="")):
|
||||
self.assertEqual(sidecar.visible_windows({}, pid=99, name="Dolphin"), [])
|
||||
|
||||
|
||||
def build_runtime(root, **overrides):
|
||||
options = dict(
|
||||
name="dolphin", process_name="dolphin-emu",
|
||||
launch_argv=lambda target: ["/usr/bin/dolphin-emu", "--batch", "--exec", str(target)],
|
||||
games=root, extensions={".iso"}, hotkeys={"pause-resume": "F10"},
|
||||
save_mode="native-and-savestate-persistent", platforms={"gamecube"},
|
||||
window_name="Dolphin", ready_seconds=0, save_paths=())
|
||||
options.update(overrides)
|
||||
return sidecar.EmulatorRuntime(**options)
|
||||
|
||||
|
||||
class EmulatorRuntimeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp(prefix="ludarium-runtime-"))
|
||||
(self.root / "gamecube").mkdir()
|
||||
(self.root / "gamecube" / "title.iso").write_bytes(b"\0" * 16)
|
||||
self.runtime = build_runtime(self.root)
|
||||
self.environment = mock.patch.object(
|
||||
sidecar, "runtime_environment", return_value={"DISPLAY": ":1"})
|
||||
self.environment.start()
|
||||
self.addCleanup(self.environment.stop)
|
||||
|
||||
def test_reports_no_running_title_before_a_launch(self):
|
||||
self.assertEqual(self.runtime.status(),
|
||||
{"running": False, "pid": None, "saveRoot": None, "savePaths": [],
|
||||
"saveMode": "native-and-savestate-persistent", "saveData": False})
|
||||
|
||||
def test_launch_returns_the_process_id_and_reports_running(self):
|
||||
process = mock.Mock(pid=4321)
|
||||
process.poll.return_value = None
|
||||
with mock.patch.object(sidecar.subprocess, "Popen", return_value=process) as popen:
|
||||
self.assertEqual(self.runtime.launch(self.root / "gamecube" / "title.iso"), 4321)
|
||||
argv = popen.call_args.args[0]
|
||||
self.assertEqual(argv[:2], [sidecar.SETUIDGID, sidecar.RUNTIME_USER])
|
||||
self.assertIn("--batch", argv)
|
||||
self.assertTrue(self.runtime.status()["running"])
|
||||
|
||||
def test_launch_reports_an_early_exit(self):
|
||||
process = mock.Mock(pid=4321, returncode=1)
|
||||
process.poll.return_value = 1
|
||||
with mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
|
||||
self.assertIsNone(self.runtime.launch(self.root / "gamecube" / "title.iso"))
|
||||
|
||||
def test_launch_terminates_a_previously_running_title(self):
|
||||
first = mock.Mock(pid=1)
|
||||
first.poll.return_value = None
|
||||
second = mock.Mock(pid=2)
|
||||
second.poll.return_value = None
|
||||
with mock.patch.object(sidecar.subprocess, "Popen", side_effect=[first, second]):
|
||||
self.runtime.launch(self.root / "gamecube" / "title.iso")
|
||||
self.runtime.launch(self.root / "gamecube" / "title.iso")
|
||||
first.send_signal.assert_called_once_with(sidecar.signal.SIGTERM)
|
||||
|
||||
def test_stop_reports_false_when_nothing_runs(self):
|
||||
self.assertFalse(self.runtime.stop())
|
||||
|
||||
def test_stop_kills_a_title_that_ignores_termination(self):
|
||||
process = mock.Mock(pid=7)
|
||||
process.poll.return_value = None
|
||||
process.wait.side_effect = [sidecar.subprocess.TimeoutExpired("dolphin-emu", 8), 0]
|
||||
with mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
|
||||
self.runtime.launch(self.root / "gamecube" / "title.iso")
|
||||
self.assertTrue(self.runtime.stop())
|
||||
process.kill.assert_called_once()
|
||||
self.assertFalse(self.runtime.status()["running"])
|
||||
|
||||
def test_hotkey_targets_the_launched_process(self):
|
||||
process = mock.Mock(pid=555)
|
||||
process.poll.return_value = None
|
||||
sent = []
|
||||
|
||||
def run(argv, **kwargs):
|
||||
sent.append(argv)
|
||||
return mock.Mock(returncode=0, stdout="900\n")
|
||||
with mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
|
||||
self.runtime.launch(self.root / "gamecube" / "title.iso")
|
||||
with mock.patch.object(sidecar.subprocess, "run", side_effect=run):
|
||||
self.assertTrue(self.runtime.hotkey("F10"))
|
||||
self.assertIn("555", sent[0])
|
||||
self.assertEqual(sent[-1][-3:], ["900", "key", "F10"])
|
||||
|
||||
def test_hotkey_reports_false_without_a_window(self):
|
||||
with mock.patch.object(sidecar.subprocess, "run",
|
||||
return_value=mock.Mock(returncode=1, stdout="")):
|
||||
self.assertFalse(self.runtime.hotkey("F10"))
|
||||
|
||||
|
||||
class ControlSurfaceTests(unittest.TestCase):
|
||||
"""Covers the exact status codes the Ludarium API depends on."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp(prefix="ludarium-surface-"))
|
||||
(self.root / "gamecube").mkdir()
|
||||
(self.root / "gamecube" / "title.iso").write_bytes(b"\0" * 16)
|
||||
self.runtime = build_runtime(self.root)
|
||||
self.server = ThreadingHTTPServer(
|
||||
("127.0.0.1", 0), sidecar.build_handler(self.runtime, "secret-token"))
|
||||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||
self.addCleanup(self.server.server_close)
|
||||
self.addCleanup(self.server.shutdown)
|
||||
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
|
||||
|
||||
def call(self, path, method="GET", body=None, token="secret-token", raw=None):
|
||||
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
|
||||
request = urllib.request.Request(self.base + path, data=payload, method=method)
|
||||
if token:
|
||||
request.add_header("X-Ludarium-Control-Token", token)
|
||||
if payload is not None:
|
||||
request.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, json.loads(response.read() or b"{}")
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, json.loads(error.read() or b"{}")
|
||||
|
||||
def test_health_needs_no_token(self):
|
||||
self.assertEqual(self.call("/health", token=None), (200, {"status": "healthy"}))
|
||||
|
||||
def test_status_needs_a_token(self):
|
||||
self.assertEqual(self.call("/v1/status", token=None)[0], 404)
|
||||
self.assertEqual(self.call("/v1/status", token="wrong")[0], 404)
|
||||
|
||||
def test_status_reports_the_save_mode(self):
|
||||
status, body = self.call("/v1/status")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body, {"running": False, "pid": None, "saveRoot": None, "savePaths": [],
|
||||
"saveMode": "native-and-savestate-persistent", "saveData": False})
|
||||
|
||||
def test_unknown_get_route_is_not_found(self):
|
||||
self.assertEqual(self.call("/v1/anything")[0], 404)
|
||||
|
||||
def test_unauthenticated_post_is_not_found(self):
|
||||
self.assertEqual(self.call("/v1/launch", "POST", {"path": "gamecube/title.iso"},
|
||||
token=None)[0], 404)
|
||||
|
||||
def test_launch_accepts_an_allowlisted_game(self):
|
||||
process = mock.Mock(pid=31337)
|
||||
process.poll.return_value = None
|
||||
with mock.patch.object(sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}), \
|
||||
mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
|
||||
status, body = self.call("/v1/launch", "POST", {"path": "gamecube/title.iso"})
|
||||
self.assertEqual(status, 202)
|
||||
self.assertEqual(body["pid"], 31337)
|
||||
self.assertEqual(body["saveMode"], "native-and-savestate-persistent")
|
||||
|
||||
def test_launch_rejects_traversal(self):
|
||||
status, body = self.call("/v1/launch", "POST", {"path": "gamecube/../../etc/passwd"})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn("Unsafe", body["message"])
|
||||
|
||||
def test_launch_reports_an_early_exit_as_unavailable(self):
|
||||
process = mock.Mock(pid=1, returncode=3)
|
||||
process.poll.return_value = 3
|
||||
with mock.patch.object(sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}), \
|
||||
mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
|
||||
self.assertEqual(self.call("/v1/launch", "POST", {"path": "gamecube/title.iso"})[0], 503)
|
||||
|
||||
def test_oversized_body_is_rejected(self):
|
||||
self.assertEqual(self.call("/v1/launch", "POST", raw=b"{" + b"a" * 9000)[0], 400)
|
||||
|
||||
def test_empty_body_is_rejected(self):
|
||||
self.assertEqual(self.call("/v1/launch", "POST", raw=b"")[0], 400)
|
||||
|
||||
def test_non_object_body_is_rejected(self):
|
||||
self.assertEqual(self.call("/v1/action", "POST", raw=b'"stop"')[0], 400)
|
||||
|
||||
def test_unsupported_action_is_rejected(self):
|
||||
self.assertEqual(self.call("/v1/action", "POST", {"action": "self-destruct"})[0], 400)
|
||||
|
||||
def test_stop_without_a_running_title_conflicts(self):
|
||||
self.assertEqual(self.call("/v1/action", "POST", {"action": "stop"})[0], 409)
|
||||
|
||||
def test_hotkey_without_a_window_conflicts(self):
|
||||
with mock.patch.object(sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}), \
|
||||
mock.patch.object(sidecar.subprocess, "run",
|
||||
return_value=mock.Mock(returncode=1, stdout="")):
|
||||
self.assertEqual(self.call("/v1/action", "POST", {"action": "pause-resume"})[0], 409)
|
||||
|
||||
|
||||
class ControllerIdentityTests(unittest.TestCase):
|
||||
"""Each shipped controller must keep its exact runtime contract."""
|
||||
|
||||
def test_dolphin_controller(self):
|
||||
module, root = load_controller("dolphin_controller", "dolphin-controller.py",
|
||||
"LUDARIUM_DOLPHIN_CONTROL_TOKEN")
|
||||
runtime = module.RUNTIME
|
||||
# Dolphin's profile also holds a shader cache; only save-bearing directories are captured.
|
||||
self.assertEqual(runtime.save_paths, ("GC", "Wii", "StateSaves"))
|
||||
self.assertEqual(runtime.name, "dolphin")
|
||||
self.assertEqual(runtime.process_name, "dolphin-emu")
|
||||
self.assertEqual(runtime.platforms, {"gamecube", "wii"})
|
||||
self.assertEqual(runtime.extensions,
|
||||
{".iso", ".gcm", ".rvz", ".gcz", ".wbfs", ".wia"})
|
||||
self.assertEqual(set(runtime.hotkeys),
|
||||
{"pause-resume", "fullscreen", "save-state", "load-state"})
|
||||
self.assertEqual(runtime.save_mode, "native-and-savestate-persistent")
|
||||
self.assertEqual(runtime.launch_argv("/games/wii/a.iso"),
|
||||
["/usr/bin/dolphin-emu", "--batch", "--exec", "/games/wii/a.iso"])
|
||||
self.assertEqual(Path(runtime.games), root)
|
||||
|
||||
def test_eden_controller(self):
|
||||
module, root = load_controller("eden_controller", "eden-controller.py",
|
||||
"LUDARIUM_EDEN_CONTROL_TOKEN")
|
||||
runtime = module.RUNTIME
|
||||
self.assertEqual(runtime.save_paths, ("nand/user/save",))
|
||||
self.assertEqual(runtime.name, "eden")
|
||||
self.assertEqual(runtime.process_name, "eden")
|
||||
self.assertIsNone(runtime.platforms)
|
||||
self.assertEqual(runtime.extensions, {".xci", ".nsp"})
|
||||
self.assertEqual(set(runtime.hotkeys), {"pause-resume", "fullscreen"})
|
||||
self.assertEqual(runtime.save_mode, "native-persistent")
|
||||
self.assertEqual(runtime.launch_argv("/games/a.xci"),
|
||||
["/usr/bin/eden", "-f", "-g", "/games/a.xci"])
|
||||
self.assertEqual(Path(runtime.games), root)
|
||||
|
||||
def test_a_controller_refuses_to_serve_without_a_token(self):
|
||||
module, _ = load_controller("eden_untokened", "eden-controller.py",
|
||||
"LUDARIUM_EDEN_CONTROL_TOKEN", token="")
|
||||
with self.assertRaises(SystemExit):
|
||||
module.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class SaveDataTests(unittest.TestCase):
|
||||
"""Round-tripping an emulator's app-owned save directories through the vault."""
|
||||
|
||||
SAVE_PATHS = ("nand/user/save", "StateSaves")
|
||||
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp(prefix="ludarium-saves-"))
|
||||
(self.root / "nand" / "user" / "save").mkdir(parents=True)
|
||||
(self.root / "nand" / "user" / "save" / "save.bin").write_bytes(b"progress" * 64)
|
||||
(self.root / "StateSaves").mkdir()
|
||||
(self.root / "StateSaves" / "slot1.sav").write_bytes(b"state")
|
||||
# A profile also holds material a capture must never carry: on a real Dolphin install this
|
||||
# is a shader cache far larger than one bounded revision.
|
||||
(self.root / "Cache").mkdir()
|
||||
(self.root / "Cache" / "shaders.bin").write_bytes(b"x" * 4096)
|
||||
(self.root / "config.ini").write_text("[General]\n")
|
||||
|
||||
def export(self):
|
||||
return sidecar.export_save_data(self.root, self.SAVE_PATHS)
|
||||
|
||||
def test_export_packs_only_the_configured_save_directories(self):
|
||||
import io
|
||||
import tarfile
|
||||
with tarfile.open(fileobj=io.BytesIO(self.export()), mode="r:gz") as archive:
|
||||
names = sorted(member.name for member in archive)
|
||||
self.assertEqual(names, ["StateSaves/slot1.sav", "nand/user/save/save.bin"])
|
||||
self.assertTrue(all(member.mode == 0o644 and member.uid == 0 for member in archive))
|
||||
|
||||
def test_export_packs_the_whole_root_when_no_paths_are_configured(self):
|
||||
import io
|
||||
import tarfile
|
||||
with tarfile.open(fileobj=io.BytesIO(sidecar.export_save_data(self.root)), mode="r:gz") as archive:
|
||||
self.assertIn("Cache/shaders.bin", [member.name for member in archive])
|
||||
|
||||
def test_export_ignores_a_configured_path_that_does_not_exist(self):
|
||||
import io
|
||||
import tarfile
|
||||
payload = sidecar.export_save_data(self.root, ("StateSaves", "GC", "Wii"))
|
||||
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
|
||||
self.assertEqual([member.name for member in archive], ["StateSaves/slot1.sav"])
|
||||
|
||||
def test_export_rejects_a_missing_directory(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sidecar.export_save_data(self.root / "absent")
|
||||
|
||||
def test_export_rejects_a_profile_with_no_save_directories(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sidecar.export_save_data(self.root, ("GC", "Wii"))
|
||||
|
||||
def test_export_rejects_an_escaping_configured_path(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sidecar.export_save_data(self.root, ("../..",))
|
||||
|
||||
def test_a_round_trip_restores_the_exact_content(self):
|
||||
payload = self.export()
|
||||
(self.root / "nand" / "user" / "save" / "save.bin").write_bytes(b"overwritten")
|
||||
(self.root / "StateSaves" / "slot1.sav").unlink()
|
||||
|
||||
self.assertEqual(sidecar.import_save_data(self.root, payload, self.SAVE_PATHS), 2)
|
||||
self.assertEqual((self.root / "nand" / "user" / "save" / "save.bin").read_bytes(), b"progress" * 64)
|
||||
self.assertEqual((self.root / "StateSaves" / "slot1.sav").read_bytes(), b"state")
|
||||
|
||||
def test_a_restore_leaves_unrelated_profile_content_alone(self):
|
||||
payload = self.export()
|
||||
sidecar.import_save_data(self.root, payload, self.SAVE_PATHS)
|
||||
self.assertEqual((self.root / "config.ini").read_text(), "[General]\n")
|
||||
self.assertEqual((self.root / "Cache" / "shaders.bin").stat().st_size, 4096)
|
||||
|
||||
def test_restore_rejects_a_member_outside_the_save_directories(self):
|
||||
import io
|
||||
import tarfile
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
info = tarfile.TarInfo("config.ini")
|
||||
info.size = 5
|
||||
archive.addfile(info, io.BytesIO(b"evil!"))
|
||||
# A stored revision must never be able to reach an emulator's configuration.
|
||||
with self.assertRaises(ValueError):
|
||||
sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS)
|
||||
self.assertEqual((self.root / "config.ini").read_text(), "[General]\n")
|
||||
|
||||
def test_restore_rejects_an_archive_that_escapes_the_save_directory(self):
|
||||
import io
|
||||
import tarfile
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
info = tarfile.TarInfo("../escaped.bin")
|
||||
info.size = 4
|
||||
archive.addfile(info, io.BytesIO(b"evil"))
|
||||
with self.assertRaises(ValueError):
|
||||
sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS)
|
||||
self.assertFalse((self.root.parent / "escaped.bin").exists())
|
||||
|
||||
def test_restore_rejects_a_symlink_member(self):
|
||||
import io
|
||||
import tarfile
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
info = tarfile.TarInfo("link")
|
||||
info.type = tarfile.SYMTYPE
|
||||
info.linkname = "/etc/passwd"
|
||||
archive.addfile(info)
|
||||
with self.assertRaises(ValueError):
|
||||
sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS)
|
||||
|
||||
def test_restore_rejects_an_oversized_archive(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sidecar.import_save_data(self.root, b"\0" * (sidecar.MAX_SAVE_DATA_BYTES + 1), self.SAVE_PATHS)
|
||||
|
||||
def test_restore_prevalidates_the_expanded_size_before_writing(self):
|
||||
import io
|
||||
import tarfile
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
info = tarfile.TarInfo("save/large.bin")
|
||||
info.size = 5
|
||||
archive.addfile(info, io.BytesIO(b"12345"))
|
||||
|
||||
with mock.patch.object(sidecar, "MAX_SAVE_DATA_EXPANDED_BYTES", 4), self.assertRaises(ValueError):
|
||||
sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS)
|
||||
self.assertFalse((self.root / "save" / "large.bin").exists())
|
||||
|
||||
def test_status_reports_save_data_support_and_the_exact_directories(self):
|
||||
without = build_runtime(self.root)
|
||||
with_saves = build_runtime(self.root, save_root=str(self.root), save_paths=self.SAVE_PATHS)
|
||||
|
||||
self.assertFalse(without.status()["saveData"])
|
||||
status = with_saves.status()
|
||||
self.assertTrue(status["saveData"])
|
||||
self.assertEqual(status["saveRoot"], str(self.root))
|
||||
# A misconfigured path is visible here instead of surfacing at capture time.
|
||||
self.assertEqual(sorted(Path(path).name for path in status["savePaths"]),
|
||||
["StateSaves", "save"])
|
||||
self.assertFalse(build_runtime(self.root, save_root=str(self.root / "absent")).status()["saveData"])
|
||||
self.assertFalse(build_runtime(self.root, save_root=str(self.root),
|
||||
save_paths=("GC",)).status()["saveData"])
|
||||
|
||||
|
||||
class SaveDataSurfaceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp(prefix="ludarium-savesurface-"))
|
||||
(self.root / "gamecube").mkdir()
|
||||
(self.root / "gamecube" / "title.iso").write_bytes(b"\0" * 16)
|
||||
self.saves = Path(tempfile.mkdtemp(prefix="ludarium-saveroot-"))
|
||||
(self.saves / "GC").mkdir()
|
||||
(self.saves / "GC" / "card.raw").write_bytes(b"memory-card")
|
||||
self.runtime = build_runtime(self.root, save_root=str(self.saves), save_paths=("GC",))
|
||||
self.server = ThreadingHTTPServer(
|
||||
("127.0.0.1", 0), sidecar.build_handler(self.runtime, "secret-token"))
|
||||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||
self.addCleanup(self.server.server_close)
|
||||
self.addCleanup(self.server.shutdown)
|
||||
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
|
||||
|
||||
def request(self, method, data=None, token="secret-token"):
|
||||
request = urllib.request.Request(self.base + "/v1/save-data", data=data, method=method)
|
||||
if token:
|
||||
request.add_header("X-Ludarium-Control-Token", token)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, error.read()
|
||||
|
||||
def test_export_needs_a_token(self):
|
||||
self.assertEqual(self.request("GET", token=None)[0], 404)
|
||||
|
||||
def test_export_and_restore_round_trip_over_http(self):
|
||||
status, payload = self.request("GET")
|
||||
self.assertEqual(status, 200)
|
||||
(self.saves / "GC" / "card.raw").write_bytes(b"lost")
|
||||
|
||||
self.assertEqual(self.request("POST", payload)[0], 202)
|
||||
self.assertEqual((self.saves / "GC" / "card.raw").read_bytes(), b"memory-card")
|
||||
|
||||
def test_restore_rejects_a_non_archive(self):
|
||||
self.assertEqual(self.request("POST", b"not-an-archive")[0], 400)
|
||||
|
||||
def test_restore_rejects_an_empty_body(self):
|
||||
self.assertEqual(self.request("POST", b"")[0], 400)
|
||||
|
||||
def test_a_runtime_without_a_save_directory_conflicts(self):
|
||||
runtime = build_runtime(self.root)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), sidecar.build_handler(runtime, "secret-token"))
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
self.addCleanup(server.server_close)
|
||||
self.addCleanup(server.shutdown)
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{server.server_address[1]}/v1/save-data", method="GET")
|
||||
request.add_header("X-Ludarium-Control-Token", "secret-token")
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:
|
||||
urllib.request.urlopen(request, timeout=5)
|
||||
self.assertEqual(error.exception.code, 409)
|
||||
Reference in New Issue
Block a user