#!/usr/bin/env bash set -Eeuo pipefail MODE="${1:-}" ARCHIVE_PATH="${2:-}" COUCH_HOME="${COUCH_HOME:-/home/couch}" STAMP="$(date +%Y%m%d-%H%M%S)" usage() { cat <<'EOF' Usage: ./scripts/backup_restore.sh backup [archive.tar.gz] ./scripts/backup_restore.sh restore EOF } if [[ -z "${MODE}" ]]; then usage >&2 exit 1 fi build_backup_manifest() { python3 - <<'PY' "${COUCH_HOME}" import os import sys from pathlib import Path couch_home = Path(sys.argv[1]) roots = [ Path(".config/couchd/runtime.json"), Path(".config/dolphin-emu"), Path(".config/Slippi"), Path(".local/share/dolphin-emu"), Path("Slippi"), ] excluded_dirs = tuple( item.lower() for item in ( ".config/Slippi Launcher", ".local/share/accounts", ".local/share/sessions", "Slippi/Replay", "Slippi/Replays", ) ) excluded_suffixes = (".slp", ".iso", ".gcm", ".wbfs", ".nkit.iso") for root in roots: source = couch_home / root if not source.exists(): continue if source.is_file(): print(str(root), end="\0") continue for current_root, dir_names, file_names in os.walk(source): rel_root = Path(current_root).relative_to(couch_home) dir_names[:] = [ name for name in dir_names if str(rel_root / name).lower() not in excluded_dirs ] for file_name in file_names: rel_path = rel_root / file_name rel_lower = str(rel_path).lower() if any(rel_lower == item or rel_lower.startswith(f"{item}/") for item in excluded_dirs): continue if rel_lower.endswith(excluded_suffixes): continue print(str(rel_path), end="\0") PY } validate_restore_archive() { python3 - <<'PY' "${ARCHIVE_PATH}" "${COUCH_HOME}" import sys import tarfile from pathlib import Path archive_path = Path(sys.argv[1]) couch_home = Path(sys.argv[2]).resolve(strict=False) with tarfile.open(archive_path, "r:*") as archive: for member in archive.getmembers(): member_path = Path(member.name) if member_path.is_absolute(): raise SystemExit(f"unsafe archive entry: {member.name}") if any(part == ".." for part in member_path.parts): raise SystemExit(f"unsafe archive entry: {member.name}") if member.isdev(): raise SystemExit(f"unsafe archive entry: {member.name}") target_path = (couch_home / member_path).resolve(strict=False) if couch_home != target_path and couch_home not in target_path.parents: raise SystemExit(f"unsafe archive entry: {member.name}") if member.issym() or member.islnk(): link_target = Path(member.linkname) if link_target.is_absolute(): raise SystemExit(f"unsafe archive entry: {member.name}") resolved_link = (target_path.parent / link_target).resolve(strict=False) if couch_home != resolved_link and couch_home not in resolved_link.parents: raise SystemExit(f"unsafe archive entry: {member.name}") PY } case "${MODE}" in backup) ARCHIVE_PATH="${ARCHIVE_PATH:-${COUCH_HOME}/Slippi/couchos-backup-${STAMP}.tar.gz}" umask 077 mapfile -d '' manifest < <(build_backup_manifest) if [[ "${#manifest[@]}" -eq 0 ]]; then printf 'no backup sources found under %s\n' "${COUCH_HOME}" >&2 exit 1 fi tar -C "${COUCH_HOME}" -czf "${ARCHIVE_PATH}" \ --exclude='.config/couchd/token' \ "${manifest[@]}" chmod 0600 "${ARCHIVE_PATH}" printf 'backup written to %s\n' "${ARCHIVE_PATH}" ;; restore) if [[ -z "${ARCHIVE_PATH}" || ! -f "${ARCHIVE_PATH}" ]]; then printf 'restore archive is required\n' >&2 exit 1 fi if ! validation_error="$(validate_restore_archive 2>&1)"; then printf '%s\n' "${validation_error}" >&2 exit 1 fi tar -C "${COUCH_HOME}" -xzf "${ARCHIVE_PATH}" printf 'restored from %s\n' "${ARCHIVE_PATH}" ;; *) usage >&2 exit 1 ;; esac