From b7cb42817cc96210cf85ff8f548247c603599287 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:33:35 +0000 Subject: [PATCH 1/2] [patch] Anchor IBAN, UUID and ULID patterns at the true end, and strip all whitespace from an IBAN In .NET, $ also matches before a final "\n", so [IsIban], [IsUuid] and [IsUlid] accepted a value with a trailing newline. Iban only stripped spaces, so the newline reached the mod-97 check as if it were a letter: "GB67WEST12345698765432\n" (wrong check digits) validated, and the valid "GB82WEST12345698765432\n" was rejected as a checksum failure. The patterns now end in \z, and Iban strips every whitespace character, as its documentation says it does. Fixes ktsu-dev/Semantics#311 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Co2rZq6e7TjwF2FMUzNoXu --- Semantics.Strings.Identifiers/Iban.cs | 17 ++++++++++++++-- .../IsIbanAttribute.cs | 2 +- .../IsUlidAttribute.cs | 2 +- .../IsUuidAttribute.cs | 2 +- Semantics.Test/Identifiers/IbanTests.cs | 20 +++++++++++++++++++ Semantics.Test/Identifiers/UlidTests.cs | 16 +++++++++++++++ Semantics.Test/Identifiers/UuidTests.cs | 16 +++++++++++++++ 7 files changed, 70 insertions(+), 5 deletions(-) diff --git a/Semantics.Strings.Identifiers/Iban.cs b/Semantics.Strings.Identifiers/Iban.cs index 41331579..aacd3cde 100644 --- a/Semantics.Strings.Identifiers/Iban.cs +++ b/Semantics.Strings.Identifiers/Iban.cs @@ -2,6 +2,8 @@ namespace ktsu.Semantics.Strings.Identifiers; +using System.Text; + using ktsu.Semantics.Strings; /// @@ -12,12 +14,23 @@ namespace ktsu.Semantics.Strings.Identifiers; [IsIban] public sealed record Iban : SemanticString { - /// Normalizes the input by stripping spaces and uppercasing. + /// + /// Normalizes the input by stripping all whitespace (spaces, tabs, line breaks) and uppercasing. + /// /// The raw input string. /// The canonical IBAN string. protected override string MakeCanonical(string input) { Ensure.NotNull(input); - return input.Replace(" ", string.Empty).ToUpperInvariant(); + StringBuilder canonical = new(input.Length); + foreach (char c in input) + { + if (!char.IsWhiteSpace(c)) + { + canonical.Append(char.ToUpperInvariant(c)); + } + } + + return canonical.ToString(); } } diff --git a/Semantics.Strings.Identifiers/IsIbanAttribute.cs b/Semantics.Strings.Identifiers/IsIbanAttribute.cs index 233a0f38..e69f8978 100644 --- a/Semantics.Strings.Identifiers/IsIbanAttribute.cs +++ b/Semantics.Strings.Identifiers/IsIbanAttribute.cs @@ -21,7 +21,7 @@ public sealed class IsIbanAttribute : NativeSemanticStringValidationAttribute private sealed class IbanValidator : ValidationAdapter { - private const string Pattern = "^[A-Z]{2}[0-9]{2}[A-Z0-9]+$"; + private const string Pattern = "^[A-Z]{2}[0-9]{2}[A-Z0-9]+\\z"; protected override ValidationResult ValidateValue(string value) { diff --git a/Semantics.Strings.Identifiers/IsUlidAttribute.cs b/Semantics.Strings.Identifiers/IsUlidAttribute.cs index 0a380eb5..03e3b2a6 100644 --- a/Semantics.Strings.Identifiers/IsUlidAttribute.cs +++ b/Semantics.Strings.Identifiers/IsUlidAttribute.cs @@ -20,7 +20,7 @@ public sealed class IsUlidAttribute : NativeSemanticStringValidationAttribute private sealed class UlidValidator : ValidationAdapter { - private const string Pattern = "^[0-7][0-9A-HJKMNP-TV-Z]{25}$"; + private const string Pattern = "^[0-7][0-9A-HJKMNP-TV-Z]{25}\\z"; protected override ValidationResult ValidateValue(string value) => Regex.IsMatch(value, Pattern, RegexOptions.None, TimeSpan.FromSeconds(1)) diff --git a/Semantics.Strings.Identifiers/IsUuidAttribute.cs b/Semantics.Strings.Identifiers/IsUuidAttribute.cs index 79c19a92..c315feef 100644 --- a/Semantics.Strings.Identifiers/IsUuidAttribute.cs +++ b/Semantics.Strings.Identifiers/IsUuidAttribute.cs @@ -20,7 +20,7 @@ public sealed class IsUuidAttribute : NativeSemanticStringValidationAttribute private sealed class UuidValidator : ValidationAdapter { - private const string Pattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + private const string Pattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\z"; protected override ValidationResult ValidateValue(string value) => Regex.IsMatch(value, Pattern, RegexOptions.None, TimeSpan.FromSeconds(1)) diff --git a/Semantics.Test/Identifiers/IbanTests.cs b/Semantics.Test/Identifiers/IbanTests.cs index 4779ec32..520016c9 100644 --- a/Semantics.Test/Identifiers/IbanTests.cs +++ b/Semantics.Test/Identifiers/IbanTests.cs @@ -44,4 +44,24 @@ public void Create_Empty_Throws() { Assert.ThrowsExactly(() => Iban.Create(string.Empty)); } + + [TestMethod] + [DataRow("GB67WEST12345698765432\n")] + [DataRow("GB67WEST12345698765432\r\n")] + public void Create_InvalidChecksumWithTrailingNewline_Throws(string input) + { + Assert.ThrowsExactly(() => Iban.Create(input)); + } + + [TestMethod] + [DataRow("GB82WEST12345698765432\n")] + [DataRow("GB82WEST12345698765432\t")] + [DataRow("\tGB82WEST12345698765432\r\n")] + [DataRow("GB82 WEST 1234 5698 7654 32\n")] + [DataRow("GB82\u00A0WEST\u00A012345698765432")] + public void Create_ValidIbanWithWhitespace_StripsItAndValidates(string input) + { + Iban iban = Iban.Create(input); + Assert.AreEqual("GB82WEST12345698765432", iban.WeakString); + } } diff --git a/Semantics.Test/Identifiers/UlidTests.cs b/Semantics.Test/Identifiers/UlidTests.cs index 3041b62b..a5c51c9a 100644 --- a/Semantics.Test/Identifiers/UlidTests.cs +++ b/Semantics.Test/Identifiers/UlidTests.cs @@ -2,6 +2,7 @@ namespace ktsu.Semantics.Test.Identifiers; +using ktsu.Semantics.Strings; using ktsu.Semantics.Strings.Identifiers; [TestClass] @@ -44,4 +45,19 @@ public void Create_Empty_Throws() { Assert.ThrowsExactly(() => Ulid.Create(string.Empty)); } + + // Ulid trims its input, which hides the attribute's own anchoring; a type that applies [IsUlid] + // without canonicalizing sees the raw value. + [IsUlid] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")] + private sealed partial record UntrimmedUlid : SemanticString { } + + [TestMethod] + [DataRow("01ARZ3NDEKTSV4RRFFQ69G5FAV\n")] + [DataRow("01ARZ3NDEKTSV4RRFFQ69G5FAV\r\n")] + public void IsUlid_OnUntrimmedType_RejectsTrailingNewline(string input) + { + Assert.ThrowsExactly(() => SemanticString.Create(input)); + Assert.AreEqual("01ARZ3NDEKTSV4RRFFQ69G5FAV", SemanticString.Create("01ARZ3NDEKTSV4RRFFQ69G5FAV").WeakString); + } } diff --git a/Semantics.Test/Identifiers/UuidTests.cs b/Semantics.Test/Identifiers/UuidTests.cs index e2952aad..cc031556 100644 --- a/Semantics.Test/Identifiers/UuidTests.cs +++ b/Semantics.Test/Identifiers/UuidTests.cs @@ -2,6 +2,7 @@ namespace ktsu.Semantics.Test.Identifiers; +using ktsu.Semantics.Strings; using ktsu.Semantics.Strings.Identifiers; [TestClass] @@ -62,4 +63,19 @@ public void As_RoundTrip_PreservesValue() Assert.AreEqual(uuid, roundTripped); Assert.AreEqual(uuid.WeakString, roundTripped.WeakString); } + + // Uuid trims its input, which hides the attribute's own anchoring; a type that applies [IsUuid] + // without canonicalizing sees the raw value. + [IsUuid] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")] + private sealed partial record UntrimmedUuid : SemanticString { } + + [TestMethod] + [DataRow("123e4567-e89b-12d3-a456-426614174000\n")] + [DataRow("123e4567-e89b-12d3-a456-426614174000\r\n")] + public void IsUuid_OnUntrimmedType_RejectsTrailingNewline(string input) + { + Assert.ThrowsExactly(() => SemanticString.Create(input)); + Assert.AreEqual("123e4567-e89b-12d3-a456-426614174000", SemanticString.Create("123e4567-e89b-12d3-a456-426614174000").WeakString); + } } From ea23aa97590c5b00bf1e4a6beac9492c9e667d92 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:37:59 +0000 Subject: [PATCH 2/2] Filter IBAN whitespace with Where instead of an if inside the loop Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Co2rZq6e7TjwF2FMUzNoXu --- Semantics.Strings.Identifiers/Iban.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Semantics.Strings.Identifiers/Iban.cs b/Semantics.Strings.Identifiers/Iban.cs index aacd3cde..ff44ac15 100644 --- a/Semantics.Strings.Identifiers/Iban.cs +++ b/Semantics.Strings.Identifiers/Iban.cs @@ -2,6 +2,7 @@ namespace ktsu.Semantics.Strings.Identifiers; +using System.Linq; using System.Text; using ktsu.Semantics.Strings; @@ -23,12 +24,9 @@ protected override string MakeCanonical(string input) { Ensure.NotNull(input); StringBuilder canonical = new(input.Length); - foreach (char c in input) + foreach (char c in input.Where(static ch => !char.IsWhiteSpace(ch))) { - if (!char.IsWhiteSpace(c)) - { - canonical.Append(char.ToUpperInvariant(c)); - } + canonical.Append(char.ToUpperInvariant(c)); } return canonical.ToString();