From 288d5d65d3a8fbb4c41a3f0aa3c74c8a57f1743f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sun, 18 Oct 2026 02:11:42 +0300 Subject: [PATCH 1/2] fix: regexes with a linear pattern over trusted input carry no wall-clock match timeout (#427) .NET's match timeout is wall-clock, so on a stalled runner it fails a match that takes microseconds (#426). The tool-side snapshot reader, renderer and source-watch loader read spec/SNAPSHOT.md, spec/source-watch.json, `git rev-parse` output and a maintainer-selected receipt; their patterns either match a fixed width (the 40- and 64-character hashes) or scan linearly (the renderer's delimiter-separated runs, one of them unanchored). Thirteen Tools.Tests regexes over repository files, packed test outputs and test constants are the same case, as is DriveManifestTests' instance-name check. Each moves to Timeout.Infinite, or Regex.InfiniteMatchTimeout where the API takes a TimeSpan; PackedReadmeTests' timeout field goes with its only use. Four Tools.Tests patterns can backtrack quadratically on adversarial text (AnalyzerConfigReader's property line, the comment scan's documentation-path and Stryker rules, CommentReader's PowerShell block), so they keep their finite timeout with the reason beside it. Closes #427 --- tests/OpenCode.Sdk.Tests/Harness/DriveManifestTests.cs | 2 +- .../CommentResidue/CommentReader.cs | 1 + .../CommentResidue/CommentResidueScan.cs | 10 ++++++---- .../Packaging/NativeAotConsumerTests.cs | 2 +- .../Packaging/PackedReadmeTests.cs | 8 +++----- .../Suppressions/AnalyzerConfigReader.cs | 5 +++-- .../Suppressions/CSharpSuppressionReader.cs | 4 ++-- .../Suppressions/ProjectSuppressionReader.cs | 2 +- .../Generator/Refresh/SnapshotMarkdownRenderer.cs | 6 +++--- .../Generator/Refresh/SnapshotStateReader.cs | 2 +- .../Generator/Refresh/SourceWatchLoader.cs | 2 +- 11 files changed, 23 insertions(+), 21 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Harness/DriveManifestTests.cs b/tests/OpenCode.Sdk.Tests/Harness/DriveManifestTests.cs index 74a45801..c90817a1 100644 --- a/tests/OpenCode.Sdk.Tests/Harness/DriveManifestTests.cs +++ b/tests/OpenCode.Sdk.Tests/Harness/DriveManifestTests.cs @@ -13,7 +13,7 @@ public sealed class DriveManifestTests /// builds for. /// private static readonly Regex InstanceNamePattern = - new("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$", RegexOptions.None, TimeSpan.FromSeconds(1)); + new("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$", RegexOptions.None, Regex.InfiniteMatchTimeout); /// /// MockFileSystem simulates the host OS, so the registry path must stay portable across diff --git a/tests/OpenCode.Sdk.Tools.Tests/CommentResidue/CommentReader.cs b/tests/OpenCode.Sdk.Tools.Tests/CommentResidue/CommentReader.cs index 6981df4c..118d45d3 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/CommentResidue/CommentReader.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/CommentResidue/CommentReader.cs @@ -239,6 +239,7 @@ private static bool IsHash(string path) => private static bool IsScript(string path) => ScriptExtensions.Any(extension => path.EndsWith(extension, StringComparison.Ordinal)); + // Quadratic, so it keeps a finite match timeout: each unclosed "<#" scans lazily to the end of the text before the next one retries. [GeneratedRegex(@"<#.*?#>", RegexOptions.Singleline, 1000)] private static partial Regex PowerShellBlock(); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/CommentResidue/CommentResidueScan.cs b/tests/OpenCode.Sdk.Tools.Tests/CommentResidue/CommentResidueScan.cs index 42b5cff4..8daba498 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/CommentResidue/CommentResidueScan.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/CommentResidue/CommentResidueScan.cs @@ -132,21 +132,23 @@ private static bool HasReason(string rest) (ResearchLogNumber, ResearchLogPattern()), ]; - [GeneratedRegex(@"\bTask [0-9]+\b", RegexOptions.None, 1000)] + [GeneratedRegex(@"\bTask [0-9]+\b", RegexOptions.None, Timeout.Infinite)] private static partial Regex TaskPattern(); - [GeneratedRegex(@"\bdesign §", RegexOptions.None, 1000)] + [GeneratedRegex(@"\bdesign §", RegexOptions.None, Timeout.Infinite)] private static partial Regex DesignSectionPattern(); + // Quadratic, so it keeps a finite match timeout: the path class also matches "docs/", so repeated prefixes without ".md" rescan the tail. [GeneratedRegex(@"\bdocs/[A-Za-z0-9_./-]*\.md\b", RegexOptions.None, 1000)] private static partial Regex DocumentationPathPattern(); - [GeneratedRegex(@"\bQ[0-9]{3}\b", RegexOptions.None, 1000)] + [GeneratedRegex(@"\bQ[0-9]{3}\b", RegexOptions.None, Timeout.Infinite)] private static partial Regex ResearchLogPattern(); - [GeneratedRegex(@"(?.*)$", RegexOptions.IgnoreCase, 1000)] private static partial Regex StrykerDisable(); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Packaging/NativeAotConsumerTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Packaging/NativeAotConsumerTests.cs index 2e6356d8..e72f06af 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Packaging/NativeAotConsumerTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Packaging/NativeAotConsumerTests.cs @@ -67,6 +67,6 @@ await Assert.That(run.StandardOutputText.Replace("\r\n", "\n", StringComparison. } /// The warning codes the trim analyzer (IL2xxx) and the AOT compiler (IL3xxx) emit. - [GeneratedRegex(@"\bIL[23]\d{3}\b", RegexOptions.None, 1000)] + [GeneratedRegex(@"\bIL[23]\d{3}\b", RegexOptions.None, Timeout.Infinite)] private static partial Regex AotWarningCodes(); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Packaging/PackedReadmeTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Packaging/PackedReadmeTests.cs index 44cc64a3..35e5438e 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Packaging/PackedReadmeTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Packaging/PackedReadmeTests.cs @@ -20,15 +20,13 @@ public sealed partial class PackedReadmeTests(PackedConsumerFeed feed) { private static readonly RealFileSystem FileSystem = new(); - private static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1); - [Test] [Arguments("OpenCodeDotNet.Sdk")] [Arguments("OpenCodeDotNet.Sdk.Extensions")] public async Task The_Packed_Readme_Should_Link_The_Repository_At_The_Packed_Commit(string packageId) { var package = await ReadPackageAsync(packageId); - var link = new Regex(Regex.Escape(package.RepositoryUrl) + @"/(?:blob|tree)/(?[^/)\s]+)/", RegexOptions.None, MatchTimeout); + var link = new Regex(Regex.Escape(package.RepositoryUrl) + @"/(?:blob|tree)/(?[^/)\s]+)/", RegexOptions.None, Regex.InfiniteMatchTimeout); var references = link.Matches(package.Readme).Select(static match => match.Groups["ref"].Value).ToArray(); await Assert.That(package.Commit).Matches(CommitPattern()); @@ -92,10 +90,10 @@ private static async Task ReadEntryAsync(ZipArchive archive, string name } } - [GeneratedRegex("^[0-9a-f]{40}$", RegexOptions.None, 1000)] + [GeneratedRegex("^[0-9a-f]{40}$", RegexOptions.None, Timeout.Infinite)] private static partial Regex CommitPattern(); - [GeneratedRegex("%[0-9A-Fa-f]{2}", RegexOptions.None, 1000)] + [GeneratedRegex("%[0-9A-Fa-f]{2}", RegexOptions.None, Timeout.Infinite)] private static partial Regex PercentSequence(); private sealed record PackedReadme(string Readme, string RepositoryUrl, string Commit); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Suppressions/AnalyzerConfigReader.cs b/tests/OpenCode.Sdk.Tools.Tests/Suppressions/AnalyzerConfigReader.cs index 86adf5dd..62a6f8fa 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Suppressions/AnalyzerConfigReader.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Suppressions/AnalyzerConfigReader.cs @@ -98,12 +98,13 @@ private static bool IsLowSeverity(string value) => private static bool Is(string actual, string expected) => string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); - [GeneratedRegex(@"^\[(?(?:[^#;]|\\#|\\;)+)\]\s*(?:[#;].*)?$", RegexOptions.None, 1000)] + [GeneratedRegex(@"^\[(?(?:[^#;]|\\#|\\;)+)\]\s*(?:[#;].*)?$", RegexOptions.None, Timeout.Infinite)] private static partial Regex SectionLine(); + // Quadratic, so it keeps a finite match timeout: the lazy value and the trailing whitespace run overlap, so a long run of spaces rescans. [GeneratedRegex(@"^(?[\w.\-]+)\s*[=:]\s*(?.*?)\s*(?:[#;].*)?$", RegexOptions.None, 1000)] private static partial Regex PropertyLine(); - [GeneratedRegex(@"^(?:[A-Za-z]{1,8}[0-9]{3,5}|dotnet_code_quality)[._]", RegexOptions.IgnoreCase, 1000)] + [GeneratedRegex(@"^(?:[A-Za-z]{1,8}[0-9]{3,5}|dotnet_code_quality)[._]", RegexOptions.IgnoreCase, Timeout.Infinite)] private static partial Regex RuleOption(); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Suppressions/CSharpSuppressionReader.cs b/tests/OpenCode.Sdk.Tools.Tests/Suppressions/CSharpSuppressionReader.cs index caba488f..5a039268 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Suppressions/CSharpSuppressionReader.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Suppressions/CSharpSuppressionReader.cs @@ -192,9 +192,9 @@ private static bool IsComment(SyntaxTrivia trivia) => || trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia); - [GeneratedRegex(@"^slopwatch-ignore(?:-start)?\s*:\s*(?SW\d{3})\b", RegexOptions.IgnoreCase, 1000)] + [GeneratedRegex(@"^slopwatch-ignore(?:-start)?\s*:\s*(?SW\d{3})\b", RegexOptions.IgnoreCase, Timeout.Infinite)] private static partial Regex SlopwatchIgnore(); - [GeneratedRegex(@"^#:property\s+(?[A-Za-z]+)\s*=(?.*)$", RegexOptions.None, 1000)] + [GeneratedRegex(@"^#:property\s+(?[A-Za-z]+)\s*=(?.*)$", RegexOptions.None, Timeout.Infinite)] private static partial Regex PropertyDirective(); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Suppressions/ProjectSuppressionReader.cs b/tests/OpenCode.Sdk.Tools.Tests/Suppressions/ProjectSuppressionReader.cs index ca0dc590..733f373d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Suppressions/ProjectSuppressionReader.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Suppressions/ProjectSuppressionReader.cs @@ -126,6 +126,6 @@ private static string Condition(XElement element) private static bool Is(string actual, string expected) => string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); - [GeneratedRegex(@"(?:^|\s)(?:-p:|/p:|--property:|-property:)(?[A-Za-z]+)=(?""[^""]*""|'[^']*'|\S+)", RegexOptions.Multiline, 1000)] + [GeneratedRegex(@"(?:^|\s)(?:-p:|/p:|--property:|-property:)(?[A-Za-z]+)=(?""[^""]*""|'[^']*'|\S+)", RegexOptions.Multiline, Timeout.Infinite)] private static partial Regex CommandLineProperty(); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Refresh/SnapshotMarkdownRenderer.cs b/tools/OpenCode.Sdk.Tools/Generator/Refresh/SnapshotMarkdownRenderer.cs index ded1790b..b19a10ba 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Refresh/SnapshotMarkdownRenderer.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Refresh/SnapshotMarkdownRenderer.cs @@ -38,13 +38,13 @@ public static string Render(string text, SnapshotReceipt receipt, DateOnly date) + @"\| Upstream release tag \| `[^`\r\n]+` \|\n\| Commit \| \S+ \|\n" + @"\| Upstream product channel \| npm `@opencode/cli@latest` \(channel `latest`\); this tag published as `[^`\r\n]+`, " + @"installing the `opencode` command \(plus the transitional `opencode2` alias\) \|$", - RegexOptions.Multiline, matchTimeoutMilliseconds: 1000)] + RegexOptions.Multiline, matchTimeoutMilliseconds: Timeout.Infinite)] private static partial Regex IdentityTableRegex(); [GeneratedRegex(@"This pin is the release tag `[^`\r\n]+`, so\n`npm install -g @opencode/cli@[^`\r\n]+` installs a server built from exactly this commit; later", - RegexOptions.None, matchTimeoutMilliseconds: 1000)] + RegexOptions.None, matchTimeoutMilliseconds: Timeout.Infinite)] private static partial Regex PinProseRegex(); - [GeneratedRegex(@"^Date: \d{4}-\d{2}-\d{2}$", RegexOptions.Multiline, matchTimeoutMilliseconds: 1000)] + [GeneratedRegex(@"^Date: \d{4}-\d{2}-\d{2}$", RegexOptions.Multiline, matchTimeoutMilliseconds: Timeout.Infinite)] private static partial Regex DateLineRegex(); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Refresh/SnapshotStateReader.cs b/tools/OpenCode.Sdk.Tools/Generator/Refresh/SnapshotStateReader.cs index 622cc98b..c2cfbbaf 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Refresh/SnapshotStateReader.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Refresh/SnapshotStateReader.cs @@ -55,7 +55,7 @@ public static SnapshotReceipt ParseReceipt(byte[] bytes, string path) public static string SerializeReceipt(SnapshotReceipt receipt) => JsonSerializer.Serialize(receipt, ToolJsonContext.Default.SnapshotReceipt) + "\n"; - [GeneratedRegex("^[0-9a-f]{40}$", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + [GeneratedRegex("^[0-9a-f]{40}$", RegexOptions.None, matchTimeoutMilliseconds: Timeout.Infinite)] private static partial Regex FullShaRegex(); private async Task ReadOptionalAsync(string path, CancellationToken cancellationToken) => diff --git a/tools/OpenCode.Sdk.Tools/Generator/Refresh/SourceWatchLoader.cs b/tools/OpenCode.Sdk.Tools/Generator/Refresh/SourceWatchLoader.cs index 79c307a2..92fc4d6b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Refresh/SourceWatchLoader.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Refresh/SourceWatchLoader.cs @@ -110,6 +110,6 @@ private static void ValidateSource(WatchedSource source) } } - [GeneratedRegex("^[0-9a-f]{64}$", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + [GeneratedRegex("^[0-9a-f]{64}$", RegexOptions.None, matchTimeoutMilliseconds: Timeout.Infinite)] private static partial Regex Sha256Regex(); } From 412f7526b3a1b65a08b5ca56c3eaa4e671c63913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sun, 18 Oct 2026 02:11:42 +0300 Subject: [PATCH 2/2] docs: a linear regex over trusted input passes an infinite match timeout; a superlinear or untrusted one keeps a finite timeout with its reason --- docs/engineering/coding-style.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/engineering/coding-style.md b/docs/engineering/coding-style.md index f985b785..860c77a7 100644 --- a/docs/engineering/coding-style.md +++ b/docs/engineering/coding-style.md @@ -1,6 +1,6 @@ # Coding Style — hand-written code -Date: 2026-10-16 +Date: 2026-10-18 Binding authoring style for every hand-written line in this repository — product code, `tools/`, and tests. Generated output is governed by the generator's emitters (ADR-0003); @@ -126,6 +126,12 @@ for a pile of static steps. rather than treating `IReadOnly*` as a deep-immutability guarantee. Generated wire models are shallow init-only DTOs (ADR-0004/0014); hand-written domain types choose stronger ownership only when a concrete consumer or concurrency boundary requires it. +- **Regular expressions:** a regex whose pattern matches in linear time and whose input is + repository-pinned or test-local passes `Timeout.Infinite` (`matchTimeoutMilliseconds:`), or + `Regex.InfiniteMatchTimeout` where the API takes a `TimeSpan`. A regex over untrusted input, or + one whose pattern can backtrack superlinearly, keeps a finite timeout with its reason beside it. + The match timeout is wall-clock: on a linear pattern over pinned input it guards nothing and fails + on a stalled runner (#426). ## 4. Layout