Cut first-install time by removing redundant payload hashing and copying (#41)

* Stop rehashing and recopying the payload during install

A first install prepares a ~635 MiB payload, then hashes the whole tree five
times and byte-copies it once more into the store. Four of those five passes
re-derive digests the same process just computed, under the same lock.

PreparePayloadFromArchives hashed the tree to write roblox_payload.json and
then immediately verified those hashes against the file it had just written.
PayloadStore::Stage hashed the same workspace again before copying it, and
Promote hashed the published directory a third time.

The guarantee that matters is unchanged: the bytes that land in the immutable
store are still hashed in full after the copy. Stage now records which payload
it verified, so promotion skips the repeat only for a payload this instance
staged; an operator-supplied ID, a directory left by an earlier run, or a fresh
process still gets the complete pass.

CopyTree now reflinks (FICLONE) or uses copy_file_range before falling back to
an ordinary copy, so on btrfs and xfs the staging copy costs no data movement
and no extra space. A hard link would be wrong here: CopyTree makes the result
read-only while the source stays writable.

File hashing moves from the portable foundation::Sha256 to OpenSSL, which
reaches the CPU's SHA extensions, and the asset tree hashes its files across
cores. Measured on this machine: 182 MB/s to 1518 MB/s, and the 1835-file asset
tree of Roblox 2.734.917 reproduces its recorded sha256_tree exactly.

MOCKTAIL_UPDATE_TRACE=1 now reports the elapsed time of each install stage, so
the breakdown is visible instead of inferred.

* Hide the system cursor as soon as the window regains focus

Roblox draws its own cursor while it owns the pointer, so Mocktail hides the
system one. Losing focus reveals it again, which is right, but regaining focus
only set the focused flag and left the re-apply to the next Pump().

Pump() runs at the top of PumpEvents(), before SDL_PollEvent delivers
SDL_EVENT_WINDOW_FOCUS_GAINED, so the desktop arrow stayed on screen for at
least one whole frame after every alt-tab back into the game, and longer
whenever the frame was long. OnFocusGained now pumps the pointer state in the
same call, mirroring OnFocusLost and OnRightButton, and its failure is reported
like the focus-loss path.

An active text field still keeps the system cursor, focused or not.

* Cover promotion of a payload the store did not stage

Stage() now lets promotion skip the repeat hash for a payload it just wrote,
so the boundary of that shortcut needs a test. Nothing covered it: the existing
corruption test rewrites roblox_payload.json, which the metadata check catches
on its own, and payload_store_test.sh exercises the shell store rather than
this class.

The new test corrupts only the content bytes, leaving the metadata describing
the original payload, then promotes from a second PayloadStore instance. It
fails if the shortcut is ever allowed to apply to a payload this instance did
not stage.

* Restore the full hash at the promotion gate

Skipping the rehash for a payload the same instance staged was wrong. The
readiness canaries run between Stage() and Promote(): they spawn the real
client against the staged directory, for up to 150 seconds, twice for a
probation candidate. Trusting the earlier hash across that window drops a
deliberate control, and payload_store_test.sh already spelled out why it is
there.

Promote() and PromoteProbation() call VerifyPreparedPayload() again, as before.
The instance no longer tracks what it staged.

The two tests now tamper with a staged payload from the instance that staged
it, which is the case that was missing: content bytes only, metadata left
describing the original payload, so nothing short of hashing the contents can
detect it. One covers the asset tree, the other libroblox.so.

Promotion still costs a complete pass over ~635 MiB, but OpenSSL and the
parallel asset hash make it 3.9 s -> 0.55 s. Stage is 9.5 s -> 0.93 s. A traced
install reports promote at 656 ms.
This commit is contained in:
Obsidian authored and GitHub committed 2026-08-24 16:22:35 +03:00
1 parent 2f179068b3
commit 0f635abc25
9 files changed
+454 -26

No files matched your search

+5 -1
View File
@@ -558,7 +558,11 @@ PreparedPayload PreparePayloadFromArchives(
metadata.dump(2) + "\n", &result.error)) {
return result;
}
const PayloadIntegrityResult verified = VerifyPreparedPayload(prepared);
// The hashes above were computed from these exact bytes and written into
// roblox_payload.json, so re-hashing the tree here would only re-derive them.
// The shape, schema, and identity guard still runs; the bytes that reach the
// immutable store are hashed in full by PayloadStore::Stage after the copy.
const PayloadIntegrityResult verified = InspectPreparedPayload(prepared);
if (!verified || verified.payload_id !=
std::to_string(expected.version_code) + "-" + build_id) {
result.error =
+114 -9
View File
@@ -2,16 +2,20 @@
#define JSON_NOEXCEPTION 1
#include <fcntl.h>
#include <openssl/evp.h>
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cctype>
#include <cerrno>
#include <filesystem>
#include <mutex>
#include <nlohmann/json.hpp>
#include <string>
#include <thread>
#include <vector>
#include "mocktail/sha256.h"
@@ -59,6 +63,56 @@ std::string ReadMetadata(const std::filesystem::path& path,
return contents;
}
// foundation::Sha256 is a portable implementation and runs at roughly 180 MB/s
// here; OpenSSL reaches the CPU's SHA extensions and measures 8x faster on the
// same bytes. A payload is several hundred MiB, so file hashing uses OpenSSL.
// The digests are identical, and foundation::Sha256 stays in use for the small
// inputs elsewhere in the updater.
class FileDigest final {
public:
FileDigest() : context_(EVP_MD_CTX_new()) {
if (context_ != nullptr &&
EVP_DigestInit_ex(context_, EVP_sha256(), nullptr) != 1) {
EVP_MD_CTX_free(context_);
context_ = nullptr;
}
}
~FileDigest() {
if (context_ != nullptr) EVP_MD_CTX_free(context_);
}
FileDigest(const FileDigest&) = delete;
FileDigest& operator=(const FileDigest&) = delete;
bool valid() const { return context_ != nullptr; }
bool Update(const unsigned char* bytes, std::size_t size) {
return context_ != nullptr &&
EVP_DigestUpdate(context_, bytes, size) == 1;
}
std::string FinalHex() {
unsigned char digest[EVP_MAX_MD_SIZE] = {};
unsigned int size = 0;
if (context_ == nullptr ||
EVP_DigestFinal_ex(context_, digest, &size) != 1 || size != 32U) {
return {};
}
static constexpr char kHex[] = "0123456789abcdef";
std::string hex;
hex.reserve(size * 2U);
for (unsigned int index = 0; index < size; ++index) {
hex.push_back(kHex[digest[index] >> 4]);
hex.push_back(kHex[digest[index] & 0x0FU]);
}
return hex;
}
private:
EVP_MD_CTX* context_ = nullptr;
};
} // namespace
std::string HashRegularFile(const std::filesystem::path& path,
@@ -76,8 +130,13 @@ std::string HashRegularFile(const std::filesystem::path& path,
*error = "hash input is not a regular file: " + path.string();
return {};
}
foundation::Sha256 sha256;
std::array<unsigned char, 128U * 1024U> buffer{};
FileDigest digest;
if (!digest.valid()) {
close(descriptor);
*error = "cannot initialise the file digest";
return {};
}
std::vector<unsigned char> buffer(256U * 1024U);
while (true) {
const ssize_t bytes = read(descriptor, buffer.data(), buffer.size());
if (bytes == 0) break;
@@ -87,10 +146,16 @@ std::string HashRegularFile(const std::filesystem::path& path,
*error = "cannot hash regular file: " + path.string();
return {};
}
sha256.Update(buffer.data(), static_cast<std::size_t>(bytes));
if (!digest.Update(buffer.data(), static_cast<std::size_t>(bytes))) {
close(descriptor);
*error = "cannot hash regular file: " + path.string();
return {};
}
}
close(descriptor);
return sha256.FinalHex();
const std::string hex = digest.FinalHex();
if (hex.empty()) *error = "cannot finalise the hash of " + path.string();
return hex;
}
std::string HashAssetTree(const std::filesystem::path& root,
@@ -132,13 +197,53 @@ std::string HashAssetTree(const std::filesystem::path& root,
[](const auto& left, const auto& right) {
return left.generic_string() < right.generic_string();
});
// Each file digest is independent; only the fold below depends on the sorted
// order, so the per-file work spreads across cores without changing the
// result. An asset tree holds a few thousand files.
std::vector<std::string> digests(relative_files.size());
std::atomic<std::size_t> next_index{0};
std::mutex error_mutex;
std::string first_error;
const auto hash_range = [&]() {
while (true) {
const std::size_t index = next_index.fetch_add(1);
if (index >= relative_files.size()) return;
{
const std::lock_guard<std::mutex> lock(error_mutex);
if (!first_error.empty()) return;
}
std::string local_failure;
std::string digest =
HashRegularFile(root / relative_files[index], &local_failure);
if (!local_failure.empty()) {
const std::lock_guard<std::mutex> lock(error_mutex);
if (first_error.empty()) first_error = std::move(local_failure);
return;
}
digests[index] = std::move(digest);
}
};
unsigned int workers = std::thread::hardware_concurrency();
if (workers == 0U) workers = 1U;
workers = std::min<unsigned int>(workers, 8U);
workers = std::min<unsigned int>(
workers, static_cast<unsigned int>(relative_files.size()));
std::vector<std::thread> pool;
pool.reserve(workers > 0U ? workers - 1U : 0U);
for (unsigned int index = 1; index < workers; ++index) {
pool.emplace_back(hash_range);
}
if (workers > 0U) hash_range();
for (std::thread& worker : pool) worker.join();
if (!first_error.empty()) {
*error = first_error;
return {};
}
foundation::Sha256 tree;
for (const auto& relative : relative_files) {
const std::string digest = HashRegularFile(root / relative, error);
if (!error->empty()) return {};
tree.Update(digest);
for (std::size_t index = 0; index < relative_files.size(); ++index) {
tree.Update(digests[index]);
tree.Update(" ");
tree.Update(relative.generic_string());
tree.Update(relative_files[index].generic_string());
const unsigned char terminator = 0;
tree.Update(&terminator, 1);
}
+101 -2
View File
@@ -5,6 +5,10 @@
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#if defined(__linux__)
#include <linux/fs.h>
#include <sys/ioctl.h>
#endif
#include <array>
#include <cctype>
@@ -227,6 +231,98 @@ bool QuarantinePayloadCollision(const std::filesystem::path& root,
return true;
}
// A payload tree is several hundred MiB. On a copy-on-write filesystem a
// reflink shares the extents instead of duplicating them, and copy_file_range
// keeps the bytes inside the kernel; both fall back to an ordinary copy.
// A hard link is deliberately not used: CopyTree makes the result read-only
// while the source stays writable, and a shared inode would break that.
bool CloneOrCopyFile(const std::filesystem::path& source,
const std::filesystem::path& destination) {
const int input = open(source.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
if (input < 0) return false;
struct stat metadata = {};
if (fstat(input, &metadata) != 0 || !S_ISREG(metadata.st_mode)) {
close(input);
return false;
}
const int output =
open(destination.c_str(),
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
if (output < 0) {
close(input);
return false;
}
bool copied = false;
#if defined(__linux__) && defined(FICLONE)
copied = ioctl(output, FICLONE, input) == 0;
#endif
#if defined(__linux__)
if (!copied) {
off_t remaining = metadata.st_size;
copied = true;
while (remaining > 0) {
const ssize_t moved = copy_file_range(
input, nullptr, output, nullptr, static_cast<size_t>(remaining), 0);
if (moved < 0 && errno == EINTR) continue;
if (moved <= 0) {
copied = false;
break;
}
remaining -= moved;
}
// copy_file_range advances both file offsets, so a partial move must be
// undone completely before the portable fallback can start from zero.
if (!copied && (ftruncate(output, 0) != 0 ||
lseek(input, 0, SEEK_SET) != 0 ||
lseek(output, 0, SEEK_SET) != 0)) {
close(input);
close(output);
std::error_code ignored;
std::filesystem::remove(destination, ignored);
return false;
}
}
#endif
if (!copied) {
std::array<char, 256U * 1024U> buffer{};
while (true) {
const ssize_t bytes = read(input, buffer.data(), buffer.size());
if (bytes == 0) break;
if (bytes < 0) {
if (errno == EINTR) continue;
close(input);
close(output);
std::error_code ignored;
std::filesystem::remove(destination, ignored);
return false;
}
std::size_t offset = 0;
while (offset < static_cast<std::size_t>(bytes)) {
const ssize_t written =
write(output, buffer.data() + offset,
static_cast<std::size_t>(bytes) - offset);
if (written < 0 && errno == EINTR) continue;
if (written <= 0) {
close(input);
close(output);
std::error_code ignored;
std::filesystem::remove(destination, ignored);
return false;
}
offset += static_cast<std::size_t>(written);
}
}
}
close(input);
const bool closed = close(output) == 0;
if (!closed) {
std::error_code ignored;
std::filesystem::remove(destination, ignored);
return false;
}
return true;
}
bool CopyTree(const std::filesystem::path& source,
const std::filesystem::path& destination, std::string* error) {
std::error_code filesystem_error;
@@ -251,7 +347,7 @@ bool CopyTree(const std::filesystem::path& source,
}
if (std::filesystem::is_directory(status)) {
std::filesystem::create_directory(output, filesystem_error);
} else {
} else if (!CloneOrCopyFile(iterator->path(), output)) {
std::filesystem::copy_file(iterator->path(), output,
std::filesystem::copy_options::none,
filesystem_error);
@@ -466,8 +562,11 @@ PayloadStoreResult PayloadStore::Stage(
PayloadStoreResult result;
StoreLock lock(root_, &result.error);
if (!lock) return result;
// Preparation produced these bytes and recorded their hashes. Hashing the
// workspace again proves nothing that the post-copy verify below does not
// prove about the bytes that actually become the immutable store entry.
const PayloadIntegrityResult verified =
VerifyPreparedPayload(prepared_payload);
InspectPreparedPayload(prepared_payload);
if (!verified) {
result.error = verified.error;
return result;
+72 -5
View File
@@ -6,6 +6,9 @@
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <memory>
@@ -64,6 +67,47 @@ void Progress(int descriptor, std::string_view message) {
(void)send(descriptor, packet.data(), packet.size(), MSG_NOSIGNAL);
}
bool TraceFlagEnabled(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0;
}
bool UpdateTraceEnabled() {
static const bool enabled = TraceFlagEnabled("MOCKTAIL_UPDATE_TRACE") ||
TraceFlagEnabled("MOCKTAIL_TRACE_ALL") ||
TraceFlagEnabled("MOCKTAIL_FULL_TRACE");
return enabled;
}
// A first install downloads, verifies, canaries, and promotes several hundred
// MiB. Without a per-stage breakdown there is no way to tell which stage owns
// the wall-clock time, so the timer reports one line per stage on request.
class StageTimer final {
public:
explicit StageTimer(std::string name)
: name_(std::move(name)), start_(std::chrono::steady_clock::now()) {}
~StageTimer() { Report(); }
StageTimer(const StageTimer&) = delete;
StageTimer& operator=(const StageTimer&) = delete;
void Report() {
if (name_.empty()) return;
if (UpdateTraceEnabled()) {
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start_);
std::fprintf(stderr, " [update] %s %lld ms\n", name_.c_str(),
static_cast<long long>(elapsed.count()));
}
name_.clear();
}
private:
std::string name_;
std::chrono::steady_clock::time_point start_;
};
std::filesystem::path UniqueDirectory(const std::filesystem::path& parent,
std::string* error) {
std::error_code filesystem_error;
@@ -208,13 +252,16 @@ Candidate DownloadCandidate(const ApkProvider* provider, PayloadStore* store,
result.identity = identity;
result.exact_supported = exact_supported;
Progress(progress_fd, "Downloading Roblox...");
StageTimer download_timer("download");
const ProviderDownloadResult downloaded = provider->DownloadExact(
identity.version_name, workspace / "provider", progress_fd);
if (!downloaded) {
result.error = downloaded.error;
return result;
}
download_timer.Report();
Progress(progress_fd, "Verifying Roblox...");
StageTimer prepare_timer("prepare");
const PreparedPayload prepared = PreparePayloadFromArchives(
downloaded.archives, identity, paths.signing_trust_manifest,
workspace / "prepare", downloaded.source + "-native");
@@ -222,6 +269,8 @@ Candidate DownloadCandidate(const ApkProvider* provider, PayloadStore* store,
result.error = prepared.error;
return result;
}
prepare_timer.Report();
StageTimer stage_timer("stage");
result.staged = store->Stage(prepared.directory);
if (!result.staged) result.error = result.staged.error;
return result;
@@ -302,7 +351,10 @@ UpdateResult RunUnsafeLatest(
const ProviderChain provider = BuildProviderChain();
Progress(request.progress_fd, "Checking latest Roblox...");
const ProviderVersion latest = provider.CheckLatest();
const ProviderVersion latest = [&] {
StageTimer timer("check-latest");
return provider.CheckLatest();
}();
if (!latest) {
result.error = "cannot check latest Roblox for unsafe launch: " +
latest.error;
@@ -356,7 +408,10 @@ UpdateResult RunUnsafeLatest(
derivation.candidate_payload_directory = candidate.staged.payload_directory;
derivation.output_directory = workspace / "derived";
Progress(request.progress_fd, "Deriving latest Roblox compatibility...");
const HostAbiDerivationResult derived = DeriveHostAbiProfile(derivation);
const HostAbiDerivationResult derived = [&] {
StageTimer timer("derive-host-abi");
return DeriveHostAbiProfile(derivation);
}();
if (!derived) {
result.error = "cannot derive latest Roblox HostAbi profile: " +
derived.error;
@@ -420,7 +475,11 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
canary.cache_root = paths.cache_root;
canary.state_root = paths.state_root;
canary.graphics_backend = graphics_backend;
const CanaryResult canary_result = RunReadinessCanary(canary);
const CanaryResult canary_result = [&] {
StageTimer timer("canary[" + std::to_string(index + 1) + "/" +
std::to_string(runs) + "]");
return RunReadinessCanary(canary);
}();
if (!canary_result) {
*error = canary_result.error;
if (!canary_result.log_path.empty()) {
@@ -444,6 +503,7 @@ PayloadStoreResult PromoteCandidate(const UpdatePaths& paths,
return {};
}
Progress(request.progress_fd, "Installing Roblox...");
StageTimer promote_timer("promote");
PayloadStoreResult promoted =
candidate.exact_supported
? store->Promote(candidate.staged.payload_id)
@@ -458,6 +518,7 @@ PayloadStoreResult PromoteCandidate(const UpdatePaths& paths,
UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
UpdateResult result;
StageTimer total_timer("total");
UpdateLock update_lock(paths.data_root, &result.error);
if (!update_lock) return result;
@@ -518,7 +579,10 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
std::optional<ProviderVersion> latest_version;
if (request.check_latest) {
Progress(request.progress_fd, "Checking Roblox...");
const ProviderVersion latest = provider.CheckLatest();
const ProviderVersion latest = [&] {
StageTimer timer("check-latest");
return provider.CheckLatest();
}();
if (latest) {
latest_version = latest;
if (current && SameVersion(current, latest)) {
@@ -608,7 +672,10 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
candidate.staged.payload_directory;
derivation.output_directory = workspace / "derived";
Progress(request.progress_fd, "Checking latest Roblox compatibility...");
const HostAbiDerivationResult derived = DeriveHostAbiProfile(derivation);
const HostAbiDerivationResult derived = [&] {
StageTimer timer("derive-host-abi");
return DeriveHostAbiProfile(derivation);
}();
if (!derived) {
candidate_error = derived.error;
candidate_rejected = true;
+4 -2
View File
@@ -1957,8 +1957,10 @@ bool PumpEvents() {
g_text_input_owner->OnFocusGained();
}
if (event.type == SDL_EVENT_WINDOW_FOCUS_GAINED &&
g_pointer_capture_owner != nullptr) {
g_pointer_capture_owner->OnFocusGained();
g_pointer_capture_owner != nullptr &&
!g_pointer_capture_owner->OnFocusGained(
g_text_input_owner != nullptr && g_text_input_owner->active())) {
fprintf(stderr, " [input] failed to restore pointer after focus gain\n");
}
const bool geometry_changed = event.type == SDL_EVENT_WINDOW_MOVED ||
+2 -2
View File
@@ -121,9 +121,9 @@ bool WindowPointerCaptureOwner::ShouldDispatchMouseMotion() {
return !discard_motion && !wait_for_native_unlock_after_right_drag_;
}
bool WindowPointerCaptureOwner::OnFocusGained() {
bool WindowPointerCaptureOwner::OnFocusGained(bool text_input_active) {
focused_ = true;
return true;
return Pump(text_input_active);
}
bool WindowPointerCaptureOwner::OnFocusLost() {
+5 -1
View File
@@ -39,7 +39,11 @@ class WindowPointerCaptureOwner final {
// Mirrors Android's captured-pointer listener: the motion that releases a
// transient capture is consumed instead of leaking one final camera delta.
bool ShouldDispatchMouseMotion();
bool OnFocusGained();
// Focus loss reveals the system cursor. Regaining focus must re-apply the
// pointer state immediately: Pump() runs at the top of the frame, before
// SDL_PollEvent delivers the focus event, so deferring the re-apply leaves
// the desktop arrow on screen for at least one whole frame.
bool OnFocusGained(bool text_input_active);
bool OnFocusLost();
bool Shutdown();
+120
View File
@@ -451,6 +451,126 @@ TEST(PayloadStoreTest, RestagesReadOnlyCorruptPayloadCollision) {
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);
+31 -4
View File
@@ -2,6 +2,7 @@
#include <gtest/gtest.h>
#include <cstddef>
#include <vector>
namespace mocktail {
@@ -70,8 +71,7 @@ TEST(WindowPointerCaptureOwnerTest, TextAndFocusAlwaysReleaseCapture) {
EXPECT_TRUE(owner.captured());
EXPECT_TRUE(owner.OnFocusLost());
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.OnFocusGained());
EXPECT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.OnFocusGained(false));
EXPECT_TRUE(owner.captured());
}
@@ -236,11 +236,38 @@ TEST(WindowPointerCaptureOwnerTest, TextAndFocusCancelRightDragCapture) {
EXPECT_TRUE(owner.OnFocusLost());
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
EXPECT_TRUE(owner.OnFocusGained());
EXPECT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.OnFocusGained(false));
EXPECT_FALSE(owner.captured());
}
// Roblox draws its own cursor while it owns the pointer. Focus loss reveals the
// system cursor; regaining focus has to hide it again in the same call, because
// Pump() runs before SDL_PollEvent delivers the focus event and would otherwise
// leave the desktop arrow on screen for a whole frame.
TEST(WindowPointerCaptureOwnerTest, FocusGainHidesTheSystemCursorImmediately) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
ASSERT_FALSE(owner.cursor_visible());
EXPECT_TRUE(owner.OnFocusLost());
EXPECT_TRUE(owner.cursor_visible());
const std::size_t calls_before = backend.calls.size();
EXPECT_TRUE(owner.OnFocusGained(false));
EXPECT_FALSE(owner.cursor_visible());
ASSERT_GT(backend.calls.size(), calls_before);
EXPECT_FALSE(backend.calls.back().cursor_visible);
// An active text field keeps the system cursor, focus or not.
EXPECT_TRUE(owner.OnFocusLost());
EXPECT_TRUE(owner.OnFocusGained(true));
EXPECT_TRUE(owner.cursor_visible());
}
} // namespace
} // namespace window
} // namespace mocktail