Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client-sysmodule/source/gen1recomp.cpp
1 change: 1 addition & 0 deletions client-sysmodule/source/gen1recomp.hpp
55 changes: 54 additions & 1 deletion client-sysmodule/source/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@

#include "account.hpp"
#include "fileio.hpp"
#include "gen1recomp.hpp"
#include "http.hpp"
#include "ini.hpp"
#include "sync.hpp"
#include "utils.hpp"


// 절대 함부로 올리지 말 것.
Expand Down Expand Up @@ -822,7 +824,48 @@ bool ensureNetwork()
}


// 한 바퀴. 올릴 것이 없으면 네트워크도 건드리지 않고 조용히 돌아간다.
// This is part of the same sysmodule round, but deliberately has no access to
// the native title/sync pipeline. A Gen1 failure cannot alter native results,
// state or scheduling.
void runGen1Backup(Config& config, const std::vector<Account>& targets)
{
const std::string setting = config["homebrew"]["gen1recomp"].value;
if (!setting.empty() && !(bool)config["homebrew"]["gen1recomp"]) return;
if (!gen1recomp::present()) return;

const std::string wanted = config["account"]["defaultAccountName"].value;
const Account* account = nullptr;
for (const Account& target : targets)
if (wanted == target.nickname) { account = &target; break; }
if (!account)
{
writeLog("Gen1Recomp: default account is not in this round");
return;
}

const SyncOptions normal = makeOptions(config, *account);
gen1recomp::Options options;
options.stagePath = normal.saveDataPath + "/"
+ toHex(normal.uid.uid[0]) + toHex(normal.uid.uid[1]);
options.accountName = normal.nickname;
options.serverUrl = normal.serverUrl;
options.remoteEnabled = normal.remoteEnabled;
// Unlike native saves, an SD homebrew has no Horizon ownership lock.
// Any application appearing while reading it aborts the archive.
options.sourceBusy = [] { return isGameRunning(); };
options.ensureNetwork = [] { return ensureNetwork(); };

const int ret = gen1recomp::runRound(options, [](const std::string& line)
{
writeLog(" " + line);
});
if (ret != gen1recomp::OK && ret != gen1recomp::ABSENT)
writeLog(" Gen1Recomp: will retry next round");
}


// 한 바퀴. 올릴 것이 없으면 네트워크는 건드리지 않지만, 시작과 끝은 반드시
// 남긴다 - 아래 훑는 동안은 몇 분씩 아무 줄도 나오지 않기 때문이다.
void runBackupRound(Config& config, const std::vector<Account>& targets)
{
int pending = 0;
Expand All @@ -834,6 +877,12 @@ void runBackupRound(Config& config, const std::vector<Account>& targets)

if (pending == 0) return;

if (pending == 0)
{
runGen1Backup(config, targets);
return;
}

writeLog("titles to upload: " + std::to_string(pending));

if (!ensureNetwork())
Expand Down Expand Up @@ -872,6 +921,10 @@ void runBackupRound(Config& config, const std::vector<Account>& targets)
writeLog("backup finished with errors - will retry");
logHeapUsage("after round");
}

// Same round, after native saves. Its failure is intentionally isolated:
// normal uNSS state and its next schedule have already been decided.
runGen1Backup(config, targets);
}

} // namespace
Expand Down
14 changes: 11 additions & 3 deletions client/source/fileio.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,22 @@
#include <fcntl.h>


int walk(const std::string& path, std::function<void(const std::string&, bool isDir)> callback)
int walk(const std::string& path, const std::function<void(const std::string&, bool isDir)>& callback, int maxDepth)
{
if (maxDepth <= 0)
{
return -2;
}

DIR* dir = opendir(path.c_str());
if (dir == NULL)
{
return -1;
}

// Propagate a depth failure; a partial traversal must not look complete.
int ret = 0;

dirent* entry;
while ((entry = readdir(dir)) != NULL)
{
Expand All @@ -32,7 +40,7 @@ int walk(const std::string& path, std::function<void(const std::string&, bool is

if (entry->d_type == DT_DIR)
{
walk(fullPath, callback);
if (walk(fullPath, callback, maxDepth - 1) == -2) ret = -2;
callback(fullPath, true);
}
else
Expand All @@ -42,7 +50,7 @@ int walk(const std::string& path, std::function<void(const std::string&, bool is
}

closedir(dir);
return 0;
return ret;
}


Expand Down
5 changes: 4 additions & 1 deletion client/source/fileio.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
#include <switch.h>


int walk(const std::string& path, std::function<void(const std::string&, bool isDir)> callback);
// Recursively visit files, then directories. Keep one callback instance to
// avoid growing the small sysmodule stack with std::function copies. Return
// -2 when maxDepth is exceeded so callers never publish a partial traversal.
int walk(const std::string& path, const std::function<void(const std::string&, bool isDir)>& callback, int maxDepth = 64);

int recursiveMkdir(const std::string& path, mode_t mode = 0777);

Expand Down
236 changes: 236 additions & 0 deletions client/source/gen1recomp.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
#include "gen1recomp.hpp"

#include <cerrno>
#include <cstdio>
#include <cstring>
#include <sys/stat.h>
#include <vector>

#include "fileio.hpp"
#include "remote.hpp"
#include "utils.hpp"
#include "zipio.hpp"

namespace
{
constexpr u64 FNV_OFFSET = 14695981039346656037ULL;
constexpr u64 FNV_PRIME = 1099511628211ULL;
constexpr size_t MAX_FILES = 256;
constexpr off_t MAX_FILE_SIZE = 4 * 1024 * 1024;
constexpr u64 MAX_TOTAL_SIZE = 32ULL * 1024 * 1024;
constexpr int MAX_DEPTH = 8;

struct Entry { std::string path; std::string name; };

bool safeName(const std::string& name)
{
if (name.empty() || name.size() >= 256 || name.find('\\') != std::string::npos
|| name.find(':') != std::string::npos) return false;
size_t at = 0;
while (at < name.size())
{
const size_t slash = name.find('/', at);
const size_t end = slash == std::string::npos ? name.size() : slash;
if (end - at == 2 && name.compare(at, 2, "..") == 0) return false;
at = end + 1;
}
return true;
}

// Do not follow a symlink at root or any of its parents. The selected source
// must stay below the configured SD path.
bool safeRoot(const std::string& root)
{
if (root.empty()) return false;
std::string path = root;
while (!path.empty())
{
struct stat st;
if (lstat(path.c_str(), &st) != 0 || S_ISLNK(st.st_mode)) return false;
if (path.size() >= 2 && path[path.size() - 2] == ':' && path.back() == '/') break;
const size_t slash = path.find_last_of('/');
if (slash == std::string::npos) break;
if (slash == 0) { path = "/"; break; }
// Keep the slash in a libnx mount root ("sdmc:/").
path.resize(path[slash - 1] == ':' ? slash + 1 : slash);
}
return true;
}

int collect(const std::string& root, bool needOptions, std::vector<Entry>& entries)
{
if (!safeRoot(root)) return gen1recomp::FAILED;
const std::string saves = root + "/saves";
struct stat st;
if (lstat(saves.c_str(), &st) != 0)
return errno == ENOENT ? gen1recomp::ABSENT : gen1recomp::FAILED;
if (!S_ISDIR(st.st_mode) || S_ISLNK(st.st_mode)) return gen1recomp::FAILED;

u64 total = 0;
int ret = gen1recomp::OK;
const int walked = walk(saves, [&](const std::string& path, bool isDir)
{
if (isDir || ret != gen1recomp::OK) return;
struct stat item;
if (lstat(path.c_str(), &item) != 0 || S_ISLNK(item.st_mode)
|| !S_ISREG(item.st_mode) || item.st_size <= 0 || item.st_size > MAX_FILE_SIZE
|| entries.size() >= MAX_FILES)
{ ret = gen1recomp::FAILED; return; }
const std::string name = "saves/" + path.substr(saves.size() + 1);
if (!safeName(name) || (total += (u64)item.st_size) > MAX_TOTAL_SIZE)
{ ret = gen1recomp::FAILED; return; }
entries.push_back({path, name});
}, MAX_DEPTH);
if (walked != 0 || ret != gen1recomp::OK) return gen1recomp::FAILED;
if (entries.empty()) return gen1recomp::ABSENT;

const std::string options = root + "/options.lua";
if (lstat(options.c_str(), &st) != 0)
return needOptions ? gen1recomp::FAILED : gen1recomp::OK;
if (!S_ISREG(st.st_mode) || S_ISLNK(st.st_mode) || st.st_size <= 0 || st.st_size > MAX_FILE_SIZE
|| entries.size() >= MAX_FILES || (total += (u64)st.st_size) > MAX_TOTAL_SIZE)
return gen1recomp::FAILED;
entries.push_back({options, "options.lua"});
return gen1recomp::OK;
}

bool hashFile(const Entry& entry, u64& combined)
{
FILE* fp = fopen(entry.path.c_str(), "rb");
if (!fp) return false;
u64 value = FNV_OFFSET;
for (const char c : entry.name) { value ^= (unsigned char)c; value *= FNV_PRIME; }
char buffer[4096];
for (size_t got; (got = fread(buffer, 1, sizeof(buffer), fp)) != 0;)
for (size_t i = 0; i < got; ++i) { value ^= (unsigned char)buffer[i]; value *= FNV_PRIME; }
const bool ok = !ferror(fp);
fclose(fp);
if (!ok) return false;
combined ^= value;
return true;
}

u64 fingerprintEntries(const std::vector<Entry>& entries)
{
u64 combined = 0;
for (const Entry& entry : entries) if (!hashFile(entry, combined)) return 0;
return (combined ^ ((u64)entries.size() * FNV_PRIME)) | 1ULL;
}

std::string statePath(const std::string& stage) { return stage + "/.syncstate.gen1"; }

u64 readState(const std::string& stage)
{
FILE* fp = fopen(statePath(stage).c_str(), "r");
if (!fp) return 0;
unsigned long long id = 0, value = 0;
const int got = fscanf(fp, "%llx %llx", &id, &value);
fclose(fp);
return got == 2 && id == gen1recomp::TITLE_ID ? (u64)value : 0;
}

bool writeState(const std::string& stage, u64 fingerprint)
{
FILE* fp = fopen(statePath(stage).c_str(), "w");
if (!fp) return false;
const bool ok = fprintf(fp, "%016llx %016llx\n",
(unsigned long long)gen1recomp::TITLE_ID, (unsigned long long)fingerprint) > 0;
fclose(fp);
return ok;
}

bool transient(int result)
{
// Keep v20's retry policy without pulling the HTTP client implementation
// into this adapter: -2 is malformed URL, -100 is unsupported.
return (result < 0 && result != -2 && result != -100) || result >= 500;
}
}

namespace gen1recomp
{
bool present(const std::string& root)
{
struct stat st;
return lstat((root + "/saves").c_str(), &st) == 0 || errno != ENOENT;
}

u64 fingerprint(const std::string& root)
{
std::vector<Entry> entries;
return collect(root, true, entries) == OK ? fingerprintEntries(entries) : 0;
}

int archive(const std::string& root, const std::string& outSar,
const std::function<bool()>& sourceBusy)
{
std::vector<Entry> entries;
const int collected = collect(root, true, entries);
if (collected != OK) return collected;
const u64 before = fingerprintEntries(entries);
if (!before) return FAILED;

const std::string temporary = outSar + ".gen1.tmp";
const std::string previous = outSar + ".gen1.previous";
remove(temporary.c_str());
remove(previous.c_str());
ZipWriter zip;
if (!zip.open(temporary)) return FAILED;
bool ok = true;
bool busy = false;
for (const Entry& entry : entries)
{
busy = sourceBusy && sourceBusy();
if (busy || !zip.add(entry.path, entry.name)) { ok = false; break; }
}
zip.close();
if (!ok)
{ remove(temporary.c_str()); return busy ? BUSY : FAILED; }

std::vector<Entry> afterEntries;
if (collect(root, true, afterEntries) != OK || fingerprintEntries(afterEntries) != before)
{ remove(temporary.c_str()); return FAILED; }

struct stat st;
const bool hadOld = lstat(outSar.c_str(), &st) == 0;
if (hadOld && rename(outSar.c_str(), previous.c_str()) != 0)
{ remove(temporary.c_str()); return FAILED; }
if (rename(temporary.c_str(), outSar.c_str()) != 0)
{
if (hadOld) rename(previous.c_str(), outSar.c_str());
remove(temporary.c_str());
return FAILED;
}
if (hadOld) remove(previous.c_str());
return OK;
}

int runRound(const Options& options, const std::function<void(const std::string&)>& log)
{
if (!present(options.root)) { log("Gen1Recomp: source save not found"); return OK; }
const u64 current = fingerprint(options.root);
if (!current) { log("Gen1Recomp: no valid SD save to back up"); return OK; }
if (current == readState(options.stagePath))
{
log("Gen1Recomp: unchanged - backup already uploaded");
return OK;
}
if (recursiveMkdir(options.stagePath) != 0) { log("Gen1Recomp: cannot create staging path"); return FAILED; }
const std::string sar = options.stagePath + "/" + toHex(TITLE_ID) + ".sar";
const int archived = archive(options.root, sar, options.sourceBusy);
if (archived != OK) { log("Gen1Recomp: archive postponed/failed (" + std::to_string(archived) + ")"); return archived; }
if (!options.remoteEnabled) return writeState(options.stagePath, current) ? OK : FAILED;
if (options.ensureNetwork && !options.ensureNetwork()) { log("Gen1Recomp: no network"); return FAILED; }
HTTPRemoteStore remote(options.serverUrl, options.stagePath);
int ret = remote.push(options.accountName, TITLE_ID);
for (int retry = 1; ret != 0 && retry < 3 && transient(remote.getLastHttpResult()); ++retry)
{
svcSleepThread(5000000000ULL);
ret = remote.push(options.accountName, TITLE_ID);
}
if (ret != 0) { log("Gen1Recomp: upload failed"); return FAILED; }
if (!writeState(options.stagePath, current)) return FAILED;
log("Gen1Recomp: backup finished");
return OK;
}
}
Loading