From 9365a9ca08410bca38c2050e5a6c13ea9a9059c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Wed, 14 Oct 2026 22:41:11 +0200 Subject: [PATCH 1/2] Expand fuzz testing and bound malformed input handling --- CHANGELOG.md | 5 + .../ProtectedOperationCatalogParser.cs | 26 +- .../README.md | 4 + .../Program.cs | 13 +- .../ProtectedOperationFuzzProbe.cs | 245 +++++++++++++ .../README.md | 11 + .../CheatEngine.SDK.Lua.Tests.csproj | 5 + .../Marshalling/LuaIntegerFuzzTests.cs | 229 +++++++++++++ .../Protected/NativeFuzzProcessTests.cs | 87 +++++ tests/CheatEngine.SDK.Lua.Tests/README.md | 4 + .../State/LuaStringFuzzTests.cs | 98 ++++++ .../Text/Utf8ScratchFuzzTests.cs | 64 ++++ ...ne.SDK.NativeAotLoaderHarness.Tests.csproj | 4 + .../PortableExecutableFuzzTests.cs | 138 ++++++++ .../README.md | 6 + .../PortableExecutableExportReader.cs | 14 +- .../Catalog/CatalogParserFuzzTests.cs | 321 ++++++++++++++++++ ...eGenerators.LuaBridgeContract.Tests.csproj | 1 + .../README.md | 6 + 19 files changed, 1271 insertions(+), 10 deletions(-) create mode 100644 tests/CheatEngine.SDK.Lua.FailureProbe/ProtectedOperationFuzzProbe.cs create mode 100644 tests/CheatEngine.SDK.Lua.Tests/Marshalling/LuaIntegerFuzzTests.cs create mode 100644 tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFuzzProcessTests.cs create mode 100644 tests/CheatEngine.SDK.Lua.Tests/State/LuaStringFuzzTests.cs create mode 100644 tests/CheatEngine.SDK.Lua.Tests/Text/Utf8ScratchFuzzTests.cs create mode 100644 tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/PortableExecutableFuzzTests.cs create mode 100644 tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Catalog/CatalogParserFuzzTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f2098de..3c3a3407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ under [1.0.0](#100---2026-09-20) before upgrading. ### Fixed +- Bound protected-operation catalogue JSON nesting to 64 containers and preserve exact diagnostic locations for + CR-only files and Unicode line separators. Generated parser, numeric/string conversion and native operation tests + exercise the relevant input contracts. +- Make the Native AOT validation tool refuse out-of-range PE export addresses as malformed data instead of throwing + an arithmetic overflow; generated byte-input tests cover the reader without loading the images. - Keep Engine API spec diagnostics within the source line when a contract field has an empty value. - Lua operation admission rejects a host-provided state with a negative stack top and checks that runtime stamp verification preserves that top. Refusal releases the operation admission without resetting or retrying the state. diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs index 1a4f4cf0..07b41654 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs @@ -13,6 +13,7 @@ internal static class ProtectedOperationCatalogParser { private const string FileName = "protected-operations.json"; private const string ExpectedCatalogId = "cheatengine-sdk-lua-protected-operations"; + private const int MaximumJsonDepth = 64; public static bool IsCatalogFile(string path) { @@ -530,6 +531,7 @@ private sealed class JsonReader private readonly string _path; private readonly string _text; private int _position; + private int _depth; public JsonReader(string path, string text) { @@ -576,8 +578,7 @@ private bool TryParseValue(out JsonValue value, out CatalogDiagnostic? diagnosti return _text[_position] switch { - '{' => TryParseObject(out value, out diagnostic), - '[' => TryParseArray(out value, out diagnostic), + '{' or '[' => TryParseContainer(out value, out diagnostic), '"' => TryParseStringValue(out value, out diagnostic), 't' => TryParseLiteral("true", true, out value, out diagnostic), 'f' => TryParseLiteral("false", false, out value, out diagnostic), @@ -588,6 +589,21 @@ private bool TryParseValue(out JsonValue value, out CatalogDiagnostic? diagnosti }; } + private bool TryParseContainer(out JsonValue value, out CatalogDiagnostic? diagnostic) + { + if (_depth >= MaximumJsonDepth) + { + return Fail(out value, out diagnostic, "JSON nesting exceeds the maximum depth of 64."); + } + + _depth++; + bool success = _text[_position] == '{' + ? TryParseObject(out value, out diagnostic) + : TryParseArray(out value, out diagnostic); + _depth--; + return success; + } + private bool TryParseObject(out JsonValue value, out CatalogDiagnostic? diagnostic) { int start = _position++; @@ -975,12 +991,14 @@ private LinePosition GetLinePosition(int position) int character = 0; for (int i = 0; i < position; i++) { - if (_text[i] == '\n') + char characterAtPosition = _text[i]; + if (characterAtPosition is '\n' or '\u0085' or '\u2028' or '\u2029' || + (characterAtPosition == '\r' && (i + 1 == _text.Length || _text[i + 1] != '\n'))) { line++; character = 0; } - else if (_text[i] != '\r') + else { character++; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/README.md b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/README.md index 6b4f7cc4..b30d25db 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/README.md +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/README.md @@ -25,6 +25,10 @@ variables, the clock, current culture or the network. Invalid JSON, duplicate op or an incompatible bridge declaration gives a localized `CESDK4001` error on the additional file and emits nothing. Two matching catalogue files give `CESDK4002` on both files and emit nothing. +JSON may contain at most 64 nested object/array containers. Deeper input receives a localized `CESDK4001` error +before further recursion. Diagnostic locations follow Roslyn's source-text line boundaries, including CR-only files +and Unicode line separators within strings. + ## Testing ```powershell diff --git a/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs b/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs index 6fad5749..73e79ad4 100644 --- a/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs +++ b/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs @@ -17,6 +17,8 @@ internal static unsafe class Program private const string GeneratedFunctionAllocationMode = "--generated-function-allocation"; + private const string FuzzProtectedOperationsMode = "--fuzz-protected-operations"; + private const int CheckStackGrowthSlots = 4096; // Exact C11 CHEATENGINE_SDK_NO_ERROR sentinel. The internal production alias is LuaProtectedApi.NoErrorStatus. @@ -39,7 +41,7 @@ public static int Main(string[] arguments) if (!HasValidArguments(arguments)) { return Fail( - "expected the Lua DLL path, optionally followed by --checkstack-growth or --generated-function-allocation"); + "expected the Lua DLL path, optionally followed by --checkstack-growth, --generated-function-allocation, or --fuzz-protected-operations"); } nint module = 0; @@ -97,7 +99,8 @@ private static bool HasValidArguments(string[] arguments) return arguments.Length is >= 1 and <= 2 && (arguments.Length != 2 || string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) || - string.Equals(arguments[1], GeneratedFunctionAllocationMode, StringComparison.Ordinal)); + string.Equals(arguments[1], GeneratedFunctionAllocationMode, StringComparison.Ordinal) || + string.Equals(arguments[1], FuzzProtectedOperationsMode, StringComparison.Ordinal)); } private static int RunRequestedProbe(LuaState state, nint module, string[] arguments) @@ -106,7 +109,9 @@ private static int RunRequestedProbe(LuaState state, nint module, string[] argum { return string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) ? RunCheckStackGrowthProbe(state) - : RunGeneratedFunctionAllocationProbe(state); + : string.Equals(arguments[1], GeneratedFunctionAllocationMode, StringComparison.Ordinal) + ? RunGeneratedFunctionAllocationProbe(state) + : ProtectedOperationFuzzProbe.Run(state); } return RunProbe(state, module); @@ -1093,7 +1098,7 @@ private static void PopulateProtectedExports(nint module, nint* exports) exports[19] = NativeLibrary.GetExport(module, "luaL_unref"); } - private static LuaStatus CaptureMemoryException(Action operation) + internal static LuaStatus CaptureMemoryException(Action operation) { Volatile.Write(ref s_rejectAllocations, 1); try diff --git a/tests/CheatEngine.SDK.Lua.FailureProbe/ProtectedOperationFuzzProbe.cs b/tests/CheatEngine.SDK.Lua.FailureProbe/ProtectedOperationFuzzProbe.cs new file mode 100644 index 00000000..9d8288c3 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.FailureProbe/ProtectedOperationFuzzProbe.cs @@ -0,0 +1,245 @@ +using System.Globalization; + +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Lua.FailureProbe; + +/// Deterministic, process-isolated sequences for protected Lua operations and their recovery contracts. +internal static class ProtectedOperationFuzzProbe +{ + private const uint Seed = 0xD1CE_BA5E; + private const int SequenceCount = 2000; + private const int MaximumBufferLength = 1024; + private const int MaximumCapacity = 64; + private const int OperationsPerSequence = 10; + private const long Sentinel = 0x51A7_E11A_600D_0001; + + public static int Run(LuaState state) + { + RandomSource random = new(Seed); + int observedApiCalls = 0; + for (int iteration = 0; iteration < SequenceCount; iteration++) + { + int stringLength = BoundaryOrRandom(ref random, iteration, MaximumBufferLength); + int tableLength = BoundaryOrRandom(ref random, iteration, MaximumCapacity); + int capacity = BoundaryOrRandom(ref random, iteration, MaximumCapacity); + nuint userdataSize = (nuint) BoundaryOrRandom(ref random, iteration, MaximumBufferLength); + byte[] stringBytes = random.NextBytes(stringLength); + byte[] tableBytes = random.NextBytes(tableLength); + long key = random.NextInt32(MaximumCapacity) + 1; + long value = random.NextInt64(); + + if (AssertEmpty(state, iteration, "sequence-start") != 0 || + PushBytes(state, stringBytes, iteration, ref observedApiCalls) != 0 || + CreateTable(state, capacity, iteration, ref observedApiCalls) != 0 || + PushByteTable(state, tableBytes, iteration, ref observedApiCalls) != 0 || + CreateUserdata(state, userdataSize, iteration, ref observedApiCalls) != 0 || + MutateTable(state, key, value, iteration, ref observedApiCalls) != 0 || + RejectInvalidTable(state, key, iteration, ref observedApiCalls) != 0 || + RejectAllocation(state, capacity, iteration, ref observedApiCalls) != 0 || + Recover(state, iteration, ref observedApiCalls) != 0 || + AssertEmpty(state, iteration, "sequence-end") != 0) + { + return 1; + } + } + + if (observedApiCalls != SequenceCount * OperationsPerSequence) + { + return Fail(SequenceCount, "call-budget", "did not execute every protected API partition"); + } + + Console.WriteLine(string.Create(CultureInfo.InvariantCulture, + $"PASS protected fuzz seed=0x{Seed:X8} sequences={SequenceCount} protected-api-calls={observedApiCalls} buffers<={MaximumBufferLength} capacities<={MaximumCapacity} allocator-refusals={SequenceCount}")); + return 0; + } + + private static int BoundaryOrRandom(ref RandomSource random, int iteration, int maximum) + { + return (iteration % 3) switch + { + 0 => 0, + 1 => maximum, + _ => random.NextInt32(maximum + 1) + }; + } + + private static int PushBytes(LuaState state, byte[] bytes, int iteration, ref int observedApiCalls) + { + LuaStatus status = state.TryPushString(bytes); + observedApiCalls++; + if (!status.IsOk || state.Top != 1 || !state.TryReadUtf8(1, out ReadOnlySpan actual) || + !actual.SequenceEqual(bytes)) + { + return Fail(iteration, "TryPushString", "did not leave the exact successful string as its only stack value"); + } + + state.Pop(1); + return AssertEmpty(state, iteration, "TryPushString-cleanup"); + } + + private static int CreateTable(LuaState state, int capacity, int iteration, ref int observedApiCalls) + { + state.CreateTable(capacity, capacity); + observedApiCalls++; + if (state.Top != 1 || !state.IsTable(1)) + { + return Fail(iteration, "CreateTable", "did not leave one table on the stack"); + } + + state.Pop(1); + return AssertEmpty(state, iteration, "CreateTable-cleanup"); + } + + private static int PushByteTable(LuaState state, byte[] bytes, int iteration, ref int observedApiCalls) + { + state.PushByteTable(bytes); + observedApiCalls++; + if (state.Top != 1 || !state.IsTable(1) || state.RawLength(1) != (nuint) bytes.Length) + { + return Fail(iteration, "PushByteTable", "did not leave a table with the requested sequence length"); + } + + for (int i = 0; i < bytes.Length; i++) + { + if (state.RawGetIndex(1, i + 1) != LuaType.Number || !state.TryReadInteger(-1, out long value) || + value != bytes[i]) + { + return Fail(iteration, "PushByteTable", "table entry did not preserve its source byte"); + } + + state.Pop(1); + } + + state.Pop(1); + return AssertEmpty(state, iteration, "PushByteTable-cleanup"); + } + + private static int CreateUserdata(LuaState state, nuint size, int iteration, ref int observedApiCalls) + { + nint block = state.NewUserdata(size); + observedApiCalls++; + if (block == 0 || state.Top != 1 || !state.IsUserdata(1) || state.RawLength(1) != size) + { + return Fail(iteration, "NewUserdata", "did not leave one correctly sized userdata block"); + } + + state.Pop(1); + return AssertEmpty(state, iteration, "NewUserdata-cleanup"); + } + + private static int MutateTable(LuaState state, long key, long value, int iteration, ref int observedApiCalls) + { + state.CreateTable(0, 1); + observedApiCalls++; + state.PushInteger(value); + LuaStatus setStatus = state.TrySetIndex(1, key); + observedApiCalls++; + if (!setStatus.IsOk || state.Top != 1 || !state.IsTable(1)) + { + return Fail(iteration, "TrySetIndex", "did not consume the value and preserve the table"); + } + + LuaStatus getStatus = state.TryGetIndex(1, key); + observedApiCalls++; + if (!getStatus.IsOk || state.Top != 2 || !state.TryReadInteger(2, out long actual) || actual != value) + { + return Fail(iteration, "TryGetIndex", "did not return the value written through the protected setter"); + } + + state.Pop(2); + return AssertEmpty(state, iteration, "table-mutation-cleanup"); + } + + private static int RejectInvalidTable(LuaState state, long key, int iteration, ref int observedApiCalls) + { + state.PushInteger(Sentinel); + LuaStatus status = state.TryGetIndex(1, key); + observedApiCalls++; + if (status != LuaStatus.RuntimeError || state.Top != 2 || !state.TryReadInteger(1, out long sentinel) || + sentinel != Sentinel || !state.TryReadString(2, out string? error) || string.IsNullOrWhiteSpace(error)) + { + return Fail(iteration, "TryGetIndex-invalid-table", "did not return one runtime error above the unchanged sentinel"); + } + + state.Pop(2); + return AssertEmpty(state, iteration, "invalid-table-cleanup"); + } + + private static int RejectAllocation(LuaState state, int capacity, int iteration, ref int observedApiCalls) + { + state.PushInteger(Sentinel); + LuaStatus status = Program.CaptureMemoryException(() => state.CreateTable(capacity, capacity)); + observedApiCalls++; + if (status != LuaStatus.MemoryError || state.Top != 1 || !state.TryReadInteger(1, out long sentinel) || + sentinel != Sentinel) + { + return Fail(iteration, "CreateTable-allocator-refusal", "did not return memory error while restoring the sentinel"); + } + + state.Pop(1); + return AssertEmpty(state, iteration, "allocator-refusal-cleanup"); + } + + private static int Recover(LuaState state, int iteration, ref int observedApiCalls) + { + LuaStatus status = state.TryExecute("return 6 * 7"u8, 1, "=protected-fuzz-recovery"u8); + observedApiCalls++; + if (!status.IsOk || state.Top != 1 || !state.TryReadInteger(1, out long value) || value != 42) + { + return Fail(iteration, "recovery", "the state did not execute the fixed protected recovery call"); + } + + state.Pop(1); + return 0; + } + + private static int AssertEmpty(LuaState state, int iteration, string operation) + { + return state.Top == 0 ? 0 : Fail(iteration, operation, "did not completely clean up the Lua stack"); + } + + private static int Fail(int iteration, string operation, string detail) + { + Console.Error.WriteLine(string.Create(CultureInfo.InvariantCulture, + $"FAIL protected fuzz seed=0x{Seed:X8} iteration={iteration} operation={operation}: {detail}")); + return 1; + } + + private struct RandomSource(uint state) + { + private uint _state = state; + + public int NextInt32(int exclusiveMaximum) + { + return (int) (NextUInt32() % (uint) exclusiveMaximum); + } + + public long NextInt64() + { + return unchecked(((long) NextUInt32() << 32) | NextUInt32()); + } + + public byte[] NextBytes(int length) + { + byte[] result = new byte[length]; + for (int i = 0; i < result.Length; i++) + { + result[i] = (byte) NextUInt32(); + } + + return result; + } + + private uint NextUInt32() + { + uint value = _state; + value ^= value << 13; + value ^= value >> 17; + value ^= value << 5; + _state = value; + return value; + } + } +} diff --git a/tests/CheatEngine.SDK.Lua.FailureProbe/README.md b/tests/CheatEngine.SDK.Lua.FailureProbe/README.md index 4f75a6cb..b497eed2 100644 --- a/tests/CheatEngine.SDK.Lua.FailureProbe/README.md +++ b/tests/CheatEngine.SDK.Lua.FailureProbe/README.md @@ -27,3 +27,14 @@ time until that direct check returns `0`, then calls the bridge wire export dire `PushBytes` operation. The bridge must return its `NoErrorStatus` sentinel without changing the full stack; the child then restores the stack and verifies recovery. The mode stays out of process because a regression that lets a native non-local exit cross the managed call frame must never terminate the main test process. + +Its `--fuzz-protected-operations` mode runs 2,000 deterministic sequences from the printed `0xD1CEBA5E` seed and +prints 20,000 exercised top-level protected API calls. Each +sequence keeps buffers at or below 1 KiB and table capacities at or below 64 while checking protected byte strings, +tables, byte tables, userdata, indexed table mutations, deliberate indexing of a non-table, allocator refusal and a +fixed recovery call. Every operation checks its values and documented stack shape, uses a lower sentinel where an +error is expected, and ends with an empty stack. Sequence positions cycle through zero, maximum and seeded-random +input sizes, so every run includes empty and boundary buffers, capacities and userdata sizes. The mode intentionally +does not create callbacks, nested protected +callback chains, executable Lua source or pointer/function-pointer inputs; those paths already have dedicated +crash-boundary tests and are unsuitable for generated data here. diff --git a/tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj b/tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj index 5232da23..12241869 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj +++ b/tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj @@ -14,6 +14,11 @@ + + + + + Generated-input checks for the lossless Lua integer conversion contract. +[Trait("Category", "NativeLua")] +public sealed class LuaIntegerFuzzTests +{ + private const long FloatExactLimit = 1L << 53; + private static readonly Config s_configuration = Config.QuickThrowOnFailure.WithMaxTest(1000).WithEndSize(128); + + [Fact] + public void Generated_integer_numerals_match_an_independent_arithmetic_oracle() + { + LuaTest.RequireNativeLua(); + Prop.ForAll(values => + { + string numeral = BuildNumeral(Bound(values, 128)); + byte[] utf8 = Encoding.UTF8.GetBytes(numeral); + (bool expectedAccepted, long expectedValue) = TryParseIntegerNumeral(utf8); + + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushString(utf8); + + Assert.Equal(expectedAccepted, Int64Marshaller.TryRead(L, 1, out long actualValue)); + Assert.Equal(expectedAccepted ? expectedValue : 0L, actualValue); + Assert.Equal(LuaType.String, L.TypeOf(1)); + Assert.Equal(1, L.Top); + Assert.True(L.TryReadUtf8(1, out ReadOnlySpan stored)); + Assert.True(stored.SequenceEqual(utf8)); + return true; + }).Check(s_configuration); + } + + [Fact] + public void Generated_integer_and_float_values_keep_exactness_and_never_change_the_slot() + { + LuaTest.RequireNativeLua(); + Prop.ForAll(value => + { + long exact = value % (FloatExactLimit - 1); + double fractional = (value % 1_000_000) + 0.5; + + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushInteger(value); + L.PushNumber(exact); + L.PushNumber(fractional); + L.PushNumber(FloatExactLimit); + L.PushNumber(-FloatExactLimit); + + Assert.True(Int64Marshaller.TryRead(L, 1, out long integerRead)); + Assert.Equal(value, integerRead); + Assert.True(Int64Marshaller.TryRead(L, 2, out long exactRead)); + Assert.Equal(exact, exactRead); + Assert.False(Int64Marshaller.TryRead(L, 3, out long fractionalRead)); + Assert.Equal(0, fractionalRead); + Assert.False(Int64Marshaller.TryRead(L, 4, out long positiveBoundaryRead)); + Assert.Equal(0, positiveBoundaryRead); + Assert.False(Int64Marshaller.TryRead(L, 5, out long negativeBoundaryRead)); + Assert.Equal(0, negativeBoundaryRead); + Assert.True(L.IsInteger(1)); + Assert.False(L.IsInteger(2)); + Assert.False(L.IsInteger(3)); + Assert.False(L.IsInteger(4)); + Assert.False(L.IsInteger(5)); + Assert.Equal(5, L.Top); + return true; + }).Check(s_configuration); + } + + [Theory] + [InlineData("0xFFFFFFFFFFFFFFFF", -1L)] + [InlineData("-0xFFFFFFFFFFFFFFFF", 1L)] + [InlineData("0x000000000000000000001", 1L)] + [InlineData("-9223372036854775808", long.MinValue)] + [InlineData("9223372036854775807", long.MaxValue)] + public void Integer_numeral_boundaries_preserve_lua_signed_wrap_policy(string numeral, long expected) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushString(numeral.AsSpan()); + + Assert.True(Int64Marshaller.TryRead(L, 1, out long actual)); + Assert.Equal(expected, actual); + Assert.Equal(LuaType.String, L.TypeOf(1)); + Assert.Equal(1, L.Top); + } + + private static byte[] Bound(byte[] values, int maximumLength) + { + return values.Length <= maximumLength ? values : values[..maximumLength]; + } + + private static string BuildNumeral(byte[] values) + { + byte selector = At(values, 0); + string spaces = BuildSpaces(values, 1); + return (selector % 8) switch + { + 0 => spaces + unchecked((long) ReadUInt64(values, 8)).ToString(CultureInfo.InvariantCulture) + spaces, + 1 => BuildHexadecimal(values, spaces), + 2 => spaces + "9223372036854775808" + spaces, + 3 => spaces + "0x10000000000000000" + spaces, + 4 => spaces + "12.5" + spaces, + 5 => spaces + "1e3" + spaces, + 6 => spaces + "42\0" + Convert.ToHexString(Bound(values, 8)), + _ => "\u00A0" + spaces + "17" + spaces + }; + } + + private static string BuildHexadecimal(byte[] values, string spaces) + { + const string Hex = "0123456789abcdef"; + int digits = (At(values, 2) % 16) + 1; + int leadingZeros = At(values, 3) % 33; + StringBuilder text = new(spaces); + if ((At(values, 4) & 1) != 0) + { + text.Append('-'); + } + + text.Append((At(values, 5) & 1) == 0 ? "0x" : "0X"); + text.Append('0', leadingZeros); + for (int i = 0; i < digits; i++) + { + text.Append(Hex[At(values, i + 6) & 0x0F]); + } + + return text.Append(spaces).ToString(); + } + + private static string BuildSpaces(byte[] values, int offset) + { + ReadOnlySpan whitespace = [' ', '\t', '\n', '\v', '\f', '\r']; + StringBuilder text = new(); + for (int i = 0; i < At(values, offset) % 5; i++) + { + text.Append(whitespace[At(values, offset + i + 1) % whitespace.Length]); + } + + return text.ToString(); + } + + private static (bool Accepted, long Value) TryParseIntegerNumeral(ReadOnlySpan utf8) + { + foreach (byte utf8Byte in utf8) + { + if (utf8Byte > 0x7F) + { + return (false, 0); + } + } + + string text = Encoding.ASCII.GetString(utf8).Trim(' ', '\t', '\n', '\v', '\f', '\r'); + if (text.Length == 0) + { + return (false, 0); + } + + bool negative = text[0] == '-'; + int signLength = text[0] is '-' or '+' ? 1 : 0; + string unsigned = text[signLength..]; + if (unsigned.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + string digits = unsigned[2..]; + if (!IsDigits(digits, hexadecimal: true)) + { + return (false, 0); + } + + string significant = digits.TrimStart('0'); + if (significant.Length > 16 || !ulong.TryParse(digits, NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, out ulong bits)) + { + return (false, 0); + } + + return (true, negative ? unchecked((long) (0UL - bits)) : unchecked((long) bits)); + } + + if (!IsDigits(unsigned, hexadecimal: false) || + !BigInteger.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out BigInteger value) || + value < long.MinValue || value > long.MaxValue) + { + return (false, 0); + } + + return (true, (long) value); + } + + private static bool IsDigits(string text, bool hexadecimal) + { + foreach (char character in text) + { + if (hexadecimal ? !Uri.IsHexDigit(character) : character is < '0' or > '9') + { + return false; + } + } + + return text.Length > 0; + } + + private static byte At(byte[] values, int index) => values.Length == 0 ? (byte) 0 : values[index % values.Length]; + + private static ulong ReadUInt64(byte[] values, int offset) + { + ulong result = 0; + for (int i = 0; i < sizeof(ulong); i++) + { + result |= (ulong) At(values, offset + i) << (i * 8); + } + + return result; + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFuzzProcessTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFuzzProcessTests.cs new file mode 100644 index 00000000..889dc3cd --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFuzzProcessTests.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; +using System.Globalization; + +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Lua.Tests.Protected; + +/// Runs bounded native protected-operation fuzzing out of process so native regressions cannot stop MTP. +[Trait("Category", "NativeLua")] +public sealed class NativeFuzzProcessTests +{ + [Fact] + public async Task Protected_operation_fuzz_sequences_preserve_results_stack_and_recovery() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + string baseDirectory = AppContext.BaseDirectory; + string probe = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.FailureProbe.dll"); + Assert.True(File.Exists(probe), $"Failure probe was not copied to '{probe}'."); + + ProcessStartInfo start = new("dotnet") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + start.ArgumentList.Add("exec"); + start.ArgumentList.Add("--runtimeconfig"); + start.ArgumentList.Add(Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.runtimeconfig.json")); + start.ArgumentList.Add("--depsfile"); + start.ArgumentList.Add(Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.deps.json")); + start.ArgumentList.Add(probe); + start.ArgumentList.Add(NativeLuaLibrary.LibraryPath!); + start.ArgumentList.Add("--fuzz-protected-operations"); + + using Process? process = Process.Start(start); + Assert.NotNull(process); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + // Pipe lifetime follows the owned process: cancellation kills it, then drains both streams before disposal. + Task output = process.StandardOutput.ReadToEndAsync(CancellationToken.None); + Task error = process.StandardError.ReadToEndAsync(CancellationToken.None); + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(60)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + await StopProcessAsync(process); + string[] captured = await Task.WhenAll(output, error); + if (cancellationToken.IsCancellationRequested) + { + throw; + } + + Assert.Fail($"The protected-operation fuzz probe did not exit within 60 seconds.\nstdout:\n{captured[0]}\nstderr:\n{captured[1]}"); + } + + string standardOutput = await output; + string standardError = await error; + Assert.True(process.ExitCode == 0, + string.Create(CultureInfo.InvariantCulture, + $"Probe exit code: {process.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{standardError}")); + Assert.Contains("PASS protected fuzz seed=0xD1CEBA5E sequences=2000 protected-api-calls=20000", standardOutput, + StringComparison.Ordinal); + Assert.Contains("buffers<=1024 capacities<=64 allocator-refusals=2000", standardOutput, + StringComparison.Ordinal); + } + + private static async Task StopProcessAsync(Process process) + { + if (!process.HasExited) + { + try + { + process.Kill(true); + } + catch (InvalidOperationException) when (process.HasExited) + { + // It exited between the status check and the kill request; drain it below. + } + } + + await process.WaitForExitAsync(CancellationToken.None); + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/README.md b/tests/CheatEngine.SDK.Lua.Tests/README.md index 8f25b06b..08489903 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/README.md +++ b/tests/CheatEngine.SDK.Lua.Tests/README.md @@ -18,6 +18,7 @@ error unwind a managed frame or allocates on a hot path. Only a real interpreter |--------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Stack and protected operations | `LuaFrame` restores the stack top on every exit path; raising metamethods, syntax errors and host-object pusher exits become statuses, never a Lua unwind | | Marshallers | Values round trip exactly, a value that does not fit is refused instead of truncated, and no integer or address passes through a lossy `double` (Q21) | +| Generated input fuzzing | Bounded FsCheck properties exercise integer numeral grammar, number precision, raw Lua bytes, UTF-16 replacement encoding and stack/pool string boundaries | | Optional values and status | `LuaOptional` keeps omitted, `nil` and a value apart; `LuaCallSupport` pushes, reads and copies optional and variadic values; status enums start at `Unknown = 0` | | References and runtime | Every `LuaRuntime.Attach` advances the epoch, and a `LuaRef` from an earlier epoch is never pushed | | Callbacks | Test thunks follow the SDK rule (static, cdecl, catch-all), state travels in the upvalue, and `Detach` drains admitted invocations before neutralizing callbacks | @@ -66,6 +67,9 @@ dotnet test --project tests/CheatEngine.SDK.Lua.Tests --filter-trait "Category=N `StringBindingTests`). - Integer and address marshallers keep every bit of an integer and refuse a float at or above 2^53, a float numeral and an out-of-range numeral (`MarshallerRoundTripTests`, traited `Qualification=Q21`). +- Generated inputs retain Lua's exact integer-string and float conversion policy, raw bytes are copied only when a + destination fits, and UTF-16 text follows the platform UTF-8 replacement policy (`LuaIntegerFuzzTests`, + `LuaStringFuzzTests`, `Utf8ScratchFuzzTests`; 1,000 cases per property). - `LuaOptional` and the optional and variadic call helpers keep omitted, `nil` and values distinct, allocate nothing, and report a missing result or an exceeded capacity (`LuaOptionalTests`, `LuaCallSupportOptionalTests`). - A default status is `Unknown`, never success, and every status value is pinned (`LuaOperationStatusTests`, diff --git a/tests/CheatEngine.SDK.Lua.Tests/State/LuaStringFuzzTests.cs b/tests/CheatEngine.SDK.Lua.Tests/State/LuaStringFuzzTests.cs new file mode 100644 index 00000000..d8c9574c --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/State/LuaStringFuzzTests.cs @@ -0,0 +1,98 @@ +using System.Text; + +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Lua.Tests.Support; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +using FsCheck; +using FsCheck.Fluent; + +namespace CheatEngine.SDK.Lua.Tests.State; + +/// Generated byte and UTF-16 strings prove that Lua string access preserves its documented contracts. +[Trait("Category", "NativeLua")] +public sealed class LuaStringFuzzTests +{ + private static readonly Config s_configuration = Config.QuickThrowOnFailure.WithMaxTest(1000).WithEndSize(128); + + [Fact] + public void Arbitrary_raw_bytes_round_trip_and_undersized_copies_preserve_the_destination() + { + LuaTest.RequireNativeLua(); + Prop.ForAll(values => + { + byte[] payload = Bound(values, 512); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushString(payload); + + Assert.Equal((nuint) payload.Length, L.RawLength(1)); + Assert.True(L.TryReadUtf8(1, out ReadOnlySpan read)); + Assert.True(read.SequenceEqual(payload)); + byte[] exact = new byte[payload.Length]; + Assert.True(L.TryCopyUtf8(1, exact, out int written)); + Assert.Equal(payload.Length, written); + Assert.Equal(payload, exact); + if (payload.Length > 0) + { + byte[] undersized = new byte[payload.Length - 1]; + Array.Fill(undersized, (byte) 0xA5); + Assert.False(L.TryCopyUtf8(1, undersized, out int refusedWritten)); + Assert.Equal(0, refusedWritten); + Assert.All(undersized, static value => Assert.Equal(0xA5, value)); + } + + Assert.Equal(LuaType.String, L.TypeOf(1)); + Assert.Equal(1, L.Top); + return true; + }).Check(s_configuration); + } + + [Fact] + public void Arbitrary_utf16_uses_the_standard_utf8_replacement_oracle() + { + LuaTest.RequireNativeLua(); + Prop.ForAll(values => + { + char[] characters = Array.ConvertAll(values[..Math.Min(values.Length, 128)], static value => (char) value); + string text = new(characters); + byte[] expectedUtf8 = Encoding.UTF8.GetBytes(text); + string expectedText = Encoding.UTF8.GetString(expectedUtf8); + + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushString(text.AsSpan()); + + Assert.True(L.TryReadUtf8(1, out ReadOnlySpan actualUtf8)); + Assert.True(actualUtf8.SequenceEqual(expectedUtf8)); + Assert.True(L.TryReadString(1, out string? actualText)); + Assert.Equal(expectedText, actualText); + Assert.Equal((nuint) expectedUtf8.Length, L.RawLength(1)); + Assert.Equal(LuaType.String, L.TypeOf(1)); + Assert.Equal(1, L.Top); + return true; + }).Check(s_configuration); + } + + [Fact] + public void Embedded_nuls_and_malformed_utf8_keep_length_and_decode_with_the_platform_oracle() + { + LuaTest.RequireNativeLua(); + byte[] payload = [0x61, 0x00, 0xC0, 0xAF, 0xFF, 0x62]; + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushString(payload); + + Assert.True(L.TryReadUtf8(1, out ReadOnlySpan bytes)); + Assert.True(bytes.SequenceEqual(payload)); + Assert.True(L.TryReadString(1, out string? text)); + Assert.Equal(Encoding.UTF8.GetString(payload), text); + Assert.Equal((nuint) payload.Length, L.RawLength(1)); + Assert.Equal(1, L.Top); + } + + private static byte[] Bound(byte[] values, int maximumLength) + { + return values.Length <= maximumLength ? values : values[..maximumLength]; + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/Text/Utf8ScratchFuzzTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Text/Utf8ScratchFuzzTests.cs new file mode 100644 index 00000000..e6262207 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/Text/Utf8ScratchFuzzTests.cs @@ -0,0 +1,64 @@ +using System.Text; + +using CheatEngine.SDK.Lua.Text; + +using FsCheck; +using FsCheck.Fluent; + +namespace CheatEngine.SDK.Lua.Tests.Text; + +/// Generated UTF-16 checks for the stack-first UTF-8 scratch buffer. +public sealed class Utf8ScratchFuzzTests +{ + private static readonly Config s_configuration = Config.QuickThrowOnFailure.WithMaxTest(1000).WithEndSize(128); + + [Fact] + public void Generated_utf16_matches_encoding_utf8_across_stack_and_pool_capacity() + { + Prop.ForAll(values => + { + string generated = new(Array.ConvertAll(values[..Math.Min(values.Length, 128)], static value => (char) value)); + string text = (values.Length == 0 || (values[0] & 1) == 0) ? generated : generated + new string('x', 513); + byte[] expected = Encoding.UTF8.GetBytes(text); + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch utf8 = Utf8Scratch.Encode(text, scratch); + + Assert.Equal(expected.Length > Utf8Scratch.StackBufferSize, utf8.IsPooled); + Assert.True(utf8.Bytes.SequenceEqual(expected)); + return true; + }).Check(s_configuration); + } + + [Theory] + [InlineData(511, false)] + [InlineData(512, false)] + [InlineData(513, true)] + public void Ascii_at_the_stack_buffer_cutover_uses_the_documented_storage(int byteCount, bool expectedPooled) + { + string text = new('x', byteCount); + AssertEncodingAndStorage(text, expectedPooled); + } + + [Theory] + [InlineData(510, false)] + [InlineData(512, false)] + [InlineData(514, true)] + public void Multibyte_utf8_at_the_stack_buffer_cutover_uses_the_documented_storage(int byteCount, + bool expectedPooled) + { + Assert.Equal(0, byteCount % 2); + string text = new('\u00E9', byteCount / 2); + AssertEncodingAndStorage(text, expectedPooled); + } + + private static void AssertEncodingAndStorage(string text, bool expectedPooled) + { + byte[] expected = Encoding.UTF8.GetBytes(text); + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch utf8 = Utf8Scratch.Encode(text, scratch); + + Assert.Equal(expectedPooled, utf8.IsPooled); + Assert.Equal(expected.Length, utf8.Bytes.Length); + Assert.True(utf8.Bytes.SequenceEqual(expected)); + } +} diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests.csproj b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests.csproj index 150c8cee..69db7801 100644 --- a/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests.csproj +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests.csproj @@ -1,5 +1,9 @@ + + + +