diff --git a/Semantics.Strings.Identifiers/CreditCardNumber.cs b/Semantics.Strings.Identifiers/CreditCardNumber.cs index 5a3812f..71f67dc 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 0000000..7cdbac7 --- /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 a7d7058..ae62068 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 81df84b..f8e652a 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 40bf0ca..398de92 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 28e134b..e449751 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); + } }