Files
2026-10-21 18:54:55 +02:00

2365 lines
60 KiB
C

/* Copyright (C) 2026
This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation; either version 3, or (at your option) any
later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; see the file COPYING. If not, see
<http://www.gnu.org/licenses/>. */
/**
* Save backup daemon for PS5.
*
* Reads /mnt/usb0/backup.conf and copies the listed files to
* /mnt/usb0/backup/<original path>:
*
* /data/PCSX2/Mcd001.ps2 one file
* /data/porpoise/saves/<star> every file and folder below, recursively
* backup_interval_seconds=60 seconds between backups (default 60)
* backup_ps4_saves=true enable decrypted PS4 saves (default false)
* backup_ps5_saves=true enable decrypted PS5 saves (default false)
* max_title_size_mb=4096 size limit for all save images of one title
* in decimal MB; default 4096 = 4.096GB
* daily_snapshots_to_keep=3 keep a dated snapshot (a copy of the live
* backup) of the newest N days in
* /mnt/usb0/backup_daily/YYYY-MM-DD, using the
* console's local date. 0..10, default 0 = off.
* The first run on a date creates its snapshot
* and it is never overwritten that day; it is
* checked at every payload start, wake-up and
* at local midnight.
*
* Plain files larger than 100MB are skipped. A game's saves are backed up
* completely or not at all: the size of the whole title id folder is checked
* against max_title_size_mb, and the USB drive and internal storage are checked for
* free space, before anything is mounted. The payload keeps running in the
* background and only copies what changed. Starting it again replaces the
* instance that is already running.
*
* PS4/PS5 saves are encrypted images. For each image that changed, a COPY of
* the image is mounted (the original is only ever read), the decrypted files
* are copied to /mnt/usb0/backup/saves/..., and the image is unmounted again.
* A title is deferred while that same title is running, suspended or still
* sandbox-mounted. Other titles may be backed up during gameplay. Activity is
* checked before mounting and during copying; unknown activity fails closed.
* Deferred titles are retried after closure, independently of the interval.
*
* Save mounting is based on the Playstation-5-Save-Mounter payload by n0llptr
* (GPLv3), which in turn builds on research by cow and the garlic-savemgr
* project by earthonion.
**/
#include <arpa/inet.h>
#include <ctype.h>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <netinet/in.h>
#include <poll.h>
#include <pthread.h>
#include <signal.h>
#include <stdarg.h>
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include "notification_icon.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
#ifndef BACKUP_HOST_TEST
#include <sys/mount.h>
#include <sys/proc.h>
#include <sys/user.h>
#include <sys/sysctl.h>
#else
#include <sys/statfs.h>
#endif
#include <ps5/kernel.h>
#include <ps5/klog.h>
#ifndef USB_ROOT
#define USB_ROOT "/mnt/usb0"
#endif
#ifndef HOME_ROOT
#define HOME_ROOT "/user/home"
#endif
#ifndef TMP_DIR
#define TMP_DIR "/data/save_files"
#endif
#ifndef PFS_ROOT
#define PFS_ROOT "/mnt/pfs"
#endif
#ifndef PFSMGR_DEV
#define PFSMGR_DEV "/dev/pfsmgr"
#endif
#define CONF_PATH USB_ROOT "/backup.conf"
#define BACKUP_ROOT USB_ROOT "/backup"
#define DAILY_ROOT USB_ROOT "/backup_daily"
#define MAX_DAILY 10 /* highest value for daily_snapshots_to_keep= */
#define MAX_DAILY_DIRS 1024 /* date folders looked at when pruning */
#define TMP_IMG TMP_DIR "/_backup_tmp_image"
#define MOUNT_POINT PFS_ROOT "/savebackup_tmp"
#define BYTES_PER_MB 1000000ULL /* decimal megabytes */
#define MAX_FILE_SZ (100ULL * BYTES_PER_MB)
#define DEF_GAME_MB 4096 /* default limit: all saves of one title id, in MB */
#ifndef MIN_GAME_MB
#define MIN_GAME_MB 64
#endif
#define MAX_GAME_MB 65536
#define MAX_IMAGES 512 /* save images per title id */
#define SPACE_MARGIN (64ULL * 1024ULL * 1024ULL)
#define COPY_BUF_SZ (1024 * 1024)
#define DEF_INTERVAL 60 /* seconds between backups */
#ifndef MIN_INTERVAL
#define MIN_INTERVAL 30
#endif
#define MAX_INTERVAL 3600
#ifndef SLICE_SEC
#define SLICE_SEC 10 /* wake-up granularity while waiting */
#endif
#ifndef LOCK_PORT
#define LOCK_PORT 9055 /* loopback port used to detect/replace a running instance */
#endif
#define MISSING_TRIES 6 /* conf missing this many slices in a row -> notify */
#define MAX_SEEN 256
#ifndef GAME_CHECK_SEC
#define GAME_CHECK_SEC 1.0 /* how often the game check is repeated while mounted */
#endif
#define SAVE_AUTHID 0x4800000000000010ULL
/* Rich notification transport follows ShadowMountPlus src/sm_log.c (GPLv3):
* https://github.com/drakmor/ShadowMountPlus/blob/
* 98c524890176c7b474fcb03387bec88c4a559861/src/sm_log.c
* sceKernelSendNotificationRequest is the legacy debug-popup mechanism;
* sceNotificationSend accepts the shell's rich JSON notification model. */
int sceNotificationSend(int user_id, bool is_logged, const char *payload);
#define NOTIFICATION_USER_ID 0xfe /* same local/broadcast user as SMP */
#define NOTIFICATION_JSON_SIZE 16384
#ifndef NOTIFICATION_ICON_DIR
#define NOTIFICATION_ICON_DIR "/user/data/ps5save"
#endif
#define NOTIFICATION_ICON_PATH NOTIFICATION_ICON_DIR "/notification.png"
static const char PS5SAVE_VERSION[] = "v2";
static int mkdir_p(const char *path);
/* Install the embedded, unmodified user artwork where ShellUI can read it.
* No USB icon file or network service is required. Try again on a later
* notification if installation fails. Never fall back to legacy debug popups. */
static int
prepare_notification_icon(void) {
static int ready;
const char *tmp = NOTIFICATION_ICON_PATH ".part";
int fd;
size_t pos = 0;
if(ready && !access(NOTIFICATION_ICON_PATH, R_OK)) {
return 1;
}
ready = 0;
if(mkdir_p(NOTIFICATION_ICON_DIR)) {
return 0;
}
if((fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644)) < 0) {
return 0;
}
while(pos < sizeof notification_icon_png) {
ssize_t n = write(fd, notification_icon_png + pos,
sizeof notification_icon_png - pos);
if(n < 0 && errno == EINTR) {
continue;
}
if(n <= 0) {
close(fd);
unlink(tmp);
return 0;
}
pos += (size_t)n;
}
int ok = fchmod(fd, 0644) == 0 && fsync(fd) == 0;
if(close(fd)) {
ok = 0;
}
if(!ok || rename(tmp, NOTIFICATION_ICON_PATH)) {
unlink(tmp);
return 0;
}
ready = 1;
return 1;
}
/* App-info ABI used by kstuff-lite and ShadowMountPlus. The title starts at
* 0x10: inserting an app_type field here shifts every title by four bytes.
* https://github.com/EchoStretch/kstuff-lite/blob/main/ps5-kstuff-ldr/src/shellui_patch.c */
typedef struct app_info {
uint32_t app_id;
uint64_t unknown1;
char title_id[14];
char unknown2[0x3c];
} app_info_t;
_Static_assert(offsetof(app_info_t, title_id) == 0x10, "Incorrect app-info title offset");
int sceKernelGetAppInfo(pid_t pid, app_info_t *info);
/* save data mounting, from libSceFsInternalForVsh (see Save Mounter) */
typedef struct { uint8_t reserved; char *budgetid; } MountOpt;
typedef struct { uint8_t dummy; } UmountOpt;
int sceFsInitMountSaveDataOpt(MountOpt *opt);
int sceFsMountSaveData(MountOpt *opt, const char *path, const char *mount,
uint8_t *key);
int sceFsInitUmountSaveDataOpt(UmountOpt *opt);
int sceFsUmountSaveData(UmountOpt *opt, const char *mount, int handle,
int ignore);
typedef struct backup_stats {
uint64_t bytes;
unsigned copied;
unsigned unchanged;
unsigned skipped;
unsigned failed;
} backup_stats_t;
typedef struct priv_state {
uint64_t authid;
uint8_t caps[16];
intptr_t jaildir;
} priv_state_t;
typedef enum { SV_OK, SV_SKIP, SV_FAILED, SV_GAME } sv_result_t;
static char *g_buf;
static long g_interval = DEF_INTERVAL;
static int g_last_failed = 0;
static char *g_seen[MAX_SEEN];
static int g_nseen = 0;
static uint64_t g_max_game = DEF_GAME_MB * BYTES_PER_MB;
static uint64_t g_file_cap = MAX_FILE_SZ; /* per-file limit, 0 = none */
static int g_daily = 0; /* daily_snapshots_to_keep=N, 0 = off */
static int g_ps4saves = 0;
static int g_ps5saves = 0;
static int g_need_cleanup = 1;
static int g_mount_stuck = 0;
static int g_watch_game = 0; /* protect the currently mounted title */
static int g_game_abort = 0; /* abort this save image only */
static int g_backup_busy = 0; /* one activity warning per copying pass */
static int g_start_notified = 0; /* one startup notification per process */
static double g_last_watch = 0;
static char g_abort_why[192];
static void logq(const char *fmt, ...);
/* Escape every JSON control character and reject overflow rather than emit
* truncated or malformed JSON. UTF-8 bytes are preserved. */
static int
notification_json_escape(char *dst, size_t size, const char *src) {
static const char hex[] = "0123456789abcdef";
size_t pos = 0;
if(!size) {
return 0;
}
for(const unsigned char *p = (const unsigned char *)src; *p; p++) {
size_t need = *p < 0x20 ? 6 : (*p == '"' || *p == '\\' ? 2 : 1);
if(need >= size - pos) {
dst[0] = 0;
return 0;
}
if(*p < 0x20) {
dst[pos++] = '\\';
dst[pos++] = 'u';
dst[pos++] = '0';
dst[pos++] = '0';
dst[pos++] = hex[*p >> 4];
dst[pos++] = hex[*p & 15];
} else {
if(*p == '"' || *p == '\\') {
dst[pos++] = '\\';
}
dst[pos++] = (char)*p;
}
}
dst[pos] = 0;
return 1;
}
/* Standard informative notification: status message, PS5Save label and image.
* Use a plain local image path, as in the SMP rich notification transport.
* No interactive actions, alternate views or legacy debug popups. */
static int
build_notification_payload(const char *message, const char *created_at,
const char *id, char *payload, size_t size) {
size_t capacity = strlen(message) * 6 + 1;
if(capacity > 2048 * 6 + 1) return 0;
char *escaped = malloc(capacity);
if(!escaped) return 0;
if(!notification_json_escape(escaped, capacity, message)) {
free(escaped);
return 0;
}
int n = snprintf(payload, size,
"{\"rawData\":{"
"\"viewTemplateType\":\"ToastTemplateB\","
"\"channelType\":\"SystemFeedback\","
"\"bundleName\":\"PS5Save\",\"useCaseId\":\"IDC\","
"\"soundEffect\":\"none\",\"toastOverwriteType\":\"No\","
"\"isImmediate\":true,\"priority\":100,"
"\"viewData\":{"
"\"icon\":{\"type\":\"Url\",\"parameters\":{"
"\"url\":\"" NOTIFICATION_ICON_PATH "\"}},"
"\"message\":{\"body\":\"%s\"},"
"\"subMessage\":{\"body\":\"PS5Save | Version %s\"}}},"
"\"createdDateTime\":\"%s\",\"localNotificationId\":\"%s\"}",
escaped, PS5SAVE_VERSION, created_at, id);
free(escaped);
return n >= 0 && (size_t)n < size;
}
/** Send every user-facing message through libSceNotification, with our icon. */
static void
notify_v(const char *fmt, va_list ap) {
static unsigned serial;
char message[2048];
char *payload;
char created_at[32];
char id[96];
struct timespec ts;
struct tm utc;
vsnprintf(message, sizeof message, fmt, ap);
logq("%s", message);
if(!prepare_notification_icon()) {
logq("Rich notification not sent: icon installation failed (%s)",
strerror(errno));
return;
}
if(clock_gettime(CLOCK_REALTIME, &ts) ||
!gmtime_r(&ts.tv_sec, &utc) ||
!strftime(created_at, sizeof created_at, "%Y-%m-%dT%H:%M:%S.000Z", &utc)) {
logq("Rich notification not sent: cannot obtain timestamp");
return;
}
snprintf(id, sizeof id, "ps5save-%lld-%ld-%d-%u",
(long long)ts.tv_sec, ts.tv_nsec, (int)getpid(), ++serial);
payload = malloc(NOTIFICATION_JSON_SIZE);
if(!payload) {
logq("Rich notification not sent: not enough memory");
return;
}
if(!build_notification_payload(message, created_at, id,
payload, NOTIFICATION_JSON_SIZE)) {
free(payload);
logq("Rich notification not sent: payload too large");
return;
}
int rc = sceNotificationSend(NOTIFICATION_USER_ID, true, payload);
free(payload);
if(rc != 0) {
logq("sceNotificationSend failed (0x%08x)", (unsigned)rc);
}
}
/* Serialize the timer and main thread, including icon setup and notification IDs. */
static void
notify(const char *fmt, ...) {
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
va_list ap;
pthread_mutex_lock(&mutex);
va_start(ap, fmt);
notify_v(fmt, ap);
va_end(ap);
pthread_mutex_unlock(&mutex);
}
#include "backup_progress.h"
/* Announce actual copy work once per pass, including save-image staging
* and daily snapshot copies. Unchanged/scanning-only passes stay quiet. */
static void
backup_activity_begin(void) {
if(!g_backup_busy) {
g_backup_busy = 1;
notify("Backing up, don't unplug the USB");
progress_start();
}
}
/**
* Quiet log: stdout and klog only, no on-screen notification.
**/
static void
logq(const char *fmt, ...) {
char msg[2048];
va_list ap;
va_start(ap, fmt);
vsnprintf(msg, sizeof msg, fmt, ap);
va_end(ap);
printf("%s\n", msg);
fflush(stdout);
klog_printf("[backup] %s\n", msg);
}
/**
* Returns non-zero if the path was reported before; remembers it otherwise.
* Used so that a permanently oversized file is not announced every cycle.
**/
static int
seen_before(const char *path) {
for(int i = 0; i < g_nseen; i++) {
if(!strcmp(g_seen[i], path)) {
return 1;
}
}
if(g_nseen < MAX_SEEN) {
g_seen[g_nseen++] = strdup(path);
}
return 0;
}
static void
format_size(uint64_t bytes, char *out, size_t len) {
if(bytes >= 1000ULL * 1000ULL * 1000ULL) {
snprintf(out, len, "%.1fGB", (double)bytes / (1000.0 * 1000.0 * 1000.0));
} else if(bytes >= 10ULL * 1000ULL * 1000ULL) {
snprintf(out, len, "%.0fMB", (double)bytes / (1000.0 * 1000.0));
} else if(bytes >= 1000ULL * 1000ULL) {
snprintf(out, len, "%.1fMB", (double)bytes / (1000.0 * 1000.0));
} else if(bytes >= 1000ULL) {
snprintf(out, len, "%.0fkB", (double)bytes / 1000.0);
} else {
snprintf(out, len, "%lluB", (unsigned long long)bytes);
}
}
static double
now_seconds(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
static time_t
wall_clock(void) {
struct timespec ts;
#ifdef BACKUP_HOST_TEST
/* the host test can set the clock through a file */
const char *tf = getenv("TEST_NOW_FILE");
if(tf) {
FILE *fp = fopen(tf, "r");
long long v;
if(fp) {
int ok = fscanf(fp, "%lld", &v) == 1;
fclose(fp);
if(ok) {
return (time_t)v;
}
}
}
#endif
clock_gettime(CLOCK_REALTIME, &ts);
return ts.tv_sec;
}
/**
* mkdir -p
**/
static int
mkdir_p(const char *path) {
char tmp[PATH_MAX];
size_t len;
len = snprintf(tmp, sizeof tmp, "%s", path);
if(len == 0 || len >= sizeof tmp) {
return -1;
}
for(char *p = tmp + 1; *p; p++) {
if(*p == '/') {
*p = 0;
if(mkdir(tmp, 0777) && errno != EEXIST) {
return -1;
}
*p = '/';
}
}
if(mkdir(tmp, 0777) && errno != EEXIST) {
return -1;
}
return 0;
}
/**
* Free space (bytes) on the file system holding path.
**/
static uint64_t
fs_free(const char *path, const char *test_env) {
struct statfs sf;
#ifdef BACKUP_HOST_TEST
const char *o = getenv(test_env);
if(o) {
return strtoull(o, NULL, 10);
}
#else
(void)test_env;
#endif
if(statfs(path, &sf) || (int64_t)sf.f_bavail <= 0) {
return 0;
}
return (uint64_t)sf.f_bavail * (uint64_t)sf.f_bsize;
}
#define usb_free() fs_free(USB_ROOT, "TEST_USB_FREE")
#define data_free() fs_free(TMP_DIR, "TEST_DATA_FREE")
/**
* Total size of all regular files below path.
**/
static void game_watch(void);
static uint64_t
dir_size(const char *path) {
char sub[PATH_MAX];
struct dirent *dp;
struct stat st;
uint64_t total = 0;
DIR *dir;
if(!(dir = opendir(path))) {
return 0;
}
while((dp = readdir(dir))) {
game_watch();
if(g_watch_game && g_game_abort) break;
if(!strcmp(dp->d_name, ".") || !strcmp(dp->d_name, "..")) {
continue;
}
if(snprintf(sub, sizeof sub, "%s/%s", path, dp->d_name) >= (int)sizeof sub ||
lstat(sub, &st)) {
continue;
}
if(S_ISDIR(st.st_mode)) {
total += dir_size(sub);
} else if(S_ISREG(st.st_mode)) {
total += st.st_size;
}
}
closedir(dir);
return total;
}
/* ------------------------------------------------------------------------
* Game detection
* ---------------------------------------------------------------------- */
#include "title_activity.h"
/**
* Called regularly while an image is copied or mounted. If its title appears,
* g_game_abort is set and save-image copy loops stop as soon as possible.
* Ordinary configured files are independent of game detection.
**/
static void
game_watch(void) {
char why[160];
double t;
if(!g_watch_game || g_game_abort) {
return;
}
t = now_seconds();
if(t - g_last_watch < GAME_CHECK_SEC) {
return;
}
g_last_watch = t;
if(title_blocked(g_watch_title, why, sizeof why, 0)) {
g_game_abort = 1;
snprintf(g_abort_why, sizeof g_abort_why, "%s", why);
}
}
#include "change_index.h"
/* ------------------------------------------------------------------------
* Plain file backup
* ---------------------------------------------------------------------- */
/**
* Copy a single file from src to dst. The data is written to a temporary file
* first, so that an interrupted copy (full or removed USB drive) never
* destroys the previous good backup.
**/
static void
backup_file(const char *src, const char *dst, const struct stat *st,
backup_stats_t *stats) {
char tmp[PATH_MAX];
char dir[PATH_MAX];
const char *label = dst + strlen(BACKUP_ROOT);
struct stat after;
struct timeval tv[2];
char *slash;
ssize_t n;
int in = -1;
int out = -1;
int ok = 0;
int aborted = 0;
unsigned reads = 0;
int progress_started = 0;
if(g_progress_planning) {
if((!g_file_cap || (uint64_t)st->st_size <= g_file_cap) &&
!file_current(dst, st))
progress_plan(1);
return;
}
/* per-file limit; not applied inside game saves, which are judged per game */
if(g_file_cap && (uint64_t)st->st_size > g_file_cap) {
if(!seen_before(label)) {
logq("Abnormal size of %s; skipping backup", label);
notify("File skipped: too large.");
}
stats->skipped++;
return;
}
game_watch();
if(g_watch_game && g_game_abort) {
return;
}
tmp[0] = 0;
if(snprintf(tmp, sizeof tmp, "%s.part", dst) >= (int)sizeof tmp) {
tmp[0] = 0;
goto done;
}
/* Exact source and destination metadata cached after a successful copy. */
if(file_current(dst, st)) {
stats->unchanged++;
if(g_progress_image) progress_advance(st->st_size);
return;
}
/* make sure the USB drive can hold the new file */
if(usb_free() < (uint64_t)st->st_size + SPACE_MARGIN) {
logq("Failed to backup %s (not enough space on USB)", label);
stats->failed++;
return;
}
snprintf(dir, sizeof dir, "%s", dst);
if((slash = strrchr(dir, '/')) && slash != dir) {
*slash = 0;
if(mkdir_p(dir)) {
goto done;
}
}
if((in = open(src, O_RDONLY)) < 0) {
goto done;
}
if(!g_progress_image) {
progress_begin(st->st_size);
progress_started = 1;
}
backup_activity_begin();
if((out = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0666)) < 0) {
goto done;
}
while((n = read(in, g_buf, COPY_BUF_SZ)) > 0) {
char *p = g_buf;
while(n > 0) {
ssize_t w = write(out, p, n);
if(w < 0) {
if(errno == EINTR) {
continue;
}
goto done;
}
progress_advance(w);
p += w;
n -= w;
}
if(++reads) {
game_watch();
if(g_watch_game && g_game_abort) {
aborted = 1;
goto done;
}
}
}
if(n < 0) {
goto done;
}
if(fstat(in, &after) || !source_same(st, &after)) {
logq("Source changed during copy: %s", src);
goto done;
}
if(fsync(out)) {
goto done;
}
if(close(out)) {
out = -1;
goto done;
}
out = -1;
/* keep the source mtime so the next cycle can detect "unchanged" */
tv[0].tv_sec = st->st_mtime;
tv[0].tv_usec = 0;
tv[1] = tv[0];
utimes(tmp, tv);
if(rename(tmp, dst)) {
goto done;
}
index_update(dst, st);
ok = 1;
done:
if(progress_started) progress_finish();
if(in >= 0) {
close(in);
}
if(out >= 0) {
close(out);
}
if(ok) {
stats->copied++;
stats->bytes += st->st_size;
} else {
if(tmp[0]) {
unlink(tmp);
}
if(!aborted) {
logq("Failed to backup %s", label);
stats->failed++;
}
}
}
/**
* Back up everything below the directory src into dst, recursively.
**/
static void
backup_tree(const char *src, const char *dst, backup_stats_t *stats) {
char spath[PATH_MAX];
char dpath[PATH_MAX];
struct dirent *dp;
struct stat st;
DIR *dir;
if(!(dir = opendir(src))) {
logq("Failed to backup %s", dst + strlen(BACKUP_ROOT));
stats->failed++;
return;
}
while((dp = readdir(dir)) && !(g_watch_game && g_game_abort)) {
if(!strcmp(dp->d_name, ".") || !strcmp(dp->d_name, "..")) {
continue;
}
if(snprintf(spath, sizeof spath, "%s/%s", src, dp->d_name) >= (int)sizeof spath ||
snprintf(dpath, sizeof dpath, "%s/%s", dst, dp->d_name) >= (int)sizeof dpath) {
continue;
}
if(lstat(spath, &st)) {
stats->failed++;
continue;
}
if(S_ISDIR(st.st_mode)) {
backup_tree(spath, dpath, stats);
} else if(S_ISREG(st.st_mode)) {
backup_file(spath, dpath, &st, stats);
}
/* symlinks and special files are ignored */
}
closedir(dir);
}
/* ------------------------------------------------------------------------
* PS4 / PS5 save images
* ---------------------------------------------------------------------- */
static void
priv_elevate(priv_state_t *old) {
pid_t me = getpid();
uint8_t caps[16];
old->authid = kernel_get_ucred_authid(me);
memset(old->caps, 0, sizeof old->caps);
kernel_get_ucred_caps(me, old->caps);
old->jaildir = kernel_get_proc_jaildir(me);
kernel_set_ucred_authid(me, SAVE_AUTHID);
memset(caps, 0xFF, sizeof caps);
kernel_set_ucred_caps(me, caps);
kernel_set_proc_rootdir(me, kernel_get_root_vnode());
kernel_set_proc_jaildir(me, 0);
}
static void
priv_restore(const priv_state_t *old) {
pid_t me = getpid();
kernel_set_ucred_authid(me, old->authid);
kernel_set_ucred_caps(me, old->caps);
kernel_set_proc_jaildir(me, old->jaildir);
}
/* Configuration booleans accept exactly the documented lowercase tokens. */
static int
parse_bool(const char *value, int *out) {
if(!strcmp(value, "true")) {
*out = 1;
return 1;
}
if(!strcmp(value, "false")) {
*out = 0;
return 1;
}
logq("Invalid boolean setting: %s (expected false or true)", value);
return 0;
}
static int
is_hex_name(const char *s) {
size_t len = strlen(s);
if(len == 0 || len > 8) {
return 0;
}
for(size_t i = 0; i < len; i++) {
if(!isxdigit((unsigned char)s[i])) {
return 0;
}
}
return 1;
}
/**
* Free space check for the temporary image copy in internal storage.
**/
static int
enough_space(uint64_t need) {
mkdir_p(TMP_DIR);
return data_free() >= need;
}
/**
* Where the decrypted files of one save image are backed up.
**/
static void
save_dst_dir(char *out, size_t len, int ps5, const char *uid,
const char *title, const char *save) {
snprintf(out, len, "%s/saves/%s/%s/%s/%s", BACKUP_ROOT,
ps5 ? "ps5" : "ps4", uid, title, save);
}
/**
* Copy src to dst (plain, no temp file). Returns 0 on success, -1 on error
* and -2 if the copy was stopped because a game appeared.
**/
static int
copy_plain(const char *src, const char *dst) {
unsigned reads = 0;
int rc = -1;
ssize_t n;
int in;
int out;
if((in = open(src, O_RDONLY)) < 0) {
return -1;
}
backup_activity_begin();
if((out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0777)) < 0) {
close(in);
return -1;
}
while((n = read(in, g_buf, COPY_BUF_SZ)) > 0) {
char *p = g_buf;
while(n > 0) {
ssize_t w = write(out, p, n);
if(w < 0) {
if(errno == EINTR) {
continue;
}
goto done;
}
progress_advance(w);
p += w;
n -= w;
}
if(++reads) {
game_watch();
if(g_game_abort) {
rc = -2;
goto done;
}
}
}
if(n == 0) {
rc = 0;
}
done:
close(in);
if(close(out)) {
rc = rc ? rc : -1;
}
return rc;
}
/**
* Decrypt the sealed save key (0x60 bytes) through the pfsmgr device.
**/
static int
decrypt_key(const uint8_t *sealed, uint8_t key[0x20]) {
uint8_t *data;
int fd;
int rc;
if(!(data = calloc(1, 0x100))) {
return -1;
}
memcpy(data, sealed, 0x60);
if((fd = open(PFSMGR_DEV, O_RDWR)) < 0) {
free(data);
return -1;
}
rc = ioctl(fd, 0xc0845302, data);
close(fd);
if(rc >= 0) {
memcpy(key, data + 0x60, 0x20);
}
free(data);
return rc < 0 ? -1 : 0;
}
/**
* Unmount the temporary save mount, retrying a few times.
**/
static int
unmount_save(void) {
UmountOpt uopt;
int ret = -1;
for(int i = 0; i < 5; i++) {
memset(&uopt, 0, sizeof uopt);
sceFsInitUmountSaveDataOpt(&uopt);
if((ret = sceFsUmountSaveData(&uopt, MOUNT_POINT, 0, 0)) >= 0) {
sync();
return 0;
}
sleep(1);
}
logq("umount failed (0x%08x)", ret);
return -1;
}
/**
* Remove anything a previous (killed) instance may have left behind.
**/
static void
cleanup_stale(void) {
UmountOpt uopt;
memset(&uopt, 0, sizeof uopt);
sceFsInitUmountSaveDataOpt(&uopt);
sceFsUmountSaveData(&uopt, MOUNT_POINT, 0, 0); /* fails if not mounted */
if(rmdir(MOUNT_POINT) && errno != ENOENT) {
g_mount_stuck = 1;
return;
}
g_mount_stuck = 0;
unlink(TMP_IMG);
}
static int
stamp_current(const char *dst_dir, const struct stat *img) {
char path[PATH_MAX];
file_signature_t stored, current = file_signature(img);
snprintf(path, sizeof path, "%s/.image_stamp", dst_dir);
FILE *f = fopen(path, "rb");
if(!f) return 0;
char magic[8];
int ok = fread(magic, 1, 8, f) == 8 && !memcmp(magic, "PS5IMG2\n", 8) &&
fread(&stored, 1, sizeof stored, f) == sizeof stored &&
!memcmp(&stored, &current, sizeof stored);
fclose(f);
return ok;
}
static void
stamp_write(const char *dst_dir, const struct stat *img) {
char path[PATH_MAX], part[PATH_MAX];
file_signature_t sig = file_signature(img);
mkdir_p(dst_dir);
snprintf(path, sizeof path, "%s/.image_stamp", dst_dir);
snprintf(part, sizeof part, "%s.part", path);
FILE *f = fopen(part, "wb");
if(!f) return;
int ok = fwrite("PS5IMG2\n", 1, 8, f) == 8 &&
fwrite(&sig, 1, sizeof sig, f) == sizeof sig;
if(fflush(f) || fsync(fileno(f))) ok = 0;
if(fclose(f)) ok = 0;
if(!ok || rename(part, path)) unlink(part);
}
/**
* Back up one save image. The original is only read: a copy is mounted.
**/
static sv_result_t
backup_save_image(const char *uid, const char *title, const char *tdir,
const char *name, int ps5, backup_stats_t *stats) {
char image[PATH_MAX];
char label[PATH_MAX];
char dst_dir[PATH_MAX];
char why[160];
uint8_t sealed[0x60];
uint8_t key[0x20];
const char *save = name;
sv_result_t result = SV_FAILED;
unsigned failed_before;
struct stat ist, after;
MountOpt mopt;
int mounted = 0;
int have_tmp = 0;
int ret;
int fd;
g_game_abort = 0;
snprintf(g_watch_title, sizeof g_watch_title, "%s", title);
if(!strncmp(save, "sdimg_", 6)) {
save += 6;
}
snprintf(image, sizeof image, "%s/%s", tdir, name);
save_dst_dir(dst_dir, sizeof dst_dir, ps5, uid, title, save);
snprintf(label, sizeof label, "%s", dst_dir + strlen(BACKUP_ROOT));
if(stat(image, &ist) || !S_ISREG(ist.st_mode)) {
return SV_SKIP;
}
/* nothing new since the last backup: no need to mount anything */
if(stamp_current(dst_dir, &ist)) {
stats->unchanged++;
return SV_SKIP;
}
/* the size of the whole game was already checked per title id */
if(ist.st_size < 0x860) {
logq("Failed to backup %s (not a save image)", label);
stats->failed++;
return SV_FAILED;
}
/* Use the image size as a conservative bound; do not rescan the USB tree. */
if(usb_free() < (uint64_t)ist.st_size + SPACE_MARGIN) {
logq("Failed to backup %s (not enough space on USB)", label);
stats->failed++;
return SV_FAILED;
}
/* Never stage/mount this title while it is running or suspended. */
if(title_blocked(title, why, sizeof why, 1)) {
g_game_abort = 1;
snprintf(g_abort_why, sizeof g_abort_why, "%s", why);
return SV_GAME;
}
/* sealed key: PS4 keeps it in a companion .bin file, PS5 inside the image */
if(ps5) {
if((fd = open(image, O_RDONLY)) < 0) {
goto fail;
}
ret = pread(fd, sealed, sizeof sealed, 0x800);
close(fd);
} else {
char bin[PATH_MAX];
snprintf(bin, sizeof bin, "%s/%s.bin", tdir, save);
if((fd = open(bin, O_RDONLY)) < 0) {
goto fail;
}
ret = read(fd, sealed, sizeof sealed);
close(fd);
}
if(ret != (int)sizeof sealed) {
goto fail;
}
if(!enough_space((uint64_t)ist.st_size + SPACE_MARGIN)) {
logq("Failed to backup %s (not enough free space)", label);
stats->failed++;
return SV_FAILED;
}
g_watch_game = 1;
g_last_watch = now_seconds();
/* work on a private copy; the original image is never mounted */
mkdir_p(TMP_DIR);
unlink(TMP_IMG);
have_tmp = 1;
g_progress_image = 1;
progress_begin(ist.st_size);
progress_stage(ist.st_size, 0, 0.5);
ret = copy_plain(image, TMP_IMG);
if(ret == -2) {
result = SV_GAME;
goto cleanup;
}
if(ret < 0) {
goto fail_cleanup;
}
/* last check right before the mount */
if(title_blocked(title, why, sizeof why, 1)) {
g_game_abort = 1;
snprintf(g_abort_why, sizeof g_abort_why, "%s", why);
result = SV_GAME;
goto cleanup;
}
if(stat(image, &after) || !source_same(&ist, &after)) {
logq("Save image changed while staging: %s", label);
goto fail_cleanup;
}
if(decrypt_key(sealed, key)) {
goto fail_cleanup;
}
if(mkdir(PFS_ROOT, 0777) && errno != EEXIST) {
goto fail_cleanup;
}
mkdir(MOUNT_POINT, 0777);
memset(&mopt, 0, sizeof mopt);
sceFsInitMountSaveDataOpt(&mopt);
mopt.budgetid = "system";
if(title_blocked(title, why, sizeof why, 1)) {
g_game_abort = 1;
snprintf(g_abort_why, sizeof g_abort_why, "%s", why);
result = SV_GAME;
goto cleanup;
}
if(sceFsMountSaveData(&mopt, TMP_IMG, MOUNT_POINT, key) < 0) {
rmdir(MOUNT_POINT);
goto fail_cleanup;
}
mounted = 1;
progress_stage(dir_size(MOUNT_POINT), 0.5, 0.5);
failed_before = stats->failed;
g_file_cap = 0; /* the whole game was already judged against its limit */
backup_tree(MOUNT_POINT, dst_dir, stats);
g_file_cap = MAX_FILE_SZ;
game_watch();
if(title_blocked(title, why, sizeof why, 1)) g_game_abort = 1;
if(g_game_abort) {
result = SV_GAME;
} else if(stat(image, &after) || !source_same(&ist, &after)) {
logq("Save image changed during backup: %s", label);
} else if(stats->failed == failed_before) {
result = SV_OK;
}
goto cleanup;
fail_cleanup:
result = SV_FAILED;
cleanup:
/* the image must ALWAYS be unmounted again */
if(mounted) {
if(unmount_save()) {
g_mount_stuck = 1;
logq("Failed to unmount save image; PS4/PS5 saves backup disabled");
notify("Save backup paused: cleanup failed.");
g_watch_game = 0;
progress_finish();
g_progress_image = 0;
stats->failed++;
return SV_FAILED;
}
rmdir(MOUNT_POINT);
}
if(have_tmp) {
unlink(TMP_IMG);
}
g_watch_game = 0;
progress_finish();
g_progress_image = 0;
if(result == SV_OK) {
stamp_write(dst_dir, &ist);
} else if(result == SV_FAILED) {
logq("Failed to backup %s", label);
stats->failed++;
}
return result;
fail:
logq("Failed to backup %s", label);
stats->failed++;
return SV_FAILED;
}
typedef struct img_info {
char name[256];
uint64_t size;
int needed; /* changed since the last backup */
} img_info_t;
/**
* Is this directory entry a save image we should back up?
**/
static int
is_save_image(const char *n, int ps5) {
size_t len = strlen(n);
if(n[0] == '.') {
return 0;
}
if(len > 4 && !strcmp(n + len - 4, ".bin")) {
return 0; /* PS4 sealed key */
}
if(!strncmp(n, "sdimg_sce_bu_", 13) || !strncmp(n, "sce_bu_", 7)) {
return 0; /* system backup copies */
}
if(!ps5 && strncmp(n, "sdimg_", 6)) {
return 0; /* PS4 images are always sdimg_* */
}
return 1;
}
/**
* Scan <HOME>/<uid>/savedata (PS4) or savedata_prospero (PS5).
*
* A game (title id folder) is backed up completely or not at all: the size of
* all its images is checked against the per-game limit, and the USB drive and
* internal storage are checked for room, before the first image is mounted.
**/
static void
scan_saves(const char *uid, int ps5, backup_stats_t *stats) {
char base[PATH_MAX];
img_info_t *imgs;
struct dirent *td;
DIR *bd;
snprintf(base, sizeof base, "%s/%s/%s", HOME_ROOT, uid,
ps5 ? "savedata_prospero" : "savedata");
if(!(bd = opendir(base))) {
return;
}
if(!(imgs = calloc(MAX_IMAGES, sizeof *imgs))) {
closedir(bd);
return;
}
while((td = readdir(bd)) && !g_mount_stuck) {
char tdir[PATH_MAX];
char tlabel[PATH_MAX];
char dst_dir[PATH_MAX];
char key[PATH_MAX + 8];
char a[32];
char b[32];
uint64_t total = 0; /* all images of the game */
uint64_t need_total = 0; /* images that must be backed up now */
uint64_t max_need = 0;
uint64_t required;
int n = 0;
int needed = 0;
int overflow = 0;
struct dirent *ie;
struct stat st;
DIR *id;
if(td->d_name[0] == '.') {
continue;
}
if(snprintf(tdir, sizeof tdir, "%s/%s", base, td->d_name) >= (int)sizeof tdir ||
stat(tdir, &st) || !S_ISDIR(st.st_mode)) {
continue;
}
pending_title_t *queued = pending_find(uid, td->d_name, ps5);
if(g_pending_only && (!queued || !queued->ready)) continue;
if(!g_progress_planning && queued) {
queued->ready = 0;
queued->retry_at = now_seconds() + g_interval;
}
if(!(id = opendir(tdir))) {
continue;
}
/* pass 1: size up the whole game without mounting anything */
while((ie = readdir(id))) {
const char *save;
char path[PATH_MAX];
if(!is_save_image(ie->d_name, ps5)) {
continue;
}
if(snprintf(path, sizeof path, "%s/%s", tdir, ie->d_name) >= (int)sizeof path ||
stat(path, &st) || !S_ISREG(st.st_mode)) {
continue;
}
if(n >= MAX_IMAGES) {
overflow = 1;
break;
}
save = ie->d_name;
if(!strncmp(save, "sdimg_", 6)) {
save += 6;
}
save_dst_dir(dst_dir, sizeof dst_dir, ps5, uid, td->d_name, save);
snprintf(imgs[n].name, sizeof imgs[n].name, "%s", ie->d_name);
imgs[n].size = st.st_size;
imgs[n].needed = !stamp_current(dst_dir, &st);
total += st.st_size;
if(imgs[n].needed) {
needed++;
need_total += st.st_size;
/* Image size bounds decrypted data without walking the USB tree. */
if((uint64_t)st.st_size > max_need) {
max_need = st.st_size;
}
}
n++;
}
closedir(id);
if(n == 0) {
continue;
}
if(g_progress_planning) {
if(!overflow && total <= g_max_game) progress_plan(needed);
continue;
}
snprintf(tlabel, sizeof tlabel, "/saves/%s/%s/%s", ps5 ? "ps5" : "ps4",
uid, td->d_name);
/* 1. per-game size limit: the whole game or nothing */
if(overflow || total > g_max_game) {
snprintf(key, sizeof key, "size:%s", tlabel);
if(!seen_before(key)) {
format_size(total, a, sizeof a);
format_size(g_max_game, b, sizeof b);
logq("Abnormal size of %s; skipping backup (%s%s, limit %s)", tlabel,
overflow ? ">" : "", a, b);
notify("Game saves skipped: size limit exceeded.");
}
stats->skipped++;
continue;
}
if(!needed) {
pending_remove(uid, td->d_name, ps5);
stats->unchanged += n;
continue;
}
char why[160];
if(title_blocked(td->d_name, why, sizeof why, 1)) {
pending_defer(uid, td->d_name, ps5);
logq("Deferred %s: %s", td->d_name, why);
continue;
}
/* 2. the largest image is copied to internal storage first */
if(!enough_space(max_need + SPACE_MARGIN)) {
snprintf(key, sizeof key, "tmp:%s", tlabel);
if(!seen_before(key)) {
format_size(max_need + SPACE_MARGIN, a, sizeof a);
format_size(data_free(), b, sizeof b);
logq("Not enough internal storage for %s (need %s, free %s); "
"skipping backup", tlabel, a, b);
notify("Game saves skipped: not enough console storage.");
}
stats->skipped++;
continue;
}
/* 3. the USB drive must be able to hold everything that is going to be
* written (image sizes are an upper bound of the decrypted content) */
required = need_total + SPACE_MARGIN;
if(usb_free() < required) {
snprintf(key, sizeof key, "usb:%s", tlabel);
if(!seen_before(key)) {
format_size(required, a, sizeof a);
format_size(usb_free(), b, sizeof b);
logq("Not enough space on USB for %s (need ~%s, free %s); "
"skipping backup", tlabel, a, b);
notify("Game saves skipped: not enough USB space.");
}
stats->skipped++;
continue;
}
/* pass 2: back up only this safe title; other titles remain independent. */
int complete = 1;
for(int i = 0; i < n && !g_mount_stuck; i++) {
if(!imgs[i].needed) {
stats->unchanged++;
continue;
}
sv_result_t result = backup_save_image(uid, td->d_name, tdir, imgs[i].name, ps5, stats);
if(result == SV_GAME) {
pending_defer(uid, td->d_name, ps5);
complete = 0;
break;
}
if(result == SV_FAILED) complete = 0;
}
g_game_abort = 0;
if(complete) pending_remove(uid, td->d_name, ps5);
}
free(imgs);
closedir(bd);
}
/**
* Back up decrypted PS4/PS5 saves for every user, as enabled in backup.conf.
**/
static void
backup_saves(backup_stats_t *stats) {
priv_state_t old;
struct dirent *ud;
DIR *hd;
priv_elevate(&old);
if(!g_progress_planning && (g_need_cleanup || g_mount_stuck)) {
cleanup_stale();
g_need_cleanup = 0;
}
if(g_mount_stuck) {
logq("A previous save mount could not be removed; saves backup disabled");
stats->failed++;
priv_restore(&old);
return;
}
if((hd = opendir(HOME_ROOT))) {
while((ud = readdir(hd)) && !g_mount_stuck) {
if(!is_hex_name(ud->d_name)) {
continue;
}
if(g_ps4saves) {
scan_saves(ud->d_name, 0, stats);
}
if(g_ps5saves && !g_mount_stuck) {
scan_saves(ud->d_name, 1, stats);
}
}
closedir(hd);
}
g_watch_game = 0;
priv_restore(&old);
}
/* ------------------------------------------------------------------------
* backup.conf handling and the main loop
* ---------------------------------------------------------------------- */
/* Match a setting name, allowing spaces before and after '='. */
static const char *
config_value(const char *line, const char *key, const char *legacy) {
const char *names[2] = {key, legacy};
for(int i = 0; i < 2; i++) {
size_t n = strlen(names[i]);
if(strncmp(line, names[i], n)) {
continue;
}
const char *p = line + n;
while(*p == ' ' || *p == '\t') p++;
if(*p++ != '=') continue;
while(*p == ' ' || *p == '\t') p++;
return p;
}
return NULL;
}
static int
config_number(const char *value, long min, long max, long *out) {
char *end;
errno = 0;
long v = strtol(value, &end, 10);
if(errno || end == value || *end) {
logq("Invalid numeric setting: %s", value);
return 0;
}
if(v < min || v > max) {
logq("Numeric setting out of range: %s (expected %ld..%ld)",
value, min, max);
return 0;
}
*out = v;
return 1;
}
/**
* Handle a single line from backup.conf.
**/
static void
backup_entry(char *line, backup_stats_t *stats) {
char dst[PATH_MAX];
struct stat st;
size_t len;
/* trim whitespace and CR/LF */
while(*line == ' ' || *line == '\t') {
line++;
}
len = strlen(line);
while(len > 0 && (line[len-1] == '\n' || line[len-1] == '\r' ||
line[len-1] == ' ' || line[len-1] == '\t')) {
line[--len] = 0;
}
if(len == 0 || line[0] == '#') {
return;
}
/* Keep legacy key aliases for existing backup.conf files. All MB values
* now mean decimal megabytes, including the legacy max_game_mb key. */
const char *value;
if((value = config_value(line, "backup_interval_seconds", "interval"))) {
long v;
if(!config_number(value, MIN_INTERVAL, MAX_INTERVAL, &v)) {
return;
}
g_interval = v;
return;
}
if((value = config_value(line, "max_title_size_mb", "max_game_mb"))) {
long v;
if(!config_number(value, MIN_GAME_MB, MAX_GAME_MB, &v)) {
return;
}
g_max_game = (uint64_t)v * BYTES_PER_MB;
return;
}
if((value = config_value(line, "daily_snapshots_to_keep", "dailybackups"))) {
long v;
if(!config_number(value, 0, MAX_DAILY, &v)) {
return;
}
g_daily = (int)v;
return;
}
if((value = config_value(line, "backup_ps4_saves", "ps4saves"))) {
parse_bool(value, &g_ps4saves);
return;
}
if((value = config_value(line, "backup_ps5_saves", "ps5saves"))) {
parse_bool(value, &g_ps5saves);
return;
}
if(g_pending_only) return;
/* only absolute paths are valid entries */
if(line[0] != '/') {
return;
}
/* strip the trailing star wildcard (and the slash before it) */
if(line[len-1] == '*') {
line[--len] = 0;
while(len > 1 && line[len-1] == '/') {
line[--len] = 0;
}
}
/* never back up the backup itself */
if(!strncmp(line, USB_ROOT, strlen(USB_ROOT))) {
return;
}
if(snprintf(dst, sizeof dst, "%s%s", BACKUP_ROOT, line) >= (int)sizeof dst) {
return;
}
if(stat(line, &st)) {
logq("Failed to backup %s", line);
stats->failed++;
return;
}
if(S_ISDIR(st.st_mode)) {
backup_tree(line, dst, stats);
} else if(S_ISREG(st.st_mode)) {
backup_file(line, dst, &st, stats);
}
}
/* ------------------------------------------------------------------------
* Daily snapshots (daily_snapshots_to_keep=N)
*
* A snapshot is a copy of the live backup folder, stored as
* /mnt/usb0/backup_daily/YYYY-MM-DD (console local date). The first run on a
* given date creates it and it is never touched again that day. Only the N
* newest date folders are kept.
* ---------------------------------------------------------------------- */
typedef struct clock_info {
char date[16]; /* YYYY-MM-DD in console local time */
int offset_min; /* local time minus UTC, in minutes */
long to_midnight; /* seconds until the next local midnight */
} clock_info_t;
static void
local_clock(time_t now, clock_info_t *ci) {
struct tm lt;
struct tm gt;
int dd;
memset(&lt, 0, sizeof lt);
memset(&gt, 0, sizeof gt);
localtime_r(&now, &lt);
gmtime_r(&now, &gt);
snprintf(ci->date, sizeof ci->date, "%04d-%02d-%02d", lt.tm_year + 1900,
lt.tm_mon + 1, lt.tm_mday);
/* offset between local time and UTC, taking a date/year change into account */
dd = lt.tm_yday - gt.tm_yday;
if(dd > 1) {
dd = -1;
} else if(dd < -1) {
dd = 1;
}
ci->offset_min = dd * 1440 + (lt.tm_hour * 60 + lt.tm_min) -
(gt.tm_hour * 60 + gt.tm_min);
ci->to_midnight = 86400 - (lt.tm_hour * 3600 + lt.tm_min * 60 + lt.tm_sec);
}
static void
format_offset(int offset_min, char *out, size_t len) {
int a = offset_min < 0 ? -offset_min : offset_min;
snprintf(out, len, "UTC%c%d:%02d", offset_min < 0 ? '-' : '+', a / 60, a % 60);
}
/**
* Exactly YYYY-MM-DD. Only folders with such a name are ever deleted.
**/
static int
is_date_name(const char *n) {
if(strlen(n) != 10) {
return 0;
}
for(int i = 0; i < 10; i++) {
if(i == 4 || i == 7) {
if(n[i] != '-') {
return 0;
}
} else if(!isdigit((unsigned char)n[i])) {
return 0;
}
}
return 1;
}
static int
is_date_part_name(const char *n) {
size_t len = strlen(n);
char date[11];
if(len != 15 || strcmp(n + 10, ".part")) {
return 0;
}
memcpy(date, n, 10);
date[10] = 0;
return is_date_name(date);
}
/**
* Delete a file or a whole directory tree.
**/
static int
remove_tree(const char *path) {
char sub[PATH_MAX];
struct dirent *dp;
struct stat st;
int rc = 0;
DIR *dir;
if(lstat(path, &st)) {
return errno == ENOENT ? 0 : -1;
}
if(!S_ISDIR(st.st_mode)) {
return unlink(path);
}
if(!(dir = opendir(path))) {
return -1;
}
while((dp = readdir(dir))) {
if(!strcmp(dp->d_name, ".") || !strcmp(dp->d_name, "..")) {
continue;
}
if(snprintf(sub, sizeof sub, "%s/%s", path, dp->d_name) >= (int)sizeof sub ||
remove_tree(sub)) {
rc = -1;
}
}
closedir(dir);
if(rmdir(path)) {
rc = -1;
}
return rc;
}
/**
* Copy one regular file, keeping its modification time.
**/
static int
copy_file_keep_time(const char *src, const char *dst, const struct stat *st) {
struct timeval tv[2];
ssize_t n;
int rc = -1;
int in;
int out;
if((in = open(src, O_RDONLY)) < 0) {
return -1;
}
if((out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0666)) < 0) {
close(in);
return -1;
}
while((n = read(in, g_buf, COPY_BUF_SZ)) > 0) {
char *p = g_buf;
while(n > 0) {
ssize_t w = write(out, p, n);
if(w < 0) {
if(errno == EINTR) {
continue;
}
goto done;
}
progress_advance(w);
p += w;
n -= w;
}
}
if(n == 0) {
rc = 0;
}
done:
close(in);
if(close(out)) {
rc = -1;
}
if(!rc) {
tv[0].tv_sec = st->st_mtime;
tv[0].tv_usec = 0;
tv[1] = tv[0];
utimes(dst, tv);
}
return rc;
}
/**
* Copy a directory tree from src to dst. Internal bookkeeping files of the
* live backup (.part, .image_stamp) are not part of a snapshot.
**/
static int
copy_tree(const char *src, const char *dst, uint64_t *bytes) {
char spath[PATH_MAX];
char dpath[PATH_MAX];
struct dirent *dp;
struct stat st;
int rc = 0;
DIR *dir;
if(mkdir(dst, 0777) && errno != EEXIST) {
return -1;
}
if(!(dir = opendir(src))) {
return -1;
}
while((dp = readdir(dir)) && !rc) {
size_t len = strlen(dp->d_name);
if(!strcmp(dp->d_name, ".") || !strcmp(dp->d_name, "..")) {
continue;
}
if(!strcmp(dp->d_name, ".ps5save-index") ||
!strcmp(dp->d_name, ".image_stamp") ||
(len > 5 && !strcmp(dp->d_name + len - 5, ".part"))) {
continue;
}
if(snprintf(spath, sizeof spath, "%s/%s", src, dp->d_name) >= (int)sizeof spath ||
snprintf(dpath, sizeof dpath, "%s/%s", dst, dp->d_name) >= (int)sizeof dpath ||
lstat(spath, &st)) {
rc = -1;
break;
}
if(S_ISDIR(st.st_mode)) {
rc = copy_tree(spath, dpath, bytes);
} else if(S_ISREG(st.st_mode)) {
rc = copy_file_keep_time(spath, dpath, &st);
if(!rc) {
*bytes += st.st_size;
}
}
}
closedir(dir);
return rc;
}
static int
cmp_desc(const void *a, const void *b) {
return strcmp((const char*)b, (const char*)a);
}
/**
* Keep only the newest g_daily date folders. Today's folder is never deleted,
* and nothing but folders named exactly YYYY-MM-DD (and stale YYYY-MM-DD.part
* folders of interrupted copies) is ever touched.
**/
static void
daily_prune(const char *today) {
char (*names)[11];
char (*stale)[16];
char path[PATH_MAX];
struct dirent *dp;
struct stat st;
int n = 0;
int ns = 0;
DIR *dir;
names = malloc((size_t)MAX_DAILY_DIRS * sizeof *names);
stale = malloc((size_t)MAX_DAILY_DIRS * sizeof *stale);
if(!names || !stale || !(dir = opendir(DAILY_ROOT))) {
free(names);
free(stale);
return;
}
/* collect first, delete afterwards (never modify a directory while reading it) */
while((dp = readdir(dir))) {
if(snprintf(path, sizeof path, "%s/%s", DAILY_ROOT, dp->d_name) >= (int)sizeof path) {
continue;
}
if(is_date_part_name(dp->d_name)) {
/* nothing is being copied while we prune, so this is stale */
if(ns < MAX_DAILY_DIRS) {
memcpy(stale[ns++], dp->d_name, 16);
}
} else if(is_date_name(dp->d_name) && n < MAX_DAILY_DIRS &&
!stat(path, &st) && S_ISDIR(st.st_mode)) {
memcpy(names[n++], dp->d_name, 11);
}
}
closedir(dir);
for(int i = 0; i < ns; i++) {
snprintf(path, sizeof path, "%s/%s", DAILY_ROOT, stale[i]);
remove_tree(path);
}
qsort(names, n, sizeof *names, cmp_desc);
for(int i = g_daily; i < n; i++) {
if(!strcmp(names[i], today)) {
continue;
}
snprintf(path, sizeof path, "%s/%s", DAILY_ROOT, names[i]);
if(!remove_tree(path)) {
logq("Daily backup %s deleted (keeping newest %d)", names[i], g_daily);
notify("Old daily snapshot removed.");
} else {
logq("Failed to delete old daily backup %s", names[i]);
}
}
free(names);
free(stale);
}
/**
* Create today's snapshot if there is none yet. Called after every live
* backup pass, which covers every payload start, every wake-up from rest mode
* and the midnight wake-up.
**/
static void
daily_check(void) {
char final[PATH_MAX];
char part[PATH_MAX];
char key[PATH_MAX];
char tz[24];
char a[32];
char b[32];
clock_info_t ci;
struct stat st;
uint64_t bytes = 0;
uint64_t need;
if(g_daily <= 0) {
return;
}
local_clock(wall_clock(), &ci);
format_offset(ci.offset_min, tz, sizeof tz);
snprintf(final, sizeof final, "%s/%s", DAILY_ROOT, ci.date);
snprintf(part, sizeof part, "%s.part", final);
/* one snapshot per date: an existing one is never wiped or refreshed */
if(!stat(final, &st) && S_ISDIR(st.st_mode)) {
daily_prune(ci.date);
return;
}
if(stat(BACKUP_ROOT, &st) || !S_ISDIR(st.st_mode)) {
logq("Daily backup %s skipped: no live backup to copy yet", ci.date);
return;
}
need = dir_size(BACKUP_ROOT) + SPACE_MARGIN;
if(usb_free() < need) {
snprintf(key, sizeof key, "daily-space:%s", ci.date);
if(!seen_before(key)) {
format_size(need, a, sizeof a);
format_size(usb_free(), b, sizeof b);
logq("Not enough space on USB for daily backup %s (need ~%s, free %s); "
"skipping", ci.date, a, b);
notify("Daily snapshot skipped: not enough USB space.");
}
return;
}
progress_begin(need - SPACE_MARGIN);
backup_activity_begin();
mkdir_p(DAILY_ROOT);
remove_tree(part); /* leftover of an interrupted copy */
/* copy into a .part folder and rename at the end, so an interrupted copy is
* never mistaken for a finished snapshot */
if(copy_tree(BACKUP_ROOT, part, &bytes) || (sync(), rename(part, final))) {
progress_finish();
remove_tree(part);
snprintf(key, sizeof key, "daily-fail:%s", ci.date);
if(!seen_before(key)) {
logq("Failed to create daily backup %s", ci.date);
notify("Daily snapshot could not be saved.");
}
return;
}
progress_finish();
format_size(bytes, a, sizeof a);
logq("Daily backup %s created (%s, console time %s)", ci.date, a, tz);
notify("Daily snapshot saved.");
daily_prune(ci.date);
}
/**
* Time of the next backup pass: the regular interval, or just after the next
* local midnight if that comes first (so the new day's snapshot is made
* promptly).
**/
static double
next_due(double started) {
double due = started + g_interval;
if(g_daily > 0) {
clock_info_t ci;
local_clock(wall_clock(), &ci);
double midnight = now_seconds() + ci.to_midnight + 1;
if(midnight < due) due = midnight;
}
/* Long passes never overlap or generate a catch-up burst. */
double now = now_seconds();
return due > now ? due : now + g_interval;
}
/**
* Run one backup pass. Returns -1 if backup.conf could not be opened.
**/
static int
run_cycle(int pending_only) {
backup_stats_t stats;
char line[PATH_MAX + 16];
char size[32];
pid_t pid = getpid();
double start;
FILE *f;
/* escape the jail so that /data and /mnt/usb0 are reachable; done on every
* cycle in case the state was reset while the console was suspended */
kernel_set_proc_rootdir(pid, kernel_get_root_vnode());
if(!(f = fopen(CONF_PATH, "r"))) {
return -1;
}
progress_reset();
index_load();
g_pending_only = pending_only;
g_cycle_deferred = 0;
g_backup_busy = 0;
if(!g_start_notified) {
notify("Backup utility started");
g_start_notified = 1;
}
memset(&stats, 0, sizeof stats);
start = now_seconds();
/* Settings are re-read from defaults on every cycle. */
g_interval = DEF_INTERVAL;
g_max_game = DEF_GAME_MB * BYTES_PER_MB;
g_daily = 0;
g_ps4saves = 0;
g_ps5saves = 0;
g_game_abort = 0;
g_watch_game = 0;
g_progress_planning = 1;
while(fgets(line, sizeof line, f)) {
backup_entry(line, &stats);
}
if(g_ps4saves || g_ps5saves) backup_saves(&stats);
if(!g_pending_only && g_daily > 0) {
clock_info_t ci;
char final[PATH_MAX];
struct stat st;
local_clock(wall_clock(), &ci);
snprintf(final, sizeof final, "%s/%s", DAILY_ROOT, ci.date);
if((stat(final, &st) || !S_ISDIR(st.st_mode)) &&
(progress_total || (!stat(BACKUP_ROOT, &st) && S_ISDIR(st.st_mode))))
progress_plan(1);
}
g_progress_planning = 0;
if(fseek(f, 0, SEEK_SET)) {
fclose(f);
return -1;
}
memset(&stats, 0, sizeof stats);
g_interval = DEF_INTERVAL;
g_max_game = DEF_GAME_MB * BYTES_PER_MB;
g_daily = g_ps4saves = g_ps5saves = 0;
while(fgets(line, sizeof line, f)) {
backup_entry(line, &stats);
}
fclose(f);
pending_disable();
if(g_pending_only) {
for(pending_title_t *p = pending_titles; p; p = p->next)
if(p->ready) p->retry_at = now_seconds() + g_interval;
}
if(g_ps4saves || g_ps5saves) {
backup_saves(&stats);
}
/* Regular files have already been processed. A deferred save-image pass
* must not prevent a snapshot of the available live backup. */
index_save();
if(!g_pending_only) daily_check();
progress_stop();
if(stats.failed) {
/* announce only on the first failed cycle until recovery */
if(!g_last_failed) {
logq("Failed to backup (%u file(s) failed, %u copied)",
stats.failed, stats.copied);
notify("Backup incomplete (%u failed).", stats.failed);
}
g_last_failed = 1;
} else if(stats.copied || (g_last_failed && !g_cycle_deferred)) {
format_size(stats.bytes, size, sizeof size);
logq("Successful backup (%s) done in %.1f seconds.", size,
now_seconds() - start);
notify("Backup complete (%s).", size);
g_last_failed = 0;
} else if(g_cycle_deferred) {
logq("Backup pass finished; protected titles queued (%u files unchanged)", stats.unchanged);
} else {
logq("Backup up to date (%u files unchanged)", stats.unchanged);
}
return 0;
}
/**
* Make sure only one instance runs. If another instance is listening on the
* loopback lock port, ask it to quit (by connecting) and take over.
* Returns the listening socket or -1 if the lock could not be set up.
**/
static int
acquire_lock(void) {
struct sockaddr_in addr;
int one = 1;
memset(&addr, 0, sizeof addr);
addr.sin_family = AF_INET;
addr.sin_port = htons(LOCK_PORT);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
for(int attempt = 0; attempt < 10; attempt++) {
int s = socket(AF_INET, SOCK_STREAM, 0);
if(s < 0) {
return -1;
}
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
if(!bind(s, (struct sockaddr*)&addr, sizeof addr) && !listen(s, 1)) {
return s;
}
close(s);
if(errno != EADDRINUSE) {
return -1;
}
/* someone holds the port: tell it to quit, then retry */
if(attempt == 0) {
logq("Replacing running backup instance");
}
s = socket(AF_INET, SOCK_STREAM, 0);
if(s >= 0) {
connect(s, (struct sockaddr*)&addr, sizeof addr);
close(s);
}
sleep(1);
}
return -1;
}
/**
* Wait on monotonic interval deadlines, with short checks for closed queued
* titles. A close retry does not reset the next regular backup deadline.
**/
/* 0 = regular deadline, 1 = replacement requested, 2 = queued title closed. */
static int
wait_until(double due, int lock) {
for(;;) {
double remaining = due - now_seconds();
if(remaining <= 0) return 0;
int slice = pending_titles ? CLOSE_POLL_SEC : SLICE_SEC;
if(pending_ready()) return 2;
int ms = remaining > slice ? slice * 1000 : (int)(remaining * 1000) + 1;
struct pollfd pfd = {lock, POLLIN, 0};
if(poll(&pfd, lock >= 0 ? 1 : 0, ms) > 0 && (pfd.revents & POLLIN)) {
int c = accept(lock, 0, 0);
if(c >= 0) close(c);
return 1;
}
}
}
int
main(void) {
int missing = 0;
int lock;
double due, started;
#ifdef BACKUP_HOST_TEST
int cycles = 1;
const char *max_cycles = getenv("TEST_CYCLES");
#endif
/* the loader's connection may be closed at any time; never die on SIGPIPE */
signal(SIGPIPE, SIG_IGN);
kernel_set_proc_rootdir(getpid(), kernel_get_root_vnode());
lock = acquire_lock();
if(lock < 0) {
notify("Backup could not start: instance lock unavailable.");
return EXIT_FAILURE;
}
if(!(g_buf = malloc(COPY_BUF_SZ))) {
notify("Backup could not start: not enough memory.");
return EXIT_FAILURE;
}
/* Initial backup immediately; subsequent regular deadlines use monotonic time. */
started = now_seconds();
if(run_cycle(0) < 0) {
notify("backup.conf not found on USB.");
return EXIT_FAILURE;
}
if(g_daily > 0) {
/* shows the date and UTC offset the payload sees, to check them against
* the console's clock */
clock_info_t ci;
char tz[24];
local_clock(wall_clock(), &ci);
format_offset(ci.offset_min, tz, sizeof tz);
logq("Daily backups on: keeping newest %d (console date %s, %s)",
g_daily, ci.date, tz);
notify("Daily snapshots enabled (%d kept).", g_daily);
}
due = next_due(started);
for(;;) {
#ifdef BACKUP_HOST_TEST
if(max_cycles && cycles >= atoi(max_cycles)) {
return EXIT_SUCCESS;
}
#endif
int wake = wait_until(due, lock);
if(wake == 1) {
notify("Previous backup instance stopped.");
break;
}
started = now_seconds();
if(run_cycle(wake == 2) < 0) {
/* USB drive may still be mounting after rest mode: retry shortly and
* only complain if it stays missing */
if(++missing == MISSING_TRIES) {
notify("backup.conf not found on USB.");
}
pending_retry_after = now_seconds() + SLICE_SEC * 3;
due = pending_retry_after;
continue;
}
#ifdef BACKUP_HOST_TEST
cycles++;
#endif
if(missing >= MISSING_TRIES) {
logq("backup.conf is back");
}
missing = 0;
pending_retry_after = 0;
/* g_interval may have been changed by the conf file */
if(wake != 2) due = next_due(started);
}
return EXIT_SUCCESS;
}