mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-18 05:00:47 +02:00
455 lines
15 KiB
C++
455 lines
15 KiB
C++
#include "runtime/platform_cache_migration.h"
|
|
|
|
#define JSON_NOEXCEPTION 1
|
|
#include <fcntl.h>
|
|
#include <sys/file.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cerrno>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <nlohmann/json.hpp>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <string_view>
|
|
|
|
#include "runtime/environment.h"
|
|
#include "runtime/runtime_paths.h"
|
|
|
|
namespace mocktail {
|
|
namespace runtime {
|
|
namespace {
|
|
|
|
constexpr std::uintmax_t kMaximumJsonBytes = 16U * 1024U * 1024U;
|
|
constexpr int kFingerprintSchemaVersion = 1;
|
|
constexpr std::string_view kAndroidDataPrefix =
|
|
"/data/user/0/com.roblox.client";
|
|
constexpr std::string_view kLegacyAndroidDataPrefix =
|
|
"/data/data/com.roblox.client";
|
|
std::atomic<std::uint64_t> g_temporary_sequence{0};
|
|
|
|
class ScopedFileDescriptor final {
|
|
public:
|
|
explicit ScopedFileDescriptor(int descriptor = -1)
|
|
: descriptor_(descriptor) {}
|
|
~ScopedFileDescriptor() {
|
|
if (descriptor_ >= 0) {
|
|
close(descriptor_);
|
|
}
|
|
}
|
|
|
|
ScopedFileDescriptor(const ScopedFileDescriptor&) = delete;
|
|
ScopedFileDescriptor& operator=(const ScopedFileDescriptor&) = delete;
|
|
|
|
int get() const { return descriptor_; }
|
|
|
|
private:
|
|
int descriptor_ = -1;
|
|
};
|
|
|
|
enum class ReadStatus { kMissing, kReady, kInvalid };
|
|
|
|
struct JsonReadResult {
|
|
ReadStatus status = ReadStatus::kMissing;
|
|
nlohmann::json value;
|
|
std::string error;
|
|
};
|
|
|
|
bool HasPathPrefix(const std::filesystem::path& path,
|
|
const std::filesystem::path& prefix) {
|
|
auto path_component = path.begin();
|
|
auto prefix_component = prefix.begin();
|
|
while (prefix_component != prefix.end()) {
|
|
if (path_component == path.end() || *path_component != *prefix_component) {
|
|
return false;
|
|
}
|
|
++path_component;
|
|
++prefix_component;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::optional<std::filesystem::path> TranslateAndroidFilesPath(
|
|
std::string_view configured, const std::filesystem::path& data_root) {
|
|
for (const std::string_view prefix :
|
|
{kAndroidDataPrefix, kLegacyAndroidDataPrefix}) {
|
|
if (configured == prefix) {
|
|
return data_root;
|
|
}
|
|
if (configured.size() > prefix.size() &&
|
|
configured.compare(0, prefix.size(), prefix) == 0 &&
|
|
configured[prefix.size()] == '/') {
|
|
return data_root / std::string(configured.substr(prefix.size() + 1));
|
|
}
|
|
}
|
|
const std::filesystem::path host_path(configured);
|
|
return host_path.is_absolute() ? std::optional(host_path) : std::nullopt;
|
|
}
|
|
|
|
bool WriteAll(int descriptor, std::string_view bytes) {
|
|
std::size_t offset = 0;
|
|
while (offset < bytes.size()) {
|
|
const ssize_t written =
|
|
write(descriptor, bytes.data() + offset, bytes.size() - offset);
|
|
if (written < 0) {
|
|
if (errno == EINTR) {
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
if (written == 0) {
|
|
return false;
|
|
}
|
|
offset += static_cast<std::size_t>(written);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
JsonReadResult ReadJson(const std::filesystem::path& path) {
|
|
const int descriptor = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
|
|
if (descriptor < 0) {
|
|
if (errno == ENOENT) {
|
|
return {};
|
|
}
|
|
return {
|
|
ReadStatus::kInvalid, {}, "cannot safely open platform cache metadata"};
|
|
}
|
|
const ScopedFileDescriptor file(descriptor);
|
|
struct stat status{};
|
|
if (fstat(file.get(), &status) != 0 || !S_ISREG(status.st_mode) ||
|
|
status.st_size < 0 ||
|
|
static_cast<std::uintmax_t>(status.st_size) > kMaximumJsonBytes) {
|
|
return {ReadStatus::kInvalid,
|
|
{},
|
|
"platform cache metadata is not a bounded regular file"};
|
|
}
|
|
|
|
std::string bytes(static_cast<std::size_t>(status.st_size), '\0');
|
|
std::size_t offset = 0;
|
|
while (offset < bytes.size()) {
|
|
const ssize_t count =
|
|
read(file.get(), bytes.data() + offset, bytes.size() - offset);
|
|
if (count < 0) {
|
|
if (errno == EINTR) {
|
|
continue;
|
|
}
|
|
return {ReadStatus::kInvalid, {}, "cannot read platform cache metadata"};
|
|
}
|
|
if (count == 0) {
|
|
return {ReadStatus::kInvalid,
|
|
{},
|
|
"platform cache metadata changed while being read"};
|
|
}
|
|
offset += static_cast<std::size_t>(count);
|
|
}
|
|
nlohmann::json parsed = nlohmann::json::parse(bytes, nullptr, false, true);
|
|
std::fill(bytes.begin(), bytes.end(), '\0');
|
|
if (parsed.is_discarded() || !parsed.is_object()) {
|
|
return {ReadStatus::kInvalid,
|
|
{},
|
|
"platform cache metadata is not a JSON object"};
|
|
}
|
|
return {ReadStatus::kReady, std::move(parsed), {}};
|
|
}
|
|
|
|
bool AtomicWriteJson(const std::filesystem::path& path,
|
|
const nlohmann::json& value, std::string* error) {
|
|
std::error_code filesystem_error;
|
|
if (!RuntimePaths::EnsureDirectory(path.parent_path(), &filesystem_error)) {
|
|
*error = "cannot create platform cache metadata directory";
|
|
return false;
|
|
}
|
|
const std::filesystem::file_status target_status =
|
|
std::filesystem::symlink_status(path, filesystem_error);
|
|
if (!filesystem_error && std::filesystem::is_symlink(target_status)) {
|
|
*error = "platform cache metadata target is a symlink";
|
|
return false;
|
|
}
|
|
if (filesystem_error != std::errc::no_such_file_or_directory &&
|
|
filesystem_error) {
|
|
*error = "cannot inspect platform cache metadata target";
|
|
return false;
|
|
}
|
|
|
|
const std::filesystem::path temporary =
|
|
path.parent_path() /
|
|
(".mocktail-platform-cache.tmp." + std::to_string(getpid()) + "." +
|
|
std::to_string(g_temporary_sequence.fetch_add(1)));
|
|
const int descriptor =
|
|
open(temporary.c_str(),
|
|
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
|
|
if (descriptor < 0) {
|
|
*error = "cannot create temporary platform cache metadata";
|
|
return false;
|
|
}
|
|
bool stored = false;
|
|
{
|
|
const ScopedFileDescriptor file(descriptor);
|
|
const std::string bytes = value.dump();
|
|
stored = fchmod(file.get(), S_IRUSR | S_IWUSR) == 0 &&
|
|
WriteAll(file.get(), bytes) && fsync(file.get()) == 0;
|
|
}
|
|
if (!stored || rename(temporary.c_str(), path.c_str()) != 0) {
|
|
const int saved_errno = errno;
|
|
(void)unlink(temporary.c_str());
|
|
errno = saved_errno;
|
|
*error = "cannot atomically store platform cache metadata";
|
|
return false;
|
|
}
|
|
const int directory_descriptor =
|
|
open(path.parent_path().c_str(), O_RDONLY | O_CLOEXEC | O_DIRECTORY);
|
|
if (directory_descriptor < 0) {
|
|
*error = "cannot open platform cache metadata directory for sync";
|
|
return false;
|
|
}
|
|
const ScopedFileDescriptor directory(directory_descriptor);
|
|
if (fsync(directory.get()) != 0) {
|
|
*error = "cannot sync platform cache metadata directory";
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::string FingerprintRevision(const nlohmann::json& fingerprint) {
|
|
const auto schema = fingerprint.find("schema_version");
|
|
const auto revision = fingerprint.find("profile_revision");
|
|
if (schema == fingerprint.end() || !schema->is_number_integer() ||
|
|
schema->get<int>() != kFingerprintSchemaVersion ||
|
|
revision == fingerprint.end() || !revision->is_string()) {
|
|
return {};
|
|
}
|
|
return revision->get<std::string>();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string BuildPlatformProfileRevision(std::string_view device_profile,
|
|
bool touch_enabled, bool mouse_enabled,
|
|
bool keyboard_enabled) {
|
|
return "device-v1-" + std::string(device_profile) +
|
|
(touch_enabled ? "-t1" : "-t0") + (mouse_enabled ? "-m1" : "-m0") +
|
|
(keyboard_enabled ? "-k1" : "-k0");
|
|
}
|
|
|
|
PlatformCacheMigrationResult MigratePlatformProfileCaches(
|
|
const Environment& environment, const RuntimePaths& paths,
|
|
std::string_view desired_revision) {
|
|
PlatformCacheMigrationResult result;
|
|
if (desired_revision.empty() || desired_revision.size() > 64) {
|
|
result.error = "platform profile revision is invalid";
|
|
return result;
|
|
}
|
|
|
|
const std::filesystem::path runtime_root =
|
|
environment.HasNonEmpty("MOCKTAIL_RUNTIME_ROOT")
|
|
? std::filesystem::path(
|
|
environment.GetOr("MOCKTAIL_RUNTIME_ROOT", ""))
|
|
: paths.android_runtime_root();
|
|
const std::filesystem::path data_root = runtime_root / "data";
|
|
std::filesystem::path files_root = data_root / "files";
|
|
if (environment.HasNonEmpty("MOCKTAIL_ANDROID_FILES_DIR")) {
|
|
const std::optional<std::filesystem::path> translated =
|
|
TranslateAndroidFilesPath(
|
|
environment.GetOr("MOCKTAIL_ANDROID_FILES_DIR", ""), data_root);
|
|
if (!translated.has_value()) {
|
|
result.error = "configured Android files directory is not absolute";
|
|
return result;
|
|
}
|
|
files_root = *translated;
|
|
}
|
|
|
|
const std::filesystem::path normalized_data = data_root.lexically_normal();
|
|
const std::filesystem::path normalized_files = files_root.lexically_normal();
|
|
if (!normalized_data.is_absolute() || !normalized_files.is_absolute() ||
|
|
!HasPathPrefix(normalized_files, normalized_data)) {
|
|
result.error = "platform cache path escapes the Mocktail Android data root";
|
|
return result;
|
|
}
|
|
|
|
result.app_storage_file =
|
|
normalized_files / "appData/LocalStorage/appStorage.json";
|
|
result.fingerprint_file = normalized_data / ".mocktail-platform-profile.json";
|
|
std::error_code filesystem_error;
|
|
if (!RuntimePaths::EnsureDirectory(normalized_data, &filesystem_error)) {
|
|
result.error = "cannot create Mocktail Android data root";
|
|
return result;
|
|
}
|
|
|
|
const std::filesystem::path lock_path =
|
|
normalized_data / ".mocktail-platform-profile.lock";
|
|
const int lock_descriptor =
|
|
open(lock_path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600);
|
|
if (lock_descriptor < 0) {
|
|
result.error = "cannot open platform cache migration lock";
|
|
return result;
|
|
}
|
|
const ScopedFileDescriptor lock(lock_descriptor);
|
|
struct stat lock_status = {};
|
|
if (fstat(lock.get(), &lock_status) != 0 || !S_ISREG(lock_status.st_mode) ||
|
|
lock_status.st_uid != geteuid() || lock_status.st_nlink != 1 ||
|
|
(lock_status.st_mode & (S_IRWXG | S_IRWXO)) != 0 ||
|
|
flock(lock.get(), LOCK_EX | LOCK_NB) != 0) {
|
|
result.error = "another process owns the platform cache migration lock";
|
|
return result;
|
|
}
|
|
|
|
const JsonReadResult fingerprint = ReadJson(result.fingerprint_file);
|
|
if (fingerprint.status == ReadStatus::kInvalid) {
|
|
result.error = fingerprint.error;
|
|
return result;
|
|
}
|
|
if (fingerprint.status == ReadStatus::kReady) {
|
|
result.previous_revision = FingerprintRevision(fingerprint.value);
|
|
}
|
|
if (result.previous_revision == desired_revision) {
|
|
return result;
|
|
}
|
|
result.transitioned = true;
|
|
|
|
JsonReadResult app_storage = ReadJson(result.app_storage_file);
|
|
if (app_storage.status == ReadStatus::kInvalid) {
|
|
result.error = app_storage.error;
|
|
return result;
|
|
}
|
|
if (app_storage.status == ReadStatus::kReady) {
|
|
result.app_storage_found = true;
|
|
const std::size_t removed =
|
|
app_storage.value.erase("PlayerHydrationBlob") +
|
|
app_storage.value.erase("PlayerHydrationSignature") +
|
|
app_storage.value.erase("AppConfiguration");
|
|
if (removed != 0) {
|
|
if (!AtomicWriteJson(result.app_storage_file, app_storage.value,
|
|
&result.error)) {
|
|
return result;
|
|
}
|
|
result.app_storage_updated = true;
|
|
}
|
|
}
|
|
|
|
nlohmann::json updated_fingerprint = {
|
|
{"schema_version", kFingerprintSchemaVersion},
|
|
{"profile_revision", std::string(desired_revision)},
|
|
};
|
|
if (!AtomicWriteJson(result.fingerprint_file, updated_fingerprint,
|
|
&result.error)) {
|
|
return result;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
RobloxThemeCacheResult ReadRobloxThemeCache(
|
|
const std::filesystem::path& app_storage_file,
|
|
std::int64_t authenticated_user_id) {
|
|
RobloxThemeCacheResult result;
|
|
if (!app_storage_file.is_absolute() || authenticated_user_id < -1) {
|
|
result.error = "Roblox theme cache path or user ID is invalid";
|
|
return result;
|
|
}
|
|
|
|
const JsonReadResult storage = ReadJson(app_storage_file);
|
|
if (storage.status == ReadStatus::kInvalid) {
|
|
result.error = storage.error;
|
|
return result;
|
|
}
|
|
if (storage.status == ReadStatus::kMissing) {
|
|
return result;
|
|
}
|
|
|
|
const auto read_theme = [](const nlohmann::json& value) -> std::optional<bool> {
|
|
if (!value.is_string()) {
|
|
return std::nullopt;
|
|
}
|
|
const std::string& theme = value.get_ref<const std::string&>();
|
|
if (theme == "dark") {
|
|
return true;
|
|
}
|
|
if (theme == "light") {
|
|
return false;
|
|
}
|
|
return std::nullopt;
|
|
};
|
|
|
|
if (authenticated_user_id >= 0) {
|
|
const auto encoded_device_themes = storage.value.find("DeviceLevelTheme");
|
|
if (encoded_device_themes != storage.value.end() &&
|
|
encoded_device_themes->is_string()) {
|
|
const nlohmann::json device_themes = nlohmann::json::parse(
|
|
encoded_device_themes->get_ref<const std::string&>(), nullptr, false,
|
|
true);
|
|
if (device_themes.is_object()) {
|
|
const auto account_theme =
|
|
device_themes.find(std::to_string(authenticated_user_id));
|
|
if (account_theme != device_themes.end()) {
|
|
result.dark_theme = read_theme(*account_theme);
|
|
if (result.dark_theme.has_value()) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const auto authenticated_theme = storage.value.find("AuthenticatedTheme");
|
|
if (authenticated_theme != storage.value.end()) {
|
|
result.dark_theme = read_theme(*authenticated_theme);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
bool ApplyRobloxThemeCacheOverride(
|
|
const std::filesystem::path& app_storage_file,
|
|
std::int64_t authenticated_user_id, bool dark_theme, std::string* error) {
|
|
if (error == nullptr || !app_storage_file.is_absolute() ||
|
|
authenticated_user_id < -1) {
|
|
if (error != nullptr) {
|
|
*error = "Roblox theme cache path or user ID is invalid";
|
|
}
|
|
return false;
|
|
}
|
|
|
|
JsonReadResult storage = ReadJson(app_storage_file);
|
|
if (storage.status == ReadStatus::kInvalid) {
|
|
*error = storage.error;
|
|
return false;
|
|
}
|
|
if (storage.status == ReadStatus::kMissing) {
|
|
storage.value = nlohmann::json::object();
|
|
}
|
|
|
|
const std::string desired_theme = dark_theme ? "dark" : "light";
|
|
const nlohmann::json original = storage.value;
|
|
storage.value["AuthenticatedTheme"] = desired_theme;
|
|
|
|
if (authenticated_user_id >= 0) {
|
|
nlohmann::json device_themes = nlohmann::json::object();
|
|
const auto encoded_device_themes = storage.value.find("DeviceLevelTheme");
|
|
if (encoded_device_themes != storage.value.end()) {
|
|
if (!encoded_device_themes->is_string()) {
|
|
*error = "Roblox DeviceLevelTheme is not an encoded object";
|
|
return false;
|
|
}
|
|
device_themes = nlohmann::json::parse(
|
|
encoded_device_themes->get_ref<const std::string&>(), nullptr, false,
|
|
true);
|
|
if (device_themes.is_discarded() || !device_themes.is_object()) {
|
|
*error = "Roblox DeviceLevelTheme encoding is invalid";
|
|
return false;
|
|
}
|
|
}
|
|
device_themes[std::to_string(authenticated_user_id)] = desired_theme;
|
|
storage.value["DeviceLevelTheme"] = device_themes.dump();
|
|
}
|
|
|
|
if (storage.value == original) {
|
|
return true;
|
|
}
|
|
return AtomicWriteJson(app_storage_file, storage.value, error);
|
|
}
|
|
|
|
} // namespace runtime
|
|
} // namespace mocktail
|