Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
</ItemGroup>

<ItemGroup Label="Tests">
<PackageVersion Include="FsCheck" Version="3.6.0"/>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8"/>
<PackageVersion Include="xunit.v3.mtp-v2" Version="4.0.2"/>
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.12.0"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, )",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FsCheck" PrivateAssets="all"/>
<ProjectReference Include="../../libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj"/>
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Bounded, offline fuzz properties for the text accepted at the AOB scan boundary.</summary>
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<char[]> 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<ArgumentException>(() => new AobPattern(text));
}
}));
}

[Fact]
public void GeneratedBytesAndWildcardsPreservePositionsAndCanonicalText()
{
Arbitrary<int[]> 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<byte[], ushort>((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<ArgumentException>(() => new AobPattern(text));
}
}));
}
}
14 changes: 14 additions & 0 deletions tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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, )",
Expand Down Expand Up @@ -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",
Expand Down
Loading