From 48113bec7095ff489fb77e10d3d182f5c5ee6210 Mon Sep 17 00:00:00 2001 From: Selectively11 Date: Thu, 13 Aug 2026 20:19:02 -0400 Subject: [PATCH 1/3] Slightly toned down readme. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 04df1cfd..19c98308 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ""Steam Cloud"" for 'lua' games. -**This the only official source for CloudRedirect. Any other websites are not operated by me or are otherwise endorsed by this project. At least one of those fake sites is actively distributing malware!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!** +**This the only official source for CloudRedirect. Any other websites are not operated by me or are otherwise endorsed by this project. At least one of those fake sites was actively distributing malware!** > ****This software is experimental and under active development.**** The underlying techniques are fairly insane. What this software tries to do is nuts to attempt. This software could damage your save files and probably will! It could overwrite your saves, cause weird conflicts, make your saves disappear, make you cry. Back up any saves you care about before using this software. From f1697e7908c498334e67e5d4abee420d0bf5ecf6 Mon Sep 17 00:00:00 2001 From: Enzo Lanzellotti Date: Tue, 18 Aug 2026 00:41:22 -0300 Subject: [PATCH 2/3] feat: add raw game save CLI commands --- src/common/cli.cpp | 365 +++++++++++++++++++++++++++++++++++++++++++++ src/common/cli.h | 11 +- 2 files changed, 375 insertions(+), 1 deletion(-) diff --git a/src/common/cli.cpp b/src/common/cli.cpp index fab79199..9c257c7d 100644 --- a/src/common/cli.cpp +++ b/src/common/cli.cpp @@ -24,6 +24,10 @@ #include #include #include +#include +#include +#include +#include #include #ifdef _WIN32 @@ -38,6 +42,16 @@ namespace CloudRedirectCli { static std::string GetConfigDir() { + if (const char* overrideDir = std::getenv("CLOUD_REDIRECT_CONFIG_DIR"); + overrideDir != nullptr && *overrideDir != '\0') { + std::string result = overrideDir; +#ifdef _WIN32 + if (result.back() != '\\' && result.back() != '/') result += '\\'; +#else + if (result.back() != '/') result += '/'; +#endif + return result; + } #ifdef _WIN32 wchar_t* appDataPath = nullptr; if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, nullptr, &appDataPath))) { @@ -135,6 +149,325 @@ static std::string JsonError(const std::string& message) { return JsonObject({{"success", JsonBool(false)}, {"error", JsonString(message)}}); } +static bool IsPositiveDecimal(const std::string& value) { + return !value.empty() && value != "0" && + std::all_of(value.begin(), value.end(), [](unsigned char c) { return c >= '0' && c <= '9'; }); +} + +static bool ReadBinaryFile(const std::string& path, std::vector& out) { + std::ifstream file(FileUtil::Utf8ToPath(path), std::ios::binary | std::ios::ate); + if (!file) return false; + auto size = file.tellg(); + if (size < 0 || static_cast(size) > 2ULL * 1024 * 1024 * 1024) return false; + out.resize(static_cast(size)); + if (size == 0) return true; + file.seekg(0, std::ios::beg); + return static_cast(file.read(reinterpret_cast(out.data()), size)); +} + +static std::string SanitizeGameFolderName(const std::string& gameName) { + std::string result; + result.reserve(gameName.size()); + for (unsigned char c : gameName) { + const bool invalid = c < 32 || c == '/' || c == '\\' || c == ':' || c == '*' || + c == '?' || c == '"' || c == '<' || c == '>' || c == '|'; + result.push_back(invalid ? '_' : static_cast(c)); + } + while (!result.empty() && (result.back() == ' ' || result.back() == '.')) result.pop_back(); + size_t first = result.find_first_not_of(' '); + if (first == std::string::npos) return "Game"; + result.erase(0, first); + if (result == "." || result == "..") return "Game"; + return result; +} + +static bool SafeSaveRelativePath(const std::string& relative) { + if (relative.empty() || relative.front() == '/' || relative.front() == '\\') return false; + std::string part; + std::istringstream stream(relative); + while (std::getline(stream, part, '/')) { + if (part.empty() || part == "." || part == ".." || part.find('\\') != std::string::npos) + return false; +#ifdef _WIN32 + if (part.find(':') != std::string::npos) return false; +#endif + } + return true; +} + +static bool EnumerateSaveFiles(const std::string& directory, + std::vector>& out, + std::string& error) { + namespace fs = std::filesystem; + std::error_code ec; + fs::path root = fs::weakly_canonical(FileUtil::Utf8ToPath(directory), ec); + if (ec || !fs::is_directory(root, ec)) { + error = "Prepared save directory was not found"; + return false; + } + + fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, ec), end; + for (; !ec && it != end; it.increment(ec)) { + if (!it->is_regular_file(ec) || ec) continue; + fs::path relativePath = fs::relative(it->path(), root, ec); + if (ec) break; + std::string relative = FileUtil::PathToUtf8(relativePath); + std::replace(relative.begin(), relative.end(), '\\', '/'); + if (!SafeSaveRelativePath(relative)) { + error = "Prepared save directory contains an unsafe path"; + return false; + } + out.emplace_back(relative, FileUtil::PathToUtf8(it->path())); + } + if (ec) { + error = "Could not enumerate prepared save files"; + return false; + } + std::sort(out.begin(), out.end()); + if (out.empty()) { + error = "Prepared save directory contains no files"; + return false; + } + return true; +} + +static bool RemoveAllObjectVersions(ICloudProvider& provider, const std::string& path) { + if (!provider.SupportsVersioning()) return provider.Remove(path); + std::vector versions; + if (!provider.ListVersions(path, versions)) return false; + bool ok = true; + for (const auto& version : versions) { + if (!version.versionId.empty() && !provider.RemoveVersion(path, version.versionId)) ok = false; + } + return ok; +} + +static bool PurgeSupersededObjectVersions(ICloudProvider& provider, const std::string& path) { + if (!provider.SupportsVersioning()) return true; + std::vector versions; + if (!provider.ListVersions(path, versions)) return false; + bool ok = true; + for (const auto& version : versions) { + if (!version.isLatest && !version.versionId.empty() && + !provider.RemoveVersion(path, version.versionId)) ok = false; + } + return ok; +} + +static std::string GetSaveProviderInitPath(const std::string& providerName) { + if (providerName != "folder" && providerName != "local") return GetTokenPath(providerName); + + std::string configPath = GetConfigDir() + "config.json"; + std::ifstream file(FileUtil::Utf8ToPath(configPath), std::ios::binary); + if (!file) return ""; + std::ostringstream content; + content << file.rdbuf(); + auto config = Json::Parse(content.str()); + return config.type == Json::Type::Object && config.has("sync_path") + ? config["sync_path"].str() + : ""; +} + +static bool InitSaveProvider(const std::string& providerName, + std::unique_ptr& provider, + std::string& error) { + std::string initPath = GetSaveProviderInitPath(providerName); + if (initPath.empty()) { + error = "Cannot determine provider configuration"; + return false; + } + provider = CreateCloudProvider(providerName); + if (!provider) { + error = "Unknown provider: " + providerName; + return false; + } + if (!provider->Init(initPath)) { + error = "Failed to initialize provider"; + return false; + } + if (!provider->IsAuthenticated()) { + provider->Shutdown(); + provider.reset(); + error = "Not authenticated"; + return false; + } + return true; +} + +std::string CmdSaveUpload(const std::string& providerName, const std::string& accountId, + const std::string& appId, const std::string& gameName, + const std::string& sourceDirectory) { + if (!IsPositiveDecimal(accountId) || !IsPositiveDecimal(appId)) + return JsonError("Invalid account_id or app_id"); + + std::vector> localFiles; + std::string error; + if (!EnumerateSaveFiles(sourceDirectory, localFiles, error)) return JsonError(error); + + std::unique_ptr provider; + if (!InitSaveProvider(providerName, provider, error)) return JsonError(error); + + const std::string folder = SanitizeGameFolderName(gameName); + const std::string prefix = accountId + "/" + appId + "/" + folder + "/"; + std::unordered_set wantedPaths; + uint64_t totalBytes = 0; + bool versionCleanupOk = true; + for (const auto& [relative, localPath] : localFiles) { + std::vector content; + if (!ReadBinaryFile(localPath, content)) { + provider->Shutdown(); + return JsonError("A save file is unreadable or larger than 2 GiB: " + relative); + } + const std::string cloudPath = prefix + relative; + if (!provider->Upload(cloudPath, content.data(), content.size())) { + provider->Shutdown(); + return JsonError("Save file upload failed: " + relative); + } + wantedPaths.insert(cloudPath); + totalBytes += content.size(); + if (!PurgeSupersededObjectVersions(*provider, cloudPath)) versionCleanupOk = false; + } + + std::vector remoteFiles; + bool complete = false; + if (!provider->ListChecked(prefix, remoteFiles, &complete) || !complete) { + provider->Shutdown(); + return JsonError("Save files uploaded, but stale remote files could not be checked"); + } + size_t removed = 0; + for (const auto& remote : remoteFiles) { + if (remote.path.rfind(prefix, 0) != 0 || wantedPaths.contains(remote.path)) continue; + if (!RemoveAllObjectVersions(*provider, remote.path)) { + provider->Shutdown(); + return JsonError("Save files uploaded, but a stale remote file could not be removed"); + } + ++removed; + } + provider->Shutdown(); + + return JsonObject({ + {"success", JsonBool(true)}, + {"game", JsonString(folder)}, + {"files", JsonInt(static_cast(localFiles.size()))}, + {"removed", JsonInt(static_cast(removed))}, + {"bytes", JsonInt(static_cast(totalBytes))}, + {"version_cleanup", JsonBool(versionCleanupOk)} + }); +} + +std::string CmdSaveDownload(const std::string& providerName, const std::string& accountId, + const std::string& appId, const std::string& gameName, + const std::string& outputDirectory) { + if (!IsPositiveDecimal(accountId) || !IsPositiveDecimal(appId)) + return JsonError("Invalid account_id or app_id"); + + std::unique_ptr provider; + std::string error; + if (!InitSaveProvider(providerName, provider, error)) return JsonError(error); + + const std::string folder = SanitizeGameFolderName(gameName); + const std::string prefix = accountId + "/" + appId + "/" + folder + "/"; + std::vector listed; + bool complete = false; + if (!provider->ListChecked(prefix, listed, &complete) || !complete) { + provider->Shutdown(); + return JsonError("Could not list save files"); + } + + std::vector saveFiles; + for (const auto& item : listed) { + if (item.path.rfind(prefix, 0) == 0 && item.path.size() > prefix.size()) + saveFiles.push_back(item.path); + } + if (saveFiles.empty()) { + provider->Shutdown(); + return JsonError("No save files found"); + } + std::sort(saveFiles.begin(), saveFiles.end()); + + namespace fs = std::filesystem; + fs::path outputRoot = FileUtil::Utf8ToPath(outputDirectory); + std::error_code ec; + fs::create_directories(outputRoot, ec); + if (ec) { + provider->Shutdown(); + return JsonError("Could not create save download directory"); + } + + uint64_t totalBytes = 0; + for (const auto& cloudPath : saveFiles) { + const std::string relative = cloudPath.substr(prefix.size()); + if (!SafeSaveRelativePath(relative)) { + provider->Shutdown(); + fs::remove_all(outputRoot, ec); + return JsonError("Cloud save contains an unsafe path"); + } + std::vector content; + if (!provider->Download(cloudPath, content)) { + provider->Shutdown(); + fs::remove_all(outputRoot, ec); + return JsonError("Save file download failed: " + relative); + } + fs::path destination = outputRoot / FileUtil::Utf8ToPath(relative); + fs::create_directories(destination.parent_path(), ec); + if (ec || !FileUtil::AtomicWriteBinary(FileUtil::PathToUtf8(destination), content.data(), content.size())) { + provider->Shutdown(); + fs::remove_all(outputRoot, ec); + return JsonError("Could not write downloaded save file: " + relative); + } + totalBytes += content.size(); + } + provider->Shutdown(); + + return JsonObject({ + {"success", JsonBool(true)}, + {"game", JsonString(folder)}, + {"path", JsonString(outputDirectory)}, + {"files", JsonInt(static_cast(saveFiles.size()))}, + {"bytes", JsonInt(static_cast(totalBytes))} + }); +} + +std::string CmdSaveList(const std::string& providerName, const std::string& accountId, + const std::string& appId, const std::string& gameName) { + if (!IsPositiveDecimal(accountId) || !IsPositiveDecimal(appId)) + return JsonError("Invalid account_id or app_id"); + + std::unique_ptr provider; + std::string error; + if (!InitSaveProvider(providerName, provider, error)) return JsonError(error); + + const std::string folder = SanitizeGameFolderName(gameName); + const std::string prefix = accountId + "/" + appId + "/" + folder + "/"; + std::vector listed; + bool complete = false; + if (!provider->ListChecked(prefix, listed, &complete) || !complete) { + provider->Shutdown(); + return JsonError("Could not list save files"); + } + provider->Shutdown(); + + std::vector saveFiles; + for (const auto& item : listed) { + if (item.path.rfind(prefix, 0) == 0 && item.path.size() > prefix.size()) saveFiles.push_back(item); + } + std::sort(saveFiles.begin(), saveFiles.end(), [](const auto& a, const auto& b) { + return a.path > b.path; + }); + + std::ostringstream array; + array << "["; + for (size_t i = 0; i < saveFiles.size(); ++i) { + if (i) array << ","; + array << JsonObject({ + {"file", JsonString(saveFiles[i].path.substr(prefix.size()))}, + {"size", JsonInt(static_cast(saveFiles[i].size))} + }); + } + array << "]"; + return JsonObject({{"success", JsonBool(true)}, {"game", JsonString(folder)}, {"files", array.str()}}); +} + static std::string JsonSuccess() { return JsonObject({{"success", JsonBool(true)}}); } @@ -1295,6 +1628,9 @@ static void PrintUsage() { fprintf(stderr, " gc-blobs Delete unreferenced SHA blobs from cloud\n"); fprintf(stderr, " scan-all List all apps across all accounts (single-pass)\n"); fprintf(stderr, " migrate Copy all cloud data from one provider to another\n"); + fprintf(stderr, " save upload Replace raw cloud save files\n"); + fprintf(stderr, " save download Download raw save files\n"); + fprintf(stderr, " save list List raw save files\n"); fprintf(stderr, "\nProviders: gdrive, onedrive, r2, s3\n"); } @@ -1417,6 +1753,35 @@ int RunCli(int argc, char** argv) { } result = CmdScanAll(argv[3]); } + else if (strcmp(command, "save") == 0) { + if (argc < 4) { + fprintf(stderr, "Error: save requires upload, download, or list\n"); + return 1; + } + const std::string operation = argv[3]; + if (operation == "upload") { + if (argc < 9) { + fprintf(stderr, "Error: save upload requires \n"); + return 1; + } + result = CmdSaveUpload(argv[4], argv[5], argv[6], argv[7], argv[8]); + } else if (operation == "download") { + if (argc < 9) { + fprintf(stderr, "Error: save download requires \n"); + return 1; + } + result = CmdSaveDownload(argv[4], argv[5], argv[6], argv[7], argv[8]); + } else if (operation == "list") { + if (argc < 8) { + fprintf(stderr, "Error: save list requires \n"); + return 1; + } + result = CmdSaveList(argv[4], argv[5], argv[6], argv[7]); + } else { + fprintf(stderr, "Error: unknown save operation: %s\n", argv[3]); + return 1; + } + } else if (strcmp(command, "migrate") == 0) { if (argc < 5) { fprintf(stderr, "Error: migrate requires \n"); diff --git a/src/common/cli.h b/src/common/cli.h index 0662e146..8e8f767b 100644 --- a/src/common/cli.h +++ b/src/common/cli.h @@ -7,7 +7,8 @@ // Linux: cloud_redirect_cli [args...] // // Commands: -// Subcommands: auth-status, authenticate, list-remote-apps, delete-remote-app, list-blobs, delete-blobs +// Subcommands include provider management plus `save upload`, `save download`, and `save list` +// for game-named folders containing the original save files. // // All output is JSON to stdout. Exit code 0 = success, 1 = error. @@ -41,6 +42,14 @@ std::string CmdSyncAllRemoteApps(const std::string& provider, const std::string& std::string CmdPruneLocalLegacyMetadata(const std::string& cloudRoot); std::string CmdPublishFullManifest(const std::string& provider, const std::string& accountId, const std::string& appId, const std::string& cloudRoot); +std::string CmdSaveUpload(const std::string& provider, const std::string& accountId, + const std::string& appId, const std::string& gameName, + const std::string& sourceDirectory); +std::string CmdSaveDownload(const std::string& provider, const std::string& accountId, + const std::string& appId, const std::string& gameName, + const std::string& outputDirectory); +std::string CmdSaveList(const std::string& provider, const std::string& accountId, + const std::string& appId, const std::string& gameName); } // namespace CloudRedirectCli From b282814bfce253cb3df860625fe837c8b2cf15b3 Mon Sep 17 00:00:00 2001 From: Enzo Lanzellotti Date: Tue, 18 Aug 2026 00:52:49 -0300 Subject: [PATCH 3/3] fix: preserve existing cloud save files --- src/common/cli.cpp | 47 +--------------------------------------------- 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/src/common/cli.cpp b/src/common/cli.cpp index 9c257c7d..2d5b5a19 100644 --- a/src/common/cli.cpp +++ b/src/common/cli.cpp @@ -231,29 +231,6 @@ static bool EnumerateSaveFiles(const std::string& directory, return true; } -static bool RemoveAllObjectVersions(ICloudProvider& provider, const std::string& path) { - if (!provider.SupportsVersioning()) return provider.Remove(path); - std::vector versions; - if (!provider.ListVersions(path, versions)) return false; - bool ok = true; - for (const auto& version : versions) { - if (!version.versionId.empty() && !provider.RemoveVersion(path, version.versionId)) ok = false; - } - return ok; -} - -static bool PurgeSupersededObjectVersions(ICloudProvider& provider, const std::string& path) { - if (!provider.SupportsVersioning()) return true; - std::vector versions; - if (!provider.ListVersions(path, versions)) return false; - bool ok = true; - for (const auto& version : versions) { - if (!version.isLatest && !version.versionId.empty() && - !provider.RemoveVersion(path, version.versionId)) ok = false; - } - return ok; -} - static std::string GetSaveProviderInitPath(const std::string& providerName) { if (providerName != "folder" && providerName != "local") return GetTokenPath(providerName); @@ -309,9 +286,7 @@ std::string CmdSaveUpload(const std::string& providerName, const std::string& ac const std::string folder = SanitizeGameFolderName(gameName); const std::string prefix = accountId + "/" + appId + "/" + folder + "/"; - std::unordered_set wantedPaths; uint64_t totalBytes = 0; - bool versionCleanupOk = true; for (const auto& [relative, localPath] : localFiles) { std::vector content; if (!ReadBinaryFile(localPath, content)) { @@ -323,25 +298,7 @@ std::string CmdSaveUpload(const std::string& providerName, const std::string& ac provider->Shutdown(); return JsonError("Save file upload failed: " + relative); } - wantedPaths.insert(cloudPath); totalBytes += content.size(); - if (!PurgeSupersededObjectVersions(*provider, cloudPath)) versionCleanupOk = false; - } - - std::vector remoteFiles; - bool complete = false; - if (!provider->ListChecked(prefix, remoteFiles, &complete) || !complete) { - provider->Shutdown(); - return JsonError("Save files uploaded, but stale remote files could not be checked"); - } - size_t removed = 0; - for (const auto& remote : remoteFiles) { - if (remote.path.rfind(prefix, 0) != 0 || wantedPaths.contains(remote.path)) continue; - if (!RemoveAllObjectVersions(*provider, remote.path)) { - provider->Shutdown(); - return JsonError("Save files uploaded, but a stale remote file could not be removed"); - } - ++removed; } provider->Shutdown(); @@ -349,9 +306,7 @@ std::string CmdSaveUpload(const std::string& providerName, const std::string& ac {"success", JsonBool(true)}, {"game", JsonString(folder)}, {"files", JsonInt(static_cast(localFiles.size()))}, - {"removed", JsonInt(static_cast(removed))}, - {"bytes", JsonInt(static_cast(totalBytes))}, - {"version_cleanup", JsonBool(versionCleanupOk)} + {"bytes", JsonInt(static_cast(totalBytes))} }); }