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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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),
Expand All @@ -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++;
Expand Down Expand Up @@ -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++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@

namespace CheatEngine.SDK.Lua.FailureProbe;

internal static unsafe class Program

Check warning on line 14 in tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs

View workflow job for this annotation

GitHub Actions / CI / Sonar / Analyze

Avoid using this unsafe code block.

Check warning on line 14 in tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs

View workflow job for this annotation

GitHub Actions / CI / Sonar / Analyze

Avoid using this unsafe code block.
{
private const string CheckStackGrowthMode = "--checkstack-growth";

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.
Expand All @@ -39,7 +41,7 @@
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;
Expand Down Expand Up @@ -97,7 +99,8 @@
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)
Expand All @@ -106,7 +109,9 @@
{
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);
Expand Down Expand Up @@ -1093,7 +1098,7 @@
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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
using System.Globalization;

using CheatEngine.SDK.Lua.Calls;
using CheatEngine.SDK.Lua.State;

namespace CheatEngine.SDK.Lua.FailureProbe;

/// <summary>Deterministic, process-isolated sequences for protected Lua operations and their recovery contracts.</summary>
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)

Check warning on line 64 in tests/CheatEngine.SDK.Lua.FailureProbe/ProtectedOperationFuzzProbe.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add comma after the last value

See more on https://sonarcloud.io/project/issues?id=CheatEngineNet_CheatEngine.SDK&issues=AaE8NHOgYwh2agYm60P0&open=AaE8NHOgYwh2agYm60P0&pullRequest=94
};
}

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<byte> 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;
}
}
}
11 changes: 11 additions & 0 deletions tests/CheatEngine.SDK.Lua.FailureProbe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading