Release v0.6.26: OpenBao settings synchronization with per-setting conflicts

This commit is contained in:
2026-11-02 00:37:57 +02:00
parent 908985d775
commit 22edece7cf
24 changed files with 1442 additions and 29 deletions
+80
View File
@@ -15,6 +15,37 @@ struct InputRegressionAccess {
static inline bool delivery = true;
static inline int notifications = 0;
static bool notify(const std::wstring &) { ++notifications; return delivery; }
static void sampleConflicts(App &a) {
a.conflictConnection=loadSyncConfig(a.data);
a.pendingSyncConflicts={{"/autoCorrectMinLength",L"Minimum word length",{'4'},{'5'}}};
}
static bool staleSync(App &a, bool connectionChanged) {
auto before=encodeSettings(a.settings);
a.syncConnection=loadSyncConfig(a.data);
a.syncSnapshot=before;
if(connectionChanged) a.syncConnection.enabled=!a.syncConnection.enabled;
else a.syncSnapshot.clear();
auto remote=a.settings; remote.recent=!remote.recent;
a.syncResult={true,sharedSyncSettings(remote),2,L"test"};
a.syncWorker=std::thread([]{});
a.syncRunning=true; a.syncDone=true;
a.pollSync();
return encodeSettings(a.settings)==before && !a.syncRunning &&
!std::filesystem::exists(a.data/L"sync-base.bin");
}
static bool syncBackoff(App &a) {
a.syncRetryAfter=GetTickCount64()+15*60000;
auto previous=a.settings;
auto next=previous;
next.toggle=next.casing=next.translit=next.undo=next.clipboard=next.screenshot=L"";
next.direct.clear(); next.directIds.clear();
// This harness deliberately has no global keyboard hook.
a.applySettings(next);
bool preserved=a.nextSync==a.syncRetryAfter;
a.settings=previous;
a.syncRetryAfter=0;
return preserved;
}
static void notificationSetup(App &a) { a.updateNotifier = notify; }
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
static bool updateTimer(App &a) {
@@ -595,6 +626,55 @@ int main(int argc, char **argv) {
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false);
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync available without local opt-in");
DestroyWindow(app.settingsWindow);
ks::saveSyncConfig(app.data,ks::SyncConfig{});
ks::showSettings(app,false);
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync button available after local opt-in");
SendMessageW(app.settingsWindow,WM_COMMAND,258,0);
auto syncWindow=FindWindowW(L"KSwitcher.Sync",nullptr);
require(syncWindow!=nullptr,"private sync dialog opens");
ShowWindow(syncWindow,SW_HIDE);
require((GetWindowLongPtrW(GetDlgItem(syncWindow,604),GWL_STYLE)&ES_PASSWORD)!=0,
"sync password is masked");
require(!IsWindowEnabled(app.settingsWindow),"sync dialog owns settings interaction");
ks::InputRegressionAccess::sampleConflicts(app);
SendMessageW(syncWindow,WM_COMMAND,612,0);
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
require(conflictsWindow!=nullptr,"per-setting conflict dialog opens");
ShowWindow(conflictsWindow,SW_HIDE);
require(SendDlgItemMessageW(conflictsWindow,620,LB_GETCOUNT,0,0)==1,"conflict shown as separate setting");
wchar_t conflictValue[20]{}; GetDlgItemTextW(conflictsWindow,621,conflictValue,20);
require(std::wstring(conflictValue)==L"4","local conflict value shown");
GetDlgItemTextW(conflictsWindow,622,conflictValue,20);
require(std::wstring(conflictValue)==L"5","remote conflict value shown");
for(int id:{620,621,622,623,624,IDOK,IDCANCEL}) {
RECT control{},client{};
GetWindowRect(GetDlgItem(conflictsWindow,id),&control);
MapWindowPoints(nullptr,conflictsWindow,reinterpret_cast<POINT*>(&control),2);
GetClientRect(conflictsWindow,&client);
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
"conflict controls fit dialog");
}
SendMessageW(conflictsWindow,WM_COMMAND,623,0);
require(!IsWindowEnabled(syncWindow),"conflict dialog owns sync interaction");
SendMessageW(conflictsWindow,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(syncWindow),"closing conflict dialog restores sync window");
for(int id:{601,602,603,604,605,606,607,608,609,610,611,612,IDOK,IDCANCEL}) {
RECT control{},client{};
GetWindowRect(GetDlgItem(syncWindow,id),&control);
MapWindowPoints(nullptr,syncWindow,reinterpret_cast<POINT*>(&control),2);
GetClientRect(syncWindow,&client);
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
"sync controls fit dialog");
}
SendMessageW(syncWindow,WM_COMMAND,IDOK,0);
require(!ks::loadSyncConfig(app.data).enabled,"saving disabled sync does not enable network access");
SendMessageW(syncWindow,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
require(ks::InputRegressionAccess::staleSync(app,true),"disconnect discards in-flight downloaded profile");
require(ks::InputRegressionAccess::syncBackoff(app),"saving settings preserves authentication error backoff");
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
require(autoOptions!=nullptr,"auto-correction options open");
+250
View File
@@ -0,0 +1,250 @@
#include "sync.h"
#include "json.hpp"
#include <iostream>
using namespace ks;
using Json = nlohmann::json;
namespace
{
int checks{};
void require(bool condition, const char *message)
{
if (!condition) throw std::runtime_error(message);
++checks;
}
template<class F> void rejects(F f, const char *message)
{
bool rejected = false;
try { f(); } catch (const std::exception &) { rejected = true; }
require(rejected, message);
}
Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; }
Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); }
struct FakeProvider : SyncProvider
{
SyncDocument remote;
int writes{};
bool race{};
SyncDocument read() override { return remote; }
uint64_t write(const Bytes &b, uint64_t expected) override
{
if (race) ++remote.revision;
if (expected != remote.revision) throw std::runtime_error("CAS conflict");
++writes; remote = {remote.revision + 1, b}; return remote.revision;
}
};
}
int main()
{
auto root = std::filesystem::temp_directory_path() / (L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId()));
try
{
std::filesystem::create_directories(root);
require(!syncVisible(root), "hidden without opt-in file");
require(!loadSyncConfig(root).enabled, "disabled by default");
SyncConfig config; config.server = L"https://bao.example.com"; config.username = L"person"; config.enabled = true;
validateSyncConfig(config);
for (const auto *server : {L"http://bao.example.com", L"https://user:pass@bao.example.com", L"https://bao.example.com/x", L"https://bao.example.com?x=1"})
{
auto invalid = config; invalid.server = server;
rejects([&] { validateSyncConfig(invalid); }, "reject unsafe endpoint");
}
auto invalid = config; invalid.path = L"../other";
rejects([&] { validateSyncConfig(invalid); }, "reject traversal");
invalid = config; invalid.provider = L"unknown";
rejects([&] { validateSyncConfig(invalid); }, "reject unsupported provider");
saveSyncConfig(root, config);
require(syncVisible(root) && loadSyncConfig(root) == config, "connection round trip");
auto rawConfig = parse(readFile(root / L"sync.json", 16384));
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
Settings local;
local.autostart = true; local.elevatedAutostart = true; local.tesseractPath = L"C:\\private\\tesseract.exe";
local.history = false;
local.snippets.push_back({L"test", L"private snippet"});
auto base = sharedSyncSettings(local);
Settings ordered = local, reordered = local;
ordered.directIds = {L"00000409", L"00000422"}; ordered.direct = {L"Ctrl+1", L"Ctrl+2"};
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
auto j = parse(base);
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded");
auto restored = restoreSyncSettings(base, local, {});
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
auto hostile = j; hostile["startWithSystem"] = false;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject injected device setting");
hostile = j; hostile["schemaVersion"] = 2;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema");
hostile = j; hostile["layouts"] = Json::array({"00000422"});
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "missing layouts block destructive normalization");
auto left = j, right = j;
left["convertLastWord"] = false; right["switchSystemLayout"] = false;
auto merged = parse(mergeSyncSettings(base, bytes(left), bytes(right)));
require(merged["convertLastWord"] == false && merged["switchSystemLayout"] == false, "merge independent edits");
left = right = j; left["autoCorrectMinLength"] = 4; right["autoCorrectMinLength"] = 5;
rejects([&] { mergeSyncSettings(base, bytes(left), bytes(right)); }, "same field conflict");
left = right = j; left["snippets"].push_back({{"trigger", "aaa"}, {"text", "one"}});
right["snippets"].push_back({{"trigger", "bbb"}, {"text", "two"}});
require(parse(mergeSyncSettings(base, bytes(left), bytes(right)))["snippets"].size() == 3, "independent snippet additions merge");
rejects([&] { mergeSyncSettings({}, bytes(left), base); }, "first sync needs explicit choice");
require(parse(mergeSyncSettings({}, base, base)) == j, "identical first sync safe");
require(SyncIntervalMs == 600000, "background interval is ten minutes");
{
auto l = j, r = j;
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
l["convertLastWord"] = false; r["switchSystemLayout"] = false;
auto plan = mergeSyncProfile(base, bytes(l), bytes(r));
require(plan.conflicts.size() == 1, "one conflict reported independently");
require(parse(plan.local)["switchSystemLayout"] == false && parse(plan.remote)["convertLastWord"] == false,
"nonconflicting changes reach both copies");
require(parse(plan.local)["autoCorrectMinLength"] == 4 && parse(plan.remote)["autoCorrectMinLength"] == 5,
"unresolved scalar retains both values");
require(parse(plan.base)["autoCorrectMinLength"] == j["autoCorrectMinLength"], "ancestor retained for unresolved setting");
saveSyncBase(root, config, plan.base);
auto again = mergeSyncProfile(loadSyncBase(root, config), plan.local, plan.remote);
require(again.conflicts.size() == 1, "conflict survives restart checkpoint");
auto choice = SyncChoice{plan.conflicts.front(), true};
auto resolved = mergeSyncProfile(plan.base, plan.local, plan.remote, {choice});
require(resolved.conflicts.empty() && parse(resolved.remote)["autoCorrectMinLength"] == 4,
"resolve only selected setting");
require(parse(resolved.remote)["switchSystemLayout"] == false, "choice preserves independent server change");
auto changed = parse(plan.remote); changed["autoCorrectMinLength"] = 6;
require(mergeSyncProfile(plan.base, plan.local, bytes(changed), {choice}).conflicts.size() == 1,
"stale conflict choice never overwrites new server value");
FakeProvider partial; partial.remote = {7, bytes(r)};
auto result = synchronize(partial, base, bytes(l), SyncAction::Automatic);
require(result.success && result.conflicts.size() == 1 && partial.writes == 1,
"partial synchronization commits independent fields with CAS");
auto encoded = encodeSyncChoices({choice});
require(decodeSyncChoices(encoded).front().conflict.key == choice.conflict.key, "choice IPC round trip");
}
{
auto id = j["snippets"][0]["id"].get<std::string>();
auto l = j, r = j;
l["snippets"][0]["trigger"] = "renamed";
r["snippets"][0]["text"] = "edited remotely";
auto merged = parse(mergeSyncSettings(base, bytes(l), bytes(r)));
require(merged["snippets"][0]["id"] == id && merged["snippets"][0]["trigger"] == "renamed" &&
merged["snippets"][0]["text"] == "edited remotely", "stable ID merges rename and text edit");
l = r = j; l["snippets"] = Json::array(); l["snippetTombstones"] = Json::array({id});
auto deletion = mergeSyncProfile(base, bytes(l), bytes(r));
require(deletion.conflicts.empty() && parse(deletion.remote)["snippets"].empty(), "delete beats unchanged snippet");
require(parse(deletion.remote)["snippetTombstones"] == Json::array({id}), "deletion marker retained");
auto offline = mergeSyncProfile(base, bytes(r), deletion.remote);
require(offline.conflicts.empty() && parse(offline.local)["snippets"].empty(), "offline copy cannot resurrect deletion");
r["snippets"][0]["text"] = "offline edit";
auto conflict = mergeSyncProfile(base, bytes(l), bytes(r));
require(conflict.conflicts.size() == 1, "delete versus edit is one snippet conflict");
auto remove = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], true}});
require(remove.conflicts.empty() && parse(remove.remote)["snippets"].empty(), "choose deletion");
auto keep = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], false}});
require(keep.conflicts.empty() && parse(keep.local)["snippetTombstones"].empty() &&
parse(keep.local)["snippets"][0]["text"] == "offline edit", "choose edited snippet removes tombstone");
require(mergeSyncProfile(base, bytes(l), bytes(l)).conflicts.empty(), "both delete converges");
auto legacy = j; legacy.erase("snippetTombstones"); legacy["snippets"][0].erase("id");
auto migrated = parse(mergeSyncSettings(bytes(legacy), base, bytes(legacy)));
require(migrated["snippets"][0]["id"] == id, "legacy profiles migrate to identical IDs");
auto roundtrip = decodeSettings(encodeSettings(restored), {});
require(snippetId(roundtrip.snippets.front()) == wide(id), "ID survives settings serialization");
require(newSnippetId() != newSnippetId(), "new snippet IDs are unique");
}
{
auto l = j, r = j;
l["snippets"].push_back({{"id", "new-a"}, {"trigger", "same"}, {"text", "A"}});
r["snippets"].push_back({{"id", "new-b"}, {"trigger", "same"}, {"text", "B"}});
auto collision = mergeSyncProfile(base, bytes(l), bytes(r));
require(collision.conflicts.size() == 2, "duplicate new triggers expose individual choices");
auto resolved = mergeSyncProfile(collision.base, collision.local, collision.remote,
{{collision.conflicts[0], true}, {collision.conflicts[1], true}});
require(resolved.conflicts.empty() && parse(resolved.remote)["snippets"].size() == 2, "choose one of colliding additions");
l = r = j; l["toggleHotkey"] = "Ctrl+Alt+F11"; r["caseHotkey"] = "Ctrl+Alt+F11";
require(mergeSyncProfile(base, bytes(l), bytes(r)).conflicts.size() == 2, "cross-field shortcut collisions exposed");
l = r = j; l["excludedApps"].push_back("local-app"); r["excludedApps"].push_back("remote-app");
require(parse(mergeSyncSettings(base, bytes(l), bytes(r)))["excludedApps"].size() == j["excludedApps"].size() + 2,
"independent exclusion edits merge");
}
FakeProvider provider;
rejects([&] { synchronize(provider, {}, base, SyncAction::Automatic); }, "empty server never auto-populated");
auto result = synchronize(provider, {}, base, SyncAction::Upload);
require(result.revision == 1 && provider.writes == 1, "initial upload CAS zero");
result = synchronize(provider, base, base, SyncAction::Automatic);
require(provider.writes == 1 && result.revision == 1, "no-op avoids versions");
result = synchronize(provider, base, bytes(left), SyncAction::Download);
require(parse(result.settings) == parse(base) && provider.writes == 1, "download never writes");
provider.race = true;
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
provider.race = false;
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload,
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
require(provider.writes == 1, "invalid data not uploaded");
saveSyncBase(root, config, base);
require(parse(loadSyncBase(root, config)) == j, "encrypted baseline round trip");
auto other = config; other.path = L"other/settings";
require(loadSyncBase(root, other).empty(), "baseline bound to connection");
auto encrypted = readFile(root / L"sync-base.bin", 1024 * 1024);
std::string disk(encrypted.begin(), encrypted.end());
require(disk.find("private snippet") == disk.npos, "baseline encrypted on disk");
// Exercise real Credential Manager with an isolated test target, never a user endpoint.
config.username = L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId());
saveSyncPassword(config, L"test-only-password");
int logins{}, revokes{}, reads{}, writes{};
unsigned readStatus = 200, writeStatus = 200;
bool deleted = false;
auto transport = [&](const std::wstring &method, const std::wstring &path,
const std::wstring &token, const Bytes &body) -> SyncHttpResponse {
if (path == L"/v1/auth/userpass/login/" + config.username)
{
require(method == L"POST" && token.empty(), "login route and no bearer token");
require(parse(body)["password"] == "test-only-password", "password sourced from credential manager");
++logins;
return {200, bytes({{"auth", {{"client_token", "test-token"}}}})};
}
require(token == L"test-token", "authenticated requests use returned token");
if (path == L"/v1/auth/token/revoke-self")
{
require(method == L"POST", "revoke method"); ++revokes; return {204, {}};
}
require(path == L"/v1/kswitcher/data/personal/settings", "KV v2 data route");
if (method == L"GET")
{
++reads;
if (readStatus != 200 && !deleted) return {readStatus, bytes({{"errors", Json::array()}})};
return {readStatus, bytes({{"data", {
{"metadata", {{"version", 7}, {"deletion_time", deleted ? "2026-01-01" : ""}, {"destroyed", false}}},
{"data", {{"syncSchema", 1}, {"settings", j}}}}}})};
}
require(method == L"POST", "write method");
auto request = parse(body);
require(request["options"]["cas"] == 7, "write includes exact CAS revision");
require(request["data"]["syncSchema"] == 2 && request["data"]["settings"] == j, "versioned settings envelope");
++writes;
return {writeStatus, bytes({{"data", {{"version", 8}}}})};
};
{
auto bao = makeSyncProvider(config, transport);
auto doc = bao->read();
require(doc.revision == 7 && parse(doc.settings) == j, "read protocol response");
require(bao->write(base, 7) == 8, "write protocol response");
readStatus = 404;
require(bao->read().revision == 0, "missing profile");
deleted = true;
rejects([&] { bao->read(); }, "soft deleted is not treated as absent");
deleted = false; readStatus = 403;
rejects([&] { bao->read(); }, "permission denied is not absent");
readStatus = 302;
rejects([&] { bao->read(); }, "redirect response rejected");
writeStatus = 400;
rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated");
}
require(logins == 1 && revokes == 1 && reads == 5 && writes == 2, "temporary login lifecycle");
forgetSyncPassword(config); forgetSyncPassword(config);
++checks;
std::filesystem::remove_all(root);
std::cout << checks << " sync checks passed\n";
return 0;
}
catch (const std::exception &e)
{
std::cerr << e.what() << "\n";
return 1;
}
}