From 388816b0153a7d35f1a9babf17ab1a609f8a860f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 29 Sep 2026 21:41:20 -0400 Subject: [PATCH] fix(math): bound fraction regex execution --- OpenNest.Core/Math/Fraction.cs | 4 +- OpenNest.Tests/Math/FractionTests.cs | 99 ++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/OpenNest.Core/Math/Fraction.cs b/OpenNest.Core/Math/Fraction.cs index be192f9..0b58255 100644 --- a/OpenNest.Core/Math/Fraction.cs +++ b/OpenNest.Core/Math/Fraction.cs @@ -8,7 +8,9 @@ namespace OpenNest.Math public static class Fraction { public static readonly Regex FractionRegex = new Regex( - @"((?\d+)(\ |-))?(?\d+\/\d+)" + @"((?\d+)(\ |-))?(?\d+\/\d+)", + RegexOptions.None, + TimeSpan.FromMilliseconds(250) ); public static bool IsValid(string s) diff --git a/OpenNest.Tests/Math/FractionTests.cs b/OpenNest.Tests/Math/FractionTests.cs index 3253e71..7c7dd8d 100644 --- a/OpenNest.Tests/Math/FractionTests.cs +++ b/OpenNest.Tests/Math/FractionTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text.RegularExpressions; using OpenNest.Math; using Xunit; @@ -5,6 +7,103 @@ namespace OpenNest.Tests.Math; public class FractionTests { + // Calibrated on .NET 8: unbounded no-match takes seconds, well above the 250 ms budget. + private static string TimeoutInput => new string('1', 50000); + + [Fact] + public void FractionRegex_HasExplicitTimeout() + { + Assert.Equal(TimeSpan.FromMilliseconds(250), Fraction.FractionRegex.MatchTimeout); + } + + [Theory] + [InlineData("abc 3/8 xyz", 0.375)] + [InlineData("abc 1 3/4 xyz", 1.75)] + [InlineData("abc 1-3/4 xyz", 1.75)] + public void Fraction_PreservesSubstringMatchingAndMixedSeparators(string input, double expected) + { + Assert.True(Fraction.IsValid(input)); + Assert.Equal(expected, Fraction.Parse(input), 8); + } + + [Theory] + [InlineData("en-US")] + [InlineData("fr-FR")] + public void Parse_UsesCurrentCultureForOrdinaryIntegerComponents(string culture) + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); + // The implementation uses current-culture double.Parse, not invariant parsing. + // Ordinary digits are invariant-parseable too; exotic numeric syntax is not this contract. + var expected = double.Parse("1") + System.Math.Round(double.Parse("3") / double.Parse("4"), 8); + Assert.Equal(expected, Fraction.Parse("1 3/4"), 8); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void ReplaceFractionsWithDecimals_PreservesOrderAndUnmatchedText() + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + Assert.Equal("first 0.5, then 1.75, finally 0.375!", + Fraction.ReplaceFractionsWithDecimals("first 1/2, then 1-3/4, finally 3/8!")); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void Parse_OnTimeout_ThrowsRatherThanReturningValue() + { + Assert.Throws(() => Fraction.Parse(TimeoutInput)); + } + + [Fact] + public void IsValid_OnTimeout_ThrowsRatherThanReturningValidity() + { + Assert.Throws(() => Fraction.IsValid(TimeoutInput)); + } + + [Fact] + public void ReplaceFractionsWithDecimals_OnTimeout_ThrowsRatherThanReturningText() + { + Assert.Throws(() => Fraction.ReplaceFractionsWithDecimals(TimeoutInput)); + } + + [Fact] + public void ReplaceFractionsWithDecimals_AfterEarlierMatchTimeout_ReturnsNoPartialText() + { + var input = "3/8 " + TimeoutInput; + var matches = Fraction.FractionRegex.Matches(input).GetEnumerator(); + Assert.True(matches.MoveNext()); + Assert.Equal("3/8", ((Match)matches.Current).Value); + Assert.Throws(() => matches.MoveNext()); + + // MatchCollection is lazy; even after an earlier match, the method's local + // StringBuilder never escapes if enumeration fails (including its sorting step). + Assert.Throws(() => Fraction.ReplaceFractionsWithDecimals(input)); + Assert.StartsWith("3/8 ", input); + } + + [Fact] + public void TryParse_OnTimeout_ReturnsFalseAndZero() + { + var input = TimeoutInput; + Assert.Throws(() => Fraction.Parse(input)); + Assert.False(Fraction.TryParse(input, out var fraction)); + Assert.Equal(0, fraction); + } + [Theory] [InlineData("3/8", 0.375)] [InlineData("1 3/4", 1.75)]