136 lines
4.0 KiB
Bash
Executable File
136 lines
4.0 KiB
Bash
Executable File
#!/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 <archive.tar.gz>
|
|
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
|