From 3fd8c9fa82c89bad9d4f483c44f7537a41478ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Sun, 25 Oct 2026 15:34:37 +0100 Subject: [PATCH] test: fuzz AOB pattern parsing and document enforced CI protections --- .github/workflows/scorecard.yml | 6 +- CONTRIBUTING.md | 15 +++ Directory.Packages.props | 1 + ...Engine.Client.Abstractions.Tests.lock.json | 14 +++ ...eatEngine.Client.Abstractions.Tests.csproj | 1 + .../Scanning/AobPatternPropertyTests.cs | 105 ++++++++++++++++++ .../packages.lock.json | 14 +++ 7 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternPropertyTests.cs diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f2b793c7..32f25e5a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -3,9 +3,9 @@ # `defaults`, workflow-level write permissions, `id-token: write` outside this job, `container`/`services`, `run:` # steps and actions outside its allowlist. This file therefore deliberately does NOT follow the repository's # `defaults: run: shell: pwsh` convention. Publication works from the default branch only (post-merge check). -# Expected low checks, explained rather than fixed: Code-Review (single maintainer), Branch-Protection (until branch -# protection rules are configured on this repository), Signed-Releases (until the first release), SAST (until CodeQL -# has history). +# Review-related scores may remain low for a single maintainer. Main requires the GitHub Actions CI / Gate check, +# an up-to-date branch and verified commit signatures; independent reviewer approvals remain optional. +# Repository age and an OpenSSF badge require their own evidence and must not be claimed from passing CI. name: Scorecard on: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2de3944..03711945 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,21 @@ dotnet test --project tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.cs Remove-Item Env:CHEATENGINE_CLIENT_PACKAGE_SOURCE ``` +### Parser fuzz properties + +`AobPatternPropertyTests` runs three bounded FsCheck properties in the normal Debug and Release test suites, with +1,000 generated cases per property. They check arbitrary UTF-16 against an independent pattern grammar, preserve +byte/wildcard positions through normalization, and reject injected malformed characters. FsCheck is test-only; +these checks do not load Cheat Engine or qualify native matching behavior. + +```pwsh +dotnet test --project tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj -c Debug --filter-class "*AobPatternPropertyTests" --report-trx --results-directory artifacts/test-results/fuzz --fail-skips on +``` + +Failures include FsCheck's seed and shrunk counterexample. Preserve both when investigating, replay with FsCheck's +`Config.WithReplay`, and add the minimal failing input as a named regression case. Keep arbitrary input out of test +display names so control characters and unpaired surrogates cannot corrupt test report identities. + ### Lock files Never edit a `packages.lock.json` by hand, and never let an IDE restore rewrite them. After a dependency change, diff --git a/Directory.Packages.props b/Directory.Packages.props index 0c69c10f..272437b7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/eng/locks/source/CheatEngine.Client.Abstractions.Tests.lock.json b/eng/locks/source/CheatEngine.Client.Abstractions.Tests.lock.json index 2e7e2260..dabd5ae7 100644 --- a/eng/locks/source/CheatEngine.Client.Abstractions.Tests.lock.json +++ b/eng/locks/source/CheatEngine.Client.Abstractions.Tests.lock.json @@ -2,6 +2,15 @@ "version": 2, "dependencies": { "net10.0": { + "FsCheck": { + "type": "Direct", + "requested": "[3.6.0, )", + "resolved": "3.6.0", + "contentHash": "XjZ5sWf/QKLqB9aMKG7FvrT/8Ak2fPo0ygRstindBWZWU8P3EcmvYJ5UkX0daIL4+bZeaiD9ThkftGlIx3ZewQ==", + "dependencies": { + "FSharp.Core": "5.0.2" + } + }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.12.0, )", @@ -70,6 +79,11 @@ "xunit.v3.core.mtp-v2": "[4.0.2]" } }, + "FSharp.Core": { + "type": "Transitive", + "resolved": "5.0.2", + "contentHash": "DpcajqENgb3VXaHw1FKfVS+TWwEzck1PCajqLwBAVrtd1lfxd7T8JISVZBdV1mX9+2g48+tIgpNaVJWObdMrBw==" + }, "Microsoft.ApplicationInsights": { "type": "Transitive", "resolved": "2.23.0", diff --git a/tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj b/tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj index e89c133c..d76bd274 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj +++ b/tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternPropertyTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternPropertyTests.cs new file mode 100644 index 00000000..2a5324a5 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternPropertyTests.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Scanning; + +using FsCheck; +using FsCheck.Fluent; + +namespace CheatEngine.Client.Abstractions.Tests.Scanning; + +/// Bounded, offline fuzz properties for the text accepted at the AOB scan boundary. +public sealed class AobPatternPropertyTests +{ + // FsCheck reports the random seed and shrunk counterexample on failure. Fixed Fact names keep arbitrary UTF-16 + // (including isolated surrogates and control characters) out of TRX test identities. + private static readonly Config FuzzConfiguration = Config.QuickThrowOnFailure.WithMaxTest(1000).WithEndSize(128); + + private static readonly Regex Grammar = new(@"\A(?:\s*(?:[0-9a-fA-F]{2}|\?\?))+\s*\z", + RegexOptions.CultureInvariant | RegexOptions.NonBacktracking, TimeSpan.FromSeconds(1)); + + private static readonly char[] Whitespace = Enumerable.Range(0, char.MaxValue + 1) + .Select(static value => (char) value).Where(char.IsWhiteSpace).ToArray(); + + private static readonly char[] InvalidCharacters = [',', '*', 'G', '\0', '\u200B', '\uD800', '\uDC00']; + + [Fact] + public void ArbitraryUtf16MatchesThePatternGrammarWithoutThrowing() + { + Arbitrary inputs = Arb.From(Gen.Choose(0, char.MaxValue).Select(static value => (char) value).ArrayOf(), + static value => value.Length == 0 ? [] : new[] { value[..(value.Length / 2)], value[1..] }); + + Check.One(FuzzConfiguration, Prop.ForAll(inputs, input => + { + string text = new(input); + bool accepted = AobPattern.TryParse(text, out AobPattern pattern); + Assert.Equal(Grammar.IsMatch(text), accepted); + if (accepted) + { + Assert.Equal(new AobPattern(text), pattern); + Assert.True(AobPattern.TryParse(pattern.Value, out AobPattern reparsed)); + Assert.Equal(pattern, reparsed); + } + else + { + Assert.Equal(default, pattern); + Assert.Throws(() => new AobPattern(text)); + } + })); + } + + [Fact] + public void GeneratedBytesAndWildcardsPreservePositionsAndCanonicalText() + { + Arbitrary inputs = Arb.From(Gen.Choose(0, ushort.MaxValue).ArrayOf(), + static value => value.Length == 0 ? [] : new[] { value[..(value.Length / 2)], value[1..] }); + + Check.One(FuzzConfiguration, Prop.ForAll(inputs, input => + { + int[] tokens = input.Length == 0 ? [256] : input; + StringBuilder text = new(); + string[] expected = new string[tokens.Length]; + bool wildcardOnly = true; + for (int index = 0; index < tokens.Length; index++) + { + int token = tokens[index] % 257; + expected[index] = token == 256 ? "??" : token.ToString("X2", CultureInfo.InvariantCulture); + wildcardOnly &= token == 256; + // Include concatenated tokens as well as every Unicode whitespace separator accepted by the contract. + if ((tokens[index] & 1) == 0) + { + text.Append(Whitespace[tokens[index] % Whitespace.Length]); + } + + text.Append((tokens[index] & 2) == 0 ? expected[index].ToLowerInvariant() : expected[index]); + } + + text.Append(Whitespace[tokens[0] % Whitespace.Length]); + Assert.True(AobPattern.TryParse(text.ToString(), out AobPattern pattern)); + Assert.Equal(string.Join(' ', expected), pattern.Value); + Assert.Equal(tokens.Length, pattern.ByteLength); + Assert.Equal(wildcardOnly, pattern.IsWildcardOnly); + Assert.Equal(pattern.Value, pattern.ToString()); + Assert.Equal(pattern, new AobPattern(pattern.Value)); + })); + } + + [Fact] + public void InvalidCharactersAreRejectedAtEveryGeneratedInsertionPoint() + { + Check.One(FuzzConfiguration, Prop.ForAll((bytes, offset) => + { + string valid = Convert.ToHexString(bytes.Length == 0 ? [0] : bytes); + int insertion = offset % (valid.Length + 1); + // Punctuation, non-hex letters, embedded NUL, zero-width space, and both unpaired surrogate halves. + foreach (char invalid in InvalidCharacters) + { + string text = valid.Insert(insertion, invalid.ToString()); + Assert.False(AobPattern.TryParse(text, out AobPattern pattern)); + Assert.Equal(default, pattern); + Assert.Throws(() => new AobPattern(text)); + } + })); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json b/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json index de7b849b..631ee2a4 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json @@ -2,6 +2,15 @@ "version": 2, "dependencies": { "net10.0": { + "FsCheck": { + "type": "Direct", + "requested": "[3.6.0, )", + "resolved": "3.6.0", + "contentHash": "XjZ5sWf/QKLqB9aMKG7FvrT/8Ak2fPo0ygRstindBWZWU8P3EcmvYJ5UkX0daIL4+bZeaiD9ThkftGlIx3ZewQ==", + "dependencies": { + "FSharp.Core": "5.0.2" + } + }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.12.0, )", @@ -70,6 +79,11 @@ "xunit.v3.core.mtp-v2": "[4.0.2]" } }, + "FSharp.Core": { + "type": "Transitive", + "resolved": "5.0.2", + "contentHash": "DpcajqENgb3VXaHw1FKfVS+TWwEzck1PCajqLwBAVrtd1lfxd7T8JISVZBdV1mX9+2g48+tIgpNaVJWObdMrBw==" + }, "Microsoft.ApplicationInsights": { "type": "Transitive", "resolved": "2.23.0",