mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-21 21:00:58 +02:00
1073 lines
46 KiB
C++
1073 lines
46 KiB
C++
#include <gtest/gtest.h>
|
|
#include <minizip/zip.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <nlohmann/json.hpp>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <vector>
|
|
|
|
#include "compat/build_profile.h"
|
|
#include "compat/host_abi_profile.h"
|
|
#include "update/apkpure_provider.h"
|
|
#include "update/compatibility_catalog.h"
|
|
#include "update/host_abi_deriver.h"
|
|
#include "update/http_download.h"
|
|
#include "update/payload_integrity.h"
|
|
#include "update/payload_store.h"
|
|
#include "update/readiness_canary.h"
|
|
#include "update/unsafe_latest_runner.h"
|
|
#include "update/update_config.h"
|
|
#include "update/update_coordinator.h"
|
|
#include "update/zip_archive.h"
|
|
|
|
namespace mocktail::update {
|
|
namespace {
|
|
|
|
class TemporaryDirectory final {
|
|
public:
|
|
TemporaryDirectory() {
|
|
char pattern[] = "/tmp/mocktail_native_update_XXXXXX";
|
|
const char* created = mkdtemp(pattern);
|
|
if (created != nullptr) root_ = created;
|
|
}
|
|
|
|
~TemporaryDirectory() {
|
|
std::error_code error;
|
|
std::filesystem::remove_all(root_, error);
|
|
}
|
|
|
|
const std::filesystem::path& root() const { return root_; }
|
|
|
|
private:
|
|
std::filesystem::path root_;
|
|
};
|
|
|
|
void Write(const std::filesystem::path& path, std::string_view contents) {
|
|
std::filesystem::create_directories(path.parent_path());
|
|
std::ofstream(path, std::ios::binary) << contents;
|
|
}
|
|
|
|
std::string ReadFile(const std::filesystem::path& path) {
|
|
std::ifstream input(path, std::ios::binary);
|
|
return std::string(std::istreambuf_iterator<char>(input),
|
|
std::istreambuf_iterator<char>());
|
|
}
|
|
|
|
std::string MetadataRecord(std::string_view version_name,
|
|
std::uint64_t version_code,
|
|
std::string_view artifact, std::string_view url) {
|
|
const std::string code = std::to_string(version_code);
|
|
std::string record("protobuf-prefix\0", 16);
|
|
record.push_back('\x2a');
|
|
record.push_back(static_cast<char>(code.size()));
|
|
record += code;
|
|
record.push_back('\x32');
|
|
record.push_back(static_cast<char>(version_name.size()));
|
|
record += version_name;
|
|
record += ":metadata";
|
|
record.push_back('\0');
|
|
record += artifact;
|
|
record.append("\0\0", 2);
|
|
record += url;
|
|
record.push_back('\0');
|
|
return record;
|
|
}
|
|
|
|
bool CreateZip(const std::filesystem::path& path) {
|
|
zipFile archive = zipOpen64(path.c_str(), APPEND_STATUS_CREATE);
|
|
if (archive == nullptr) return false;
|
|
const std::vector<std::pair<std::string, std::string>> entries = {
|
|
{"ignored.txt", "ignored"},
|
|
{"assets/content/first.txt", "first"},
|
|
{"assets/nested/second.txt", "second"},
|
|
};
|
|
for (const auto& [name, contents] : entries) {
|
|
if (zipOpenNewFileInZip64(archive, name.c_str(), nullptr, nullptr, 0,
|
|
nullptr, 0, nullptr, Z_DEFLATED,
|
|
Z_DEFAULT_COMPRESSION, 1) != ZIP_OK ||
|
|
zipWriteInFileInZip(archive, contents.data(), contents.size()) !=
|
|
ZIP_OK ||
|
|
zipCloseFileInZip(archive) != ZIP_OK) {
|
|
zipClose(archive, nullptr);
|
|
return false;
|
|
}
|
|
}
|
|
return zipClose(archive, nullptr) == ZIP_OK;
|
|
}
|
|
|
|
TEST(NativeUpdateConfigTest, ReadsOnlyTypedUpdaterSection) {
|
|
TemporaryDirectory temporary;
|
|
const auto config = temporary.root() / "config.yaml";
|
|
Write(config,
|
|
"version: 1\n"
|
|
"device: pc-windows-11\n"
|
|
"updates:\n"
|
|
" automatic: false\n"
|
|
" source: apk-pure\n"
|
|
" launch_after_update: true\n");
|
|
const UpdateConfigResult result = LoadUpdateConfig(config);
|
|
ASSERT_TRUE(result) << result.error;
|
|
EXPECT_TRUE(result.file_loaded);
|
|
EXPECT_FALSE(result.config.automatic);
|
|
EXPECT_TRUE(result.config.launch_after_update);
|
|
EXPECT_EQ(result.config.source, "apk-pure");
|
|
EXPECT_TRUE(result.warnings.empty());
|
|
}
|
|
|
|
TEST(NativeUpdateConfigTest, IgnoresDeprecatedTestingLatestOnlyWithWarning) {
|
|
TemporaryDirectory temporary;
|
|
const auto config = temporary.root() / "config.yaml";
|
|
Write(config,
|
|
"version: 1\nupdates:\n testing_latest_only: true\n");
|
|
const UpdateConfigResult result = LoadUpdateConfig(config);
|
|
ASSERT_TRUE(result) << result.error;
|
|
ASSERT_EQ(result.warnings.size(), 1U);
|
|
EXPECT_NE(result.warnings.front().find("no longer supported"),
|
|
std::string::npos);
|
|
}
|
|
|
|
TEST(NativeUpdateConfigTest, MissingFileUsesSafeDefaults) {
|
|
TemporaryDirectory temporary;
|
|
const UpdateConfigResult result =
|
|
LoadUpdateConfig(temporary.root() / "missing.yaml");
|
|
ASSERT_TRUE(result) << result.error;
|
|
EXPECT_FALSE(result.file_loaded);
|
|
EXPECT_TRUE(result.config.automatic);
|
|
EXPECT_EQ(result.config.source, "apk-pure");
|
|
}
|
|
|
|
TEST(NativeUpdateConfigTest, RejectsUnsupportedSource) {
|
|
TemporaryDirectory temporary;
|
|
const auto config = temporary.root() / "config.yaml";
|
|
Write(config,
|
|
"version: 1\nupdates:\n source: google-play\n");
|
|
const UpdateConfigResult result = LoadUpdateConfig(config);
|
|
EXPECT_FALSE(result);
|
|
EXPECT_NE(result.error.find("apk-pure"), std::string::npos);
|
|
}
|
|
|
|
TEST(ApkPureProviderTest, ParsesExactVersionIdentityAndTrustedUrl) {
|
|
const std::string metadata =
|
|
MetadataRecord("2.727.1199", 2628, "XAPKJ",
|
|
"https://download.pureapk.com/b/XAPK/roblox");
|
|
const ProviderVersion latest = ParseApkPureLatestMetadata(metadata);
|
|
ASSERT_TRUE(latest) << latest.error;
|
|
EXPECT_EQ(latest.version_name, "2.727.1199");
|
|
EXPECT_EQ(latest.version_code, 2628);
|
|
std::string error;
|
|
const auto urls =
|
|
ParseApkPureExactDownloadUrls(metadata, "2.727.1199", &error);
|
|
ASSERT_TRUE(error.empty()) << error;
|
|
ASSERT_EQ(urls.size(), 1);
|
|
EXPECT_EQ(urls.front(), "https://download.pureapk.com/b/XAPK/roblox");
|
|
}
|
|
|
|
TEST(UnsafeLatestRunnerTest, LaunchesCandidateWithUnapprovedEnvironment) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path output = temporary.root() / "environment.txt";
|
|
const std::filesystem::path arguments = temporary.root() / "arguments.txt";
|
|
const std::filesystem::path runtime = temporary.root() / "runtime.sh";
|
|
Write(runtime,
|
|
"#!/bin/sh\n"
|
|
"env | sort > \"$ENV_OUTPUT\"\n"
|
|
"printf '%s\\n' \"$@\" > \"$ARGUMENT_OUTPUT\"\n");
|
|
ASSERT_EQ(chmod(runtime.c_str(), 0700), 0);
|
|
|
|
const std::filesystem::path payload = temporary.root() / "payload";
|
|
Write(payload / "libroblox.so", "candidate");
|
|
std::filesystem::create_directories(payload / "assets/content");
|
|
const std::filesystem::path compatibility =
|
|
temporary.root() / "compatibility.json";
|
|
const std::filesystem::path profile = temporary.root() / "profile.json";
|
|
Write(compatibility, "{}\n");
|
|
Write(profile, "{}\n");
|
|
|
|
UnsafeLatestRunOptions options;
|
|
options.runtime_binary = runtime;
|
|
options.payload_directory = payload;
|
|
options.compatibility_manifest = compatibility;
|
|
options.host_abi_profile = profile;
|
|
options.inherited_environment = {
|
|
"PATH=/usr/bin:/bin", "ENV_OUTPUT=" + output.string(),
|
|
"ARGUMENT_OUTPUT=" + arguments.string(), "UNCHANGED=value",
|
|
"ROBLOX_LIB_PATH=/stale/libroblox.so",
|
|
"MOCKTAIL_HOST_ABI_APPROVAL_RECEIPT=/stale/approval.json",
|
|
"MOCKTAIL_SKIP_UPDATE_CHECK=0",
|
|
};
|
|
|
|
const UnsafeLatestRunResult result = RunUnsafeLatestCandidate(options);
|
|
ASSERT_TRUE(result) << result.error;
|
|
const std::string environment = ReadFile(output);
|
|
EXPECT_NE(environment.find("UNCHANGED=value\n"), std::string::npos);
|
|
EXPECT_NE(environment.find("ROBLOX_LIB_PATH=" +
|
|
(payload / "libroblox.so").string() + "\n"),
|
|
std::string::npos);
|
|
EXPECT_NE(environment.find("MOCKTAIL_COMPATIBILITY_MANIFEST=" +
|
|
compatibility.string() + "\n"),
|
|
std::string::npos);
|
|
EXPECT_NE(environment.find("MOCKTAIL_HOST_ABI_PROFILE_FILE=" +
|
|
profile.string() + "\n"),
|
|
std::string::npos);
|
|
EXPECT_NE(environment.find("MOCKTAIL_HOST_ABI_CANARY=1\n"),
|
|
std::string::npos);
|
|
EXPECT_NE(environment.find("MOCKTAIL_ALLOW_CANDIDATE_HOST_ABI=1\n"),
|
|
std::string::npos);
|
|
EXPECT_NE(environment.find("MOCKTAIL_SKIP_UPDATE_CHECK=1\n"),
|
|
std::string::npos);
|
|
EXPECT_NE(environment.find("MOCKTAIL_UNSAFE_LATEST=1\n"),
|
|
std::string::npos);
|
|
EXPECT_EQ(environment.find("/stale/libroblox.so"), std::string::npos);
|
|
EXPECT_EQ(environment.find("/stale/approval.json"), std::string::npos);
|
|
EXPECT_EQ(ReadFile(arguments), "--windowed\n--allow-unverified-build\n");
|
|
}
|
|
|
|
TEST(HttpDownloadPolicyTest, RejectsCredentialsAndHostSuffixTricks) {
|
|
const std::vector<std::string> hosts = {"pureapk.com"};
|
|
EXPECT_TRUE(IsTrustedHttpsUrl("https://download.pureapk.com/a", hosts));
|
|
EXPECT_FALSE(IsTrustedHttpsUrl("http://download.pureapk.com/a", hosts));
|
|
EXPECT_FALSE(IsTrustedHttpsUrl("https://pureapk.com.attacker.test/a", hosts));
|
|
EXPECT_FALSE(IsTrustedHttpsUrl("https://user@pureapk.com/a", hosts));
|
|
}
|
|
|
|
TEST(ReadinessCanaryTest, AcceptsRealPresentWithoutShaderPackSummary) {
|
|
const std::string valid =
|
|
"[compat] legacy binary patches: disabled\n"
|
|
"[compat] signal-recovery handler disabled\n"
|
|
"[compat] native allocator retained; host allocator bridges disabled\n"
|
|
"[window] vkQueuePresentKHR #240 window=0x1234\n"
|
|
"[window] first Roblox Vulkan frame presented\n"
|
|
"[vulkan] SDL WSI adapter shut down\n"
|
|
"[main] Roblox lifecycle shutdown: Stopped\n";
|
|
std::string error;
|
|
EXPECT_TRUE(ValidateReadinessLog(CanaryGraphicsBackend::kDirectVulkan,
|
|
valid, &error))
|
|
<< error;
|
|
error.clear();
|
|
EXPECT_FALSE(ValidateReadinessLog(CanaryGraphicsBackend::kDirectVulkan,
|
|
valid + "[FATAL] crash\n", &error));
|
|
|
|
const std::string missing_real_present =
|
|
"[compat] legacy binary patches: disabled\n"
|
|
"[compat] signal-recovery handler disabled\n"
|
|
"[compat] native allocator retained; host allocator bridges disabled\n"
|
|
"Loaded 2988 shaders from pack vulkan_mobile\n"
|
|
"[window] first Roblox Vulkan frame presented\n"
|
|
"[vulkan] SDL WSI adapter shut down\n"
|
|
"[main] Roblox lifecycle shutdown: Stopped\n";
|
|
error.clear();
|
|
EXPECT_FALSE(ValidateReadinessLog(CanaryGraphicsBackend::kDirectVulkan,
|
|
missing_real_present, &error));
|
|
EXPECT_NE(error.find("real queue present"), std::string::npos);
|
|
}
|
|
|
|
TEST(ReadinessCanaryTest, ValidatesRealOpenGlEsThreeSwap) {
|
|
const std::string valid =
|
|
"[compat] legacy binary patches: disabled\n"
|
|
"[compat] signal-recovery handler disabled\n"
|
|
"[compat] native allocator retained; host allocator bridges disabled\n"
|
|
"[window] OpenGL ES context version=3.2\n"
|
|
"[window] EGL context and surface initialized via SDL3\n"
|
|
"[window] SwapBuffers #1 window=0x1234\n"
|
|
"[window] first Roblox frame presented\n"
|
|
"[main] Roblox lifecycle shutdown: Stopped\n";
|
|
std::string error;
|
|
EXPECT_TRUE(ValidateReadinessLog(CanaryGraphicsBackend::kOpenGlEs, valid,
|
|
&error))
|
|
<< error;
|
|
|
|
std::string gles2 = valid;
|
|
gles2.replace(gles2.find("version=3.2"), std::string("version=3.2").size(),
|
|
"version=2.0");
|
|
error.clear();
|
|
EXPECT_FALSE(ValidateReadinessLog(CanaryGraphicsBackend::kOpenGlEs, gles2,
|
|
&error));
|
|
EXPECT_NE(error.find("version=3."), std::string::npos);
|
|
|
|
std::string missing_swap = valid;
|
|
const std::string swap_marker =
|
|
"[window] SwapBuffers #1 window=0x1234\n";
|
|
missing_swap.erase(missing_swap.find(swap_marker), swap_marker.size());
|
|
error.clear();
|
|
EXPECT_FALSE(ValidateReadinessLog(CanaryGraphicsBackend::kOpenGlEs,
|
|
missing_swap, &error));
|
|
EXPECT_NE(error.find("real EGL swap"), std::string::npos);
|
|
}
|
|
|
|
TEST(ReadinessCanaryTest, MapsWindowBackendsToTheirPresentPath) {
|
|
CanaryGraphicsBackend backend = CanaryGraphicsBackend::kDirectVulkan;
|
|
EXPECT_TRUE(ParseCanaryGraphicsBackend("direct-vulkan", &backend));
|
|
EXPECT_EQ(backend, CanaryGraphicsBackend::kDirectVulkan);
|
|
EXPECT_TRUE(ParseCanaryGraphicsBackend("opengl", &backend));
|
|
EXPECT_EQ(backend, CanaryGraphicsBackend::kOpenGlEs);
|
|
EXPECT_TRUE(ParseCanaryGraphicsBackend("angle-vulkan", &backend));
|
|
EXPECT_EQ(backend, CanaryGraphicsBackend::kAngleVulkan);
|
|
EXPECT_EQ(CanaryGraphicsBackendName(backend), "angle-vulkan");
|
|
EXPECT_FALSE(ParseCanaryGraphicsBackend("unknown", &backend));
|
|
}
|
|
|
|
TEST(ZipArchiveTest, ExtractsSelectedPrefixInOneSequentialPass) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path archive = temporary.root() / "fixture.zip";
|
|
ASSERT_TRUE(CreateZip(archive));
|
|
std::size_t files = 0;
|
|
std::string error;
|
|
const std::filesystem::path output = temporary.root() / "output";
|
|
ASSERT_TRUE(
|
|
ExtractZipPrefix(archive, "assets/", output, 1024, &files, &error))
|
|
<< error;
|
|
EXPECT_EQ(files, 2);
|
|
EXPECT_EQ(ReadFile(output / "content/first.txt"), "first");
|
|
EXPECT_EQ(ReadFile(output / "nested/second.txt"), "second");
|
|
EXPECT_FALSE(std::filesystem::exists(output / "ignored.txt"));
|
|
}
|
|
|
|
std::vector<SupportedPayloadProfile> CatalogFixture() {
|
|
return {
|
|
{"2.725.1142", 2546, "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21"},
|
|
{"2.727.1199", 2628, "1686400865ae0e408cd7bd67de7a439625c6fd13"},
|
|
{"2.734.917", 2908, "63c5109637b7d7b2bdb8ed8f858023ff5ef49326"},
|
|
};
|
|
}
|
|
|
|
PayloadStoreResult InstalledFixture(std::string_view version_name,
|
|
std::uint64_t version_code) {
|
|
PayloadStoreResult installed;
|
|
installed.payload_id = std::to_string(version_code) + "-" +
|
|
std::string(40, 'a');
|
|
installed.version_name = version_name;
|
|
installed.version_code = version_code;
|
|
return installed;
|
|
}
|
|
|
|
// A rejected candidate used to end the update, leaving the user on whatever
|
|
// was installed.
|
|
TEST(UpdateCandidatePlanTest, FallsBackToTheNewestSupportedProfile) {
|
|
const auto profiles = CatalogFixture();
|
|
const ProviderVersion latest{"2.736.1408", 2998, {}};
|
|
const PayloadStoreResult current = InstalledFixture("2.725.1142", 2546);
|
|
|
|
const std::vector<UpdateCandidatePlan> plans =
|
|
PlanUpdateCandidates(profiles, profiles.back(), &latest, current,
|
|
current);
|
|
|
|
ASSERT_EQ(plans.size(), 2U);
|
|
EXPECT_EQ(plans[0].identity.version_code, 2998U);
|
|
EXPECT_FALSE(plans[0].exact_supported);
|
|
EXPECT_EQ(plans[1].identity.version_code, 2908U);
|
|
EXPECT_TRUE(plans[1].exact_supported);
|
|
EXPECT_EQ(plans[1].identity.exact_build_id,
|
|
"63c5109637b7d7b2bdb8ed8f858023ff5ef49326");
|
|
}
|
|
|
|
TEST(UpdateCandidatePlanTest, OffersTheLatestOnceWhenItIsExactSupported) {
|
|
const auto profiles = CatalogFixture();
|
|
const ProviderVersion latest{"2.734.917", 2908, {}};
|
|
const PayloadStoreResult current = InstalledFixture("2.725.1142", 2546);
|
|
|
|
const std::vector<UpdateCandidatePlan> plans =
|
|
PlanUpdateCandidates(profiles, profiles.back(), &latest, current,
|
|
current);
|
|
|
|
ASSERT_EQ(plans.size(), 1U);
|
|
EXPECT_EQ(plans[0].identity.version_code, 2908U);
|
|
EXPECT_TRUE(plans[0].exact_supported);
|
|
}
|
|
|
|
TEST(UpdateCandidatePlanTest, NeverDowngradesARunnablePayload) {
|
|
const auto profiles = CatalogFixture();
|
|
const ProviderVersion latest{"2.736.1408", 2998, {}};
|
|
const PayloadStoreResult current = InstalledFixture("2.736.1408", 2998);
|
|
|
|
EXPECT_TRUE(
|
|
PlanUpdateCandidates(profiles, profiles.back(), &latest, current, current)
|
|
.empty());
|
|
// The same holds when the provider cannot be reached at all.
|
|
EXPECT_TRUE(PlanUpdateCandidates(profiles, profiles.back(), nullptr, current,
|
|
current)
|
|
.empty());
|
|
}
|
|
|
|
TEST(UpdateCandidatePlanTest, InstallsTheSupportedProfileWithoutAProvider) {
|
|
const auto profiles = CatalogFixture();
|
|
const PayloadStoreResult current = InstalledFixture("2.725.1142", 2546);
|
|
|
|
const std::vector<UpdateCandidatePlan> plans =
|
|
PlanUpdateCandidates(profiles, profiles.back(), nullptr, current,
|
|
current);
|
|
|
|
ASSERT_EQ(plans.size(), 1U);
|
|
EXPECT_EQ(plans[0].identity.version_code, 2908U);
|
|
}
|
|
|
|
TEST(UpdateCandidatePlanTest, ReusesAnApprovedPayloadForTheSameVersion) {
|
|
const auto profiles = CatalogFixture();
|
|
const ProviderVersion latest{"2.736.1408", 2998, {}};
|
|
PayloadStoreResult installed = InstalledFixture("2.736.1408", 2998);
|
|
installed.host_abi_profile = "host_abi_profiles/2998.json";
|
|
installed.compatibility_manifest = "compatibility_profiles/2998.json";
|
|
|
|
const std::vector<UpdateCandidatePlan> plans = PlanUpdateCandidates(
|
|
profiles, profiles.back(), &latest, PayloadStoreResult{}, installed);
|
|
|
|
ASSERT_FALSE(plans.empty());
|
|
EXPECT_EQ(plans[0].identity.version_code, 2998U);
|
|
EXPECT_TRUE(plans[0].reuse_installed);
|
|
}
|
|
|
|
TEST(HostAbiSidecarTest, RejectsAnIdentityThatDoesNotDescribeItsOwnPayload) {
|
|
TemporaryDirectory temporary;
|
|
const std::string build_id = "1686400865ae0e408cd7bd67de7a439625c6fd13";
|
|
const std::filesystem::path consistent = temporary.root() / "good.json";
|
|
Write(consistent, nlohmann::json({{"schema_version", 1},
|
|
{"elf_build_id", build_id},
|
|
{"payload_id", "2628-" + build_id},
|
|
{"payload_path",
|
|
"payloads/2628-" + build_id}})
|
|
.dump(2) +
|
|
"\n");
|
|
const HostAbiSidecarIdentity identity =
|
|
ReadHostAbiSidecarIdentity(consistent);
|
|
ASSERT_TRUE(identity) << identity.error;
|
|
EXPECT_EQ(identity.elf_build_id, build_id);
|
|
EXPECT_EQ(identity.payload_id, "2628-" + build_id);
|
|
|
|
const std::filesystem::path mismatched = temporary.root() / "bad.json";
|
|
const std::string other = "2908-" + std::string(40, 'b');
|
|
Write(mismatched,
|
|
nlohmann::json({{"schema_version", 1},
|
|
{"elf_build_id", build_id},
|
|
{"payload_id", other},
|
|
{"payload_path", "payloads/" + other}})
|
|
.dump(2) +
|
|
"\n");
|
|
EXPECT_FALSE(ReadHostAbiSidecarIdentity(mismatched));
|
|
}
|
|
|
|
// The regression: the library used to be the newest supported profile while
|
|
// the sidecar stayed whatever file shipped, and the pair failed every
|
|
// derivation.
|
|
TEST(ReferenceProfileTest, FollowsTheSidecarRatherThanTheNewestProfile) {
|
|
TemporaryDirectory temporary;
|
|
const auto profiles = CatalogFixture();
|
|
const auto preferred = PreferredSupportedProfile(profiles);
|
|
ASSERT_TRUE(preferred.has_value());
|
|
const auto& older = profiles[1];
|
|
const std::string payload_id =
|
|
std::to_string(older.version_code) + "-" + older.elf_build_id;
|
|
const auto reference_file = temporary.root() / "reference.json";
|
|
Write(reference_file,
|
|
nlohmann::json({{"schema_version", 1},
|
|
{"elf_build_id", older.elf_build_id},
|
|
{"payload_id", payload_id},
|
|
{"payload_path", "payloads/" + payload_id}})
|
|
.dump(2) +
|
|
"\n");
|
|
|
|
std::filesystem::path sidecar;
|
|
const auto reference =
|
|
ResolveReferenceProfile(reference_file, profiles, &sidecar);
|
|
ASSERT_TRUE(reference.has_value());
|
|
EXPECT_EQ(sidecar, reference_file);
|
|
EXPECT_EQ(reference->elf_build_id,
|
|
ReadHostAbiSidecarIdentity(sidecar).elf_build_id);
|
|
EXPECT_EQ(reference->elf_build_id, older.elf_build_id);
|
|
EXPECT_NE(reference->elf_build_id, preferred->elf_build_id);
|
|
}
|
|
|
|
TEST(ReferenceProfileTest, PicksTheNewestSidecarInADirectory) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path directory = temporary.root() / "host_abi";
|
|
const auto profiles = CatalogFixture();
|
|
for (const SupportedPayloadProfile& profile : {profiles[1], profiles[2]}) {
|
|
const std::string payload_id =
|
|
std::to_string(profile.version_code) + "-" + profile.elf_build_id;
|
|
Write(directory / (profile.elf_build_id + ".json"),
|
|
nlohmann::json({{"schema_version", 1},
|
|
{"elf_build_id", profile.elf_build_id},
|
|
{"payload_id", payload_id},
|
|
{"payload_path", "payloads/" + payload_id}})
|
|
.dump(2) +
|
|
"\n");
|
|
}
|
|
|
|
std::filesystem::path sidecar;
|
|
const auto reference =
|
|
ResolveReferenceProfile(directory, profiles, &sidecar);
|
|
ASSERT_TRUE(reference.has_value());
|
|
EXPECT_EQ(reference->version_code, 2908U);
|
|
EXPECT_EQ(sidecar, directory / (profiles[2].elf_build_id + ".json"));
|
|
|
|
// A sidecar for a Build ID no supported profile owns is not a reference.
|
|
const auto orphaned = ResolveReferenceProfile(
|
|
directory, {{"2.740.0", 3100, std::string(40, 'c')}}, &sidecar);
|
|
EXPECT_FALSE(orphaned.has_value());
|
|
}
|
|
|
|
// A sidecar for a Build ID the catalog does not carry disables Tier C.
|
|
TEST(ShippedMetadataTest, ReferenceSidecarDescribesASupportedProfile) {
|
|
const std::filesystem::path root = MOCKTAIL_TEST_SOURCE_DIR;
|
|
const CompatibilityCatalogResult catalog =
|
|
LoadCompatibilityCatalog(root / "config/roblox_compatibility.json");
|
|
ASSERT_TRUE(catalog) << catalog.error;
|
|
const HostAbiSidecarIdentity identity = ReadHostAbiSidecarIdentity(
|
|
root / "config/roblox_host_abi_reference.json");
|
|
ASSERT_TRUE(identity) << identity.error;
|
|
|
|
const auto supported = std::find_if(
|
|
catalog.profiles.begin(), catalog.profiles.end(),
|
|
[&](const SupportedPayloadProfile& profile) {
|
|
return profile.elf_build_id == identity.elf_build_id;
|
|
});
|
|
ASSERT_NE(supported, catalog.profiles.end())
|
|
<< "no supported profile owns reference Build ID "
|
|
<< identity.elf_build_id;
|
|
EXPECT_EQ(identity.payload_id,
|
|
std::to_string(supported->version_code) + "-" +
|
|
supported->elf_build_id);
|
|
|
|
// A stale reference can derive a runnable client while silently dropping
|
|
// fullscreen synchronization and the host audio output menu.
|
|
const auto preferred = PreferredSupportedProfile(catalog.profiles);
|
|
ASSERT_TRUE(preferred.has_value());
|
|
EXPECT_EQ(identity.elf_build_id, preferred->elf_build_id);
|
|
const auto runtime = compat::FindBuildProfile(
|
|
(root / "config/roblox_compatibility.json").string(),
|
|
identity.elf_build_id);
|
|
ASSERT_TRUE(runtime) << runtime.error;
|
|
ASSERT_TRUE(runtime.profile.has_value());
|
|
EXPECT_TRUE(
|
|
runtime.profile->user_game_settings_fullscreen_setter_rva.has_value());
|
|
EXPECT_TRUE(runtime.profile->fmod_output_device_bridge.has_value());
|
|
}
|
|
|
|
TEST(ShippedMetadataTest, DefaultPayloadHasABuiltinProfileAndMatchingReference) {
|
|
const std::filesystem::path root = MOCKTAIL_TEST_SOURCE_DIR;
|
|
const auto catalog =
|
|
LoadCompatibilityCatalog(root / "config/roblox_compatibility.json");
|
|
ASSERT_TRUE(catalog) << catalog.error;
|
|
const auto preferred = PreferredSupportedProfile(catalog.profiles);
|
|
ASSERT_TRUE(preferred.has_value());
|
|
EXPECT_EQ(preferred->version_name, "2.736.1408");
|
|
EXPECT_EQ(preferred->version_code, 2998U);
|
|
|
|
const auto payload = nlohmann::json::parse(
|
|
ReadFile(root / "config/roblox_payload.json"));
|
|
EXPECT_EQ(payload.at("version_name"), preferred->version_name);
|
|
EXPECT_EQ(payload.at("version_code"), preferred->version_code);
|
|
EXPECT_EQ(payload.at("elf_build_id"), preferred->elf_build_id);
|
|
EXPECT_EQ(payload.at("compatibility_status"), "supported");
|
|
|
|
const auto reference = nlohmann::json::parse(
|
|
ReadFile(root / "config/roblox_host_abi_reference.json"));
|
|
EXPECT_EQ(reference.at("payload_sha256"),
|
|
payload.at("sha256").at("libroblox"));
|
|
const auto* builtin = compat::FindHostAbiProfile(preferred->elf_build_id);
|
|
ASSERT_NE(builtin, nullptr);
|
|
const auto& profile = reference.at("profile");
|
|
const auto rva = [](const nlohmann::json& value) {
|
|
return std::stoull(value.get<std::string>(), nullptr, 16);
|
|
};
|
|
EXPECT_EQ(builtin->init_array_offset, rva(profile.at("init_array_offset")));
|
|
EXPECT_EQ(builtin->init_array_count, profile.at("init_array_count"));
|
|
ASSERT_EQ(builtin->bridge_entry_count, profile.at("bridge_entries").size());
|
|
for (std::size_t i = 0; i < builtin->bridge_entry_count; ++i) {
|
|
EXPECT_EQ(builtin->bridge_entries[i].rva,
|
|
rva(profile.at("bridge_entries").at(i).at("rva")));
|
|
}
|
|
EXPECT_EQ(builtin->native_allocator.allocate,
|
|
rva(profile.at("native_allocator").at("allocate")));
|
|
EXPECT_EQ(builtin->native_allocator.deallocate,
|
|
rva(profile.at("native_allocator").at("deallocate")));
|
|
EXPECT_EQ(builtin->native_pre_jni_bootstrap.registry_initializer,
|
|
rva(profile.at("native_pre_jni_bootstrap")
|
|
.at("registry_initializer")));
|
|
EXPECT_EQ(builtin->native_pre_jni_bootstrap.registry_slot,
|
|
rva(profile.at("native_pre_jni_bootstrap").at("registry_slot")));
|
|
EXPECT_TRUE(builtin->HasValidConstructorRanges());
|
|
EXPECT_TRUE(builtin->HasValidNativeMimallocConstructorRanges());
|
|
EXPECT_EQ(builtin->NativeMimallocConstructorRangeEndExclusive(),
|
|
builtin->init_array_count);
|
|
EXPECT_EQ(builtin->default_allocator_strategy,
|
|
compat::HostAllocatorStrategy::kNativeMimalloc);
|
|
}
|
|
|
|
std::string ActivationManifestFixture(const std::string& payload_id) {
|
|
return nlohmann::json({{"schema_version", 1},
|
|
{"payload_id", payload_id},
|
|
{"payload_path", "payloads/" + payload_id}})
|
|
.dump(2) +
|
|
"\n";
|
|
}
|
|
|
|
TEST(PayloadStoreTest, CollectsSupersededPayloadsAndKeepsTheRollbackTarget) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path root = temporary.root() / "store";
|
|
const std::string active = "2998-" + std::string(40, 'a');
|
|
const std::string rollback = "2908-" + std::string(40, 'b');
|
|
const std::string reference = "2628-" + std::string(40, 'c');
|
|
const std::string superseded = "2546-" + std::string(40, 'd');
|
|
const std::string abandoned = ".stage-" + superseded + "-4242";
|
|
for (const std::string& id : {active, rollback, reference, superseded,
|
|
abandoned}) {
|
|
Write(root / "payloads" / id / "libroblox.so", "payload bytes");
|
|
}
|
|
// Payloads are published read-only; the collector has to cope.
|
|
ASSERT_EQ(chmod((root / "payloads" / superseded).c_str(), 0555), 0);
|
|
Write(root / "current.json", ActivationManifestFixture(active));
|
|
Write(root / "previous_good.json", ActivationManifestFixture(rollback));
|
|
|
|
PayloadStore store(root, temporary.root() / "compatibility.json");
|
|
const PayloadGarbageResult collected = store.CollectGarbage({reference});
|
|
ASSERT_TRUE(collected) << collected.error;
|
|
EXPECT_EQ(collected.removed.size(), 2U);
|
|
EXPECT_GT(collected.freed_bytes, 0U);
|
|
EXPECT_TRUE(std::filesystem::exists(root / "payloads" / active));
|
|
EXPECT_TRUE(std::filesystem::exists(root / "payloads" / rollback));
|
|
EXPECT_TRUE(std::filesystem::exists(root / "payloads" / reference));
|
|
EXPECT_FALSE(std::filesystem::exists(root / "payloads" / superseded));
|
|
EXPECT_FALSE(std::filesystem::exists(root / "payloads" / abandoned));
|
|
}
|
|
|
|
// #110: the metadata carries the moment of preparation, so restaging the same
|
|
// bytes quarantined the running payload, took it out of payloads/, and left
|
|
// the next launch to download it again.
|
|
TEST(PayloadStoreTest, RestagingIdenticalBytesKeepsTheInstalledPayload) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path prepared = temporary.root() / "prepared";
|
|
const std::filesystem::path restaged = temporary.root() / "restaged";
|
|
Write(prepared / "libroblox.so", "native-library");
|
|
Write(prepared / "sober_apk/base.apk", "base-apk");
|
|
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
|
Write(prepared / "assets/content/fixture", "asset");
|
|
std::string error;
|
|
std::size_t asset_count = 0;
|
|
const std::string asset_hash =
|
|
HashAssetTree(prepared / "assets", &asset_count, &error);
|
|
ASSERT_TRUE(error.empty()) << error;
|
|
nlohmann::json metadata = {
|
|
{"schema_version", 1},
|
|
{"package", "com.roblox.client"},
|
|
{"version_name", "2.727.1199"},
|
|
{"version_code", 2628},
|
|
{"elf_build_id", "1686400865ae0e408cd7bd67de7a439625c6fd13"},
|
|
{"sha256",
|
|
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
|
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
|
{"x86_64_split_apk",
|
|
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
|
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
|
{"source", "apk-pure-native"},
|
|
{"imported_at", "2026-09-04T03:49:16Z"},
|
|
};
|
|
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
|
std::filesystem::copy(prepared, restaged,
|
|
std::filesystem::copy_options::recursive);
|
|
metadata["imported_at"] = "2026-09-04T04:12:55Z";
|
|
Write(restaged / "roblox_payload.json", metadata.dump(2) + "\n");
|
|
|
|
const std::filesystem::path store_root = temporary.root() / "store";
|
|
PayloadStore store(store_root, temporary.root() / "compatibility.json");
|
|
const PayloadStoreResult initial = store.Stage(prepared);
|
|
ASSERT_TRUE(initial) << initial.error;
|
|
const auto written_at =
|
|
std::filesystem::last_write_time(initial.payload_directory);
|
|
|
|
const PayloadStoreResult again = store.Stage(restaged);
|
|
ASSERT_TRUE(again) << again.error;
|
|
EXPECT_EQ(again.payload_id, initial.payload_id);
|
|
EXPECT_EQ(std::filesystem::last_write_time(initial.payload_directory),
|
|
written_at);
|
|
EXPECT_FALSE(std::filesystem::exists(store_root / "quarantine"));
|
|
}
|
|
|
|
TEST(PayloadStoreTest, CollectsNothingWhileTheStoreIsUnreadable) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path root = temporary.root() / "store";
|
|
const std::string superseded = "2546-" + std::string(40, 'd');
|
|
Write(root / "payloads" / superseded / "libroblox.so", "payload bytes");
|
|
Write(root / "current.json", "{not json\n");
|
|
|
|
PayloadStore store(root, temporary.root() / "compatibility.json");
|
|
const PayloadGarbageResult collected = store.CollectGarbage({});
|
|
EXPECT_FALSE(collected);
|
|
EXPECT_TRUE(std::filesystem::exists(root / "payloads" / superseded));
|
|
}
|
|
|
|
TEST(PayloadStoreTest, StagesAndPromotesVerifiedExactPayload) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path prepared = temporary.root() / "prepared";
|
|
Write(prepared / "libroblox.so", "native-library");
|
|
Write(prepared / "sober_apk/base.apk", "base-apk");
|
|
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
|
Write(prepared / "assets/content/fixture", "asset");
|
|
std::string error;
|
|
std::size_t asset_count = 0;
|
|
const std::string asset_hash =
|
|
HashAssetTree(prepared / "assets", &asset_count, &error);
|
|
ASSERT_TRUE(error.empty()) << error;
|
|
constexpr std::string_view kBuildId =
|
|
"1686400865ae0e408cd7bd67de7a439625c6fd13";
|
|
nlohmann::json metadata = {
|
|
{"schema_version", 1},
|
|
{"package", "com.roblox.client"},
|
|
{"version_name", "2.727.1199"},
|
|
{"version_code", 2628},
|
|
{"elf_build_id", kBuildId},
|
|
{"sha256",
|
|
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
|
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
|
{"x86_64_split_apk",
|
|
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
|
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
|
};
|
|
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
|
const std::filesystem::path compatibility =
|
|
temporary.root() / "compatibility.json";
|
|
Write(compatibility,
|
|
"{\"schema_version\":1,\"profiles\":[{"
|
|
"\"version_name\":\"2.727.1199\",\"version_code\":2628,"
|
|
"\"elf_build_id\":\"1686400865ae0e408cd7bd67de7a439625c6fd13\","
|
|
"\"status\":\"supported\",\"default_allowed\":true,"
|
|
"\"allow_legacy_binary_patches\":false}]}\n");
|
|
PayloadStore store(temporary.root() / "store", compatibility);
|
|
const PayloadStoreResult staged = store.Stage(prepared);
|
|
ASSERT_TRUE(staged) << staged.error;
|
|
const PayloadStoreResult promoted = store.Promote(staged.payload_id);
|
|
ASSERT_TRUE(promoted) << promoted.error;
|
|
const PayloadStoreResult current = store.VerifyCurrent();
|
|
ASSERT_TRUE(current) << current.error;
|
|
EXPECT_EQ(current.payload_id,
|
|
"2628-1686400865ae0e408cd7bd67de7a439625c6fd13");
|
|
const nlohmann::json activation = nlohmann::json::parse(
|
|
ReadFile(temporary.root() / "store/current.json"));
|
|
EXPECT_EQ(activation["version_name"], metadata["version_name"]);
|
|
EXPECT_EQ(activation["version_code"], metadata["version_code"]);
|
|
EXPECT_EQ(activation["elf_build_id"], metadata["elf_build_id"]);
|
|
EXPECT_EQ(activation["payload_sha256"],
|
|
metadata["sha256"]["libroblox"]);
|
|
|
|
nlohmann::json tampered_activation = activation;
|
|
tampered_activation["payload_sha256"] = std::string(64, '0');
|
|
Write(temporary.root() / "store/current.json",
|
|
tampered_activation.dump(2) + "\n");
|
|
const PayloadStoreResult tampered = store.VerifyCurrent();
|
|
EXPECT_FALSE(tampered);
|
|
EXPECT_EQ(tampered.error,
|
|
"active payload manifest hash does not match payload");
|
|
}
|
|
|
|
TEST(PayloadStoreTest, RestagesReadOnlyCorruptPayloadCollision) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path prepared = temporary.root() / "prepared";
|
|
Write(prepared / "libroblox.so", "native-library");
|
|
Write(prepared / "sober_apk/base.apk", "base-apk");
|
|
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
|
Write(prepared / "assets/content/fixture", "asset");
|
|
std::string error;
|
|
std::size_t asset_count = 0;
|
|
const std::string asset_hash =
|
|
HashAssetTree(prepared / "assets", &asset_count, &error);
|
|
ASSERT_TRUE(error.empty()) << error;
|
|
constexpr std::string_view kBuildId =
|
|
"1686400865ae0e408cd7bd67de7a439625c6fd13";
|
|
const nlohmann::json metadata = {
|
|
{"schema_version", 1},
|
|
{"package", "com.roblox.client"},
|
|
{"version_name", "2.727.1199"},
|
|
{"version_code", 2628},
|
|
{"elf_build_id", kBuildId},
|
|
{"sha256",
|
|
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
|
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
|
{"x86_64_split_apk",
|
|
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
|
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
|
};
|
|
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
|
|
|
const std::filesystem::path store_root = temporary.root() / "store";
|
|
PayloadStore store(store_root, temporary.root() / "compatibility.json");
|
|
const PayloadStoreResult initial = store.Stage(prepared);
|
|
ASSERT_TRUE(initial) << initial.error;
|
|
|
|
const std::filesystem::path stored_metadata =
|
|
initial.payload_directory / "roblox_payload.json";
|
|
std::error_code filesystem_error;
|
|
std::filesystem::permissions(stored_metadata,
|
|
std::filesystem::perms::owner_write,
|
|
std::filesystem::perm_options::add,
|
|
filesystem_error);
|
|
ASSERT_FALSE(filesystem_error);
|
|
Write(stored_metadata, "corrupt\n");
|
|
std::filesystem::permissions(stored_metadata,
|
|
std::filesystem::perms::owner_read,
|
|
std::filesystem::perm_options::replace,
|
|
filesystem_error);
|
|
ASSERT_FALSE(filesystem_error);
|
|
|
|
const PayloadStoreResult recovered = store.Stage(prepared);
|
|
ASSERT_TRUE(recovered) << recovered.error;
|
|
EXPECT_TRUE(VerifyPreparedPayload(recovered.payload_directory));
|
|
|
|
const std::filesystem::path quarantine = store_root / "quarantine";
|
|
std::filesystem::directory_iterator iterator(quarantine, filesystem_error);
|
|
ASSERT_FALSE(filesystem_error);
|
|
ASSERT_NE(iterator, std::filesystem::directory_iterator());
|
|
const auto quarantined_status = iterator->symlink_status(filesystem_error);
|
|
ASSERT_FALSE(filesystem_error);
|
|
EXPECT_EQ(quarantined_status.permissions() &
|
|
std::filesystem::perms::owner_write,
|
|
std::filesystem::perms::none);
|
|
++iterator;
|
|
EXPECT_EQ(iterator, std::filesystem::directory_iterator());
|
|
}
|
|
|
|
// Promotion is separated from staging by the readiness canaries, which run the
|
|
// real client against the staged directory in a child process. A candidate
|
|
// modified in that window must never become current, so promotion rehashes the
|
|
// immutable bytes even though the very same instance staged them.
|
|
TEST(PayloadStoreTest, PromoteRehashesBytesTamperedAfterStaging) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path prepared = temporary.root() / "prepared";
|
|
Write(prepared / "libroblox.so", "native-library");
|
|
Write(prepared / "sober_apk/base.apk", "base-apk");
|
|
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
|
Write(prepared / "assets/content/fixture", "asset");
|
|
std::string error;
|
|
std::size_t asset_count = 0;
|
|
const std::string asset_hash =
|
|
HashAssetTree(prepared / "assets", &asset_count, &error);
|
|
ASSERT_TRUE(error.empty()) << error;
|
|
const nlohmann::json metadata = {
|
|
{"schema_version", 1},
|
|
{"package", "com.roblox.client"},
|
|
{"version_name", "2.727.1199"},
|
|
{"version_code", 2628},
|
|
{"elf_build_id", "1686400865ae0e408cd7bd67de7a439625c6fd13"},
|
|
{"sha256",
|
|
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
|
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
|
{"x86_64_split_apk",
|
|
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
|
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
|
};
|
|
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
|
const std::filesystem::path compatibility =
|
|
temporary.root() / "compatibility.json";
|
|
Write(compatibility,
|
|
"{\"schema_version\":1,\"profiles\":[{"
|
|
"\"version_name\":\"2.727.1199\",\"version_code\":2628,"
|
|
"\"elf_build_id\":\"1686400865ae0e408cd7bd67de7a439625c6fd13\","
|
|
"\"status\":\"supported\",\"default_allowed\":true,"
|
|
"\"allow_legacy_binary_patches\":false}]}\n");
|
|
|
|
const std::filesystem::path store_root = temporary.root() / "store";
|
|
PayloadStore store(store_root, compatibility);
|
|
const PayloadStoreResult staged = store.Stage(prepared);
|
|
ASSERT_TRUE(staged) << staged.error;
|
|
|
|
// Stands in for the canary interval. Content bytes only: the metadata still
|
|
// describes the original payload, so nothing short of hashing the contents
|
|
// can detect this.
|
|
const std::filesystem::path asset =
|
|
staged.payload_directory / "assets/content/fixture";
|
|
std::error_code filesystem_error;
|
|
std::filesystem::permissions(asset, std::filesystem::perms::owner_write,
|
|
std::filesystem::perm_options::add,
|
|
filesystem_error);
|
|
ASSERT_FALSE(filesystem_error);
|
|
Write(asset, "tampered");
|
|
|
|
const PayloadStoreResult promoted = store.Promote(staged.payload_id);
|
|
EXPECT_FALSE(promoted);
|
|
EXPECT_EQ(promoted.error, "payload asset tree does not match metadata");
|
|
EXPECT_FALSE(std::filesystem::exists(store_root / "current.json",
|
|
filesystem_error));
|
|
}
|
|
|
|
// The same guarantee holds for a library modified in that window.
|
|
TEST(PayloadStoreTest, PromoteRejectsALibraryTamperedAfterStaging) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path prepared = temporary.root() / "prepared";
|
|
Write(prepared / "libroblox.so", "native-library");
|
|
Write(prepared / "sober_apk/base.apk", "base-apk");
|
|
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
|
Write(prepared / "assets/content/fixture", "asset");
|
|
std::string error;
|
|
std::size_t asset_count = 0;
|
|
const std::string asset_hash =
|
|
HashAssetTree(prepared / "assets", &asset_count, &error);
|
|
ASSERT_TRUE(error.empty()) << error;
|
|
const nlohmann::json metadata = {
|
|
{"schema_version", 1},
|
|
{"package", "com.roblox.client"},
|
|
{"version_name", "2.727.1199"},
|
|
{"version_code", 2628},
|
|
{"elf_build_id", "1686400865ae0e408cd7bd67de7a439625c6fd13"},
|
|
{"sha256",
|
|
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
|
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
|
{"x86_64_split_apk",
|
|
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
|
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
|
};
|
|
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
|
const std::filesystem::path compatibility =
|
|
temporary.root() / "compatibility.json";
|
|
Write(compatibility,
|
|
"{\"schema_version\":1,\"profiles\":[{"
|
|
"\"version_name\":\"2.727.1199\",\"version_code\":2628,"
|
|
"\"elf_build_id\":\"1686400865ae0e408cd7bd67de7a439625c6fd13\","
|
|
"\"status\":\"supported\",\"default_allowed\":true,"
|
|
"\"allow_legacy_binary_patches\":false}]}\n");
|
|
|
|
const std::filesystem::path store_root = temporary.root() / "store";
|
|
PayloadStore store(store_root, compatibility);
|
|
const PayloadStoreResult staged = store.Stage(prepared);
|
|
ASSERT_TRUE(staged) << staged.error;
|
|
|
|
const std::filesystem::path library =
|
|
staged.payload_directory / "libroblox.so";
|
|
std::error_code filesystem_error;
|
|
std::filesystem::permissions(library, std::filesystem::perms::owner_write,
|
|
std::filesystem::perm_options::add,
|
|
filesystem_error);
|
|
ASSERT_FALSE(filesystem_error);
|
|
Write(library, "backdoored");
|
|
|
|
const PayloadStoreResult promoted = store.Promote(staged.payload_id);
|
|
EXPECT_FALSE(promoted);
|
|
EXPECT_EQ(promoted.error, "payload hash mismatch: libroblox.so");
|
|
EXPECT_FALSE(std::filesystem::exists(store_root / "current.json",
|
|
filesystem_error));
|
|
}
|
|
|
|
TEST(PayloadStoreTest, EmptyResultCannotReportFalsePromotion) {
|
|
const PayloadStoreResult result;
|
|
EXPECT_FALSE(result);
|
|
}
|
|
|
|
TEST(PayloadStoreTest, RequiresTwoRuntimeBoundCanariesForLatestCandidate) {
|
|
TemporaryDirectory temporary;
|
|
const std::filesystem::path prepared = temporary.root() / "prepared";
|
|
Write(prepared / "libroblox.so", "new-native-library");
|
|
Write(prepared / "sober_apk/base.apk", "new-base-apk");
|
|
Write(prepared / "sober_apk/split_config.x86_64.apk", "new-split-apk");
|
|
Write(prepared / "assets/content/fixture", "new-asset");
|
|
std::string error;
|
|
std::size_t asset_count = 0;
|
|
const std::string asset_hash =
|
|
HashAssetTree(prepared / "assets", &asset_count, &error);
|
|
ASSERT_TRUE(error.empty()) << error;
|
|
constexpr std::string_view kBuildId =
|
|
"a6c1f5c57f9d7aa3fb99a5fa30565e07e5c88f6a";
|
|
const nlohmann::json metadata = {
|
|
{"schema_version", 1},
|
|
{"package", "com.roblox.client"},
|
|
{"version_name", "2.732.1043"},
|
|
{"version_code", 2814},
|
|
{"abi", "x86_64"},
|
|
{"elf_build_id", kBuildId},
|
|
{"sha256",
|
|
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
|
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
|
{"x86_64_split_apk",
|
|
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
|
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
|
};
|
|
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
|
const std::filesystem::path compatibility =
|
|
temporary.root() / "repository-compatibility.json";
|
|
Write(compatibility,
|
|
"{\"schema_version\":1,\"profiles\":[{"
|
|
"\"version_name\":\"2.727.1199\",\"version_code\":2628,"
|
|
"\"elf_build_id\":\"1686400865ae0e408cd7bd67de7a439625c6fd13\","
|
|
"\"status\":\"supported\",\"default_allowed\":true,"
|
|
"\"allow_legacy_binary_patches\":false}]}\n");
|
|
std::error_code filesystem_error;
|
|
const std::filesystem::path runtime =
|
|
std::filesystem::canonical("/proc/self/exe", filesystem_error);
|
|
ASSERT_FALSE(filesystem_error);
|
|
PayloadStore store(temporary.root() / "store", compatibility, runtime);
|
|
const PayloadStoreResult staged = store.Stage(prepared);
|
|
ASSERT_TRUE(staged) << staged.error;
|
|
|
|
const std::string payload_id =
|
|
"2814-a6c1f5c57f9d7aa3fb99a5fa30565e07e5c88f6a";
|
|
const std::filesystem::path profile = temporary.root() / "candidate.json";
|
|
Write(profile,
|
|
nlohmann::json(
|
|
{{"schema_version", 1},
|
|
{"elf_build_id", kBuildId},
|
|
{"payload_sha256", metadata["sha256"]["libroblox"]},
|
|
{"payload_id", payload_id},
|
|
{"payload_path", "payloads/" + payload_id},
|
|
{"reference",
|
|
{{"elf_build_id", "1686400865ae0e408cd7bd67de7a439625c6fd13"},
|
|
{"payload_sha256",
|
|
"3e9c26c81186f93458ff65d8a8bc240c220974db02b8"
|
|
"388b91340b77b336997d"}}},
|
|
{"profile", {{"elf_build_id", kBuildId}}},
|
|
{"derivation_anchors", {{"signature_version", 1}}}})
|
|
.dump(2) +
|
|
"\n");
|
|
const std::filesystem::path candidate_compatibility =
|
|
temporary.root() / "candidate-compatibility.json";
|
|
Write(candidate_compatibility,
|
|
nlohmann::json(
|
|
{{"schema_version", 1},
|
|
{"profiles", nlohmann::json::array(
|
|
{{{"version_name", "2.732.1043"},
|
|
{"version_code", 2814},
|
|
{"elf_build_id", kBuildId},
|
|
{"status", "experimental"},
|
|
{"default_allowed", true},
|
|
{"allow_legacy_binary_patches", false},
|
|
{"allow_host_abi_bridges", true},
|
|
{"allow_host_constructor_replay", true}}})}})
|
|
.dump(2) +
|
|
"\n");
|
|
const std::array<std::filesystem::path, 2> canary_logs = {
|
|
temporary.root() / "canary-1.log",
|
|
temporary.root() / "canary-2.log",
|
|
};
|
|
Write(canary_logs[0], "first independent Tier C pass\n");
|
|
Write(canary_logs[1], "second independent Tier C pass\n");
|
|
|
|
const PayloadStoreResult promoted = store.PromoteProbation(
|
|
staged.payload_id, profile, candidate_compatibility, canary_logs);
|
|
ASSERT_TRUE(promoted) << promoted.error;
|
|
EXPECT_FALSE(promoted.approval_receipt.empty());
|
|
const PayloadStoreResult current = store.VerifyCurrent();
|
|
ASSERT_TRUE(current) << current.error;
|
|
EXPECT_EQ(current.payload_id, payload_id);
|
|
EXPECT_EQ(current.approval_receipt, promoted.approval_receipt);
|
|
|
|
// A Mocktail rebuild does not invalidate a canary result for unchanged
|
|
// Roblox bytes. The immutable receipt still binds the two original canary
|
|
// runs, payload, profile, and compatibility manifest to one evidence set.
|
|
nlohmann::json legacy_activation = nlohmann::json::parse(
|
|
ReadFile(temporary.root() / "store/current.json"));
|
|
legacy_activation.erase("payload_sha256");
|
|
Write(temporary.root() / "store/current.json",
|
|
legacy_activation.dump(2) + "\n");
|
|
PayloadStore rebuilt_runtime_store(temporary.root() / "store", compatibility);
|
|
const PayloadStoreResult cached = rebuilt_runtime_store.VerifyCurrent();
|
|
ASSERT_TRUE(cached) << cached.error;
|
|
EXPECT_EQ(cached.payload_id, payload_id);
|
|
EXPECT_EQ(cached.approval_receipt, promoted.approval_receipt);
|
|
const nlohmann::json activation = nlohmann::json::parse(
|
|
ReadFile(temporary.root() / "store/current.json"));
|
|
EXPECT_EQ(activation["payload_sha256"],
|
|
metadata["sha256"]["libroblox"]);
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace mocktail::update
|