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:
2026-06-06 12:01:08 -05:00
parent ca14aaddc7
commit b3c2282b53
12 changed files with 584 additions and 8 deletions

View File

@@ -21,6 +21,7 @@
#include "util/amount_format.h"
#include "util/payment_uri.h"
#include "wallet/lite_owned_string.h"
#include "wallet/lite_rollout_policy.h"
#include "wallet/lite_wallet_controller.h"
#include "wallet/lite_wallet_gateway.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
}
// 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
int main()
@@ -3813,6 +4005,13 @@ int main()
testLiteWalletGatewayRefreshSkipsFailedCommand();
testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerShutdownDoesNotHangDuringSync();
testLiteRolloutVersionCompare();
testLiteRolloutBucketAndOverrideHelpers();
testLiteRolloutPolicyOverridesAndKillSwitch();
testLiteRolloutPolicyManifestGates();
testLiteRolloutPolicyStagedRollout();
testLiteRolloutManifestLoader();
testLiteWalletControllerRolloutGate();
testGeneratedResourceBehavior();
if (g_failures != 0) {