feat(lite): runtime kill-switch + staged-rollout gate (M5b)
Adds a fail-open, local-only gate that decides whether the lite wallet may run,
so a post-release issue can disable it and rollout can be staged — without any
phone-home (privacy posture: no runtime network fetch; the per-install rollout
bucket is a hashed, never-transmitted local id).
- wallet/lite_rollout_policy.{h,cpp}: a pure decision core. Order — emergency env
kill-switch (absolute) -> local override -> manifest gates (global enable /
version floor-ceiling / blocklist / staged-rollout permille) -> fail-open allow.
Plus a JSON manifest loader (missing/invalid -> fail-open) and FNV-1a bucketing.
- Threads the decision through LiteWalletController -> LiteWalletLifecycleService:
new availability() reason RolloutDisabled blocks create/open/restore and surfaces
the gate's user-facing message via the lifecycle status.
- App::rebuildLiteWallet() resolves it from: DRAGONX_LITE_KILL_SWITCH (env), the
lite_rollout setting (auto/force_on/force_off), and a locally-cached manifest at
<config-dir>/lite_rollout.json. install id generated once via libsodium.
- Settings: persist lite_rollout override + the install id.
A signed remote fetcher can populate the manifest cache later without touching the
policy. Unit-tested (version compare, bucketing, override/env precedence, manifest
gates, staged rollout, loader fail-open, controller integration) and runtime-verified
on Linux (env kill-switch, manifest disable, control sync). Both variants build;
full suite passes; hygiene clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,8 @@ Guard full-node-only code paths with `#if DRAGONX_LITE_BUILD` / chat code with `
|
|||||||
The Lite variant is **functionally complete and runtime-verified on Linux + Windows** (work lives on branch `cleanup/lite-plan-churn`, **local-only — not pushed yet**):
|
The Lite variant is **functionally complete and runtime-verified on Linux + Windows** (work lives on branch `cleanup/lite-plan-churn`, **local-only — not pushed yet**):
|
||||||
- **Implemented:** lifecycle (create/open/restore + auto-open on startup), sync, refresh, send / shield / import / export / seed, persistence (the backend does *not* auto-save after sync/send/shield — the controller triggers `save` at those points), and passphrase **encryption** (encrypt/unlock/lock/decrypt + Settings UI + send-time & startup unlock; the backend locks immediately on `encrypt`). All controller-tested against the fake backend (`tests/fake_lite_backend.h`) and smoke-verified against the real SDXL backend via `tools/lite_smoke` (incl. a full sync). GUI is wired end-to-end with lite-appropriate wording; the full-node RPC connect loop / wizard / daemon strings are gated out of lite (lite "online" is derived from `lite_wallet_->walletOpen()`, not RPC).
|
- **Implemented:** lifecycle (create/open/restore + auto-open on startup), sync, refresh, send / shield / import / export / seed, persistence (the backend does *not* auto-save after sync/send/shield — the controller triggers `save` at those points), and passphrase **encryption** (encrypt/unlock/lock/decrypt + Settings UI + send-time & startup unlock; the backend locks immediately on `encrypt`). All controller-tested against the fake backend (`tests/fake_lite_backend.h`) and smoke-verified against the real SDXL backend via `tools/lite_smoke` (incl. a full sync). GUI is wired end-to-end with lite-appropriate wording; the full-node RPC connect loop / wizard / daemon strings are gated out of lite (lite "online" is derived from `lite_wallet_->walletOpen()`, not RPC).
|
||||||
- **Packaging (verified):** `./build.sh --lite-backend --linux-release` (zip + AppImage) and `--win-release` (cross-compiled `.exe`; first build the Windows backend artifact with `scripts/build-lite-backend-artifact.sh --platform windows`). Both correctly exclude full-node assets.
|
- **Packaging (verified):** `./build.sh --lite-backend --linux-release` (zip + AppImage) and `--win-release` (cross-compiled `.exe`; first build the Windows backend artifact with `scripts/build-lite-backend-artifact.sh --platform windows`). Both correctly exclude full-node assets.
|
||||||
- **Remaining (M5b):** macOS packaging, CI backend-artifact build + signing, runtime kill-switch / staged rollout.
|
- **Rollout / kill-switch (implemented):** `wallet/lite_rollout_policy.{h,cpp}` is a pure, fail-open gate (local-only, no network) feeding `LiteWalletLifecycleService::availability()` (new `RolloutDisabled` reason). Inputs: the emergency env var `DRAGONX_LITE_KILL_SWITCH` (absolute — not even `force_on` bypasses it); a `lite_rollout` setting (`auto`/`force_on`/`force_off`); and an optional **locally-cached** manifest at `<config-dir>/lite_rollout.json` (`global_enabled`, `min_version`/`max_version`, `blocked_versions`, `rollout_permille`, `message`) keyed for staged rollout on a hashed, never-transmitted per-install id. A signed remote fetcher can populate that cache later without touching the policy. Resolved in `App::rebuildLiteWallet()`; the disable message surfaces via the lifecycle status. Unit-tested + runtime-verified (env / manifest / control).
|
||||||
|
- **Remaining (M5b):** macOS packaging, CI backend-artifact build + signing.
|
||||||
- **To publish:** rename branch → `feat/lite-wallet`, base the PR on `dev` (the full-node UX is already there), and handle the dormant gated-OFF HushChat content bundled in commit `af06b8b`.
|
- **To publish:** rename branch → `feat/lite-wallet`, base the PR on `dev` (the full-node UX is already there), and handle the dormant gated-OFF HushChat content bundled in commit `af06b8b`.
|
||||||
|
|
||||||
The detailed milestone plan and design history (the v2 plan, backend artifact/ABI/signing design docs, the v1 plan, chat specs, etc.) are kept **untracked** under `docs/_archive/`.
|
The detailed milestone plan and design history (the v2 plan, backend artifact/ABI/signing design docs, the v1 plan, chat specs, etc.) are kept **untracked** under `docs/_archive/`.
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ set(APP_SOURCES
|
|||||||
src/services/wallet_security_workflow_executor.cpp
|
src/services/wallet_security_workflow_executor.cpp
|
||||||
src/chat/chat_protocol.cpp
|
src/chat/chat_protocol.cpp
|
||||||
src/wallet/lite_owned_string.cpp
|
src/wallet/lite_owned_string.cpp
|
||||||
|
src/wallet/lite_rollout_policy.cpp
|
||||||
src/wallet/lite_client_bridge.cpp
|
src/wallet/lite_client_bridge.cpp
|
||||||
src/wallet/lite_connection_service.cpp
|
src/wallet/lite_connection_service.cpp
|
||||||
src/wallet/lite_wallet_controller.cpp
|
src/wallet/lite_wallet_controller.cpp
|
||||||
@@ -504,6 +505,7 @@ set(APP_HEADERS
|
|||||||
src/wallet/wallet_capabilities.h
|
src/wallet/wallet_capabilities.h
|
||||||
src/wallet/wallet_backend.h
|
src/wallet/wallet_backend.h
|
||||||
src/wallet/lite_owned_string.h
|
src/wallet/lite_owned_string.h
|
||||||
|
src/wallet/lite_rollout_policy.h
|
||||||
src/wallet/lite_client_bridge.h
|
src/wallet/lite_client_bridge.h
|
||||||
src/wallet/lite_connection_service.h
|
src/wallet/lite_connection_service.h
|
||||||
src/wallet/lite_result_parsers.h
|
src/wallet/lite_result_parsers.h
|
||||||
@@ -689,6 +691,7 @@ if(DRAGONX_LITE_BACKEND_READY)
|
|||||||
tools/lite_smoke.cpp
|
tools/lite_smoke.cpp
|
||||||
src/wallet/lite_client_bridge.cpp
|
src/wallet/lite_client_bridge.cpp
|
||||||
src/wallet/lite_owned_string.cpp
|
src/wallet/lite_owned_string.cpp
|
||||||
|
src/wallet/lite_rollout_policy.cpp
|
||||||
src/wallet/lite_connection_service.cpp
|
src/wallet/lite_connection_service.cpp
|
||||||
src/wallet/lite_result_parsers.cpp
|
src/wallet/lite_result_parsers.cpp
|
||||||
)
|
)
|
||||||
@@ -934,6 +937,7 @@ if(BUILD_TESTING)
|
|||||||
src/services/wallet_security_workflow_executor.cpp
|
src/services/wallet_security_workflow_executor.cpp
|
||||||
src/chat/chat_protocol.cpp
|
src/chat/chat_protocol.cpp
|
||||||
src/wallet/lite_owned_string.cpp
|
src/wallet/lite_owned_string.cpp
|
||||||
|
src/wallet/lite_rollout_policy.cpp
|
||||||
src/wallet/lite_client_bridge.cpp
|
src/wallet/lite_client_bridge.cpp
|
||||||
src/wallet/lite_connection_service.cpp
|
src/wallet/lite_connection_service.cpp
|
||||||
src/wallet/lite_wallet_controller.cpp
|
src/wallet/lite_wallet_controller.cpp
|
||||||
|
|||||||
61
src/app.cpp
61
src/app.cpp
@@ -13,7 +13,9 @@
|
|||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
#include "wallet/lite_wallet_controller.h"
|
#include "wallet/lite_wallet_controller.h"
|
||||||
#include "wallet/lite_wallet_server_selection_adapter.h"
|
#include "wallet/lite_wallet_server_selection_adapter.h"
|
||||||
|
#include "wallet/lite_rollout_policy.h"
|
||||||
|
|
||||||
|
#include <cstdlib> // std::getenv for the lite kill-switch env var
|
||||||
#include <sodium.h> // sodium_memzero for the lite unlock-passphrase buffer
|
#include <sodium.h> // sodium_memzero for the lite unlock-passphrase buffer
|
||||||
#include "daemon/daemon_controller.h"
|
#include "daemon/daemon_controller.h"
|
||||||
#include "daemon/embedded_daemon.h"
|
#include "daemon/embedded_daemon.h"
|
||||||
@@ -370,6 +372,62 @@ void App::preFrame()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Resolve the lite-wallet rollout / kill-switch decision (see wallet/lite_rollout_policy.h).
|
||||||
|
// Local-only: reads the override + a stable per-install id from settings, an emergency env var,
|
||||||
|
// and a locally-cached manifest file (NO network fetch). Fail-open: a missing/invalid manifest
|
||||||
|
// leaves the wallet enabled.
|
||||||
|
wallet::LiteRolloutDecision resolveLiteRolloutDecision(config::Settings& settings)
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
LiteRolloutInputs inputs;
|
||||||
|
inputs.appVersion = DRAGONX_VERSION;
|
||||||
|
|
||||||
|
// Emergency kill-switch env var: any value other than empty/"0"/"false" disables.
|
||||||
|
if (const char* env = std::getenv("DRAGONX_LITE_KILL_SWITCH")) {
|
||||||
|
const std::string v = env;
|
||||||
|
inputs.killSwitchEnv = !v.empty() && v != "0" && v != "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs.override = liteRolloutOverrideFromString(settings.getLiteRolloutOverride());
|
||||||
|
|
||||||
|
// Stable per-install bucket source — generated once, persisted, never transmitted, no PII.
|
||||||
|
std::string installId = settings.getLiteInstallId();
|
||||||
|
if (installId.empty()) {
|
||||||
|
if (sodium_init() >= 0) {
|
||||||
|
unsigned char buf[16];
|
||||||
|
randombytes_buf(buf, sizeof(buf));
|
||||||
|
static const char kHex[] = "0123456789abcdef";
|
||||||
|
installId.reserve(sizeof(buf) * 2);
|
||||||
|
for (unsigned char c : buf) {
|
||||||
|
installId.push_back(kHex[c >> 4]);
|
||||||
|
installId.push_back(kHex[c & 0x0F]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings.setLiteInstallId(installId);
|
||||||
|
settings.save();
|
||||||
|
}
|
||||||
|
inputs.installBucket = liteRolloutBucketFromInstallId(installId);
|
||||||
|
|
||||||
|
// Local manifest cache next to settings.json — no network fetch (a signed remote fetcher can
|
||||||
|
// populate this later). Absent/unreadable -> fail-open.
|
||||||
|
try {
|
||||||
|
const std::filesystem::path cfgDir =
|
||||||
|
std::filesystem::path(config::Settings::getDefaultPath()).parent_path();
|
||||||
|
inputs.manifest = loadLiteRolloutManifestFromFile((cfgDir / "lite_rollout.json").string());
|
||||||
|
} catch (...) {
|
||||||
|
// leave manifest absent -> fail-open
|
||||||
|
}
|
||||||
|
|
||||||
|
const LiteRolloutDecision decision = evaluateLiteRollout(inputs);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
DEBUG_LOGF("[lite-rollout] lite wallet gated OFF: %s — %s\n",
|
||||||
|
liteRolloutStatusName(decision.status), decision.message.c_str());
|
||||||
|
}
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void App::rebuildLiteWallet()
|
void App::rebuildLiteWallet()
|
||||||
{
|
{
|
||||||
if (!supportsLiteBackend() || !settings_) return;
|
if (!supportsLiteBackend() || !settings_) return;
|
||||||
@@ -381,7 +439,8 @@ void App::rebuildLiteWallet()
|
|||||||
|
|
||||||
lite_wallet_ = wallet::LiteWalletController::createLinked(
|
lite_wallet_ = wallet::LiteWalletController::createLinked(
|
||||||
walletCapabilities(),
|
walletCapabilities(),
|
||||||
wallet::liteConnectionSettingsFromAppSettings(*settings_));
|
wallet::liteConnectionSettingsFromAppSettings(*settings_),
|
||||||
|
resolveLiteRolloutDecision(*settings_));
|
||||||
lite_wallet_->setPersistCallback([this]() { settings_->save(); });
|
lite_wallet_->setPersistCallback([this]() { settings_->save(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,13 @@ bool Settings::load(const std::string& path)
|
|||||||
lite_servers_.push_back(preference);
|
lite_servers_.push_back(preference);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (lite.contains("rollout_override") && lite["rollout_override"].is_string()) {
|
||||||
|
const auto v = lite["rollout_override"].get<std::string>();
|
||||||
|
lite_rollout_override_ = (v == "force_on" || v == "force_off") ? v : "auto";
|
||||||
|
}
|
||||||
|
if (lite.contains("install_id") && lite["install_id"].is_string()) {
|
||||||
|
lite_install_id_ = lite["install_id"].get<std::string>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (j.contains("verbose_logging")) verbose_logging_ = j["verbose_logging"].get<bool>();
|
if (j.contains("verbose_logging")) verbose_logging_ = j["verbose_logging"].get<bool>();
|
||||||
if (j.contains("debug_categories") && j["debug_categories"].is_array()) {
|
if (j.contains("debug_categories") && j["debug_categories"].is_array()) {
|
||||||
@@ -355,6 +362,8 @@ bool Settings::save(const std::string& path)
|
|||||||
entry["enabled"] = server.enabled;
|
entry["enabled"] = server.enabled;
|
||||||
lite["servers"].push_back(entry);
|
lite["servers"].push_back(entry);
|
||||||
}
|
}
|
||||||
|
lite["rollout_override"] = lite_rollout_override_;
|
||||||
|
lite["install_id"] = lite_install_id_;
|
||||||
j["lite_wallet"] = lite;
|
j["lite_wallet"] = lite;
|
||||||
}
|
}
|
||||||
j["verbose_logging"] = verbose_logging_;
|
j["verbose_logging"] = verbose_logging_;
|
||||||
|
|||||||
@@ -244,6 +244,15 @@ public:
|
|||||||
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
|
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
|
||||||
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
|
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
|
||||||
|
|
||||||
|
// Lite wallet rollout / kill-switch (see wallet/lite_rollout_policy.h).
|
||||||
|
// Override: "auto" (honor rollout manifest), "force_on", or "force_off".
|
||||||
|
std::string getLiteRolloutOverride() const { return lite_rollout_override_; }
|
||||||
|
void setLiteRolloutOverride(const std::string& v) { lite_rollout_override_ = v; }
|
||||||
|
// Stable, locally-generated install id used only to derive the staged-rollout bucket.
|
||||||
|
// Never transmitted; carries no PII. Generated on first use if empty.
|
||||||
|
std::string getLiteInstallId() const { return lite_install_id_; }
|
||||||
|
void setLiteInstallId(const std::string& v) { lite_install_id_ = v; }
|
||||||
|
|
||||||
// Verbose diagnostic logging (connection attempts, daemon state, port owner, etc.)
|
// Verbose diagnostic logging (connection attempts, daemon state, port owner, etc.)
|
||||||
bool getVerboseLogging() const { return verbose_logging_; }
|
bool getVerboseLogging() const { return verbose_logging_; }
|
||||||
void setVerboseLogging(bool v) { verbose_logging_ = v; }
|
void setVerboseLogging(bool v) { verbose_logging_ = v; }
|
||||||
@@ -392,6 +401,8 @@ private:
|
|||||||
std::string lite_chain_name_ = "main"; // SDXL backend chain id; must be main/test/regtest
|
std::string lite_chain_name_ = "main"; // SDXL backend chain id; must be main/test/regtest
|
||||||
std::size_t lite_random_selection_seed_ = 0;
|
std::size_t lite_random_selection_seed_ = 0;
|
||||||
bool lite_persist_selected_server_ = true;
|
bool lite_persist_selected_server_ = true;
|
||||||
|
std::string lite_rollout_override_ = "auto"; // auto|force_on|force_off
|
||||||
|
std::string lite_install_id_; // random local-only id; rollout-bucket source
|
||||||
std::vector<LiteServerPreference> lite_servers_ = {
|
std::vector<LiteServerPreference> lite_servers_ = {
|
||||||
{"https://lite.dragonx.is", "DragonX Lite", true},
|
{"https://lite.dragonx.is", "DragonX Lite", true},
|
||||||
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
|
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
|
||||||
|
|||||||
181
src/wallet/lite_rollout_policy.cpp
Normal file
181
src/wallet/lite_rollout_policy.cpp
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
|
||||||
|
#include "lite_rollout_policy.h"
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace wallet {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
using json = nlohmann::json;
|
||||||
|
|
||||||
|
// Numeric dotted core of a version, dropping any "-prerelease" / "+build" suffix.
|
||||||
|
std::vector<long long> parseVersionCore(const std::string& v)
|
||||||
|
{
|
||||||
|
const std::string core = v.substr(0, v.find_first_of("-+"));
|
||||||
|
std::vector<long long> parts;
|
||||||
|
std::stringstream ss(core);
|
||||||
|
std::string token;
|
||||||
|
while (std::getline(ss, token, '.')) {
|
||||||
|
if (token.empty()) { parts.push_back(0); continue; }
|
||||||
|
try { parts.push_back(std::stoll(token)); }
|
||||||
|
catch (...) { parts.push_back(0); }
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* defaultMessageFor(LiteRolloutStatus s)
|
||||||
|
{
|
||||||
|
switch (s) {
|
||||||
|
case LiteRolloutStatus::DisabledByKillSwitchEnv:
|
||||||
|
return "The lite wallet is disabled by an emergency kill-switch on this machine.";
|
||||||
|
case LiteRolloutStatus::DisabledByLocalOverride:
|
||||||
|
return "The lite wallet is turned off in this app's settings.";
|
||||||
|
case LiteRolloutStatus::DisabledByManifest:
|
||||||
|
return "The lite wallet is currently disabled by the rollout policy.";
|
||||||
|
case LiteRolloutStatus::DisabledUnsupportedVersion:
|
||||||
|
return "This app version is not supported by the lite wallet; please update.";
|
||||||
|
case LiteRolloutStatus::DisabledBlockedVersion:
|
||||||
|
return "This app version has a known issue and the lite wallet is disabled; please update.";
|
||||||
|
case LiteRolloutStatus::DisabledByStagedRollout:
|
||||||
|
return "The lite wallet is rolling out gradually and is not yet enabled for this install.";
|
||||||
|
case LiteRolloutStatus::Allowed:
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LiteRolloutDecision disabledDecision(LiteRolloutStatus s, const std::string& manifestMessage)
|
||||||
|
{
|
||||||
|
LiteRolloutDecision d;
|
||||||
|
d.allowed = false;
|
||||||
|
d.status = s;
|
||||||
|
d.message = !manifestMessage.empty() ? manifestMessage : defaultMessageFor(s);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int compareLiteVersions(const std::string& a, const std::string& b)
|
||||||
|
{
|
||||||
|
const auto pa = parseVersionCore(a);
|
||||||
|
const auto pb = parseVersionCore(b);
|
||||||
|
const std::size_t n = std::max(pa.size(), pb.size());
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
const long long va = i < pa.size() ? pa[i] : 0;
|
||||||
|
const long long vb = i < pb.size() ? pb[i] : 0;
|
||||||
|
if (va < vb) return -1;
|
||||||
|
if (va > vb) return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int liteRolloutBucketFromInstallId(const std::string& installId)
|
||||||
|
{
|
||||||
|
if (installId.empty()) return 0;
|
||||||
|
// FNV-1a (64-bit) → 0..999. Stable across runs for a given id.
|
||||||
|
std::uint64_t h = 1469598103934665603ULL;
|
||||||
|
for (unsigned char c : installId) {
|
||||||
|
h ^= static_cast<std::uint64_t>(c);
|
||||||
|
h *= 1099511628211ULL;
|
||||||
|
}
|
||||||
|
return static_cast<int>(h % 1000ULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
LiteRolloutOverride liteRolloutOverrideFromString(const std::string& value)
|
||||||
|
{
|
||||||
|
if (value == "force_on") return LiteRolloutOverride::ForceOn;
|
||||||
|
if (value == "force_off") return LiteRolloutOverride::ForceOff;
|
||||||
|
return LiteRolloutOverride::Auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* liteRolloutOverrideToString(LiteRolloutOverride o)
|
||||||
|
{
|
||||||
|
switch (o) {
|
||||||
|
case LiteRolloutOverride::ForceOn: return "force_on";
|
||||||
|
case LiteRolloutOverride::ForceOff: return "force_off";
|
||||||
|
case LiteRolloutOverride::Auto: default: return "auto";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* liteRolloutStatusName(LiteRolloutStatus s)
|
||||||
|
{
|
||||||
|
switch (s) {
|
||||||
|
case LiteRolloutStatus::Allowed: return "Allowed";
|
||||||
|
case LiteRolloutStatus::DisabledByKillSwitchEnv: return "DisabledByKillSwitchEnv";
|
||||||
|
case LiteRolloutStatus::DisabledByLocalOverride: return "DisabledByLocalOverride";
|
||||||
|
case LiteRolloutStatus::DisabledByManifest: return "DisabledByManifest";
|
||||||
|
case LiteRolloutStatus::DisabledUnsupportedVersion: return "DisabledUnsupportedVersion";
|
||||||
|
case LiteRolloutStatus::DisabledBlockedVersion: return "DisabledBlockedVersion";
|
||||||
|
case LiteRolloutStatus::DisabledByStagedRollout: return "DisabledByStagedRollout";
|
||||||
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
LiteRolloutDecision evaluateLiteRollout(const LiteRolloutInputs& in)
|
||||||
|
{
|
||||||
|
// 1. Emergency env kill-switch is absolute — not even ForceOn bypasses it.
|
||||||
|
if (in.killSwitchEnv)
|
||||||
|
return disabledDecision(LiteRolloutStatus::DisabledByKillSwitchEnv, {});
|
||||||
|
|
||||||
|
// 2/3. Local override.
|
||||||
|
if (in.override == LiteRolloutOverride::ForceOff)
|
||||||
|
return disabledDecision(LiteRolloutStatus::DisabledByLocalOverride, {});
|
||||||
|
if (in.override == LiteRolloutOverride::ForceOn)
|
||||||
|
return LiteRolloutDecision{true, LiteRolloutStatus::Allowed, {}};
|
||||||
|
|
||||||
|
// 4. Manifest gates — fail-open: only enforced when a manifest is present AND valid.
|
||||||
|
const LiteRolloutManifest& m = in.manifest;
|
||||||
|
if (m.present && m.valid) {
|
||||||
|
if (!m.globalEnabled)
|
||||||
|
return disabledDecision(LiteRolloutStatus::DisabledByManifest, m.message);
|
||||||
|
if (!m.minVersion.empty() && compareLiteVersions(in.appVersion, m.minVersion) < 0)
|
||||||
|
return disabledDecision(LiteRolloutStatus::DisabledUnsupportedVersion, m.message);
|
||||||
|
if (!m.maxVersion.empty() && compareLiteVersions(in.appVersion, m.maxVersion) > 0)
|
||||||
|
return disabledDecision(LiteRolloutStatus::DisabledUnsupportedVersion, m.message);
|
||||||
|
for (const auto& bad : m.blockedVersions)
|
||||||
|
if (compareLiteVersions(in.appVersion, bad) == 0)
|
||||||
|
return disabledDecision(LiteRolloutStatus::DisabledBlockedVersion, m.message);
|
||||||
|
if (in.installBucket >= m.rolloutPermille)
|
||||||
|
return disabledDecision(LiteRolloutStatus::DisabledByStagedRollout, m.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fail-open default.
|
||||||
|
return LiteRolloutDecision{true, LiteRolloutStatus::Allowed, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
LiteRolloutManifest loadLiteRolloutManifestFromFile(const std::string& path)
|
||||||
|
{
|
||||||
|
LiteRolloutManifest m; // present=false, valid=false (fail-open)
|
||||||
|
std::ifstream in(path);
|
||||||
|
if (!in.is_open()) return m; // no file -> no manifest
|
||||||
|
m.present = true;
|
||||||
|
try {
|
||||||
|
json j;
|
||||||
|
in >> j;
|
||||||
|
if (j.contains("global_enabled")) m.globalEnabled = j["global_enabled"].get<bool>();
|
||||||
|
if (j.contains("min_version")) m.minVersion = j["min_version"].get<std::string>();
|
||||||
|
if (j.contains("max_version")) m.maxVersion = j["max_version"].get<std::string>();
|
||||||
|
if (j.contains("blocked_versions"))
|
||||||
|
m.blockedVersions = j["blocked_versions"].get<std::vector<std::string>>();
|
||||||
|
if (j.contains("rollout_permille")) {
|
||||||
|
const int p = j["rollout_permille"].get<int>();
|
||||||
|
m.rolloutPermille = std::max(0, std::min(1000, p));
|
||||||
|
}
|
||||||
|
if (j.contains("message")) m.message = j["message"].get<std::string>();
|
||||||
|
m.valid = true;
|
||||||
|
} catch (...) {
|
||||||
|
m.valid = false; // parse error -> fail-open
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace wallet
|
||||||
|
} // namespace dragonx
|
||||||
85
src/wallet/lite_rollout_policy.h
Normal file
85
src/wallet/lite_rollout_policy.h
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
//
|
||||||
|
// Lite-wallet rollout / kill-switch policy.
|
||||||
|
//
|
||||||
|
// A pure decision core that gates whether the lite wallet may run, given a local override, an
|
||||||
|
// emergency env kill-switch, an optional rollout manifest, the running app version, and a stable
|
||||||
|
// per-install rollout bucket. Privacy posture (this is a privacy coin): the manifest is read from a
|
||||||
|
// LOCAL cache file only — there is NO runtime network fetch (a signed remote fetcher can populate
|
||||||
|
// that cache later without touching this policy), it is consulted FAIL-OPEN (absent/invalid →
|
||||||
|
// allowed, so a missing file never bricks a working install), and the install bucket is derived
|
||||||
|
// from a locally-generated random id that is never transmitted and carries no PII.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dragonx::wallet {
|
||||||
|
|
||||||
|
// User/support local override (persisted in settings as "lite_rollout").
|
||||||
|
enum class LiteRolloutOverride {
|
||||||
|
Auto, // honor the manifest / staged rollout
|
||||||
|
ForceOn, // always enable; bypasses the manifest + staged rollout, but NOT the env kill-switch
|
||||||
|
ForceOff // always disable
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class LiteRolloutStatus {
|
||||||
|
Allowed,
|
||||||
|
DisabledByKillSwitchEnv, // DRAGONX_LITE_KILL_SWITCH=1 (emergency, absolute)
|
||||||
|
DisabledByLocalOverride, // lite_rollout=force_off
|
||||||
|
DisabledByManifest, // manifest global_enabled=false
|
||||||
|
DisabledUnsupportedVersion, // version below min / above max supported
|
||||||
|
DisabledBlockedVersion, // version explicitly blocked
|
||||||
|
DisabledByStagedRollout // install bucket above the rollout threshold
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rollout manifest. Loaded from a local cache file; present=false means no manifest (fail-open).
|
||||||
|
struct LiteRolloutManifest {
|
||||||
|
bool present = false; // a manifest file was found
|
||||||
|
bool valid = false; // it parsed and validated
|
||||||
|
bool globalEnabled = true; // master enable switch
|
||||||
|
std::string minVersion; // inclusive floor (empty = none)
|
||||||
|
std::string maxVersion; // inclusive ceiling (empty = none)
|
||||||
|
std::vector<std::string> blockedVersions;
|
||||||
|
int rolloutPermille = 1000; // 0..1000; install allowed when bucket < permille (1000 = 100%)
|
||||||
|
std::string message; // user-facing reason shown when disabled
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LiteRolloutInputs {
|
||||||
|
LiteRolloutOverride override = LiteRolloutOverride::Auto;
|
||||||
|
bool killSwitchEnv = false;
|
||||||
|
LiteRolloutManifest manifest;
|
||||||
|
std::string appVersion;
|
||||||
|
int installBucket = 0; // 0..999, stable per install
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LiteRolloutDecision {
|
||||||
|
bool allowed = true;
|
||||||
|
LiteRolloutStatus status = LiteRolloutStatus::Allowed;
|
||||||
|
std::string message; // user-facing (empty when allowed)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The pure decision. No I/O. Evaluation order: env kill-switch (absolute) → local override →
|
||||||
|
// manifest gates (global / version / blocked / staged rollout) → fail-open allow.
|
||||||
|
LiteRolloutDecision evaluateLiteRollout(const LiteRolloutInputs& inputs);
|
||||||
|
|
||||||
|
// Stable bucket 0..999 from an install id (FNV-1a). Deterministic; empty id -> 0.
|
||||||
|
int liteRolloutBucketFromInstallId(const std::string& installId);
|
||||||
|
|
||||||
|
// Compare dotted version cores, ignoring any "-suffix"/"+build". Returns -1 / 0 / 1.
|
||||||
|
int compareLiteVersions(const std::string& a, const std::string& b);
|
||||||
|
|
||||||
|
// Override <-> string ("auto"/"force_on"/"force_off"); unknown parses to Auto.
|
||||||
|
LiteRolloutOverride liteRolloutOverrideFromString(const std::string& value);
|
||||||
|
const char* liteRolloutOverrideToString(LiteRolloutOverride override);
|
||||||
|
|
||||||
|
const char* liteRolloutStatusName(LiteRolloutStatus status);
|
||||||
|
|
||||||
|
// Load + validate a manifest from a JSON file. Missing file -> {present=false}; parse error ->
|
||||||
|
// {present=true, valid=false}. Both are fail-open at evaluation time.
|
||||||
|
LiteRolloutManifest loadLiteRolloutManifestFromFile(const std::string& path);
|
||||||
|
|
||||||
|
} // namespace dragonx::wallet
|
||||||
@@ -196,7 +196,8 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
|
|||||||
: bridge_(std::make_shared<LiteClientBridge>(std::move(bridge))),
|
: bridge_(std::make_shared<LiteClientBridge>(std::move(bridge))),
|
||||||
chainName_(connectionSettings.chainName),
|
chainName_(connectionSettings.chainName),
|
||||||
lifecycle_(capabilities, connectionSettings, bridge_.get(),
|
lifecycle_(capabilities, connectionSettings, bridge_.get(),
|
||||||
LiteWalletLifecycleOptions{options.allowBridgeCalls}),
|
LiteWalletLifecycleOptions{options.allowBridgeCalls,
|
||||||
|
options.rolloutBlocked, options.rolloutMessage}),
|
||||||
gateway_(capabilities, connectionSettings, bridge_.get(),
|
gateway_(capabilities, connectionSettings, bridge_.get(),
|
||||||
LiteWalletGatewayOptions{options.allowBridgeCalls}),
|
LiteWalletGatewayOptions{options.allowBridgeCalls}),
|
||||||
sync_(capabilities, connectionSettings, bridge_.get(),
|
sync_(capabilities, connectionSettings, bridge_.get(),
|
||||||
@@ -226,13 +227,14 @@ LiteWalletController::~LiteWalletController()
|
|||||||
|
|
||||||
std::unique_ptr<LiteWalletController> LiteWalletController::createLinked(
|
std::unique_ptr<LiteWalletController> LiteWalletController::createLinked(
|
||||||
WalletCapabilities capabilities,
|
WalletCapabilities capabilities,
|
||||||
LiteConnectionSettings connectionSettings)
|
LiteConnectionSettings connectionSettings,
|
||||||
|
LiteRolloutDecision rollout)
|
||||||
{
|
{
|
||||||
return std::make_unique<LiteWalletController>(
|
return std::make_unique<LiteWalletController>(
|
||||||
capabilities,
|
capabilities,
|
||||||
std::move(connectionSettings),
|
std::move(connectionSettings),
|
||||||
LiteClientBridge::linkedSdxl(),
|
LiteClientBridge::linkedSdxl(),
|
||||||
LiteWalletControllerOptions{true});
|
LiteWalletControllerOptions{true, !rollout.allowed, rollout.message});
|
||||||
}
|
}
|
||||||
|
|
||||||
void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result)
|
void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
#include "lite_connection_service.h"
|
#include "lite_connection_service.h"
|
||||||
#include "lite_wallet_lifecycle_service.h"
|
#include "lite_wallet_lifecycle_service.h"
|
||||||
#include "lite_wallet_gateway.h"
|
#include "lite_wallet_gateway.h"
|
||||||
|
#include "lite_rollout_policy.h"
|
||||||
#include "lite_sync_service.h"
|
#include "lite_sync_service.h"
|
||||||
#include "lite_wallet_state_mapper.h"
|
#include "lite_wallet_state_mapper.h"
|
||||||
#include "wallet_backend.h"
|
#include "wallet_backend.h"
|
||||||
@@ -53,6 +54,8 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
|
|
||||||
struct LiteWalletControllerOptions {
|
struct LiteWalletControllerOptions {
|
||||||
bool allowBridgeCalls = true;
|
bool allowBridgeCalls = true;
|
||||||
|
bool rolloutBlocked = false; // runtime kill-switch / staged-rollout gate is blocking
|
||||||
|
std::string rolloutMessage; // user-facing reason when rolloutBlocked
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LiteNewAddressResult {
|
struct LiteNewAddressResult {
|
||||||
@@ -133,10 +136,13 @@ public:
|
|||||||
LiteWalletController(const LiteWalletController&) = delete;
|
LiteWalletController(const LiteWalletController&) = delete;
|
||||||
LiteWalletController& operator=(const LiteWalletController&) = delete;
|
LiteWalletController& operator=(const LiteWalletController&) = delete;
|
||||||
|
|
||||||
// Production factory: links the SDXL backend compiled into this build.
|
// Production factory: links the SDXL backend compiled into this build. The optional rollout
|
||||||
|
// decision (runtime kill-switch / staged rollout) gates lifecycle execution: when not allowed,
|
||||||
|
// availability() reports RolloutDisabled and create/open/restore are blocked with its message.
|
||||||
static std::unique_ptr<LiteWalletController> createLinked(
|
static std::unique_ptr<LiteWalletController> createLinked(
|
||||||
WalletCapabilities capabilities,
|
WalletCapabilities capabilities,
|
||||||
LiteConnectionSettings connectionSettings);
|
LiteConnectionSettings connectionSettings,
|
||||||
|
LiteRolloutDecision rollout = LiteRolloutDecision{});
|
||||||
|
|
||||||
// Invoked after a wallet becomes ready, so the owner can persist settings.
|
// Invoked after a wallet becomes ready, so the owner can persist settings.
|
||||||
void setPersistCallback(std::function<void()> callback) { persist_ = std::move(callback); }
|
void setPersistCallback(std::function<void()> callback) { persist_ = std::move(callback); }
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ const char* liteWalletLifecycleAvailabilityName(LiteWalletLifecycleAvailability
|
|||||||
return "BridgeCallsDisabled";
|
return "BridgeCallsDisabled";
|
||||||
case LiteWalletLifecycleAvailability::NoUsableServer:
|
case LiteWalletLifecycleAvailability::NoUsableServer:
|
||||||
return "NoUsableServer";
|
return "NoUsableServer";
|
||||||
|
case LiteWalletLifecycleAvailability::RolloutDisabled:
|
||||||
|
return "RolloutDisabled";
|
||||||
}
|
}
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
@@ -131,6 +133,9 @@ LiteWalletLifecycleAvailability LiteWalletLifecycleService::availability() const
|
|||||||
if (!isLiteBuild(capabilities_)) return LiteWalletLifecycleAvailability::UnsupportedBuild;
|
if (!isLiteBuild(capabilities_)) return LiteWalletLifecycleAvailability::UnsupportedBuild;
|
||||||
if (!supportsLiteBackend(capabilities_)) return LiteWalletLifecycleAvailability::BackendUnavailable;
|
if (!supportsLiteBackend(capabilities_)) return LiteWalletLifecycleAvailability::BackendUnavailable;
|
||||||
if (!bridge_ || !bridge_->available()) return LiteWalletLifecycleAvailability::BridgeUnavailable;
|
if (!bridge_ || !bridge_->available()) return LiteWalletLifecycleAvailability::BridgeUnavailable;
|
||||||
|
// Runtime kill-switch / staged-rollout gate: a structural readiness check, applied before
|
||||||
|
// server selection so a gated-off wallet reports the rollout reason rather than a server error.
|
||||||
|
if (options_.rolloutBlocked) return LiteWalletLifecycleAvailability::RolloutDisabled;
|
||||||
if (!selectLiteServer(connectionSettings_).ok) return LiteWalletLifecycleAvailability::NoUsableServer;
|
if (!selectLiteServer(connectionSettings_).ok) return LiteWalletLifecycleAvailability::NoUsableServer;
|
||||||
if (!options_.allowBridgeCalls) return LiteWalletLifecycleAvailability::BridgeCallsDisabled;
|
if (!options_.allowBridgeCalls) return LiteWalletLifecycleAvailability::BridgeCallsDisabled;
|
||||||
return LiteWalletLifecycleAvailability::Ready;
|
return LiteWalletLifecycleAvailability::Ready;
|
||||||
@@ -142,6 +147,9 @@ WalletBackendStatus LiteWalletLifecycleService::status() const
|
|||||||
if (currentAvailability == LiteWalletLifecycleAvailability::NoUsableServer) {
|
if (currentAvailability == LiteWalletLifecycleAvailability::NoUsableServer) {
|
||||||
return statusFor(currentAvailability, selectLiteServer(connectionSettings_).error);
|
return statusFor(currentAvailability, selectLiteServer(connectionSettings_).error);
|
||||||
}
|
}
|
||||||
|
if (currentAvailability == LiteWalletLifecycleAvailability::RolloutDisabled) {
|
||||||
|
return statusFor(currentAvailability, options_.rolloutMessage);
|
||||||
|
}
|
||||||
return statusFor(currentAvailability);
|
return statusFor(currentAvailability);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +318,14 @@ WalletBackendStatus LiteWalletLifecycleService::statusFor(
|
|||||||
{},
|
{},
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
case LiteWalletLifecycleAvailability::RolloutDisabled:
|
||||||
|
return WalletBackendStatus{
|
||||||
|
WalletBackendState::Unavailable,
|
||||||
|
detail.empty() ? "the lite wallet is disabled by the rollout policy" : detail,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
0.0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return WalletBackendStatus{WalletBackendState::Unavailable, "unknown lite wallet lifecycle state", {}, {}, 0.0};
|
return WalletBackendStatus{WalletBackendState::Unavailable, "unknown lite wallet lifecycle state", {}, {}, 0.0};
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ enum class LiteWalletLifecycleAvailability {
|
|||||||
BackendUnavailable,
|
BackendUnavailable,
|
||||||
BridgeUnavailable,
|
BridgeUnavailable,
|
||||||
BridgeCallsDisabled,
|
BridgeCallsDisabled,
|
||||||
NoUsableServer
|
NoUsableServer,
|
||||||
|
RolloutDisabled // runtime kill-switch / staged-rollout gate (see lite_rollout_policy.h)
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class LitePrivateDataKind {
|
enum class LitePrivateDataKind {
|
||||||
@@ -93,6 +94,8 @@ struct LiteWalletLifecycleResult {
|
|||||||
|
|
||||||
struct LiteWalletLifecycleOptions {
|
struct LiteWalletLifecycleOptions {
|
||||||
bool allowBridgeCalls = false;
|
bool allowBridgeCalls = false;
|
||||||
|
bool rolloutBlocked = false; // runtime kill-switch / staged-rollout gate is blocking
|
||||||
|
std::string rolloutMessage; // user-facing reason when rolloutBlocked
|
||||||
};
|
};
|
||||||
|
|
||||||
const char* liteWalletLifecycleOperationName(LiteWalletLifecycleOperation operation);
|
const char* liteWalletLifecycleOperationName(LiteWalletLifecycleOperation operation);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
#include "util/amount_format.h"
|
#include "util/amount_format.h"
|
||||||
#include "util/payment_uri.h"
|
#include "util/payment_uri.h"
|
||||||
#include "wallet/lite_owned_string.h"
|
#include "wallet/lite_owned_string.h"
|
||||||
|
#include "wallet/lite_rollout_policy.h"
|
||||||
#include "wallet/lite_wallet_controller.h"
|
#include "wallet/lite_wallet_controller.h"
|
||||||
#include "wallet/lite_wallet_gateway.h"
|
#include "wallet/lite_wallet_gateway.h"
|
||||||
#include "wallet/lite_wallet_state_mapper.h"
|
#include "wallet/lite_wallet_state_mapper.h"
|
||||||
@@ -3767,6 +3768,197 @@ void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
|||||||
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly
|
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h).
|
||||||
|
void testLiteRolloutVersionCompare()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2.0"), 0);
|
||||||
|
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2"), 0); // missing components treated as 0
|
||||||
|
EXPECT_EQ(compareLiteVersions("1.2.0-rc1", "1.2.0"), 0); // pre-release suffix ignored
|
||||||
|
EXPECT_EQ(compareLiteVersions("1.2.0", "1.3.0"), -1);
|
||||||
|
EXPECT_EQ(compareLiteVersions("2.0.0", "1.9.9"), 1);
|
||||||
|
EXPECT_EQ(compareLiteVersions("1.2.10", "1.2.9"), 1); // numeric, not lexical
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLiteRolloutBucketAndOverrideHelpers()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
const int a = liteRolloutBucketFromInstallId("install-abc");
|
||||||
|
EXPECT_EQ(a, liteRolloutBucketFromInstallId("install-abc")); // deterministic
|
||||||
|
EXPECT_TRUE(a >= 0 && a < 1000); // in range
|
||||||
|
EXPECT_EQ(liteRolloutBucketFromInstallId(""), 0); // empty -> 0
|
||||||
|
EXPECT_TRUE(liteRolloutBucketFromInstallId("install-abc")
|
||||||
|
!= liteRolloutBucketFromInstallId("install-xyz"));
|
||||||
|
|
||||||
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_on"))),
|
||||||
|
std::string("force_on"));
|
||||||
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_off"))),
|
||||||
|
std::string("force_off"));
|
||||||
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("auto"))),
|
||||||
|
std::string("auto"));
|
||||||
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("garbage"))),
|
||||||
|
std::string("auto")); // unknown -> auto
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLiteRolloutPolicyOverridesAndKillSwitch()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
LiteRolloutInputs in;
|
||||||
|
in.appVersion = "1.2.0";
|
||||||
|
|
||||||
|
// No manifest, auto -> allowed (fail-open).
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||||||
|
|
||||||
|
// force_off -> disabled by local override.
|
||||||
|
in.override = LiteRolloutOverride::ForceOff;
|
||||||
|
auto off = evaluateLiteRollout(in);
|
||||||
|
EXPECT_FALSE(off.allowed);
|
||||||
|
EXPECT_TRUE(off.status == LiteRolloutStatus::DisabledByLocalOverride);
|
||||||
|
|
||||||
|
// force_on -> allowed, even with a disabling manifest.
|
||||||
|
in.override = LiteRolloutOverride::ForceOn;
|
||||||
|
in.manifest.present = true; in.manifest.valid = true; in.manifest.globalEnabled = false;
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||||||
|
|
||||||
|
// env kill-switch is absolute: disables even with force_on.
|
||||||
|
in.killSwitchEnv = true;
|
||||||
|
auto killed = evaluateLiteRollout(in);
|
||||||
|
EXPECT_FALSE(killed.allowed);
|
||||||
|
EXPECT_TRUE(killed.status == LiteRolloutStatus::DisabledByKillSwitchEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLiteRolloutPolicyManifestGates()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
LiteRolloutInputs in;
|
||||||
|
in.appVersion = "1.2.0";
|
||||||
|
in.manifest.present = true;
|
||||||
|
in.manifest.valid = true;
|
||||||
|
|
||||||
|
// Global disable carries the manifest message through to the decision.
|
||||||
|
in.manifest.globalEnabled = false;
|
||||||
|
in.manifest.message = "paused for maintenance";
|
||||||
|
{
|
||||||
|
auto d = evaluateLiteRollout(in);
|
||||||
|
EXPECT_FALSE(d.allowed);
|
||||||
|
EXPECT_TRUE(d.status == LiteRolloutStatus::DisabledByManifest);
|
||||||
|
EXPECT_EQ(d.message, std::string("paused for maintenance"));
|
||||||
|
}
|
||||||
|
in.manifest.globalEnabled = true;
|
||||||
|
in.manifest.message.clear();
|
||||||
|
|
||||||
|
// Version floor / ceiling / blocklist.
|
||||||
|
in.manifest.minVersion = "1.3.0";
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
|
||||||
|
in.manifest.minVersion.clear();
|
||||||
|
in.manifest.maxVersion = "1.1.0";
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
|
||||||
|
in.manifest.maxVersion.clear();
|
||||||
|
in.manifest.blockedVersions = {"1.2.0"};
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledBlockedVersion);
|
||||||
|
in.manifest.blockedVersions.clear();
|
||||||
|
|
||||||
|
// Within range and not blocked -> allowed.
|
||||||
|
in.manifest.minVersion = "1.0.0";
|
||||||
|
in.manifest.maxVersion = "2.0.0";
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLiteRolloutPolicyStagedRollout()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
LiteRolloutInputs in;
|
||||||
|
in.appVersion = "1.2.0";
|
||||||
|
in.manifest.present = true;
|
||||||
|
in.manifest.valid = true;
|
||||||
|
in.manifest.globalEnabled = true;
|
||||||
|
|
||||||
|
in.manifest.rolloutPermille = 0; // nobody (bucket 0 >= 0)
|
||||||
|
in.installBucket = 0;
|
||||||
|
EXPECT_FALSE(evaluateLiteRollout(in).allowed);
|
||||||
|
|
||||||
|
in.manifest.rolloutPermille = 500; // bucket < permille is in
|
||||||
|
in.installBucket = 499;
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||||||
|
in.installBucket = 500;
|
||||||
|
auto out = evaluateLiteRollout(in);
|
||||||
|
EXPECT_FALSE(out.allowed);
|
||||||
|
EXPECT_TRUE(out.status == LiteRolloutStatus::DisabledByStagedRollout);
|
||||||
|
|
||||||
|
in.manifest.rolloutPermille = 1000; // everyone (bucket 999 < 1000)
|
||||||
|
in.installBucket = 999;
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLiteRolloutManifestLoader()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
// Missing file -> not present -> fail-open allowed.
|
||||||
|
auto missing = loadLiteRolloutManifestFromFile("/tmp/obsidian-rollout-missing-xyz.json");
|
||||||
|
EXPECT_FALSE(missing.present);
|
||||||
|
{
|
||||||
|
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = missing;
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid manifest file parses into the expected fields.
|
||||||
|
const std::string path = "/tmp/obsidian-rollout-test.json";
|
||||||
|
{
|
||||||
|
std::ofstream f(path);
|
||||||
|
f << R"({"global_enabled":false,"min_version":"1.0.0","rollout_permille":250,"message":"hold"})";
|
||||||
|
}
|
||||||
|
auto m = loadLiteRolloutManifestFromFile(path);
|
||||||
|
EXPECT_TRUE(m.present);
|
||||||
|
EXPECT_TRUE(m.valid);
|
||||||
|
EXPECT_FALSE(m.globalEnabled);
|
||||||
|
EXPECT_EQ(m.minVersion, std::string("1.0.0"));
|
||||||
|
EXPECT_EQ(m.rolloutPermille, 250);
|
||||||
|
EXPECT_EQ(m.message, std::string("hold"));
|
||||||
|
std::remove(path.c_str());
|
||||||
|
|
||||||
|
// Malformed JSON -> present but invalid -> fail-open allowed.
|
||||||
|
const std::string badPath = "/tmp/obsidian-rollout-bad.json";
|
||||||
|
{ std::ofstream f(badPath); f << "{ this is not json"; }
|
||||||
|
auto bad = loadLiteRolloutManifestFromFile(badPath);
|
||||||
|
EXPECT_TRUE(bad.present);
|
||||||
|
EXPECT_FALSE(bad.valid);
|
||||||
|
{
|
||||||
|
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = bad;
|
||||||
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||||||
|
}
|
||||||
|
std::remove(badPath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// M5b: the rollout gate threads through the controller -> lifecycle availability() and blocks
|
||||||
|
// lifecycle execution with the gate's user-facing message.
|
||||||
|
void testLiteWalletControllerRolloutGate()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
||||||
|
const auto conn = defaultLiteConnectionSettings();
|
||||||
|
|
||||||
|
// Gated OFF: availability() reports RolloutDisabled, create is blocked, wallet stays closed.
|
||||||
|
{
|
||||||
|
LiteWalletController controller(
|
||||||
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
|
||||||
|
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ true,
|
||||||
|
"lite paused for maintenance"});
|
||||||
|
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::RolloutDisabled);
|
||||||
|
const auto result = controller.createWallet(LiteWalletCreateRequest{});
|
||||||
|
EXPECT_FALSE(result.walletReady);
|
||||||
|
EXPECT_TRUE(result.status.message.find("maintenance") != std::string::npos);
|
||||||
|
EXPECT_FALSE(controller.walletOpen());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not gated: availability() is Ready (matches the other lifecycle tests).
|
||||||
|
{
|
||||||
|
LiteWalletController controller(
|
||||||
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
|
||||||
|
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ false, ""});
|
||||||
|
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::Ready);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
@@ -3813,6 +4005,13 @@ int main()
|
|||||||
testLiteWalletGatewayRefreshSkipsFailedCommand();
|
testLiteWalletGatewayRefreshSkipsFailedCommand();
|
||||||
testLiteWalletControllerWorkerProducesModel();
|
testLiteWalletControllerWorkerProducesModel();
|
||||||
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
||||||
|
testLiteRolloutVersionCompare();
|
||||||
|
testLiteRolloutBucketAndOverrideHelpers();
|
||||||
|
testLiteRolloutPolicyOverridesAndKillSwitch();
|
||||||
|
testLiteRolloutPolicyManifestGates();
|
||||||
|
testLiteRolloutPolicyStagedRollout();
|
||||||
|
testLiteRolloutManifestLoader();
|
||||||
|
testLiteWalletControllerRolloutGate();
|
||||||
testGeneratedResourceBehavior();
|
testGeneratedResourceBehavior();
|
||||||
|
|
||||||
if (g_failures != 0) {
|
if (g_failures != 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user