godot icon indicating copy to clipboard operation
godot copied to clipboard

StringExtensions.CompareTo leads to IndexOutOfRangeException on non-empty strings

Open krisutofu opened this issue 10 months ago • 12 comments

Tested versions

It happens in 4.2.2-stable and 4.2.2-rc1, but likely all others too because they use the same implementation.

System information

Godot v4.2.2.stable.mono - Garuda Linux #1 ZEN SMP PREEMPT_DYNAMIC Wed, 17 Apr 2024 15:20:00 +0000 - X11 - Vulkan (Mobile) - integrated Intel(R) Xe Graphics (TGL GT2) () - 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz (8 Threads)

Issue description

Calling StringExtensions.CompareTo() with non-empty strings crashes the script with IndexOutOfRangeException. You falsely assume, the strings are nul-terminated. Maybe Godot.Strings are (is this just a copy of the code from your implementation for GDScript?) but normal C# strings aren't which you are using in the parameters.

Just replace stringVariable[index] == 0 with index >= stringVariable.Length and you are good. This might be a good time to check your other C# functions.

Here it is: StringExtensions.cs:452…489

PS: copy-pasting that code is dirty and should be cleaned up. The performance benefit is almost non-existent. Not recommended.

Steps to reproduce

Add a C# script and write if ("a".CasecmpTo("a") == 0); into it. When it is run, it will crash script execution.

Minimal reproduction project (MRP)

CompareToBug.zip

krisutofu avatar Apr 24 '24 17:04 krisutofu

To be honest, I don't know what we're not simply calling CompareInfo.Compare(string, string, CompareOptions) here.

paulloz avatar Apr 24 '24 21:04 paulloz

My thought was similar. It looks like it fell under the Radar because C# provides these functions already and calling string.CompareTo(string) does not call the StringExtensions method of Godot but the standard .NET method in which everything works fine. I only actually noticed it because I added the boolean argument.

For someone who wants to exercise using Git pull requests, it could look like this:

public static int CompareTo(this string instance, string to, bool caseSensitive = true)
{
    return String.Compare(instance, to, caseSensitive? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);
}

krisutofu avatar Apr 25 '24 18:04 krisutofu

I ran a quick test to check if String.Compare would work and found out that:

Mathf.Clamp(String.Compare(instance, to, caseSensitive? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase), -1, 1)

outputs a different result to your originally suggested fix (index >= stringVariable.Length) ~50% of the time.

I don't know if this will cause any issues. Maybe it's risky to have inconsistent results between c# and GDScript? At the same time it might be more risky for the StringExtensions method to not match the expected C# behaviour, in which case we should also drop the [-1,1] range...

pirey0 avatar Apr 25 '24 23:04 pirey0

Using invariant instead of ordinal should match the implementation more closely IMHO.

paulloz avatar Apr 26 '24 04:04 paulloz

You are right, I re-ran the test using Invariant and got a 100% match for both String.Compare and InvariantCulture.CompareInfo.Compare.

pirey0 avatar Apr 26 '24 08:04 pirey0

Ah, I didn't know, GDScript uses an Invariant Culture like comparison. The original C# implementation (that I linked above) looks perfectly ordinal to me (instance[instanceIndex] < to[toIndex]) and using InvariantCulture comparison could be a breaking change, or I am missing something. But people get Invariant Culture comparison when they don't provide the boolean 2nd argument because Invariant Culture comparison is the comparison used by C#'s own string.CompareTo(). Maybe this means, InvariantCulture comparison indeed reduces the surprises (between passing a 2nd argument true and no second argument). The behaviour just needs to be documented properly.

krisutofu avatar Apr 26 '24 21:04 krisutofu

Ah, I didn't know, GDScript uses an Invariant Culture like comparison.

It does not, it is ordinal. But I'd still be inclined to match default BCL behaviour here, as I think that's what most people would expect.

About the change being breaking, yes, but the current behaviour we are breaking is an NRE so I'm not sure how much of a consideration that really is.

Cc @raulsntos @Repiteo since we talked quite a lot about cultures and comparisons a while ago.

paulloz avatar Apr 26 '24 22:04 paulloz

While I'm generally in favor of matching BCL behavior over GDScript behavior, the purpose of StringExtensions is to match the Godot string methods. In this particular case, it may be better for C# users to just use string.CompareTo with a StringComparison parameter directly which allows you to get the exact behavior you want.

Personally, I'd just deprecate this method since we already have string.CompareTo in the BCL. But last time I proposed this I was told that the boolean parameter was more convenient over the StringComparison parameter so it was worth keeping it.

Regarding changing behavior, @paulloz is right. Since the current behavior is a bug (resulting in an exception), it's fine to change behavior here.

raulsntos avatar Apr 27 '24 00:04 raulsntos

It's not technically a behaviour bug (the behaviour is ordinal comparison, the bug is the nul-termination assumption) but never mind, just make sure to document the choice, e.g. here.

I would fully support deprecating this Godot method because there is a BCL-type method String.Compare(String, String, Boolean) https://learn.microsoft.com/en-us/dotnet/api/system.string.compare?view=net-8.0#system-string-compare(system-string-system-string-system-boolean) which already does string comparison with boolean ignoreCase parameter. Typing just an additional String class name in front is not a valid argument about less convenience as I think.

krisutofu avatar Apr 27 '24 13:04 krisutofu

This wouldn't be the first time a StringExtensions method was deprecated in favor of a native alternative; I say we just go for that.

Repiteo avatar Apr 27 '24 13:04 Repiteo

I didn't realize there was also an overload with a boolean, I must have confused this with another method. In that case I agree that we should just deprecate it.

raulsntos avatar Apr 28 '24 01:04 raulsntos

TBH, even deprecated, the method probably should not NRE. It doesn't change the fact that that needs to be addressed.

paulloz avatar Apr 28 '24 06:04 paulloz