Add flag --force-run-latest

#20
This commit is contained in:
komaruworld committed 2026-08-17 14:18:34 +03:00
1 parent e161fecfc5
commit 4b437f623f
15 files changed
+453 -8

No files matched your search

+1
View File
@@ -224,6 +224,7 @@ add_library(mocktail_update STATIC
src/update/payload_integrity.cc
src/update/payload_store.cc
src/update/readiness_canary.cc
src/update/unsafe_latest_runner.cc
src/update/update_config.cc
src/update/update_coordinator.cc
src/update/zip_archive.cc
+3
View File
@@ -30,6 +30,9 @@ struct CommandLineOptions {
std::string launch_request_json;
int launch_argument_index = -1;
bool allow_unverified_build = false;
// Requests one explicit launch of the provider latest without promoting it
// into the managed payload state.
bool force_run_latest = false;
};
struct CommandLineParseResult {
+2 -1
View File
@@ -17,7 +17,8 @@ struct PayloadUpdatePreflightResult {
};
PayloadUpdatePreflightResult RunPayloadUpdatePreflight(
const Environment& environment, const RuntimePaths& paths);
const Environment& environment, const RuntimePaths& paths,
bool force_run_latest = false);
} // namespace runtime
} // namespace mocktail
+36
View File
@@ -0,0 +1,36 @@
#ifndef MOCKTAIL_UPDATE_UNSAFE_LATEST_RUNNER_H_
#define MOCKTAIL_UPDATE_UNSAFE_LATEST_RUNNER_H_
#include <filesystem>
#include <string>
#include <vector>
namespace mocktail::update {
// Launches a downloaded latest payload once without promoting it to the active
// store. A non-empty HostAbi profile puts the child in explicit candidate mode;
// normal startup approval is intentionally not created or modified.
struct UnsafeLatestRunOptions {
std::filesystem::path runtime_binary;
std::filesystem::path payload_directory;
std::filesystem::path compatibility_manifest;
std::filesystem::path host_abi_profile;
// Tests may supply a deterministic environment. Production callers leave
// this empty to inherit the updater process environment.
std::vector<std::string> inherited_environment;
};
struct UnsafeLatestRunResult {
int exit_code = -1;
std::string error;
explicit operator bool() const { return error.empty() && exit_code == 0; }
};
UnsafeLatestRunResult RunUnsafeLatestCandidate(
const UnsafeLatestRunOptions& options);
} // namespace mocktail::update
#endif // MOCKTAIL_UPDATE_UNSAFE_LATEST_RUNNER_H_
+3
View File
@@ -24,6 +24,9 @@ struct UpdateRequest {
bool startup_preflight = false;
bool check_latest = true;
bool run_canary = true;
// Explicit manual escape hatch. It launches the provider latest once with a
// derived candidate profile and never promotes it into current.json.
bool force_run_latest = false;
CanaryGraphicsBackend canary_graphics_backend =
CanaryGraphicsBackend::kDirectVulkan;
int progress_fd = -1;
+11
View File
@@ -139,6 +139,17 @@ int main(int argc, char* argv[]) {
MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST));
const mocktail::runtime::RuntimePaths paths =
mocktail::runtime::RuntimePaths::FromEnvironment(environment);
if (command_line.options.force_run_latest) {
std::cerr << "[runtime] WARNING: latest Roblox will run once without "
"approval and will not become the active payload\n";
const mocktail::runtime::PayloadUpdatePreflightResult force_latest =
mocktail::runtime::RunPayloadUpdatePreflight(environment, paths, true);
if (!force_latest) {
std::cerr << "[FATAL] " << force_latest.error << '\n';
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
std::optional<mocktail::runtime::RobloxExperienceLaunchRequest>
external_launch_request;
if (!command_line.options.launch_request_json.empty()) {
+12
View File
@@ -76,6 +76,8 @@ CommandLineParseResult ParseCommandLine(int argc, const char* const argv[]) {
}
} else if (argument == "--allow-unverified-build") {
result.options.allow_unverified_build = true;
} else if (argument == "--force-run-latest") {
result.options.force_run_latest = true;
} else if (argument == "--launch-uri") {
if (!result.options.launch_request_json.empty()) {
result.error = "duplicate option: --launch-uri";
@@ -135,6 +137,14 @@ CommandLineParseResult ParseCommandLine(int argc, const char* const argv[]) {
return result;
}
}
if (result.options.force_run_latest &&
(!result.options.roblox_library_path.empty() ||
result.options.window_mode != WindowMode::kUnspecified ||
!result.options.graphics_backend.empty() ||
result.options.allow_unverified_build ||
!result.options.launch_request_json.empty())) {
result.error = "--force-run-latest must be used on its own";
}
return result;
}
@@ -242,6 +252,8 @@ std::string CommandLineUsage(const std::string& program_name) {
"angle-vulkan (default: direct-vulkan)\n"
<< " --allow-unverified-build Run a known but unverified Build-ID "
"profile\n"
<< " --force-run-latest Download and run the provider latest once "
"without approval; it is not activated\n"
<< " --launch-uri <uri> Join from a roblox: or roblox-player: "
"website link\n"
<< " --help, -h Show this help\n\n"
+13 -5
View File
@@ -249,15 +249,20 @@ std::vector<std::string> ChildEnvironment(const RuntimePaths& paths,
} // namespace
PayloadUpdatePreflightResult RunPayloadUpdatePreflight(
const Environment& environment, const RuntimePaths& paths) {
const Environment& environment, const RuntimePaths& paths,
bool force_run_latest) {
PayloadUpdatePreflightResult result;
if (Enabled(environment, "MOCKTAIL_SKIP_UPDATE_CHECK") ||
environment.HasNonEmpty("ROBLOX_LIB_PATH")) {
if (!force_run_latest &&
(Enabled(environment, "MOCKTAIL_SKIP_UPDATE_CHECK") ||
environment.HasNonEmpty("ROBLOX_LIB_PATH"))) {
return result;
}
const std::filesystem::path helper = UpdateHelper(environment);
if (helper.empty()) {
if (force_run_latest) {
result.error = "cannot locate the Mocktail update helper";
}
return result;
}
@@ -265,7 +270,9 @@ PayloadUpdatePreflightResult RunPayloadUpdatePreflight(
std::string helper_string = helper.string();
char update[] = "update";
char startup_preflight[] = "--startup-preflight";
char* arguments[] = {helper_string.data(), update, startup_preflight,
char force_latest[] = "--force-run-latest";
char* arguments[] = {helper_string.data(), update,
force_run_latest ? force_latest : startup_preflight,
nullptr};
UpdateProgressMonitor progress = UpdateProgressMonitor::Start(environment);
std::vector<std::string> child_environment =
@@ -318,7 +325,8 @@ PayloadUpdatePreflightResult RunPayloadUpdatePreflight(
return result;
}
if (!WIFEXITED(child_status) || WEXITSTATUS(child_status) != 0) {
result.error = "Roblox update preflight failed";
result.error = force_run_latest ? "forced latest Roblox run failed"
: "Roblox update preflight failed";
}
return result;
}
+12
View File
@@ -311,11 +311,23 @@ int main(int argc, char** argv) {
request.check_latest = false;
} else if (option == "--skip-canary") {
request.run_canary = false;
} else if (option == "--force-run-latest") {
request.force_run_latest = true;
} else {
std::cerr << "[native-updater] unknown option: " << option << '\n';
return 2;
}
}
if (request.force_run_latest &&
(request.startup_preflight || !request.check_latest || !request.run_canary)) {
std::cerr << "[native-updater] --force-run-latest cannot be combined with "
"--startup-preflight, --no-latest-check, or --skip-canary\n";
return 2;
}
if (request.force_run_latest) {
std::cerr << "[native-updater] WARNING: latest Roblox will run once without "
"approval and will not become the active payload\n";
}
const mocktail::update::UpdateResult updated =
mocktail::update::RunUpdate(paths, request);
for (const std::string& warning : updated.warnings) {
+162
View File
@@ -0,0 +1,162 @@
#include "update/unsafe_latest_runner.h"
#include <spawn.h>
#include <sys/wait.h>
#include <unistd.h>
#include <array>
#include <cerrno>
#include <cstring>
#include <filesystem>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
extern char** environ;
namespace mocktail::update {
namespace {
constexpr std::array<std::string_view, 10> kOverriddenVariables = {
"ROBLOX_LIB_PATH",
"MOCKTAIL_ASSET_ROOT",
"MOCKTAIL_ASSET_PATH",
"MOCKTAIL_COMPATIBILITY_MANIFEST",
"MOCKTAIL_HOST_ABI_PROFILE_FILE",
"MOCKTAIL_HOST_ABI_CANARY",
"MOCKTAIL_ALLOW_CANDIDATE_HOST_ABI",
"MOCKTAIL_HOST_ABI_APPROVAL_RECEIPT",
"MOCKTAIL_SKIP_UPDATE_CHECK",
"MOCKTAIL_UNSAFE_LATEST",
};
bool IsEnvironmentEntry(std::string_view entry, std::string_view name) {
return entry.size() > name.size() && entry[name.size()] == '=' &&
entry.substr(0, name.size()) == name;
}
bool IsOverridden(std::string_view entry) {
for (const std::string_view name : kOverriddenVariables) {
if (IsEnvironmentEntry(entry, name)) return true;
}
return false;
}
bool IsRegularFile(const std::filesystem::path& path) {
std::error_code error;
const std::filesystem::file_status status =
std::filesystem::symlink_status(path, error);
return !error && std::filesystem::is_regular_file(status) &&
!std::filesystem::is_symlink(status);
}
bool IsExecutableRegularFile(const std::filesystem::path& path) {
return IsRegularFile(path) && access(path.c_str(), X_OK) == 0;
}
std::vector<std::string> CurrentEnvironment() {
std::vector<std::string> result;
for (char** entry = environ; entry != nullptr && *entry != nullptr; ++entry) {
result.emplace_back(*entry);
}
return result;
}
std::vector<std::string> BuildEnvironment(
const UnsafeLatestRunOptions& options) {
const std::vector<std::string> inherited =
options.inherited_environment.empty() ? CurrentEnvironment()
: options.inherited_environment;
std::vector<std::string> result;
result.reserve(inherited.size() + 10U);
for (const std::string& entry : inherited) {
if (entry.find('\0') == std::string::npos && !IsOverridden(entry)) {
result.push_back(entry);
}
}
const auto add = [&](std::string_view name,
const std::filesystem::path& value) {
result.push_back(std::string(name) + "=" + value.string());
};
add("ROBLOX_LIB_PATH", options.payload_directory / "libroblox.so");
add("MOCKTAIL_ASSET_ROOT", options.payload_directory / "assets");
add("MOCKTAIL_ASSET_PATH", options.payload_directory / "assets/content");
add("MOCKTAIL_COMPATIBILITY_MANIFEST", options.compatibility_manifest);
result.push_back("MOCKTAIL_SKIP_UPDATE_CHECK=1");
result.push_back("MOCKTAIL_UNSAFE_LATEST=1");
if (!options.host_abi_profile.empty()) {
add("MOCKTAIL_HOST_ABI_PROFILE_FILE", options.host_abi_profile);
result.push_back("MOCKTAIL_HOST_ABI_CANARY=1");
result.push_back("MOCKTAIL_ALLOW_CANDIDATE_HOST_ABI=1");
}
return result;
}
} // namespace
UnsafeLatestRunResult RunUnsafeLatestCandidate(
const UnsafeLatestRunOptions& options) {
UnsafeLatestRunResult result;
if (!IsExecutableRegularFile(options.runtime_binary)) {
result.error = "unsafe latest runtime is not an executable regular file";
return result;
}
if (!IsRegularFile(options.payload_directory / "libroblox.so")) {
result.error = "unsafe latest payload library is not a regular file";
return result;
}
if (!IsRegularFile(options.compatibility_manifest)) {
result.error = "unsafe latest compatibility manifest is not a regular file";
return result;
}
if (!options.host_abi_profile.empty() &&
!IsRegularFile(options.host_abi_profile)) {
result.error = "unsafe latest HostAbi profile is not a regular file";
return result;
}
std::vector<std::string> environment = BuildEnvironment(options);
std::vector<char*> environment_pointers;
environment_pointers.reserve(environment.size() + 1U);
for (std::string& entry : environment)
environment_pointers.push_back(entry.data());
environment_pointers.push_back(nullptr);
std::string binary = options.runtime_binary.string();
char windowed[] = "--windowed";
char allow_unverified[] = "--allow-unverified-build";
char* arguments[] = {
binary.data(), windowed,
options.host_abi_profile.empty() ? nullptr : allow_unverified, nullptr};
pid_t child = -1;
const int spawn_status =
posix_spawn(&child, options.runtime_binary.c_str(), nullptr, nullptr,
arguments, environment_pointers.data());
if (spawn_status != 0) {
result.error = "cannot start unsafe latest Roblox: " +
std::string(std::strerror(spawn_status));
return result;
}
int status = 0;
while (waitpid(child, &status, 0) < 0) {
if (errno == EINTR) continue;
result.error = "cannot wait for unsafe latest Roblox: " +
std::string(std::strerror(errno));
return result;
}
if (!WIFEXITED(status)) {
result.exit_code = 128 + (WIFSIGNALED(status) ? WTERMSIG(status) : 0);
result.error = "unsafe latest Roblox terminated before a normal exit";
return result;
}
result.exit_code = WEXITSTATUS(status);
if (result.exit_code != 0) {
result.error = "unsafe latest Roblox exited with status " +
std::to_string(result.exit_code);
}
return result;
}
} // namespace mocktail::update
+110
View File
@@ -22,6 +22,7 @@
#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"
namespace mocktail::update {
@@ -277,6 +278,109 @@ ReferenceProfile ResolveReference(const UpdatePaths& paths,
return result;
}
UpdateResult RunUnsafeLatest(
const UpdatePaths& paths, const UpdateRequest& request,
const std::vector<SupportedPayloadProfile>& profiles,
const SupportedPayloadProfile& preferred,
const PayloadStoreResult& installed, PayloadStore* store) {
UpdateResult result;
if (request.startup_preflight || !request.check_latest) {
result.error =
"unsafe latest launch requires an explicit latest-version check";
return result;
}
ApkPureProvider provider;
Progress(request.progress_fd, "Checking latest Roblox...");
const ProviderVersion latest = provider.CheckLatest();
if (!latest) {
result.error = "cannot check latest Roblox for unsafe launch: " +
latest.error;
return result;
}
std::string workspace_error;
const std::filesystem::path workspace = UniqueDirectory(
paths.cache_root / "downloads/native-updater", &workspace_error);
if (!workspace_error.empty()) {
result.error = workspace_error;
return result;
}
std::error_code filesystem_error;
const auto cleanup = [&]() {
std::filesystem::remove_all(workspace, filesystem_error);
};
ExpectedPayloadIdentity identity;
identity.version_name = latest.version_name;
identity.version_code = latest.version_code;
const std::optional<SupportedPayloadProfile> exact =
FindSupportedProfile(profiles, identity.version_name, identity.version_code);
if (exact.has_value()) identity.exact_build_id = exact->elf_build_id;
Candidate candidate = FindStagedCandidate(paths, identity, exact.has_value());
if (!candidate) {
candidate = DownloadCandidate(&provider, store, identity, exact.has_value(),
paths, workspace / "candidate",
request.progress_fd);
}
if (!candidate) {
result.error = "cannot prepare latest Roblox for unsafe launch: " +
candidate.error;
cleanup();
return result;
}
if (!candidate.exact_supported) {
const ReferenceProfile reference = ResolveReference(
paths, installed, preferred, &provider, store, workspace,
request.progress_fd);
if (!reference) {
result.error = "cannot prepare latest Roblox for unsafe launch: " +
reference.error;
cleanup();
return result;
}
HostAbiDerivationOptions derivation;
derivation.reference_library = reference.library;
derivation.reference_profile = reference.profile;
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);
if (!derived) {
result.error = "cannot derive latest Roblox HostAbi profile: " +
derived.error;
cleanup();
return result;
}
candidate.profile = derived.profile;
candidate.compatibility = derived.compatibility_manifest;
}
// Staging preserves the immutable APK for inspection, but this path must
// never promote it or alter the normal current payload.
UnsafeLatestRunOptions launch;
launch.runtime_binary = paths.runtime_binary;
launch.payload_directory = candidate.staged.payload_directory;
launch.compatibility_manifest = candidate.exact_supported
? paths.compatibility_manifest
: candidate.compatibility;
launch.host_abi_profile = candidate.profile;
Progress(request.progress_fd, "Launching unapproved latest Roblox...");
const UnsafeLatestRunResult launched = RunUnsafeLatestCandidate(launch);
result.payload_id = candidate.staged.payload_id;
if (!launched) {
result.error = "unsafe latest Roblox " + candidate.staged.version_name +
" did not complete: " + launched.error;
cleanup();
return result;
}
result.message = "unsafe latest Roblox " + candidate.staged.version_name +
" exited successfully; active payload was not changed";
cleanup();
return result;
}
bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
bool run_canary,
CanaryGraphicsBackend graphics_backend,
@@ -383,6 +487,12 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
} else if (!current) {
installed = store.InspectCurrent();
}
if (request.force_run_latest) {
UpdateResult unsafe = RunUnsafeLatest(paths, request, catalog.profiles,
*preferred, installed, &store);
unsafe.warnings = std::move(result.warnings);
return unsafe;
}
if (!configured.config.automatic && request.startup_preflight) {
if (current) {
result.payload_id = current.payload_id;
+2 -1
View File
@@ -267,7 +267,8 @@ TEST(CommandLineTest, UsageContainsEverySupportedOption) {
EXPECT_NE(usage.find("mocktail-test"), std::string::npos);
for (const char* option :
{"--roblox-lib", "--headless", "--windowed", "--graphics",
"--allow-unverified-build", "--launch-uri", "--help"}) {
"--allow-unverified-build", "--force-run-latest", "--launch-uri",
"--help"}) {
EXPECT_NE(usage.find(option), std::string::npos) << option;
}
EXPECT_EQ(usage.find("--login"), std::string::npos);
+9 -1
View File
@@ -28,7 +28,8 @@ int IntegerEnvironment(const char* name, int fallback) {
int main(int argc, char** argv) {
if (argc != 3 || std::string_view(argv[1]) != "update" ||
std::string_view(argv[2]) != "--startup-preflight") {
(std::string_view(argv[2]) != "--startup-preflight" &&
std::string_view(argv[2]) != "--force-run-latest")) {
return 64;
}
const char* capture = Environment("MOCKTAIL_TEST_UPDATE_CAPTURE");
@@ -40,6 +41,13 @@ int main(int argc, char** argv) {
<< Environment("MOCKTAIL_STATE_ROOT") << '\n';
if (!output) return 65;
}
const char* argument_capture =
Environment("MOCKTAIL_TEST_UPDATE_ARGUMENT_CAPTURE");
if (argument_capture[0] != '\0') {
std::ofstream output(argument_capture);
output << argv[2] << '\n';
if (!output) return 67;
}
const int progress = IntegerEnvironment("MOCKTAIL_UPDATE_PROGRESS_FD", -1);
if (progress >= 0) {
constexpr std::string_view packet = "PDownloading Roblox...";
+61
View File
@@ -1,5 +1,6 @@
#include <gtest/gtest.h>
#include <minizip/zip.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cstdlib>
@@ -16,6 +17,7 @@
#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/zip_archive.h"
@@ -161,6 +163,65 @@ TEST(ApkPureProviderTest, ParsesExactVersionIdentityAndTrustedUrl) {
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));
+16
View File
@@ -101,6 +101,22 @@ TEST(PayloadUpdatePreflightTest, RunsNativeUpdater) {
EXPECT_TRUE(result.attempted);
}
TEST(PayloadUpdatePreflightTest, RunsForceLatestThroughNativeUpdater) {
TemporaryDirectory temporary;
const std::filesystem::path capture = temporary.root() / "argument";
ScopedVariable capture_variable("MOCKTAIL_TEST_UPDATE_ARGUMENT_CAPTURE",
capture.string());
const MapEnvironment environment = NativeEnvironment();
const RuntimePaths paths = RuntimePaths::FromEnvironment(environment);
const auto result = RunPayloadUpdatePreflight(environment, paths, true);
ASSERT_TRUE(result) << result.error;
EXPECT_TRUE(result.attempted);
EXPECT_EQ(ReadLines(capture),
std::vector<std::string>({"--force-run-latest"}));
}
TEST(PayloadUpdatePreflightTest, RunsPackagedUpdaterFromProjectBin) {
TemporaryDirectory temporary;
const std::filesystem::path project_root = temporary.root() / "portable";