From e25f52ef70ab27b4d656c93d700b2032d4a2f19b Mon Sep 17 00:00:00 2001 From: Nathan Jacobs Date: Sat, 24 Oct 2026 07:54:28 -0400 Subject: [PATCH] Add GitHub Actions CI and remove the stale C# projects Every push and pull request runs the Rust tests and builds the controller. Every push also builds the setup with installer\build.ps1 and uploads the setup .exe and the MSI as an artifact named after the short commit id, kept for 30 days. The C# tests, benchmarks and spike projects referenced the C# service libraries (Core, Transport, Cluster, Service and others) that the Rust rewrite replaced. They no longer compiled. Their tests are either ported one-for-one to the Rust workspace or covered the cluster model (shared roster, host election, leases, join keys) that pairing by code replaced. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 82 ++++ .../VirtualKmSwitch.Benchmarks/Program.cs | 1 - .../VirtualKmSwitch.Benchmarks.csproj | 22 - .../spike/VirtualKmSwitch.Spike/Program.cs | 417 ------------------ .../VirtualKmSwitch.Spike.csproj | 19 - .../spike/VirtualKmSwitch.UacSpike/Program.cs | 308 ------------- .../VirtualKmSwitch.UacSpike.csproj | 24 - src/csharp/spike/run-uac-spike.ps1 | 117 ----- .../VirtualKmSwitch.Tests/ClockSyncTests.cs | 203 --------- .../ClusterMembershipTests.cs | 215 --------- .../ClusterRegistryTests.cs | 152 ------- .../ClusterStoreTests.cs | 310 ------------- .../VirtualKmSwitch.Tests/ControlPipeTests.cs | 161 ------- .../VirtualKmSwitch.Tests/EnrollmentTests.cs | 128 ------ .../VirtualKmSwitch.Tests/FrameCodecTests.cs | 206 --------- .../HeldKeyTrackerTests.cs | 172 -------- .../HostElectionTests.cs | 379 ---------------- .../HotkeyMatcherTests.cs | 181 -------- .../InputCoalescerTests.cs | 174 -------- .../VirtualKmSwitch.Tests/InputRouterTests.cs | 207 --------- .../VirtualKmSwitch.Tests/JoinKeyTests.cs | 158 ------- .../KeyboardTranslatorTests.cs | 116 ----- .../LocalSettingsTests.cs | 164 ------- .../VirtualKmSwitch.Tests/MeshRoutingTests.cs | 261 ----------- .../MouseCalibrationTests.cs | 221 ---------- .../PairingWindowTests.cs | 284 ------------ .../PeerConnectionTests.cs | 380 ---------------- .../VirtualKmSwitch.Tests/PeerMeshTests.cs | 305 ------------- .../PeerSendPumpTests.cs | 274 ------------ .../RosterPinProjectionTests.cs | 180 -------- .../VirtualKmSwitch.Tests/RosterTests.cs | 286 ------------ .../SharedMemoryRingTests.cs | 236 ---------- .../SpscRingBufferTests.cs | 198 --------- .../VirtualKmSwitch.Tests.csproj | 29 -- 34 files changed, 82 insertions(+), 6488 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 src/csharp/bench/VirtualKmSwitch.Benchmarks/Program.cs delete mode 100644 src/csharp/bench/VirtualKmSwitch.Benchmarks/VirtualKmSwitch.Benchmarks.csproj delete mode 100644 src/csharp/spike/VirtualKmSwitch.Spike/Program.cs delete mode 100644 src/csharp/spike/VirtualKmSwitch.Spike/VirtualKmSwitch.Spike.csproj delete mode 100644 src/csharp/spike/VirtualKmSwitch.UacSpike/Program.cs delete mode 100644 src/csharp/spike/VirtualKmSwitch.UacSpike/VirtualKmSwitch.UacSpike.csproj delete mode 100644 src/csharp/spike/run-uac-spike.ps1 delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/ClockSyncTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/ClusterMembershipTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/ClusterRegistryTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/ClusterStoreTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/ControlPipeTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/EnrollmentTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/FrameCodecTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/HeldKeyTrackerTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/HostElectionTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/HotkeyMatcherTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/InputCoalescerTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/InputRouterTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/JoinKeyTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/KeyboardTranslatorTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/LocalSettingsTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/MeshRoutingTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/MouseCalibrationTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/PairingWindowTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/PeerConnectionTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/PeerMeshTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/PeerSendPumpTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/RosterPinProjectionTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/RosterTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/SharedMemoryRingTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/SpscRingBufferTests.cs delete mode 100644 src/csharp/tests/VirtualKmSwitch.Tests/VirtualKmSwitch.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..be5b33e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +# Tests every push and pull request; on a push, also builds the setup and uploads it as an artifact, +# to download from the run's Summary page. + +name: CI + +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +# A newer push to the same branch or PR supersedes a run still in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + test: + name: Test + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - name: Install Rust + run: rustup toolchain install stable --profile minimal && rustup default stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src/rust + + - name: Rust tests + working-directory: src/rust + run: cargo test --workspace --locked + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.4xx' + + # The controller has no tests of its own; building it (warnings are errors) is the check. + - name: Build controller + working-directory: src/csharp + run: dotnet build VirtualKmSwitch.slnx -c Release + + installer: + name: Installer + if: github.event_name != 'pull_request' + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - name: Install Rust + run: rustup toolchain install stable --profile minimal && rustup default stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src/rust + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.4xx' + + - name: Build setup + run: powershell -ExecutionPolicy Bypass -File installer\build.ps1 + + - name: Short commit id + id: sha + shell: bash + run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v7 + with: + name: VirtualKmSwitch-${{ steps.sha.outputs.short }}-x64 + path: | + target/installer/*-setup.exe + target/installer/*.msi + if-no-files-found: error + retention-days: 30 diff --git a/src/csharp/bench/VirtualKmSwitch.Benchmarks/Program.cs b/src/csharp/bench/VirtualKmSwitch.Benchmarks/Program.cs deleted file mode 100644 index 32248a3..0000000 --- a/src/csharp/bench/VirtualKmSwitch.Benchmarks/Program.cs +++ /dev/null @@ -1 +0,0 @@ -Console.WriteLine("Virtual KM Switch benchmarks (not yet implemented)."); diff --git a/src/csharp/bench/VirtualKmSwitch.Benchmarks/VirtualKmSwitch.Benchmarks.csproj b/src/csharp/bench/VirtualKmSwitch.Benchmarks/VirtualKmSwitch.Benchmarks.csproj deleted file mode 100644 index 81beb80..0000000 --- a/src/csharp/bench/VirtualKmSwitch.Benchmarks/VirtualKmSwitch.Benchmarks.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - false - true - - true - - - - - - - - - - - - - - diff --git a/src/csharp/spike/VirtualKmSwitch.Spike/Program.cs b/src/csharp/spike/VirtualKmSwitch.Spike/Program.cs deleted file mode 100644 index f800af2..0000000 --- a/src/csharp/spike/VirtualKmSwitch.Spike/Program.cs +++ /dev/null @@ -1,417 +0,0 @@ -using System.Diagnostics; -using VirtualKmSwitch.Core; -using VirtualKmSwitch.InputEngine; -using VirtualKmSwitch.Interop; - -namespace VirtualKmSwitch.Spike; - -/// -/// Verifies replay end to end on this machine: does a calibrated mouse delta actually travel the -/// distance it asked for, and does scancode keyboard replay reach a real application? -/// -/// The mouse half is self-checking -- it injects and then measures the cursor -- because the whole -/// point of calibration is that the number requested and the number travelled agree, and that is -/// measurable without a human. It is compared against uncalibrated injection so the correction is -/// visible rather than merely asserted. -/// -internal static class Program -{ - private static int Main(string[] args) - { - // Mode selection comes first: the verification path probes by moving the cursor, which - // has no business running when the caller asked for something else. - if (args.Contains("--control", StringComparer.OrdinalIgnoreCase)) - { - return QueryServiceAsync().GetAwaiter().GetResult(); - } - - if (args.Contains("--engage", StringComparer.OrdinalIgnoreCase)) - { - return RunEngagementDemo(); - } - - if (args.Contains("--tray", StringComparer.OrdinalIgnoreCase)) - { - return RunTrayCheck(); - } - - Console.WriteLine("Virtual KM Switch replay verification"); - Console.WriteLine(new string('=', 66)); - - (int t1, int t2, int accel, int speed) = PointerSettings.Read(); - var ballistics = new PointerBallistics(t1, t2, accel, speed); - - Console.WriteLine($"this machine: threshold1={t1} threshold2={t2} acceleration={accel} speed={speed}"); - Console.WriteLine($"\"Enhance pointer precision\": {(accel != 0 ? "ON" : "OFF")}" - + (accel != 0 ? " (chunking active)" : " (no acceleration curve; chunking disabled)")); - Console.WriteLine($"speed multiplier: {ballistics.SpeedMultiplier:0.###}x, " - + $"max unaccelerated chunk: {ballistics.MaxUnacceleratedChunk}"); - Console.WriteLine(); - - Console.WriteLine("Probing this machine's actual response (the cursor will move briefly)..."); - var probeInjector = new InputInjector(); - MouseCorrectionCurve correction = MouseCalibrationProbe.Measure(probeInjector, ballistics); - Console.WriteLine($"measured correction curve across {correction.SampleCount} magnitudes: " - + $"10px={correction.FactorFor(10):0.###}x 50px={correction.FactorFor(50):0.###}x " - + $"200px={correction.FactorFor(200):0.###}x"); - Console.WriteLine(); - - int failures = 0; - failures += VerifyMouseTravel(ballistics, correction); - - if (args.Contains("--keyboard", StringComparer.OrdinalIgnoreCase)) - { - VerifyKeyboardReplay(); - } - else - { - Console.WriteLine(); - Console.WriteLine("Skipping the keyboard check (needs a focused Notepad)."); - Console.WriteLine("Run with --keyboard to include it."); - } - - failures += VerifyHookHealth(); - - Console.WriteLine(); - Console.WriteLine("Watching pointer settings for 6s -- change the speed slider or toggle"); - Console.WriteLine("\"Enhance pointer precision\" now to see it detected."); - - using (var monitor = new PointerSettingsMonitor( - changed => Console.WriteLine($" CHANGE DETECTED: speed={changed.Speed} " - + $"acceleration={changed.Acceleration} " - + $"(pointer precision {(changed.IsPointerPrecisionEnabled ? "ON" : "OFF")}) " - + "-- calibration is now stale and would be re-probed"), - TimeSpan.FromSeconds(1))) - { - Thread.Sleep(TimeSpan.FromSeconds(6)); - } - - Console.WriteLine(); - Console.WriteLine(new string('=', 66)); - Console.WriteLine(failures == 0 ? "RESULT: replay verified." : $"RESULT: {failures} check(s) failed."); - return failures; - } - - /// - /// Talks to the running service over the control pipe, as an ordinary unelevated user. - /// - /// This is the cross-privilege check: a LocalSystem service and a normal-user client, with the - /// two-tier authorisation deciding what the caller may do from its own token rather than from - /// anything it claims. - /// - private static async Task QueryServiceAsync() - { - Console.WriteLine("Control pipe check (running unelevated)"); - Console.WriteLine(new string('=', 66)); - - await using var client = new VirtualKmSwitch.Ipc.ControlPipeClient(); - - try - { - await client.ConnectAsync(TimeSpan.FromSeconds(5)); - Console.WriteLine("connected to the service"); - } - catch (Exception ex) - { - Console.WriteLine($"could not connect: {ex.Message}"); - Console.WriteLine("Is the service installed and running?"); - return 1; - } - - Console.WriteLine(); - Console.WriteLine("-- runtime-tier verb (should succeed) --"); - VirtualKmSwitch.Ipc.ControlResponse status = await client.SendAsync( - new VirtualKmSwitch.Ipc.ControlRequest { Verb = VirtualKmSwitch.Ipc.ControlVerb.GetStatus }); - - Console.WriteLine($" success: {status.Success}"); - if (status.Values is not null) - { - foreach ((string key, string value) in status.Values) - { - Console.WriteLine($" {key} = {value}"); - } - } - - Console.WriteLine(); - Console.WriteLine("-- membership (runtime tier: reading who is a member is not privileged) --"); - VirtualKmSwitch.Ipc.ControlResponse machines = await client.SendAsync( - new VirtualKmSwitch.Ipc.ControlRequest { Verb = VirtualKmSwitch.Ipc.ControlVerb.GetMachines }); - - VirtualKmSwitch.Ipc.ClusterView? view = VirtualKmSwitch.Ipc.ClusterView.FromResponse(machines); - if (view is null) - { - Console.WriteLine($" no view returned: {machines.Error}"); - } - else - { - Console.WriteLine($" cluster : {(view.HasCluster ? $"'{view.ClusterName}' ({view.ClusterId})" : "none")}"); - Console.WriteLine($" this machine: {view.LocalNodeId}"); - Console.WriteLine($" health : {view.Health}"); - Console.WriteLine($" join key : {(view.JoinKey is null ? "not disclosed" : "DISCLOSED -- it should not be")}"); - - foreach (VirtualKmSwitch.Ipc.MachineInfo machine in view.Machines) - { - Console.WriteLine($" {machine.Label}{(machine.IsLocal ? " (this machine)" : string.Empty)}" - + $" {machine.NodeId:N}"); - } - } - - Console.WriteLine(); - Console.WriteLine("-- administrative verb (should be refused unless elevated) --"); - VirtualKmSwitch.Ipc.ControlResponse enroll = await client.SendAsync( - new VirtualKmSwitch.Ipc.ControlRequest { Verb = VirtualKmSwitch.Ipc.ControlVerb.EnrollPeer }); - - Console.WriteLine($" success: {enroll.Success}"); - Console.WriteLine($" error : {enroll.Error}"); - - Console.WriteLine(); - Console.WriteLine(new string('=', 66)); - return status.Success ? 0 : 1; - } - - /// - /// Puts a real icon in the notification area and reports what the user does with it. - /// - /// Shell_NotifyIcon failing is the one thing here that cannot be inferred from a build, and - /// the controller deliberately swallows that failure rather than refusing to start — so this - /// is where it is allowed to be loud. - /// - private static int RunTrayCheck() - { - Console.WriteLine("Virtual KM Switch tray icon check"); - Console.WriteLine(new string('=', 66)); - - TrayIcon icon; - try - { - icon = new TrayIcon( - "Virtual KM Switch spike", - [ - new TrayMenuItem(1, "Say hello"), - TrayMenuItem.Separator, - new TrayMenuItem(2, "Quit"), - ]); - } - catch (Exception ex) - { - Console.WriteLine($"FAILED to add the icon: {ex.Message}"); - return 1; - } - - Console.WriteLine("icon added. Look in the notification area (it may be under the overflow arrow)."); - Console.WriteLine(" left-click -> reports activation"); - Console.WriteLine(" right-click -> shows the menu"); - Console.WriteLine(); - - using var finished = new ManualResetEventSlim(false); - using (icon) - { - icon.Activated += () => Console.WriteLine("activated (left click)"); - icon.CommandInvoked += command => - { - Console.WriteLine($"menu command {command} chosen"); - if (command == 2) - { - finished.Set(); - } - }; - - Console.WriteLine("waiting up to 60s; choose Quit from the menu to finish early."); - finished.Wait(TimeSpan.FromSeconds(60)); - } - - Console.WriteLine("icon removed."); - return 0; - } - - /// - /// Drives the full engagement path by hand: hotkeys, blocking, capture and panic. - /// - /// Nothing is sent anywhere -- captured events are printed -- so this shows exactly what would - /// have gone on the wire, and what the local machine was prevented from seeing. - /// - private static int RunEngagementDemo() - { - long captured = 0; - - Console.WriteLine("Engagement demo -- 30 seconds."); - Console.WriteLine(); - Console.WriteLine(" Ctrl+Alt+1 engage (keyboard and mouse go dead locally)"); - Console.WriteLine(" Ctrl+Alt+0 return control"); - Console.WriteLine(" Ctrl+Alt+Shift+Esc panic"); - Console.WriteLine(); - Console.WriteLine("While engaged, typing and mouse movement are captured and shown here"); - Console.WriteLine("instead of reaching the desktop."); - Console.WriteLine(); - - // A hard backstop: blocking must never outlive this process, whatever else goes wrong. - var watchdog = new Thread(() => - { - Thread.Sleep(TimeSpan.FromSeconds(40)); - Environment.Exit(99); - }) - { IsBackground = true }; - watchdog.Start(); - - using var controller = new EngagementController( - sink: (in InputEvent e) => - { - long total = Interlocked.Increment(ref captured); - - // Only keys and buttons are printed: motion at kilohertz rates would bury them. - if (e.Kind != InputEventKind.MouseMove && total < 200) - { - Console.WriteLine($" -> {e.Kind} code={e.Code} flags={e.Flags}"); - } - }, - log: message => Console.WriteLine($" [{message}]")); - - controller.StateChanged += (state, slot) => - Console.WriteLine($" STATE: {state}{(slot > 0 ? $" -> slot {slot}" : string.Empty)}"); - controller.HostClaimRequested += () => Console.WriteLine(" HOST CLAIM requested"); - controller.PanicTriggered += () => Console.WriteLine(" PANIC handled"); - - Thread.Sleep(TimeSpan.FromSeconds(30)); - - Console.WriteLine(); - Console.WriteLine($" captured {Interlocked.Read(ref captured)} events while engaged"); - Console.WriteLine($" final state: {controller.State}"); - return 0; - } - - /// - /// Confirms the watchdog can actually tell a live hook from a dead one. A liveness probe that - /// always answers "alive" is worse than no watchdog at all, so both directions are checked. - /// - private static int VerifyHookHealth() - { - Console.WriteLine(); - Console.WriteLine("Hook liveness watchdog:"); - - var injector = new InputInjector(); - using var hook = new LowLevelHook(); - using var monitor = new HookHealthMonitor(hook, injector, log: m => Console.WriteLine($" {m}")); - - HookProbeResult alive = monitor.Probe(); - Console.WriteLine($" hook installed : {alive}"); - - // Disposing the hook is the closest available stand-in for Windows silently removing it. - hook.Dispose(); - HookProbeResult dead = monitor.Probe(); - Console.WriteLine($" hook disposed : {dead}"); - - bool aliveDetected = alive == HookProbeResult.Alive; - bool deadDetected = dead == HookProbeResult.Dead; - - bool ok = aliveDetected && deadDetected; - Console.WriteLine($" => watchdog {(ok ? "distinguishes live from dead" : "IS NOT RELIABLE")}"); - return ok ? 0 : 1; - } - - private static int VerifyMouseTravel(PointerBallistics ballistics, MouseCorrectionCurve correction) - { - var injector = new InputInjector(); - var calibration = new MouseCalibration(ballistics) { Correction = correction }; - - (int originX, int originY) = Cursor.GetPosition(); - - Console.WriteLine("Injecting horizontal moves and measuring actual cursor travel."); - Console.WriteLine(); - Console.WriteLine(" requested | uncalibrated | calibrated | calibrated error"); - Console.WriteLine(" " + new string('-', 60)); - - int failures = 0; - - foreach (int requested in new[] { 10, 25, 50, 100, 200, 400 }) - { - int uncalibrated = MeasureUncalibrated(injector, requested); - - calibration.Reset(); - int calibrated = MeasureCalibrated(injector, calibration, requested); - - int error = calibrated - requested; - - // A little slack: the measurement races the input pipeline, and the cursor clamps at - // screen edges. - bool ok = Math.Abs(error) <= Math.Max(2, requested / 20); - if (!ok) - { - failures++; - } - - Console.WriteLine($" {requested,9} | {uncalibrated,12} | {calibrated,10} | " - + $"{error,4} {(ok ? "ok" : "FAIL")}"); - } - - Cursor.SetPosition(originX, originY); - return failures; - } - - private static int MeasureUncalibrated(InputInjector injector, int requested) - { - int start = BeginMeasure(); - - Span single = stackalloc InputEvent[1]; - single[0] = InputEvent.MouseMove(Stopwatch.GetTimestamp(), requested, 0); - injector.Inject(single); - - return EndMeasure(start); - } - - private static int MeasureCalibrated(InputInjector injector, MouseCalibration calibration, int requested) - { - int start = BeginMeasure(); - - Span chunks = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - int count = calibration.Prepare(Stopwatch.GetTimestamp(), requested, 0, chunks); - injector.Inject(chunks[..count]); - - return EndMeasure(start); - } - - /// Parks the cursor well away from any screen edge, which would clamp travel. - private static int BeginMeasure() - { - Cursor.SetPosition(400, 400); - Thread.Sleep(60); - return Cursor.GetPosition().X; - } - - private static int EndMeasure(int start) - { - Thread.Sleep(120); // let the input pipeline settle - return Cursor.GetPosition().X - start; - } - - private static void VerifyKeyboardReplay() - { - Console.WriteLine(); - Console.WriteLine("Keyboard replay: focus a Notepad window within 5 seconds..."); - for (int i = 5; i > 0; i--) - { - Console.Write($"{i}... "); - Thread.Sleep(1000); - } - - Console.WriteLine(); - - var injector = new InputInjector(); - - // Scancodes for "virtual ok" typed on a US layout. Scancodes rather than virtual keys - // is the whole point: the target reproduces the physical key, not a layout-dependent - // character. - ushort[] scanCodes = [0x2F, 0x17, 0x13, 0x14, 0x16, 0x1E, 0x26, 0x39, 0x18, 0x25]; - - Span batch = stackalloc InputEvent[2]; - foreach (ushort scanCode in scanCodes) - { - long now = Stopwatch.GetTimestamp(); - batch[0] = InputEvent.Key(now, scanCode, InputEventFlags.None); - batch[1] = InputEvent.Key(now, scanCode, InputEventFlags.Up); - injector.Inject(batch); - Thread.Sleep(30); - } - - Console.WriteLine(" Injected. Check whether the text appeared in Notepad."); - } -} diff --git a/src/csharp/spike/VirtualKmSwitch.Spike/VirtualKmSwitch.Spike.csproj b/src/csharp/spike/VirtualKmSwitch.Spike/VirtualKmSwitch.Spike.csproj deleted file mode 100644 index dd4013d..0000000 --- a/src/csharp/spike/VirtualKmSwitch.Spike/VirtualKmSwitch.Spike.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - - Exe - false - true - - - - - - - - - - diff --git a/src/csharp/spike/VirtualKmSwitch.UacSpike/Program.cs b/src/csharp/spike/VirtualKmSwitch.UacSpike/Program.cs deleted file mode 100644 index fb80229..0000000 --- a/src/csharp/spike/VirtualKmSwitch.UacSpike/Program.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Interop; - -namespace VirtualKmSwitch.UacSpike; - -/// -/// Proves (or disproves) the premise of the three-tier process model: that a LocalSystem service -/// can place a helper inside the interactive session, and that the helper can follow the input -/// desktop onto Winlogon and inject there -- i.e. drive a live UAC prompt. -/// -/// Two modes in one executable: -/// --service : runs as LocalSystem in session 0, spawns the agent into the console session. -/// --agent : runs as SYSTEM inside that session, follows the desktop, injects on Winlogon. -/// -/// Neither mode has a console, so both log to a file. -/// -/// The injected key is ESCAPE, which *dismisses* a UAC prompt. Choosing Escape rather than -/// Enter keeps the spike from approving an elevation it did not intend to: if the prompt -/// disappears, injection into the secure desktop demonstrably works, and nothing was granted. -/// -internal static class Program -{ - private static readonly string LogDirectory = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "VirtualKmSwitch"); - - private static readonly string _logPath = Path.Combine(LogDirectory, "uac-spike.log"); - - /// Used if ProgramData is not writable by whatever identity we ended up running as. - private static readonly string FallbackLogPath = - Path.Combine(Path.GetTempPath(), "vkms-uac-spike.log"); - private static readonly Lock LogGate = new(); - - private static int Main(string[] args) - { - // Logged before anything else can fail. Under Task Scheduler there is no console, so a - // process that starts and then exits down some other path is indistinguishable from one - // that never started at all -- unless it says so here first. - Log($"=== process start === argc={args.Length} args=[{string.Join(" | ", args)}] " - + $"cwd={Environment.CurrentDirectory}"); - - string mode = args.FirstOrDefault() ?? "--help"; - - return mode switch - { - "--service" => RunService(), - "--agent" => RunAgent(), - "--agent-secure" => RunSecureDesktopAgent(), - "--tail" => TailLog(), - _ => ShowHelp(), - }; - } - - private static int ShowHelp() - { - Console.WriteLine("Virtual KM Switch UAC spike"); - Console.WriteLine(); - Console.WriteLine(" --service run as LocalSystem (session 0); spawns the agent"); - Console.WriteLine(" --agent run inside the console session as SYSTEM"); - Console.WriteLine(" --tail print the log written by the other two modes"); - Console.WriteLine(); - Console.WriteLine($"Log: {_logPath}"); - return 1; - } - - private static int TailLog() - { - if (!File.Exists(_logPath)) - { - Console.WriteLine($"No log yet at {_logPath}"); - return 1; - } - - foreach (string line in File.ReadAllLines(_logPath)) - { - Console.WriteLine(line); - } - - return 0; - } - - // --------------------------------------------------------------------------------------- - // Service side: session 0. Cannot inject anything itself; its whole job is placing the agent. - // --------------------------------------------------------------------------------------- - private static int RunService() - { - Log("=== service starting ==="); - Log($"identity: {Environment.UserName}, session: {Process.GetCurrentProcess().SessionId}"); - - try - { - uint session = SessionLauncher.GetActiveConsoleSession(); - Log($"active console session: {session}"); - - if (session == SessionLauncher.NoSession) - { - Log("no interactive session attached; nothing to do"); - return 1; - } - - string executable = Environment.ProcessPath - ?? throw new InvalidOperationException("Cannot determine own executable path."); - - uint defaultPid = SessionLauncher.LaunchAsSystemInSession( - executable, "--agent", session, @"Winsta0\Default"); - Log($"spawned Default-desktop agent as SYSTEM in session {session}, pid {defaultPid}"); - - // The interesting one: created directly on the secure desktop rather than migrating - // onto it later. SetThreadDesktop moved a thread there successfully but SendInput was - // still denied, which points at the process's own desktop association mattering. - try - { - uint securePid = SessionLauncher.LaunchAsSystemInSession( - executable, "--agent-secure", session, @"Winsta0\Winlogon"); - Log($"spawned Winlogon-desktop agent as SYSTEM in session {session}, pid {securePid}"); - } - catch (Exception ex) - { - Log($"could not spawn agent on Winlogon desktop: {ex.Message}"); - } - - return 0; - } - catch (Exception ex) - { - Log($"SERVICE FAILED: {ex}"); - return 2; - } - } - - // --------------------------------------------------------------------------------------- - // Agent side: inside the interactive session, as SYSTEM. This is the half that can inject. - // --------------------------------------------------------------------------------------- - private static int RunAgent() - { - Log("=== agent starting ==="); - Log($"identity: {Environment.UserName}, session: {Process.GetCurrentProcess().SessionId}"); - using (var identity = System.Security.Principal.WindowsIdentity.GetCurrent()) - { - Log($"is SYSTEM: {identity.IsSystem}, sid: {identity.User}"); - } - - var injector = new InputInjector(); - int secureDesktopVisits = 0; - - try - { - using var watcher = new DesktopWatcher( - onDesktopChanged: name => - { - Log($"input desktop is now '{name}'"); - - if (!string.Equals(name, "Winlogon", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - secureDesktopVisits++; - Log($"SECURE DESKTOP reached (visit {secureDesktopVisits}) -- injecting ESC"); - - // Injection must happen on the thread that owns the desktop attachment, - // which is exactly the thread this callback runs on. - long now = Stopwatch.GetTimestamp(); - Span escape = - [ - InputEvent.Key(now, 0x01, InputEventFlags.None), - InputEvent.Key(now, 0x01, InputEventFlags.Up), - ]; - - uint accepted = injector.Inject(escape); - int error = Marshal.GetLastWin32Error(); - - // SendInput reports UIPI blocking neither in its return value nor in - // GetLastError, so 'accepted' is necessary but not sufficient evidence. - // Whether the prompt actually closed is the real result. - Log($" SendInput accepted {accepted}/2 events (last error {error})"); - }, - log: Log); - - Log("watching for desktop switches for 120s -- trigger a UAC prompt now"); - - for (int i = 0; i < 120; i++) - { - Thread.Sleep(1000); - } - - Log($"=== agent finished; secure desktop reached {secureDesktopVisits} time(s) ==="); - return secureDesktopVisits > 0 ? 0 : 1; - } - catch (Exception ex) - { - Log($"AGENT FAILED: {ex}"); - return 2; - } - } - - /// - /// Runs already attached to the Winlogon desktop, having been created there. It therefore never - /// calls SetThreadDesktop -- doing so is what the previous attempt did, and injection was - /// denied. It only watches for its own desktop becoming the active input desktop. - /// - private static int RunSecureDesktopAgent() - { - Log("=== secure-desktop agent starting ==="); - Log($"identity: {Environment.UserName}, session: {Process.GetCurrentProcess().SessionId}"); - - using (var identity = System.Security.Principal.WindowsIdentity.GetCurrent()) - { - Log($"is SYSTEM: {identity.IsSystem}"); - } - - var injector = new InputInjector(); - string previous = string.Empty; - int injections = 0; - - try - { - for (int tick = 0; tick < 1200; tick++) - { - string current = DesktopWatcher.GetInputDesktopName(); - - if (current != previous) - { - Log($"input desktop is now '{current}'"); - previous = current; - - if (string.Equals(current, "Winlogon", StringComparison.OrdinalIgnoreCase)) - { - injections++; - Log($"SECURE DESKTOP active (visit {injections}) -- running injection sequence"); - - // The desktop switches BEFORE consent.exe has created and focused its - // dialog, so injecting immediately can land before anything exists to - // receive it. Several attempts are spread over a few seconds, and both - // encodings are tried, so a null result means "rejected" rather than - // "mistimed" or "wrong encoding". - InjectAfter(injector, 750, "scancode", scanCode: true); - InjectAfter(injector, 750, "virtual-key", scanCode: false); - InjectAfter(injector, 1000, "scancode (late)", scanCode: true); - InjectAfter(injector, 1000, "virtual-key (late)", scanCode: false); - } - } - - Thread.Sleep(100); - } - - Log($"=== secure agent finished; {injections} injection attempt(s) ==="); - return 0; - } - catch (Exception ex) - { - Log($"SECURE AGENT FAILED: {ex}"); - return 2; - } - } - - /// - /// Waits, then injects Escape once, reporting exactly what SendInput said. - /// - private static void InjectAfter(InputInjector injector, int delayMilliseconds, string label, bool scanCode) - { - Thread.Sleep(delayMilliseconds); - - const ushort EscapeScanCode = 0x01; - const ushort VkEscape = 0x1B; - - long now = Stopwatch.GetTimestamp(); - Span escape = scanCode - ? - [ - InputEvent.Key(now, EscapeScanCode, InputEventFlags.None), - InputEvent.Key(now, EscapeScanCode, InputEventFlags.Up), - ] - : - [ - InputEvent.Key(now, VkEscape, InputEventFlags.VirtualKey), - InputEvent.Key(now, VkEscape, InputEventFlags.VirtualKey | InputEventFlags.Up), - ]; - - uint accepted = injector.Inject(escape); - int error = Marshal.GetLastWin32Error(); - Log($" [{label}] on '{DesktopWatcher.GetInputDesktopName()}': " - + $"SendInput accepted {accepted}/2 (last error {error})"); - } - - private static void Log(string message) - { - string line = $"{DateTime.Now:HH:mm:ss.fff} [{Environment.ProcessId,6}] {message}"; - lock (LogGate) - { - foreach (string path in new[] { _logPath, FallbackLogPath }) - { - try - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.AppendAllText(path, line + Environment.NewLine); - return; - } - catch - { - // Fall through to the next candidate. A spike must never die because logging - // failed, but it must also not fail *silently* -- hence the second location. - } - } - } - } -} diff --git a/src/csharp/spike/VirtualKmSwitch.UacSpike/VirtualKmSwitch.UacSpike.csproj b/src/csharp/spike/VirtualKmSwitch.UacSpike/VirtualKmSwitch.UacSpike.csproj deleted file mode 100644 index 1d61e01..0000000 --- a/src/csharp/spike/VirtualKmSwitch.UacSpike/VirtualKmSwitch.UacSpike.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - - Exe - false - true - VirtualKmSwitch.UacSpike - - - - - - - - - - - - - diff --git a/src/csharp/spike/run-uac-spike.ps1 b/src/csharp/spike/run-uac-spike.ps1 deleted file mode 100644 index d1a61e1..0000000 --- a/src/csharp/spike/run-uac-spike.ps1 +++ /dev/null @@ -1,117 +0,0 @@ -#Requires -RunAsAdministrator -<# - Runs the UAC / secure-desktop spike. - - The service half must run in session 0 to reproduce the real constraint, so it is launched as - SYSTEM via a scheduled task -- the same isolation a Windows service gets, without the SCM - plumbing, which belongs to the real implementation rather than to a spike. - - Everything created here is removed again at the end. -#> - -$ErrorActionPreference = 'Stop' - -$taskName = 'VirtualKmSwitchUacSpike' -$logPath = Join-Path $env:ProgramData 'VirtualKmSwitch\uac-spike.log' -$exePath = Join-Path $PSScriptRoot 'VirtualKmSwitch.UacSpike\bin\Debug\net10.0-windows10.0.19041.0\VirtualKmSwitch.UacSpike.exe' - -function Remove-SpikeTask { - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue -} - -function Stop-SpikeProcesses { - Get-Process -Name 'VirtualKmSwitch.UacSpike' -ErrorAction SilentlyContinue | - Stop-Process -Force -ErrorAction SilentlyContinue -} - -if (-not (Test-Path $exePath)) { - Write-Host 'Not built. Run:' -ForegroundColor Red - Write-Host ' dotnet build src/csharp/spike/VirtualKmSwitch.UacSpike/VirtualKmSwitch.UacSpike.csproj' - Write-Host "Expected: $exePath" - exit 1 -} - -Write-Host 'Virtual KM Switch UAC spike' -ForegroundColor Cyan -Write-Host ('=' * 62) -Write-Host "exe : $exePath" -Write-Host "log : $logPath" -Write-Host '' - -# Start from a clean log so the result is unambiguous. -if (Test-Path $logPath) { Remove-Item $logPath -Force -ErrorAction SilentlyContinue } - -Remove-SpikeTask -Stop-SpikeProcesses - -try { - Write-Host 'Registering SYSTEM task (runs in session 0)...' -NoNewline - $action = New-ScheduledTaskAction -Execute $exePath -Argument '--service' - $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest - - # AllowStartIfOnBatteries is essential, not cosmetic: the default is to REFUSE to start on - # battery, and the task then sits in Queued forever with no error and no process -- which - # looks identical to the launch itself having failed. - $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable - - $null = Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force - Write-Host ' done' - - Write-Host 'Starting it...' -NoNewline - Start-ScheduledTask -TaskName $taskName - Write-Host ' done' - - Start-Sleep -Seconds 4 - - $taskInfo = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue - $taskState = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State - Remove-SpikeTask - - $agent = Get-Process -Name 'VirtualKmSwitch.UacSpike' -ErrorAction SilentlyContinue - if (-not $agent) { - Write-Host '' - Write-Host 'The agent is NOT running: the service half could not place it in the session.' -ForegroundColor Red - Write-Host '' - if ($taskInfo) { - Write-Host (" task state : {0}" -f $taskState) - Write-Host (" last run time : {0}" -f $taskInfo.LastRunTime) - Write-Host (" last task result : 0x{0:X}" -f $taskInfo.LastTaskResult) - if ($taskState -eq 'Queued') { - Write-Host ' (Queued means Task Scheduler never ran it -- a conditions problem, not a code one.)' -ForegroundColor Yellow - } - } - Write-Host '' - Write-Host 'Log:' -ForegroundColor Yellow - if (Test-Path $logPath) { Get-Content $logPath } else { Write-Host ' (no log written -- the process never started)' } - exit 1 - } - - Write-Host '' - Write-Host ('=' * 62) -ForegroundColor Green - Write-Host ' AGENT IS RUNNING AS SYSTEM IN YOUR SESSION' -ForegroundColor Green - Write-Host ('=' * 62) -ForegroundColor Green - Write-Host '' - Write-Host ' Now TRIGGER A UAC PROMPT. From a NORMAL (non-admin) PowerShell:' -ForegroundColor Yellow - Write-Host '' - Write-Host ' Start-Process notepad -Verb RunAs' -ForegroundColor White - Write-Host '' - Write-Host ' The agent injects ESCAPE on reaching the secure desktop, which DISMISSES the' -ForegroundColor Yellow - Write-Host ' prompt rather than approving it.' -ForegroundColor Yellow - Write-Host '' - Write-Host ' WATCH FOR: does the UAC prompt close by itself?' -ForegroundColor Cyan - Write-Host '' - - for ($i = 60; $i -gt 0; $i -= 5) { - Write-Host " ${i}s remaining" -ForegroundColor DarkGray - Start-Sleep -Seconds 5 - } -} -finally { - Remove-SpikeTask - Stop-SpikeProcesses -} - -Write-Host '' -Write-Host ('=' * 62) -Write-Host 'LOG' -ForegroundColor Cyan -Write-Host ('=' * 62) -if (Test-Path $logPath) { Get-Content $logPath } else { Write-Host '(no log written)' -ForegroundColor Red } diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ClockSyncTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ClockSyncTests.cs deleted file mode 100644 index eee486d..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/ClockSyncTests.cs +++ /dev/null @@ -1,203 +0,0 @@ -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// The offset estimator, driven with synthetic timestamps so the answer is known exactly. -/// -/// Worth testing precisely rather than approximately: every latency figure the system reports is -/// this number subtracted from another, so an error here does not surface as a wrong measurement, -/// it surfaces as a measurement that looks right. -/// -public class ClockSyncTests -{ - /// - /// Builds one round trip for a peer whose clock reads - /// microseconds later than ours, with the given one-way delay in each direction. - /// - private static (long T1, long T2, long T3, long T4) RoundTrip( - long localSend, long remoteAhead, long delayOut, long remoteThinking, long delayBack) - { - long t1 = localSend; - long t2 = localSend + delayOut + remoteAhead; - long t3 = t2 + remoteThinking; - long t4 = t3 - remoteAhead + delayBack; - return (t1, t2, t3, t4); - } - - [Fact] - public void ASymmetricRoundTripRecoversTheOffsetExactly() - { - var sync = new ClockSync(); - (long t1, long t2, long t3, long t4) = RoundTrip( - localSend: 1_000_000, remoteAhead: 250_000, delayOut: 300, remoteThinking: 50, delayBack: 300); - - sync.Record(t1, t2, t3, t4); - - // Signed as local minus remote, because that is how callers use it: a peer timestamp plus - // the offset is that instant on our clock. - Assert.Equal(-250_000, sync.OffsetMicroseconds); - Assert.Equal(600, sync.RoundTripMicroseconds); - Assert.True(sync.HasSample); - } - - [Fact] - public void TheResponderIsNotChargedForItsOwnThinkingTime() - { - var sync = new ClockSync(); - - // A peer that sat on the ping for 5 ms before replying. That is not network delay, and - // counting it would halve into the offset and skew every later measurement. - (long t1, long t2, long t3, long t4) = RoundTrip( - localSend: 0, remoteAhead: 0, delayOut: 200, remoteThinking: 5_000, delayBack: 200); - - sync.Record(t1, t2, t3, t4); - - Assert.Equal(0, sync.OffsetMicroseconds); - Assert.Equal(400, sync.RoundTripMicroseconds); - } - - [Fact] - public void TheLowestRoundTripSampleWins() - { - var sync = new ClockSync(); - - // A congested sample first. Its delays are lopsided, so its offset is wrong by half the - // asymmetry — which is exactly the error the minimum-round-trip rule is there to reject. - (long t1, long t2, long t3, long t4) = RoundTrip( - localSend: 0, remoteAhead: 1_000, delayOut: 9_000, remoteThinking: 0, delayBack: 1_000); - sync.Record(t1, t2, t3, t4); - long congested = sync.OffsetMicroseconds; - - (t1, t2, t3, t4) = RoundTrip( - localSend: 100_000, remoteAhead: 1_000, delayOut: 150, remoteThinking: 0, delayBack: 150); - sync.Record(t1, t2, t3, t4); - - Assert.NotEqual(congested, sync.OffsetMicroseconds); - Assert.Equal(-1_000, sync.OffsetMicroseconds); - Assert.Equal(300, sync.RoundTripMicroseconds); - Assert.Equal(2, sync.SampleCount); - } - - [Fact] - public void AWorseSampleDoesNotDisplaceABetterOne() - { - var sync = new ClockSync(); - - (long t1, long t2, long t3, long t4) = RoundTrip( - localSend: 0, remoteAhead: 500, delayOut: 100, remoteThinking: 0, delayBack: 100); - sync.Record(t1, t2, t3, t4); - - (t1, t2, t3, t4) = RoundTrip( - localSend: 10_000, remoteAhead: 500, delayOut: 20_000, remoteThinking: 0, delayBack: 100); - sync.Record(t1, t2, t3, t4); - - Assert.Equal(-500, sync.OffsetMicroseconds); - Assert.Equal(200, sync.RoundTripMicroseconds); - } - - [Fact] - public void ASampleFromAClockThatSteppedIsIgnored() - { - var sync = new ClockSync(); - - // The reply appears to arrive before it was sent. Nothing usable can be extracted, and - // folding it in would corrupt an estimate that may already be good. - sync.Record(t1: 1_000, t2: 500, t3: 9_000, t4: 1_100); - - Assert.False(sync.HasSample); - Assert.Equal(0, sync.OffsetMicroseconds); - } - - [Fact] - public void UntilAnExchangeCompletesTheOffsetIsZeroAndSaysSo() - { - var sync = new ClockSync(); - - Assert.False(sync.HasSample); - Assert.Equal(0, sync.OffsetMicroseconds); - Assert.Equal(0, sync.RoundTripMicroseconds); - } - - [Fact] - public void APingRoundTripsThroughTheWireFormat() - { - Span buffer = stackalloc byte[ClockSync.PayloadSize]; - ClockSync.WritePing(buffer, 123_456); - - Assert.True(ClockSync.TryRead(buffer, out HeartbeatKind kind, out long t1, out long t2, out long t3)); - Assert.Equal(HeartbeatKind.Ping, kind); - Assert.Equal(123_456, t1); - Assert.Equal(0, t2); - Assert.Equal(0, t3); - } - - [Fact] - public void APongCarriesTheOriginatorsInstantBackUntouched() - { - Span buffer = stackalloc byte[ClockSync.PayloadSize]; - ClockSync.WritePong(buffer, originatorSend: 111, localReceive: 222, localSend: 333); - - Assert.True(ClockSync.TryRead(buffer, out HeartbeatKind kind, out long t1, out long t2, out long t3)); - Assert.Equal(HeartbeatKind.Pong, kind); - - // The originator's instant must survive verbatim: it is on the originator's clock, and - // reinterpreting it on the responder's would be the very error being corrected for. - Assert.Equal(111, t1); - Assert.Equal(222, t2); - Assert.Equal(333, t3); - } - - [Theory] - [InlineData(0)] - [InlineData(ClockSync.PayloadSize - 1)] - [InlineData(ClockSync.PayloadSize + 1)] - public void AWronglySizedHeartbeatIsRefused(int size) - { - Assert.False(ClockSync.TryRead(new byte[size], out _, out _, out _, out _)); - } - - [Fact] - public void AnUnknownHeartbeatKindIsRefused() - { - byte[] payload = new byte[ClockSync.PayloadSize]; - payload[0] = 7; - - Assert.False(ClockSync.TryRead(payload, out _, out _, out _, out _)); - } -} - -/// The wire clock itself. -public class MonotonicClockTests -{ - [Fact] - public void ConversionSurvivesATimestampFromAMachineThatHasBeenUpForYears() - { - // The obvious form, ticks * 1_000_000 / Frequency, overflows a long after about eleven - // days at 10 MHz. That is not an edge case for a service that starts at boot and is - // expected never to be restarted, and the symptom is a negative latency, not a crash. - long threeYearsOfTicks = System.Diagnostics.Stopwatch.Frequency * 60L * 60 * 24 * 365 * 3; - - long microseconds = MonotonicClock.ToMicroseconds(threeYearsOfTicks); - - Assert.True(microseconds > 0, $"conversion overflowed, giving {microseconds}"); - - // Within a second over three years. Exact equality would be asserting that the machine's - // tick frequency divides a million, which is true here and not guaranteed anywhere. - long expectedSeconds = 60L * 60 * 24 * 365 * 3; - Assert.InRange(microseconds / 1_000_000, expectedSeconds - 1, expectedSeconds + 1); - } - - [Fact] - public void TheClockMovesForwards() - { - long first = MonotonicClock.Microseconds; - Thread.Sleep(2); - long second = MonotonicClock.Microseconds; - - Assert.True(second > first); - Assert.True(second - first < 1_000_000, "two milliseconds should not read as a second"); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterMembershipTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ClusterMembershipTests.cs deleted file mode 100644 index 8e346c7..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterMembershipTests.cs +++ /dev/null @@ -1,215 +0,0 @@ -using VirtualKmSwitch.Cluster; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class ClusterMembershipTests : IDisposable -{ - private const string Thumbprint = "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF001122334455667788AA"; - - private readonly string _directory = - Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N")); - - private ClusterStore Store() => new(Path.Combine(_directory, "roster.json")); - - private LocalSettingsStore Settings() => new(Path.Combine(_directory, "local.json")); - - private ClusterMembership Create() => new(Store(), Settings()); - - private ClusterMembership CreateWithCluster() - { - ClusterMembership membership = Create(); - membership.CreateCluster("Home", "0123456789ABCDEF", "AAAA-BBBB-CCCC-DDDD", Thumbprint, "Desk"); - return membership; - } - - [Fact] - public void AFreshMachineBelongsToNoCluster() - { - ClusterMembership membership = Create(); - - Assert.False(membership.HasCluster); - Assert.Empty(membership.Machines); - Assert.Equal(LoadOutcome.Absent, membership.LoadOutcome); - } - - [Fact] - public void AMachineGetsOneIdentityAndKeepsIt() - { - // The node id is what every peer pins a certificate against, so a machine that generated - // a new one on each start would look like a different computer after every reboot. - Guid first = Create().LocalNodeId; - Guid second = Create().LocalNodeId; - - Assert.NotEqual(Guid.Empty, first); - Assert.Equal(first, second); - } - - [Fact] - public void CreatingAClusterYieldsAOneMemberRosterContainingThisMachine() - { - ClusterMembership membership = CreateWithCluster(); - - RosterEntry only = Assert.Single(membership.Machines); - - Assert.Equal(membership.LocalNodeId, only.NodeId); - Assert.Equal("Desk", only.DisplayName); - Assert.Equal(Thumbprint, only.Thumbprint); - Assert.True(membership.HasCluster); - } - - [Fact] - public void MembershipSurvivesARestart() - { - // The point of persisting it: whoever becomes host after a cold boot uses the last saved - // membership rather than reconstructing it from whoever happens to be online. - Guid nodeId = CreateWithCluster().LocalNodeId; - - ClusterMembership reloaded = Create(); - - Assert.True(reloaded.HasCluster); - Assert.Equal("Home", reloaded.ClusterName); - Assert.Equal("AAAA-BBBB-CCCC-DDDD", reloaded.JoinKey); - Assert.Equal(nodeId, Assert.Single(reloaded.Machines).NodeId); - } - - [Fact] - public void AMachineCannotJoinASecondClusterWithoutLeavingTheFirst() - { - // Belonging to two clusters would mean two sets of peers each believing they may inject - // keystrokes here, with nothing reconciling them. - ClusterMembership membership = CreateWithCluster(); - - Assert.Throws( - () => membership.CreateCluster("Other", "FEDCBA9876543210", "EEEE-FFFF-GGGG-HHHH", Thumbprint, "Desk")); - } - - [Fact] - public void LeavingClearsTheClusterButKeepsThisMachinesIdentity() - { - ClusterMembership membership = CreateWithCluster(); - Guid nodeId = membership.LocalNodeId; - - membership.LeaveCluster(); - - Assert.False(membership.HasCluster); - Assert.Empty(membership.Machines); - Assert.Equal(string.Empty, membership.JoinKey); - Assert.Equal(nodeId, Create().LocalNodeId); - } - - [Fact] - public void ThisMachineCannotRemoveItself() - { - // Removing yourself would leave a machine that is a member of a cluster it is tombstoned - // in, which would keep trying to rejoin and keep being refused. - ClusterMembership membership = CreateWithCluster(); - - Assert.Throws(() => membership.RemoveMachine(membership.LocalNodeId)); - } - - [Fact] - public void RemovingAMachineThatIsNotAMemberReportsSoRatherThanThrowing() - { - ClusterMembership membership = CreateWithCluster(); - - Assert.False(membership.RemoveMachine(Guid.NewGuid())); - } - - [Fact] - public void RemovingAMachineLeavesATombstoneRatherThanAnAbsence() - { - // A peer that was powered off during the removal must not merge the machine — and its - // certificate pin — back in when it returns. - ClusterMembership membership = CreateWithCluster(); - Guid peer = EnrollPeer(membership, "Laptop"); - - Assert.True(membership.RemoveMachine(peer)); - - Assert.DoesNotContain(membership.Machines, entry => entry.NodeId == peer); - Assert.Equal(MembershipState.Removed, membership.Find(peer)?.State); - } - - [Fact] - public void RemovingAMachineClearsAnyHotkeyPointingAtIt() - { - // Otherwise a familiar key would stay bound to a machine that is no longer a member, and - // pressing it would silently do nothing while still looking configured. - LocalSettingsStore settings = Settings(); - ClusterMembership membership = new(Store(), settings); - membership.CreateCluster("Home", "0123456789ABCDEF", "AAAA-BBBB-CCCC-DDDD", Thumbprint, "Desk"); - - Guid peer = EnrollPeer(membership, "Laptop"); - settings.Save(settings.Bind(settings.Load(), 3, peer)); - - membership.RemoveMachine(peer); - - Assert.Null(LocalSettingsStore.TargetFor(settings.Load(), 3)); - } - - [Fact] - public void MergingAPeersViewBringsInMachinesItKnowsAbout() - { - ClusterMembership membership = CreateWithCluster(); - - var peerId = Guid.NewGuid(); - var theirRoster = new Roster(peerId); - theirRoster.Enroll(peerId, Thumbprint, "Laptop"); - - Assert.True(membership.Merge(theirRoster.Snapshot())); - - Assert.Contains(membership.Machines, entry => entry.NodeId == peerId && entry.DisplayName == "Laptop"); - } - - [Fact] - public void MergingTheSameViewTwiceChangesNothingTheSecondTime() - { - // Anti-entropy will replay snapshots constantly, and each replay must not count as a - // change or the roster would be rewritten to disk forever. - ClusterMembership membership = CreateWithCluster(); - - var peerId = Guid.NewGuid(); - var theirRoster = new Roster(peerId); - theirRoster.Enroll(peerId, Thumbprint, "Laptop"); - RosterSnapshot snapshot = theirRoster.Snapshot(); - - Assert.True(membership.Merge(snapshot)); - Assert.False(membership.Merge(snapshot)); - } - - [Fact] - public void ThisMachineIsListedFirst() - { - // It is the one the person is sitting at, and the one they cannot remove, so it is not - // left to sort in wherever its name happens to fall. - ClusterMembership membership = CreateWithCluster(); - EnrollPeer(membership, "Aardvark"); - - Assert.True(membership.Machines[0].NodeId == membership.LocalNodeId); - } - - /// Adds a peer the way a completed enrollment eventually will. - private static Guid EnrollPeer(ClusterMembership membership, string name) - { - var peerId = Guid.NewGuid(); - var theirRoster = new Roster(peerId); - theirRoster.Enroll(peerId, Thumbprint, name); - membership.Merge(theirRoster.Snapshot()); - return peerId; - } - - public void Dispose() - { - try - { - if (Directory.Exists(_directory)) - { - Directory.Delete(_directory, recursive: true); - } - } - catch - { - // Best effort. - } - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterRegistryTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ClusterRegistryTests.cs deleted file mode 100644 index 870bf09..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterRegistryTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using VirtualKmSwitch.Cluster; -using VirtualKmSwitch.Ipc; -using VirtualKmSwitch.Service; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class ClusterRegistryTests : IDisposable -{ - private const string Thumbprint = "0011223344556677889900112233445566778899001122334455667788990011"; - - private readonly string _directory = - Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N")); - - private string RosterPath => Path.Combine(_directory, "roster.json"); - - private ClusterRegistry Create() => new( - NullLogger.Instance, - new ClusterStore(RosterPath), - new LocalSettingsStore(Path.Combine(_directory, "local.json")), - - // Stubbed so the test does not create a certificate in a Windows store and leave it on - // whatever machine ran it. - _ => Thumbprint); - - [Fact] - public void AFreshMachineIsDescribedAsBelongingToNothing() - { - ClusterView view = Create().Describe(); - - Assert.False(view.HasCluster); - Assert.Empty(view.Machines); - Assert.Equal(ClusterHealth.Ok, view.Health); - Assert.NotEqual(Guid.Empty, view.LocalNodeId); - } - - [Fact] - public void CreatingAClusterPutsThisMachineInTheDescribedRoster() - { - ClusterRegistry registry = Create(); - registry.CreateCluster("Workshop"); - - ClusterView view = registry.Describe(); - - Assert.True(view.HasCluster); - Assert.Equal("Workshop", view.ClusterName); - Assert.NotEmpty(view.ClusterId); - - MachineInfo only = Assert.Single(view.Machines); - Assert.True(only.IsLocal); - Assert.Equal(view.LocalNodeId, only.NodeId); - Assert.Equal(Environment.MachineName, only.DisplayName); - Assert.Equal(Thumbprint, only.Thumbprint); - } - - [Fact] - public void TheJoinKeyIsWithheldUnlessItIsExplicitlyAskedFor() - { - // Every routine membership read goes to the controller, so a key attached by default - // would sit in an unelevated process's memory for the whole session — for the sake of a - // string that is only useful in the seconds after it is created. - ClusterRegistry registry = Create(); - string created = registry.CreateCluster("Workshop"); - - Assert.Null(registry.Describe().JoinKey); - Assert.Equal(created, registry.Describe(includeJoinKey: true).JoinKey); - } - - [Fact] - public void TheClusterIdDoesNotRevealTheJoinKey() - { - // Beacons carry the id in the clear, so anything recoverable from it is public. - ClusterRegistry registry = Create(); - string key = registry.CreateCluster("Workshop"); - - ClusterView view = registry.Describe(includeJoinKey: true); - - Assert.DoesNotContain(JoinKeyWithoutDashes(key), view.ClusterId, StringComparison.OrdinalIgnoreCase); - Assert.Equal(16, view.ClusterId.Length); - } - - [Fact] - public void AMachineThatIsNotAMemberCannotBeRemoved() - { - ClusterRegistry registry = Create(); - registry.CreateCluster("Workshop"); - - Assert.False(registry.RemoveMachine(Guid.NewGuid())); - } - - [Fact] - public void LeavingIsDescribedAsBelongingToNothingAgain() - { - ClusterRegistry registry = Create(); - registry.CreateCluster("Workshop"); - registry.LeaveCluster(); - - ClusterView view = registry.Describe(includeJoinKey: true); - - Assert.False(view.HasCluster); - Assert.Empty(view.Machines); - Assert.Null(view.JoinKey); - } - - [Fact] - public void ADamagedRosterIsReportedRatherThanPresentedAsAnEmptyCluster() - { - // An empty roster and an unreadable one look identical on screen, and only one of them - // means every peer has genuinely left. This also covers the first roster ever written, - // which is the copy a freshly joined machine depends on. - Create().CreateCluster("Workshop"); - File.WriteAllText(RosterPath, "{ not json"); - - ClusterView view = Create().Describe(); - - Assert.Equal(ClusterHealth.RecoveredFromBackup, view.Health); - Assert.NotNull(view.HealthDetail); - } - - [Fact] - public void AnUnreadableRosterIsReportedAsSuch() - { - ClusterStore store = new(RosterPath); - Create().CreateCluster("Workshop"); - - File.WriteAllText(RosterPath, "{ not json"); - File.WriteAllText(store.BackupPath, "{ also not json"); - - ClusterView view = Create().Describe(); - - Assert.Equal(ClusterHealth.Unreadable, view.Health); - Assert.False(view.HasCluster); - } - - private static string JoinKeyWithoutDashes(string key) => key.Replace("-", string.Empty); - - public void Dispose() - { - try - { - if (Directory.Exists(_directory)) - { - Directory.Delete(_directory, recursive: true); - } - } - catch - { - // Best effort. - } - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterStoreTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ClusterStoreTests.cs deleted file mode 100644 index b3a9e91..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterStoreTests.cs +++ /dev/null @@ -1,310 +0,0 @@ -using VirtualKmSwitch.Cluster; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class ClusterStoreTests : IDisposable -{ - private readonly string _directory = - Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N")); - - private string RosterPath => Path.Combine(_directory, "roster.json"); - - private ClusterStore CreateStore() => new(RosterPath); - - private static PersistedRoster SampleRoster(string clusterName = "home") => new() - { - ClusterId = "ABCD1234", - ClusterName = clusterName, - Clock = 7, - Entries = - [ - new RosterEntry - { - NodeId = Guid.Parse("11111111-1111-1111-1111-111111111111"), - Thumbprint = "THUMB-A", - DisplayName = "desktop", - State = MembershipState.Enrolled, - StateChangedAt = new LamportStamp(3, Guid.Empty), - }, - new RosterEntry - { - NodeId = Guid.Parse("22222222-2222-2222-2222-222222222222"), - Thumbprint = "THUMB-B", - DisplayName = "laptop", - State = MembershipState.Removed, - StateChangedAt = new LamportStamp(7, Guid.Empty), - }, - ], - }; - - [Fact] - public void AMissingFileIsReportedAsAbsentRatherThanEmpty() - { - // The distinction matters: an empty roster looks exactly like every peer having left. - LoadResult result = CreateStore().Load(); - - Assert.Equal(LoadOutcome.Absent, result.Outcome); - Assert.Null(result.Roster); - } - - [Fact] - public void RostersRoundTrip() - { - ClusterStore store = CreateStore(); - store.Save(SampleRoster()); - - LoadResult result = store.Load(); - - Assert.Equal(LoadOutcome.Loaded, result.Outcome); - Assert.Equal("ABCD1234", result.Roster!.ClusterId); - Assert.Equal(7UL, result.Roster.Clock); - Assert.Equal(2, result.Roster.Entries.Count); - - // Tombstones must persist, or a restart would resurrect removed machines. - Assert.Contains(result.Roster.Entries, entry => entry.State == MembershipState.Removed); - } - - [Fact] - public void SavingKeepsThePreviousCopyAsABackup() - { - ClusterStore store = CreateStore(); - store.Save(SampleRoster("first")); - store.Save(SampleRoster("second")); - - Assert.True(File.Exists(store.BackupPath)); - Assert.Equal("second", store.Load().Roster!.ClusterName); - } - - [Fact] - public void TheVeryFirstSaveAlsoLeavesABackup() - { - // Otherwise a machine that has just joined a cluster holds exactly one copy of the - // membership it would need to rejoin, until something happens to write a second time. - // Losing it there means re-enrolling from another machine, which is precisely the task a - // KVM cannot help with. - ClusterStore store = CreateStore(); - store.Save(SampleRoster("first")); - - Assert.True(File.Exists(store.BackupPath)); - - File.WriteAllText(store.FilePath, "{ corrupt"); - - LoadResult result = store.Load(); - - Assert.Equal(LoadOutcome.RecoveredFromBackup, result.Outcome); - Assert.Equal("first", result.Roster!.ClusterName); - } - - [Fact] - public void ACorruptPrimaryFallsBackToTheBackup() - { - // The power-cut case. Without the fallback, membership becomes unreadable — and this is - // the tool you would otherwise use to reach the other machines and fix it. - ClusterStore store = CreateStore(); - store.Save(SampleRoster("original")); - store.Save(SampleRoster("current")); - - File.WriteAllText(RosterPath, "{ this is not json"); - - LoadResult result = store.Load(); - - Assert.Equal(LoadOutcome.RecoveredFromBackup, result.Outcome); - Assert.Equal("original", result.Roster!.ClusterName); - Assert.NotNull(result.Error); - } - - [Fact] - public void AnEmptyPrimaryIsTreatedAsCorrupt() - { - // A zero-length file is what an interrupted write leaves behind, and it parses as nothing - // rather than failing loudly. - ClusterStore store = CreateStore(); - store.Save(SampleRoster("original")); - store.Save(SampleRoster("current")); - - File.WriteAllBytes(RosterPath, []); - - Assert.Equal(LoadOutcome.RecoveredFromBackup, store.Load().Outcome); - } - - [Fact] - public void BothCopiesUnreadableIsReportedRatherThanSilentlyEmpty() - { - ClusterStore store = CreateStore(); - store.Save(SampleRoster("first")); - store.Save(SampleRoster("second")); - - File.WriteAllText(RosterPath, "corrupt"); - File.WriteAllText(store.BackupPath, "also corrupt"); - - LoadResult result = store.Load(); - - Assert.Equal(LoadOutcome.Unrecoverable, result.Outcome); - Assert.Null(result.Roster); - Assert.NotNull(result.Error); - } - - [Fact] - public void UnknownFieldsSurviveARoundTrip() - { - // A peer on an older build must not strip settings it does not understand: it would write - // the loss back and quietly remove them from the whole mesh. - ClusterStore store = CreateStore(); - store.Save(SampleRoster()); - - string json = File.ReadAllText(RosterPath); - json = json.TrimEnd().TrimEnd('}') + ", \"futureSetting\": { \"nested\": 42 } }"; - File.WriteAllText(RosterPath, json); - - LoadResult loaded = store.Load(); - Assert.Equal(LoadOutcome.Loaded, loaded.Outcome); - Assert.NotNull(loaded.Roster!.Extra); - Assert.True(loaded.Roster.Extra!.ContainsKey("futureSetting")); - - store.Save(loaded.Roster); - Assert.Contains("futureSetting", File.ReadAllText(RosterPath)); - } - - [Fact] - public void NoTemporaryFileIsLeftBehind() - { - ClusterStore store = CreateStore(); - store.Save(SampleRoster()); - store.Save(SampleRoster()); - - Assert.Empty(Directory.GetFiles(_directory, "*.tmp")); - } - - [Fact] - public void ARosterSurvivesTheFullRoundTripThroughTheCrdt() - { - var nodeA = Guid.NewGuid(); - var roster = new Roster(nodeA); - roster.Enroll(nodeA, "THUMB-SELF", "self"); - roster.Enroll(Guid.NewGuid(), "THUMB-OTHER", "other"); - roster.Remove(roster.EnrolledEntries.Last().NodeId); - - ClusterStore store = CreateStore(); - RosterSnapshot snapshot = roster.Snapshot(); - - store.Save(new PersistedRoster - { - ClusterId = "X", - Clock = snapshot.Clock, - Entries = snapshot.Entries, - }); - - PersistedRoster reloaded = store.Load().Roster!; - var restored = new Roster(nodeA); - restored.Load(new RosterSnapshot { Clock = reloaded.Clock, Entries = reloaded.Entries }); - - Assert.Equal(roster.EnrolledCount, restored.EnrolledCount); - Assert.Equal(roster.Clock, restored.Clock); - Assert.Equal(roster.AllEntries.Count, restored.AllEntries.Count); - } - - public void Dispose() - { - try - { - if (Directory.Exists(_directory)) - { - Directory.Delete(_directory, recursive: true); - } - } - catch - { - // Temp cleanup is best effort. - } - } -} - -public class ControlLeasesTests -{ - private static readonly Guid MachineA = Guid.NewGuid(); - private static readonly Guid PeerOne = Guid.NewGuid(); - private static readonly Guid PeerTwo = Guid.NewGuid(); - - private static TimeSpan At(double seconds) => TimeSpan.FromSeconds(seconds); - - [Fact] - public void TheFirstRequestWinsAndTheSecondIsRefused() - { - // Two people aiming at one machine would otherwise interleave keystrokes into a single - // stream, unusable for both and mystifying to each. - var leases = new ControlLeases(); - - Assert.Equal(LeaseResult.Granted, leases.Request(At(0), MachineA, PeerOne)); - Assert.Equal(LeaseResult.HeldByAnother, leases.Request(At(0), MachineA, PeerTwo)); - Assert.Equal(PeerOne, leases.HolderOf(At(0), MachineA)); - } - - [Fact] - public void TheHolderRenewsRatherThanBeingRefused() - { - var leases = new ControlLeases(); - leases.Request(At(0), MachineA, PeerOne); - - Assert.Equal(LeaseResult.Renewed, leases.Request(At(1), MachineA, PeerOne)); - Assert.Equal(PeerOne, leases.HolderOf(At(3.5), MachineA)); - } - - [Fact] - public void ALeaseExpiresSoADeadPeerDoesNotHoldAMachineForever() - { - // The usual ending is a machine sleeping or losing the network, not a polite release. - var leases = new ControlLeases(); - leases.Request(At(0), MachineA, PeerOne); - - Assert.Null(leases.HolderOf(At(10), MachineA)); - Assert.Equal(LeaseResult.Granted, leases.Request(At(10), MachineA, PeerTwo)); - } - - [Fact] - public void ReleasingFreesTheTargetImmediately() - { - var leases = new ControlLeases(); - leases.Request(At(0), MachineA, PeerOne); - - Assert.True(leases.Release(MachineA, PeerOne)); - Assert.Equal(LeaseResult.Granted, leases.Request(At(0), MachineA, PeerTwo)); - } - - [Fact] - public void OnlyTheHolderCanRelease() - { - var leases = new ControlLeases(); - leases.Request(At(0), MachineA, PeerOne); - - Assert.False(leases.Release(MachineA, PeerTwo)); - Assert.Equal(PeerOne, leases.HolderOf(At(0), MachineA)); - } - - [Fact] - public void DisconnectingReleasesEverythingThatPeerHeld() - { - var leases = new ControlLeases(); - var machineB = Guid.NewGuid(); - - leases.Request(At(0), MachineA, PeerOne); - leases.Request(At(0), machineB, PeerOne); - - Assert.Equal(2, leases.ReleaseAllHeldBy(PeerOne)); - Assert.Null(leases.HolderOf(At(0), MachineA)); - Assert.Null(leases.HolderOf(At(0), machineB)); - } - - [Fact] - public void ANewHostRebuildsLeasesFromWhatPeersReport() - { - // Leases describe who is typing right now, so carrying them across a host change would - // hand out stale claims. They are rebuilt, never persisted. - var leases = new ControlLeases(); - leases.Rebuild([new ControlLease(MachineA, PeerTwo, At(5))]); - - Assert.Equal(PeerTwo, leases.HolderOf(At(1), MachineA)); - Assert.Single(leases.Active(At(1))); - Assert.Empty(leases.Active(At(6))); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ControlPipeTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ControlPipeTests.cs deleted file mode 100644 index 8d0d9e1..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/ControlPipeTests.cs +++ /dev/null @@ -1,161 +0,0 @@ -using VirtualKmSwitch.Ipc; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class ControlProtocolTests -{ - [Theory] - [InlineData(ControlVerb.GetStatus)] - [InlineData(ControlVerb.Engage)] - [InlineData(ControlVerb.Disengage)] - [InlineData(ControlVerb.SetHotkeys)] - [InlineData(ControlVerb.ClaimHost)] - public void RuntimeVerbsAreAvailableToTheInteractiveUser(ControlVerb verb) => - Assert.Equal(ControlTier.Runtime, ControlProtocol.TierFor(verb)); - - [Theory] - [InlineData(ControlVerb.CreateCluster)] - [InlineData(ControlVerb.JoinCluster)] - [InlineData(ControlVerb.LeaveCluster)] - [InlineData(ControlVerb.EnrollPeer)] - [InlineData(ControlVerb.RemovePeer)] - [InlineData(ControlVerb.OpenPairingWindow)] - public void MembershipChangesRequireAdministrator(ControlVerb verb) => - Assert.Equal(ControlTier.Administrative, ControlProtocol.TierFor(verb)); - - [Fact] - public void UnclassifiedVerbsDefaultToAdministrative() - { - // A verb added later without being classified must fail closed. The alternative — silently - // exposing a new operation to any local user — is the dangerous direction. - Assert.Equal(ControlTier.Administrative, ControlProtocol.TierFor(ControlVerb.Unknown)); - Assert.Equal(ControlTier.Administrative, ControlProtocol.TierFor((ControlVerb)9999)); - } -} - -/// -/// End-to-end over a real named pipe: a real server, a real client, real ACLs. Only the caller's -/// privilege is simulated, since a test process cannot drop to a different token on demand. -/// -public class ControlPipeServerTests -{ - /// - /// A unique pipe per test. Using the production name would attach to a VirtualKmSwitch service if - /// one happens to be installed on the machine, and the tests would then silently exercise that - /// instead — passing or failing for reasons entirely unrelated to the code under test. - /// - private static string UniquePipeName([System.Runtime.CompilerServices.CallerMemberName] string caller = "") => - $"VirtualKmSwitch.Test.{caller}.{Environment.ProcessId}"; - - private static async Task ConnectAsync(string pipeName) - { - var client = new ControlPipeClient(pipeName); - await client.ConnectAsync(TimeSpan.FromSeconds(5)); - return client; - } - - [Fact] - public async Task RequestsRoundTripOverTheRealPipe() - { - string pipeName = UniquePipeName(); - await using var server = new ControlPipeServer( - (request, _) => ValueTask.FromResult(ControlResponse.Ok(new Dictionary - { - ["echo"] = request.Verb.ToString(), - })), - pipeName: pipeName); - - await using ControlPipeClient client = await ConnectAsync(pipeName); - - ControlResponse response = await client.SendAsync(new ControlRequest { Verb = ControlVerb.GetStatus }); - - Assert.True(response.Success); - Assert.Equal("GetStatus", response.Values?["echo"]); - } - - [Fact] - public async Task ArgumentsSurviveTheRoundTrip() - { - Dictionary? seen = null; - - string pipeName = UniquePipeName(); - await using var server = new ControlPipeServer( - (request, _) => - { - seen = request.Arguments; - return ValueTask.FromResult(ControlResponse.Ok()); - }, - pipeName: pipeName); - - await using ControlPipeClient client = await ConnectAsync(pipeName); - - await client.SendAsync(new ControlRequest - { - Verb = ControlVerb.Engage, - Arguments = new Dictionary { ["slot"] = "3" }, - }); - - Assert.Equal("3", seen?["slot"]); - } - - [Fact] - public async Task SeveralRequestsShareOneConnection() - { - string pipeName = UniquePipeName(); - await using var server = new ControlPipeServer( - (request, _) => ValueTask.FromResult(ControlResponse.Ok(new Dictionary - { - ["verb"] = request.Verb.ToString(), - })), - pipeName: pipeName); - - await using ControlPipeClient client = await ConnectAsync(pipeName); - - // Responses are matched to requests by order alone, so this is what proves the framing - // stays aligned across messages. - foreach (ControlVerb verb in new[] { ControlVerb.GetStatus, ControlVerb.Engage, ControlVerb.Disengage }) - { - ControlResponse response = await client.SendAsync(new ControlRequest { Verb = verb }); - Assert.Equal(verb.ToString(), response.Values?["verb"]); - } - } - - [Fact] - public async Task AHandlerThatThrowsBecomesAFailureNotADroppedConnection() - { - string pipeName = UniquePipeName(); - await using var server = new ControlPipeServer( - (_, _) => throw new InvalidOperationException("handler exploded"), - pipeName: pipeName); - - await using ControlPipeClient client = await ConnectAsync(pipeName); - - ControlResponse response = await client.SendAsync(new ControlRequest { Verb = ControlVerb.GetStatus }); - - Assert.False(response.Success); - Assert.Contains("handler exploded", response.Error); - } - - [Fact] - public async Task TheHandlerIsToldWhichTierTheCallerHas() - { - ControlTier? observed = null; - - string pipeName = UniquePipeName(); - await using var server = new ControlPipeServer( - (_, tier) => - { - observed = tier; - return ValueTask.FromResult(ControlResponse.Ok()); - }, - pipeName: pipeName); - - await using ControlPipeClient client = await ConnectAsync(pipeName); - await client.SendAsync(new ControlRequest { Verb = ControlVerb.GetStatus }); - - // The value depends on whether the test process is elevated; what matters is that the - // server determined it from the connection rather than from anything the client sent. - Assert.NotNull(observed); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/EnrollmentTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/EnrollmentTests.cs deleted file mode 100644 index 3508e44..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/EnrollmentTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Security.Cryptography.X509Certificates; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class EnrollmentHandshakeTests -{ - private const string JoinKey = "TEST-KEY-0000-0001"; - private const string AliceThumb = "AAAA1111"; - private const string BobThumb = "BBBB2222"; - - private static (byte[] Initiator, byte[] Responder) Nonces() => - (EnrollmentHandshake.CreateNonce(), EnrollmentHandshake.CreateNonce()); - - [Fact] - public void BothSidesComputeTheSameProofForTheSameExchange() - { - (byte[] initiator, byte[] responder) = Nonces(); - - // Each side names its own certificate first and the peer's second, so the two computations - // are mirror images that must agree. - byte[] fromAlice = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder); - byte[] fromBobsView = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder); - - Assert.Equal(fromAlice, fromBobsView); - - Assert.Equal( - EnrollmentResult.Accepted, - EnrollmentHandshake.Verify(JoinKey, AliceThumb, BobThumb, initiator, responder, fromAlice)); - } - - [Fact] - public void TheWrongKeyIsRefused() - { - (byte[] initiator, byte[] responder) = Nonces(); - byte[] proof = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder); - - Assert.Equal( - EnrollmentResult.WrongKey, - EnrollmentHandshake.Verify("DIFFERENT-KEY", AliceThumb, BobThumb, initiator, responder, proof)); - } - - [Fact] - public void AProofForDifferentCertificatesIsRefused() - { - // The man-in-the-middle case. An interceptor can relay a genuine proof between two honest - // peers, but it must present its own certificate to each of them -- so the thumbprints it - // is observed with differ from the ones the proof was computed over, and it fails. - (byte[] initiator, byte[] responder) = Nonces(); - byte[] genuine = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder); - - const string AttackerThumb = "CCCC3333"; - - Assert.Equal( - EnrollmentResult.WrongKey, - EnrollmentHandshake.Verify(JoinKey, AliceThumb, AttackerThumb, initiator, responder, genuine)); - - Assert.Equal( - EnrollmentResult.WrongKey, - EnrollmentHandshake.Verify(JoinKey, AttackerThumb, BobThumb, initiator, responder, genuine)); - } - - [Fact] - public void SwappingTheCertificateOrderChangesTheProof() - { - // Direction must matter, or a proof computed by one side would validate as the other's. - (byte[] initiator, byte[] responder) = Nonces(); - - byte[] forward = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder); - byte[] reversed = EnrollmentHandshake.ComputeProof(JoinKey, BobThumb, AliceThumb, initiator, responder); - - Assert.NotEqual(forward, reversed); - } - - [Fact] - public void ReplayingAProofWithFreshNoncesFails() - { - // Nonces are what stop a captured exchange being replayed later against the same pair. - (byte[] initiator, byte[] responder) = Nonces(); - byte[] proof = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder); - - (byte[] freshInitiator, byte[] freshResponder) = Nonces(); - - Assert.Equal( - EnrollmentResult.WrongKey, - EnrollmentHandshake.Verify(JoinKey, AliceThumb, BobThumb, freshInitiator, freshResponder, proof)); - } - - [Fact] - public void ThumbprintCasingDoesNotChangeTheOutcome() - { - // Thumbprints come from different code paths on each side, and a casing difference must - // not read as a different certificate. - (byte[] initiator, byte[] responder) = Nonces(); - byte[] proof = EnrollmentHandshake.ComputeProof(JoinKey, "abcd1234", "ef567890", initiator, responder); - - Assert.Equal( - EnrollmentResult.Accepted, - EnrollmentHandshake.Verify(JoinKey, "ABCD1234", "EF567890", initiator, responder, proof)); - } - - [Fact] - public void MalformedInputIsRejectedWithoutComparing() - { - (byte[] initiator, byte[] responder) = Nonces(); - - Assert.Equal( - EnrollmentResult.Malformed, - EnrollmentHandshake.Verify(JoinKey, AliceThumb, BobThumb, initiator, responder, new byte[8])); - - Assert.Equal( - EnrollmentResult.Malformed, - EnrollmentHandshake.Verify(JoinKey, AliceThumb, BobThumb, new byte[4], responder, new byte[32])); - } - - [Fact] - public void ClusterIdIsStableAndRevealsNothingObvious() - { - string id = EnrollmentHandshake.ClusterIdFor(JoinKey); - - Assert.Equal(id, EnrollmentHandshake.ClusterIdFor(JoinKey)); - Assert.NotEqual(id, EnrollmentHandshake.ClusterIdFor(JoinKey + "x")); - - // Broadcast on the LAN, so it must not contain the key it derives from. - Assert.DoesNotContain(JoinKey, id, StringComparison.OrdinalIgnoreCase); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/FrameCodecTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/FrameCodecTests.cs deleted file mode 100644 index 3ea1503..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/FrameCodecTests.cs +++ /dev/null @@ -1,206 +0,0 @@ -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class FrameCodecTests -{ - private static readonly InputEvent[] Sample = - [ - InputEvent.MouseMove(1000, 12, -34), - InputEvent.MouseButton(1000, 1, up: false), - InputEvent.Key(1000, 0x1E, InputEventFlags.Extended), - InputEvent.MouseWheel(1000, -120, horizontal: true), - InputEvent.ReleaseAll(1000), - ]; - - [Fact] - public void InputBatchesRoundTrip() - { - Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)]; - Assert.True(FrameCodec.TryWriteInputBatch(buffer, 987654321, Sample, out int written)); - Assert.Equal(buffer.Length, written); - - Assert.Equal( - FrameReadResult.Complete, - FrameCodec.TryReadFrame(buffer, out FrameType type, out ReadOnlySpan payload, out int consumed)); - - Assert.Equal(FrameType.InputBatch, type); - Assert.Equal(written, consumed); - - var decoded = new InputEvent[Sample.Length]; - int count = FrameCodec.ReadInputBatch(payload, decoded, out long timestamp); - - Assert.Equal(Sample.Length, count); - Assert.Equal(987654321, timestamp); - - for (int i = 0; i < Sample.Length; i++) - { - Assert.Equal(Sample[i].Kind, decoded[i].Kind); - Assert.Equal(Sample[i].Flags, decoded[i].Flags); - Assert.Equal(Sample[i].Code, decoded[i].Code); - Assert.Equal(Sample[i].X, decoded[i].X); - Assert.Equal(Sample[i].Y, decoded[i].Y); - } - } - - [Fact] - public void ExtremeValuesSurviveEncoding() - { - // Deltas are signed and a wheel delta is negative; a width or signedness mistake here - // would send the pointer the wrong way rather than fail visibly. - InputEvent[] extremes = - [ - new(long.MaxValue, InputEventKind.MouseMove, InputEventFlags.None, ushort.MaxValue, int.MinValue, int.MaxValue), - new(long.MinValue, InputEventKind.Key, InputEventFlags.Up | InputEventFlags.E1, 0, -1, 1), - ]; - - Span buffer = new byte[FrameCodec.InputBatchSize(extremes.Length)]; - FrameCodec.TryWriteInputBatch(buffer, -5, extremes, out _); - FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _); - - var decoded = new InputEvent[extremes.Length]; - FrameCodec.ReadInputBatch(payload, decoded, out long timestamp); - - Assert.Equal(-5, timestamp); - Assert.Equal(int.MinValue, decoded[0].X); - Assert.Equal(int.MaxValue, decoded[0].Y); - Assert.Equal(ushort.MaxValue, decoded[0].Code); - Assert.Equal(-1, decoded[1].X); - } - - [Fact] - public void PartialFramesReportIncompleteRatherThanFailing() - { - // TCP delivers a stream, not messages: a frame arriving in pieces is the normal case, not - // an error, and treating it as one would drop healthy connections constantly. - Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)]; - FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out int written); - - for (int prefix = 0; prefix < written; prefix++) - { - Assert.Equal( - FrameReadResult.Incomplete, - FrameCodec.TryReadFrame(buffer[..prefix], out _, out _, out _)); - } - - Assert.Equal(FrameReadResult.Complete, FrameCodec.TryReadFrame(buffer, out _, out _, out _)); - } - - [Fact] - public void OversizedLengthIsRejected() - { - // The length prefix is attacker-controlled until checked. Trusting it would let a peer - // dictate an allocation, or read past the buffer. - Span buffer = new byte[16]; - System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(buffer, ushort.MaxValue); - buffer[2] = (byte)FrameType.InputBatch; - - Assert.Equal(FrameReadResult.Malformed, FrameCodec.TryReadFrame(buffer, out _, out _, out _)); - } - - [Theory] - [InlineData(0)] // Unknown - [InlineData(99)] // never defined - [InlineData(255)] - public void UnknownFrameTypesAreRejected(byte type) - { - Span buffer = new byte[8]; - System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(buffer, 1); - buffer[2] = type; - - Assert.Equal(FrameReadResult.Malformed, FrameCodec.TryReadFrame(buffer, out _, out _, out _)); - } - - [Fact] - public void ADeclaredCountThatDisagreesWithThePayloadIsRejected() - { - // Believing the count over the actual bytes would read past the payload, which is the - // classic way a length-prefixed parser becomes an information leak. - Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)]; - FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out _); - FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _); - - byte[] tampered = payload.ToArray(); - System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(tampered.AsSpan(8), 9999); - - Assert.Equal(-1, FrameCodec.ReadInputBatch(tampered, new InputEvent[64], out _)); - } - - [Fact] - public void TruncatedBatchPayloadIsRejected() - { - Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)]; - FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out _); - FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _); - - Assert.Equal(-1, FrameCodec.ReadInputBatch(payload[..^3], new InputEvent[64], out _)); - Assert.Equal(-1, FrameCodec.ReadInputBatch([], new InputEvent[64], out _)); - } - - [Fact] - public void ADestinationTooSmallIsRejectedRatherThanOverflowing() - { - Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)]; - FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out _); - FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _); - - Assert.Equal(-1, FrameCodec.ReadInputBatch(payload, new InputEvent[2], out _)); - } - - [Fact] - public void ConsecutiveFramesAreReadIndependently() - { - // Several frames arrive in one TCP read constantly; the consumed count is what keeps the - // reader aligned across them. - Span buffer = new byte[512]; - FrameCodec.TryWriteInputBatch(buffer, 1, Sample.AsSpan(0, 2), out int first); - FrameCodec.TryWriteInputBatch(buffer[first..], 2, Sample.AsSpan(2, 3), out int second); - - FrameCodec.TryReadFrame(buffer[..(first + second)], out _, out ReadOnlySpan firstPayload, out int consumed); - Assert.Equal(first, consumed); - Assert.Equal(2, FrameCodec.ReadInputBatch(firstPayload, new InputEvent[8], out long firstTimestamp)); - Assert.Equal(1, firstTimestamp); - - FrameCodec.TryReadFrame(buffer[consumed..(first + second)], out _, out ReadOnlySpan secondPayload, out _); - Assert.Equal(3, FrameCodec.ReadInputBatch(secondPayload, new InputEvent[8], out long secondTimestamp)); - Assert.Equal(2, secondTimestamp); - } - - [Fact] - public void EncodingAndDecodingAllocateNothing() - { - // The wire path carries every event; allocation here would eventually mean gen0 - // collections, and a GC pause reaching the hook thread risks a silent unhook. - byte[] buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)]; - var decoded = new InputEvent[Sample.Length]; - - FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out _); - FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan warm, out _); - FrameCodec.ReadInputBatch(warm, decoded, out _); - - long before = GC.GetAllocatedBytesForCurrentThread(); - - for (int i = 0; i < 10_000; i++) - { - FrameCodec.TryWriteInputBatch(buffer, i, Sample, out _); - FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _); - FrameCodec.ReadInputBatch(payload, decoded, out _); - } - - Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); - } - - [Fact] - public void AMouseMoveCostsTwelveBytesInABatch() - { - // Guards the wire size against drifting upward unnoticed: at kilohertz rates a few extra - // bytes per event is real bandwidth. - int one = FrameCodec.InputBatchSize(1); - int two = FrameCodec.InputBatchSize(2); - - Assert.Equal(FrameCodec.EncodedEventSize, two - one); - Assert.Equal(12, two - one); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/HeldKeyTrackerTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/HeldKeyTrackerTests.cs deleted file mode 100644 index f10a5a3..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/HeldKeyTrackerTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -using VirtualKmSwitch.Core; -using VirtualKmSwitch.InputEngine; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class HeldKeyTrackerTests -{ - private const ushort ScanLeftShift = 0x2A; - private const ushort ScanA = 0x1E; - private const ushort ScanRightCtrl = 0x1D; - - private static InputEvent KeyDown(ushort scanCode, InputEventFlags flags = InputEventFlags.None) => - InputEvent.Key(1, scanCode, flags); - - private static InputEvent KeyUp(ushort scanCode, InputEventFlags flags = InputEventFlags.None) => - InputEvent.Key(1, scanCode, flags | InputEventFlags.Up); - - [Fact] - public void HeldKeysAreReleasedAndTheSetIsCleared() - { - var tracker = new HeldKeyTracker(); - tracker.Record(KeyDown(ScanLeftShift)); - tracker.Record(KeyDown(ScanA)); - - Assert.Equal(2, tracker.HeldCount); - - Span releases = new InputEvent[tracker.MaxReleaseEvents]; - int count = tracker.BuildReleaseAll(99, releases); - - Assert.Equal(2, count); - Assert.All(releases[..count].ToArray(), e => Assert.True(e.IsUp)); - Assert.All(releases[..count].ToArray(), e => Assert.Equal(99, e.Timestamp)); - Assert.Equal(0, tracker.HeldCount); - - // A second call has nothing left to release. - Assert.Equal(0, tracker.BuildReleaseAll(100, releases)); - } - - [Fact] - public void ReleasedKeysAreNoLongerHeld() - { - var tracker = new HeldKeyTracker(); - tracker.Record(KeyDown(ScanA)); - tracker.Record(KeyUp(ScanA)); - - Assert.Equal(0, tracker.HeldCount); - - Span releases = new InputEvent[tracker.MaxReleaseEvents]; - Assert.Equal(0, tracker.BuildReleaseAll(1, releases)); - } - - [Fact] - public void ExtendedKeysAreTrackedSeparatelyFromTheirBaseScancode() - { - // Right Ctrl and left Ctrl share scancode 0x1D and differ only by the extended flag. - // Collapsing them would release the wrong key, or miss one entirely. - var tracker = new HeldKeyTracker(); - tracker.Record(KeyDown(ScanRightCtrl, InputEventFlags.Extended)); - tracker.Record(KeyDown(ScanRightCtrl)); - - Assert.Equal(2, tracker.HeldCount); - - Span releases = new InputEvent[tracker.MaxReleaseEvents]; - int count = tracker.BuildReleaseAll(1, releases); - - Assert.Equal(2, count); - Assert.Contains(releases[..count].ToArray(), e => (e.Flags & InputEventFlags.Extended) != 0); - Assert.Contains(releases[..count].ToArray(), e => (e.Flags & InputEventFlags.Extended) == 0); - } - - [Fact] - public void VirtualKeysAreReleasedAsVirtualKeys() - { - // Pause travels as a virtual key because its scancode sequence cannot be replayed, so its - // release has to go back the same way. - var tracker = new HeldKeyTracker(); - tracker.Record(new InputEvent(1, InputEventKind.Key, InputEventFlags.VirtualKey, 0x13, 0, 0)); - - Span releases = new InputEvent[tracker.MaxReleaseEvents]; - int count = tracker.BuildReleaseAll(1, releases); - - Assert.Equal(1, count); - Assert.True((releases[0].Flags & InputEventFlags.VirtualKey) != 0); - Assert.True(releases[0].IsUp); - Assert.Equal(0x13, releases[0].Code); - } - - [Fact] - public void MouseButtonsAreReleasedToo() - { - var tracker = new HeldKeyTracker(); - tracker.Record(InputEvent.MouseButton(1, 0, up: false)); - tracker.Record(InputEvent.MouseButton(1, 2, up: false)); - - Span releases = new InputEvent[tracker.MaxReleaseEvents]; - int count = tracker.BuildReleaseAll(1, releases); - - Assert.Equal(2, count); - Assert.All(releases[..count].ToArray(), e => Assert.Equal(InputEventKind.MouseButton, e.Kind)); - Assert.All(releases[..count].ToArray(), e => Assert.True(e.IsUp)); - } - - [Fact] - public void MotionAndWheelCarryNoStateToRelease() - { - var tracker = new HeldKeyTracker(); - tracker.Record(InputEvent.MouseMove(1, 10, 10)); - tracker.Record(InputEvent.MouseWheel(1, 120, horizontal: false)); - - Assert.Equal(0, tracker.HeldCount); - } - - [Fact] - public void RepeatedKeyDownCountsOnce() - { - // Auto-repeat sends key-down many times without an intervening key-up. - var tracker = new HeldKeyTracker(); - for (int i = 0; i < 50; i++) - { - tracker.Record(KeyDown(ScanA)); - } - - Assert.Equal(1, tracker.HeldCount); - } - - [Fact] - public void TrackersAreIndependentPerSourcePeer() - { - // Two peers may drive one machine at once; releasing one peer's Shift must not clear the - // other's, or the second peer's typing silently loses its modifier. - var fromPeerA = new HeldKeyTracker(); - var fromPeerB = new HeldKeyTracker(); - - fromPeerA.Record(KeyDown(ScanLeftShift)); - fromPeerB.Record(KeyDown(ScanLeftShift)); - - Span releases = new InputEvent[fromPeerA.MaxReleaseEvents]; - fromPeerA.BuildReleaseAll(1, releases); - - Assert.Equal(0, fromPeerA.HeldCount); - Assert.Equal(1, fromPeerB.HeldCount); - } - - [Fact] - public void ClearForgetsWithoutEmittingReleases() - { - var tracker = new HeldKeyTracker(); - tracker.Record(KeyDown(ScanA)); - tracker.Clear(); - - Span releases = new InputEvent[tracker.MaxReleaseEvents]; - Assert.Equal(0, tracker.BuildReleaseAll(1, releases)); - } - - [Fact] - public void RecordingAllocatesNothing() - { - var tracker = new HeldKeyTracker(); - tracker.Record(KeyDown(ScanA)); - - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 10_000; i++) - { - tracker.Record(KeyDown(ScanA)); - tracker.Record(KeyUp(ScanA)); - tracker.Record(InputEvent.MouseMove(i, 1, 1)); - } - - Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/HostElectionTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/HostElectionTests.cs deleted file mode 100644 index 020efaa..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/HostElectionTests.cs +++ /dev/null @@ -1,379 +0,0 @@ -using VirtualKmSwitch.Cluster; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// The election matrix, driven by an injected clock so every timing case runs instantly and -/// deterministically rather than by sleeping. -/// -public class HostElectionTests -{ - private static readonly Guid LowId = new("11111111-1111-1111-1111-111111111111"); - private static readonly Guid MidId = new("55555555-5555-5555-5555-555555555555"); - private static readonly Guid HighId = new("99999999-9999-9999-9999-999999999999"); - - private static readonly ElectionTimings Timings = new(); - - private static HostElection Node(Guid nodeId, int priority = 0, ulong term = 0) => - new(new NodeRank(priority, nodeId), term, Timings); - - private static TimeSpan At(double milliseconds) => TimeSpan.FromMilliseconds(milliseconds); - - /// Runs the clock forward in small steps, collecting what the node broadcasts. - private static List Advance(HostElection election, double fromMs, double toMs, double stepMs = 50) - { - var effects = new List(); - - for (double now = fromMs; now <= toMs; now += stepMs) - { - ElectionEffect effect = election.Tick(At(now)); - if (effect != ElectionEffect.None) - { - effects.Add(effect); - } - } - - return effects; - } - - [Fact] - public void ALoneNodeBecomesHostAfterTheDiscoveryWindow() - { - // A cluster of one -- the create-cluster case -- must reach a host without waiting on - // anyone else to answer. - HostElection node = Node(LowId); - - Assert.Equal(HostRole.Follower, node.Role); - - Advance(node, 0, 500); - Assert.Equal(HostRole.Follower, node.Role); - - Advance(node, 500, 2500); - Assert.Equal(HostRole.Host, node.Role); - Assert.Equal(LowId, node.CurrentHost); - Assert.True(node.Term > 0); - } - - /// - /// Advances the clock while a live host keeps heartbeating, which is what actually suppresses - /// an election. A single heartbeat does not: after the timeout the host is presumed gone, and - /// correctly so. - /// - private static void AdvanceWithLiveHost( - HostElection election, double fromMs, double toMs, Guid hostId, HostClaim claim, double stepMs = 50) - { - double lastHeartbeat = fromMs; - - for (double now = fromMs; now <= toMs; now += stepMs) - { - if (now - lastHeartbeat >= Timings.HeartbeatInterval.TotalMilliseconds) - { - election.OnHeartbeat(At(now), hostId, claim); - lastHeartbeat = now; - } - - election.Tick(At(now)); - } - } - - [Fact] - public void ALiveHostSuppressesElectionsIndefinitely() - { - HostElection node = Node(LowId); - var claim = new HostClaim(5, new NodeRank(0, HighId)); - - node.OnHeartbeat(At(100), HighId, claim); - AdvanceWithLiveHost(node, 100, 5000, HighId, claim); - - Assert.Equal(HostRole.Follower, node.Role); - Assert.Equal(HighId, node.CurrentHost); - Assert.Equal(5UL, node.Term); - } - - [Fact] - public void ASingleHeartbeatOnlyDelaysAnElectionByTheTimeout() - { - // The converse, and the reason the test above must keep heartbeating: one heartbeat proves - // a host was alive at that instant, not that it still is. - HostElection node = Node(LowId); - node.OnHeartbeat(At(100), HighId, new HostClaim(5, new NodeRank(0, HighId))); - - Advance(node, 100, 700); - Assert.Equal(HostRole.Follower, node.Role); - - Advance(node, 700, 2000); - Assert.Equal(HostRole.Host, node.Role); - } - - [Fact] - public void AHostThatGoesSilentIsReplaced() - { - HostElection node = Node(LowId); - node.OnHeartbeat(At(100), HighId, new HostClaim(3, new NodeRank(0, HighId))); - - // Still within the timeout: the host is presumed alive. - Advance(node, 100, 700); - Assert.Equal(HostRole.Follower, node.Role); - - Advance(node, 700, 2000); - Assert.Equal(HostRole.Host, node.Role); - Assert.True(node.Term > 3); - } - - [Fact] - public void ABetterRankedNodeJoiningDoesNotDisturbAHealthyHost() - { - // The rule that keeps the role stable as a cluster wakes up. Without it, every machine - // powering on in the morning would trigger a handover. - HostElection host = Node(HighId); - Advance(host, 0, 2500); - Assert.Equal(HostRole.Host, host.Role); - - ulong termWhileHosting = host.Term; - - // A better-ranked latecomer starts up while the incumbent keeps heartbeating. - HostElection latecomer = Node(LowId, priority: 100); - var incumbentClaim = new HostClaim(termWhileHosting, host.Rank); - AdvanceWithLiveHost(latecomer, 3000, 6000, HighId, incumbentClaim); - - Assert.Equal(HostRole.Follower, latecomer.Role); - Assert.Equal(HighId, latecomer.CurrentHost); - - // And it never campaigns: a follower emits neither a claim nor a heartbeat, which is what - // keeps the incumbent from ever hearing anything that could dethrone it. Rank at an equal - // term only ever arises between two hosts, which is the split-brain case. - List emitted = []; - double lastHeartbeat = 6000; - - for (double now = 6000; now <= 9000; now += 50) - { - if (now - lastHeartbeat >= Timings.HeartbeatInterval.TotalMilliseconds) - { - latecomer.OnHeartbeat(At(now), HighId, incumbentClaim); - lastHeartbeat = now; - } - - ElectionEffect effect = latecomer.Tick(At(now)); - if (effect != ElectionEffect.None) - { - emitted.Add(effect); - } - } - - Assert.Empty(emitted); - Assert.Equal(HostRole.Follower, latecomer.Role); - } - - [Fact] - public void OnlyAHostEverEmitsAHeartbeat() - { - // The invariant the incumbent-stability rule rests on. If a follower or candidate could - // heartbeat, a better-ranked one would dethrone a healthy host at an equal term. - HostElection node = Node(LowId); - - List whileFollower = Advance(node, 0, 1400); - Assert.DoesNotContain(ElectionEffect.BroadcastHeartbeat, whileFollower); - - List throughCandidacy = Advance(node, 1400, 1900); - Assert.Contains(ElectionEffect.BroadcastClaim, throughCandidacy); - Assert.DoesNotContain(ElectionEffect.BroadcastHeartbeat, throughCandidacy); - - Advance(node, 1900, 2500); - Assert.Equal(HostRole.Host, node.Role); - - List asHost = Advance(node, 2500, 3200); - Assert.Contains(ElectionEffect.BroadcastHeartbeat, asHost); - } - - [Fact] - public void RankDecidesOnlyWhenTermsAreEqual() - { - var lowRank = new HostClaim(5, new NodeRank(0, HighId)); - var highRank = new HostClaim(5, new NodeRank(10, LowId)); - - Assert.True(highRank.Beats(lowRank)); - Assert.False(lowRank.Beats(highRank)); - - // A higher term wins regardless of rank -- which is what makes deliberate takeover work. - var laterButLowerRank = new HostClaim(6, new NodeRank(0, HighId)); - Assert.True(laterButLowerRank.Beats(highRank)); - } - - [Fact] - public void ManualTakeoverBeatsABetterRankedIncumbent() - { - // Someone sitting at a machine asking for control should get it, not be told another - // machine outranks them. - HostElection incumbent = Node(HighId, priority: 100); - Advance(incumbent, 0, 2500); - Assert.Equal(HostRole.Host, incumbent.Role); - - HostElection challenger = Node(LowId); - challenger.OnHeartbeat(At(3000), HighId, new HostClaim(incumbent.Term, incumbent.Rank)); - - Assert.Equal(ElectionEffect.BroadcastClaim, challenger.ClaimHost(At(3100))); - Assert.True(challenger.Term > incumbent.Term); - - Advance(challenger, 3100, 4000); - Assert.Equal(HostRole.Host, challenger.Role); - - // The incumbent stands down on seeing the higher term. - incumbent.OnHeartbeat(At(4100), LowId, new HostClaim(challenger.Term, challenger.Rank)); - Assert.Equal(HostRole.Follower, incumbent.Role); - Assert.Equal(LowId, incumbent.CurrentHost); - } - - [Fact] - public void SplitBrainResolvesToExactlyOneHost() - { - // Two partitions each elect a host; when they heal, the strict total order must leave - // precisely one standing. - HostElection left = Node(LowId, priority: 5); - HostElection right = Node(HighId, priority: 5); - - Advance(left, 0, 2500); - Advance(right, 0, 2500); - Assert.Equal(HostRole.Host, left.Role); - Assert.Equal(HostRole.Host, right.Role); - - // The partition heals and each sees the other's heartbeat. - left.OnHeartbeat(At(3000), HighId, new HostClaim(right.Term, right.Rank)); - right.OnHeartbeat(At(3000), LowId, new HostClaim(left.Term, left.Rank)); - - Assert.True( - left.IsHost ^ right.IsHost, - $"exactly one host expected, got left={left.Role} right={right.Role}"); - } - - [Fact] - public void AYieldingHostDoesNotImmediatelyWinTheElectionItCaused() - { - HostElection host = Node(LowId); - Advance(host, 0, 2500); - Assert.Equal(HostRole.Host, host.Role); - - Assert.Equal(ElectionEffect.BroadcastYield, host.Yield(At(3000))); - Assert.Equal(HostRole.Follower, host.Role); - - // Held out of the running while another node takes over. - Advance(host, 3000, 5000); - Assert.Equal(HostRole.Follower, host.Role); - - // Eligible again once the cooldown expires and nobody else has taken the role. - Advance(host, 5000, 8000); - Assert.Equal(HostRole.Host, host.Role); - } - - [Fact] - public void AYieldFromTheCurrentHostStartsAnElectionWithoutWaitingOut() - { - HostElection follower = Node(LowId); - follower.OnHeartbeat(At(100), HighId, new HostClaim(3, new NodeRank(0, HighId))); - - follower.OnYield(At(200), HighId); - - // The full heartbeat timeout is not waited out, because the host said it was leaving. - Advance(follower, 200, 900); - Assert.Equal(HostRole.Host, follower.Role); - } - - [Fact] - public void ACandidateStandsDownForABetterClaim() - { - HostElection candidate = Node(LowId); - Advance(candidate, 0, 1600); - Assert.Equal(HostRole.Candidate, candidate.Role); - - candidate.OnClaim(At(1650), HighId, new HostClaim(candidate.Term + 5, new NodeRank(0, HighId))); - - Assert.Equal(HostRole.Follower, candidate.Role); - Assert.Equal(HighId, candidate.CurrentHost); - - Advance(candidate, 1650, 2000); - Assert.Equal(HostRole.Follower, candidate.Role); - } - - [Fact] - public void ACandidateIgnoresAnInferiorClaim() - { - HostElection candidate = Node(HighId, priority: 10); - Advance(candidate, 0, 1600); - Assert.Equal(HostRole.Candidate, candidate.Role); - - candidate.OnClaim(At(1650), LowId, new HostClaim(candidate.Term, new NodeRank(0, LowId))); - - Assert.Equal(HostRole.Candidate, candidate.Role); - - Advance(candidate, 1650, 2500); - Assert.Equal(HostRole.Host, candidate.Role); - } - - [Fact] - public void AHostHeartbeatsAtTheConfiguredInterval() - { - HostElection host = Node(LowId); - Advance(host, 0, 2500); - Assert.Equal(HostRole.Host, host.Role); - - List effects = Advance(host, 2500, 3500, stepMs: 50); - - Assert.All(effects, effect => Assert.Equal(ElectionEffect.BroadcastHeartbeat, effect)); - - // Roughly one per interval over a second, not one per tick. - Assert.InRange(effects.Count, 3, 6); - } - - /// - /// The determinism the plan asks for: the same set of nodes must elect the same host every - /// time, or a cluster would settle differently after each restart. - /// - [Theory] - [InlineData(1)] - [InlineData(99)] - [InlineData(12345)] - public void TheSameNodeSetAlwaysElectsTheSameHost(int seed) - { - var random = new Random(seed); - (Guid Id, int Priority)[] candidates = - [ - (LowId, random.Next(0, 3)), - (MidId, random.Next(0, 3)), - (HighId, random.Next(0, 3)), - ]; - - Guid? firstWinner = null; - - // Repeat the contest with the nodes campaigning in different orders each round. - for (int attempt = 0; attempt < 8; attempt++) - { - HostElection[] nodes = - [.. candidates.OrderBy(_ => random.Next()).Select(c => Node(c.Id, c.Priority))]; - - foreach (HostElection node in nodes) - { - Advance(node, 0, 1600); - } - - // Everyone hears everyone else's claim. - foreach (HostElection node in nodes) - { - foreach (HostElection other in nodes.Where(n => !ReferenceEquals(n, node))) - { - node.OnClaim(At(1650), other.Rank.NodeId, new HostClaim(other.Term, other.Rank)); - } - } - - foreach (HostElection node in nodes) - { - Advance(node, 1650, 2500); - } - - HostElection[] hosts = [.. nodes.Where(node => node.IsHost)]; - Assert.Single(hosts); - - firstWinner ??= hosts[0].Rank.NodeId; - Assert.Equal(firstWinner, hosts[0].Rank.NodeId); - } - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/HotkeyMatcherTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/HotkeyMatcherTests.cs deleted file mode 100644 index c22f14e..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/HotkeyMatcherTests.cs +++ /dev/null @@ -1,181 +0,0 @@ -using VirtualKmSwitch.InputEngine; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class HotkeyMatcherTests -{ - private const ushort LCtrl = 0xA2; - private const ushort RCtrl = 0xA3; - private const ushort LAlt = 0xA4; - private const ushort LShift = 0xA0; - private const ushort Key1 = 0x31; - private const ushort Key0 = 0x30; - private const ushort Home = 0x24; - private const ushort Escape = 0x1B; - private const ushort KeyA = 0x41; - - private static HotkeyMatch Press(HotkeyMatcher matcher, ushort key, out bool swallow) => - matcher.Process(key, isUp: false, out swallow); - - private static void Hold(HotkeyMatcher matcher, params ushort[] keys) - { - foreach (ushort key in keys) - { - matcher.Process(key, isUp: false, out _); - } - } - - [Fact] - public void CtrlAltDigitEngagesTheMatchingSlot() - { - var matcher = new HotkeyMatcher(); - Hold(matcher, LCtrl, LAlt); - - HotkeyMatch match = Press(matcher, Key1, out bool swallow); - - Assert.Equal(HotkeyActionKind.Engage, match.Kind); - Assert.Equal(1, match.Slot); - Assert.True(swallow, "the trigger key must not reach the local machine"); - } - - [Fact] - public void CtrlAltZeroReturnsControlLocally() - { - var matcher = new HotkeyMatcher(); - Hold(matcher, LCtrl, LAlt); - - Assert.Equal(HotkeyActionKind.ReturnLocal, Press(matcher, Key0, out _).Kind); - } - - [Fact] - public void CtrlAltHomeClaimsHost() - { - var matcher = new HotkeyMatcher(); - Hold(matcher, LCtrl, LAlt); - - Assert.Equal(HotkeyActionKind.ClaimHost, Press(matcher, Home, out _).Kind); - } - - [Fact] - public void PanicRequiresShiftAsWell() - { - var matcher = new HotkeyMatcher(); - - // Ctrl+Alt+Esc is not the panic chord and must not fire it. - Hold(matcher, LCtrl, LAlt); - Assert.False(Press(matcher, Escape, out _).IsMatch); - - Hold(matcher, LShift); - Assert.Equal(HotkeyActionKind.Panic, Press(matcher, Escape, out _).Kind); - } - - [Fact] - public void EitherSideOfAModifierPairCounts() - { - var matcher = new HotkeyMatcher(); - Hold(matcher, RCtrl, LAlt); - - Assert.Equal(HotkeyActionKind.Engage, Press(matcher, Key1, out _).Kind); - } - - [Fact] - public void ReleasingOneOfAPairKeepsTheModifierHeld() - { - // Holding both controls and releasing one must not clear Control, or a chord typed with - // both hands would silently stop matching. - var matcher = new HotkeyMatcher(); - Hold(matcher, LCtrl, RCtrl, LAlt); - - matcher.Process(LCtrl, isUp: true, out _); - - Assert.True(matcher.CurrentModifiers.HasFlag(Modifiers.Control)); - Assert.Equal(HotkeyActionKind.Engage, Press(matcher, Key1, out _).Kind); - } - - [Fact] - public void ExtraModifiersPreventAMatch() - { - // Ctrl+Alt+Shift+1 is not Ctrl+Alt+1. Matching it would hijack chords belonging to other - // applications. - var matcher = new HotkeyMatcher(); - Hold(matcher, LCtrl, LAlt, LShift); - - Assert.False(Press(matcher, Key1, out bool swallow).IsMatch); - Assert.False(swallow); - } - - [Fact] - public void PlainKeysAreNeitherMatchedNorSwallowed() - { - var matcher = new HotkeyMatcher(); - - Assert.False(Press(matcher, KeyA, out bool swallow).IsMatch); - Assert.False(swallow); - - Hold(matcher, LCtrl, LAlt); - Assert.False(Press(matcher, KeyA, out swallow).IsMatch); - Assert.False(swallow); - } - - [Fact] - public void ModifierKeysThemselvesPassThrough() - { - // Swallowing modifiers would break every ordinary shortcut on the machine while idle. - var matcher = new HotkeyMatcher(); - - Press(matcher, LCtrl, out bool swallow); - Assert.False(swallow); - - Press(matcher, LAlt, out swallow); - Assert.False(swallow); - } - - [Fact] - public void ReleaseOfASwallowedTriggerIsAlsoSwallowed() - { - // Otherwise the application underneath sees a key-up with no matching key-down, which some - // apps treat as a stuck key. - var matcher = new HotkeyMatcher(); - Hold(matcher, LCtrl, LAlt); - Press(matcher, Key1, out _); - - matcher.Process(Key1, isUp: true, out bool swallow); - Assert.True(swallow); - - // Only the one release is consumed, not every subsequent release of that key. - matcher.Process(Key1, isUp: true, out swallow); - Assert.False(swallow); - } - - [Fact] - public void ResetForgetsHeldModifiers() - { - var matcher = new HotkeyMatcher(); - Hold(matcher, LCtrl, LAlt); - - matcher.Reset(); - - Assert.Equal(Modifiers.None, matcher.CurrentModifiers); - Assert.False(Press(matcher, Key1, out _).IsMatch); - } - - [Fact] - public void MatchingAllocatesNothing() - { - // Runs inside the hook callback, where a GC pause risks the LowLevelHooksTimeout. - var matcher = new HotkeyMatcher(); - Hold(matcher, LCtrl, LAlt); - Press(matcher, Key1, out _); - - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 10_000; i++) - { - matcher.Process(Key1, isUp: false, out _); - matcher.Process(Key1, isUp: true, out _); - matcher.Process(KeyA, isUp: false, out _); - } - - Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/InputCoalescerTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/InputCoalescerTests.cs deleted file mode 100644 index fef3a0b..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/InputCoalescerTests.cs +++ /dev/null @@ -1,174 +0,0 @@ -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class InputCoalescerTests -{ - private static int Run(InputEvent[] source, out InputEvent[] result) - { - var destination = new InputEvent[source.Length]; - int count = InputCoalescer.Coalesce(source, destination); - result = destination[..count]; - return count; - } - - [Fact] - public void ConsecutiveMotionMergesIntoOneEvent() - { - InputEvent[] source = - [ - InputEvent.MouseMove(10, 3, 1), - InputEvent.MouseMove(20, 4, 2), - InputEvent.MouseMove(30, -1, 3), - ]; - - Assert.Equal(1, Run(source, out InputEvent[] result)); - Assert.Equal(6, result[0].X); - Assert.Equal(6, result[0].Y); - - // The earliest timestamp survives, so latency is measured from when the movement began - // rather than when the batch happened to be flushed. - Assert.Equal(10, result[0].Timestamp); - } - - [Fact] - public void ButtonsAndKeysEndTheRun() - { - // A click must stay after the motion that preceded it, or a drag lands somewhere else. - InputEvent[] source = - [ - InputEvent.MouseMove(1, 5, 0), - InputEvent.MouseMove(2, 5, 0), - InputEvent.MouseButton(3, 0, up: false), - InputEvent.MouseMove(4, 7, 0), - InputEvent.MouseButton(5, 0, up: true), - ]; - - Assert.Equal(4, Run(source, out InputEvent[] result)); - Assert.Equal(InputEventKind.MouseMove, result[0].Kind); - Assert.Equal(10, result[0].X); - Assert.Equal(InputEventKind.MouseButton, result[1].Kind); - Assert.False(result[1].IsUp); - Assert.Equal(7, result[2].X); - Assert.True(result[3].IsUp); - } - - [Fact] - public void KeyEventsAreNeverMergedOrDropped() - { - // Losing a key-up strands a modifier held down on a remote machine, which is the one - // failure that cannot be recovered from the far end. - InputEvent[] source = - [ - InputEvent.Key(1, 0x2A, InputEventFlags.None), - InputEvent.Key(2, 0x1E, InputEventFlags.None), - InputEvent.Key(3, 0x1E, InputEventFlags.Up), - InputEvent.Key(4, 0x2A, InputEventFlags.Up), - ]; - - Assert.Equal(4, Run(source, out InputEvent[] result)); - Assert.Equal(source, result); - } - - [Fact] - public void MotionThatCancelsOutIsDropped() - { - // A net-zero run is not movement; sending it would make the far side inject nothing at - // some cost. - InputEvent[] source = - [ - InputEvent.MouseMove(1, 5, 5), - InputEvent.MouseMove(2, -5, -5), - ]; - - Assert.Equal(0, Run(source, out _)); - } - - [Fact] - public void ACancellingRunBetweenClicksStillPreservesTheClicks() - { - InputEvent[] source = - [ - InputEvent.MouseButton(1, 0, up: false), - InputEvent.MouseMove(2, 3, 0), - InputEvent.MouseMove(3, -3, 0), - InputEvent.MouseButton(4, 0, up: true), - ]; - - Assert.Equal(2, Run(source, out InputEvent[] result)); - Assert.All(result, e => Assert.Equal(InputEventKind.MouseButton, e.Kind)); - } - - [Fact] - public void AccumulatedMotionSaturatesRatherThanWrapping() - { - // Wrapping would send the pointer hard in the opposite direction, which is far worse than - // clamping at a distance no screen is that wide. - InputEvent[] source = - [ - InputEvent.MouseMove(1, int.MaxValue, 0), - InputEvent.MouseMove(2, int.MaxValue, 0), - ]; - - Assert.Equal(1, Run(source, out InputEvent[] result)); - Assert.Equal(int.MaxValue, result[0].X); - } - - [Fact] - public void OtherEventKindsPassThroughUntouched() - { - InputEvent[] source = - [ - InputEvent.MouseWheel(1, 120, horizontal: false), - InputEvent.ReleaseAll(2), - ]; - - Assert.Equal(2, Run(source, out InputEvent[] result)); - Assert.Equal(InputEventKind.MouseWheel, result[0].Kind); - Assert.Equal(InputEventKind.ReleaseAll, result[1].Kind); - } - - [Fact] - public void EmptyInputProducesNothing() => - Assert.Equal(0, InputCoalescer.Coalesce([], new InputEvent[4])); - - [Fact] - public void CoalescingAllocatesNothing() - { - var source = new InputEvent[64]; - for (int i = 0; i < source.Length; i++) - { - source[i] = i % 8 == 7 - ? InputEvent.Key(i, 0x1E, InputEventFlags.None) - : InputEvent.MouseMove(i, 1, 1); - } - - var destination = new InputEvent[64]; - InputCoalescer.Coalesce(source, destination); - - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 10_000; i++) - { - InputCoalescer.Coalesce(source, destination); - } - - Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); - } - - [Fact] - public void MotionHeavyTrafficCompressesSharply() - { - // The realistic case: a high-polling-rate mouse produces long runs of tiny deltas, and - // this is where batching earns its keep on the wire. - var source = new InputEvent[1000]; - for (int i = 0; i < source.Length; i++) - { - source[i] = InputEvent.MouseMove(i, 1, 0); - } - - Assert.Equal(1, Run(source, out InputEvent[] result)); - Assert.Equal(1000, result[0].X); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/InputRouterTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/InputRouterTests.cs deleted file mode 100644 index 66f4c44..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/InputRouterTests.cs +++ /dev/null @@ -1,207 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Service; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// Which machine a captured keystroke goes to. -/// -/// The whole product is this decision made correctly a few thousand times a second, and both ways -/// of getting it wrong are bad in ways a user cannot recover from: input that leaks to the local -/// machine while they believe they are driving a remote one, or a modifier left held down on a -/// machine they have stopped driving. -/// -public class InputRouterTests -{ - private sealed class FakeSender : IPeerSender - { - private readonly List<(Guid Peer, InputEvent Event)> _sent = []; - - /// Peers this sender will refuse, standing in for a queue that has backed up. - public HashSet Refusing { get; } = []; - - public HashSet Connected { get; } = []; - - public IReadOnlyList<(Guid Peer, InputEvent Event)> Sent => _sent; - - public bool TrySend(Guid peerId, in InputEvent inputEvent) - { - if (Refusing.Contains(peerId)) - { - return false; - } - - _sent.Add((peerId, inputEvent)); - return true; - } - - public bool IsConnected(Guid peerId) => Connected.Contains(peerId); - } - - private static InputRouter Create(FakeSender sender) - { - var router = new InputRouter( - new AgentSupervisor(NullLogger.Instance), - NullLogger.Instance); - - router.Sender = sender; - return router; - } - - [Fact] - public void WithNothingEngagedNothingLeavesTheMachine() - { - // The default state, and the one a bug here would be most damaging in: an idle machine - // that quietly forwards its owner's typing somewhere. - var sender = new FakeSender(); - InputRouter router = Create(sender); - - router.Capture(InputEvent.Key(1, 30, InputEventFlags.None)); - router.Capture(InputEvent.MouseMove(2, 5, 5)); - - Assert.Empty(sender.Sent); - Assert.False(router.IsEngaged); - Assert.Equal(Guid.Empty, router.Target); - } - - [Fact] - public void WhileEngagedEveryCapturedEventGoesToThatMachine() - { - var sender = new FakeSender(); - InputRouter router = Create(sender); - var peer = Guid.NewGuid(); - - router.Engage(peer); - router.Capture(InputEvent.Key(1, 30, InputEventFlags.None)); - router.Capture(InputEvent.MouseMove(2, 5, 5)); - router.Capture(InputEvent.Key(3, 30, InputEventFlags.Up)); - - Assert.Equal(3, sender.Sent.Count); - Assert.All(sender.Sent, entry => Assert.Equal(peer, entry.Peer)); - Assert.Equal(3, router.SentToPeer); - } - - [Fact] - public void EngagingASecondMachineRedirectsRatherThanDuplicating() - { - var sender = new FakeSender(); - InputRouter router = Create(sender); - var first = Guid.NewGuid(); - var second = Guid.NewGuid(); - - router.Engage(first); - router.Capture(InputEvent.Key(1, 30, InputEventFlags.None)); - - router.Engage(second); - router.Capture(InputEvent.Key(2, 31, InputEventFlags.None)); - - Assert.Equal(first, sender.Sent[0].Peer); - Assert.Equal(second, sender.Sent[1].Peer); - Assert.Equal(second, router.Target); - } - - [Fact] - public void DisengagingReleasesEverythingHeldOnTheMachineBeingLeft() - { - // The single most common way a KVM breaks: a modifier is down when control moves away, and - // the machine left behind has no way to work out that it should let go. - var sender = new FakeSender(); - InputRouter router = Create(sender); - var peer = Guid.NewGuid(); - - router.Engage(peer); - router.Capture(InputEvent.Key(1, 42, InputEventFlags.None)); - router.Disengage(); - - (Guid Peer, InputEvent Event) last = sender.Sent[^1]; - - Assert.Equal(peer, last.Peer); - Assert.Equal(InputEventKind.ReleaseAll, last.Event.Kind); - Assert.False(router.IsEngaged); - } - - [Fact] - public void DisengagingWhenNothingIsEngagedIsHarmless() - { - // Reached by a person who is not sure what state they are in, which is exactly when it - // must not throw or send anything. - var sender = new FakeSender(); - InputRouter router = Create(sender); - - router.Disengage(); - router.Disengage(); - - Assert.Empty(sender.Sent); - } - - [Fact] - public void OnceDisengagedInputStaysOnThisMachine() - { - var sender = new FakeSender(); - InputRouter router = Create(sender); - var peer = Guid.NewGuid(); - - router.Engage(peer); - router.Disengage(); - - int afterRelease = sender.Sent.Count; - router.Capture(InputEvent.Key(9, 30, InputEventFlags.None)); - - Assert.Equal(afterRelease, sender.Sent.Count); - } - - [Fact] - public void ATargetThatCannotTakeAKeyEventIsReported() - { - var sender = new FakeSender(); - InputRouter router = Create(sender); - var peer = Guid.NewGuid(); - sender.Refusing.Add(peer); - - Guid? stalled = null; - router.TargetStalled += id => stalled = id; - - router.Engage(peer); - router.Capture(InputEvent.Key(1, 30, InputEventFlags.None)); - - // Surfaced rather than counted. The listener disengages, which is the only response that - // does not leave the remote machine in a state nothing can clear. - Assert.Equal(peer, stalled); - Assert.Equal(1, router.RefusedByPeer); - } - - [Fact] - public void WithNoMeshAttachedEngagingIsInertRatherThanFatal() - { - // The service starts before the mesh does, and a hotkey pressed in that window must not - // take the router down. - var router = new InputRouter( - new AgentSupervisor(NullLogger.Instance), - NullLogger.Instance); - - router.Engage(Guid.NewGuid()); - router.Capture(InputEvent.Key(1, 30, InputEventFlags.None)); - router.Disengage(); - - Assert.False(router.IsEngaged); - } - - [Fact] - public void CapturedEventsAreStillReportedLocallyWhileEngaged() - { - // Diagnostics must not go dark the moment the machine starts driving another one. - var sender = new FakeSender(); - InputRouter router = Create(sender); - - int seen = 0; - router.CapturedLocally += _ => seen++; - - router.Engage(Guid.NewGuid()); - router.Capture(InputEvent.Key(1, 30, InputEventFlags.None)); - router.Capture(InputEvent.Key(2, 30, InputEventFlags.Up)); - - Assert.Equal(2, seen); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/JoinKeyTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/JoinKeyTests.cs deleted file mode 100644 index 9336a28..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/JoinKeyTests.cs +++ /dev/null @@ -1,158 +0,0 @@ -using VirtualKmSwitch.Ipc; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class JoinKeyTests -{ - [Fact] - public void AKeyIsGroupedForReadingAloud() - { - string key = JoinKey.Create(); - - Assert.Equal("XXXX-XXXX-XXXX-XXXX".Length, key.Length); - Assert.Equal(4, key.Split('-').Length); - Assert.All(key.Split('-'), group => Assert.Equal(4, group.Length)); - } - - [Theory] - [InlineData('I')] - [InlineData('O')] - [InlineData('0')] - [InlineData('1')] - public void TheAlphabetOmitsCharactersThatAreMisreadOffAScreen(char excluded) - { - // Someone is going to read this out loud to someone in another room. A key containing - // both O and 0 turns that into a guessing game, and the failure looks like a wrong key. - for (int attempt = 0; attempt < 200; attempt++) - { - Assert.DoesNotContain(excluded, JoinKey.Create()); - } - } - - [Fact] - public void KeysDoNotRepeat() - { - var seen = new HashSet(); - - for (int attempt = 0; attempt < 500; attempt++) - { - Assert.True(seen.Add(JoinKey.Create())); - } - } - - [Theory] - [InlineData("ABCD-EFGH-JKLM-NPQR")] - [InlineData("abcd-efgh-jklm-npqr")] - [InlineData("ABCDEFGHJKLMNPQR")] - [InlineData("ABCD EFGH JKLM NPQR")] - [InlineData(" ABCD-efgh JKLM-npqr ")] - public void TranscriptionDifferencesDoNotChangeTheKey(string typed) - { - // Case, spaces and missing dashes are how people actually type a key they were told over - // the phone; none of them should be the difference between joining and being refused. - Assert.Equal("ABCDEFGHJKLMNPQR", JoinKey.Normalize(typed)); - } - - [Fact] - public void GenuinelyDifferentKeysStayDifferent() - { - Assert.NotEqual(JoinKey.Normalize("ABCD-EFGH-JKLM-NPQR"), JoinKey.Normalize("ABCD-EFGH-JKLM-NPQS")); - } - - [Fact] - public void EmptyInputNormalisesToEmptyRatherThanThrowing() - { - Assert.Equal(string.Empty, JoinKey.Normalize(string.Empty)); - Assert.Equal(string.Empty, JoinKey.Normalize(" ")); - } - - [Fact] - public void TheClusterIdIsStableAcrossHowTheKeyWasTyped() - { - // The beacon advertises this id, so two machines holding the same key must derive the - // same one regardless of who typed it and how. - string fromCreator = EnrollmentHandshake.ClusterIdFor(JoinKey.Normalize("ABCD-EFGH-JKLM-NPQR")); - string fromJoiner = EnrollmentHandshake.ClusterIdFor(JoinKey.Normalize("abcd efgh jklm npqr")); - - Assert.Equal(fromCreator, fromJoiner); - } -} - -public class ClusterViewTests -{ - [Fact] - public void AMachineWithNoNameFallsBackToItsIdRatherThanAnInventedName() - { - // "Machine 2" would read as information when it is really just a position in a list. - var machine = new MachineInfo { NodeId = Guid.NewGuid() }; - - Assert.Equal(machine.NodeId.ToString("N")[..12], machine.Label); - } - - [Fact] - public void ANamedMachineShowsItsName() - { - var machine = new MachineInfo { NodeId = Guid.NewGuid(), DisplayName = "Workshop" }; - - Assert.Equal("Workshop", machine.Label); - } - - [Fact] - public void AViewSurvivesTheRoundTripThroughAControlResponse() - { - var view = new ClusterView - { - HasCluster = true, - ClusterId = "0123456789ABCDEF", - ClusterName = "Home", - LocalNodeId = Guid.NewGuid(), - Health = ClusterHealth.RecoveredFromBackup, - HealthDetail = "primary was empty", - Machines = [new MachineInfo { NodeId = Guid.NewGuid(), DisplayName = "Desk", IsLocal = true }], - }; - - ControlResponse response = ControlResponse.Ok(new Dictionary - { - [ClusterView.ValueKey] = view.ToJson(), - }); - - ClusterView? decoded = ClusterView.FromResponse(response); - - Assert.NotNull(decoded); - Assert.Equal(view.ClusterName, decoded.ClusterName); - Assert.Equal(view.LocalNodeId, decoded.LocalNodeId); - Assert.Equal(ClusterHealth.RecoveredFromBackup, decoded.Health); - Assert.Equal("Desk", Assert.Single(decoded.Machines).DisplayName); - } - - [Fact] - public void TheJoinKeyIsAbsentUnlessDeliberatelyIncluded() - { - ClusterView view = new() { HasCluster = true }; - - Assert.Null(ClusterView.FromResponse( - ControlResponse.Ok(new Dictionary { [ClusterView.ValueKey] = view.ToJson() }))?.JoinKey); - } - - [Fact] - public void AResponseCarryingNoViewDecodesToNothingRatherThanAnEmptyCluster() - { - // An empty cluster and "the service did not answer that question" look identical on - // screen, and only one of them means every peer has left. - Assert.Null(ClusterView.FromResponse(ControlResponse.Ok())); - Assert.Null(ClusterView.FromResponse(ControlResponse.Fail("nope"))); - } - - [Fact] - public void MalformedContentDecodesToNothingRatherThanThrowing() - { - ControlResponse response = ControlResponse.Ok(new Dictionary - { - [ClusterView.ValueKey] = "{ not json", - }); - - Assert.Null(ClusterView.FromResponse(response)); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/KeyboardTranslatorTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/KeyboardTranslatorTests.cs deleted file mode 100644 index 4715643..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/KeyboardTranslatorTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using VirtualKmSwitch.Core; -using VirtualKmSwitch.InputEngine; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class KeyboardTranslatorTests -{ - private const uint Down = KeyboardTranslator.WM_KEYDOWN; - private const uint Up = KeyboardTranslator.WM_KEYUP; - private const uint SysDown = KeyboardTranslator.WM_SYSKEYDOWN; - private const uint SysUp = KeyboardTranslator.WM_SYSKEYUP; - private const uint Extended = KeyboardTranslator.LLKHF_EXTENDED; - - [Theory] - [InlineData(Down, false)] - [InlineData(SysDown, false)] - [InlineData(Up, true)] - [InlineData(SysUp, true)] - public void KeyTransitionDirectionIsTaggedFromTheMessage(uint message, bool expectUp) - { - Assert.True(KeyboardTranslator.TryTranslate(message, 0x41, 0x1E, 0, 100, out InputEvent e)); - Assert.Equal(expectUp, e.IsUp); - Assert.Equal(0x1E, e.Code); - Assert.Equal(100, e.Timestamp); - } - - [Fact] - public void ExtendedKeysAreFlaggedRatherThanPrefixed() - { - // Right Ctrl: same scancode as left, distinguished only by the E0 prefix. The prefix must - // travel as a flag, never packed into the scancode itself. - Assert.True(KeyboardTranslator.TryTranslate(Down, 0xA3, 0x1D, Extended, 1, out InputEvent e)); - Assert.Equal(0x1D, e.Code); - Assert.True((e.Flags & InputEventFlags.Extended) != 0); - } - - [Theory] - [InlineData(0x2A)] // left shift - [InlineData(0x36)] // right shift - public void FakeShiftIsDropped(ushort scanCode) - { - // The driver brackets grey navigation keys with synthetic shifts when Shift or NumLock is - // active. Forwarding them leaves shift stuck or double-toggled on the target. - Assert.False(KeyboardTranslator.TryTranslate(Down, 0xA0, scanCode, Extended, 1, out _)); - } - - [Theory] - [InlineData(0x2A)] - [InlineData(0x36)] - public void RealShiftIsKept(ushort scanCode) - { - // A genuine shift never carries the extended flag, which is what makes the filter exact. - Assert.True(KeyboardTranslator.TryTranslate(Down, 0xA0, scanCode, 0, 1, out InputEvent e)); - Assert.Equal(scanCode, e.Code); - } - - [Fact] - public void PauseIsSentAsVirtualKeyBecauseItsScancodeCannotBeReplayed() - { - // Pause emits E1 1D 45 and reports no release at all, so scancode replay cannot reproduce - // it. It travels as a virtual key and the target synthesises the key-up. - Assert.True(KeyboardTranslator.TryTranslate(Down, 0x13, 0x45, 0, 1, out InputEvent e)); - Assert.True((e.Flags & InputEventFlags.VirtualKey) != 0); - Assert.Equal(0x13, e.Code); - } - - [Fact] - public void CtrlBreakIsSentAsVirtualKey() - { - Assert.True(KeyboardTranslator.TryTranslate(Down, 0x03, 0x46, Extended, 1, out InputEvent e)); - Assert.True((e.Flags & InputEventFlags.VirtualKey) != 0); - Assert.Equal(0x03, e.Code); - } - - [Fact] - public void NumLockIsNotConfusedWithPause() - { - // Both report scancode 0x45. Keying on the scancode alone would emit a spurious NumLock - // toggle on every Pause press; the virtual key is what separates them. - Assert.True(KeyboardTranslator.TryTranslate(Down, 0x90, 0x45, 0, 1, out InputEvent numLock)); - Assert.False((numLock.Flags & InputEventFlags.VirtualKey) != 0); - Assert.Equal(0x45, numLock.Code); - - Assert.True(KeyboardTranslator.TryTranslate(Down, 0x13, 0x45, 0, 1, out InputEvent pause)); - Assert.True((pause.Flags & InputEventFlags.VirtualKey) != 0); - } - - [Fact] - public void KeyboardOverrunSentinelIsDropped() - { - Assert.False(KeyboardTranslator.TryTranslate(Down, 0xFF, 0xFF, 0, 1, out _)); - } - - [Fact] - public void NonKeyboardMessagesAreIgnored() - { - Assert.False(KeyboardTranslator.TryTranslate(0x0200 /* WM_MOUSEMOVE */, 0, 0, 0, 1, out _)); - } - - [Fact] - public void TranslationAllocatesNothing() - { - // Runs on the hook thread, which is on a hard latency budget: a GC pause there risks - // overrunning LowLevelHooksTimeout, after which Windows unhooks us silently. - KeyboardTranslator.TryTranslate(Down, 0x41, 0x1E, 0, 1, out _); - - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 10_000; i++) - { - KeyboardTranslator.TryTranslate(Down, 0x41, 0x1E, 0, i, out _); - } - - Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/LocalSettingsTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/LocalSettingsTests.cs deleted file mode 100644 index b11fe5f..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/LocalSettingsTests.cs +++ /dev/null @@ -1,164 +0,0 @@ -using VirtualKmSwitch.Cluster; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class LocalSettingsTests : IDisposable -{ - private readonly string _directory = - Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N")); - - private static readonly Guid MachineOne = Guid.NewGuid(); - private static readonly Guid MachineTwo = Guid.NewGuid(); - - private LocalSettingsStore CreateStore() => new(Path.Combine(_directory, "local.json")); - - [Fact] - public void AbsentSettingsLoadAsDefaultsRatherThanFailing() - { - LocalSettingsDocument settings = CreateStore().Load(); - - Assert.Empty(settings.Hotkeys); - Assert.True(settings.ClipboardSyncEnabled); - } - - [Fact] - public void BindingsRoundTrip() - { - LocalSettingsStore store = CreateStore(); - LocalSettingsDocument settings = store.Bind(store.Load(), 3, MachineOne); - store.Save(settings); - - LocalSettingsDocument reloaded = store.Load(); - - Assert.Equal(MachineOne, LocalSettingsStore.TargetFor(reloaded, 3)); - } - - [Fact] - public void BindingAMachineAlreadyBoundMovesItRatherThanDuplicating() - { - // Otherwise a machine would appear under two keys that both claim to drive it, and which - // one actually did would depend on iteration order. - LocalSettingsStore store = CreateStore(); - - LocalSettingsDocument settings = store.Bind(store.Load(), 1, MachineOne); - settings = store.Bind(settings, 5, MachineOne); - - Assert.Null(LocalSettingsStore.TargetFor(settings, 1)); - Assert.Equal(MachineOne, LocalSettingsStore.TargetFor(settings, 5)); - Assert.Single(settings.Hotkeys); - } - - [Fact] - public void BindingAnEmptyTargetClearsTheSlot() - { - LocalSettingsStore store = CreateStore(); - - LocalSettingsDocument settings = store.Bind(store.Load(), 2, MachineOne); - settings = store.Bind(settings, 2, Guid.Empty); - - Assert.Null(LocalSettingsStore.TargetFor(settings, 2)); - } - - [Theory] - [InlineData(0)] - [InlineData(10)] - [InlineData(-1)] - public void OnlySlotsOneToNineAreBindable(int slot) - { - // Slot 0 returns control to this machine and is fixed; binding it would take away the way - // back. - LocalSettingsStore store = CreateStore(); - - Assert.Throws(() => store.Bind(store.Load(), slot, MachineOne)); - } - - [Fact] - public void ARemovedMachineLeavesItsSlotEmptyRatherThanShiftingOthersUp() - { - // Silently rebinding a familiar key to a different machine is the worst outcome available: - // the keystrokes land somewhere unexpected and nothing explains why. - LocalSettingsStore store = CreateStore(); - - LocalSettingsDocument settings = store.Bind(store.Load(), 1, MachineOne); - settings = store.Bind(settings, 2, MachineTwo); - - settings = store.PruneRemoved(settings, new HashSet { MachineTwo }); - - Assert.Null(LocalSettingsStore.TargetFor(settings, 1)); - Assert.Equal(MachineTwo, LocalSettingsStore.TargetFor(settings, 2)); - } - - [Fact] - public void ANewMachineTakesTheLowestFreeSlot() - { - LocalSettingsStore store = CreateStore(); - - Assert.Equal(1, LocalSettingsStore.NextFreeSlot(store.Load())); - - LocalSettingsDocument settings = store.Bind(store.Load(), 1, MachineOne); - Assert.Equal(2, LocalSettingsStore.NextFreeSlot(settings)); - - settings = store.Bind(settings, 3, MachineTwo); - Assert.Equal(2, LocalSettingsStore.NextFreeSlot(settings)); - } - - [Fact] - public void AllSlotsTakenReportsNoneRatherThanAnInvalidSlot() - { - LocalSettingsStore store = CreateStore(); - LocalSettingsDocument settings = store.Load(); - - for (int slot = LocalSettingsStore.MinSlot; slot <= LocalSettingsStore.MaxSlot; slot++) - { - settings = store.Bind(settings, slot, Guid.NewGuid()); - } - - Assert.Null(LocalSettingsStore.NextFreeSlot(settings)); - } - - [Fact] - public void CorruptSettingsFallBackToDefaults() - { - // Unlike the roster, nothing here decides who is trusted, so losing it is an inconvenience - // rather than a reason to refuse to start. - LocalSettingsStore store = CreateStore(); - store.Save(store.Bind(store.Load(), 1, MachineOne)); - - File.WriteAllText(store.FilePath, "{ not json"); - - Assert.Empty(store.Load().Hotkeys); - } - - [Fact] - public void UnknownFieldsSurviveARoundTrip() - { - LocalSettingsStore store = CreateStore(); - store.Save(store.Bind(store.Load(), 1, MachineOne)); - - string json = File.ReadAllText(store.FilePath); - json = json.TrimEnd().TrimEnd('}') + ", \"futureOption\": true }"; - File.WriteAllText(store.FilePath, json); - - LocalSettingsDocument loaded = store.Load(); - Assert.NotNull(loaded.Extra); - - store.Save(loaded); - Assert.Contains("futureOption", File.ReadAllText(store.FilePath)); - } - - public void Dispose() - { - try - { - if (Directory.Exists(_directory)) - { - Directory.Delete(_directory, recursive: true); - } - } - catch - { - // Best effort. - } - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/MeshRoutingTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/MeshRoutingTests.cs deleted file mode 100644 index 7309830..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/MeshRoutingTests.cs +++ /dev/null @@ -1,261 +0,0 @@ -using System.Diagnostics; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Logging.Abstractions; -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Service; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// A keystroke captured on one machine arriving on another, over a real encrypted link. -/// -/// This composes the same pieces the service composes -- a router, a mesh, and the adapter between -/// them -- rather than mocking the join between them, because that join is the part that has never -/// run before and is where the wiring can be wrong while every component test still passes. -/// -public class MeshRoutingTests -{ - /// The adapter is, reduced to the part under test. - private sealed class MeshSender(PeerMesh mesh) : IPeerSender - { - public bool TrySend(Guid peerId, in InputEvent inputEvent) => mesh.TrySend(peerId, in inputEvent); - - public bool IsConnected(Guid peerId) => mesh.Connection(peerId) is not null; - } - - private sealed class TwoMachines : IAsyncDisposable - { - public TwoMachines() - { - AliceCertificate = NodeCertificate.Create("CN=RouteAlice", StoreLocation.CurrentUser); - BobCertificate = NodeCertificate.Create("CN=RouteBob", StoreLocation.CurrentUser); - - // Ordered so Alice is the dialler, matching the lower-id-dials rule. - Guid first = Guid.NewGuid(); - Guid second = Guid.NewGuid(); - (AliceId, BobId) = first.CompareTo(second) < 0 ? (first, second) : (second, first); - - var alicePins = new PinStore(); - var bobPins = new PinStore(); - alicePins.Pin(NodeCertificate.ThumbprintOf(BobCertificate), BobId); - bobPins.Pin(NodeCertificate.ThumbprintOf(AliceCertificate), AliceId); - - int port = Random.Shared.Next(45_000, 60_000); - AlicePort = port; - BobPort = port + 1; - - AliceMesh = new PeerMesh(AliceId, AliceCertificate, alicePins, AlicePort); - BobMesh = new PeerMesh(BobId, BobCertificate, bobPins, BobPort); - - AliceRouter = new InputRouter( - new AgentSupervisor(NullLogger.Instance), - NullLogger.Instance) - { - Sender = new MeshSender(AliceMesh), - }; - - BobMesh.InputReceived += (_, batch, _) => - { - lock (Received) - { - foreach (InputEvent inputEvent in batch) - { - Received.Add(inputEvent); - } - } - }; - } - - public X509Certificate2 AliceCertificate { get; } - - public X509Certificate2 BobCertificate { get; } - - public Guid AliceId { get; } - - public Guid BobId { get; } - - public int AlicePort { get; } - - public int BobPort { get; } - - public PeerMesh AliceMesh { get; } - - public PeerMesh BobMesh { get; } - - public InputRouter AliceRouter { get; } - - /// What actually landed on Bob. - public List Received { get; } = []; - - public InputEvent[] Snapshot() - { - lock (Received) - { - return [.. Received]; - } - } - - public async Task ConnectAsync() - { - AliceMesh.Start(); - BobMesh.Start(); - - Assert.True(await AliceMesh.EnsureConnectedAsync(BobId, "127.0.0.1", BobPort)); - await WaitForAsync(() => BobMesh.ConnectionCount > 0, "Bob to see the link"); - } - - public async ValueTask DisposeAsync() - { - await AliceMesh.DisposeAsync(); - await BobMesh.DisposeAsync(); - AliceCertificate.Dispose(); - BobCertificate.Dispose(); - } - } - - private static async Task WaitForAsync(Func condition, string what) - { - var deadline = Stopwatch.StartNew(); - while (deadline.Elapsed < TimeSpan.FromSeconds(15)) - { - if (condition()) - { - return; - } - - await Task.Delay(10); - } - - Assert.Fail("timed out waiting for " + what); - } - - [Fact] - public async Task AKeystrokeCapturedWhileEngagedArrivesOnTheMachineBeingDriven() - { - await using var machines = new TwoMachines(); - await machines.ConnectAsync(); - - machines.AliceRouter.Engage(machines.BobId); - - machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 30, InputEventFlags.None)); - machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 30, InputEventFlags.Up)); - - await WaitForAsync(() => machines.Snapshot().Length >= 2, "both key transitions to arrive"); - - InputEvent[] received = machines.Snapshot(); - - Assert.Equal(InputEventKind.Key, received[0].Kind); - Assert.Equal(30, received[0].Code); - Assert.False(received[0].IsUp); - - // The release matters as much as the press. It is the half that, when lost, leaves a key - // held down on a machine the user cannot reach from where they are sitting. - Assert.True(received[1].IsUp); - } - - [Fact] - public async Task NothingCrossesTheLinkUntilTheMachineIsEngaged() - { - // The link exists from the moment both machines are members. Being connected must not be - // the same as being driven, or every peer would type into every other one. - await using var machines = new TwoMachines(); - await machines.ConnectAsync(); - - machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 30, InputEventFlags.None)); - await Task.Delay(250); - - Assert.Empty(machines.Snapshot()); - } - - [Fact] - public async Task DisengagingSendsTheReleaseBeforeControlComesBack() - { - await using var machines = new TwoMachines(); - await machines.ConnectAsync(); - - machines.AliceRouter.Engage(machines.BobId); - machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 42, InputEventFlags.None)); - await WaitForAsync(() => machines.Snapshot().Length >= 1, "the keypress to arrive"); - - machines.AliceRouter.Disengage(); - - await WaitForAsync( - () => machines.Snapshot().Any(e => e.Kind == InputEventKind.ReleaseAll), - "the release to reach the machine being left"); - - Assert.False(machines.AliceRouter.IsEngaged); - } - - [Fact] - public async Task MouseMotionMergesOnTheWayOutButTheClickStaysBehindIt() - { - // Proves the coalescer is genuinely in this path rather than merely compiled into it, and - // that the ordering rule survives the whole journey: a drag must land where it was aimed. - await using var machines = new TwoMachines(); - await machines.ConnectAsync(); - - machines.AliceRouter.Engage(machines.BobId); - - for (int i = 0; i < 50; i++) - { - machines.AliceRouter.Capture(InputEvent.MouseMove(Stopwatch.GetTimestamp(), 2, 1)); - } - - machines.AliceRouter.Capture(InputEvent.MouseButton(Stopwatch.GetTimestamp(), 0, up: false)); - - await WaitForAsync( - () => machines.Snapshot().Any(e => e.Kind == InputEventKind.MouseButton), - "the click to arrive"); - - InputEvent[] received = machines.Snapshot(); - InputEvent[] motion = [.. received.Where(e => e.Kind == InputEventKind.MouseMove)]; - - // Every pixel of movement is still there; it simply travelled in fewer events. - Assert.Equal(100, motion.Sum(e => e.X)); - Assert.Equal(50, motion.Sum(e => e.Y)); - - int click = Array.FindIndex(received, e => e.Kind == InputEventKind.MouseButton); - Assert.Equal(received.Length - 1, click); - } - - [Fact] - public async Task InputFlowsBothWaysAtOnceBecauseEitherPeerMayBeDriving() - { - // A can drive B while C drives A, so a link is never one-directional. Only loopback - // suppression makes that safe, and it only matters if both directions really work. - await using var machines = new TwoMachines(); - await machines.ConnectAsync(); - - var backToAlice = new List(); - machines.AliceMesh.InputReceived += (_, batch, _) => - { - lock (backToAlice) - { - foreach (InputEvent inputEvent in batch) - { - backToAlice.Add(inputEvent); - } - } - }; - - machines.AliceRouter.Engage(machines.BobId); - machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 30, InputEventFlags.None)); - - Assert.True(machines.BobMesh.TrySend( - machines.AliceId, InputEvent.Key(Stopwatch.GetTimestamp(), 31, InputEventFlags.None))); - - await WaitForAsync(() => machines.Snapshot().Length >= 1, "Alice's keystroke to reach Bob"); - - await WaitForAsync( - () => - { - lock (backToAlice) - { - return backToAlice.Count >= 1; - } - }, - "Bob's keystroke to reach Alice"); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/MouseCalibrationTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/MouseCalibrationTests.cs deleted file mode 100644 index 93490ef..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/MouseCalibrationTests.cs +++ /dev/null @@ -1,221 +0,0 @@ -using VirtualKmSwitch.Core; -using VirtualKmSwitch.InputEngine; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class MouseCalibrationTests -{ - private static readonly PointerBallistics Defaults = PointerBallistics.Default; - - private static (int X, int Y) Sum(Span events, int count) - { - int x = 0, y = 0; - for (int i = 0; i < count; i++) - { - x += events[i].X; - y += events[i].Y; - } - - return (x, y); - } - - [Fact] - public void SpeedSliderMidpointIsUnityAndTheCurveIsNotLinear() - { - Assert.Equal(1.0, Defaults.SpeedMultiplier, 5); - Assert.Equal(0.5, new PointerBallistics(6, 10, 1, 6).SpeedMultiplier, 5); - Assert.Equal(3.5, new PointerBallistics(6, 10, 1, 20).SpeedMultiplier, 5); - - // Slider position is not proportional to multiplier: 20 is 3.5x, not 2x of 10. - Assert.NotEqual(2.0, new PointerBallistics(6, 10, 1, 20).SpeedMultiplier, 5); - } - - [Fact] - public void NoChunkExceedsTheAccelerationThreshold() - { - // This is the whole point: a chunk above the threshold gets silently doubled by Windows. - var calibration = new MouseCalibration(Defaults); - Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - - int count = calibration.Prepare(1, 100, -73, output); - - Assert.True(count > 1, "a large delta must be split"); - for (int i = 0; i < count; i++) - { - Assert.True(Math.Abs(output[i].X) <= Defaults.Threshold1, $"chunk X {output[i].X} exceeds threshold"); - Assert.True(Math.Abs(output[i].Y) <= Defaults.Threshold1, $"chunk Y {output[i].Y} exceeds threshold"); - } - } - - [Fact] - public void ChunksSumToTheRequestedTotal() - { - var calibration = new MouseCalibration(Defaults); - Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - - foreach ((int dx, int dy) in new[] { (100, -73), (7, 0), (0, -1), (-45, 45), (1, 1) }) - { - calibration.Reset(); - int count = calibration.Prepare(1, dx, dy, output); - Assert.Equal((dx, dy), Sum(output, count)); - } - } - - [Fact] - public void SmallDeltasStayAsOneChunk() - { - var calibration = new MouseCalibration(Defaults); - Span output = stackalloc InputEvent[16]; - - // Under the threshold, no splitting is needed and none should happen. - Assert.Equal(1, calibration.Prepare(1, 3, 2, output)); - } - - [Fact] - public void AccelerationDisabledMeansNoChunkingAtAll() - { - // With acceleration off there is no doubling to avoid, so a big delta stays whole. - var calibration = new MouseCalibration(new PointerBallistics(6, 10, Acceleration: 0, Speed: 10)); - Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - - int count = calibration.Prepare(1, 500, 500, output); - - Assert.Equal(1, count); - Assert.Equal((500, 500), Sum(output, count)); - } - - [Fact] - public void FastPointerSpeedIsCompensatedByScalingDown() - { - // Speed 14 doubles motion, so half as much must be sent to travel the same distance. - var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, Speed: 14)); - Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - - int count = calibration.Prepare(1, 100, 0, output); - - Assert.Equal(50, Sum(output, count).X); - } - - [Fact] - public void SlowPointerSpeedIsCompensatedByScalingUp() - { - // Speed 6 halves motion, so twice as much must be sent. - var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, Speed: 6)); - Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - - int count = calibration.Prepare(1, 10, 0, output); - - Assert.Equal(20, Sum(output, count).X); - } - - [Fact] - public void SubUnitMovementAccumulatesRatherThanVanishing() - { - // At speed 20 a delta of 1 scales to 0.28, which truncates to zero. Without carrying the - // remainder the pointer would never move at all during slow, precise movement -- a far - // worse artefact than being a fraction of a pixel out. - var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, Speed: 20)); - Span output = stackalloc InputEvent[16]; - - int emitted = 0; - for (int i = 0; i < 20; i++) - { - int count = calibration.Prepare(i, 1, 0, output); - emitted += Sum(output, count).X; - } - - // 20 units at 1/3.5 scaling is ~5.7; the carry should have produced most of that. - Assert.InRange(emitted, 4, 6); - } - - [Fact] - public void ResetDiscardsCarry() - { - var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, Speed: 20)); - Span output = stackalloc InputEvent[16]; - - for (int i = 0; i < 3; i++) - { - calibration.Prepare(i, 1, 0, output); - } - - calibration.Reset(); - - // With the carry cleared, a single sub-unit delta must produce nothing. - Assert.Equal(0, calibration.Prepare(9, 1, 0, output)); - } - - [Fact] - public void ChunkCountIsBoundedForEnormousDeltas() - { - var calibration = new MouseCalibration(Defaults); - Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - - int count = calibration.Prepare(1, 100_000, 0, output); - - Assert.InRange(count, 1, MouseCalibration.MaxChunksPerEvent); - Assert.Equal(100_000, Sum(output, count).X); - } - - [Fact] - public void CorrectionCurveInterpolatesBetweenMeasuredSamples() - { - var curve = new MouseCorrectionCurve([10, 100], [1.5, 1.1]); - - Assert.Equal(1.5, curve.FactorFor(10), 4); - Assert.Equal(1.1, curve.FactorFor(100), 4); - Assert.Equal(1.3, curve.FactorFor(55), 2); - - // Held flat beyond the measured range rather than extrapolated into nonsense. - Assert.Equal(1.5, curve.FactorFor(1), 4); - Assert.Equal(1.1, curve.FactorFor(10_000), 4); - } - - [Fact] - public void CorrectionCurveIsAppliedByMagnitudeNotPerAxis() - { - // The acceleration curve responds to the overall movement, so a diagonal move of the same - // magnitude as a horizontal one must be corrected the same way. - var curve = new MouseCorrectionCurve([10, 100], [2.0, 1.0]); - var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, 10)) { Correction = curve }; - - Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - - int count = calibration.Prepare(1, 10, 0, output); - Assert.Equal(20, Sum(output, count).X); - - // 30/40 has magnitude 50, midway on the curve, so a factor of ~1.5. - calibration.Reset(); - count = calibration.Prepare(1, 30, 40, output); - (int x, int y) = Sum(output, count); - Assert.InRange(x, 43, 47); - Assert.InRange(y, 58, 62); - } - - [Fact] - public void IdentityCurveLeavesMotionUnchanged() - { - var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, 10)); - Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - - int count = calibration.Prepare(1, 137, -42, output); - Assert.Equal((137, -42), Sum(output, count)); - } - - [Fact] - public void PreparationAllocatesNothing() - { - var calibration = new MouseCalibration(Defaults); - Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent]; - calibration.Prepare(1, 10, 10, output); - - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 10_000; i++) - { - calibration.Prepare(i, 13, -7, output); - } - - Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/PairingWindowTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/PairingWindowTests.cs deleted file mode 100644 index e1064ad..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/PairingWindowTests.cs +++ /dev/null @@ -1,284 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Net.Security; -using System.Net.Sockets; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// The enrollment exchange over real TLS, which is the only place the join key is ever used and -/// the only moment an unenrolled machine is allowed to talk to this one. -/// -public class PairingWindowTests : IDisposable -{ - private const string Key = "ABCD-EFGH-JKLM-NPQR"; - - private readonly X509Certificate2 _accepterCertificate = - NodeCertificate.Create("CN=PairAccepter", StoreLocation.CurrentUser); - - private readonly X509Certificate2 _joinerCertificate = - NodeCertificate.Create("CN=PairJoiner", StoreLocation.CurrentUser); - - private readonly Guid _accepterId = Guid.NewGuid(); - private readonly Guid _joinerId = Guid.NewGuid(); - - private PairingWindow OpenWindow(TimeSpan? duration = null) => - new( - new ClusterIdentity - { - NodeId = _accepterId, - ClusterId = "0123456789ABCDEF", - ClusterName = "Home", - JoinKey = JoinKey.Normalize(Key), - DisplayName = "Desk", - }, - _accepterCertificate, - duration ?? TimeSpan.FromSeconds(30), - port: 0); - - private Task JoinAsync(int port, string key = Key) => - PairingWindow.JoinAsync( - "127.0.0.1", port, key, _joinerId, _joinerCertificate, "Laptop"); - - [Fact] - public async Task TwoMachinesHoldingTheSameKeyEnrollEachOther() - { - await using PairingWindow window = OpenWindow(); - - var accepted = new TaskCompletionSource(); - window.AttemptCompleted += outcome => accepted.TrySetResult(outcome); - - EnrollmentOutcome joinerSaw = await JoinAsync(window.Port); - EnrollmentOutcome accepterSaw = await accepted.Task.WaitAsync(TimeSpan.FromSeconds(15)); - - Assert.True(joinerSaw.Accepted, joinerSaw.Detail); - Assert.True(accepterSaw.Accepted, accepterSaw.Detail); - - // Each side comes away with the other's identity and the exact certificate it will pin. - // Everything after this point is authorised by that thumbprint alone. - Assert.Equal(_accepterId, joinerSaw.PeerNodeId); - Assert.Equal(NodeCertificate.ThumbprintOf(_accepterCertificate), joinerSaw.PeerThumbprint); - Assert.Equal("Desk", joinerSaw.PeerDisplayName); - Assert.Equal("Home", joinerSaw.ClusterName); - - Assert.Equal(_joinerId, accepterSaw.PeerNodeId); - Assert.Equal(NodeCertificate.ThumbprintOf(_joinerCertificate), accepterSaw.PeerThumbprint); - Assert.Equal("Laptop", accepterSaw.PeerDisplayName); - } - - [Fact] - public async Task AWrongKeyIsRefused() - { - await using PairingWindow window = OpenWindow(); - - EnrollmentOutcome outcome = await JoinAsync(window.Port, "ZZZZ-ZZZZ-ZZZZ-ZZZZ"); - - Assert.False(outcome.Accepted); - Assert.Equal(EnrollmentResult.WrongKey, outcome.Result); - } - - [Fact] - public async Task TranscriptionDifferencesInTheTypedKeyStillJoin() - { - // The key is read off one screen and typed into another, so the spacing and case it - // arrives in must not be the difference between joining and being told it is wrong. - await using PairingWindow window = OpenWindow(); - - EnrollmentOutcome outcome = await JoinAsync(window.Port, " abcd efgh JKLM-npqr "); - - Assert.True(outcome.Accepted, outcome.Detail); - } - - [Fact] - public async Task AnInterceptedConnectionIsRefusedEvenWithTheRightKey() - { - // The property the whole thumbprint-binding exists for. The relay knows neither private - // key; it only re-terminates TLS, which is exactly what an attacker on the same network - // can do. It can relay both proofs verbatim and they still do not verify, because each was - // computed over the certificates the honest peers actually presented. - await using PairingWindow window = OpenWindow(); - await using var relay = new InterceptingRelay(window.Port); - - EnrollmentOutcome outcome = await JoinAsync(relay.Port); - - Assert.False(outcome.Accepted); - - // And it is named for what it is. Reporting "wrong key" here would send the user to check - // the key while the interceptor waits for them to try again. - Assert.Equal(EnrollmentResult.CertificateMismatch, outcome.Result); - Assert.Contains("not direct", outcome.Detail); - } - - [Fact] - public async Task AClosedWindowAcceptsNothingAtAll() - { - int port; - - await using (PairingWindow window = OpenWindow()) - { - port = window.Port; - } - - // Not refused by a check inside the handshake -- there is nothing listening. The join key - // is unreachable rather than merely unused while the window is shut. - await Assert.ThrowsAnyAsync(() => JoinAsync(port)); - } - - [Fact] - public async Task TheWindowClosesItselfWhenItsTimeIsUp() - { - await using PairingWindow window = OpenWindow(TimeSpan.FromMilliseconds(300)); - - Assert.True(window.IsOpen); - await WaitForAsync(() => !window.IsOpen, "the window to close on its own"); - } - - [Fact] - public async Task ARunOfAttemptsClosesTheWindowRatherThanThrottlingIt() - { - await using PairingWindow window = OpenWindow(); - - // Eighty bits of key is not something a person fails at eleven times in a row. Being - // probed is the only explanation, and the answer to it is to stop being reachable. - for (int attempt = 0; attempt < 12 && window.IsOpen; attempt++) - { - try - { - using var probe = new TcpClient(); - await probe.ConnectAsync("127.0.0.1", window.Port); - } - catch (SocketException) - { - break; - } - - await Task.Delay(20); - } - - await WaitForAsync(() => !window.IsOpen, "the window to close after repeated attempts"); - } - - private static async Task WaitForAsync(Func condition, string what) - { - var deadline = Stopwatch.StartNew(); - while (deadline.Elapsed < TimeSpan.FromSeconds(15)) - { - if (condition()) - { - return; - } - - await Task.Delay(10); - } - - Assert.Fail("timed out waiting for " + what); - } - - /// - /// A machine-in-the-middle: terminates TLS on both sides with its own certificate and copies - /// the plaintext between them without altering it. - /// - private sealed class InterceptingRelay : IAsyncDisposable - { - private readonly X509Certificate2 _certificate = - NodeCertificate.Create("CN=Interceptor", StoreLocation.CurrentUser); - - private readonly TcpListener _listener; - private readonly CancellationTokenSource _stopping = new(); - private readonly Task _loop; - private readonly int _targetPort; - - public InterceptingRelay(int targetPort) - { - _targetPort = targetPort; - _listener = new TcpListener(IPAddress.Loopback, 0); - _listener.Start(); - Port = ((IPEndPoint)_listener.LocalEndpoint).Port; - _loop = Task.Run(() => RunAsync(_stopping.Token)); - } - - public int Port { get; } - - private async Task RunAsync(CancellationToken cancellationToken) - { - try - { - using TcpClient victim = await _listener.AcceptTcpClientAsync(cancellationToken); - using var upstream = new TcpClient(); - await upstream.ConnectAsync("127.0.0.1", _targetPort, cancellationToken); - - await using var toVictim = new SslStream( - victim.GetStream(), false, (_, _, _, _) => true); - - await using var toAccepter = new SslStream( - upstream.GetStream(), false, (_, _, _, _) => true); - - Task asServer = toVictim.AuthenticateAsServerAsync( - new SslServerAuthenticationOptions - { - ServerCertificate = _certificate, - ClientCertificateRequired = true, - EnabledSslProtocols = SslProtocols.Tls13, - CertificateRevocationCheckMode = X509RevocationMode.NoCheck, - }, - cancellationToken); - - Task asClient = toAccepter.AuthenticateAsClientAsync( - new SslClientAuthenticationOptions - { - TargetHost = "VirtualKmSwitch", - ClientCertificates = [_certificate], - EnabledSslProtocols = SslProtocols.Tls13, - CertificateRevocationCheckMode = X509RevocationMode.NoCheck, - }, - cancellationToken); - - await Task.WhenAll(asServer, asClient); - - await Task.WhenAny( - Pump(toVictim, toAccepter, cancellationToken), - Pump(toAccepter, toVictim, cancellationToken)); - } - catch (Exception) - { - // The victim aborting mid-exchange is the expected end of this, not a fault. - } - } - - private static async Task Pump(SslStream from, SslStream to, CancellationToken cancellationToken) - { - byte[] buffer = new byte[8192]; - - while (!cancellationToken.IsCancellationRequested) - { - int read = await from.ReadAsync(buffer, cancellationToken); - if (read == 0) - { - return; - } - - await to.WriteAsync(buffer.AsMemory(0, read), cancellationToken); - } - } - - public async ValueTask DisposeAsync() - { - await _stopping.CancelAsync(); - _listener.Stop(); - await Task.WhenAny(_loop, Task.Delay(TimeSpan.FromSeconds(2))); - _stopping.Dispose(); - _certificate.Dispose(); - } - } - - public void Dispose() - { - GC.SuppressFinalize(this); - _accepterCertificate.Dispose(); - _joinerCertificate.Dispose(); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/PeerConnectionTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/PeerConnectionTests.cs deleted file mode 100644 index 75d7c17..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/PeerConnectionTests.cs +++ /dev/null @@ -1,380 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using System.Security.Cryptography.X509Certificates; -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// Two peers over a real loopback socket, with real mutual TLS and real pinning. Only the network -/// is local; the handshake, the certificate validation and the framing are all genuine. -/// -public class PeerConnectionTests -{ - private sealed class Fixture : IAsyncDisposable - { - public X509Certificate2 AliceCertificate { get; } = - NodeCertificate.Create("CN=Alice", StoreLocation.CurrentUser); - - public X509Certificate2 BobCertificate { get; } = - NodeCertificate.Create("CN=Bob", StoreLocation.CurrentUser); - - public Guid AliceId { get; } = Guid.NewGuid(); - - public Guid BobId { get; } = Guid.NewGuid(); - - public PinStore AlicePins { get; } = new(); - - public PinStore BobPins { get; } = new(); - - /// Pins each side to the other, as a completed enrollment would. - public void PinEachOther() - { - AlicePins.Pin(NodeCertificate.ThumbprintOf(BobCertificate), BobId); - BobPins.Pin(NodeCertificate.ThumbprintOf(AliceCertificate), AliceId); - } - - public ValueTask DisposeAsync() - { - AliceCertificate.Dispose(); - BobCertificate.Dispose(); - return ValueTask.CompletedTask; - } - } - - /// Establishes a connected, mutually authenticated pair on an ephemeral port. - private static async Task<(PeerConnection Client, PeerConnection Server)> ConnectPairAsync(Fixture fixture) - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - - try - { - int port = ((IPEndPoint)listener.LocalEndpoint).Port; - - Task accepting = listener.AcceptTcpClientAsync(); - - Task clientTask = PeerConnection.ConnectAsync( - IPAddress.Loopback.ToString(), port, fixture.AliceId, fixture.AliceCertificate, fixture.AlicePins); - - TcpClient accepted = await accepting; - Task serverTask = PeerConnection.AcceptAsync( - accepted, fixture.BobId, fixture.BobCertificate, fixture.BobPins); - - return (await clientTask, await serverTask); - } - finally - { - listener.Stop(); - } - } - - [Fact] - public async Task MutuallyPinnedPeersConnectAndIdentifyEachOther() - { - await using var fixture = new Fixture(); - fixture.PinEachOther(); - - (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture); - - await using (client) - await using (server) - { - // Each side resolved the other's identity from its pinned certificate, not from - // anything the peer claimed about itself. - Assert.Equal(fixture.BobId, client.PeerId); - Assert.Equal(fixture.AliceId, server.PeerId); - } - } - - [Fact] - public async Task AnUnpinnedPeerIsRefused() - { - // The whole authorisation model: without a pin there is no CA to fall back on, so the - // handshake must fail rather than accept a valid-looking self-signed certificate. - await using var fixture = new Fixture(); - fixture.BobPins.Pin(NodeCertificate.ThumbprintOf(fixture.AliceCertificate), fixture.AliceId); - - // Alice does not pin Bob, so Alice must reject Bob's certificate. - await Assert.ThrowsAnyAsync(async () => - { - (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture); - await client.DisposeAsync(); - await server.DisposeAsync(); - }); - } - - [Fact] - public async Task APeerWithNoCertificatePinnedAtAllIsRefused() - { - await using var fixture = new Fixture(); - - await Assert.ThrowsAnyAsync(async () => - { - (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture); - await client.DisposeAsync(); - await server.DisposeAsync(); - }); - } - - [Fact] - public async Task InputEventsTravelAcrossTheEncryptedLink() - { - await using var fixture = new Fixture(); - fixture.PinEachOther(); - - (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture); - - await using (client) - await using (server) - { - var received = new List(); - var done = new TaskCompletionSource(); - using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - Task receiving = server.ReceiveLoopAsync( - (_, events, _) => - { - foreach (ref readonly InputEvent inputEvent in events) - { - received.Add(inputEvent); - } - - if (received.Count >= 3) - { - done.TrySetResult(); - } - }, - onControl: null, - cancellation.Token); - - InputEvent[] batch = - [ - InputEvent.MouseMove(100, 5, -5), - InputEvent.Key(100, 0x1E, InputEventFlags.None), - InputEvent.MouseButton(100, 0, up: true), - ]; - - // Measured as a delta: the connection has already exchanged a hello frame, which is - // what proves the peer accepted it. - long framesBefore = client.FramesSent; - - await client.SendInputAsync(100, batch); - await done.Task.WaitAsync(TimeSpan.FromSeconds(10)); - - Assert.Equal(3, received.Count); - Assert.Equal(InputEventKind.MouseMove, received[0].Kind); - Assert.Equal(5, received[0].X); - Assert.Equal(0x1E, received[1].Code); - Assert.True(received[2].IsUp); - - Assert.Equal(1, client.FramesSent - framesBefore); - Assert.Equal(3, client.EventsSent); - - await cancellation.CancelAsync(); - await Task.WhenAny(receiving, Task.Delay(TimeSpan.FromSeconds(2))); - } - } - - [Fact] - public async Task ManyBatchesArriveCompleteAndInOrder() - { - // Frames split and coalesce arbitrarily across TCP reads; ordering is what a key-up - // depends on, so this is the property that matters most. - await using var fixture = new Fixture(); - fixture.PinEachOther(); - - (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture); - - await using (client) - await using (server) - { - const int batches = 500; - const int perBatch = 8; - - var received = new List(batches * perBatch); - var done = new TaskCompletionSource(); - using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(20)); - - Task receiving = server.ReceiveLoopAsync( - (_, events, _) => - { - foreach (ref readonly InputEvent inputEvent in events) - { - received.Add(inputEvent.X); - } - - if (received.Count >= batches * perBatch) - { - done.TrySetResult(); - } - }, - onControl: null, - cancellation.Token); - - var buffer = new InputEvent[perBatch]; - int sequence = 0; - - for (int batch = 0; batch < batches; batch++) - { - for (int i = 0; i < perBatch; i++) - { - buffer[i] = InputEvent.MouseMove(batch, sequence++, 0); - } - - await client.SendInputAsync(batch, buffer); - } - - await done.Task.WaitAsync(TimeSpan.FromSeconds(20)); - - Assert.Equal(batches * perBatch, received.Count); - for (int i = 0; i < received.Count; i++) - { - Assert.Equal(i, received[i]); - } - - await cancellation.CancelAsync(); - await Task.WhenAny(receiving, Task.Delay(TimeSpan.FromSeconds(2))); - } - } - - [Fact] - public async Task ControlPayloadsTravelAlongsideInput() - { - await using var fixture = new Fixture(); - fixture.PinEachOther(); - - (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture); - - await using (client) - await using (server) - { - byte[]? seen = null; - var done = new TaskCompletionSource(); - using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - Task receiving = server.ReceiveLoopAsync( - onInput: null, - onControl: (_, payload) => - { - seen = payload.ToArray(); - done.TrySetResult(); - }, - cancellation.Token); - - byte[] message = "hello"u8.ToArray(); - await client.SendControlAsync(message); - await done.Task.WaitAsync(TimeSpan.FromSeconds(10)); - - Assert.Equal(message, seen); - - await cancellation.CancelAsync(); - await Task.WhenAny(receiving, Task.Delay(TimeSpan.FromSeconds(2))); - } - } - - [Fact] - public async Task LatencyIsMeasuredOnReceive() - { - await using var fixture = new Fixture(); - fixture.PinEachOther(); - - (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture); - - await using (client) - await using (server) - { - var done = new TaskCompletionSource(); - using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - Task receiving = server.ReceiveLoopAsync( - (_, _, _) => done.TrySetResult(), onControl: null, cancellation.Token); - - // Microseconds, which is what the wire carries. Handing it Stopwatch ticks is exactly - // the mistake the shared clock exists to prevent, and it reads as a plausible number. - await client.SendInputAsync( - MonotonicClock.Microseconds, new[] { InputEvent.MouseMove(0, 1, 1) }); - - await done.Task.WaitAsync(TimeSpan.FromSeconds(10)); - - Assert.Equal(1, server.Latency.Count); - Assert.True(server.Latency.MaxMicroseconds >= 0); - - await cancellation.CancelAsync(); - await Task.WhenAny(receiving, Task.Delay(TimeSpan.FromSeconds(2))); - } - } -} - -public class LatencyTrackerTests -{ - [Fact] - public void PercentilesReflectTheDistribution() - { - var tracker = new LatencyTracker(); - - // 99 fast samples and one very slow one: the mean would hide the outlier entirely, which - // is exactly why the budget is stated as a p99. - for (int i = 0; i < 99; i++) - { - tracker.Record(100); - } - - tracker.Record(50_000); - - Assert.Equal(100, tracker.Count); - Assert.True(tracker.Percentile(0.50) <= 200); - Assert.True(tracker.Percentile(0.99) <= 200); - Assert.True(tracker.Percentile(1.0) >= 50_000); - Assert.Equal(50_000, tracker.MaxMicroseconds); - } - - [Fact] - public void NegativeSamplesAreDiscarded() - { - // A negative measurement means the clock offset estimate is stale; counting it would drag - // the percentiles below anything achievable. - var tracker = new LatencyTracker(); - tracker.Record(-500); - - Assert.Equal(0, tracker.Count); - } - - [Fact] - public void ResolutionIsFinestWhereTheBudgetLives() - { - var tracker = new LatencyTracker(); - tracker.Record(45); - - // Sub-millisecond samples are bucketed to 10 microseconds. - Assert.InRange(tracker.Percentile(0.5), 45, 55); - } - - [Fact] - public void RecordingAllocatesNothing() - { - var tracker = new LatencyTracker(); - tracker.Record(100); - - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 10_000; i++) - { - tracker.Record(i % 5_000); - } - - Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); - } - - [Fact] - public void EmptyTrackerReportsZeroRatherThanFailing() - { - var tracker = new LatencyTracker(); - - Assert.Equal(0, tracker.Count); - Assert.Equal(0, tracker.Percentile(0.99)); - Assert.Equal(0, tracker.MeanMicroseconds); - Assert.Equal("no samples", tracker.Summary()); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/PeerMeshTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/PeerMeshTests.cs deleted file mode 100644 index 94d3559..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/PeerMeshTests.cs +++ /dev/null @@ -1,305 +0,0 @@ -using System.Diagnostics; -using System.Security.Cryptography.X509Certificates; -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// Two complete meshes on loopback: real listeners, real dialling, real mutual TLS, real pinning. -/// -public class PeerMeshTests -{ - /// Ephemeral ports per test so parallel runs cannot collide. - private static int NextPort() => Random.Shared.Next(45_000, 60_000); - - private sealed class TwoNodes : IAsyncDisposable - { - public TwoNodes() - { - AliceCertificate = NodeCertificate.Create("CN=MeshAlice", StoreLocation.CurrentUser); - BobCertificate = NodeCertificate.Create("CN=MeshBob", StoreLocation.CurrentUser); - - // Ordered so Alice is always the dialler, matching the lower-id-dials rule. - Guid first = Guid.NewGuid(); - Guid second = Guid.NewGuid(); - (AliceId, BobId) = first.CompareTo(second) < 0 ? (first, second) : (second, first); - - AlicePins.Pin(NodeCertificate.ThumbprintOf(BobCertificate), BobId); - BobPins.Pin(NodeCertificate.ThumbprintOf(AliceCertificate), AliceId); - - AlicePort = NextPort(); - BobPort = AlicePort + 1; - - Alice = new PeerMesh(AliceId, AliceCertificate, AlicePins, AlicePort); - Bob = new PeerMesh(BobId, BobCertificate, BobPins, BobPort); - } - - public X509Certificate2 AliceCertificate { get; } - - public X509Certificate2 BobCertificate { get; } - - public Guid AliceId { get; } - - public Guid BobId { get; } - - public PinStore AlicePins { get; } = new(); - - public PinStore BobPins { get; } = new(); - - public int AlicePort { get; } - - public int BobPort { get; } - - public PeerMesh Alice { get; } - - public PeerMesh Bob { get; } - - public async Task ConnectAsync() - { - Alice.Start(); - Bob.Start(); - - return await Alice.EnsureConnectedAsync(BobId, "127.0.0.1", BobPort); - } - - public async ValueTask DisposeAsync() - { - await Alice.DisposeAsync(); - await Bob.DisposeAsync(); - AliceCertificate.Dispose(); - BobCertificate.Dispose(); - } - } - - private static async Task WaitForAsync(Func condition, TimeSpan timeout) - { - var deadline = Stopwatch.StartNew(); - while (deadline.Elapsed < timeout) - { - if (condition()) - { - return; - } - - await Task.Delay(20); - } - } - - [Fact] - public void DiallingIsDecidedByNodeOrderSoBothSidesAgree() - { - // Both peers see each other simultaneously. Without a total order they would dial at once - // and build two links where one is wanted; each side evaluates this identically. - var low = new Guid("00000000-0000-0000-0000-000000000001"); - var high = new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"); - - using X509Certificate2 certificate = NodeCertificate.Create("CN=Order", StoreLocation.CurrentUser); - - var lowMesh = new PeerMesh(low, certificate, new PinStore()); - var highMesh = new PeerMesh(high, certificate, new PinStore()); - - Assert.True(lowMesh.ShouldDial(high)); - Assert.False(highMesh.ShouldDial(low)); - } - - [Fact] - public async Task PeersConnectAndBothSidesSeeTheLink() - { - await using var nodes = new TwoNodes(); - - Assert.True(await nodes.ConnectAsync()); - await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10)); - - Assert.Contains(nodes.BobId, nodes.Alice.ConnectedPeers); - Assert.Contains(nodes.AliceId, nodes.Bob.ConnectedPeers); - } - - [Fact] - public async Task InputFlowsBetweenMeshes() - { - await using var nodes = new TwoNodes(); - Assert.True(await nodes.ConnectAsync()); - - var received = new List(); - var done = new TaskCompletionSource(); - - nodes.Bob.InputReceived += (_, events, _) => - { - foreach (ref readonly InputEvent inputEvent in events) - { - received.Add(inputEvent); - } - - done.TrySetResult(); - }; - - await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10)); - - await nodes.Alice.SendInputAsync( - nodes.BobId, 42, new[] { InputEvent.Key(42, 0x1E, InputEventFlags.None) }); - - await done.Task.WaitAsync(TimeSpan.FromSeconds(10)); - - Assert.Single(received); - Assert.Equal(0x1E, received[0].Code); - } - - [Fact] - public async Task SendingToAnUnknownPeerIsIgnoredRatherThanThrowing() - { - // Peers disappear constantly — sleep, reboot, network change. A send racing a disconnect - // is normal traffic, not an error worth propagating into the input path. - await using var nodes = new TwoNodes(); - nodes.Alice.Start(); - - await nodes.Alice.SendInputAsync(Guid.NewGuid(), 1, new[] { InputEvent.MouseMove(1, 1, 1) }); - } - - [Fact] - public async Task AnUnpinnedPeerCannotJoinTheMesh() - { - await using var nodes = new TwoNodes(); - - // Bob forgets Alice, as removing her from the mesh would do. - nodes.BobPins.Clear(); - - nodes.Alice.Start(); - nodes.Bob.Start(); - - Assert.False(await nodes.Alice.EnsureConnectedAsync(nodes.BobId, "127.0.0.1", nodes.BobPort)); - Assert.Empty(nodes.Alice.ConnectedPeers); - } - - [Fact] - public async Task AnEightKilohertzStreamStaysInsideTheLatencyBudgetAndKeepsItsOrder() - { - // The plan budgets p99 added latency at 2 ms and calls for a synthetic 8 kHz load through - // the full pipeline. On loopback the wire costs nothing, so what is measured is the queue, - // the coalescer, the codec and TLS -- and it should sit far below the budget, leaving the - // rest of it for a real network. - // - // Pacing matters as much as the count. An unpaced loop produces events faster than any - // mouse in existence, the queue backs up, and the resulting figure measures the test - // harness rather than the transport. Measured that way the same code reports 6 ms. - await using var nodes = new TwoNodes(); - Assert.True(await nodes.ConnectAsync()); - await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10)); - - const int events = 2_000; - const long periodMicroseconds = 125; // 8 kHz - - var received = new List(events); - var done = new TaskCompletionSource(); - - // Measured a second way, straight from the batch timestamp, with no clock offset applied. - // On loopback both ends read the same clock so the two must agree, and holding them to - // that caught a real bug: a cold first heartbeat biased every reported figure by 2.3 ms - // while this direct measurement stayed at 98 us. - var direct = new List(events); - - nodes.Bob.InputReceived += (_, batch, capture) => - { - direct.Add(MonotonicClock.Microseconds - capture); - - foreach (InputEvent inputEvent in batch) - { - received.Add(inputEvent.Code); - } - - if (received.Count >= events) - { - done.TrySetResult(); - } - }; - - // Key events, not motion: motion is deliberately allowed to merge, so a test that counted - // it would be asserting on how the scheduler happened to interleave. - long next = MonotonicClock.Microseconds; - for (int i = 0; i < events; i++) - { - while (MonotonicClock.Microseconds < next) - { - Thread.SpinWait(10); - } - - next += periodMicroseconds; - Assert.True(nodes.Alice.TrySend( - nodes.BobId, InputEvent.Key(Stopwatch.GetTimestamp(), (ushort)i, InputEventFlags.None))); - } - - await done.Task.WaitAsync(TimeSpan.FromSeconds(30)); - - PeerConnection connection = nodes.Bob.Connection(nodes.AliceId)!; - PeerSendPump pump = nodes.Alice.Pump(nodes.BobId)!; - long p99 = connection.Latency.Percentile(0.99); - - Assert.Equal(events, received.Count); - - // Nothing reordered. A key-up that overtook its key-down would leave the key held, which - // is the failure the whole ordered-stream design exists to prevent. - Assert.Equal(Enumerable.Range(0, events).Select(i => (ushort)i), received); - - Assert.Equal(0, pump.MovesDropped); - Assert.True( - p99 < 2_000, - $"p99 was {p99}us, over the 2ms budget. Summary: {connection.Latency.Summary()}"); - - // The two measurements must not disagree by more than the histogram's own bucket width - // plus the work between them. A gap far larger than that means the clock estimate is - // biased, and the reported figures are then confidently wrong rather than merely noisy. - long directMedian = direct.Order().ElementAt(direct.Count / 2); - Assert.InRange(connection.Latency.Percentile(0.50), 0, directMedian + 500); - - Assert.True( - connection.Clock.HasSample, - "the link reported latency without ever having synchronised its clock"); - } - - [Fact] - public async Task ClockSynchronisationConvergesWithoutBeingAskedTo() - { - await using var nodes = new TwoNodes(); - Assert.True(await nodes.ConnectAsync()); - await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10)); - - PeerConnection fromAlice = nodes.Bob.Connection(nodes.AliceId)!; - PeerConnection fromBob = nodes.Alice.Connection(nodes.BobId)!; - - await WaitForAsync( - () => fromAlice.Clock.HasSample && fromBob.Clock.HasSample, TimeSpan.FromSeconds(10)); - - // Both ends of a loopback link read the same clock, so the true offset is zero. Anything - // else here would be the estimator inventing a difference, and on two real machines that - // invention is indistinguishable from latency. - Assert.InRange(fromAlice.Clock.OffsetMicroseconds, -2_000, 2_000); - Assert.InRange(fromBob.Clock.OffsetMicroseconds, -2_000, 2_000); - - // A round trip on loopback is fast, but the figure only has to be plausible: it is a - // quality signal for choosing between samples, not a measurement anything reports. - Assert.InRange(fromAlice.Clock.RoundTripMicroseconds, 0, 100_000); - } - - [Fact] - public async Task DisconnectionIsReportedToBothSides() - { - await using var nodes = new TwoNodes(); - Assert.True(await nodes.ConnectAsync()); - await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10)); - - var bobSawDisconnect = new TaskCompletionSource(); - nodes.Bob.PeerConnectionChanged += (_, connected) => - { - if (!connected) - { - bobSawDisconnect.TrySetResult(); - } - }; - - await nodes.Alice.DisposeAsync(); - await bobSawDisconnect.Task.WaitAsync(TimeSpan.FromSeconds(10)); - - Assert.Empty(nodes.Bob.ConnectedPeers); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/PeerSendPumpTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/PeerSendPumpTests.cs deleted file mode 100644 index e6ed4ef..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/PeerSendPumpTests.cs +++ /dev/null @@ -1,274 +0,0 @@ -using System.Diagnostics; -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Transport; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// The outbound queue, its batching, and the one policy question that matters: what is allowed to -/// be lost when the link cannot keep up. -/// -public class PeerSendPumpTests -{ - /// A sink that records what it was handed, and can be made slow on demand. - private sealed class RecordingSink : IInputSink - { - private readonly Lock _gate = new(); - private readonly List _batches = []; - - /// Held closed to let the queue build up behind a send in progress. - public ManualResetEventSlim Release { get; } = new(initialState: true); - - public bool Fail { get; set; } - - public int BatchCount - { - get - { - lock (_gate) - { - return _batches.Count; - } - } - } - - public IReadOnlyList Batches - { - get - { - lock (_gate) - { - return _batches.ToArray(); - } - } - } - - public InputEvent[] AllEvents - { - get - { - lock (_gate) - { - return _batches.SelectMany(batch => batch).ToArray(); - } - } - } - - public bool TrySendInput(long captureMicroseconds, ReadOnlySpan events) - { - Release.Wait(); - - if (Fail) - { - return false; - } - - InputEvent[] copy = events.ToArray(); - lock (_gate) - { - _batches.Add(copy); - } - - return true; - } - } - - private static async Task WaitForAsync(Func condition, string what) - { - var deadline = Stopwatch.StartNew(); - while (deadline.Elapsed < TimeSpan.FromSeconds(10)) - { - if (condition()) - { - return; - } - - await Task.Delay(5); - } - - Assert.Fail("timed out waiting for " + what); - } - - [Fact] - public async Task AtIdleAnEventGoesOutOnItsOwnRatherThanWaitingForCompany() - { - // The property that separates coalescing from buffering. A pump that waited for a second - // event before sending would post better throughput numbers and feel worse to use. - var sink = new RecordingSink(); - using var pump = new PeerSendPump(sink, "idle"); - pump.Start(); - - Assert.True(pump.TryEnqueue(InputEvent.Key(1, 30, InputEventFlags.None))); - - await WaitForAsync(() => sink.BatchCount == 1, "the lone event to be sent"); - Assert.Single(sink.Batches[0]); - } - - [Fact] - public async Task BatchesEmergeFromTheDrainWhenTheLinkIsBusy() - { - var sink = new RecordingSink(); - using var pump = new PeerSendPump(sink, "busy"); - - // Hold the sink shut so the pump blocks inside its first send while events pile up. This - // is what a slow write looks like from the queue's side. - sink.Release.Reset(); - pump.Start(); - - pump.TryEnqueue(InputEvent.Key(1, 1, InputEventFlags.None)); - await Task.Delay(30); - - for (int i = 0; i < 200; i++) - { - Assert.True(pump.TryEnqueue(InputEvent.Key(i + 2, (ushort)(i % 60), InputEventFlags.None))); - } - - sink.Release.Set(); - - await WaitForAsync(() => sink.AllEvents.Length == 201, "every event to arrive"); - - // Nothing was dropped, and the events that queued during the stall left in far fewer - // frames than they arrived in -- one syscall and one TLS record instead of 200. - Assert.Equal(201, sink.AllEvents.Length); - Assert.True(pump.LargestBatch > 1, $"largest batch was {pump.LargestBatch}; no batching happened"); - Assert.True( - sink.BatchCount < 201, - $"{sink.BatchCount} frames for 201 events means each one went out alone"); - } - - [Fact] - public async Task MotionMergesButAClickNeverCrossesIt() - { - var sink = new RecordingSink(); - using var pump = new PeerSendPump(sink, "merge"); - - sink.Release.Reset(); - pump.Start(); - - // Prime the pump so it is blocked in a send, then queue a gesture behind it. - pump.TryEnqueue(InputEvent.Key(1, 1, InputEventFlags.None)); - await Task.Delay(30); - - pump.TryEnqueue(InputEvent.MouseMove(10, 3, 0)); - pump.TryEnqueue(InputEvent.MouseMove(11, 4, 0)); - pump.TryEnqueue(InputEvent.MouseButton(12, 0, up: false)); - pump.TryEnqueue(InputEvent.MouseMove(13, 5, 0)); - pump.TryEnqueue(InputEvent.MouseButton(14, 0, up: true)); - - sink.Release.Set(); - - await WaitForAsync( - () => sink.AllEvents.Count(e => e.Kind == InputEventKind.MouseButton) == 2, - "both button transitions"); - - InputEvent[] gesture = sink.AllEvents.Where(e => e.Kind != InputEventKind.Key).ToArray(); - - // Motion before the press is one event carrying the sum, and the press still lands after - // it. A drag that reordered here would click somewhere the user never pointed. - Assert.Equal(InputEventKind.MouseMove, gesture[0].Kind); - Assert.Equal(7, gesture[0].X); - Assert.Equal(InputEventKind.MouseButton, gesture[1].Kind); - Assert.False(gesture[1].IsUp); - Assert.Equal(InputEventKind.MouseMove, gesture[2].Kind); - Assert.Equal(5, gesture[2].X); - Assert.Equal(InputEventKind.MouseButton, gesture[3].Kind); - Assert.True(gesture[3].IsUp); - } - - [Fact] - public void AFullQueueDropsMotionAndSaysItSucceeded() - { - // The pump is never started, so nothing drains and the queue fills. - var sink = new RecordingSink(); - using var pump = new PeerSendPump(sink, "full-motion", capacity: 8); - - for (int i = 0; i < 8; i++) - { - Assert.True(pump.TryEnqueue(InputEvent.MouseMove(i, 1, 0))); - } - - // Accepted, because the motion after it supersedes it: the cursor still ends up in the - // right place, and a hitch in movement is a far cheaper failure than a stuck modifier. - Assert.True(pump.TryEnqueue(InputEvent.MouseMove(9, 1, 0))); - Assert.Equal(1, pump.MovesDropped); - } - - [Fact] - public void AFullQueueRefusesAKeyEventAndReportsTheStall() - { - var sink = new RecordingSink(); - using var pump = new PeerSendPump(sink, "full-keys", capacity: 8); - - SendPumpStall? stall = null; - pump.Stalled += reason => stall = reason; - - for (int i = 0; i < 8; i++) - { - Assert.True(pump.TryEnqueue(InputEvent.Key(i, (ushort)i, InputEventFlags.None))); - } - - // Refused rather than dropped. Losing one of these is a coin flip between a key that never - // arrives and a key-up that never arrives, and the second leaves a modifier held down on a - // machine the user may already have stopped driving. - Assert.False(pump.TryEnqueue(InputEvent.Key(9, 9, InputEventFlags.None))); - Assert.Equal(0, pump.MovesDropped); - Assert.Equal(SendPumpStall.QueueFull, stall); - } - - [Fact] - public void AReleaseAllIsNeverTreatedAsDroppable() - { - var sink = new RecordingSink(); - using var pump = new PeerSendPump(sink, "release-all", capacity: 8); - - for (int i = 0; i < 8; i++) - { - pump.TryEnqueue(InputEvent.MouseMove(i, 1, 0)); - } - - // The event whose entire purpose is to clean up after the others. - Assert.False(pump.TryEnqueue(InputEvent.ReleaseAll(9))); - Assert.Equal(0, pump.MovesDropped); - } - - [Fact] - public async Task ALinkThatDiesIsReportedRatherThanRetriedForever() - { - var sink = new RecordingSink { Fail = true }; - using var pump = new PeerSendPump(sink, "dead"); - - SendPumpStall? stall = null; - pump.Stalled += reason => stall = reason; - pump.Start(); - - pump.TryEnqueue(InputEvent.Key(1, 1, InputEventFlags.None)); - - await WaitForAsync(() => stall is not null, "the link loss to be reported"); - Assert.Equal(SendPumpStall.LinkLost, stall); - } - - [Fact] - public void QueueingAnEventAllocatesNothing() - { - // The assertion that keeps the hot path honest as the code grows. A single allocation here - // is a gen0 collection every few seconds of typing, and a gen0 collection near the hook - // thread is how Windows silently unhooks us. - var sink = new RecordingSink(); - using var pump = new PeerSendPump(sink, "alloc", capacity: 1024); - - var warmup = InputEvent.MouseMove(1, 1, 1); - pump.TryEnqueue(in warmup); - - long before = GC.GetAllocatedBytesForCurrentThread(); - - for (int i = 0; i < 1000; i++) - { - var moved = InputEvent.MouseMove(i, i, i); - pump.TryEnqueue(in moved); - } - - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.Equal(0, allocated); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/RosterPinProjectionTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/RosterPinProjectionTests.cs deleted file mode 100644 index 2af9943..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/RosterPinProjectionTests.cs +++ /dev/null @@ -1,180 +0,0 @@ -using VirtualKmSwitch.Cluster; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// Trust is a projection of the roster, and these are the properties that makes it worth having. -/// -/// The pins decide who may open a connection and inject keystrokes into this machine, so the -/// question is never only "does the right peer connect" but "does the wrong one stop connecting, -/// and stay stopped across a restart". -/// -public class RosterPinProjectionTests : IDisposable -{ - private const string LocalThumbprint = "1111111111111111111111111111111111111111111111111111111111111111"; - - private readonly string _directory = - Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N")); - - private ClusterMembership Create() => - new(new ClusterStore(Path.Combine(_directory, "roster.json")), - new LocalSettingsStore(Path.Combine(_directory, "local.json"))); - - private ClusterMembership CreateWithCluster() - { - ClusterMembership membership = Create(); - membership.CreateCluster("Home", "0123456789ABCDEF", "AAAA-BBBB-CCCC-DDDD", LocalThumbprint, "Desk"); - return membership; - } - - /// Adds a peer the way a completed enrollment eventually will. - private static Guid EnrollPeer(ClusterMembership membership, string thumbprint, string name = "Laptop") - { - var peerId = Guid.NewGuid(); - var theirRoster = new Roster(peerId); - theirRoster.Enroll(peerId, thumbprint, name); - membership.Merge(theirRoster.Snapshot()); - return peerId; - } - - [Fact] - public void EnrollingAPeerTrustsItsCertificate() - { - ClusterMembership membership = CreateWithCluster(); - const string peerThumbprint = "2222222222222222222222222222222222222222222222222222222222222222"; - - Guid peerId = EnrollPeer(membership, peerThumbprint); - - Assert.True(membership.Pins.TryResolve(peerThumbprint, out Guid resolved)); - Assert.Equal(peerId, resolved); - } - - [Fact] - public void ThisMachinesOwnCertificateIsNotTrusted() - { - // Nothing dials itself, so pinning our own certificate would only mean that anything - // holding a copy of our private key could present it back to us and be believed. - ClusterMembership membership = CreateWithCluster(); - - Assert.False(membership.Pins.IsTrusted(LocalThumbprint)); - } - - [Fact] - public void AnUnknownCertificateIsNeverTrusted() - { - ClusterMembership membership = CreateWithCluster(); - EnrollPeer(membership, "2222222222222222222222222222222222222222222222222222222222222222"); - - Assert.False(membership.Pins.IsTrusted( - "3333333333333333333333333333333333333333333333333333333333333333")); - } - - [Fact] - public void RemovingAPeerRevokesItsCertificate() - { - ClusterMembership membership = CreateWithCluster(); - const string peerThumbprint = "2222222222222222222222222222222222222222222222222222222222222222"; - Guid peerId = EnrollPeer(membership, peerThumbprint); - - Assert.True(membership.RemoveMachine(peerId)); - - // The point of the whole arrangement. A removal that left the pin behind would take a - // machine off the list while it carried on being allowed to type into this one. - Assert.False(membership.Pins.IsTrusted(peerThumbprint)); - } - - [Fact] - public void LeavingTheClusterTrustsNobody() - { - ClusterMembership membership = CreateWithCluster(); - EnrollPeer(membership, "2222222222222222222222222222222222222222222222222222222222222222"); - - membership.LeaveCluster(); - - Assert.Equal(0, membership.Pins.Count); - } - - [Fact] - public void TrustSurvivesARestartWithoutAFileOfItsOwn() - { - const string peerThumbprint = "2222222222222222222222222222222222222222222222222222222222222222"; - - ClusterMembership first = CreateWithCluster(); - Guid peerId = EnrollPeer(first, peerThumbprint); - - // A new instance over the same directory: what the service does after a reboot. The pins - // are rebuilt from the roster that was already being written atomically, so there is no - // second file to have been left half-written. - ClusterMembership restarted = Create(); - - Assert.True(restarted.Pins.TryResolve(peerThumbprint, out Guid resolved)); - Assert.Equal(peerId, resolved); - } - - [Fact] - public void ARemovalStaysRemovedAcrossARestart() - { - const string peerThumbprint = "2222222222222222222222222222222222222222222222222222222222222222"; - - ClusterMembership first = CreateWithCluster(); - Guid peerId = EnrollPeer(first, peerThumbprint); - first.RemoveMachine(peerId); - - // The tombstone is what carries this, so the revocation is not something a restart can - // quietly undo. - Assert.False(Create().Pins.IsTrusted(peerThumbprint)); - } - - [Fact] - public void APeerThatReEnrolsWithANewCertificateLosesTheOldOne() - { - ClusterMembership membership = CreateWithCluster(); - - const string oldThumbprint = "2222222222222222222222222222222222222222222222222222222222222222"; - const string newThumbprint = "4444444444444444444444444444444444444444444444444444444444444444"; - - var peerId = Guid.NewGuid(); - var theirRoster = new Roster(peerId); - theirRoster.Enroll(peerId, oldThumbprint, "Laptop"); - membership.Merge(theirRoster.Snapshot()); - Assert.True(membership.Pins.IsTrusted(oldThumbprint)); - - // The peer reinstalled and generated a fresh key. Rotating a compromised certificate has - // to actually retire the old one, or the rotation bought nothing. - theirRoster.Enroll(peerId, newThumbprint, "Laptop"); - membership.Merge(theirRoster.Snapshot()); - - Assert.True(membership.Pins.IsTrusted(newThumbprint)); - Assert.False(membership.Pins.IsTrusted(oldThumbprint)); - } - - [Fact] - public void ImportingReplacesTheSetWithoutEverLeavingALivePinAbsent() - { - // Import is called on every roster change while handshakes may be in flight. Clearing - // first would open a window where a peer that is trusted before and after is refused in - // between, which reads as a flaky network and never reproduces on demand. - ClusterMembership membership = CreateWithCluster(); - const string kept = "2222222222222222222222222222222222222222222222222222222222222222"; - - EnrollPeer(membership, kept, "Kept"); - Guid dropped = EnrollPeer( - membership, "5555555555555555555555555555555555555555555555555555555555555555", "Dropped"); - - membership.RemoveMachine(dropped); - - Assert.True(membership.Pins.IsTrusted(kept)); - Assert.Equal(1, membership.Pins.Count); - } - - public void Dispose() - { - GC.SuppressFinalize(this); - - if (Directory.Exists(_directory)) - { - Directory.Delete(_directory, recursive: true); - } - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/RosterTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/RosterTests.cs deleted file mode 100644 index 86b76e1..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/RosterTests.cs +++ /dev/null @@ -1,286 +0,0 @@ -using VirtualKmSwitch.Cluster; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class RosterTests -{ - private static readonly Guid NodeA = new("11111111-1111-1111-1111-111111111111"); - private static readonly Guid NodeB = new("22222222-2222-2222-2222-222222222222"); - private static readonly Guid NodeC = new("33333333-3333-3333-3333-333333333333"); - - private static string Fingerprint(Guid nodeId) => nodeId.ToString("N")[..16].ToUpperInvariant(); - - [Fact] - public void EnrollingAddsAMember() - { - var roster = new Roster(NodeA); - roster.Enroll(NodeB, Fingerprint(NodeB), "Bob"); - - Assert.True(roster.IsEnrolled(NodeB)); - Assert.Equal(1, roster.EnrolledCount); - Assert.Equal("Bob", roster.Find(NodeB)!.DisplayName); - } - - [Fact] - public void RemovingLeavesATombstoneRatherThanDeleting() - { - // The tombstone is what makes the removal survive a merge with a peer that never saw it. - var roster = new Roster(NodeA); - roster.Enroll(NodeB, Fingerprint(NodeB)); - roster.Remove(NodeB); - - Assert.False(roster.IsEnrolled(NodeB)); - Assert.Equal(0, roster.EnrolledCount); - Assert.Single(roster.AllEntries); - Assert.Equal(MembershipState.Removed, roster.Find(NodeB)!.State); - } - - [Fact] - public void APeerWithAStaleCopyCannotResurrectARemovedMachine() - { - // The scenario the tombstone exists for: a machine powered off during a removal, merging - // its older view back in when it returns. - var authoritative = new Roster(NodeA); - authoritative.Enroll(NodeB, Fingerprint(NodeB)); - - var stale = new Roster(NodeC); - stale.Merge(authoritative.Snapshot()); - - authoritative.Remove(NodeB); - authoritative.Merge(stale.Snapshot()); - - Assert.False(authoritative.IsEnrolled(NodeB)); - - // And the stale peer learns of the removal when it merges the other way. - stale.Merge(authoritative.Snapshot()); - Assert.False(stale.IsEnrolled(NodeB)); - } - - [Fact] - public void ReEnrollingAfterRemovalWins() - { - // Deliberately re-adding a machine has to beat the tombstone, or a removal would be - // permanent and the machine could never rejoin. - var roster = new Roster(NodeA); - roster.Enroll(NodeB, Fingerprint(NodeB)); - roster.Remove(NodeB); - roster.Enroll(NodeB, "NEWTHUMBPRINT"); - - Assert.True(roster.IsEnrolled(NodeB)); - Assert.Equal("NEWTHUMBPRINT", roster.Find(NodeB)!.Thumbprint); - } - - [Fact] - public void MergingIsIdempotent() - { - var left = new Roster(NodeA); - left.Enroll(NodeB, Fingerprint(NodeB), "Bob"); - - var right = new Roster(NodeC); - right.Merge(left.Snapshot()); - - RosterSnapshot before = right.Snapshot(); - right.Merge(left.Snapshot()); - right.Merge(left.Snapshot()); - - Assert.Equal(before.Entries.Count, right.Snapshot().Entries.Count); - Assert.True(right.IsEnrolled(NodeB)); - } - - [Fact] - public void MergingIsCommutative() - { - var alice = new Roster(NodeA); - alice.Enroll(NodeB, Fingerprint(NodeB), "Bob"); - - var carol = new Roster(NodeC); - carol.Enroll(NodeC, Fingerprint(NodeC), "Carol"); - - var aliceThenCarol = new Roster(Guid.NewGuid()); - aliceThenCarol.Merge(alice.Snapshot()); - aliceThenCarol.Merge(carol.Snapshot()); - - var carolThenAlice = new Roster(Guid.NewGuid()); - carolThenAlice.Merge(carol.Snapshot()); - carolThenAlice.Merge(alice.Snapshot()); - - AssertSameMembership(aliceThenCarol, carolThenAlice); - } - - [Fact] - public void ConcurrentEnrolAndRemoveResolveTheSameWayOnBothSides() - { - // A partition: one side removes a machine while the other re-enrolls it. Both must land on - // the same answer, whichever order they exchange rosters in. - var shared = new Roster(NodeA); - shared.Enroll(NodeB, Fingerprint(NodeB)); - - var remover = new Roster(NodeA); - remover.Load(shared.Snapshot()); - remover.Remove(NodeB); - - var reEnroller = new Roster(NodeC); - reEnroller.Load(shared.Snapshot()); - reEnroller.Enroll(NodeB, "REENROLLED"); - - var wayOne = new Roster(Guid.NewGuid()); - wayOne.Merge(remover.Snapshot()); - wayOne.Merge(reEnroller.Snapshot()); - - var wayTwo = new Roster(Guid.NewGuid()); - wayTwo.Merge(reEnroller.Snapshot()); - wayTwo.Merge(remover.Snapshot()); - - Assert.Equal(wayOne.IsEnrolled(NodeB), wayTwo.IsEnrolled(NodeB)); - Assert.Equal(wayOne.Find(NodeB)!.Thumbprint, wayTwo.Find(NodeB)!.Thumbprint); - } - - [Fact] - public void AMachineOwnsItsOwnName() - { - // Membership and name have separate stamps, so a remover's stale copy of the name cannot - // overwrite what the machine calls itself. - var machine = new Roster(NodeB); - machine.Enroll(NodeB, Fingerprint(NodeB), "old-name"); - machine.SetDisplayName(NodeB, "renamed-by-owner"); - - var other = new Roster(NodeA); - other.Enroll(NodeB, Fingerprint(NodeB), "old-name"); - other.Merge(machine.Snapshot()); - - Assert.Equal("renamed-by-owner", other.Find(NodeB)!.DisplayName); - } - - [Fact] - public void MergingAdvancesTheClockPastEverythingObserved() - { - // A node returning from a long absence must not keep issuing stamps that lose to changes - // it has just learned about. - var busy = new Roster(NodeA); - for (int i = 0; i < 20; i++) - { - busy.Enroll(Guid.NewGuid(), $"THUMB{i}"); - } - - var quiet = new Roster(NodeC); - Assert.Equal(0UL, quiet.Clock); - - quiet.Merge(busy.Snapshot()); - Assert.True(quiet.Clock >= busy.Clock); - - // A change made now outranks everything it merged. - quiet.Enroll(NodeB, Fingerprint(NodeB)); - Assert.True(quiet.Find(NodeB)!.StateChangedAt.Counter > busy.Clock); - } - - [Fact] - public void EqualStampsAreBrokenDeterministically() - { - // Two nodes acting at the same logical time must still converge; without a tie-break each - // would keep its own and the meshes would never agree. - var lower = new LamportStamp(5, NodeA); - var higher = new LamportStamp(5, NodeC); - - Assert.NotEqual(0, lower.CompareTo(higher)); - Assert.Equal(-Math.Sign(lower.CompareTo(higher)), Math.Sign(higher.CompareTo(lower))); - } - - /// - /// The property that matters: any set of changes, delivered in any order, converges. - /// - /// Enumerated cases only cover the orderings someone thought of. This runs randomised - /// histories against three replicas exchanging in different sequences, which is where a - /// non-commutative merge actually shows up. - /// - [Theory] - [InlineData(1)] - [InlineData(7)] - [InlineData(42)] - [InlineData(1337)] - [InlineData(90210)] - public void AnyDeliveryOrderConverges(int seed) - { - var random = new Random(seed); - Guid[] nodes = [NodeA, NodeB, NodeC]; - Guid[] machines = [.. Enumerable.Range(0, 6).Select(_ => Guid.NewGuid())]; - - Roster[] replicas = [.. nodes.Select(node => new Roster(node))]; - - // Each replica applies its own random history, entirely independently. - for (int round = 0; round < 60; round++) - { - Roster replica = replicas[random.Next(replicas.Length)]; - Guid machine = machines[random.Next(machines.Length)]; - - switch (random.Next(3)) - { - case 0: - replica.Enroll(machine, $"THUMB-{random.Next(1000)}"); - break; - - case 1: - replica.Remove(machine); - break; - - default: - replica.SetDisplayName(machine, $"name-{random.Next(100)}"); - break; - } - } - - RosterSnapshot[] snapshots = [.. replicas.Select(replica => replica.Snapshot())]; - - // Exchange everything with everyone, repeatedly and in shuffled order, which is how a real - // mesh gossips. - for (int pass = 0; pass < 4; pass++) - { - foreach (Roster replica in replicas.OrderBy(_ => random.Next())) - { - foreach (RosterSnapshot snapshot in snapshots.OrderBy(_ => random.Next())) - { - replica.Merge(snapshot); - } - } - - snapshots = [.. replicas.Select(replica => replica.Snapshot())]; - } - - for (int i = 1; i < replicas.Length; i++) - { - AssertSameMembership(replicas[0], replicas[i]); - } - } - - [Fact] - public void SnapshotsRoundTripThroughLoad() - { - var original = new Roster(NodeA); - original.Enroll(NodeB, Fingerprint(NodeB), "Bob"); - original.Enroll(NodeC, Fingerprint(NodeC), "Carol"); - original.Remove(NodeC); - - var restored = new Roster(NodeA); - restored.Load(original.Snapshot()); - - AssertSameMembership(original, restored); - Assert.Equal(original.Clock, restored.Clock); - } - - private static void AssertSameMembership(Roster left, Roster right) - { - Dictionary leftEntries = left.AllEntries.ToDictionary(entry => entry.NodeId); - Dictionary rightEntries = right.AllEntries.ToDictionary(entry => entry.NodeId); - - Assert.Equal(leftEntries.Keys.OrderBy(id => id), rightEntries.Keys.OrderBy(id => id)); - - foreach ((Guid nodeId, RosterEntry leftEntry) in leftEntries) - { - RosterEntry rightEntry = rightEntries[nodeId]; - - Assert.Equal(leftEntry.State, rightEntry.State); - Assert.Equal(leftEntry.Thumbprint, rightEntry.Thumbprint); - Assert.Equal(leftEntry.DisplayName, rightEntry.DisplayName); - } - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/SharedMemoryRingTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/SharedMemoryRingTests.cs deleted file mode 100644 index 428d35a..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/SharedMemoryRingTests.cs +++ /dev/null @@ -1,236 +0,0 @@ -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Ipc; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -/// -/// Exercises the cross-process ring within one process. Both ends map the same named region, so -/// the shared-memory path, the header handshake and the index arithmetic are all real; only the -/// process boundary is absent. -/// -public class SharedMemoryRingTests -{ - /// - /// Named per test so parallel runs cannot collide. Created in the Local namespace without the - /// SYSTEM-only DACL, because the test process is an ordinary user: Global needs - /// SeCreateGlobalPrivilege, which only the real service has. The ring logic under test is - /// identical either way. - /// - private static SharedMemoryRing CreateRing(string suffix, int capacity = 64) => - SharedMemoryRing.Create( - $"Test.{suffix}.{Environment.ProcessId}", capacity, systemOnly: false, globalNamespace: false); - - private static SharedMemoryRing OpenRing(string suffix, int capacity = 64) => - SharedMemoryRing.Open( - $"Test.{suffix}.{Environment.ProcessId}", capacity, globalNamespace: false); - - [Fact] - public void ItemsSurviveTheSharedMemoryRoundTrip() - { - using SharedMemoryRing writer = CreateRing(nameof(ItemsSurviveTheSharedMemoryRoundTrip)); - using SharedMemoryRing reader = OpenRing(nameof(ItemsSurviveTheSharedMemoryRoundTrip)); - - for (int i = 1; i <= 10; i++) - { - Assert.True(writer.TryWrite(InputEvent.MouseMove(i, i, -i))); - } - - var received = new InputEvent[32]; - int count = reader.ReadBatch(received); - - Assert.Equal(10, count); - for (int i = 0; i < 10; i++) - { - Assert.Equal(i + 1, received[i].X); - Assert.Equal(-(i + 1), received[i].Y); - Assert.Equal(i + 1, received[i].Timestamp); - } - } - - [Fact] - public void EveryFieldCrossesTheBoundaryIntact() - { - // The struct is copied as raw bytes through shared memory, so a layout or packing mistake - // would silently corrupt fields rather than fail loudly. - using SharedMemoryRing writer = CreateRing(nameof(EveryFieldCrossesTheBoundaryIntact)); - using SharedMemoryRing reader = OpenRing(nameof(EveryFieldCrossesTheBoundaryIntact)); - - var original = new InputEvent( - long.MaxValue, - InputEventKind.Key, - InputEventFlags.Extended | InputEventFlags.Up, - 0x1D, - int.MinValue, - int.MaxValue); - - writer.TryWrite(original); - - var received = new InputEvent[1]; - Assert.Equal(1, reader.ReadBatch(received)); - Assert.Equal(original, received[0]); - } - - [Fact] - public void FullRingRefusesWritesAndCountsThem() - { - using SharedMemoryRing ring = CreateRing(nameof(FullRingRefusesWritesAndCountsThem), 4); - - for (int i = 0; i < ring.Capacity; i++) - { - Assert.True(ring.TryWrite(InputEvent.MouseMove(i, i, 0))); - } - - Assert.False(ring.TryWrite(InputEvent.MouseMove(99, 99, 0))); - Assert.Equal(1, ring.RejectedWrites); - } - - [Fact] - public void IndicesWrapWithoutCorruption() - { - using SharedMemoryRing writer = CreateRing(nameof(IndicesWrapWithoutCorruption), 4); - using SharedMemoryRing reader = OpenRing(nameof(IndicesWrapWithoutCorruption), 4); - - var received = new InputEvent[4]; - for (int i = 0; i < 1000; i++) - { - Assert.True(writer.TryWrite(InputEvent.MouseMove(i, i, 0))); - Assert.Equal(1, reader.ReadBatch(received)); - Assert.Equal(i, received[0].X); - } - } - - [Fact] - public void MismatchedCapacityIsRejectedRatherThanMisread() - { - // Both sides compute slot addresses from the capacity. Disagreeing about it would not - // fail — it would silently read the wrong memory, so the header is checked on open. - using SharedMemoryRing writer = CreateRing(nameof(MismatchedCapacityIsRejectedRatherThanMisread), 64); - - Assert.Throws( - () => OpenRing(nameof(MismatchedCapacityIsRejectedRatherThanMisread), 128)); - } - - [Fact] - public void DiscardPendingDropsStaleInput() - { - // The ring outlives an agent that crashes. Replaying what it had queued would inject - // keystrokes captured seconds ago into whatever has focus now. - using SharedMemoryRing writer = CreateRing(nameof(DiscardPendingDropsStaleInput)); - using SharedMemoryRing reader = OpenRing(nameof(DiscardPendingDropsStaleInput)); - - for (int i = 0; i < 10; i++) - { - writer.TryWrite(InputEvent.MouseMove(i, i, 0)); - } - - reader.DiscardPending(); - - Assert.Equal(0, reader.ReadBatch(new InputEvent[32])); - - // Still usable afterwards. - writer.TryWrite(InputEvent.MouseMove(99, 5, 5)); - Assert.Equal(1, reader.ReadBatch(new InputEvent[32])); - } - - [Fact] - public void SignalWakesAWaitingConsumer() - { - using SharedMemoryRing writer = CreateRing(nameof(SignalWakesAWaitingConsumer)); - using SharedMemoryRing reader = OpenRing(nameof(SignalWakesAWaitingConsumer)); - - Assert.False(reader.WaitForData(TimeSpan.FromMilliseconds(50))); - - writer.TryWrite(InputEvent.MouseMove(1, 1, 1)); - writer.SignalDataAvailable(); - - Assert.True(reader.WaitForData(TimeSpan.FromSeconds(2))); - } - - /// - /// The assertion the plan asks for at this step. Allocation on this path would eventually mean - /// gen0 collections, and a GC pause reaching the hook thread risks the LowLevelHooksTimeout, - /// after which Windows unhooks us silently. - /// - [Fact] - public void SteadyStateTrafficAllocatesNothing() - { - using SharedMemoryRing writer = CreateRing(nameof(SteadyStateTrafficAllocatesNothing), 1024); - using SharedMemoryRing reader = OpenRing(nameof(SteadyStateTrafficAllocatesNothing), 1024); - - var destination = new InputEvent[64]; - - // Warm up so JIT and first-touch page faults are not counted. - for (int i = 0; i < 100; i++) - { - writer.TryWrite(InputEvent.MouseMove(i, 1, 1)); - } - - reader.ReadBatch(destination); - - long before = GC.GetAllocatedBytesForCurrentThread(); - - for (int round = 0; round < 1000; round++) - { - for (int i = 0; i < 32; i++) - { - writer.TryWrite(InputEvent.MouseMove(i, i, i)); - } - - while (reader.ReadBatch(destination) > 0) - { - } - } - - Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); - } - - [Fact] - public async Task ConcurrentProducerAndConsumerLoseNothing() - { - const int total = 100_000; - - using SharedMemoryRing writer = CreateRing(nameof(ConcurrentProducerAndConsumerLoseNothing), 256); - using SharedMemoryRing reader = OpenRing(nameof(ConcurrentProducerAndConsumerLoseNothing), 256); - - var producer = Task.Run(() => - { - for (int i = 0; i < total; i++) - { - while (!writer.TryWrite(InputEvent.MouseMove(i, i, 0))) - { - Thread.SpinWait(1); - } - } - }); - - var consumer = Task.Run(() => - { - var batch = new InputEvent[64]; - int expected = 0; - - while (expected < total) - { - int count = reader.ReadBatch(batch); - if (count == 0) - { - Thread.SpinWait(1); - continue; - } - - for (int i = 0; i < count; i++) - { - // Order must hold exactly: a key-up overtaking its key-down would strand a - // modifier held on a remote machine. - Assert.Equal(expected, batch[i].X); - expected++; - } - } - - return expected; - }); - - await producer; - Assert.Equal(total, await consumer); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/SpscRingBufferTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/SpscRingBufferTests.cs deleted file mode 100644 index ece59e4..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/SpscRingBufferTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -using VirtualKmSwitch.Core; -using VirtualKmSwitch.Ipc; -using Xunit; - -namespace VirtualKmSwitch.Tests; - -public class SpscRingBufferTests -{ - [Fact] - public void CapacityRoundsUpToPowerOfTwo() - { - Assert.Equal(8, new SpscRingBuffer(5).Capacity); - Assert.Equal(1024, new SpscRingBuffer(1024).Capacity); - } - - [Fact] - public void WritesAreReadBackInOrder() - { - var ring = new SpscRingBuffer(16); - - for (int i = 1; i <= 10; i++) - { - Assert.True(ring.TryWrite(InputEvent.MouseMove(i, i, -i))); - } - - for (int i = 1; i <= 10; i++) - { - Assert.True(ring.TryRead(out InputEvent read)); - Assert.Equal(i, read.X); - Assert.Equal(-i, read.Y); - Assert.Equal(i, read.Timestamp); - } - - Assert.False(ring.TryRead(out _)); - } - - [Fact] - public void RefusesWritesWhenFullAndCountsThem() - { - var ring = new SpscRingBuffer(4); - - for (int i = 0; i < 4; i++) - { - Assert.True(ring.TryWrite(InputEvent.MouseMove(i, i, 0))); - } - - Assert.False(ring.TryWrite(InputEvent.MouseMove(99, 99, 0))); - Assert.Equal(1, ring.RejectedWrites); - - // Draining one slot makes room again. - Assert.True(ring.TryRead(out _)); - Assert.True(ring.TryWrite(InputEvent.MouseMove(99, 99, 0))); - } - - [Fact] - public void IndicesWrapWithoutCorruption() - { - var ring = new SpscRingBuffer(4); - - // Many times capacity, so the mask arithmetic wraps repeatedly. - for (int i = 0; i < 1000; i++) - { - Assert.True(ring.TryWrite(InputEvent.MouseMove(i, i, 0))); - Assert.True(ring.TryRead(out InputEvent read)); - Assert.Equal(i, read.X); - } - } - - [Fact] - public void ReadBatchDrainsWhatIsAvailable() - { - var ring = new SpscRingBuffer(64); - for (int i = 0; i < 10; i++) - { - ring.TryWrite(InputEvent.MouseMove(i, i, 0)); - } - - var destination = new InputEvent[32]; - int read = ring.ReadBatch(destination); - - Assert.Equal(10, read); - for (int i = 0; i < 10; i++) - { - Assert.Equal(i, destination[i].X); - } - - Assert.Equal(0, ring.ReadBatch(destination)); - } - - [Fact] - public void ReadBatchIsLimitedByDestinationLength() - { - var ring = new SpscRingBuffer(64); - for (int i = 0; i < 20; i++) - { - ring.TryWrite(InputEvent.MouseMove(i, i, 0)); - } - - var small = new InputEvent[8]; - Assert.Equal(8, ring.ReadBatch(small)); - Assert.Equal(0, small[0].X); - Assert.Equal(7, small[7].X); - Assert.Equal(12, ring.Count); - } - - /// - /// The regression test that keeps the hot path honest. Every input event from capture to wire - /// must allocate nothing: allocation means eventual gen0 collections, and a GC pause on the - /// hook thread risks overrunning LowLevelHooksTimeout, which makes Windows silently unhook us. - /// - [Fact] - public void SteadyStateTrafficAllocatesNothing() - { - var ring = new SpscRingBuffer(1024); - var destination = new InputEvent[64]; - - // Warm up so JIT and first-touch costs are not counted. - for (int i = 0; i < 100; i++) - { - ring.TryWrite(InputEvent.MouseMove(i, 1, 1)); - } - - ring.ReadBatch(destination); - - long before = GC.GetAllocatedBytesForCurrentThread(); - - for (int round = 0; round < 1000; round++) - { - for (int i = 0; i < 32; i++) - { - ring.TryWrite(InputEvent.MouseMove(i, i, i)); - } - - while (ring.ReadBatch(destination) > 0) - { - } - } - - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.Equal(0, allocated); - } - - [Fact] - public async Task ConcurrentProducerAndConsumerLoseNothing() - { - const int total = 200_000; - var ring = new SpscRingBuffer(256); - - var producer = Task.Run(() => - { - for (int i = 0; i < total; i++) - { - // Full buffer is expected under load; spin rather than drop, so the test measures - // correctness rather than backpressure policy. - while (!ring.TryWrite(InputEvent.MouseMove(i, i, 0))) - { - Thread.SpinWait(1); - } - } - }); - - var consumer = Task.Run(() => - { - var batch = new InputEvent[64]; - int expected = 0; - - while (expected < total) - { - int count = ring.ReadBatch(batch); - if (count == 0) - { - Thread.SpinWait(1); - continue; - } - - for (int i = 0; i < count; i++) - { - // Order must be exact: a key-up overtaking its key-down would strand a - // modifier held on a remote machine. - Assert.Equal(expected, batch[i].X); - expected++; - } - } - - return expected; - }); - - await producer; - int received = await consumer; - - Assert.Equal(total, received); - - // Rejections are expected here -- a 256-slot ring cannot hold 200k events, and the - // producer spins rather than dropping. What matters is that nothing was lost or - // reordered, which the consumer asserted event by event. - Assert.True(ring.RejectedWrites >= 0); - } -} diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/VirtualKmSwitch.Tests.csproj b/src/csharp/tests/VirtualKmSwitch.Tests/VirtualKmSwitch.Tests.csproj deleted file mode 100644 index 3f888f4..0000000 --- a/src/csharp/tests/VirtualKmSwitch.Tests/VirtualKmSwitch.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - false - true - - - - - - - - - - - - - - - - - - - -