From 2a4f2c363a1819553dc8c5cac7af6140eef8a072 Mon Sep 17 00:00:00 2001 From: Chaotic Holon Date: Mon, 19 Oct 2026 08:24:35 +0000 Subject: [PATCH] Standing fuzz suite and fuzzing log - Sources/MicControl.Tests/Fuzz: the three fuzzing rounds' harnesses as opt-in tests (MICCONTROL_FUZZ=1), a new seed each run printed with any failure, MICCONTROL_FUZZ_SEED to repeat and MICCONTROL_FUZZ_SCALE for more cases - tools/FuzzWav.cs: damaged WAVs through the playback decoder, and Avalonia keys with no virtual key - tools/Fuzz.ps1 runs both after a build - docs/fuzzing-report.md: the log of rounds (what each covered, up to which commit, what to try next), read first by every new round --- CLAUDE.md | 1 + Sources/MicControl.Tests/Fuzz/FuzzRun.cs | 34 ++ .../MicControl.Tests/Fuzz/ParserFuzzTests.cs | 530 ++++++++++++++++++ .../MicControl.Tests/Fuzz/StateFuzzTests.cs | 246 ++++++++ docs/fuzzing-report.md | 189 +++++++ tools/Fuzz.ps1 | 48 ++ tools/FuzzWav.cs | 108 ++++ 7 files changed, 1156 insertions(+) create mode 100644 Sources/MicControl.Tests/Fuzz/FuzzRun.cs create mode 100644 Sources/MicControl.Tests/Fuzz/ParserFuzzTests.cs create mode 100644 Sources/MicControl.Tests/Fuzz/StateFuzzTests.cs create mode 100644 docs/fuzzing-report.md create mode 100644 tools/Fuzz.ps1 create mode 100644 tools/FuzzWav.cs diff --git a/CLAUDE.md b/CLAUDE.md index 4aa1541..8693025 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ System-wide microphone mute/unmute with hotkeys, an always-on-top overlay, sound - Run (Windows): `dotnet run --project MicControl`. The app asks for elevation (`highestAvailable`), so run from an elevated terminal or Visual Studio started as administrator. - Tests already run on both Windows and Linux. The Windows projects also build on Linux (`EnableWindowsTargeting`). - Screenshots for the README and releases (the bundled icon font makes Linux renders match Windows, apart from the About page's system details): set `MICCONTROL_SCREENSHOTS=1` and run the tests; `ScreenshotTests` renders the real windows (headless Skia, sample data, light and dark, greyscale text) into `docs/screenshots`. Re-run after visible UI changes and commit the images. +- Fuzzing (from the repo root, after a build): `powershell -ExecutionPolicy Bypass -File tools\Fuzz.ps1 [-Scale 5] [-Seed N]` runs the opt-in suite in `MicControl.Tests/Fuzz/` (`MICCONTROL_FUZZ=1`, a new seed each run, printed with any failure) and `tools/FuzzWav.cs` (damaged WAVs through the playback decoder). `docs/fuzzing-report.md` logs each round: what it covered, up to which commit, and what to try next. A new round reads it first and attacks what earlier rounds missed or what changed since; fuzzers that find something graduate into `Fuzz/`. - Setup (from the repo root): `installer\build.ps1 [-Version 1.0.0]` publishes the app as one self-contained file and writes `artifacts\MicControl-Setup-.exe`. CI builds it for every push to `master`/`dev` and every PR; a signed `vMAJOR.MINOR.PATCH` tag on `master` drafts a GitHub release, which the developer signs and publishes (see "Branches"). ## Installer and updates diff --git a/Sources/MicControl.Tests/Fuzz/FuzzRun.cs b/Sources/MicControl.Tests/Fuzz/FuzzRun.cs new file mode 100644 index 0000000..50127d5 --- /dev/null +++ b/Sources/MicControl.Tests/Fuzz/FuzzRun.cs @@ -0,0 +1,34 @@ +namespace MicControl.Tests.Fuzz; + +/// +/// The opt-in fuzz suite: skipped unless MICCONTROL_FUZZ=1 (tools/Fuzz.ps1 sets it). Each run uses a new seed, printed +/// with every failure; MICCONTROL_FUZZ_SEED repeats a run and MICCONTROL_FUZZ_SCALE multiplies the case counts. +/// +internal static class FuzzRun +{ + public static bool Enabled { get; } = Environment.GetEnvironmentVariable("MICCONTROL_FUZZ") == "1"; + + public static int Seed { get; } = int.TryParse(Environment.GetEnvironmentVariable("MICCONTROL_FUZZ_SEED"), out var seed) + ? seed + : System.Random.Shared.Next(); + + private static readonly double Factor = double.TryParse(Environment.GetEnvironmentVariable("MICCONTROL_FUZZ_SCALE"), + System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var factor) && factor > 0 ? factor : 1; + + public static void Require() => Assert.SkipUnless(Enabled, "Set MICCONTROL_FUZZ=1 (or run tools/Fuzz.ps1) to fuzz."); + + /// A generator for one test: the run's seed mixed with the test's own number, so tests don't share a sequence. + public static Random Random(int salt) => new(unchecked((Seed * 1_000_003) + salt)); + + public static int Scale(int cases) => Math.Max(1, (int)(cases * Factor)); + + /// Fails with every distinct problem found, and the seed that repeats the run. + public static void AssertNone(IReadOnlyDictionary failures) + { + if (failures.Count > 0) + { + Assert.Fail($"Seed {Seed} (set MICCONTROL_FUZZ_SEED={Seed} to repeat this run)\n\n" + + string.Join("\n\n", failures.Select(f => f.Key + "\n " + f.Value))); + } + } +} diff --git a/Sources/MicControl.Tests/Fuzz/ParserFuzzTests.cs b/Sources/MicControl.Tests/Fuzz/ParserFuzzTests.cs new file mode 100644 index 0000000..9757e89 --- /dev/null +++ b/Sources/MicControl.Tests/Fuzz/ParserFuzzTests.cs @@ -0,0 +1,530 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using MicControl.Hotkeys; +using MicControl.Security; +using MicControl.Settings; +using MicControl.Updates; + +namespace MicControl.Tests.Fuzz; + +/// Mutation fuzzing of the parsers that read files and downloads: settings, MicSwitch configs, hotkey text, signatures, the update feed. +public sealed class ParserFuzzTests : IDisposable +{ + private static readonly int Iterations = FuzzRun.Scale(20_000); + private readonly string directory = Directory.CreateTempSubdirectory("miccontrol-fuzz").FullName; + + public void Dispose() => Directory.Delete(directory, recursive: true); + + private static readonly string[] Tokens = + [ + "null", "true", "false", "0", "-1", "1e308", "-1e308", "1e39", "3.4e38", "1e-400", "99999999999999999999", "2147483648", + "\"\"", "\"x\"", "\"NaN\"", "\"Infinity\"", "\"-Infinity\"", "[]", "{}", "[null]", "[1,2]", "{\"a\":1}", "\"Ctrl+\"", "\"++\"", + "\"Mouse\"", "\"MouseXButton9\"", "\"tray\"", "\"window\"", "\"Toggle\"", "\"PushToTalk\"", "999", "-2147483649", "\"\\u0000\"", + "1.5", "NaN", "\"Ctrl+Alt+Shift+Win+F24\"", + ]; + + private static string Rich() + { + var s = new AppSettings(); + s.Microphone.Hotkey.Key = HotkeyGesture.Parse("Ctrl+F2"); + s.Microphone.Volume = 0.5f; + s.CustomHotkeys.Add(new CustomHotkey { Action = HotkeyAction.SpeakerVolumeUp, Hotkey = new HotkeySettings { Key = HotkeyGesture.Parse("MouseXButton1") } }); + s.Overlay.Bounds = new WindowBounds(1, 2, 3, 4); + s.Overlay.Monitor = new WindowBounds(0, 0, 1920, 1080); + s.Window.Bounds = new WindowBounds(10, 10, 800, 600); + s.Notifications.OutputDeviceId = "dev"; + return SettingsStore.Serialize(s); + } + + // Mutates JSON both structurally (replace a value with a hostile token) and textually (byte noise, truncation). + private static string Mutate(Random r, string json) + { + switch (r.Next(7)) + { + case 0: + case 1: + case 2: + { + var node = JsonNode.Parse(json)!; + for (var n = r.Next(1, 4); n > 0; n--) + { + ReplaceRandom(r, node); + } + + return node.ToJsonString(); + } + case 3: + { + var chars = json.ToCharArray(); + for (var n = r.Next(1, 5); n > 0; n--) + { + chars[r.Next(chars.Length)] = (char)r.Next(0, 128); + } + + return new string(chars); + } + case 4: + return json[..r.Next(json.Length)]; + case 5: + { + // A lone surrogate escape in a random string: valid JSON syntax, invalid text. + var strings = System.Text.RegularExpressions.Regex.Matches(json, "\"[^\"\\\\]*\""); + var m = strings[r.Next(strings.Count)]; + return json[..m.Index] + "\"\\uD800x\"" + json[(m.Index + m.Length)..]; + } + default: + var depth = r.Next(1, 200); + return new string('[', depth) + json + new string(']', depth); + } + } + + private static void ReplaceRandom(Random r, JsonNode root) + { + var all = new List<(JsonNode Parent, object Key)>(); + void Walk(JsonNode? n) + { + switch (n) + { + case JsonObject o: + foreach (var p in o.ToList()) + { + all.Add((o, p.Key)); + Walk(p.Value); + } + + break; + case JsonArray a: + for (var i = 0; i < a.Count; i++) + { + all.Add((a, i)); + Walk(a[i]); + } + + break; + } + } + + Walk(root); + if (all.Count == 0) + { + return; + } + + var (parent, key) = all[r.Next(all.Count)]; + var replacement = JsonNode.Parse(Tokens[r.Next(Tokens.Length)].Replace("NaN", "\"NaN\"", StringComparison.Ordinal).Replace("\"\"NaN\"\"", "\"NaN\"", StringComparison.Ordinal)); + if (parent is JsonObject obj) + { + obj[(string)key] = replacement; + } + else + { + ((JsonArray)parent)[(int)key] = replacement; + } + } + + private static void Record(Dictionary failures, Exception ex, string input) + { + var key = $"{ex.GetType().Name} at {ex.StackTrace?.Split('\n').FirstOrDefault()?.Trim()}"; + failures.TryAdd(key, $"{ex.Message}\n input: {(input.Length > 400 ? input[..400] + "…" : input)}"); + } + + /// What a loaded settings object must look like for the app to run with it. + private static string? Invariants(AppSettings s) + { + if (s.Microphone is null || s.Microphone.Hotkey?.Key is null || s.Notifications is null || s.Overlay is null || s.Window is null + || s.CustomHotkeys is null || s.CustomHotkeys.Any(h => h?.Hotkey?.Key is null || h.Hotkey.AlternativeKey is null)) + { + return "null section"; + } + + if (!Enum.IsDefined(s.Microphone.MuteMode) || !Enum.IsDefined(s.Microphone.InitialState) || !Enum.IsDefined(s.Overlay.Visibility) + || !Enum.IsDefined(s.Overlay.LastVisibleMode) || !Enum.IsDefined(s.Overlay.Corner) || !Enum.IsDefined(s.Window.AppMode) + || !Enum.IsDefined(s.Window.Theme) || !Enum.IsDefined(s.LogLevel) || s.CustomHotkeys.Any(h => !Enum.IsDefined(h.Action))) + { + return "undefined enum value"; + } + + if (s.Microphone.Volume is { } v && (!float.IsFinite(v) || v < 0 || v > 1) || !float.IsFinite(s.Notifications.Volume) + || s.Notifications.Volume < 0 || s.Notifications.Volume > 1) + { + return $"volume out of range (mic {s.Microphone.Volume}, sounds {s.Notifications.Volume})"; + } + + if (!double.IsFinite(s.Overlay.Opacity) || s.Overlay.Opacity is < 0 or > 1 || !double.IsFinite(s.Overlay.Scale) || s.Overlay.Scale <= 0) + { + return $"overlay opacity/scale out of range ({s.Overlay.Opacity}, {s.Overlay.Scale})"; + } + + foreach (var b in new[] { s.Overlay.Bounds, s.Overlay.Monitor, s.Window.Bounds }) + { + if (b is not null && (!double.IsFinite(b.Left) || !double.IsFinite(b.Top) || !double.IsFinite(b.Width) || !double.IsFinite(b.Height) || b.Width < 0 || b.Height < 0)) + { + return $"bounds out of range {b}"; + } + } + + return null; + } + + [Fact] + public void Settings_MutatedJson_LoadsOrIsPreserved() + { + FuzzRun.Require(); + var r = FuzzRun.Random(1); + var seeds = new[] { SettingsStore.Serialize(new AppSettings()), Rich() }; + var crashes = new Dictionary(); + var invalid = new Dictionary(); + var store = new SettingsStore(directory, Path.Combine(directory, "no-legacy")); + for (var i = 0; i < Iterations; i++) + { + var json = Mutate(r, seeds[r.Next(seeds.Length)]); + try + { + File.WriteAllText(store.FilePath, json); + var loaded = store.Load(); + if (Invariants(loaded) is { } problem) + { + invalid.TryAdd(problem, json); + } + + // Whatever loads must save and load again. + SettingsStore.Deserialize(SettingsStore.Serialize(loaded)); + } + catch (Exception ex) + { + Record(crashes, ex, json); + } + } + + FuzzRun.AssertNone(crashes.Concat(invalid.Select(p => KeyValuePair.Create("INVARIANT: " + p.Key, p.Value))).ToDictionary()); + } + + [Fact] + public void Settings_LockedOrUnreadableFile_DoesNotThrow() + { + FuzzRun.Require(); + var store = new SettingsStore(directory, Path.Combine(directory, "no-legacy")); + File.WriteAllText(store.FilePath, "{}"); + var crashes = new Dictionary(); + using (new FileStream(store.FilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + try + { + store.Load(); + } + catch (Exception ex) + { + Record(crashes, ex, "settings.json opened exclusively by another process"); + } + } + + // A directory where the file should be. + File.Delete(store.FilePath); + Directory.CreateDirectory(store.FilePath); + try + { + store.Load(); + } + catch (Exception ex) + { + Record(crashes, ex, "settings.json is a directory"); + } + + Directory.Delete(store.FilePath); + FuzzRun.AssertNone(crashes); + } + + private static string LegacySeed() => """ + {"Items":[ + {"TypeName":"MicSwitch.MainWindow.Models.MicSwitchConfig, MicSwitch","ConfigValue":{ + "MicrophoneLineId":{"LineId":"{0.0.1.00000000}.{abc}"},"VolumeControlEnabled":true,"Volume":0.4, + "MicrophoneHotkey":"Ctrl+F1","MicrophoneHotkeyAlt":"MouseXButton1","SuppressHotkey":false, + "Notifications":{"On":"Beep300","Off":"custom.wav"},"NotificationVolume":0.7, + "MainWindowBounds":"10, 20, 800, 600","OverlayBounds":{"X":5,"Y":6,"Width":100,"Height":50}, + "OverlayOpacity":0.5,"OverlayVisibilityMode":"Always","OverlayEnabled":true,"MutedMicrophoneIcon":"AAEC","MicrophoneIcon":"AAEC"}}, + {"TypeName":"MicSwitch.Modularity.MicSwitchHotkeyConfig","ConfigValue":{ + "Hotkey":{"Key":"F13","AlternativeKey":"None","Suppress":true},"EnableAdvancedHotkeys":true, + "HotkeyForToggle":{"Key":"Ctrl+T"},"HotkeyForMute":{"Key":"Ctrl+M"},"HotkeyForUnmute":{"Key":"Ctrl+U"}, + "HotkeyForPushToTalk":{"Key":"MouseMiddle"},"HotkeyForPushToMute":{"Key":"Alt+P"}}}, + {"TypeName":"MicSwitchOverlayConfig","ConfigValue":{"OverlayVisibilityMode":2,"OverlayBounds":"1;2;3;4"}}, + {"TypeName":"MicSwitchVolumeControlConfig","ConfigValue":{"IsEnabled":true,"DeviceId":{"LineId":"out"}, + "HotkeyForToggle":{"Key":"Ctrl+S"},"HotkeyForVolumeUp":{"Key":"WheelUp"}}} + ]} + """; + + [Fact] + public void Legacy_MutatedConfig_ImportsOrIsSkipped() + { + FuzzRun.Require(); + var r = FuzzRun.Random(2); + var seed = LegacySeed(); + var crashes = new Dictionary(); + var invalid = new Dictionary(); + var legacy = Path.Combine(directory, "MicSwitch"); + Directory.CreateDirectory(Path.Combine(legacy, "release")); + var configPath = Path.Combine(legacy, "release", "config.cfg"); + var icons = Path.Combine(directory, "Icons"); + var intact = 0; + for (var i = 0; i < Iterations / 4; i++) + { + var json = Mutate(r, seed); + try + { + File.WriteAllText(configPath, json, r.Next(2) == 0 ? Encoding.Unicode : Encoding.UTF8); + var migrated = LegacyConfigMigrator.TryMigrate(legacy, icons); + if (migrated is not null) + { + intact++; + if (Invariants(migrated.Normalize()) is { } problem) + { + invalid.TryAdd(problem, json); + } + + SettingsStore.Deserialize(SettingsStore.Serialize(migrated)); + } + } + catch (Exception ex) + { + Record(crashes, ex, json); + } + } + + Assert.True(intact > 0); + FuzzRun.AssertNone(crashes.Concat(invalid.Select(p => KeyValuePair.Create("INVARIANT: " + p.Key, p.Value))).ToDictionary()); + } + + [Fact] + public void Legacy_OneMalformedEntry_KeepsTheRest() + { + FuzzRun.Require(); + // MicSwitch configs edited by hand: one broken item or field shouldn't lose the whole import. + var cases = new Dictionary + { + ["non-object item"] = """{"Items":[1,{"TypeName":"MicSwitchHotkeyConfig","ConfigValue":{"Hotkey":{"Key":"F13"}}}]}""", + ["string LineId parent"] = """{"Items":[{"TypeName":"MicSwitchConfig","ConfigValue":{"MicrophoneLineId":"x"}},{"TypeName":"MicSwitchHotkeyConfig","ConfigValue":{"Hotkey":{"Key":"F13"}}}]}""", + ["bad icon"] = """{"Items":[{"TypeName":"MicSwitchConfig","ConfigValue":{"MicrophoneIcon":"!!notbase64"}},{"TypeName":"MicSwitchHotkeyConfig","ConfigValue":{"Hotkey":{"Key":"F13"}}}]}""", + }; + var failures = new Dictionary(); + foreach (var (name, json) in cases) + { + try + { + var s = LegacyConfigMigrator.Migrate(json, Path.Combine(directory, "Icons")); + if (s.Microphone.Hotkey.Key != HotkeyGesture.Parse("F13")) + { + failures[name] = "hotkey lost"; + } + } + catch (Exception ex) + { + failures[name] = $"{ex.GetType().Name}: {ex.Message}"; + } + } + + FuzzRun.AssertNone(failures); + } + + private const string HotkeyAlphabet = "+ CtrlAltShiftWinMouseXButton12WheelUpDownNone*-/=`F1Num\t\u00a0\0é😀"; + + [Fact] + public void Hotkey_RandomText_ParsesSafelyAndRoundTrips() + { + FuzzRun.Require(); + var r = FuzzRun.Random(3); + var crashes = new Dictionary(); + var words = new[] { "Ctrl", "Alt", "Shift", "Win", "Windows", "Control", "+", "Mouse", "MouseXButton1", "WheelUp", "None", "F1", " ", "Num +", "Num", "*", "0", "Mouse0", "Mouse-1", "MouseNone", "Wheel" }; + for (var i = 0; i < Iterations * 5; i++) + { + var sb = new StringBuilder(); + for (var n = r.Next(0, 6); n > 0; n--) + { + if (r.Next(2) == 0) + { + sb.Append(words[r.Next(words.Length)]); + } + else + { + sb.Append(HotkeyAlphabet[r.Next(HotkeyAlphabet.Length)]); + } + } + + var text = sb.ToString(); + try + { + if (HotkeyGesture.TryParse(text, out var g)) + { + var printed = g.ToString(); + if (!HotkeyGesture.TryParse(printed, out var again) || again != g) + { + crashes.TryAdd("round trip " + printed, $"'{text}' -> '{printed}' -> {(again is null ? "fail" : again.ToString())}"); + } + + // The settings file keeps hotkeys as text, so every parsed gesture must survive a save. + var s = new AppSettings(); + s.Microphone.Hotkey.Key = g; + if (SettingsStore.Deserialize(SettingsStore.Serialize(s)).Microphone.Hotkey.Key != g) + { + crashes.TryAdd("json round trip " + printed, text); + } + } + } + catch (Exception ex) + { + Record(crashes, ex, text); + } + } + + FuzzRun.AssertNone(crashes); + } + + [Fact] + public void Signature_MutatedSshsig_NeverThrowsAndNeverPasses() + { + FuzzRun.Require(); + var r = FuzzRun.Random(4); + var message = Encoding.UTF8.GetBytes(SecurityTests.Sums); + Assert.True(SshSignature.Verify(message, SecurityTests.GoodSignature, ReleaseFeed.SignatureNamespace, [SecurityTests.ReleaseKey])); + var lines = SecurityTests.GoodSignature.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var blob = Convert.FromBase64String(string.Concat(lines[1..^1])); + var failures = new Dictionary(); + for (var i = 0; i < Iterations / 10; i++) + { + var bytes = (byte[])blob.Clone(); + var mode = r.Next(4); + switch (mode) + { + case 0: + bytes[r.Next(bytes.Length)] ^= (byte)(1 << r.Next(8)); + break; + case 1: + Array.Resize(ref bytes, r.Next(bytes.Length)); + break; + case 2: + // Hostile length prefixes (0xFFFFFFFF, 0x7FFFFFFF, 0x80000000) at a random offset. + var at = r.Next(Math.Max(1, bytes.Length - 4)); + var len = new[] { 0xFFFFFFFFu, 0x7FFFFFFFu, 0x80000000u, 0u }[r.Next(4)]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(at), len); + break; + default: + bytes = [.. bytes, .. new byte[r.Next(1, 64)]]; + break; + } + + var armored = "-----BEGIN SSH SIGNATURE-----\n" + Convert.ToBase64String(bytes) + "\n-----END SSH SIGNATURE-----\n"; + try + { + if (SshSignature.Verify(message, armored, ReleaseFeed.SignatureNamespace, [SecurityTests.ReleaseKey]) + && !bytes.AsSpan().SequenceEqual(blob)) + { + failures.TryAdd("ACCEPTED MUTATION " + mode, Convert.ToBase64String(bytes)); + } + } + catch (Exception ex) + { + Record(failures, ex, Convert.ToBase64String(bytes)); + } + } + + // Garbage armour, keys and namespaces. + string[] armours = ["", "\n", "-----BEGIN SSH SIGNATURE-----", "-----BEGIN SSH SIGNATURE-----\n\n-----END SSH SIGNATURE-----", "-----BEGIN SSH SIGNATURE-----\n====\n-----END SSH SIGNATURE-----", SecurityTests.GoodSignature.Replace("\n", "\r\n", StringComparison.Ordinal)]; + string[] keys = ["", " ", "ssh-ed25519", "ssh-ed25519 !!!", "ssh-ed25519 AAAA", "ssh-rsa AAAAB3NzaC1yc2E=", SecurityTests.ReleaseKey + " extra words"]; + foreach (var a in armours) + { + foreach (var k in keys) + { + foreach (var ns in new[] { "", ReleaseFeed.SignatureNamespace, "\0", "git" }) + { + try + { + SshSignature.Verify(message, a, ns, [k]); + } + catch (Exception ex) + { + Record(failures, ex, $"{a} | {k} | {ns}"); + } + } + } + } + + // Raw Ed25519 with random points and scalars. + for (var i = 0; i < 300; i++) + { + var key = new byte[r.Next(30, 34)]; + var sig = new byte[r.Next(62, 66)]; + r.NextBytes(key); + r.NextBytes(sig); + if (r.Next(3) == 0) + { + Array.Fill(key, (byte)0xFF); + } + + try + { + if (Ed25519.Verify(key, message, sig)) + { + failures.TryAdd("ED25519 ACCEPTED RANDOM", Convert.ToBase64String(sig)); + } + } + catch (Exception ex) + { + Record(failures, ex, Convert.ToBase64String(key) + " " + Convert.ToBase64String(sig)); + } + } + + FuzzRun.AssertNone(failures); + } + + private const string FeedSeed = """ + {"tag_name":"v2.1.0","draft":false,"prerelease":false,"assets":[ + {"name":"MicControl-Setup-2.1.0.exe","browser_download_url":"https://example.com/MicControl-Setup-2.1.0.exe"}, + {"name":"SHA512SUMS","browser_download_url":"https://example.com/SHA512SUMS"}, + {"name":"SHA512SUMS.sig","browser_download_url":"https://example.com/SHA512SUMS.sig"}]} + """; + + [Fact] + public void ReleaseFeed_MutatedJson_FailsOnlyWithCaughtExceptions() + { + FuzzRun.Require(); + // MainViewModel.IsUpdateFailure catches JsonException and InvalidOperationException; anything else crashes the check. + var r = FuzzRun.Random(5); + var failures = new Dictionary(); + for (var i = 0; i < Iterations; i++) + { + var json = Mutate(r, FeedSeed); + try + { + if (ReleaseFeed.Parse(json) is { } release) + { + Assert.StartsWith("https", release.SetupUrl.Scheme, StringComparison.Ordinal); + } + + ReleaseFeed.FindSha512(json, "MicControl-Setup-2.1.0.exe"); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + } + catch (Exception ex) + { + Record(failures, ex, json); + } + } + + foreach (var tag in new[] { "", "v", "vv1.0.0", "1", "1.2.3.4.5", "-1.0.0", "1.-1.0", "99999999999.0.0", "2147483647.2147483647.2147483647", " v1.0.0 ", "1.0.0-beta" }) + { + try + { + if (ReleaseFeed.TryParseVersion(tag, out var v)) + { + _ = $"{ReleaseFeed.SetupFilePrefix}{v.ToString(3)}.exe"; + } + } + catch (Exception ex) + { + Record(failures, ex, tag); + } + } + + FuzzRun.AssertNone(failures); + } +} diff --git a/Sources/MicControl.Tests/Fuzz/StateFuzzTests.cs b/Sources/MicControl.Tests/Fuzz/StateFuzzTests.cs new file mode 100644 index 0000000..349a255 --- /dev/null +++ b/Sources/MicControl.Tests/Fuzz/StateFuzzTests.cs @@ -0,0 +1,246 @@ +using System.Diagnostics; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using MicControl.Hotkeys; +using MicControl.Installation; +using MicControl.Platform; +using MicControl.Settings; +using MicControl.Tests.Ui; + +namespace MicControl.Tests.Fuzz; + +/// +/// Fuzzing beyond single files: image headers, command lines, autostart entries taken over from MicSwitch, settings +/// files built to be huge or deep, and the hotkey state machine (push-to-talk must always end muted). +/// +public sealed class StateFuzzTests(ITestOutputHelper output) +{ + [Fact] + public void ImageSize_MutatedHeaders_NeverThrowOrHang() + { + FuzzRun.Require(); + var png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="); + byte[] gif = [.. "GIF89a"u8, 1, 0, 1, 0, 0, 0, 0]; + var bmp = new byte[54]; + bmp[0] = (byte)'B'; bmp[1] = (byte)'M'; + byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 16, .. new byte[14], 0xFF, 0xC0, 0, 17, 8, 0, 1, 0, 1, 3, .. new byte[9]]; + byte[] ico = [0, 0, 1, 0, 2, 0, .. new byte[12], 22 + 16, 0, 0, 0, .. new byte[12], 0, 0, 0, 0, .. png]; + var seeds = new[] { png, gif, bmp, jpeg, ico }; + var r = FuzzRun.Random(31); + var failures = new Dictionary(); + for (var i = 0; i < FuzzRun.Scale(200_000); i++) + { + var b = (byte[])seeds[r.Next(seeds.Length)].Clone(); + for (var n = r.Next(1, 6); n > 0; n--) + { + switch (r.Next(3)) + { + case 0: b[r.Next(b.Length)] = (byte)r.Next(256); break; + case 1: b[r.Next(b.Length)] = new byte[] { 0, 0xFF, 0x89, 0xC0, 0xD8 }[r.Next(5)]; break; + default: Array.Resize(ref b, r.Next(b.Length + 1)); if (b.Length == 0) b = [0]; break; + } + } + + try + { + MicControl.Imaging.ImageSize.IsReasonable(b); + } + catch (Exception ex) + { + failures.TryAdd(ex.GetType().Name + ex.StackTrace?.Split('\n').FirstOrDefault(), Convert.ToHexString(b.AsSpan(0, Math.Min(b.Length, 80)))); + } + } + + FuzzRun.AssertNone(failures); + } + + [Fact] + public void CommandLines_RandomArguments_ParseAndRoundTrip() + { + FuzzRun.Require(); + var r = FuzzRun.Random(21); + var words = new[] { "--install", "--uninstall", "--silent", "--relaunch", "--scope=all", "--scope=user", "--scope=", "--scope=ME", "/install", "/SILENT", "-install", "--", "", " ", "--scope=all=user", "--relaunched-from=12", "\"", "C:\\x y\\MicControl.exe" }; + var paths = new[] { null, "", "MicControl-Setup-1.0.0.exe", "C:\\Downloads\\MicControl-Setup-1.0.0 (1).exe", "/tmp/micControl-setup", "C:\\a\\MicControl.exe", "\0", "C:\\" + new string('a', 300) + "\\MicControl-Setup.exe" }; + var failures = new Dictionary(); + for (var i = 0; i < FuzzRun.Scale(20_000); i++) + { + var args = Enumerable.Range(0, r.Next(5)).Select(_ => words[r.Next(words.Length)]).ToArray(); + var path = paths[r.Next(paths.Length)]; + try + { + var command = SetupCommand.Parse(args, path); + var again = SetupCommand.Parse(command.ToArguments().Split(' ', StringSplitOptions.RemoveEmptyEntries), "MicControl.exe"); + if (command.Mode != SetupMode.None && again != command) + { + failures.TryAdd("round trip", $"{string.Join(' ', args)} [{path}] -> {command} -> {again}"); + } + } + catch (Exception ex) + { + failures.TryAdd($"{string.Join(' ', args)} [{path}]: {ex.GetType().Name}", ex.Message); + } + } + + var commands = new[] { null, "", "\"", "\"\"", "\" ", "\"C:\\a b\\x.exe\" --x", "C:\\a b\\x.exe --x", "x", "%APPDATA%\\x.exe", "\"%NOPE%\\x.exe\"", ".exe", "\0", new string('"', 1000) }; + foreach (var c in commands) + { + try + { + AutostartCommand.IsStale(c, _ => false); + AutostartCommand.GetExecutable(c); + } + catch (Exception ex) + { + failures.TryAdd($"{c ?? "null"}: {ex.GetType().Name}", ex.Message); + } + } + + FuzzRun.AssertNone(failures); + } + + [Fact] + public void Autostart_Differential() + { + FuzzRun.Require(); + var r = FuzzRun.Random(3); + var parts = new[] { "Program Files", "Users", "alex.exeter", "Tools.exe.d", "my.exec", "app-1.2.3", "MicSwitch", "A B", "x.EXE files", ".exe", "dot.ex", "e.exe1" }; + var failures = new List(); + for (var i = 0; i < FuzzRun.Scale(20_000); i++) + { + var depth = r.Next(1, 5); + var folders = Enumerable.Range(0, depth).Select(_ => parts[r.Next(parts.Length)]); + var exe = @"C:\" + string.Join('\\', folders) + @"\MicSwitch.exe"; + var quoted = r.Next(3) == 0; + var args = r.Next(3) switch { 0 => "", 1 => " --autostart", _ => " /minimized" }; + var command = (quoted ? $"\"{exe}\"" : exe) + args; + // Unquoted with a space before the real .exe is ambiguous for Windows too. + if (!quoted && exe.Contains(' ', StringComparison.Ordinal)) + { + continue; + } + + var got = AutostartCommand.GetExecutable(command); + if (got != exe) + { + failures.Add($"{command} -> {got}"); + } + } + + output.WriteLine($"{failures.Count} wrong"); + foreach (var f in failures.Distinct().Take(15)) + { + output.WriteLine(f); + } + + FuzzRun.AssertNone(failures.Distinct().Take(15).Select((f, i) => KeyValuePair.Create($"#{i + 1}", f)).ToDictionary()); + } + + public static TheoryData HugeSettings() => ["deep", "manyHotkeys", "longString", "manySounds", "bigNumbers"]; + + [Theory] + [MemberData(nameof(HugeSettings))] + public void Settings_HugeOrDeep_LoadQuickly(string kind) + { + FuzzRun.Require(); + var json = kind switch + { + "deep" => "{\"setupCompleted\":true,\"x\":" + new string('[', 100000) + new string(']', 100000) + "}", + "manyHotkeys" => "{\"setupCompleted\":true,\"customHotkeysEnabled\":true,\"customHotkeys\":[" + + string.Join(',', Enumerable.Range(0, 200000).Select(i => "{\"action\":" + (i % 12) + ",\"hotkey\":{\"key\":\"Ctrl+F" + (i % 24 + 1) + "\"}}")) + "]}", + "longString" => "{\"setupCompleted\":true,\"microphone\":{\"deviceId\":\"" + new string('a', 50_000_000) + "\"}}", + "manySounds" => "{\"setupCompleted\":true,\"notifications\":{\"customSoundsEnabled\":true,\"muteSound\":\"" + string.Concat(Enumerable.Repeat("..\\\\", 100000)) + "x.wav\"}}", + _ => "{\"setupCompleted\":true,\"version\":1e308,\"logLevel\":99999999999999999999,\"window\":{\"bounds\":{\"left\":-1e308,\"top\":1e-320,\"width\":5e-324,\"height\":1e308}}}", + }; + var folder = Directory.CreateTempSubdirectory("miccontrol-r3-").FullName; + try + { + File.WriteAllText(Path.Combine(folder, "settings.json"), json); + var watch = Stopwatch.StartNew(); + var store = new SettingsStore(folder, Path.Combine(folder, "none")); + var loaded = store.Load(); + output.WriteLine($"{kind}: {watch.ElapsedMilliseconds} ms, error {store.LoadError?.GetType().Name}, hotkeys {loaded.CustomHotkeys.Count}"); + store.Save(loaded); + output.WriteLine($"{kind}: saved after {watch.ElapsedMilliseconds} ms"); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(20)); + } + finally + { + Directory.Delete(folder, true); + } + } + + [AvaloniaFact] + public void HotkeyStateMachine_Fuzz() + { + FuzzRun.Require(); + var failures = new List(); + for (var seed = 0; seed < FuzzRun.Scale(300); seed++) + { + var r = FuzzRun.Random(1000 + seed); + using var harness = new TestHarness(s => + { + s.Microphone.Hotkey.Key = HotkeyGesture.Parse("MouseXButton1"); + s.Microphone.Hotkey.AlternativeKey = HotkeyGesture.Parse("F13"); + s.CustomHotkeysEnabled = true; + s.CustomHotkeys.Add(new CustomHotkey { Action = HotkeyAction.PushToTalk, Hotkey = new HotkeySettings { Key = HotkeyGesture.Parse("MouseXButton2") } }); + s.CustomHotkeys.Add(new CustomHotkey { Action = HotkeyAction.SpeakerVolumeUp, Hotkey = new HotkeySettings { Key = HotkeyGesture.Parse("MouseMiddle") } }); + }); + var vm = harness.ViewModel; + var settings = harness.Settings.Current; + var keys = new[] { settings.Microphone.Hotkey, settings.CustomHotkeys[0].Hotkey, settings.CustomHotkeys[1].Hotkey }; + var trace = new List(); + for (var step = 0; step < 40; step++) + { + switch (r.Next(6)) + { + case < 3: + var k = r.Next(keys.Length); + var p = r.Next(2) == 0; + trace.Add($"key{k}:{p}"); + harness.Hotkeys.Trigger(keys[k], p); + break; + case 3: + trace.Add("customToggle"); + vm.CustomHotkeysEnabled = !vm.CustomHotkeysEnabled; + break; + case 4: + trace.Add("rowToggle"); + var row = vm.CustomHotkeys[r.Next(vm.CustomHotkeys.Count)]; + row.IsEnabled = !row.IsEnabled; + break; + default: + // A click on Unmute would make the mic live on purpose, so only the mode changes here. + trace.Add("mode"); + vm.MuteMode = r.Next(2) == 0 ? MuteMode.PushToTalk : MuteMode.ToggleMute; + break; + } + + Dispatcher.UIThread.RunJobs(); + } + + // Release everything; in push-to-talk the mic must end muted and the volume must stop moving. + vm.MuteMode = MuteMode.PushToTalk; + foreach (var key in keys) + { + harness.Hotkeys.Trigger(key, false); + } + + Dispatcher.UIThread.RunJobs(); + var volume = harness.Devices.Speaker.Volume; + Thread.Sleep(150); + Dispatcher.UIThread.RunJobs(); + if (harness.Devices.Microphone.Mute != true || harness.Devices.Speaker.Volume != volume) + { + failures.Add($"seed {seed}: mute {harness.Devices.Microphone.Mute}, volume {volume}->{harness.Devices.Speaker.Volume}: {string.Join(' ', trace)}"); + } + } + + output.WriteLine($"{failures.Count} failing seeds"); + foreach (var f in failures.Take(5)) + { + output.WriteLine(f); + } + + FuzzRun.AssertNone(failures.Distinct().Take(15).Select((f, i) => KeyValuePair.Create($"#{i + 1}", f)).ToDictionary()); + } +} diff --git a/docs/fuzzing-report.md b/docs/fuzzing-report.md new file mode 100644 index 0000000..686245f --- /dev/null +++ b/docs/fuzzing-report.md @@ -0,0 +1,189 @@ +# MicControl fuzzing log + +One section per round, newest last. Each round reads this log first, so it attacks what earlier rounds missed and what changed since the last one (the `fuzz-round` skill does this). + +**Run the standing suite after a build** (from the repo root): `powershell -ExecutionPolicy Bypass -File tools\Fuzz.ps1` (`-Scale 5` for more cases, `-Seed N` to repeat a failing run). It runs the opt-in tests in `Sources/MicControl.Tests/Fuzz/` (`MICCONTROL_FUZZ=1`) and `tools/FuzzWav.cs`. On Linux: `MICCONTROL_FUZZ=1 dotnet test --project Sources/MicControl.Tests -- --filter-namespace MicControl.Tests.Fuzz` and `dotnet run tools/FuzzWav.cs`. + +| Round | Date | Covered up to | Found | +| --- | --- | --- | --- | +| 1 | Oct 19 2026 | `e084b3a` | 3 crashes, 2 smaller issues | +| 2 | Oct 19 2026 | `a700932` | 3 crashes, 2 smaller issues | +| 3 | Oct 19 2026 | `c25f946` | 3 issues, 1 cap; 1 open (two keys of one hotkey) | + +# Round 1: settings, imports, hotkeys, signatures + +Date: Oct 19 2026 · Branch `fix/fuzzing-hardening` · PR [#39](https://github.com/ChaoticHolon/MicControl/pull/39) into `dev` + +## Summary + +I ran 7 stress iterations, under the limit of 15. They found 3 distinct crashes, which is where the plan's circuit breaker stops, plus 2 smaller robustness issues. All five are fixed, each one has a regression test, and the full suite passes on Linux: 272 tests, 267 passed, 5 skipped (the screenshot tests). The PR's CI covers Windows. + +Everything ran in the cloud container (.NET 10.0.402 SDK, headless Avalonia). `%APPDATA%\MicControl\logs` only exists on Windows, so I took exceptions from the test output and from `Dispatcher.UIThread.UnhandledException`. Nothing needed a session on the developer's PC. + +## What was tested + +| Target | Method | Volume | Result | +|---|---|---|---| +| Settings JSON (`SettingsStore.Load`/`Save`, `Normalize`) | Seeded mutations of two seed files: hostile tokens swapped in for any value (null, ±1e308, 1e39, 2^31, -2^31-1, `[]`, `{}`, enum names in the wrong place, …), byte noise, truncation, 1-200 levels of nesting, lone-surrogate escapes. Each loaded file must also save and load again and meet range checks | 20,000 per run | **2 crashes**, plus out-of-range values | +| Locked or unreadable `settings.json` | File opened with `FileShare.None`; a folder where the file should be | 2 cases | **1 crash** | +| MicSwitch 1.x import (`LegacyConfigMigrator`) | Same mutations on a full 1.x config (all four config types, icons, bounds), saved as UTF-16 and UTF-8 | 5,000 per run | Whole import lost on one bad entry; huge numbers crashed the first save | +| Hotkey text (`HotkeyGesture`) | Random strings built from modifier names, key names, aliases, `+`, whitespace, control characters, non-ASCII and surrogates. Round trips through `ToString` and through the JSON file | 100,000 per run | No crashes. 2 round-trip bugs | +| Release signatures (`SshSignature`, `Ed25519`) | Bit flips, truncation, extensions and hostile length prefixes (0xFFFFFFFF, 0x7FFFFFFF, 0x80000000, 0) on a real SSHSIG; garbage armour, keys and namespaces; 300 random raw Ed25519 keys and signatures | ~2,500 | **Clean**: never threw, never accepted a mutation | +| Update feed (`ReleaseFeed.Parse`, `FindSha512`, `TryParseVersion`) | Mutated GitHub release JSON; edge-case tags | 20,000 | **Clean**: only `JsonException`/`InvalidOperationException`, which `IsUpdateFailure` already catches | +| Headless UI | 8 threads posting 3,200 random steps to the UI thread (as the Windows hook and Core Audio callbacks do): hotkey presses and releases on the main key and every custom action, mode changes, device unplug/replug and list changes, device selection, page switches, mute toggles, custom hotkeys on/off, overlay visibility, mic volume. Main window and overlay open. 3 seeds | 9,600 steps | **Clean**. Push-to-talk always settles muted | +| Hand-edited settings opening every window | 16 out-of-range files: each opens the main window and overlay, visits every page, toggles, presses the hotkey and saves | 16 | **2 crashes** (the same as rows 1 and 3 above) | + +## What crashed, and the fixes + +1. **Infinity in settings made every save throw (crash).** A float too large for its type, such as `"notifications": {"volume": 1e39}` or the mic volume, reads as infinity. System.Text.Json then refuses to write infinity (`ArgumentException`), and `SettingsService.Flush` only catches IO errors, so the first save from the timer crashes the app. The same crash hit a MicSwitch import with `"Volume": 1e300` on its very first start (`Save(migrated)` in `Load`). + *Fix:* `AppSettings.Normalize` clamps volumes to 0-1, overlay opacity to 0.2-1 and scale to 0.6-2.5 (new `OverlaySettings.Min/Max*` constants, now also used by the sliders' view model). A MicSwitch import is normalized before it's saved. +2. **Negative window size crashed the main window.** `window.bounds` with a negative width threw `ArgumentException: -800 is not a valid value for 'Width'` in the `MainWindow` constructor, so the app couldn't start. + *Fix:* `Normalize` drops window, overlay and monitor bounds that are infinite or not positive, and the default placement is used instead. +3. **A locked `settings.json` crashed startup.** `SettingsStore.Load` didn't catch `IOException` or `UnauthorizedAccessException`, so a file held by a backup, sync or antivirus tool stopped the app. The `.corrupt` copy could fail the same way. + *Fix:* the read is retried 5 times, 200 ms apart. If it's still locked, defaults load for the session with setup marked done (no wizard and no first-run autostart), `SettingsStore.LoadError` is set, `Save` leaves the file alone, and `App` logs a warning. The file is now read once and parsed from memory, which still skips a UTF-8 BOM (a test covers it). If the `.corrupt` copy can't be written, the original is protected the same way. A failed save of a MicSwitch import no longer crashes; the next change saves it. + +Smaller issues, also fixed: + +4. **MicSwitch import: one bad entry lost everything.** A non-object item, `MicrophoneLineId` given as a string, a `DeviceId` given as a number, or an icon that isn't valid base64 threw. `TryMigrate` caught the exception, so the user silently got no import at all. Entries, fields and icons are now skipped one at a time, and `UnauthorizedAccessException` (unreadable `config.cfg`) is caught as well. +5. **Hotkeys that didn't survive a save.** `"+None"` parsed as a key named "None", which prints as `None` and loads back as no hotkey. A key containing half a surrogate pair was saved as U+FFFD, a different key. `TryParse` now rejects a key named None and keys with control characters or lone surrogates. + +Unknown enum values (`"muteMode": 99`, `"logLevel": 77`, a custom hotkey `"action": 1234`) didn't crash anything I could reach. `Normalize` resets them to defaults anyway (an unknown custom action drops that row), because `MuteModes.First(...)` in `MicrophoneStatusDetail` throws for an unknown mode. + +## Tests kept and removed + +Kept in the PR as regression tests: +- `SettingsTests`: locked file, BOM file, out-of-range values clamped and saveable, MicSwitch config with huge numbers, one malformed MicSwitch entry (4 cases). +- `HotkeyGestureTests`: new junk cases and a lone-surrogate case. +- `Ui/UiStressTests`: the multi-threaded stress loop (one seed, about 1 s) and the 16 hand-edited settings files. +- `TestHarness`: a new `settingsJson` parameter for starting from a file's exact text. + +Removed from the repo: the 20,000-iteration fuzz harness. It adds about 20 s and was only needed to find these problems. A copy is in [docs/fuzzing/CoreFuzzTests.cs](/mnt/project-files/docs/fuzzing/CoreFuzzTests.cs) for re-running: copy it into `Sources/MicControl.Tests/Fuzz/` and run `dotnet test --project MicControl.Tests -- --filter-class MicControl.Tests.Fuzz.CoreFuzzTests`. All of its targets pass with the fixes. + +## Remaining stability recommendations + +- `SettingsService.Flush` still catches only IO errors. If a future setting adds a value JSON can't write, the save-timer crash comes back. Consider catching `ArgumentException`/`NotSupportedException` there too and logging them. +- These tests don't cover the Windows-only code: low-level hooks under real input floods, Core Audio device churn with COM exceptions, and `WindowGuard`. The fakes model the dispatcher marshalling but not COM failures. A short manual pass on the PC (plug and unplug a USB mic while spamming push-to-talk) would close that gap. +- `ReleaseFeed.Parse` is safe only because callers catch `InvalidOperationException`. Making it return `null` for a feed of the wrong shape would remove that dependency. +- The overlay's custom icon paths and user-added sounds aren't fuzzed: corrupt image or WAV files, or paths that point at folders. That's a good next target. + +--- + +# Round 2: going deeper + +Date: Oct 19 2026 · Branch `fix/fuzzing-round-2` · PR [#40](https://github.com/ChaoticHolon/MicControl/pull/40) into `dev` + +## Strategy + +Round 1 covered the settings, import, hotkey and signature parsers, and the Core parsers came out clean. So round 2 went after input that reaches the app **from files and from other programs**, which round 1 didn't touch: + +- WAV files, through the real NAudio code that plays them (structure-aware mutations of the shipped cues). +- Custom icons, including decompression bombs, run under a memory cap like a PC with less free RAM. +- MicSwitch's sound folder. +- Combinations of an update feed and a signature, rather than each one separately. +- Setup and autostart command lines. +- Shutdown races. +- Round 1's new lock/retry path, run concurrently. +- A property check of every key Avalonia can report against the Windows virtual-key map. + +The WAV fuzzer runs on Linux because `NAudio.Core` is managed code. It calls the player's real catch filter by reflection. + +Round 2 used 8 iterations. Like round 1, it stopped at 3 distinct crashes, plus 2 smaller issues. All of them are fixed, and 290 tests run on Linux: 285 pass and 5 screenshot tests are skipped. + +## What was tested + +| Target | Method | Volume | Result | +|---|---|---|---| +| WAV playback (`WasapiSoundPlayer` → NAudio `WaveFileReader` → `SampleChannel`, read to the end) | Byte noise, truncation, hostile 32-bit fields (chunk sizes, rates), 16-bit header fields (format tag, channels, bits, block align), junk chunks with hostile sizes, RIFF size; each case had a 5 s hang timeout | 20,000 + 40,000 after the fix | **Crash**: 8 exception kinds escaped the catch filter. No hangs | +| Custom overlay icons | Garbage, empty, truncated, a directory, a bad ICO, a PNG named `.ico`, a 0 × 0 PNG, a 1.5 MB PNG of 40000 × 40000; startup, mute toggles and picking each one in the dialog, with 12 GB of address space | 8 + timing | **Crash** under a memory cap; a 15-30 s UI freeze without one | +| Image header reader (new) | 200,000 structure-aware mutations of PNG, GIF, BMP, JPEG and ICO headers | 200,000 | It found 2 bugs in my own first draft, both fixed before the commit: an ICO entry pointing at itself recursed forever, and a 4G × 4G size overflowed the pixel count | +| MicSwitch sound import | A locked sound file and a folder named `x.wav` in `Resources\Notifications` | 2 | **Crash** on first start | +| Update feed + signature | A signed `SHA512SUMS` replayed onto a newer tag; setup names that differ by case, prefix or suffix; an `https` asset followed by an `http` copy; `file:` and userinfo URLs; path traversal in an asset name; tags with leading zeros or 4 parts; `SHA512SUMS` edge cases (CRLF, BOM, tab, short hash, duplicate lines) | 25 | Secure: a replayed or misnamed checksum file never verifies anything. **A BOM** hid the setup's line | +| Setup and autostart command lines | Random combinations of every switch, `/x` forms, `--scope=` variants, empty or quote-only tokens and odd executable paths; parse then round-trip | 20,000 + 13 | Clean | +| Hotkey → Windows key map | Every `Avalonia.Input.Key` name, as `HotkeyBox` stores it, looked up in `VirtualKeys` | 260 names | **46 never fired**, including Japanese, Korean and Brazilian keys | +| Shutdown race | Hotkey presses, device changes and a running volume-repeat timer, all posted and then the view model disposed before they run | 150 events | Clean | +| Round 1's lock/retry path | One thread saving in a loop while another loads | 4 s | Not reachable in the app (see below) | + +## What crashed, and the fixes + +1. **A WAV NAudio can't play crashed the app from the Sounds page's Play button.** `WasapiSoundPlayer.Play` caught only COM, IO, InvalidOperation and Format errors. NAudio also throws `ArgumentException` ("Unsupported source encoding" for perfectly valid ADPCM and µ-law WAVs, and "Must read complete blocks"), `InvalidDataException` (bad chunk sizes), `DivideByZeroException` (block align 0), `OverflowException` and `ArgumentOutOfRangeException`. The Play button is an `AsyncRelayCommand`, so those reached the dispatcher and crashed the app. On mute and unmute the same failure was a silently missing cue. Such files reach the folder through the MicSwitch import, which copies them unchecked, or by hand. + *Fix:* a single `IsUnplayable` filter, which 40,000 mutated files confirm catches everything, also used when adding a sound. +2. **A huge custom icon stopped every start.** Skia reports pixels it can't allocate as a plain `System.Exception`, which `LoadImage`'s filter didn't catch. It threw from the view model's constructor, and because the path is saved in settings, it happened on every launch. With enough memory it didn't crash, but each decode froze the UI for about 15 s and held gigabytes. Picking such an image froze the UI long enough to set off the `UiWatchdog`. + *Fix:* the new Core `Imaging/ImageSize` reads the size from the header first and refuses anything over 64 megapixels, which still allows a 48 MP phone photo, or a size of 0. The allocation failure is caught too. +3. **A locked or unreadable MicSwitch sound stopped the first start.** `SettingsStore.CopyDirectory` ran before the import was saved, with no error handling, so the crash repeated on every start until the file came free. + *Fix:* the copy goes one file at a time, skips files it can't copy and never fails the load. + +Smaller issues, also fixed: + +4. **Hotkeys on IME and regional keys never fired.** Japanese 変換/無変換 (`ImeConvert`/`ImeNonConvert`) are popular push-to-talk keys. They, the Korean and Kana mode keys, ABNT C1/C2 and the OEM/DBE keys under the names Avalonia's `ToString()` picks (for example `DbeNoRoman` for Attn) all mapped to virtual key 0. The 24 names still unmapped don't exist on PC keyboards: TV remote keys, Fn arrows and `System`. +5. **`SHA512SUMS` with a byte order mark.** The setup's line went unmatched, so the update would never be offered. The release workflow writes the file without a BOM, so this can't happen today, but a hand-made file from Windows PowerShell 5.1 would have one. It is now ignored, which is safe because the signature covers the exact bytes. + +## Checked and found sound + +- A signed checksums file from one release can't make another release verify. The setup's name comes from the tag, and the signed file has to list exactly that name. +- An `http` copy listed after the `https` setup cancels the offer instead of downgrading to it. `file:` URLs and asset names with a path in them are refused. +- Hotkey and device events queued behind shutdown don't throw. +- A 6000 × 4000 photo as the icon adds about 0.2 s to startup and about 1 ms per mute toggle. +- On Linux, a load racing a save in another thread can briefly see no file at all, and loads defaults. The app can't hit this: it loads settings once, at startup, in the single instance, and on Windows the same race shows up as a sharing violation that round 1's retry handles. I'm noting it for the Linux port. + +## Files + +- Kept in the PR: `ImageSizeTests`, new cases in `SettingsTests`, `ReleaseFeedTests` and `UiStressTests` (bad icons, dispose race). +- Archived outside the repo: [docs/fuzzing/Round2Tests.cs](/mnt/project-files/docs/fuzzing/Round2Tests.cs) (the full round-2 harness), and [docs/fuzzing/WavFuzz.Program.cs](/mnt/project-files/docs/fuzzing/WavFuzz.Program.cs) with [wavfuzz.csproj](/mnt/project-files/docs/fuzzing/wavfuzz.csproj) (the WAV and key-map fuzzer: build it with `dotnet build` and run the dll with a case count). + +## Recommendations for round 3 + +- Adding a sound converts it through Media Foundation, and the Core Audio paths can throw COM errors. Both are Windows-only and need a session on the PC: fuzz MP3 and M4A files through `ConvertToWav`, and plug and unplug devices while sounds play. +- `MicSwitch`'s copied sounds are still played unchecked. A quick header check when importing or adding, like the one icons now get, would give a clear message instead of a silent failure. +- Settings export and import (a planned 1.x feature) and the local control API (OSC, named pipe) will be the next parsers to take outside input. Fuzz them as they're written. + +# Round 3: state, the folder around the app, and its neighbours + +## Strategy + +Rounds 1 and 2 broke file parsers (settings, MicSwitch configs, WAVs, icons). Round 3 aimed elsewhere: + +- **State over time**, not single inputs: a model-based fuzzer drove the hotkeys through random presses, releases, mode changes and switches turned on and off, then checked that push-to-talk ends muted and the volume stops changing. +- **The folder around the app**: what happens when the settings folder, or a folder inside it, can't be created. +- **Other programs' data the app acts on**: the MicSwitch autostart entries MicControl takes over, fuzzed against a reference of what the path really is. +- **Size and depth**: settings files with 100,000 nested brackets, 200,000 custom hotkeys, a 50 MB device id, extreme numbers. + +To make the hotkey fuzzer honest, the test fake now behaves like the real Windows hook: a repeated press is ignored and a release only reaches the registration that saw the press. With the old fake, the stuck-live bug below could never show. + +## What was tested + +| Area | How | Cases | Result | +| --- | --- | --- | --- | +| MicSwitch autostart takeover (`AutostartCommand`) | Differential fuzz: random paths with ".exe" inside folder names, quoted and unquoted | 20,000 | 5,255 wrong (bug 1), 0 after the fix | +| Hotkey state machine | Model-based: 300 seeds × 40 steps of presses, releases, switches, modes | 300 sequences | 53 seeds left the mic live (bug 2), 0 after the fix | +| Held volume key | Hotkeys changed while held | 1 | Repeat timer kept running (bug 2), stops after the fix | +| Settings folder can't be created | A file where `logs` or `Icons` goes | 3 paths | Start, icon picker and Open logs folder crashed (bug 3), all handled after the fix | +| Setup command line (`SetupCommand`) | Random argument lists, round trip through `ToArguments` | 50,000 | Sound | +| Settings size and depth | Deep nesting, 200k hotkeys, 50 MB string, 1e308 numbers | 5 files | No crash; slowest load 2.5 s (50 MB) | +| Hotkey text | Random Unicode (combining marks, zero-width, CJK) | 200,000 | Sound: everything parsed round-trips | +| Many custom hotkeys in the UI | Opening the Hotkeys page | 100 to 5,000 rows | Slow (see below) | + +## What crashed or misbehaved, and the fixes + +1. **A working MicSwitch lost its autostart.** On first run MicControl takes over MicSwitch's autostart entry only when MicSwitch's program is gone. For an unquoted command, the path was cut at the first ".exe" anywhere, so `C:\Users\alex.exeter\AppData\Local\MicSwitch\MicSwitch.exe` was read as `C:\Users\alex.exe`. That doesn't exist, so the entry was judged stale and deleted, even though MicSwitch was installed. User names and folders like `alex.exeter`, `Tools.exe.d` or `.exe` all triggered it. Fix: ".exe" must end a name (followed by a space or the end). +2. **Push-to-talk stayed live after letting go.** Changing any hotkey setting (turning Custom hotkeys on or off, switching a row, editing a row) re-registers every hotkey. The new registration never saw the press, so it ignored the release. A keyboard key's auto-repeat hides this, but a mouse button doesn't repeat: with push-to-talk on a mouse side button, clicking a setting while holding it left the mic live until the next press. A held volume key on a mouse button kept the volume repeating the same way. Fix: re-applying the hotkeys now ends any hold (`MainViewModel.HotkeyRegistration`), so push-to-talk fails muted. +3. **A settings folder that can't be written stopped the app.** If the logs folder couldn't be created (a file in its place, or a roaming AppData redirected to a network share that's offline), the logger threw at startup and the app never started. Picking an overlay icon and About → Open logs folder crashed the same way when their folder couldn't be created. Fix: the logger starts anyway and drops lines until the file can be opened, and the other two show a message. + +The circuit breaker stopped the round here (three distinct issues). + +## Found but not changed + +- **Two keys of one hotkey held together.** When the main key and the alternative key are both held, letting go of either one releases the hotkey. In push-to-talk that mutes early (safe); in push-to-mute it goes live while the other key is still down. The fix is in the Windows hook (`GlobalHotkeyService.Process`: only the key that pressed it should release it), which can't run on Linux, so it needs a test on the PC. +- **Very many custom hotkeys** (fixed after the round, at the developer's request). Each Hotkeys page row costs about 33 ms to build headlessly: 1,000 rows took 33 s and 5,000 took 163 s, long enough for the UI watchdog to restart the app. Custom hotkeys are now capped at 100 (`AppSettings.MaxCustomHotkeys`): a longer hand-edited list is cut on load, and Add is disabled at the cap with a note saying why. Test: `UiStressTests.CustomHotkeys_CappedOnLoadAndOnAdd`. +- **Exclusive hotkeys and Win+L.** Still not released by the missed key-up check, as documented: a swallowed key never shows as down. + +## Files + +- Fixes: `AutostartCommand.cs`, `MainViewModel.cs`, `MainViewModel.Overlay.cs`, `MainViewModel.AboutAndSetup.cs`, `FileLoggerProvider.cs`; CLAUDE.md's held-hotkey line. +- Kept tests: `AutostartCommandTests` (3 paths, one installed-MicSwitch case), `UiStressTests.PushToTalk_HeldOnAMouseButton_WhileHotkeysChange_EndsMuted`, `UiStressTests.SettingsFolderCantBeWritten_PickIconAndOpenLogs_ShowAMessage`, `FileLoggerTests.LogsFolderCantBeCreated_StillStarts_AndDropsLines`, and the stricter `FakeHotkeys`. +- Archived scratch fuzzers: `/mnt/project-files/docs/fuzzing/Round3Tests.cs`. +- Final run: 298 tests, 293 passed, 5 skipped (screenshots), 0 failed. + +## Recommendations for round 4 + +- Fix and test the two-keys release on the PC, with a session on the developer's device, and fuzz `GlobalHotkeyService` with real `SendInput` events there. +- Still open from round 2: Media Foundation conversion of MP3/M4A files when adding a sound, and unplugging devices during playback, both on Windows. diff --git a/tools/Fuzz.ps1 b/tools/Fuzz.ps1 new file mode 100644 index 0000000..4dd6dd7 --- /dev/null +++ b/tools/Fuzz.ps1 @@ -0,0 +1,48 @@ +# Runs MicControl's fuzz suite after a build: the opt-in Fuzz tests (parsers, command lines, autostart takeover, huge +# settings files, the hotkey state machine) and the WAV decoding fuzzer. Each run uses a new seed unless -Seed is given; +# a failure prints the seed that repeats it. Works in Windows PowerShell 5.1 and PowerShell 7. +# +# powershell -ExecutionPolicy Bypass -File tools\Fuzz.ps1 # one pass, new seed +# powershell -ExecutionPolicy Bypass -File tools\Fuzz.ps1 -Scale 5 # five times the cases +# powershell -ExecutionPolicy Bypass -File tools\Fuzz.ps1 -Seed 1234 # repeat a failing run +param( + [double]$Scale = 1, + [int]$Seed = 0, + [int]$WavCases = 20000 +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +Push-Location $root +$saved = @{} +$names = 'MICCONTROL_FUZZ', 'MICCONTROL_FUZZ_SEED', 'MICCONTROL_FUZZ_SCALE', 'DOTNET_CLI_TELEMETRY_OPTOUT', 'TESTINGPLATFORM_TELEMETRY_OPTOUT', 'AVALONIA_TELEMETRY_OPTOUT', 'DOTNET_NOLOGO' +foreach ($name in $names) { $saved[$name] = [Environment]::GetEnvironmentVariable($name) } +try { + if ($Seed -eq 0) { $Seed = Get-Random -Minimum 1 -Maximum ([int]::MaxValue) } + $env:MICCONTROL_FUZZ = '1' + $env:MICCONTROL_FUZZ_SEED = "$Seed" + $env:MICCONTROL_FUZZ_SCALE = $Scale.ToString([Globalization.CultureInfo]::InvariantCulture) + $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' + $env:TESTINGPLATFORM_TELEMETRY_OPTOUT = '1' + $env:AVALONIA_TELEMETRY_OPTOUT = '1' + $env:DOTNET_NOLOGO = '1' + + Write-Host "Fuzz suite, seed $Seed, scale $Scale" + dotnet test --project Sources/MicControl.Tests -- --filter-namespace MicControl.Tests.Fuzz + $tests = $LASTEXITCODE + + dotnet run tools/FuzzWav.cs $([int]($WavCases * $Scale)) $Seed + $wav = $LASTEXITCODE + + if ($tests -eq 0 -and $wav -eq 0) { + Write-Host "Fuzzing passed (seed $Seed)." -ForegroundColor Green + exit 0 + } + + Write-Host "Fuzzing found problems (seed $Seed): tests exit $tests, WAV fuzzer exit $wav. Rerun with -Seed $Seed to repeat." -ForegroundColor Red + exit 1 +} +finally { + foreach ($name in $names) { [Environment]::SetEnvironmentVariable($name, $saved[$name]) } + Pop-Location +} diff --git a/tools/FuzzWav.cs b/tools/FuzzWav.cs new file mode 100644 index 0000000..7374c2c --- /dev/null +++ b/tools/FuzzWav.cs @@ -0,0 +1,108 @@ +// Fuzzes the notification sound player's decoding path with damaged WAV files: WaveFileReader → SampleChannel, as +// WasapiSoundPlayer.Play reads them, failing on any exception IsUnplayable doesn't catch and on any file that hangs. +// Also lists Avalonia keys with no virtual key (a hotkey on one would never fire). Runs on Windows and Linux. +// Run from the repository root: dotnet run tools/FuzzWav.cs [cases] [seed] (tools/Fuzz.ps1 runs it too) +// Failing files are written to artifacts/fuzz/ so they can be replayed. +#:project ../Sources/MicControl.Windows/MicControl.Windows.csproj +#:project ../Sources/MicControl.UI/MicControl.UI.csproj +#:property TargetFramework=net10.0-windows10.0.19041.0 +#:property EnableWindowsTargeting=true +#:property PublishAot=false + +using NAudio.Wave; +using NAudio.Wave.SampleProviders; + +var seedFolder = Path.Combine("Sources", "MicControl.UI", "Assets", "Notifications"); +var seeds = Directory.GetFiles(seedFolder, "*.wav").Select(File.ReadAllBytes).ToArray(); +var n = args.Length > 0 ? int.Parse(args[0], System.Globalization.CultureInfo.InvariantCulture) : 20000; +var seed = args.Length > 1 ? int.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture) : Random.Shared.Next(); +var r = new Random(seed); +Console.WriteLine($"WAV fuzz: {n} cases, seed {seed}"); +var failures = new Dictionary(); +var hangs = 0; + +// Play() in WasapiSoundPlayer: WaveFileReader → SampleChannel → WasapiOut pulls samples. Only these are caught: +var filter = typeof(MicControl.Windows.WasapiSoundPlayer).GetMethod("IsUnplayable", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; +bool Caught(Exception ex) => (bool)filter.Invoke(null, [ex])!; + +byte[] Mutate(byte[] seed) +{ + var b = (byte[])seed.Clone(); + switch (r.Next(6)) + { + case 0: for (var i = r.Next(1, 8); i > 0; i--) b[r.Next(b.Length)] = (byte)r.Next(256); break; + case 1: Array.Resize(ref b, r.Next(b.Length)); break; + case 2: + // Hostile 32-bit field anywhere in the first 64 bytes (chunk sizes, rates, block align). + var at = r.Next(0, 60); + var v = new uint[] { 0, 1, 0xFFFFFFFF, 0x7FFFFFFF, 0x80000000, 0xFFFFFFF0, 2, 3 }[r.Next(8)]; + BitConverter.GetBytes(v).CopyTo(b, at); + break; + case 3: + // A 16-bit header field (format tag, channels, bits, block align, extra size). + var at16 = new[] { 20, 22, 32, 34, 36 }[r.Next(5)]; + BitConverter.GetBytes((ushort)new[] { 0, 1, 2, 3, 6, 7, 0x11, 0xFFFE, 0xFFFF, 255, 64, 24, 32 }[r.Next(13)]).CopyTo(b, at16); + break; + case 4: + // An extra chunk with a hostile size before "data". + var extra = new byte[8 + r.Next(0, 16)]; + "junk"u8.CopyTo(extra); + BitConverter.GetBytes(new uint[] { 0xFFFFFFFF, 0x7FFFFFFF, 0x80000000, 0, 0xFFFFFFF8 }[r.Next(5)]).CopyTo(extra, 4); + b = [.. b[..36], .. extra, .. b[36..]]; + break; + default: + BitConverter.GetBytes(new uint[] { 0, 0xFFFFFFFF, 0x7FFFFFFF, (uint)b.Length }[r.Next(4)]).CopyTo(b, 4); // RIFF size + break; + } + + return b; +} + +for (var i = 0; i < n; i++) +{ + var data = Mutate(seeds[r.Next(seeds.Length)]); + var task = Task.Run(() => + { + using var reader = new WaveFileReader(new MemoryStream(data)); + var samples = new SampleChannel(reader) { Volume = 1 }; + var buffer = new float[4800]; + long total = 0; + int read; + while ((read = samples.Read(buffer, 0, buffer.Length)) > 0 && total < 10_000_000) + { + total += read; + } + }); + bool finished; + try { finished = task.Wait(TimeSpan.FromSeconds(5)); } catch (AggregateException) { finished = true; } + if (!finished) + { + hangs++; + failures.TryAdd("HANG", ("did not finish in 5 s", data)); + continue; + } + if (task.Exception?.InnerException is { } ex && !Caught(ex)) + { + var key = $"{ex.GetType().Name} @ {ex.StackTrace?.Split('\n').FirstOrDefault()?.Trim()}"; + failures.TryAdd(key, (ex.Message, data)); + } +} + +Console.WriteLine($"WAV cases: {n}, hangs: {hangs}, uncaught kinds: {failures.Count}"); +foreach (var (k, (m, d)) in failures) +{ + Console.WriteLine($"- {k}\n {m}\n header: {Convert.ToHexString(d.AsSpan(0, Math.Min(64, d.Length)))}"); + Directory.CreateDirectory(Path.Combine("artifacts", "fuzz")); + File.WriteAllBytes(Path.Combine("artifacts", "fuzz", $"wav-{seed}-{failures.Keys.ToList().IndexOf(k)}.wav"), d); +} + +// Every key Avalonia's HotkeyBox can produce (Key.ToString()) must map to a virtual key, or that hotkey never fires. +var vk = typeof(MicControl.Windows.GlobalHotkeyService).Assembly.GetType("MicControl.Windows.VirtualKeys")!.GetMethod("FromKeyName")!; +// These have no Windows virtual key at all (Avalonia keeps them for other platforms and TV remotes), so Windows never sends them. +string[] noWindowsKey = ["LineFeed", "System", "DeadCharProcessed", "FnLeftArrow", "FnRightArrow", "FnUpArrow", "FnDownArrow", "MediaHome", + "MediaChannelList", "MediaChannelRaise", "MediaChannelLower", "MediaRecord", "MediaRed", "MediaGreen", "MediaYellow", "MediaBlue", + "MediaMenu", "MediaMore", "MediaOption", "MediaInfo", "MediaSearch", "MediaSubtitle", "MediaTvGuide", "MediaPreviousChannel"]; +var unmapped = Enum.GetValues().Distinct().Select(k => k.ToString()).Distinct() + .Where(name => (int)vk.Invoke(null, [name])! == 0 && name != "None" && !noWindowsKey.Contains(name)).ToList(); +Console.WriteLine($"Avalonia keys with no virtual key: {unmapped.Count}\n {string.Join(", ", unmapped)}"); +return failures.Count == 0 && unmapped.Count == 0 ? 0 : 1;