diff --git a/include/moonbase/http_curl.hpp b/include/moonbase/http_curl.hpp index eec0169..ad3fadb 100644 --- a/include/moonbase/http_curl.hpp +++ b/include/moonbase/http_curl.hpp @@ -27,7 +27,7 @@ class curl_http_transport : public http_transport { { std::unique_ptr curl(curl_easy_init(), curl_easy_cleanup); if (!curl) { - throw api_error(0, "Could not initialize curl"); + throw api_error(0, "HTTP request to " + request.url + " failed: could not initialize curl"); } http_response response; @@ -64,7 +64,7 @@ class curl_http_transport : public http_transport { const auto result = curl_easy_perform(curl.get()); if (result != CURLE_OK) { - throw api_error(0, curl_easy_strerror(result)); + throw api_error(0, "HTTP request to " + request.url + " failed: " + curl_easy_strerror(result)); } curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &response.status_code); return response; diff --git a/include/moonbase/store.hpp b/include/moonbase/store.hpp index 2d04f07..6346971 100644 --- a/include/moonbase/store.hpp +++ b/include/moonbase/store.hpp @@ -1,11 +1,13 @@ #pragma once +#include #include #include #include #include #include #include +#include #include @@ -105,17 +107,27 @@ class file_license_store : public license_store { return std::nullopt; } + errno = 0; std::ifstream file(path_); if (!file) { - throw storage_error("Could not open local license file for reading"); + const int err = errno; + throw storage_error("Could not open local license file for reading: " + path_.string() + + errno_suffix(err)); } try { nlohmann::json json; file >> json; return json.get(); - } catch (const std::exception& ex) { - throw storage_error(std::string("Could not parse local license file: ") + ex.what()); + } catch (const std::exception&) { + // A corrupt / unparseable license file is useless and would otherwise + // throw on every load, blocking re-activation. Remove it (best-effort, + // after closing the handle so Windows can unlink it) and report no + // stored license, so a fresh activation can be written in its place. + file.close(); + std::error_code ec; + std::filesystem::remove(path_, ec); + return std::nullopt; } } @@ -123,14 +135,26 @@ class file_license_store : public license_store { { const auto parent = path_.parent_path(); if (!parent.empty()) { - std::filesystem::create_directories(parent); + std::error_code ec; + std::filesystem::create_directories(parent, ec); + if (ec) { + throw storage_error("Could not create license directory " + parent.string() + + ": " + ec.message()); + } } + errno = 0; std::ofstream file(path_, std::ios::trunc); if (!file) { - throw storage_error("Could not open local license file for writing"); + const int err = errno; + throw storage_error("Could not open local license file for writing: " + path_.string() + + errno_suffix(err)); } file << nlohmann::json(value).dump(2); + file.flush(); + if (!file) { + throw storage_error("Could not write local license file: " + path_.string()); + } } void delete_local_license() override @@ -138,7 +162,8 @@ class file_license_store : public license_store { std::error_code error; std::filesystem::remove(path_, error); if (error) { - throw storage_error("Could not delete local license file: " + error.message()); + throw storage_error("Could not delete local license file " + path_.string() + ": " + + error.message()); } } @@ -157,6 +182,17 @@ class file_license_store : public license_store { } private: + // Best-effort OS reason for a failed std::fstream open. Stream failures don't + // portably set errno, so callers reset errno to 0 first and we only append a + // reason when something was actually recorded. + static std::string errno_suffix(int err) + { + if (err == 0) { + return {}; + } + return " (" + std::generic_category().message(err) + ")"; + } + class file_store_lock : public store_lock_guard { public: explicit file_store_lock(const std::filesystem::path& path) : lock_(path) {} diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index 3b84fd3..52bb59a 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -9,6 +9,19 @@ namespace moonbase::juce_integration { namespace { constexpr int kPollIntervalMs = 1500; + +// Diagnostic-only error text. For transport failures (moonbase::api_error) the +// SDK stashes actionable guidance (e.g. the macOS network entitlement hint) in +// the detail field; append it so it reaches onDiagnostic without ever appearing +// in the friendly, user-facing screen text. +juce::String describeError(const std::exception& ex) +{ + juce::String message = ex.what(); + if (const auto* api = dynamic_cast(&ex)) + if (! api->detail().empty()) + message << " (" << api->detail() << ")"; + return message; +} } // namespace ActivationController::ActivationController(ActivationConfig config) @@ -177,7 +190,7 @@ void ActivationController::start() catch (const std::exception& ex) { // Invalid / unreachable-past-grace -> locked. - diag = juce::String("Re-validating stored license failed: ") + ex.what(); + diag = juce::String("Re-validating stored license failed: ") + describeError(ex); result = std::nullopt; } } @@ -185,7 +198,7 @@ void ActivationController::start() } catch (const std::exception& ex) { - diag = juce::String("Loading stored license failed: ") + ex.what(); + diag = juce::String("Loading stored license failed: ") + describeError(ex); result = std::nullopt; } @@ -228,7 +241,7 @@ void ActivationController::beginOnlineActivation() } catch (const std::exception& ex) { - error = ex.what(); + error = describeError(ex); } juce::MessageManager::callAsync([safe, generation, request, error]() mutable @@ -239,9 +252,12 @@ void ActivationController::beginOnlineActivation() if (! request) { + // Full reason (incl. the entitlement hint) goes to the developer + // sink; the user sees a friendly, fixed prompt to retry. self->emitDiagnostic("request_activation failed: " + error); self->setScreen(Screen::Error, - "Couldn't reach Moonbase to start activation. " + error); + "Couldn't reach Moonbase to start activation. " + "Check your internet connection and try again."); return; } @@ -315,7 +331,7 @@ void ActivationController::refreshLicense(bool force, std::function } catch (const std::exception& ex) { - diag = ex.what(); + diag = describeError(ex); } juce::MessageManager::callAsync([safe, generation, refreshed, expired, currentLicense, wasTrial, @@ -395,7 +411,7 @@ void ActivationController::timerCallback() catch (const std::exception& ex) { // Transient transport/5xx error — keep polling. - transient = ex.what(); + transient = describeError(ex); } juce::MessageManager::callAsync([safe, generation, fulfilled, fatal, error, transient]() mutable @@ -563,7 +579,7 @@ void ActivationController::deactivate() catch (const moonbase::operation_not_supported_error& ex) { outcome = Outcome::NotRevokable; diag = ex.what(); } catch (const moonbase::license_invalid_error&) { outcome = Outcome::Revoked; } catch (const moonbase::license_expired_error&) { outcome = Outcome::Revoked; } - catch (const std::exception& ex) { outcome = Outcome::Unreachable; diag = ex.what(); } + catch (const std::exception& ex) { outcome = Outcome::Unreachable; diag = describeError(ex); } const int outcomeCode = static_cast(outcome); juce::MessageManager::callAsync([safe, generation, outcomeCode, activationId, diag]() mutable diff --git a/modules/moonbase_licensing/juce/juce_http_transport.h b/modules/moonbase_licensing/juce/juce_http_transport.h index 5e34457..ec41ee5 100644 --- a/modules/moonbase_licensing/juce/juce_http_transport.h +++ b/modules/moonbase_licensing/juce/juce_http_transport.h @@ -80,8 +80,19 @@ class juce_http_transport : public moonbase::http_transport // Match curl_http_transport: a transport-level failure surfaces as // api_error with status 0, which the SDK's grace-period logic treats // as "unreachable" (this also covers a cancelled connect()). - throw moonbase::api_error( - 0, "HTTP request to " + request.url + " failed (could not connect)"); + // + // The friendly message stays user-safe; the macOS sandbox hint goes in + // the detail field so it reaches developers (via onDiagnostic) without + // surfacing in the plugin UI. A status-0 connect failure inside a + // sandboxed Apple build is most often the missing network entitlement. + juce::String message = "Couldn't connect to " + request.url + + " (check the internet connection or firewall)"; + juce::String detail; + #if JUCE_MAC || JUCE_IOS + detail = "If this build runs sandboxed (AUv3 / Mac App Store), enable the " + "com.apple.security.network.client entitlement."; + #endif + throw moonbase::api_error(0, message.toStdString(), {}, detail.toStdString()); } moonbase::http_response response; diff --git a/modules/moonbase_licensing/moonbase/http_curl.hpp b/modules/moonbase_licensing/moonbase/http_curl.hpp index eec0169..ad3fadb 100644 --- a/modules/moonbase_licensing/moonbase/http_curl.hpp +++ b/modules/moonbase_licensing/moonbase/http_curl.hpp @@ -27,7 +27,7 @@ class curl_http_transport : public http_transport { { std::unique_ptr curl(curl_easy_init(), curl_easy_cleanup); if (!curl) { - throw api_error(0, "Could not initialize curl"); + throw api_error(0, "HTTP request to " + request.url + " failed: could not initialize curl"); } http_response response; @@ -64,7 +64,7 @@ class curl_http_transport : public http_transport { const auto result = curl_easy_perform(curl.get()); if (result != CURLE_OK) { - throw api_error(0, curl_easy_strerror(result)); + throw api_error(0, "HTTP request to " + request.url + " failed: " + curl_easy_strerror(result)); } curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &response.status_code); return response; diff --git a/modules/moonbase_licensing/moonbase/store.hpp b/modules/moonbase_licensing/moonbase/store.hpp index 2d04f07..6346971 100644 --- a/modules/moonbase_licensing/moonbase/store.hpp +++ b/modules/moonbase_licensing/moonbase/store.hpp @@ -1,11 +1,13 @@ #pragma once +#include #include #include #include #include #include #include +#include #include @@ -105,17 +107,27 @@ class file_license_store : public license_store { return std::nullopt; } + errno = 0; std::ifstream file(path_); if (!file) { - throw storage_error("Could not open local license file for reading"); + const int err = errno; + throw storage_error("Could not open local license file for reading: " + path_.string() + + errno_suffix(err)); } try { nlohmann::json json; file >> json; return json.get(); - } catch (const std::exception& ex) { - throw storage_error(std::string("Could not parse local license file: ") + ex.what()); + } catch (const std::exception&) { + // A corrupt / unparseable license file is useless and would otherwise + // throw on every load, blocking re-activation. Remove it (best-effort, + // after closing the handle so Windows can unlink it) and report no + // stored license, so a fresh activation can be written in its place. + file.close(); + std::error_code ec; + std::filesystem::remove(path_, ec); + return std::nullopt; } } @@ -123,14 +135,26 @@ class file_license_store : public license_store { { const auto parent = path_.parent_path(); if (!parent.empty()) { - std::filesystem::create_directories(parent); + std::error_code ec; + std::filesystem::create_directories(parent, ec); + if (ec) { + throw storage_error("Could not create license directory " + parent.string() + + ": " + ec.message()); + } } + errno = 0; std::ofstream file(path_, std::ios::trunc); if (!file) { - throw storage_error("Could not open local license file for writing"); + const int err = errno; + throw storage_error("Could not open local license file for writing: " + path_.string() + + errno_suffix(err)); } file << nlohmann::json(value).dump(2); + file.flush(); + if (!file) { + throw storage_error("Could not write local license file: " + path_.string()); + } } void delete_local_license() override @@ -138,7 +162,8 @@ class file_license_store : public license_store { std::error_code error; std::filesystem::remove(path_, error); if (error) { - throw storage_error("Could not delete local license file: " + error.message()); + throw storage_error("Could not delete local license file " + path_.string() + ": " + + error.message()); } } @@ -157,6 +182,17 @@ class file_license_store : public license_store { } private: + // Best-effort OS reason for a failed std::fstream open. Stream failures don't + // portably set errno, so callers reset errno to 0 first and we only append a + // reason when something was actually recorded. + static std::string errno_suffix(int err) + { + if (err == 0) { + return {}; + } + return " (" + std::generic_category().message(err) + ")"; + } + class file_store_lock : public store_lock_guard { public: explicit file_store_lock(const std::filesystem::path& path) : lock_(path) {} diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index f06cfa9..30fce68 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -497,6 +497,42 @@ TEST_CASE("diagnostics carry the underlying detail behind a friendly error") responseFile.deleteFile(); } +namespace { +// Models a connect failure that carries developer guidance in the api_error +// detail field - exactly what juce_http_transport sets for the macOS sandbox +// network entitlement. Lets us assert the hint is routed to diagnostics only. +struct hinted_failure_transport : moonbase::http_transport +{ + moonbase::http_response send(const moonbase::http_request&) override + { + throw moonbase::api_error( + 0, "Couldn't connect to https://demo.moonbase.sh", {}, + "enable the com.apple.security.network.client entitlement"); + } +}; +} // namespace + +TEST_CASE("a transport failure routes the entitlement hint to diagnostics, not the user screen") +{ + controller_fixture fx; + juce::StringArray diags; + fx.config.onDiagnostic = [&](const juce::String& m) { diags.add(m); }; + + auto licensing = std::make_shared( + fx.config.toLicensingOptions(), fx.store, fx.fingerprint, + std::make_shared()); + + ActivationController controller(fx.config, licensing); + controller.beginOnlineActivation(); + + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Error; })); + // The user sees a friendly, fixed prompt - never the entitlement detail. + CHECK(controller.statusMessage().containsIgnoreCase("Couldn't reach Moonbase")); + CHECK_FALSE(controller.statusMessage().containsIgnoreCase("entitlement")); + // The developer sink gets the full reason, including the detail hint. + CHECK(diags.joinIntoString(" ").containsIgnoreCase("entitlement")); +} + //============================================================================== // Online re-validation (refresh entitlements after a purchase) //============================================================================== diff --git a/tests/store_tests.cpp b/tests/store_tests.cpp index fa12235..17faff1 100644 --- a/tests/store_tests.cpp +++ b/tests/store_tests.cpp @@ -3,9 +3,16 @@ #include #include #include +#include +#include #include #include +#ifndef _WIN32 + #include + #include +#endif + #include "moonbase/store.hpp" using namespace moonbase; @@ -37,6 +44,11 @@ license sample_license() return value; } +std::string unique_suffix() +{ + return std::to_string(std::chrono::system_clock::now().time_since_epoch().count()); +} + } // namespace TEST_CASE("memory_license_store round-trips and deletes") @@ -104,6 +116,33 @@ TEST_CASE("file_license_store round-trips and deletes") CHECK_FALSE(std::filesystem::exists(path)); } +TEST_CASE("file_license_store removes an unparseable license file and reports none") +{ + const auto path = std::filesystem::temp_directory_path() / + ("moonbase-cpp-corrupt-" + unique_suffix() + ".json"); + { + std::ofstream file(path); + file << "this is not valid json {{{"; + } + REQUIRE(std::filesystem::exists(path)); + + file_license_store store(path); + // A corrupt file reads as "no stored license" instead of throwing on every + // load... + CHECK_FALSE(store.load_local_license().has_value()); + // ...and is deleted so a fresh activation can be written on top. + CHECK_FALSE(std::filesystem::exists(path)); + + // A new license then round-trips cleanly over the cleared slot. + store.store_local_license(sample_license()); + auto loaded = store.load_local_license(); + REQUIRE(loaded.has_value()); + CHECK(loaded->id == "license-123"); + + std::error_code ec; + std::filesystem::remove(path, ec); +} + TEST_CASE("file_license_store::lock_for_update serializes concurrent acquirers") { const auto path = std::filesystem::temp_directory_path() / @@ -314,3 +353,98 @@ TEST_CASE("memory_license_store guard allows re-entrant load/store on the holdin store.delete_local_license(); CHECK_FALSE(store.load_local_license().has_value()); } + +TEST_CASE("file_license_store wraps a blocked directory creation in storage_error with the path") +{ + // Regression: create_directories used to run without an error_code, so a + // failure escaped as a raw std::filesystem::filesystem_error that consumers + // catching storage_error would miss. Place a regular file where store needs + // a directory so the parent can never be created. + const auto blocker = std::filesystem::temp_directory_path() / + ("moonbase-cpp-parent-is-file-" + unique_suffix()); + { + std::ofstream file(blocker); + file << "not a directory"; + } + REQUIRE(std::filesystem::is_regular_file(blocker)); + + // The license path is nested under the regular file, so create_directories + // of its parent (/nested) must fail. + const auto path = blocker / "nested" / "license.mb"; + file_license_store store(path); + + bool threw_storage_error = false; + std::string message; + try { + store.store_local_license(sample_license()); + } catch (const storage_error& ex) { + threw_storage_error = true; + message = ex.what(); + } + CHECK(threw_storage_error); + CHECK(message.find(blocker.string()) != std::string::npos); + + std::error_code ec; + std::filesystem::remove(blocker, ec); +} + +#ifndef _WIN32 +TEST_CASE("file_license_store reports a storage_error with the path when the license file is unreadable") +{ + // Permission bits don't constrain root, so the chmod below wouldn't deny us. + if (::geteuid() == 0) + return; + + const auto path = std::filesystem::temp_directory_path() / + ("moonbase-cpp-unreadable-" + unique_suffix() + ".json"); + { + file_license_store seed(path); + seed.store_local_license(sample_license()); + } + REQUIRE(::chmod(path.c_str(), 0) == 0); // strip all permissions + + file_license_store store(path); + bool threw = false; + std::string message; + try { + (void) store.load_local_license(); + } catch (const storage_error& ex) { + threw = true; + message = ex.what(); + } + CHECK(threw); + CHECK(message.find(path.string()) != std::string::npos); + + ::chmod(path.c_str(), 0600); // restore so cleanup can remove it + std::error_code ec; + std::filesystem::remove(path, ec); +} + +TEST_CASE("file_license_store reports a storage_error with the path when the directory is read-only") +{ + if (::geteuid() == 0) + return; + + const auto dir = std::filesystem::temp_directory_path() / + ("moonbase-cpp-ro-dir-" + unique_suffix()); + std::filesystem::create_directories(dir); + REQUIRE(::chmod(dir.c_str(), 0500) == 0); // r-x: cannot create files within + + const auto path = dir / "license.mb"; + file_license_store store(path); + bool threw = false; + std::string message; + try { + store.store_local_license(sample_license()); + } catch (const storage_error& ex) { + threw = true; + message = ex.what(); + } + CHECK(threw); + CHECK(message.find(path.string()) != std::string::npos); + + ::chmod(dir.c_str(), 0700); // restore so cleanup can recurse + std::error_code ec; + std::filesystem::remove_all(dir, ec); +} +#endif // _WIN32