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 - - - - - - - - - - - - - - - - - - - -