From 8825bbeaea05536980f712a26e460a728b4a43e8 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:50:34 +0900 Subject: [PATCH 1/2] fix: correct value conversions and memory stream reads Reject out-of-range int, long, uint and ulong reads before casting, using exclusive double upper bounds for 64-bit targets. TryRead returns false instead of clamping or throwing, and generated argument errors remain Lua errors. Read booleans as boxed bool values for object-typed interop. Advance StringStream after its full-content fast path. Scan leading whitespace once for memory-backed numeric reads and preserve nonnumeric input when conversion fails. Use a shared Lua-library string reader that accepts numeric arguments in the selected platform format and updates the argument slot like Lua 5.2. Keep public typed C# string reads strict and preserve caller values across reentrant callbacks. Add 54 regression cases for integer boundaries, boxing, generated bindings, stream cursor sequences, numeric string arguments, format policies and reentrant debug inspection. The original binary fails the regression cases for all five bugs; native Lua 5.2 confirms the argument-slot behavior. Validation: 2,697 runtime tests pass on each of .NET 8 and .NET 10, plus 73 generator tests. Release solution build passes for all four runtime targets with no warnings. CSharpier, diff checks and benchmark --verify pass. Paired .NET 10 samples show string workloads 6-10% faster, unchanged allocations and no host-call slowdown. Direct signed integer reads add about 2% for range validation; unsigned reads improve after eliminating checked conversion helpers. --- src/Lua/IO/MemoryStreams.cs | 23 +-- src/Lua/IO/NumberReaderHelper.cs | 15 +- src/Lua/LuaFunctionExecutionContext.cs | 22 ++- src/Lua/LuaValue.cs | 19 ++- src/Lua/Standard/BasicLibrary.cs | 12 +- src/Lua/Standard/FileHandle.cs | 4 +- src/Lua/Standard/IOLibrary.cs | 6 +- src/Lua/Standard/ModuleLibrary.cs | 14 +- src/Lua/Standard/OpenLibsExtensions.cs | 2 +- src/Lua/Standard/OperatingSystemLibrary.cs | 14 +- src/Lua/Standard/StringLibrary.cs | 30 ++-- src/Lua/Standard/TableLibrary.cs | 2 +- tests/Lua.Tests/MemoryStreamReadTests.cs | 72 +++++++++ .../StringArgumentConversionTests.cs | 137 ++++++++++++++++++ tests/Lua.Tests/ValueConversionTests.cs | 136 +++++++++++++++++ 15 files changed, 426 insertions(+), 82 deletions(-) create mode 100644 tests/Lua.Tests/MemoryStreamReadTests.cs create mode 100644 tests/Lua.Tests/StringArgumentConversionTests.cs create mode 100644 tests/Lua.Tests/ValueConversionTests.cs diff --git a/src/Lua/IO/MemoryStreams.cs b/src/Lua/IO/MemoryStreams.cs index fc54b77f..9755d3f1 100644 --- a/src/Lua/IO/MemoryStreams.cs +++ b/src/Lua/IO/MemoryStreams.cs @@ -112,18 +112,6 @@ public virtual ValueTask ReadAllAsync(CancellationToken cancellationToke } var remaining = contents[Position..].Span; - var startPos = Position; - - // Use the shared utility to scan for a number - var numberLength = NumberReaderHelper.ScanNumberLength(remaining, true); - - if (numberLength == 0) - { - Position = contents.Length; - return new((double?)null); - } - - // Find where the actual number starts (after whitespace) var whitespaceLength = 0; while ( whitespaceLength < remaining.Length && char.IsWhiteSpace(remaining[whitespaceLength]) @@ -132,12 +120,10 @@ public virtual ValueTask ReadAllAsync(CancellationToken cancellationToke whitespaceLength++; } - var numberSpan = remaining.Slice(whitespaceLength, numberLength); - Position = startPos + whitespaceLength + numberLength; - - // Parse using shared utility - var result = NumberReaderHelper.ParseNumber(numberSpan); - return new(result); + remaining = remaining[whitespaceLength..]; + var numberLength = NumberReaderHelper.ScanNumberLength(remaining); + Position += whitespaceLength + numberLength; + return new(NumberReaderHelper.ParseNumber(remaining[..numberLength])); } public ValueTask WriteAsync(ReadOnlyMemory content, CancellationToken cancellationToken) @@ -200,6 +186,7 @@ public override ValueTask ReadAllAsync(CancellationToken cancellationTok cancellationToken.ThrowIfCancellationRequested(); if (Position == 0) { + Position = content.Length; return new(content); } diff --git a/src/Lua/IO/NumberReaderHelper.cs b/src/Lua/IO/NumberReaderHelper.cs index 5da48bc9..ec348914 100644 --- a/src/Lua/IO/NumberReaderHelper.cs +++ b/src/Lua/IO/NumberReaderHelper.cs @@ -10,27 +10,16 @@ internal static class NumberReaderHelper /// Returns the length of the number portion, or 0 if no valid number is found. /// /// The span to scan - /// Whether to skip leading whitespace /// The length of the valid number portion - public static int ScanNumberLength(ReadOnlySpan span, bool skipWhitespace = true) + public static int ScanNumberLength(ReadOnlySpan span) { var position = 0; - // Skip leading whitespace - if (skipWhitespace) - { - while (position < span.Length && char.IsWhiteSpace(span[position])) - { - position++; - } - } - if (position >= span.Length) { return 0; } - var numberStart = position; var hasStarted = false; var isHex = false; var hasDecimal = false; @@ -129,7 +118,7 @@ public static int ScanNumberLength(ReadOnlySpan span, bool skipWhitespace } // Return the length of the number portion - return position - numberStart; + return position; } public static bool TryParseToDouble(ReadOnlySpan span, out double result) diff --git a/src/Lua/LuaFunctionExecutionContext.cs b/src/Lua/LuaFunctionExecutionContext.cs index 049437c7..bd6036c6 100644 --- a/src/Lua/LuaFunctionExecutionContext.cs +++ b/src/Lua/LuaFunctionExecutionContext.cs @@ -67,6 +67,23 @@ public T GetArgument(int index) return ReadArgument(index, in Arguments[index]); } + // Lua library strings accept numbers; typed C# arguments remain strict. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal string GetStringArgument(int index) + { + var argument = GetArgument(index); + if (argument.TryReadString(out var text)) + return text; + if (argument.Type == LuaValueType.Number) + { + text = argument.ToLuaString(State.Platform.NumberFormat); + State.Stack.Get(FrameBase + index) = text; + return text; + } + ThrowArgumentError(index, argument); + return null!; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal T GetArgumentOrDefault(int index, T defaultValue = default!) { @@ -110,7 +127,10 @@ T ReadArgument(int index, in LuaValue arg) void ThrowArgumentError(int index, in LuaValue arg) { var t = typeof(T); - if ((t == typeof(int) || t == typeof(long)) && arg.TryReadNumber(out _)) + if ( + (t == typeof(int) || t == typeof(long) || t == typeof(uint) || t == typeof(ulong)) + && arg.TryReadNumber(out _) + ) { LuaRuntimeException.BadArgumentNumberIsNotInteger(State, index + 1); } diff --git a/src/Lua/LuaValue.cs b/src/Lua/LuaValue.cs index b4f71adc..23df1d03 100644 --- a/src/Lua/LuaValue.cs +++ b/src/Lua/LuaValue.cs @@ -64,7 +64,7 @@ public bool TryRead(out T result) } else if (t == typeof(int)) { - if (!MathEx.IsInteger(value)) + if (!MathEx.IsInteger(value) || value < int.MinValue || value > int.MaxValue) { break; } @@ -75,7 +75,12 @@ public bool TryRead(out T result) } else if (t == typeof(long)) { - if (!MathEx.IsInteger(value)) + // The upper bound is exclusive: long.MaxValue rounds to 2^63 as a double. + if ( + !MathEx.IsInteger(value) + || value < long.MinValue + || value >= 9223372036854775808d + ) { break; } @@ -86,23 +91,23 @@ public bool TryRead(out T result) } else if (t == typeof(uint)) { - if (!MathEx.IsInteger(value)) + if (!MathEx.IsInteger(value) || value < 0 || value > uint.MaxValue) { break; } - var v = checked((uint)value); + var v = (uint)value; result = Unsafe.As(ref v); return true; } else if (t == typeof(ulong)) { - if (!MathEx.IsInteger(value)) + if (!MathEx.IsInteger(value) || value < 0 || value >= 18446744073709551616d) { break; } - var v = checked((ulong)value); + var v = (ulong)value; result = Unsafe.As(ref v); return true; } @@ -124,7 +129,7 @@ public bool TryRead(out T result) } else if (t == typeof(object)) { - result = (T)(object)value; + result = (T)(object)(value != 0); return true; } else diff --git a/src/Lua/Standard/BasicLibrary.cs b/src/Lua/Standard/BasicLibrary.cs index 5e379eaf..7df436ef 100644 --- a/src/Lua/Standard/BasicLibrary.cs +++ b/src/Lua/Standard/BasicLibrary.cs @@ -109,7 +109,7 @@ CancellationToken cancellationToken { if (context.HasArgument(0)) { - var option = context.GetArgument(0); + var option = context.GetStringArgument(0); if (!KnownCollectGarbageOptions.Contains(option)) { throw new LuaRuntimeException( @@ -129,7 +129,7 @@ public async ValueTask DoFile( CancellationToken cancellationToken ) { - var arg0 = context.GetArgument(0); + var arg0 = context.GetStringArgument(0); context.State.Stack.PopUntil(context.ReturnFrameBase); var closure = await context.State.LoadFileAsync( context.State.Platform.DecodeText(arg0), @@ -217,8 +217,8 @@ public async ValueTask LoadFile( CancellationToken cancellationToken ) { - var arg0 = context.GetArgument(0); - var mode = context.HasArgument(1) ? context.GetArgument(1) : "bt"; + var arg0 = context.GetStringArgument(0); + var mode = context.HasArgument(1) ? context.GetStringArgument(1) : "bt"; var hasEnvironment = context.ArgumentCount > 2; var environment = hasEnvironment ? context.GetArgument(2) : LuaValue.Nil; @@ -251,9 +251,9 @@ CancellationToken cancellationToken { var arg0 = context.GetArgument(0); - var name = context.HasArgument(1) ? context.GetArgument(1) : null; + var name = context.HasArgument(1) ? context.GetStringArgument(1) : null; - var mode = context.HasArgument(2) ? context.GetArgument(2) : "bt"; + var mode = context.HasArgument(2) ? context.GetStringArgument(2) : "bt"; var hasEnvironment = context.ArgumentCount > 3; var environment = hasEnvironment ? context.GetArgument(3) : LuaValue.Nil; diff --git a/src/Lua/Standard/FileHandle.cs b/src/Lua/Standard/FileHandle.cs index 296496e8..b2521add 100644 --- a/src/Lua/Standard/FileHandle.cs +++ b/src/Lua/Standard/FileHandle.cs @@ -244,7 +244,7 @@ static async (context, cancellationToken) => (context, cancellationToken) => { var file = context.GetArgument(0); - var whence = context.HasArgument(1) ? context.GetArgument(1) : "cur"; + var whence = context.HasArgument(1) ? context.GetStringArgument(1) : "cur"; var offset = context.HasArgument(2) ? context.GetArgument(2) : 0; if (whence is not ("set" or "cur" or "end")) @@ -277,7 +277,7 @@ static async (context, cancellationToken) => (context, cancellationToken) => { var file = context.GetArgument(0); - var mode = context.GetArgument(1); + var mode = context.GetStringArgument(1); var size = context.HasArgument(2) ? context.GetArgument(2) : -1; file.SetVBuf(mode, size); diff --git a/src/Lua/Standard/IOLibrary.cs b/src/Lua/Standard/IOLibrary.cs index 5eda6787..01cf8828 100644 --- a/src/Lua/Standard/IOLibrary.cs +++ b/src/Lua/Standard/IOLibrary.cs @@ -149,7 +149,7 @@ static async (context, cancellationToken) => } else { - var fileName = context.GetArgument(0); + var fileName = context.GetStringArgument(0); LuaValue[] formats = context.ArgumentCount > 1 ? context.Arguments[1..context.ArgumentCount].ToArray() @@ -207,8 +207,8 @@ public async ValueTask Open( CancellationToken cancellationToken ) { - var fileName = context.GetArgument(0); - var mode = context.HasArgument(1) ? context.GetArgument(1) : "r"; + var fileName = context.GetStringArgument(0); + var mode = context.HasArgument(1) ? context.GetStringArgument(1) : "r"; context.Return(); try { diff --git a/src/Lua/Standard/ModuleLibrary.cs b/src/Lua/Standard/ModuleLibrary.cs index 18c7d3a5..dbf9f9cf 100644 --- a/src/Lua/Standard/ModuleLibrary.cs +++ b/src/Lua/Standard/ModuleLibrary.cs @@ -24,7 +24,7 @@ public async ValueTask Require( CancellationToken cancellationToken ) { - var arg0 = context.GetArgument(0); + var arg0 = context.GetStringArgument(0); var loaded = context.GlobalState.LoadedModules; if (!loaded.TryGetValue(arg0, out var loadedTable)) @@ -74,10 +74,10 @@ public ValueTask SearchPath( CancellationToken cancellationToken ) { - var name = context.GetArgument(0); - var path = context.GetArgument(1); - var separator = context.GetArgument(2); - var dirSeparator = context.GetArgument(3); + var name = context.GetStringArgument(0); + var path = context.GetStringArgument(1); + var separator = context.GetStringArgument(2); + var dirSeparator = context.GetStringArgument(3); var fileName = SearchPath(context.State, name, path, separator, dirSeparator); return new(context.Return(fileName ?? LuaValue.Nil)); } @@ -170,7 +170,7 @@ public ValueTask SearcherPreload( CancellationToken cancellationToken ) { - var name = context.GetArgument(0); + var name = context.GetStringArgument(0); var preload = context.GlobalState.PreloadModules[name]; if (preload == LuaValue.Nil) { @@ -185,7 +185,7 @@ public async ValueTask SearcherLua( CancellationToken cancellationToken ) { - var name = context.GetArgument(0); + var name = context.GetStringArgument(0); var fileName = await FindFile( context.State, name, diff --git a/src/Lua/Standard/OpenLibsExtensions.cs b/src/Lua/Standard/OpenLibsExtensions.cs index 73947b1b..e987e917 100644 --- a/src/Lua/Standard/OpenLibsExtensions.cs +++ b/src/Lua/Standard/OpenLibsExtensions.cs @@ -141,7 +141,7 @@ public static void OpenStringLibrary(this LuaState state) "index", (context, cancellationToken) => { - context.GetArgument(0); + context.GetStringArgument(0); var key = context.GetArgument(1); return new(context.Return(@string[key])); } diff --git a/src/Lua/Standard/OperatingSystemLibrary.cs b/src/Lua/Standard/OperatingSystemLibrary.cs index 72f5accb..51d3433f 100644 --- a/src/Lua/Standard/OperatingSystemLibrary.cs +++ b/src/Lua/Standard/OperatingSystemLibrary.cs @@ -41,9 +41,7 @@ public ValueTask Date( CancellationToken cancellationToken ) { - var format = context.HasArgument(0) - ? context.GetArgument(0).AsSpan() - : "%c".AsSpan(); + var format = context.HasArgument(0) ? context.GetStringArgument(0).AsSpan() : "%c".AsSpan(); if (format.IsEmpty) return new(context.Return("")); @@ -155,7 +153,7 @@ public ValueTask GetEnv( CancellationToken cancellationToken ) { - var variable = context.GetArgument(0); + var variable = context.GetStringArgument(0); var platform = context.State.Platform; var value = platform.OsEnvironment.GetEnvironmentVariable(platform.DecodeText(variable)); return new(context.Return(value is null ? LuaValue.Nil : platform.EncodeText(value))); @@ -166,7 +164,7 @@ public async ValueTask Remove( CancellationToken cancellationToken ) { - var fileName = context.GetArgument(0); + var fileName = context.GetStringArgument(0); try { await context.GlobalState.Platform.FileSystem.Remove( @@ -190,8 +188,8 @@ public async ValueTask Rename( CancellationToken cancellationToken ) { - var oldName = context.GetArgument(0); - var newName = context.GetArgument(1); + var oldName = context.GetStringArgument(0); + var newName = context.GetStringArgument(1); try { await context.GlobalState.Platform.FileSystem.Rename( @@ -218,7 +216,7 @@ CancellationToken cancellationToken { // os.setlocale is not supported besides the standard "C" POSIX locale // Any locale besides "C" will return nil - var locale = context.GetArgument(0); + var locale = context.GetStringArgument(0); if (locale == "C") { return new(context.Return(locale)); diff --git a/src/Lua/Standard/StringLibrary.cs b/src/Lua/Standard/StringLibrary.cs index 1f8a92c8..3d4aef1c 100644 --- a/src/Lua/Standard/StringLibrary.cs +++ b/src/Lua/Standard/StringLibrary.cs @@ -42,7 +42,7 @@ public ValueTask Byte( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); var i = context.HasArgument(1) ? ReadStringIndex(context, 1) : 1; var j = context.HasArgument(2) ? ReadStringIndex(context, 2) : i; @@ -112,7 +112,7 @@ public async ValueTask Format( CancellationToken cancellationToken ) { - var format = context.GetArgument(0); + var format = context.GetStringArgument(0); var stack = context.State.Stack; // TODO: pooling StringBuilder StringBuilder builder = new(format.Length * 2); @@ -463,8 +463,8 @@ public ValueTask GMatch( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); - var pattern = context.GetArgument(1); + var s = context.GetStringArgument(0); + var pattern = context.GetStringArgument(1); return new( context.Return( @@ -588,8 +588,8 @@ public async ValueTask GSub( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); - var pattern = context.GetArgument(1); + var s = context.GetStringArgument(0); + var pattern = context.GetStringArgument(1); var repl = context.GetArgument(2); var n_arg = context.HasArgument(3) ? context.GetArgument(3) : s.Length + 1; @@ -783,7 +783,7 @@ public ValueTask Len( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); return new(context.Return(s.Length)); } @@ -792,7 +792,7 @@ public ValueTask Lower( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); return new( context.Return( context.State.Platform.StringMode == LuaStringMode.Bytes @@ -812,8 +812,8 @@ CancellationToken cancellationToken public ValueTask FindAux(LuaFunctionExecutionContext context, bool find) { - var s = context.GetArgument(0); - var pattern = context.GetArgument(1); + var s = context.GetStringArgument(0); + var pattern = context.GetStringArgument(1); var init = context.HasArgument(2) ? ReadStringIndex(context, 2) : 1; var offset = Math.Max(1, StringHelper.RelativePosition(init, s.Length)) - 1; @@ -918,9 +918,9 @@ public ValueTask Rep( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); var n_arg = context.GetArgument(1); - var sep = context.HasArgument(2) ? context.GetArgument(2) : null; + var sep = context.HasArgument(2) ? context.GetStringArgument(2) : null; if (!MathEx.IsFinite(n_arg)) { @@ -982,7 +982,7 @@ public ValueTask Reverse( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); var result = s.Length <= 1 ? s @@ -1003,7 +1003,7 @@ public ValueTask Sub( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); var i = ReadStringIndex(context, 1); var j = context.HasArgument(2) ? ReadStringIndex(context, 2) : -1; @@ -1016,7 +1016,7 @@ public ValueTask Upper( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); return new( context.Return( context.State.Platform.StringMode == LuaStringMode.Bytes diff --git a/src/Lua/Standard/TableLibrary.cs b/src/Lua/Standard/TableLibrary.cs index 2d30f3da..739193b2 100644 --- a/src/Lua/Standard/TableLibrary.cs +++ b/src/Lua/Standard/TableLibrary.cs @@ -78,7 +78,7 @@ CancellationToken cancellationToken ) { var arg0 = context.GetArgument(0); - var arg1 = context.HasArgument(1) ? context.GetArgument(1) : ""; + var arg1 = context.HasArgument(1) ? context.GetStringArgument(1) : ""; var arg2 = context.HasArgument(2) ? ToInteger(context.GetArgument(2)) : 1; var arg3 = context.HasArgument(3) ? ToInteger(context.GetArgument(3)) diff --git a/tests/Lua.Tests/MemoryStreamReadTests.cs b/tests/Lua.Tests/MemoryStreamReadTests.cs new file mode 100644 index 00000000..f40127d9 --- /dev/null +++ b/tests/Lua.Tests/MemoryStreamReadTests.cs @@ -0,0 +1,72 @@ +using Lua.IO; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(false)] +[TestFixture(true)] +public class MemoryStreamReadTests(bool stringBacked) +{ + ILuaStream Create(string text) => + stringBacked + ? ILuaStream.CreateFromString(text) + : ILuaStream.CreateFromMemory(text.AsMemory()); + + [Test] + public async Task ReadAllConsumesWholeAndRemainingContent() + { + using var stream = Create("abc"); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo("abc")); + Assert.That(stream.Seek(SeekOrigin.Current, 0), Is.EqualTo(3)); + Assert.That(await stream.ReadAsync(1, default), Is.Null); + Assert.That(await stream.ReadAllAsync(default), Is.Empty); + stream.Seek(SeekOrigin.Begin, 0); + Assert.That(await stream.ReadAsync(1, default), Is.EqualTo("a")); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo("bc")); + Assert.That(await stream.ReadAllAsync(default), Is.Empty); + } + + [TestCase("x12", "x12")] + [TestCase(" x12", "x12")] + [TestCase(" \t\r\n", "")] + [TestCase("", "")] + public async Task FailedNumericReadsConsumeOnlyLeadingWhitespace(string input, string remainder) + { + using var stream = Create(input); + Assert.That(await stream.ReadNumberAsync(default), Is.Null); + Assert.That(await stream.ReadNumberAsync(default), Is.Null); + Assert.That( + stream.Seek(SeekOrigin.Current, 0), + Is.EqualTo(input.Length - remainder.Length) + ); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo(remainder)); + } + + [TestCase(" 12 rest", 12d)] + [TestCase(" -3.5 rest", -3.5)] + [TestCase(" 0x1.fp2 rest", 7.75)] + public async Task SuccessfulNumericReadsPreserveFollowingText(string input, double expected) + { + using var stream = Create(input); + Assert.That(await stream.ReadNumberAsync(default), Is.EqualTo(expected)); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo(" rest")); + } + + [Test] + public async Task LuaFileReadsFollowTheSameCursorRules() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var whole = Create("abc"); + using var numeric = Create(" x12"); + state.Environment["whole"] = new LuaValue(new FileHandle(whole)); + state.Environment["numeric"] = new LuaValue(new FileHandle(numeric)); + var result = await state.DoStringAsync( + "return whole:read('*a'),whole:read(1),whole:read('*a'),numeric:read('*n'),numeric:read('*a')" + ); + Assert.That( + result, + Is.EqualTo(new LuaValue[] { "abc", LuaValue.Nil, "", LuaValue.Nil, "x12" }) + ); + } +} diff --git a/tests/Lua.Tests/StringArgumentConversionTests.cs b/tests/Lua.Tests/StringArgumentConversionTests.cs new file mode 100644 index 00000000..f2dfcece --- /dev/null +++ b/tests/Lua.Tests/StringArgumentConversionTests.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaNumberFormat.DotNet)] +[TestFixture(LuaNumberFormat.Lua)] +public class StringArgumentConversionTests(LuaNumberFormat format) +{ + LuaState CreateState() + { + var state = LuaState.Create(LuaPlatform.Default with { NumberFormat = format }); + state.OpenStandardLibraries(); + return state; + } + + [Test] + public async Task NumericStringArgumentsWorkAcrossStringFunctions() + { + using var state = CreateState(); + var result = await state.DoStringAsync( + """ + local first,last=string.find(12345,23) + local iterator=string.gmatch(123,'%d') + return string.byte(123,2),string.format(123),string.len(123), + string.lower(123),string.upper(123),string.reverse(123), + string.sub(12345,2,4),string.rep(12,2,3),string.match(12345,23), + (string.gsub(12345,23,0)),first,last,iterator(),iterator(),iterator(), + table.concat({'a','b'},12) + """ + ); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] + { + 50, + "123", + 3, + "123", + "123", + "321", + "234", + "12312", + "23", + "1045", + 2, + 3, + "1", + "2", + "3", + "a12b", + } + ) + ); + } + + [TestCase(1d / 3)] + [TestCase(-0d)] + [TestCase(double.PositiveInfinity)] + [TestCase(double.NaN)] + public async Task NumericStringArgumentsUseTheSelectedInvariantFormat(double number) + { + using var state = CreateState(); + state.Environment["number"] = number; + var expected = (await state.DoStringAsync("return tostring(number)"))[0].Read(); + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var result = await state.DoStringAsync( + "return string.len(number),string.sub(number,1),string.format(number),string.match(number,'.+'),table.concat({'a','b'},number)" + ); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] + { + expected.Length, + expected, + expected, + expected, + "a" + expected + "b", + } + ) + ); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + [TestCase("nil")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("setmetatable({}, {__tostring=function() error('unexpected tostring') end})")] + public async Task StringArgumentsRejectOtherTypesWithoutCallingTostring(string expression) + { + using var state = CreateState(); + var result = await state.DoStringAsync($"return pcall(string.len,{expression})"); + Assert.That(result[0], Is.EqualTo(new LuaValue(false))); + Assert.That(result[1].Read(), Does.Contain("string expected")); + } + + [Test] + public async Task OtherLuaLibraryStringArgumentsAcceptNumbers() + { + using var state = CreateState(); + var result = await state.DoStringAsync( + "package.preload['123']=function() return 42 end;local f=assert(load('return 43',123));return require(123),f()" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 42, 43 })); + } + + [Test] + public async Task CoercedArgumentsRemainStringsDuringReentrantCallbacks() + { + using var state = CreateState(); + var result = await state.DoStringAsync( + """ + local source,search=123,2 + local subject,pattern + local replaced=string.gsub(source,search,function() + local _,value=debug.getlocal(2,1) + subject=value + _,value=debug.getlocal(2,2) + pattern=value + return 'x' + end) + return subject,pattern,replaced,source,search + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { "123", "2", "1x3", 123, 2 })); + } +} diff --git a/tests/Lua.Tests/ValueConversionTests.cs b/tests/Lua.Tests/ValueConversionTests.cs new file mode 100644 index 00000000..f531640a --- /dev/null +++ b/tests/Lua.Tests/ValueConversionTests.cs @@ -0,0 +1,136 @@ +using Lua.Standard; + +namespace Lua.Tests; + +[LuaObject] +public partial class ValueConversionObject +{ + [LuaMember] + public static int Int32(int value) => value; + + [LuaMember] + public static long Int64(long value) => value; + + [LuaMember] + public static uint UInt32(uint value) => value; + + [LuaMember] + public static ulong UInt64(ulong value) => value; + + [LuaMember] + public static string ObjectType(object value) => value.GetType().Name; + + [LuaMember] + public static string StringEcho(string value) => value; +} + +public class ValueConversionTests +{ + static void AssertRead(double number, T expected) + { + LuaValue value = number; + Assert.That(value.TryRead(out var actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + Assert.That(value.Read(), Is.EqualTo(expected)); + } + + static void AssertRejected(double number) + { + LuaValue value = number; + Assert.That(value.TryRead(out var actual), Is.False); + Assert.That(actual, Is.EqualTo(default(T))); + Assert.Throws(() => value.Read()); + } + + [Test] + public void IntegerReadsAcceptRepresentableBounds() + { + AssertRead(int.MinValue, int.MinValue); + AssertRead(int.MaxValue, int.MaxValue); + AssertRead(long.MinValue, long.MinValue); + AssertRead(9223372036854774784d, 9223372036854774784L); + AssertRead(uint.MaxValue, uint.MaxValue); + AssertRead(18446744073709549568d, 18446744073709549568UL); + AssertRead(-0d, 0); + AssertRead(-0d, 0L); + AssertRead(-0d, 0U); + AssertRead(-0d, 0UL); + } + + [Test] + public void IntegerReadsRejectAdjacentOutOfRangeValues() + { + AssertRejected((double)int.MinValue - 1); + AssertRejected((double)int.MaxValue + 1); + AssertRejected(Math.BitDecrement((double)long.MinValue)); + AssertRejected(9223372036854775808d); + AssertRejected(-1); + AssertRejected((double)uint.MaxValue + 1); + AssertRejected(-1); + AssertRejected(18446744073709551616d); + } + + [TestCase(double.NaN)] + [TestCase(double.NegativeInfinity)] + [TestCase(double.PositiveInfinity)] + [TestCase(1.25)] + [TestCase(1e30)] + public void InvalidIntegerReadsReturnFalseWithoutThrowing(double number) + { + AssertRejected(number); + AssertRejected(number); + AssertRejected(number); + AssertRejected(number); + } + + [TestCase(false)] + [TestCase(true)] + public void BooleansReadAsObjectsPreserveTheirTypeAndRoundTrip(bool boolean) + { + LuaValue value = boolean; + Assert.That(value.TryRead(out var boxed), Is.True); + Assert.That(boxed, Is.TypeOf().And.EqualTo(boolean)); + Assert.That(value.Read(), Is.TypeOf().And.EqualTo(boolean)); + Assert.That(LuaValue.FromObject(boxed), Is.EqualTo(value)); + } + + [TestCase("Int32", 2147483648d)] + [TestCase("Int64", 9223372036854775808d)] + [TestCase("UInt32", -1d)] + [TestCase("UInt64", 18446744073709551616d)] + public async Task GeneratedIntegerArgumentsRejectOverflowAsLuaErrors( + string method, + double number + ) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["target"] = new ValueConversionObject(); + state.Environment["number"] = number; + var result = await state.DoStringAsync($"return pcall(target.{method},number)"); + Assert.That(result[0], Is.EqualTo(new LuaValue(false))); + Assert.That( + result[1].Read(), + Does.Contain("bad argument #1").And.Contain("integer representation") + ); + Assert.That( + (await state.DoStringAsync($"return target.{method}(42)"))[0], + Is.EqualTo(new LuaValue(42)) + ); + Assert.That(state.Stack.Count, Is.Zero); + } + + [Test] + public async Task GeneratedObjectArgumentsReceiveBooleansAndStringArgumentsStayStrict() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["target"] = new ValueConversionObject(); + var result = await state.DoStringAsync( + "return target.ObjectType(true),target.ObjectType(false),target.ObjectType(12),pcall(target.StringEcho,123)" + ); + Assert.That(result.Take(3), Is.EqualTo(new LuaValue[] { "Boolean", "Boolean", "Double" })); + Assert.That(result[3], Is.EqualTo(new LuaValue(false))); + Assert.That(new LuaValue(123).TryRead(out _), Is.False); + } +} From 3509f565b9cc1e6da7798033a7bfec9b010af13c Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:04:08 +0900 Subject: [PATCH 2/2] fix: preserve 64-bit Lua file seek offsets Read file:seek offsets as long to match ILuaStream.Seek and FileHandle.Seek. This avoids a 32-bit restriction when the underlying .NET stream supports larger offsets and keeps range validation at the existing conversion boundary. Lua 5.2 likewise sizes offsets for its configured file backend. Add a position-only regression covering a 3 GB absolute seek, querying the position, and a negative relative offset beyond Int32 range. The original binary fails this case; no large files or buffers are created by the test. Validation: Release solution builds all runtime targets without warnings. All 77 focused conversion, memory-stream, file-seek and I/O tests pass on .NET 8 and .NET 10. CSharpier and diff checks pass. --- src/Lua/Standard/FileHandle.cs | 4 ++-- tests/Lua.Tests/FileSeekTests.cs | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 tests/Lua.Tests/FileSeekTests.cs diff --git a/src/Lua/Standard/FileHandle.cs b/src/Lua/Standard/FileHandle.cs index b2521add..d8886628 100644 --- a/src/Lua/Standard/FileHandle.cs +++ b/src/Lua/Standard/FileHandle.cs @@ -245,7 +245,7 @@ static async (context, cancellationToken) => { var file = context.GetArgument(0); var whence = context.HasArgument(1) ? context.GetStringArgument(1) : "cur"; - var offset = context.HasArgument(2) ? context.GetArgument(2) : 0; + var offset = context.HasArgument(2) ? context.GetArgument(2) : 0; if (whence is not ("set" or "cur" or "end")) { @@ -257,7 +257,7 @@ static async (context, cancellationToken) => try { - return new(context.Return(file.Seek(whence, (long)offset))); + return new(context.Return(file.Seek(whence, offset))); } catch (IOException ex) { diff --git a/tests/Lua.Tests/FileSeekTests.cs b/tests/Lua.Tests/FileSeekTests.cs new file mode 100644 index 00000000..14780314 --- /dev/null +++ b/tests/Lua.Tests/FileSeekTests.cs @@ -0,0 +1,37 @@ +using Lua.IO; +using Lua.Standard; + +namespace Lua.Tests; + +public class FileSeekTests +{ + [Test] + public async Task LuaSeekAcceptsOffsetsBeyondInt32() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var stream = new PositionStream(); + state.Environment["file"] = new LuaValue(new FileHandle(stream)); + var result = await state.DoStringAsync( + "return file:seek('set',3000000000),file:seek(),file:seek('cur',-2999999999)" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 3000000000d, 3000000000d, 1 })); + } + + sealed class PositionStream : ILuaStream + { + long position; + public bool IsOpen { get; private set; } = true; + public LuaFileOpenMode Mode => LuaFileOpenMode.Read; + + public void Dispose() => IsOpen = false; + + public long Seek(SeekOrigin origin, long offset) => + position = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => position + offset, + _ => throw new NotSupportedException(), + }; + } +}