From 74c3683248e007299513f1a4929dc872eed77752 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Tue, 20 Oct 2026 02:29:58 +0000 Subject: [PATCH] Strip all whitespace from a pasted card number or ISBN [patch] CreditCardNumber and Isbn removed only the ASCII space and `-`, so a valid value carrying a trailing newline, a tab or a non-breaking space was rejected, while Iban in the same package already stripped every char.IsWhiteSpace character. Both now go through one shared IdentifierCanonicalization helper so the two cannot drift apart again, and the README table and XML docs say "whitespace". Fixes #476 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VhcNHmVUqSbyA94vJQRrc5 --- .../CreditCardNumber.cs | 8 ++--- .../IdentifierCanonicalization.cs | 30 +++++++++++++++++++ Semantics.Strings.Identifiers/Isbn.cs | 6 ++-- Semantics.Strings.Identifiers/README.md | 6 ++-- .../Identifiers/CreditCardNumberTests.cs | 12 ++++++++ Semantics.Test/Identifiers/IsbnTests.cs | 12 ++++++++ 6 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 Semantics.Strings.Identifiers/IdentifierCanonicalization.cs diff --git a/Semantics.Strings.Identifiers/CreditCardNumber.cs b/Semantics.Strings.Identifiers/CreditCardNumber.cs index 5a3812fa..71f67dcd 100644 --- a/Semantics.Strings.Identifiers/CreditCardNumber.cs +++ b/Semantics.Strings.Identifiers/CreditCardNumber.cs @@ -5,20 +5,20 @@ namespace ktsu.Semantics.Strings.Identifiers; using ktsu.Semantics.Strings; /// -/// A payment card number (PAN): 13-19 digits passing the Luhn checksum. Spaces and hyphens are -/// stripped on creation. Validation is Luhn-only — no BIN/issuer/network detection and no PCI +/// A payment card number (PAN): 13-19 digits passing the Luhn checksum. Whitespace and hyphens +/// are stripped on creation. Validation is Luhn-only — no BIN/issuer/network detection and no PCI /// guarantees; treat the value as sensitive and do not log it. /// [IsCreditCardNumber] public sealed record CreditCardNumber : SemanticString { - /// Normalizes the input by stripping spaces and hyphens. + /// Normalizes the input by stripping whitespace (spaces, tabs, line breaks) and hyphens. /// The raw input string. /// The digit-only canonical form. protected override string MakeCanonical(string input) { Ensure.NotNull(input); - return input.Replace(" ", string.Empty).Replace("-", string.Empty); + return IdentifierCanonicalization.StripWhitespaceAndHyphens(input); } /// A rejected value is sensitive, so it is left out of the validation exception message. diff --git a/Semantics.Strings.Identifiers/IdentifierCanonicalization.cs b/Semantics.Strings.Identifiers/IdentifierCanonicalization.cs new file mode 100644 index 00000000..7cdbac73 --- /dev/null +++ b/Semantics.Strings.Identifiers/IdentifierCanonicalization.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Strings.Identifiers; + +using System.Linq; +using System.Text; + +/// +/// Normalization shared by the identifier types, so that what counts as a separator cannot drift +/// between them. +/// +internal static class IdentifierCanonicalization +{ + /// + /// Removes every whitespace character (spaces, tabs, line breaks, non-breaking spaces) and every + /// hyphen, which is what a copied card number or ISBN typically carries around its digits. + /// + /// The raw input string. + /// The input with whitespace and hyphens removed. + internal static string StripWhitespaceAndHyphens(string input) + { + StringBuilder canonical = new(input.Length); + foreach (char c in input.Where(static ch => !char.IsWhiteSpace(ch) && ch != '-')) + { + canonical.Append(c); + } + + return canonical.ToString(); + } +} diff --git a/Semantics.Strings.Identifiers/Isbn.cs b/Semantics.Strings.Identifiers/Isbn.cs index a7d7058a..ae620687 100644 --- a/Semantics.Strings.Identifiers/Isbn.cs +++ b/Semantics.Strings.Identifiers/Isbn.cs @@ -6,7 +6,7 @@ namespace ktsu.Semantics.Strings.Identifiers; /// /// An International Standard Book Number in ISBN-10 or ISBN-13 form, e.g. 0306406152 or -/// 9780306406157. Hyphens and spaces are stripped and the value is uppercased (for the ISBN-10 +/// 9780306406157. Hyphens and whitespace are stripped and the value is uppercased (for the ISBN-10 /// X check digit) on creation. The check digit is validated, and an ISBN-13 must start with /// the 978 or 979 Bookland prefix, but not 9790, which is the ISMN range for printed /// music; registration-group and publisher ranges are not validated. @@ -14,12 +14,12 @@ namespace ktsu.Semantics.Strings.Identifiers; [IsIsbn] public sealed record Isbn : SemanticString { - /// Normalizes the input by stripping hyphens/spaces and uppercasing. + /// Normalizes the input by stripping hyphens and whitespace (spaces, tabs, line breaks) and uppercasing. /// The raw input string. /// The canonical ISBN string. protected override string MakeCanonical(string input) { Ensure.NotNull(input); - return input.Replace("-", string.Empty).Replace(" ", string.Empty).ToUpperInvariant(); + return IdentifierCanonicalization.StripWhitespaceAndHyphens(input).ToUpperInvariant(); } } diff --git a/Semantics.Strings.Identifiers/README.md b/Semantics.Strings.Identifiers/README.md index 81df84b8..f8e652a6 100644 --- a/Semantics.Strings.Identifiers/README.md +++ b/Semantics.Strings.Identifiers/README.md @@ -86,9 +86,9 @@ Each type is a `sealed record` deriving from `SemanticString`. None declare t |------|---------------|------------|-------------------| | `Uuid` | Trim, strip wrapping `{}` / `()`, lowercase | Canonical 8-4-4-4-12 hex (RFC 4122 layout) | Any variant/version accepted, including the nil UUID. Not version-checked. | | `Ulid` | Trim, uppercase | 26 Crockford base32 chars, first char `0`-`7` | Timestamp is not otherwise decoded. | -| `Iban` | Strip spaces, uppercase | Length 15-34, country/check prefix, ISO 7064 mod-97-10 checksum | Country-specific BBAN structure is not enforced. | -| `Isbn` | Strip `-` and spaces, uppercase | ISBN-10 (weighted mod-11, `X` allowed) or ISBN-13 (`978`/`979` prefix but not the `9790` ISMN range, mod-10) | Registration-group/publisher ranges are not validated. | -| `CreditCardNumber` | Strip spaces and hyphens | 13-19 digits, Luhn (mod-10) checksum | Luhn only. No issuer/network detection, no PCI guarantee. The value is sensitive, do not log it. | +| `Iban` | Strip whitespace, uppercase | Length 15-34, country/check prefix, ISO 7064 mod-97-10 checksum | Country-specific BBAN structure is not enforced. | +| `Isbn` | Strip `-` and whitespace, uppercase | ISBN-10 (weighted mod-11, `X` allowed) or ISBN-13 (`978`/`979` prefix but not the `9790` ISMN range, mod-10) | Registration-group/publisher ranges are not validated. | +| `CreditCardNumber` | Strip whitespace and hyphens | 13-19 digits, Luhn (mod-10) checksum | Luhn only. No issuer/network detection, no PCI guarantee. The value is sensitive, do not log it. | | `JwtToken` | None (stored verbatim, case-sensitive) | Three `.`-separated segments in the unpadded base64url alphabet (no whitespace, `=`, `+` or `/`), non-empty header and payload that decode to UTF-8 text delimited as `{ ... }` | Structural only: the header and payload bodies are not parsed, so malformed JSON between the braces is accepted. Signature is not decoded or verified. `alg`, claims, and expiry are not inspected. | Creation follows the base-type contract: `Create(...)` throws `ArgumentException` on invalid input and `ArgumentNullException` on null, while `TryCreate(...)` returns `false` instead. diff --git a/Semantics.Test/Identifiers/CreditCardNumberTests.cs b/Semantics.Test/Identifiers/CreditCardNumberTests.cs index 40bf0cad..398de92f 100644 --- a/Semantics.Test/Identifiers/CreditCardNumberTests.cs +++ b/Semantics.Test/Identifiers/CreditCardNumberTests.cs @@ -44,4 +44,16 @@ public void Create_Empty_Throws() { Assert.ThrowsExactly(() => CreditCardNumber.Create(string.Empty)); } + + [TestMethod] + [DataRow("4111111111111111\n")] + [DataRow("4111111111111111\r\n")] + [DataRow("4111\t1111\t1111\t1111")] + [DataRow("4111\u00A01111\u00A01111\u00A01111")] + [DataRow(" 4111-1111 1111-1111\t")] + public void Create_WithWhitespace_StripsItAndValidates(string input) + { + CreditCardNumber card = CreditCardNumber.Create(input); + Assert.AreEqual("4111111111111111", card.WeakString); + } } diff --git a/Semantics.Test/Identifiers/IsbnTests.cs b/Semantics.Test/Identifiers/IsbnTests.cs index 28e134b5..e4497517 100644 --- a/Semantics.Test/Identifiers/IsbnTests.cs +++ b/Semantics.Test/Identifiers/IsbnTests.cs @@ -93,4 +93,16 @@ public void Create_Empty_Throws() { Assert.ThrowsExactly(() => Isbn.Create(string.Empty)); } + + [TestMethod] + [DataRow("978-0-306-40615-7\n", "9780306406157")] + [DataRow("978-0-306-40615-7\r\n", "9780306406157")] + [DataRow("978\t0306406157", "9780306406157")] + [DataRow("978\u00A00\u00A0306\u00A040615\u00A07", "9780306406157")] + [DataRow("\t0-8044-2957-x\n", "080442957X")] + public void Create_WithWhitespace_StripsItAndValidates(string input, string expected) + { + Isbn isbn = Isbn.Create(input); + Assert.AreEqual(expected, isbn.WeakString); + } }