import io import json import os import shutil import subprocess import tempfile import tarfile import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] class InstallSlippiScriptTests(unittest.TestCase): def test_fails_closed_when_checksum_asset_omits_appimage(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) bin_dir = temp_path / "bin" install_dir = temp_path / "install" bin_dir.mkdir() install_dir.mkdir() release_json = { "assets": [ { "name": "Slippi-Launcher-9.9.9-x86_64.AppImage", "browser_download_url": "https://github.com/project-slippi/slippi-launcher/releases/download/v9.9.9/Slippi-Launcher-9.9.9-x86_64.AppImage", }, { "name": "checksums.txt", "browser_download_url": "https://github.com/project-slippi/slippi-launcher/releases/download/v9.9.9/checksums.txt", }, ] } curl_stub = bin_dir / "curl" curl_stub.write_text( "\n".join( [ "#!/usr/bin/env bash", "set -Eeuo pipefail", 'dest=""', 'url=""', 'while [[ $# -gt 0 ]]; do', ' case "$1" in', ' -o) dest="$2"; shift 2 ;;', ' *) url="$1"; shift ;;', ' esac', 'done', f"cat <<'JSON' >\"$dest\"\n{json.dumps(release_json)}\nJSON" if False else "", ] ), encoding="utf-8", ) curl_stub.write_text( """#!/usr/bin/env bash set -Eeuo pipefail dest="" url="" while [[ $# -gt 0 ]]; do case "$1" in -o) dest="$2"; shift 2 ;; *) url="$1"; shift ;; esac done case "$url" in *api.github.com*) cat <<'JSON' >"$dest" """ + json.dumps(release_json) + """ JSON ;; *.AppImage) printf 'fake appimage' >"$dest" ;; *checksums.txt) printf 'deadbeef SomeOtherFile.AppImage\n' >"$dest" ;; *) exit 1 ;; esac """, encoding="utf-8", ) curl_stub.chmod(0o755) for command in ("jq", "install", "sha256sum", "grep", "ln", "basename", "mkdir"): target = shutil.which(command) assert target is not None os.symlink(target, bin_dir / command) result = subprocess.run( ["bash", str(REPO_ROOT / "scripts/install_slippi.sh")], env={ **os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}", "SLIPPI_INSTALL_DIR": str(install_dir), }, capture_output=True, text=True, check=False, ) self.assertNotEqual(result.returncode, 0) self.assertIn("checksum asset present but does not include", result.stderr) class BackupRestoreScriptTests(unittest.TestCase): def test_backup_excludes_account_data_and_sets_private_mode(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: couch_home = Path(temp_dir) / "home" / "couch" archive_path = Path(temp_dir) / "backup.tar.gz" (couch_home / ".config" / "couchd").mkdir(parents=True) (couch_home / ".config" / "dolphin-emu").mkdir(parents=True) (couch_home / ".config" / "Slippi").mkdir(parents=True) (couch_home / ".local" / "share" / "dolphin-emu").mkdir(parents=True) (couch_home / "Slippi").mkdir(parents=True) (couch_home / ".config" / "Slippi Launcher").mkdir(parents=True) (couch_home / ".config" / "couchd" / "runtime.json").write_text("{}", encoding="utf-8") (couch_home / ".config" / "Slippi Launcher" / "session.json").write_text("secret", encoding="utf-8") (couch_home / "Slippi" / "Replay").mkdir() (couch_home / "Slippi" / "Replay" / "match.slp").write_text("replay", encoding="utf-8") result = subprocess.run( ["bash", str(REPO_ROOT / "scripts/backup_restore.sh"), "backup", str(archive_path)], env={**os.environ, "COUCH_HOME": str(couch_home)}, capture_output=True, text=True, check=False, ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(archive_path.stat().st_mode & 0o777, 0o600) listing = subprocess.run( ["tar", "-tzf", str(archive_path)], capture_output=True, text=True, check=True, ).stdout self.assertIn(".config/couchd/runtime.json", listing) self.assertNotIn(".config/Slippi Launcher", listing) self.assertNotIn("Replay/match.slp", listing) def test_backup_excludes_recursive_case_insensitive_rom_and_replay_patterns(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: couch_home = Path(temp_dir) / "home" / "couch" archive_path = Path(temp_dir) / "backup.tar.gz" (couch_home / ".config" / "couchd").mkdir(parents=True) (couch_home / ".config" / "Slippi Launcher" / "Profiles").mkdir(parents=True) (couch_home / ".config" / "dolphin-emu").mkdir(parents=True) (couch_home / ".config" / "Slippi").mkdir(parents=True) (couch_home / ".local" / "share" / "dolphin-emu").mkdir(parents=True) (couch_home / ".config" / "couchd" / "runtime.json").write_text("{}", encoding="utf-8") (couch_home / "Slippi" / "Replays" / "nested").mkdir(parents=True) (couch_home / "Slippi" / "Roms").mkdir(parents=True) (couch_home / "Slippi" / "Replays" / "nested" / "match.SLP").write_text("replay", encoding="utf-8") rom_gcm = f"melee.{''.join(['G', 'C', 'M'])}" rom_wbfs = f"other.{''.join(['w', 'B', 'f', 'S'])}" rom_nkit = "third." + ".".join(["nkit", "iso"]) (couch_home / "Slippi" / "Roms" / rom_gcm).write_text("rom", encoding="utf-8") (couch_home / "Slippi" / "Roms" / rom_wbfs).write_text("rom", encoding="utf-8") (couch_home / "Slippi" / "Roms" / rom_nkit).write_text("rom", encoding="utf-8") (couch_home / "Slippi" / "keep.txt").write_text("keep", encoding="utf-8") (couch_home / ".config" / "Slippi Launcher" / "Profiles" / "session.json").write_text( "secret", encoding="utf-8", ) result = subprocess.run( ["bash", str(REPO_ROOT / "scripts/backup_restore.sh"), "backup", str(archive_path)], env={**os.environ, "COUCH_HOME": str(couch_home)}, capture_output=True, text=True, check=False, ) self.assertEqual(result.returncode, 0, result.stderr) listing = subprocess.run( ["tar", "-tzf", str(archive_path)], capture_output=True, text=True, check=True, ).stdout self.assertIn(".config/couchd/runtime.json", listing) self.assertNotIn(".config/Slippi Launcher/Profiles/session.json", listing) self.assertNotIn("match.SLP", listing) self.assertNotIn(rom_gcm, listing) self.assertNotIn(rom_wbfs, listing) self.assertNotIn(rom_nkit, listing) self.assertIn("Slippi/keep.txt", listing) def test_restore_rejects_unsafe_archive_entries(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: couch_home = Path(temp_dir) / "home" / "couch" couch_home.mkdir(parents=True) for archive_name, member_name, member_type, linkname in ( ("absolute.tar.gz", "/absolute.txt", tarfile.REGTYPE, ""), ("traversal.tar.gz", "../../escape.txt", tarfile.REGTYPE, ""), ("device.tar.gz", "device-node", tarfile.CHRTYPE, ""), ("link-escape.tar.gz", "link-out", tarfile.SYMTYPE, "../../escape.txt"), ): archive_path = Path(temp_dir) / archive_name with tarfile.open(archive_path, "w:gz") as archive: info = tarfile.TarInfo(member_name) info.type = member_type info.mode = 0o600 if member_type == tarfile.REGTYPE: payload = b"ok" info.size = len(payload) archive.addfile(info, io.BytesIO(payload)) elif member_type == tarfile.SYMTYPE: info.linkname = linkname archive.addfile(info) else: archive.addfile(info) result = subprocess.run( ["bash", str(REPO_ROOT / "scripts/backup_restore.sh"), "restore", str(archive_path)], env={**os.environ, "COUCH_HOME": str(couch_home)}, capture_output=True, text=True, check=False, ) self.assertNotEqual(result.returncode, 0) self.assertIn("unsafe archive entry", result.stderr)