using System.Buffers; using System.Globalization; using System.Runtime.InteropServices.JavaScript; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; public static partial class RegexBridge { private const int AbiVersion = 1; private const int MaximumCaptureGroups = 1_000; private static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(9); private static readonly Lazy ReplacementParity = new( ReplacementParitySelfTest ); [JSExport] public static string Identity() { if (!ReplacementParity.Value) throw new InvalidOperationException( "The bounded replacement parser failed its native parity self-test." ); return "dotnet-regex|10.0.10|System.Text.RegularExpressions|utf16|culture-invariant|replacement-parity"; } [JSExport] public static string Run(string requestJson) { try { using var document = JsonDocument.Parse( requestJson, new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow, MaxDepth = 16, } ); var root = document.RootElement; if (RequiredInt(root, "abi") != AbiVersion) return Failure("bridge", "unsupported-abi", "Unsupported bridge ABI."); var operation = RequiredString(root, "operation", 16); if (operation is not ("execute" or "replace")) return Failure( "bridge", "invalid-operation", "Unsupported bridge operation." ); var pattern = RequiredString(root, "pattern", 65_536); var subject = RequiredString(root, "subject", 16 * 1024 * 1024); var flags = RequiredString(root, "flags", 32); var maximumMatches = RequiredInt(root, "maximumMatches"); var maximumCaptureRows = RequiredInt(root, "maximumCaptureRows"); if ( maximumMatches is < 1 or > 10_000 || maximumCaptureRows is < 1 or > 100_000 ) return Failure( "bridge", "invalid-limit", "Result limits are outside the bridge contract." ); var replacement = operation == "replace" ? RequiredString(root, "replacement", 65_536) : string.Empty; var maximumOutputBytes = operation == "replace" ? RequiredInt(root, "maximumOutputBytes") : 1; if ( operation == "replace" && maximumOutputBytes is < 1 or > 64 * 1024 * 1024 ) return Failure( "bridge", "invalid-limit", "Output limit is outside the bridge contract." ); Regex expression; try { expression = new Regex( pattern, RegexOptionsFor(flags) | RegexOptions.CultureInvariant, MatchTimeout ); } catch (RegexParseException error) { return Failure( "compile", "regex-parse-error", error.Message, error.Offset ); } catch (ArgumentException error) { return Failure("compile", "regex-parse-error", error.Message); } var numbers = expression.GetGroupNumbers(); var groupCount = numbers.Length == 0 ? 0 : numbers.Max(); if (groupCount > MaximumCaptureGroups) return Failure( "compile", "capture-group-limit", "The pattern exceeds the 1,000 capture-group bridge limit." ); var presentNumbers = numbers.ToHashSet(); var groupNames = expression .GetGroupNames() .Select(name => (Name: name, Number: expression.GroupNumberFromName(name))) .Where(item => item.Number > 0 && !int.TryParse( item.Name, NumberStyles.None, CultureInfo.InvariantCulture, out _ ) ) .OrderBy(item => item.Number) .ToArray(); ReplacementProgram? replacementProgram = null; if (operation == "replace") { try { replacementProgram = ReplacementProgram.Parse( replacement, expression, presentNumbers ); } catch (ArgumentException error) { return Failure( "replacement", "replacement-error", error.Message ); } } var records = new List(); var captureRows = 0; var resultsTruncated = false; var global = flags.Contains('g'); try { for (var match = expression.Match(subject); match.Success; match = match.NextMatch()) { var rows = Math.Max(1, groupCount); if ( records.Count >= maximumMatches || captureRows + rows > maximumCaptureRows ) { resultsTruncated = true; break; } var captures = new NativeSpan?[groupCount]; for (var number = 1; number <= groupCount; number++) { if (!presentNumbers.Contains(number)) continue; var group = match.Groups[number]; if (group.Success) captures[number - 1] = new NativeSpan( group.Index, group.Index + group.Length ); } records.Add( new MatchRecord( new NativeSpan(match.Index, match.Index + match.Length), captures, match ) ); captureRows += rows; if (!global) break; } } catch (RegexMatchTimeoutException error) { return Failure("match", "match-timeout", error.Message); } BoundedUtf8Output? output = null; if (operation == "replace") { output = new BoundedUtf8Output(maximumOutputBytes); var sourcePosition = 0; try { foreach (var record in records.OrderBy(record => record.Span.Start)) { output.Append( subject.AsSpan( sourcePosition, record.Span.Start - sourcePosition ) ); AppendReplacement( output, subject, record.Match, replacementProgram! ); sourcePosition = record.Span.End; } output.Append(subject.AsSpan(sourcePosition)); } catch (ArgumentException error) { return Failure( "replacement", "replacement-error", error.Message ); } } return Success( groupCount, groupNames, records, resultsTruncated, output ); } catch (JsonException error) { return Failure("bridge", "invalid-json", error.Message); } catch (Exception error) { return Failure("bridge", "bridge-error", error.Message); } } private static RegexOptions RegexOptionsFor(string flags) { var options = RegexOptions.None; foreach (var flag in flags) options |= flag switch { 'g' => RegexOptions.None, 'i' => RegexOptions.IgnoreCase, 'm' => RegexOptions.Multiline, 's' => RegexOptions.Singleline, 'n' => RegexOptions.ExplicitCapture, 'x' => RegexOptions.IgnorePatternWhitespace, 'r' => RegexOptions.RightToLeft, 'c' => RegexOptions.CultureInvariant, 'b' => RegexOptions.NonBacktracking, _ => throw new ArgumentException($"Unsupported .NET flag: {flag}."), }; return options; } private static string RequiredString(JsonElement root, string name, int maximumLength) { if ( !root.TryGetProperty(name, out var property) || property.ValueKind != JsonValueKind.String ) throw new JsonException($"{name} must be a string."); var value = property.GetString()!; if (value.Length > maximumLength) throw new JsonException($"{name} exceeds its bounded contract."); return value; } private static int RequiredInt(JsonElement root, string name) { if ( !root.TryGetProperty(name, out var property) || !property.TryGetInt32(out var value) ) throw new JsonException($"{name} must be a 32-bit integer."); return value; } private static string Failure( string phase, string code, string message, int? position = null ) { using var stream = new MemoryStream(); using (var writer = new Utf8JsonWriter(stream)) { writer.WriteStartObject(); writer.WriteNumber("abi", AbiVersion); writer.WriteBoolean("ok", false); writer.WriteString("phase", phase); writer.WriteString("code", code); writer.WriteString( "message", message.Length <= 1_024 ? message : message[..1_024] ); if (position >= 0) writer.WriteNumber("position", position.Value); writer.WriteEndObject(); } return Encoding.UTF8.GetString(stream.ToArray()); } private static string Success( int groupCount, IEnumerable<(string Name, int Number)> groupNames, IEnumerable records, bool resultsTruncated, BoundedUtf8Output? output ) { using var stream = new MemoryStream(); using (var writer = new Utf8JsonWriter(stream)) { writer.WriteStartObject(); writer.WriteNumber("abi", AbiVersion); writer.WriteBoolean("ok", true); writer.WriteNumber("groupCount", groupCount); writer.WriteStartArray("groupNames"); foreach (var group in groupNames) { writer.WriteStartArray(); writer.WriteNumberValue(group.Number); writer.WriteStringValue(group.Name); writer.WriteEndArray(); } writer.WriteEndArray(); writer.WriteStartArray("matches"); foreach (var record in records) { writer.WriteStartObject(); WriteSpan(writer, "span", record.Span); writer.WriteStartArray("captures"); foreach (var capture in record.Captures) { if (capture is { } span) WriteSpan(writer, null, span); else writer.WriteNullValue(); } writer.WriteEndArray(); writer.WriteEndObject(); } writer.WriteEndArray(); writer.WriteBoolean("resultsTruncated", resultsTruncated); if (output is not null) { writer.WriteBase64String("outputBase64", output.Bytes); writer.WriteNumber("outputBytes", output.ByteCount); writer.WriteBoolean("outputTruncated", output.Truncated); } writer.WriteEndObject(); } return Encoding.UTF8.GetString(stream.ToArray()); } private static void WriteSpan( Utf8JsonWriter writer, string? propertyName, NativeSpan span ) { if (propertyName is null) writer.WriteStartArray(); else writer.WriteStartArray(propertyName); writer.WriteNumberValue(span.Start); writer.WriteNumberValue(span.End); writer.WriteEndArray(); } private readonly record struct NativeSpan(int Start, int End); private sealed record MatchRecord( NativeSpan Span, NativeSpan?[] Captures, Match Match ); private static void AppendReplacement( BoundedUtf8Output output, string subject, Match match, ReplacementProgram replacement ) { foreach (var token in replacement.Tokens) { switch (token.Kind) { case ReplacementTokenKind.Literal: output.Append( replacement.Template.AsSpan(token.Start, token.Length) ); break; case ReplacementTokenKind.Group: { var group = match.Groups[token.GroupNumber]; if (group.Success) output.Append(subject.AsSpan(group.Index, group.Length)); break; } case ReplacementTokenKind.LeftPortion: output.Append(subject.AsSpan(0, match.Index)); break; case ReplacementTokenKind.RightPortion: { var start = match.Index + match.Length; output.Append(subject.AsSpan(start)); break; } case ReplacementTokenKind.LastGroup: { var group = match.Groups[replacement.LastGroupNumber]; if (group.Success) output.Append(subject.AsSpan(group.Index, group.Length)); break; } case ReplacementTokenKind.WholeString: output.Append(subject.AsSpan()); break; } } } private static bool ReplacementParitySelfTest() { try { var expression = new Regex( "(?a)(b)?(?c)?", RegexOptions.CultureInvariant, MatchTimeout ); var presentNumbers = expression.GetGroupNumbers().ToHashSet(); var templates = new[] { "$2:$1/${word}/${3}/$$/$&/$`/$'/$+/$_/$99/${missing}/$", "${1}$01-$0", "literal", }; for ( var match = expression.Match("zab a"); match.Success; match = match.NextMatch() ) { foreach (var template in templates) { var replacement = ReplacementProgram.Parse( template, expression, presentNumbers ); var bounded = new BoundedUtf8Output(16 * 1024); AppendReplacement( bounded, "zab a", match, replacement ); if ( bounded.Truncated || Encoding.UTF8.GetString(bounded.Bytes) != match.Result(template) ) return false; } } try { _ = ReplacementProgram.Parse( "$2147483648", expression, presentNumbers ); return false; } catch (ArgumentException) { try { _ = expression.Match("a").Result("$2147483648"); return false; } catch (ArgumentException) { // Both parsers reject an overflowing numeric reference. } } var largeSubject = new string('a', 256 * 1024); var largeExpression = new Regex( "(a+)", RegexOptions.CultureInvariant, MatchTimeout ); var amplifyingTemplate = string.Concat( Enumerable.Repeat("$1", 32_768) ); var amplifyingProgram = ReplacementProgram.Parse( amplifyingTemplate, largeExpression, largeExpression.GetGroupNumbers().ToHashSet() ); var adversarial = new BoundedUtf8Output(5); AppendReplacement( adversarial, largeSubject, largeExpression.Match(largeSubject), amplifyingProgram ); return adversarial.Truncated && Encoding.UTF8.GetString(adversarial.Bytes) == "aaaaa"; } catch { return false; } } private enum ReplacementTokenKind { Literal, Group, LeftPortion, RightPortion, LastGroup, WholeString, } private readonly record struct ReplacementToken( ReplacementTokenKind Kind, int Start = 0, int Length = 0, int GroupNumber = 0 ); private sealed record ReplacementProgram( string Template, ReplacementToken[] Tokens, int LastGroupNumber ) { public static ReplacementProgram Parse( string template, Regex expression, HashSet presentNumbers ) { var tokens = new List(); var literalStart = 0; var cursor = 0; void AddLiteral(int end) { if (end > literalStart) tokens.Add( new ReplacementToken( ReplacementTokenKind.Literal, literalStart, end - literalStart ) ); } void AddRecognized( int dollar, int end, ReplacementToken token ) { AddLiteral(dollar); tokens.Add(token); cursor = end; literalStart = end; } while (cursor < template.Length) { var dollar = template.IndexOf('$', cursor); if (dollar < 0) break; if (dollar + 1 >= template.Length) { cursor = dollar + 1; continue; } var next = template[dollar + 1]; if (next == '$') { AddRecognized( dollar, dollar + 2, new ReplacementToken( ReplacementTokenKind.Literal, dollar + 1, 1 ) ); continue; } if (next == '&') { AddRecognized( dollar, dollar + 2, new ReplacementToken( ReplacementTokenKind.Group, GroupNumber: 0 ) ); continue; } if (next == '`') { AddRecognized( dollar, dollar + 2, new ReplacementToken( ReplacementTokenKind.LeftPortion ) ); continue; } if (next == '\'') { AddRecognized( dollar, dollar + 2, new ReplacementToken( ReplacementTokenKind.RightPortion ) ); continue; } if (next == '+') { AddRecognized( dollar, dollar + 2, new ReplacementToken( ReplacementTokenKind.LastGroup ) ); continue; } if (next == '_') { AddRecognized( dollar, dollar + 2, new ReplacementToken( ReplacementTokenKind.WholeString ) ); continue; } var angled = next == '{' && dollar + 2 < template.Length; var referenceStart = dollar + (angled ? 2 : 1); if ( referenceStart < template.Length && template[referenceStart] is >= '0' and <= '9' ) { var number = 0; var end = referenceStart; while ( end < template.Length && template[end] is >= '0' and <= '9' ) { var digit = template[end] - '0'; if ( number > int.MaxValue / 10 || ( number == int.MaxValue / 10 && digit > int.MaxValue % 10 ) ) throw new ArgumentException( "A replacement capture number exceeds Int32.MaxValue." ); number = number * 10 + digit; end++; } var syntaxComplete = !angled || ( end < template.Length && template[end] == '}' ); if (syntaxComplete && presentNumbers.Contains(number)) { AddRecognized( dollar, angled ? end + 1 : end, new ReplacementToken( ReplacementTokenKind.Group, GroupNumber: number ) ); continue; } } else if ( angled && referenceStart < template.Length && IsBoundaryWordCharacter(template[referenceStart]) ) { var end = referenceStart + 1; while ( end < template.Length && IsBoundaryWordCharacter(template[end]) ) end++; if (end < template.Length && template[end] == '}') { var name = template[ referenceStart..end ]; var number = expression.GroupNumberFromName(name); if (number >= 0) { AddRecognized( dollar, end + 1, new ReplacementToken( ReplacementTokenKind.Group, GroupNumber: number ) ); continue; } } } cursor = dollar + 1; } AddLiteral(template.Length); var numbers = expression.GetGroupNumbers(); return new ReplacementProgram( template, tokens.ToArray(), numbers.Length == 0 ? 0 : numbers[^1] ); } private static bool IsBoundaryWordCharacter(char value) { if (value is '\u200c' or '\u200d') return true; return CharUnicodeInfo.GetUnicodeCategory(value) switch { UnicodeCategory.UppercaseLetter => true, UnicodeCategory.LowercaseLetter => true, UnicodeCategory.TitlecaseLetter => true, UnicodeCategory.ModifierLetter => true, UnicodeCategory.OtherLetter => true, UnicodeCategory.NonSpacingMark => true, UnicodeCategory.DecimalDigitNumber => true, UnicodeCategory.ConnectorPunctuation => true, _ => false, }; } } private sealed class BoundedUtf8Output(int maximumBytes) { private readonly ArrayBufferWriter _value = new(); public int ByteCount => _value.WrittenCount; public bool Truncated { get; private set; } public ReadOnlySpan Bytes => _value.WrittenSpan; public void Append(ReadOnlySpan value) { if (Truncated) return; while (!value.IsEmpty) { var status = Rune.DecodeFromUtf16(value, out var rune, out var consumed); if (status != OperationStatus.Done) { rune = Rune.ReplacementChar; consumed = 1; } var width = rune.Utf8SequenceLength; if (ByteCount + width > maximumBytes) { Truncated = true; return; } var destination = _value.GetSpan(width); var written = rune.EncodeToUtf8(destination); _value.Advance(written); value = value[consumed..]; } } } }