diff --git a/CMakeLists.txt b/CMakeLists.txt index ba097e3..f94cbbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -215,6 +215,7 @@ pkg_check_modules(WEBKITGTK6 REQUIRED IMPORTED_TARGET webkitgtk-6.0) add_library(mocktail_update STATIC src/update/android_manifest.cc src/update/apk_bundle.cc + src/update/apk_provider.cc src/update/apk_signature.cc src/update/apkpure_provider.cc src/update/candidate_approval.cc diff --git a/config/mocktail.example.yaml b/config/mocktail.example.yaml index ccfb127..628feba 100644 --- a/config/mocktail.example.yaml +++ b/config/mocktail.example.yaml @@ -118,7 +118,9 @@ updates: # backend, and promote only on success. An existing current payload is # preserved when probation fails. automatic: true - # String (default: apk-pure): direct x86_64 APK provider. + # String (default: apk-pure): direct x86_64 APK provider selection. Both + # accepted values resolve the provider chain, which currently starts with + # APKPure. Supported values: auto, apk-pure. source: apk-pure # Reserved for desktop update integrations. `mocktail_updater` itself never # launches another process after changing the active payload. diff --git a/include/update/apk_provider.h b/include/update/apk_provider.h new file mode 100644 index 0000000..fa11b45 --- /dev/null +++ b/include/update/apk_provider.h @@ -0,0 +1,69 @@ +#ifndef MOCKTAIL_UPDATE_APK_PROVIDER_H_ +#define MOCKTAIL_UPDATE_APK_PROVIDER_H_ + +#include +#include +#include +#include +#include +#include + +namespace mocktail::update { + +struct ProviderVersion { + std::string version_name; + std::uint64_t version_code = 0; + std::string error; + + explicit operator bool() const { return error.empty(); } +}; + +struct ProviderDownloadResult { + std::vector archives; + // Provider that produced the archives, so a stored payload can be traced + // back to where it came from. + std::string source; + std::string error; + + explicit operator bool() const { return error.empty(); } +}; + +// A source of Roblox APK archives. Providers are interchangeable: whichever +// one answers first wins, and every archive still passes the same signature, +// Build-ID, and ABI verification afterwards. +class ApkProvider { + public: + virtual ~ApkProvider() = default; + + // Short stable identifier, recorded in payload receipts and error messages. + virtual std::string_view name() const = 0; + + // Latest published version. A provider that only serves pinned versions + // reports an error here and stays usable through DownloadExact. + virtual ProviderVersion CheckLatest() const = 0; + + virtual ProviderDownloadResult DownloadExact( + std::string_view version, const std::filesystem::path& output_directory, + int progress_fd) const = 0; +}; + +// Tries every provider in order. One unreachable provider is the most common +// first-run failure, and on its own it must not fail the whole update. +class ProviderChain final : public ApkProvider { + public: + void Add(std::unique_ptr provider); + bool empty() const { return providers_.empty(); } + + std::string_view name() const override { return "provider-chain"; } + ProviderVersion CheckLatest() const override; + ProviderDownloadResult DownloadExact( + std::string_view version, const std::filesystem::path& output_directory, + int progress_fd) const override; + + private: + std::vector> providers_; +}; + +} // namespace mocktail::update + +#endif // MOCKTAIL_UPDATE_APK_PROVIDER_H_ diff --git a/include/update/apkpure_provider.h b/include/update/apkpure_provider.h index 0232342..664b34a 100644 --- a/include/update/apkpure_provider.h +++ b/include/update/apkpure_provider.h @@ -1,42 +1,30 @@ #ifndef MOCKTAIL_UPDATE_APKPURE_PROVIDER_H_ #define MOCKTAIL_UPDATE_APKPURE_PROVIDER_H_ -#include #include #include #include #include +#include "update/apk_provider.h" + namespace mocktail::update { -struct ProviderVersion { - std::string version_name; - std::uint64_t version_code = 0; - std::string error; - - explicit operator bool() const { return error.empty(); } -}; - -struct ProviderDownloadResult { - std::vector archives; - std::string error; - - explicit operator bool() const { return error.empty(); } -}; - ProviderVersion ParseApkPureLatestMetadata(std::string_view metadata); std::vector ParseApkPureExactDownloadUrls( std::string_view metadata, std::string_view version, std::string* error = nullptr); -class ApkPureProvider final { +class ApkPureProvider final : public ApkProvider { public: - ProviderVersion CheckLatest() const; + std::string_view name() const override { return "apk-pure"; } + + ProviderVersion CheckLatest() const override; ProviderDownloadResult DownloadExact( std::string_view version, const std::filesystem::path& output_directory, - int progress_fd = -1) const; + int progress_fd = -1) const override; }; } // namespace mocktail::update diff --git a/src/runtime/runtime_config_bootstrap.cc b/src/runtime/runtime_config_bootstrap.cc index 187e0ae..9b19471 100644 --- a/src/runtime/runtime_config_bootstrap.cc +++ b/src/runtime/runtime_config_bootstrap.cc @@ -136,7 +136,9 @@ updates: # backend, and promote only on success. An existing current payload is # preserved when probation fails. automatic: true - # String (default: apk-pure): direct x86_64 APK provider. + # String (default: apk-pure): direct x86_64 APK provider selection. Both + # accepted values resolve the provider chain, which currently starts with + # APKPure. Supported values: auto, apk-pure. source: apk-pure # Reserved for desktop update integrations. `mocktail_updater` itself never # launches another process after changing the active payload. diff --git a/src/update/apk_provider.cc b/src/update/apk_provider.cc new file mode 100644 index 0000000..e7c6af5 --- /dev/null +++ b/src/update/apk_provider.cc @@ -0,0 +1,61 @@ +#include "update/apk_provider.h" + +#include +#include + +namespace mocktail::update { +namespace { + +void AppendFailure(std::string* aggregate, std::string_view provider, + std::string_view failure) { + if (!aggregate->empty()) aggregate->append("; "); + aggregate->append(provider); + aggregate->append(": "); + aggregate->append(failure); +} + +} // namespace + +void ProviderChain::Add(std::unique_ptr provider) { + if (provider != nullptr) providers_.push_back(std::move(provider)); +} + +ProviderVersion ProviderChain::CheckLatest() const { + ProviderVersion result; + if (providers_.empty()) { + result.error = "no APK provider is configured"; + return result; + } + std::string failures; + for (const std::unique_ptr& provider : providers_) { + const ProviderVersion version = provider->CheckLatest(); + if (version) return version; + AppendFailure(&failures, provider->name(), version.error); + } + result.error = std::move(failures); + return result; +} + +ProviderDownloadResult ProviderChain::DownloadExact( + std::string_view version, const std::filesystem::path& output_directory, + int progress_fd) const { + ProviderDownloadResult result; + if (providers_.empty()) { + result.error = "no APK provider is configured"; + return result; + } + std::string failures; + for (const std::unique_ptr& provider : providers_) { + // Each provider gets its own directory: providers refuse to write into a + // directory another one already populated, and a failed attempt must not + // poison the next. + ProviderDownloadResult downloaded = provider->DownloadExact( + version, output_directory / provider->name(), progress_fd); + if (downloaded) return downloaded; + AppendFailure(&failures, provider->name(), downloaded.error); + } + result.error = std::move(failures); + return result; +} + +} // namespace mocktail::update diff --git a/src/update/apkpure_provider.cc b/src/update/apkpure_provider.cc index b8523b1..9a4011a 100644 --- a/src/update/apkpure_provider.cc +++ b/src/update/apkpure_provider.cc @@ -208,6 +208,7 @@ ProviderDownloadResult ApkPureProvider::DownloadExact( std::string_view version, const std::filesystem::path& output_directory, int progress_fd) const { ProviderDownloadResult result; + result.source = std::string(name()); if (version.empty() || version.size() > 128 || !std::all_of(version.begin(), version.end(), [](unsigned char character) { return VersionCharacter(character); diff --git a/src/update/update_config.cc b/src/update/update_config.cc index 6cc1d2e..6d057d6 100644 --- a/src/update/update_config.cc +++ b/src/update/update_config.cc @@ -167,8 +167,12 @@ UpdateConfigResult LoadUpdateConfig(const std::filesystem::path& path) { } } else if (key == "source") { result.config.source = Scalar(value); - if (result.config.source != "apk-pure") { - result.error = "updates.source must be apk-pure in the native updater"; + // `apk-pure` predates the fallback chain and still selects it, so an + // existing configuration file keeps working unchanged. + if (result.config.source != "apk-pure" && + result.config.source != "auto") { + result.error = + "updates.source must be auto or apk-pure in the native updater"; break; } } else { diff --git a/src/update/update_coordinator.cc b/src/update/update_coordinator.cc index 796d0dc..edaf9d6 100644 --- a/src/update/update_coordinator.cc +++ b/src/update/update_coordinator.cc @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include "compat/elf_build_id.h" #include "update/apk_bundle.h" +#include "update/apk_provider.h" #include "update/apkpure_provider.h" #include "update/compatibility_catalog.h" #include "update/host_abi_deriver.h" @@ -189,7 +191,15 @@ void RecordRejection(const UpdatePaths& paths, const Candidate& candidate, close(descriptor); } -Candidate DownloadCandidate(ApkPureProvider* provider, PayloadStore* store, +// Built once per run. Providers are consulted in order, so adding a second +// source is a one-line change here rather than a rewrite of the coordinator. +ProviderChain BuildProviderChain() { + ProviderChain chain; + chain.Add(std::make_unique()); + return chain; +} + +Candidate DownloadCandidate(const ApkProvider* provider, PayloadStore* store, const ExpectedPayloadIdentity& identity, bool exact_supported, const UpdatePaths& paths, const std::filesystem::path& workspace, @@ -207,7 +217,7 @@ Candidate DownloadCandidate(ApkPureProvider* provider, PayloadStore* store, Progress(progress_fd, "Verifying Roblox..."); const PreparedPayload prepared = PreparePayloadFromArchives( downloaded.archives, identity, paths.signing_trust_manifest, - workspace / "prepare", "apk-pure-native"); + workspace / "prepare", downloaded.source + "-native"); if (!prepared) { result.error = prepared.error; return result; @@ -230,7 +240,7 @@ struct ReferenceProfile { ReferenceProfile ResolveReference(const UpdatePaths& paths, const PayloadStoreResult& installed, const SupportedPayloadProfile& preferred, - ApkPureProvider* provider, + const ApkProvider* provider, PayloadStore* store, const std::filesystem::path& workspace, int progress_fd) { @@ -290,7 +300,7 @@ UpdateResult RunUnsafeLatest( return result; } - ApkPureProvider provider; + const ProviderChain provider = BuildProviderChain(); Progress(request.progress_fd, "Checking latest Roblox..."); const ProviderVersion latest = provider.CheckLatest(); if (!latest) { @@ -457,8 +467,9 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { result.error = configured.error; return result; } - if (configured.config.source != "apk-pure") { - result.error = "native updater supports only the direct APKPure provider"; + if (configured.config.source != "apk-pure" && + configured.config.source != "auto") { + result.error = "native updater supports only the direct APK providers"; return result; } const CompatibilityCatalogResult catalog = @@ -503,7 +514,7 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { "no runnable payload is installed and automatic updates are disabled"; return result; } - ApkPureProvider provider; + const ProviderChain provider = BuildProviderChain(); std::optional latest_version; if (request.check_latest) { Progress(request.progress_fd, "Checking Roblox..."); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9db44c0..f199549 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -56,6 +56,12 @@ target_link_libraries(native_update_test PRIVATE GTest::gtest_main ) +add_executable(apk_provider_test apk_provider_test.cc) +target_link_libraries(apk_provider_test PRIVATE + Mocktail::Update + GTest::gtest_main +) + add_executable(support_bundle_runtime_test support_bundle_runtime_test.cc) target_link_libraries(support_bundle_runtime_test PRIVATE Mocktail::Runtime @@ -807,6 +813,7 @@ gtest_discover_tests(jnivm_test) gtest_discover_tests(runtime_config_test) gtest_discover_tests(runtime_config_file_test) gtest_discover_tests(payload_update_preflight_test) +gtest_discover_tests(apk_provider_test) gtest_discover_tests(native_update_test) gtest_discover_tests(support_bundle_runtime_test) gtest_discover_tests(session_log_test) diff --git a/tests/apk_provider_test.cc b/tests/apk_provider_test.cc new file mode 100644 index 0000000..cb223a9 --- /dev/null +++ b/tests/apk_provider_test.cc @@ -0,0 +1,94 @@ +#include "update/apk_provider.h" + +#include + +#include +#include +#include +#include +#include + +namespace mocktail::update { +namespace { + +class StubProvider final : public ApkProvider { + public: + StubProvider(std::string name, std::string failure) + : name_(std::move(name)), failure_(std::move(failure)) {} + + std::string_view name() const override { return name_; } + + ProviderVersion CheckLatest() const override { + ProviderVersion version; + if (failure_.empty()) { + version.version_name = "2.727.1199"; + version.version_code = 2628; + } else { + version.error = failure_; + } + return version; + } + + ProviderDownloadResult DownloadExact(std::string_view, + const std::filesystem::path& directory, + int) const override { + ProviderDownloadResult result; + result.source = name_; + if (failure_.empty()) { + result.archives.push_back(directory / "candidate.apk"); + } else { + result.error = failure_; + } + return result; + } + + private: + std::string name_; + std::string failure_; +}; + +TEST(ProviderChainTest, FallsBackToTheNextProvider) { + ProviderChain chain; + chain.Add(std::make_unique("apk-pure", + "api.pureapk.com request " + "returned status 503")); + chain.Add(std::make_unique("pinned-mirror", "")); + const ProviderDownloadResult downloaded = + chain.DownloadExact("2.725.1142", "/tmp/mocktail-chain", -1); + ASSERT_TRUE(downloaded) << downloaded.error; + EXPECT_EQ(downloaded.source, "pinned-mirror"); + ASSERT_EQ(downloaded.archives.size(), 1U); + // Each provider downloads into its own directory so a failed attempt cannot + // block the next one. + EXPECT_EQ(downloaded.archives.front(), + std::filesystem::path("/tmp/mocktail-chain/pinned-mirror/candidate.apk")); +} + +TEST(ProviderChainTest, ReportsEveryProviderWhenAllFail) { + ProviderChain chain; + chain.Add(std::make_unique("apk-pure", "returned status 503")); + chain.Add(std::make_unique("pinned-mirror", "no pinned source")); + const ProviderDownloadResult downloaded = + chain.DownloadExact("2.734.917", "/tmp/mocktail-chain", -1); + EXPECT_FALSE(downloaded); + EXPECT_EQ(downloaded.error, + "apk-pure: returned status 503; pinned-mirror: no pinned source"); +} + +TEST(ProviderChainTest, PrefersTheFirstProviderThatAnswers) { + ProviderChain chain; + chain.Add(std::make_unique("apk-pure", "")); + chain.Add(std::make_unique("pinned-mirror", "")); + const ProviderVersion latest = chain.CheckLatest(); + ASSERT_TRUE(latest) << latest.error; + EXPECT_EQ(latest.version_name, "2.727.1199"); +} + +TEST(ProviderChainTest, ReportsAnEmptyChain) { + const ProviderChain chain; + EXPECT_TRUE(chain.empty()); + EXPECT_EQ(chain.CheckLatest().error, "no APK provider is configured"); +} + +} // namespace +} // namespace mocktail::update diff --git a/tests/runtime_config_file_test.cc b/tests/runtime_config_file_test.cc index ee37d78..7216e6f 100644 --- a/tests/runtime_config_file_test.cc +++ b/tests/runtime_config_file_test.cc @@ -170,7 +170,11 @@ TEST(RuntimeConfigBootstrapTest, "payload is\n " "# preserved when probation fails.\n " "automatic: true", - "# String (default: apk-pure): direct x86_64 APK provider.\n " + "# String (default: apk-pure): direct x86_64 APK provider " + "selection. Both\n " + "# accepted values resolve the provider chain, which currently " + "starts with\n " + "# APKPure. Supported values: auto, apk-pure.\n " "source: apk-pure", "# Reserved for desktop update integrations. `mocktail_updater` " "itself never\n # launches another process after changing the "