diff --git a/CMakeLists.txt b/CMakeLists.txt index b7e8d34..c1f00e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,7 @@ if(MOONBASE_BUILD_TESTS) tests/client_tests.cpp tests/fingerprint_tests.cpp tests/licensing_tests.cpp + tests/process_dedup_tests.cpp tests/store_tests.cpp tests/validator_tests.cpp tests/live_tests.cpp) diff --git a/examples/juce/MoonbaseJuceBridge.h b/examples/juce/MoonbaseJuceBridge.h index 8b6870c..2443523 100644 --- a/examples/juce/MoonbaseJuceBridge.h +++ b/examples/juce/MoonbaseJuceBridge.h @@ -18,15 +18,18 @@ #pragma once +#include #include #include #include #include #include #include +#include #include #include #include +#include #include @@ -187,7 +190,7 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus juce::String websiteName = "moonbase.sh") : productId_(options.product_id), websiteName_(std::move(websiteName)), - licensing_(std::make_shared( + licensing_(getOrCreateLicensing( std::move(options), std::move(store), std::move(fingerprint))) { juce::RSAKey::createKeyPair(juceUnlockPublicKey_, juceUnlockPrivateKey_, 512); @@ -224,13 +227,10 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus ? licensing_->validate_token_online(stored->token) : licensing_->validate_token_local(stored->token); - // Persist refreshed token so the cadence/grace clock advances - // across restarts. Storage failures are non-fatal here. - if (validated.token != stored->token) - { - try { licensing_->store().store_local_license(validated); } - catch (const moonbase::storage_error&) {} - } + // validate_token_online persists the refreshed token itself so + // sibling plugin instances (including those in sandboxed sibling + // processes) observe the new validated_at on their next call. + // The local-only path doesn't refresh anything, so nothing to do. setUnlocked(std::move(validated)); return true; @@ -307,14 +307,17 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus // Marshals state mutation + callback to the message thread, gated on // the captured generation matching the current one. Stale calls do // nothing and silently drop the callback. + // + // Persistence is handled inside moonbase::licensing::validate_token_online + // (under its in-process mutex and the store's cross-process lock), so + // the message-thread continuation only updates JUCE state. auto deliver = [safeThis, generation](AsyncValidationResult result, - std::function cb, - bool persistRefreshed) mutable + std::function cb) mutable { juce::MessageManager::callAsync( [safeThis, generation, result = std::move(result), - cb = std::move(cb), persistRefreshed]() mutable + cb = std::move(cb)]() mutable { auto* self = safeThis.get(); if (self == nullptr @@ -325,14 +328,7 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus { case AsyncValidationOutcome::Refreshed: if (result.license) - { - if (persistRefreshed) - { - try { self->licensing_->store().store_local_license(*result.license); } - catch (const moonbase::storage_error&) {} - } self->setUnlocked(*result.license); - } break; case AsyncValidationOutcome::OfflineToken: if (result.license) @@ -360,7 +356,7 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus if (!stored) { deliver({AsyncValidationOutcome::NoStoredLicense, std::nullopt}, - std::move(onComplete), false); + std::move(onComplete)); return; } local = licensing_->validate_token_local(stored->token); @@ -368,21 +364,21 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus catch (const std::exception&) { deliver({AsyncValidationOutcome::LocalInvalid, std::nullopt}, - std::move(onComplete), false); + std::move(onComplete)); return; } if (local->method == moonbase::activation_method::offline) { deliver({AsyncValidationOutcome::OfflineToken, local}, - std::move(onComplete), false); + std::move(onComplete)); return; } // Optimistic unlock from the local check, marshalled to the message // thread (no callback yet). The final state may override this once // the online check resolves. - deliver({AsyncValidationOutcome::Refreshed, *local}, {}, false); + deliver({AsyncValidationOutcome::Refreshed, *local}, {}); // Step 2: online check on a background thread. auto licensingHandle = licensing_; @@ -390,35 +386,46 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus auto cb = std::move(onComplete); juce::Thread::launch( - [licensingHandle, token, deliver = std::move(deliver), - cb = std::move(cb)]() mutable + [licensingHandle, token, generation, safeThis, + deliver = std::move(deliver), cb = std::move(cb)]() mutable { + // Veto the SDK-side persist if our generation has been + // superseded (clearLicense / a fresh activation / another + // tryLoadStoredLicenseAsync). The SDK evaluates this while + // still holding both its in-process mutex and the cross- + // process file lock; any concurrent clearLicense() blocks on + // the same file lock and runs strictly after the predicate + // decision, so a stale refresh can never resurrect a cleared + // or replaced license on disk. + auto shouldPersist = [safeThis, generation]() -> bool { + auto* self = safeThis.get(); + return self != nullptr + && generation == self->validationGeneration_.load(); + }; + AsyncValidationResult result{AsyncValidationOutcome::Refreshed, std::nullopt}; - bool persistRefreshed = true; try { - auto refreshed = licensingHandle->validate_token_online(token); + auto refreshed = + licensingHandle->validate_token_online(token, shouldPersist); result = {AsyncValidationOutcome::Refreshed, std::move(refreshed)}; } catch (const moonbase::license_invalid_error&) { result = {AsyncValidationOutcome::LockedInvalid, std::nullopt}; - persistRefreshed = false; } catch (const moonbase::license_expired_error&) { result = {AsyncValidationOutcome::LockedExpired, std::nullopt}; - persistRefreshed = false; } catch (const std::exception&) { // validate_token_online only throws non-license exceptions // when grace has elapsed. result = {AsyncValidationOutcome::Unreachable, std::nullopt}; - persistRefreshed = false; } - deliver(std::move(result), std::move(cb), persistRefreshed); + deliver(std::move(result), std::move(cb)); }); } @@ -457,6 +464,10 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus auto clearMatchingLocal = [&] { try { + // Hold the update lock for load+delete so an in-flight + // validate_token_online in another instance can't persist + // between our read and our delete. + auto guard = licensing_->store().lock_for_update(); auto stored = licensing_->store().load_local_license(); if (stored && stored->activation_id == activationId) licensing_->store().delete_local_license(); @@ -593,8 +604,14 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus // cleanup. activation_id matching ensures a stale // revoke whose generation somehow still matches // can't wipe an unrelated cached license. + // + // Held under the cross-process update lock so a + // concurrent validate_token_online in another + // instance can't slip a persist between our load + // and our delete. try { + auto guard = licensingHandle->store().lock_for_update(); auto stored = licensingHandle->store().load_local_license(); if (stored && stored->activation_id == activationId) licensingHandle->store().delete_local_license(); @@ -641,6 +658,11 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus try { + // Under the update lock so an in-flight validate_token_online for + // the prior token doesn't race-persist over the newly-activated + // license. The validate's should_persist predicate also rejects + // its write because we already bumped the generation above. + auto guard = licensing_->store().lock_for_update(); licensing_->store().store_local_license(*fulfilled); } catch (const moonbase::storage_error&) @@ -660,7 +682,19 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus { ++validationGeneration_; // invalidate any in-flight async revalidation - try { licensing_->store().delete_local_license(); } + try + { + // Bumping the generation above is observed by an in-flight + // validate_token_online's should_persist predicate; the file + // lock here ensures the predicate decision and our delete are + // ordered relative to each other, so the validate either + // (a) sees the bump under its lock → skips persist → we delete, + // (b) finished persisting under its lock → we wait → we delete + // what it just wrote. + // Both terminate with the file in the cleared state. + auto guard = licensing_->store().lock_for_update(); + licensing_->store().delete_local_license(); + } catch (const moonbase::storage_error&) {} const juce::ScopedLock lock(stateLock_); @@ -834,6 +868,79 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus // callback if the value has changed by the time they run. std::atomic validationGeneration_{0}; + // Process-wide cache of moonbase::licensing instances. Multiple plugin + // instances loaded into the same DAW process share one SDK instance — + // including its in-process validate mutex — so a project-load thundering + // herd collapses to a single network call. (The SDK's cross-process file + // lock + store-reload handles dedup across sandboxed processes too.) + // + // The key combines every input that affects which backend, which signing + // key, and which on-disk slot the instance speaks to. Two bridges that + // happen to share a product_id but point at different tenants, public + // keys, store paths, or fingerprint providers each get their own SDK + // instance. Weak-ptr storage releases entries once their last bridge + // dies; the vector scan is O(N) over distinct active configurations, + // which is bounded by the number of products this process hosts. + struct LicensingCacheKey + { + std::string endpoint; + std::string product_id; + std::string public_key; + std::optional account_id; + moonbase::license_store* store_ptr; + moonbase::fingerprint_provider* fingerprint_ptr; + + bool operator==(const LicensingCacheKey& other) const noexcept + { + return endpoint == other.endpoint + && product_id == other.product_id + && public_key == other.public_key + && account_id == other.account_id + && store_ptr == other.store_ptr + && fingerprint_ptr == other.fingerprint_ptr; + } + }; + + static std::shared_ptr getOrCreateLicensing( + moonbase::licensing_options options, + std::shared_ptr store, + std::shared_ptr fingerprint) + { + static std::mutex cacheMutex; + static std::vector>> cache; + + const LicensingCacheKey key{ + options.endpoint, + options.product_id, + options.public_key, + options.account_id, + store.get(), + fingerprint.get()}; + + std::lock_guard lock(cacheMutex); + + // Prune dead entries opportunistically — keeps the scan bounded as + // bridges come and go over the process lifetime. + cache.erase( + std::remove_if( + cache.begin(), cache.end(), + [](const auto& entry) { return entry.second.expired(); }), + cache.end()); + + for (auto& entry : cache) { + if (entry.first == key) { + if (auto existing = entry.second.lock()) + return existing; + } + } + + auto instance = std::make_shared( + std::move(options), std::move(store), std::move(fingerprint)); + cache.emplace_back(key, instance); + return instance; + } + JUCE_DECLARE_WEAK_REFERENCEABLE(MoonbaseUnlockStatus) }; diff --git a/include/moonbase/detail/file_lock.hpp b/include/moonbase/detail/file_lock.hpp new file mode 100644 index 0000000..9dcaaa3 --- /dev/null +++ b/include/moonbase/detail/file_lock.hpp @@ -0,0 +1,170 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "moonbase/errors.hpp" + +#if defined(_WIN32) +#include +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +// store.hpp pulls this header in via lock_for_update(), so any consumer of +// the public moonbase store API transitively sees . Without +// NOMINMAX, Win32 would inject min/max macros and clobber std::min/std::max +// in their translation units. +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#include +#include +#include +#include +#endif + +namespace moonbase::detail { + +// RAII exclusive lock on a regular file. Callers pass a dedicated sidecar +// path (e.g. ".lock") — the file is created on first acquire and +// is not assumed to contain anything, so it can outlive any data file the +// caller may later unlink. That separation is what makes the lock safe +// against delete_local_license() on POSIX, where flocking the data file +// would orphan the lock on the unlinked inode. +// +// POSIX: flock(LOCK_EX) on the file's descriptor. Locks are per-open-file +// description and cooperative; only callers that go through this class +// observe each other. +// Windows: LockFileEx(LOCKFILE_EXCLUSIVE_LOCK) on a single-byte range — +// the range is required by the API but otherwise arbitrary; all callers +// using this class lock the same range. +// +// Throws moonbase::storage_error on unrecoverable open/lock failures. +class file_lock { +public: + explicit file_lock(const std::filesystem::path& path) + { + const auto parent = path.parent_path(); + if (!parent.empty()) { + std::error_code ec; + std::filesystem::create_directories(parent, ec); + // Directory creation failure is non-fatal here: if the parent + // can't be created the subsequent open will fail with a clearer + // error. + } + +#if defined(_WIN32) + handle_ = ::CreateFileW( + path.wstring().c_str(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr); + if (handle_ == INVALID_HANDLE_VALUE) { + throw storage_error( + "Could not open license lock file (error " + + std::to_string(::GetLastError()) + ")"); + } + + OVERLAPPED overlapped{}; + if (!::LockFileEx( + handle_, + LOCKFILE_EXCLUSIVE_LOCK, + 0, + lock_range_low_, + lock_range_high_, + &overlapped)) { + const auto err = ::GetLastError(); + ::CloseHandle(handle_); + handle_ = INVALID_HANDLE_VALUE; + throw storage_error( + "Could not acquire license file lock (error " + + std::to_string(err) + ")"); + } +#else + fd_ = ::open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644); + if (fd_ < 0) { + throw storage_error( + std::string("Could not open license lock file: ") + + std::strerror(errno)); + } + + if (::flock(fd_, LOCK_EX) != 0) { + const auto err = errno; + ::close(fd_); + fd_ = -1; + throw storage_error( + std::string("Could not acquire license file lock: ") + + std::strerror(err)); + } +#endif + } + + file_lock(const file_lock&) = delete; + file_lock& operator=(const file_lock&) = delete; + + file_lock(file_lock&& other) noexcept + { +#if defined(_WIN32) + handle_ = other.handle_; + other.handle_ = INVALID_HANDLE_VALUE; +#else + fd_ = other.fd_; + other.fd_ = -1; +#endif + } + + file_lock& operator=(file_lock&& other) noexcept + { + if (this != &other) { + release(); +#if defined(_WIN32) + handle_ = other.handle_; + other.handle_ = INVALID_HANDLE_VALUE; +#else + fd_ = other.fd_; + other.fd_ = -1; +#endif + } + return *this; + } + + ~file_lock() { release(); } + +private: + void release() noexcept + { +#if defined(_WIN32) + if (handle_ != INVALID_HANDLE_VALUE) { + OVERLAPPED overlapped{}; + ::UnlockFileEx(handle_, 0, lock_range_low_, lock_range_high_, &overlapped); + ::CloseHandle(handle_); + handle_ = INVALID_HANDLE_VALUE; + } +#else + if (fd_ >= 0) { + ::flock(fd_, LOCK_UN); + ::close(fd_); + fd_ = -1; + } +#endif + } + +#if defined(_WIN32) + HANDLE handle_ = INVALID_HANDLE_VALUE; + static constexpr DWORD lock_range_low_ = 1; + static constexpr DWORD lock_range_high_ = 0; +#else + int fd_ = -1; +#endif +}; + +} // namespace moonbase::detail diff --git a/include/moonbase/licensing.hpp b/include/moonbase/licensing.hpp index d643c21..7547724 100644 --- a/include/moonbase/licensing.hpp +++ b/include/moonbase/licensing.hpp @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include #include @@ -57,7 +59,19 @@ class licensing { return validator_->validate_token(token); } - [[nodiscard]] license validate_token_online(std::string_view token) const + // should_persist (optional): evaluated inside the SDK's locks immediately + // before the refreshed license would be written to the store. When it + // returns false, the SDK skips the persist but still returns the + // refreshed license. Lets callers cancel writes that have been invalidated + // by concurrent state changes (e.g. the user pressed "Deactivate" while a + // background revalidation was in flight) without dropping the file lock + // in between — the predicate runs while the lock is still held, so a + // racing clearLicense() either bumped its flag before the predicate read + // (we skip), or after (we persist and the clear's own delete-under-lock + // runs strictly after our release). + [[nodiscard]] license validate_token_online( + std::string_view token, + std::function should_persist = {}) const { auto local = validator_->validate_token(token); @@ -65,7 +79,33 @@ class licensing { return local; } - const auto age = std::chrono::system_clock::now() - local.validated_at; + // Cross-instance deduplication: many DAW plugin instances frequently + // call this concurrently (project load with N plugin instances; in + // sandboxed hosts each instance is its own process). Layered guards: + // 1. Process-local mutex: serializes same-process callers cheaply. + // 2. File lock from the store: serializes across processes that + // share the license file. + // 3. Reload-under-lock + persist-on-success: the second waiter + // observes the fresh validated_at the first writer just wrote + // and short-circuits via the throttle. + std::lock_guard in_process_guard(validate_mutex_); + auto cross_process_guard = store_->lock_for_update(); + + license freshest = local; + try { + if (auto stored = store_->load_local_license(); + stored && stored->activation_id == local.activation_id) { + auto candidate = validator_->validate_token(stored->token); + if (candidate.validated_at > freshest.validated_at) { + freshest = std::move(candidate); + } + } + } catch (const std::exception&) { + // A corrupt or unparseable store entry must not block validation; + // fall back to the caller-supplied token. + } + + const auto age = std::chrono::system_clock::now() - freshest.validated_at; // The throttle is only allowed to skip the API while we're also // inside the grace period — otherwise a min_interval longer than the @@ -73,18 +113,29 @@ class licensing { // check" past its advertised limit. if (age < options_.online_validation_min_interval && age <= options_.online_validation_grace_period) { - return local; + return freshest; } try { - return client_->validate_token_online(token); + auto refreshed = client_->validate_token_online(token); + // Persist so sibling processes/instances see the fresh + // validated_at and hit the throttle on their next call. Storage + // failures are best-effort — we already have a valid license to + // return. + if (!should_persist || should_persist()) { + try { + store_->store_local_license(refreshed); + } catch (const storage_error&) { + } + } + return refreshed; } catch (const license_invalid_error&) { throw; } catch (const license_expired_error&) { throw; } catch (const std::exception&) { if (age <= options_.online_validation_grace_period) { - return local; + return freshest; } throw; } @@ -111,7 +162,13 @@ class licensing { // Store cleanup is best-effort — the server-side seat is already // freed, so a local IO failure must not surface as a revoke failure // (callers would retry against a token the server no longer knows). + // + // Hold the cross-process update lock for the load/delete window so an + // in-flight validate_token_online in a sibling instance can't slip a + // persist between our read and our delete (which would resurrect the + // license the user just revoked). try { + auto cross_process_guard = store_->lock_for_update(); if (auto stored = store_->load_local_license(); stored && stored->activation_id == local.activation_id) { store_->delete_local_license(); @@ -154,6 +211,7 @@ class licensing { std::shared_ptr transport_; std::shared_ptr validator_; std::shared_ptr client_; + mutable std::mutex validate_mutex_; }; } // namespace moonbase diff --git a/include/moonbase/store.hpp b/include/moonbase/store.hpp index 7b5a61a..2d04f07 100644 --- a/include/moonbase/store.hpp +++ b/include/moonbase/store.hpp @@ -2,33 +2,90 @@ #include #include +#include +#include #include #include #include +#include "moonbase/detail/file_lock.hpp" #include "moonbase/errors.hpp" #include "moonbase/types.hpp" namespace moonbase { +// Opaque RAII handle returned by license_store::lock_for_update(). Holding it +// guarantees exclusive access to the underlying store for the lifetime of the +// handle, including across processes when the store is backed by a shared +// resource (e.g. a file). +class store_lock_guard { +public: + virtual ~store_lock_guard() = default; +}; + class license_store { public: virtual ~license_store() = default; [[nodiscard]] virtual std::optional load_local_license() = 0; virtual void store_local_license(const license& value) = 0; virtual void delete_local_license() = 0; + + // Acquires an exclusive lock spanning the load/validate/store critical + // section in licensing::validate_token_online. Returns nullptr if the + // store does not require coordination (e.g. an in-memory store used by a + // single SDK instance); the SDK then relies on its in-process mutex only. + [[nodiscard]] virtual std::unique_ptr lock_for_update() + { + return nullptr; + } }; class memory_license_store : public license_store { public: - [[nodiscard]] std::optional load_local_license() override { return value_; } + [[nodiscard]] std::optional load_local_license() override + { + std::lock_guard guard(mutex_); + return value_; + } - void store_local_license(const license& value) override { value_ = value; } + void store_local_license(const license& value) override + { + std::lock_guard guard(mutex_); + value_ = value; + } - void delete_local_license() override { value_.reset(); } + void delete_local_license() override + { + std::lock_guard guard(mutex_); + value_.reset(); + } + + // In-process serialization for the validate→persist critical section. + // Without this, a concurrent clearLicense() running on another thread + // (the SDK's validate_mutex_ only serializes validate calls, not external + // mutations of the store) could delete between should_persist returning + // true and the actual store_local_license call, resurrecting the cleared + // license in memory. + // + // Recursive so callers (notably validate_token_online) can keep using + // load_local_license / store_local_license on the same thread while + // holding the guard. + [[nodiscard]] std::unique_ptr lock_for_update() override + { + return std::make_unique(mutex_); + } private: + class memory_store_lock : public store_lock_guard { + public: + explicit memory_store_lock(std::recursive_mutex& mutex) : guard_(mutex) {} + + private: + std::lock_guard guard_; + }; + + std::recursive_mutex mutex_; std::optional value_; }; @@ -85,7 +142,29 @@ class file_license_store : public license_store { } } + [[nodiscard]] std::unique_ptr lock_for_update() override + { + // Lock a sidecar file, not the license file itself. delete_local_license + // unlinks the license path, which on POSIX would orphan an flock on + // that inode: a sibling instance could then open a fresh inode at the + // same path and acquire an independent lock, defeating the + // serialization. The sidecar is created on first use and never + // deleted by us, so its inode is stable for the lifetime of the + // process tree. + auto lock_path = path_; + lock_path += ".lock"; + return std::make_unique(lock_path); + } + private: + class file_store_lock : public store_lock_guard { + public: + explicit file_store_lock(const std::filesystem::path& path) : lock_(path) {} + + private: + detail::file_lock lock_; + }; + std::filesystem::path path_; }; diff --git a/tests/licensing_tests.cpp b/tests/licensing_tests.cpp index 5ccecb5..61b980c 100644 --- a/tests/licensing_tests.cpp +++ b/tests/licensing_tests.cpp @@ -1,8 +1,12 @@ #include +#include #include #include #include +#include +#include +#include #include @@ -341,3 +345,302 @@ TEST_CASE("revoke_activation succeeds even if local store cleanup fails") REQUIRE(transport->requests.size() == 1); } + +namespace { + +// Thread-safe counting transport for the concurrent-dedup test. recording_transport's +// vector/deque mutations aren't safe across threads, and we want a hard assertion +// on call count rather than relying on the "no queued response → throw" failure mode. +class counting_transport : public http_transport { +public: + std::atomic count{0}; + std::string response_body; + + http_response send(const http_request&) override + { + count.fetch_add(1, std::memory_order_relaxed); + return http_response{200, {}, response_body}; + } +}; + +// Wraps another store to count lock_for_update() calls. Used to verify the +// SDK acquires the cross-process lock around the validate-online critical section. +class tracking_store : public license_store { +public: + std::atomic lock_count{0}; + + std::optional load_local_license() override + { + std::lock_guard lock(mutex_); + return value_; + } + + void store_local_license(const license& value) override + { + std::lock_guard lock(mutex_); + value_ = value; + } + + void delete_local_license() override + { + std::lock_guard lock(mutex_); + value_.reset(); + } + + std::unique_ptr lock_for_update() override + { + lock_count.fetch_add(1, std::memory_order_relaxed); + return std::make_unique(); + } + +private: + std::mutex mutex_; + std::optional value_; +}; + +} // namespace + +TEST_CASE("validate_token_online deduplicates concurrent in-process callers") +{ + // Set up a fresh facade with a thread-safe counting transport instead of + // recording_transport (which is not safe under concurrent send()). + auto fingerprints = + std::make_shared("Test Device", "device-id"); + auto transport = std::make_shared(); + + moonbase::tests::generated_key key = moonbase::tests::generate_key(); + licensing_options options; + options.endpoint = "https://demo.moonbase.sh"; + options.product_id = "demo-app"; + options.public_key = key.public_pem; + options.account_id = "tenant-1"; + + licensing instance(std::move(options), nullptr, fingerprints, transport); + + auto stale = moonbase::tests::default_claims(); + stale["validated"] = moonbase::tests::now_seconds() - (10 * 60); // > 5 min min_interval + const auto stale_token = moonbase::tests::make_token(key.key.get(), stale); + + // The refreshed token the (single) network call will return. + auto refreshed = moonbase::tests::default_claims(); + refreshed["validated"] = moonbase::tests::now_seconds(); + transport->response_body = + moonbase::tests::make_token(key.key.get(), refreshed); + + constexpr int thread_count = 16; + std::atomic ready{0}; + std::atomic go{false}; + std::atomic success{0}; + std::vector threads; + threads.reserve(thread_count); + + for (int i = 0; i < thread_count; ++i) { + threads.emplace_back([&] { + ready.fetch_add(1); + while (!go.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + try { + const auto result = instance.validate_token_online(stale_token); + if (result.id == "license-123") { + success.fetch_add(1, std::memory_order_relaxed); + } + } catch (...) { + // Counted as failure via success not incrementing. + } + }); + } + + while (ready.load() < thread_count) { + std::this_thread::yield(); + } + go.store(true, std::memory_order_release); + + for (auto& t : threads) t.join(); + + CHECK(success.load() == thread_count); + CHECK(transport->count.load() == 1); +} + +TEST_CASE("validate_token_online acquires the store update lock once per online check") +{ + auto fingerprints = + std::make_shared("Test Device", "device-id"); + auto transport = std::make_shared(); + auto store = std::make_shared(); + + moonbase::tests::generated_key key = moonbase::tests::generate_key(); + licensing_options options; + options.endpoint = "https://demo.moonbase.sh"; + options.product_id = "demo-app"; + options.public_key = key.public_pem; + options.account_id = "tenant-1"; + + licensing instance(std::move(options), store, fingerprints, transport); + + auto fresh_claims = moonbase::tests::default_claims(); + fresh_claims["validated"] = moonbase::tests::now_seconds() - 30; // within throttle window + const auto fresh_token = + moonbase::tests::make_token(key.key.get(), fresh_claims); + + // Within the throttle window the SDK must still take the lock — that's + // exactly the window where two racing processes need to see each other's + // freshly-persisted validated_at without hitting the network. + (void)instance.validate_token_online(fresh_token); + CHECK(store->lock_count.load() == 1); + CHECK(transport->requests.empty()); + + // Offline-activated tokens short-circuit before the lock — no extra + // contention for processes coordinating on the file. + auto offline_claims = moonbase::tests::default_claims(); + offline_claims["method"] = "Offline"; + const auto offline_token = + moonbase::tests::make_token(key.key.get(), offline_claims); + (void)instance.validate_token_online(offline_token); + CHECK(store->lock_count.load() == 1); +} + +TEST_CASE("validate_token_online persists the refreshed license itself") +{ + facade_fixture fixture; + + auto stale = moonbase::tests::default_claims(); + stale["validated"] = moonbase::tests::now_seconds() - (10 * 60); + const auto stale_token = fixture.make_token(stale); + + auto refreshed_claims = moonbase::tests::default_claims(); + refreshed_claims["validated"] = moonbase::tests::now_seconds(); + const auto refreshed_token = fixture.make_token(refreshed_claims); + fixture.transport->responses.push_back(http_response{200, {}, refreshed_token}); + + const auto returned = fixture.instance.validate_token_online(stale_token); + CHECK(returned.token == refreshed_token); + + // The SDK now writes the refresh back so that sibling instances/processes + // observe the new validated_at on their next call. + auto stored = fixture.instance.store().load_local_license(); + REQUIRE(stored.has_value()); + CHECK(stored->token == refreshed_token); +} + +TEST_CASE("validate_token_online prefers the freshest stored license when reloading under lock") +{ + facade_fixture fixture; + + // The caller passes a stale token (e.g. cached in a sibling plugin instance's + // memory), but another process has already persisted a fresher one. The SDK + // re-reads the store under the lock and uses the fresher timestamp for the + // throttle check, so no network call is needed. + auto stale_claims = moonbase::tests::default_claims(); + stale_claims["validated"] = moonbase::tests::now_seconds() - (10 * 60); + const auto stale_token = fixture.make_token(stale_claims); + + auto fresh_claims = moonbase::tests::default_claims(); + fresh_claims["validated"] = moonbase::tests::now_seconds() - 10; // well under min_interval + const auto fresh_token = fixture.make_token(fresh_claims); + auto fresh_license = fixture.instance.validator().validate_token(fresh_token); + fixture.instance.store().store_local_license(fresh_license); + + const auto result = fixture.instance.validate_token_online(stale_token); + + CHECK(fixture.transport->requests.empty()); + CHECK(result.token == fresh_token); +} + +TEST_CASE("validate_token_online skips persist when should_persist returns false") +{ + // Models the bridge's "user pressed Deactivate (or activated a different + // license) while a background revalidation was in flight" race. The + // refreshed token must still be returned to the caller (it's a valid + // license — the caller just chooses to discard it), but it must NOT be + // written to the store, or else the next launch or sibling instance + // would resurrect the activation the user just cleared. + facade_fixture fixture; + + auto stale_claims = moonbase::tests::default_claims(); + stale_claims["validated"] = moonbase::tests::now_seconds() - (10 * 60); + const auto stale_token = fixture.make_token(stale_claims); + + auto refreshed_claims = moonbase::tests::default_claims(); + refreshed_claims["validated"] = moonbase::tests::now_seconds(); + const auto refreshed_token = fixture.make_token(refreshed_claims); + fixture.transport->responses.push_back(http_response{200, {}, refreshed_token}); + + int predicate_calls = 0; + const auto returned = fixture.instance.validate_token_online( + stale_token, + [&] { ++predicate_calls; return false; }); + + CHECK(predicate_calls == 1); + CHECK(returned.token == refreshed_token); + CHECK_FALSE(fixture.instance.store().load_local_license().has_value()); +} + +TEST_CASE("validate_token_online persists when should_persist returns true") +{ + facade_fixture fixture; + + auto stale_claims = moonbase::tests::default_claims(); + stale_claims["validated"] = moonbase::tests::now_seconds() - (10 * 60); + const auto stale_token = fixture.make_token(stale_claims); + + auto refreshed_claims = moonbase::tests::default_claims(); + refreshed_claims["validated"] = moonbase::tests::now_seconds(); + const auto refreshed_token = fixture.make_token(refreshed_claims); + fixture.transport->responses.push_back(http_response{200, {}, refreshed_token}); + + const auto returned = fixture.instance.validate_token_online( + stale_token, [] { return true; }); + + CHECK(returned.token == refreshed_token); + auto stored = fixture.instance.store().load_local_license(); + REQUIRE(stored.has_value()); + CHECK(stored->token == refreshed_token); +} + +TEST_CASE("validate_token_online does not invoke should_persist on the throttle fast path") +{ + facade_fixture fixture; + + auto fresh_claims = moonbase::tests::default_claims(); + fresh_claims["validated"] = moonbase::tests::now_seconds() - 30; // within min_interval + const auto fresh_token = fixture.make_token(fresh_claims); + + int predicate_calls = 0; + (void)fixture.instance.validate_token_online( + fresh_token, + [&] { ++predicate_calls; return true; }); + + CHECK(predicate_calls == 0); + CHECK(fixture.transport->requests.empty()); +} + +TEST_CASE("revoke_activation acquires the store update lock around its cleanup") +{ + // Without the lock, an in-flight validate_token_online in a sibling + // instance could persist between revoke_activation's load and delete, + // resurrecting the license the user just revoked. The lock makes the + // load+delete atomic with respect to any concurrent persist. + auto fingerprints = + std::make_shared("Test Device", "device-id"); + auto transport = std::make_shared(); + auto store = std::make_shared(); + + moonbase::tests::generated_key key = moonbase::tests::generate_key(); + licensing_options options; + options.endpoint = "https://demo.moonbase.sh"; + options.product_id = "demo-app"; + options.public_key = key.public_pem; + options.account_id = "tenant-1"; + + licensing instance(std::move(options), store, fingerprints, transport); + + const auto token = moonbase::tests::make_token( + key.key.get(), moonbase::tests::default_claims()); + transport->responses.push_back(http_response{200, {}, ""}); + + const auto baseline = store->lock_count.load(); + instance.revoke_activation(token); + + CHECK(store->lock_count.load() > baseline); +} diff --git a/tests/process_dedup_tests.cpp b/tests/process_dedup_tests.cpp new file mode 100644 index 0000000..b70f607 --- /dev/null +++ b/tests/process_dedup_tests.cpp @@ -0,0 +1,173 @@ +#include + +// Cross-process deduplication integration test. POSIX-only (uses fork()). +// On Windows this test compiles to nothing — coverage on Windows comes from +// the in-process and file-lock unit tests, plus the Win32-specific file_lock +// path in detail/file_lock.hpp. +#if !defined(_WIN32) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "moonbase/fingerprint.hpp" +#include "moonbase/http.hpp" +#include "moonbase/licensing.hpp" +#include "moonbase/store.hpp" + +#include "test_helpers.hpp" + +using namespace moonbase; + +namespace { + +// Transport that atomically increments an on-disk integer counter under +// flock() on every send(). The counter file is what the parent test inspects +// post-fork to assert "exactly one process actually hit the network." +class counting_file_transport : public http_transport { +public: + std::filesystem::path counter_path; + std::string response_body; + + http_response send(const http_request&) override + { + const int fd = ::open(counter_path.c_str(), O_RDWR | O_CREAT, 0644); + REQUIRE(fd >= 0); + REQUIRE(::flock(fd, LOCK_EX) == 0); + + std::int64_t value = 0; + char buf[32] = {0}; + const auto bytes = ::pread(fd, buf, sizeof(buf) - 1, 0); + if (bytes > 0) { + value = std::strtoll(buf, nullptr, 10); + } + ++value; + + const auto out = std::to_string(value); + ::ftruncate(fd, 0); + ::pwrite(fd, out.data(), out.size(), 0); + + ::flock(fd, LOCK_UN); + ::close(fd); + + return http_response{200, {}, response_body}; + } +}; + +std::int64_t read_counter(const std::filesystem::path& path) +{ + std::ifstream in(path); + if (!in) return 0; + std::int64_t value = 0; + in >> value; + return value; +} + +std::filesystem::path unique_temp_path(const std::string& tag) +{ + return std::filesystem::temp_directory_path() / + ("moonbase-cpp-" + tag + "-" + + std::to_string(std::chrono::system_clock::now().time_since_epoch().count()) + + "-" + std::to_string(::getpid())); +} + +} // namespace + +TEST_CASE("validate_token_online deduplicates across forked processes") +{ + // Set up the world in the parent so all children inherit a fully-baked + // signing key + tokens. OpenSSL state lives in heap memory and survives + // fork's copy-on-write snapshot cleanly here because no OpenSSL global + // (e.g. RNG) is touched at validate time on the read path. + moonbase::tests::generated_key key = moonbase::tests::generate_key(); + + auto stale_claims = moonbase::tests::default_claims(); + stale_claims["validated"] = moonbase::tests::now_seconds() - (10 * 60); + const auto stale_token = moonbase::tests::make_token(key.key.get(), stale_claims); + + auto fresh_claims = moonbase::tests::default_claims(); + fresh_claims["validated"] = moonbase::tests::now_seconds(); + const auto refreshed_token = + moonbase::tests::make_token(key.key.get(), fresh_claims); + + const auto store_path = unique_temp_path("xproc-store") += ".json"; + const auto counter_path = unique_temp_path("xproc-counter") += ".txt"; + + licensing_options opts; + opts.endpoint = "https://demo.moonbase.sh"; + opts.product_id = "demo-app"; + opts.public_key = key.public_pem; + opts.account_id = "tenant-1"; + + // Seed the store with a stale-but-locally-valid license so each child + // exercises the "stale → must check online" path on entry. The + // cross-process file lock + SDK persist-on-success then collapses N + // children into a single network call. + { + license_validator seeder( + opts, + std::make_shared("Test Device", "device-id")); + file_license_store seed(store_path); + seed.store_local_license(seeder.validate_token(stale_token)); + } + + constexpr int child_count = 8; + std::vector children; + children.reserve(child_count); + + for (int i = 0; i < child_count; ++i) { + const pid_t pid = ::fork(); + REQUIRE(pid >= 0); + if (pid == 0) { + // Child: run one validate_token_online call against the shared + // file store + shared counter, then _exit() to bypass doctest's + // atexit teardown (which would otherwise double-report). + try { + auto fingerprint = + std::make_shared("Test Device", "device-id"); + auto transport = std::make_shared(); + transport->counter_path = counter_path; + transport->response_body = refreshed_token; + auto store = std::make_shared(store_path); + + licensing instance(opts, store, fingerprint, transport); + (void)instance.validate_token_online(stale_token); + ::_exit(0); + } catch (...) { + ::_exit(1); + } + } + children.push_back(pid); + } + + int failed = 0; + for (const auto pid : children) { + int status = 0; + const auto rc = ::waitpid(pid, &status, 0); + REQUIRE(rc == pid); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + ++failed; + } + } + CHECK(failed == 0); + + const auto counter = read_counter(counter_path); + CHECK(counter == 1); + + std::error_code ec; + std::filesystem::remove(store_path, ec); + std::filesystem::remove(counter_path, ec); +} + +#endif // !_WIN32 diff --git a/tests/store_tests.cpp b/tests/store_tests.cpp index 3b056fb..cf05220 100644 --- a/tests/store_tests.cpp +++ b/tests/store_tests.cpp @@ -1,7 +1,10 @@ #include +#include #include #include +#include +#include #include "moonbase/store.hpp" @@ -63,3 +66,200 @@ TEST_CASE("file_license_store round-trips and deletes") store.delete_local_license(); CHECK_FALSE(std::filesystem::exists(path)); } + +TEST_CASE("file_license_store::lock_for_update serializes concurrent acquirers") +{ + const auto path = std::filesystem::temp_directory_path() / + ("moonbase-cpp-lock-test-" + + std::to_string(std::chrono::system_clock::now().time_since_epoch().count()) + + ".lock"); + + // Two independent file_license_store instances pointed at the same path — + // models two plugin instances (possibly in separate processes) coordinating + // on the same on-disk license file. + constexpr int thread_count = 4; + std::atomic inside{0}; + std::atomic max_inside{0}; + std::atomic ready{0}; + std::atomic go{false}; + + auto runner = [&] { + file_license_store store(path); + ready.fetch_add(1); + while (!go.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + auto guard = store.lock_for_update(); + REQUIRE(guard != nullptr); + + const int now_inside = inside.fetch_add(1, std::memory_order_acq_rel) + 1; + int prev = max_inside.load(std::memory_order_relaxed); + while (now_inside > prev + && !max_inside.compare_exchange_weak(prev, now_inside, + std::memory_order_acq_rel)) { + } + + std::this_thread::sleep_for(std::chrono::milliseconds(40)); + inside.fetch_sub(1, std::memory_order_acq_rel); + }; + + std::vector threads; + threads.reserve(thread_count); + for (int i = 0; i < thread_count; ++i) { + threads.emplace_back(runner); + } + while (ready.load() < thread_count) { + std::this_thread::yield(); + } + go.store(true, std::memory_order_release); + for (auto& t : threads) t.join(); + + CHECK(max_inside.load() == 1); + + std::error_code ec; + std::filesystem::remove(path, ec); + auto lock_path = path; + lock_path += ".lock"; + std::filesystem::remove(lock_path, ec); +} + +TEST_CASE("file_license_store::lock_for_update survives delete_local_license") +{ + // Regression: when the lock was held on the license file itself, + // delete_local_license() would unlink the path and orphan the POSIX + // flock on the dead inode. A sibling acquirer could then open a fresh + // file at the same path and take an independent lock — letting a + // refresh persist after the user's clear/revoke. With the sidecar + // (.lock), the lock target outlives the license payload. + const auto path = std::filesystem::temp_directory_path() / + ("moonbase-cpp-lock-survive-" + + std::to_string(std::chrono::system_clock::now().time_since_epoch().count()) + + ".json"); + + // Seed once so delete_local_license has something to remove. + { + file_license_store seed(path); + seed.store_local_license(sample_license()); + } + + constexpr int thread_count = 4; + std::atomic inside{0}; + std::atomic max_inside{0}; + std::atomic ready{0}; + std::atomic go{false}; + + auto runner = [&] { + file_license_store store(path); + ready.fetch_add(1); + while (!go.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + auto guard = store.lock_for_update(); + REQUIRE(guard != nullptr); + + const int now_inside = inside.fetch_add(1, std::memory_order_acq_rel) + 1; + int prev = max_inside.load(std::memory_order_relaxed); + while (now_inside > prev + && !max_inside.compare_exchange_weak(prev, now_inside, + std::memory_order_acq_rel)) { + } + + // Mutate the license file while holding the lock — alternating + // delete and rewrite hammers exactly the scenario where the old + // (license-on-itself) lock would have failed. + try { + store.delete_local_license(); + } catch (const storage_error&) { + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + try { + store.store_local_license(sample_license()); + } catch (const storage_error&) { + } + + inside.fetch_sub(1, std::memory_order_acq_rel); + }; + + std::vector threads; + threads.reserve(thread_count); + for (int i = 0; i < thread_count; ++i) { + threads.emplace_back(runner); + } + while (ready.load() < thread_count) { + std::this_thread::yield(); + } + go.store(true, std::memory_order_release); + for (auto& t : threads) t.join(); + + CHECK(max_inside.load() == 1); + + std::error_code ec; + std::filesystem::remove(path, ec); + auto lock_path = path; + lock_path += ".lock"; + std::filesystem::remove(lock_path, ec); +} + +TEST_CASE("memory_license_store::lock_for_update blocks concurrent mutations") +{ + // Regression: the default store used to return nullptr from + // lock_for_update, which meant clearLicense() in a bridge could delete + // between validate_token_online's should_persist (returning true) and + // its actual store_local_license, resurrecting the cleared license in + // memory. + memory_license_store store; + store.store_local_license(sample_license()); + + std::atomic holder_inside{false}; + std::atomic mutator_finished{false}; + std::atomic mutator_started_while_holder_inside{false}; + + std::thread holder([&] { + auto guard = store.lock_for_update(); + REQUIRE(guard != nullptr); + holder_inside.store(true, std::memory_order_release); + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + // If lock_for_update truly serializes against mutating ops, the + // mutator thread can not have finished its delete by now. + mutator_started_while_holder_inside.store( + !mutator_finished.load(std::memory_order_acquire), + std::memory_order_release); + holder_inside.store(false, std::memory_order_release); + }); + + std::thread mutator([&] { + while (!holder_inside.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + // Holder is inside the lock; this delete must block until the + // holder releases the recursive_mutex. + store.delete_local_license(); + mutator_finished.store(true, std::memory_order_release); + }); + + holder.join(); + mutator.join(); + + CHECK(mutator_finished.load()); + CHECK(mutator_started_while_holder_inside.load()); + CHECK_FALSE(store.load_local_license().has_value()); +} + +TEST_CASE("memory_license_store guard allows re-entrant load/store on the holding thread") +{ + // validate_token_online holds lock_for_update across load_local_license + // and (later) store_local_license. Those must not deadlock when the + // store's coordination primitive happens to also guard load/store/delete. + memory_license_store store; + + auto guard = store.lock_for_update(); + REQUIRE(guard != nullptr); + + CHECK_FALSE(store.load_local_license().has_value()); + store.store_local_license(sample_license()); + auto loaded = store.load_local_license(); + REQUIRE(loaded.has_value()); + CHECK(loaded->id == "license-123"); + store.delete_local_license(); + CHECK_FALSE(store.load_local_license().has_value()); +}