Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Semantics.Strings.Identifiers/CreditCardNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@ namespace ktsu.Semantics.Strings.Identifiers;
using ktsu.Semantics.Strings;

/// <summary>
/// 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.
/// </summary>
[IsCreditCardNumber]
public sealed record CreditCardNumber : SemanticString<CreditCardNumber>
{
/// <summary>Normalizes the input by stripping spaces and hyphens.</summary>
/// <summary>Normalizes the input by stripping whitespace (spaces, tabs, line breaks) and hyphens.</summary>
/// <param name="input">The raw input string.</param>
/// <returns>The digit-only canonical form.</returns>
protected override string MakeCanonical(string input)
{
Ensure.NotNull(input);
return input.Replace(" ", string.Empty).Replace("-", string.Empty);
return IdentifierCanonicalization.StripWhitespaceAndHyphens(input);
}

/// <summary>A rejected value is sensitive, so it is left out of the validation exception message.</summary>
Expand Down
30 changes: 30 additions & 0 deletions Semantics.Strings.Identifiers/IdentifierCanonicalization.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Strings.Identifiers;

using System.Linq;
using System.Text;

/// <summary>
/// Normalization shared by the identifier types, so that what counts as a separator cannot drift
/// between them.
/// </summary>
internal static class IdentifierCanonicalization
{
/// <summary>
/// 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.
/// </summary>
/// <param name="input">The raw input string.</param>
/// <returns>The input with whitespace and hyphens removed.</returns>
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();
}
}
6 changes: 3 additions & 3 deletions Semantics.Strings.Identifiers/Isbn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ namespace ktsu.Semantics.Strings.Identifiers;

/// <summary>
/// An International Standard Book Number in ISBN-10 or ISBN-13 form, e.g. <c>0306406152</c> or
/// <c>9780306406157</c>. Hyphens and spaces are stripped and the value is uppercased (for the ISBN-10
/// <c>9780306406157</c>. Hyphens and whitespace are stripped and the value is uppercased (for the ISBN-10
/// <c>X</c> check digit) on creation. The check digit is validated, and an ISBN-13 must start with
/// the <c>978</c> or <c>979</c> Bookland prefix, but not <c>9790</c>, which is the ISMN range for printed
/// music; registration-group and publisher ranges are not validated.
/// </summary>
[IsIsbn]
public sealed record Isbn : SemanticString<Isbn>
{
/// <summary>Normalizes the input by stripping hyphens/spaces and uppercasing.</summary>
/// <summary>Normalizes the input by stripping hyphens and whitespace (spaces, tabs, line breaks) and uppercasing.</summary>
/// <param name="input">The raw input string.</param>
/// <returns>The canonical ISBN string.</returns>
protected override string MakeCanonical(string input)
{
Ensure.NotNull(input);
return input.Replace("-", string.Empty).Replace(" ", string.Empty).ToUpperInvariant();
return IdentifierCanonicalization.StripWhitespaceAndHyphens(input).ToUpperInvariant();
}
}
6 changes: 3 additions & 3 deletions Semantics.Strings.Identifiers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ Each type is a `sealed record` deriving from `SemanticString<T>`. 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.
Expand Down
12 changes: 12 additions & 0 deletions Semantics.Test/Identifiers/CreditCardNumberTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,16 @@ public void Create_Empty_Throws()
{
Assert.ThrowsExactly<ArgumentException>(() => 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);
}
}
12 changes: 12 additions & 0 deletions Semantics.Test/Identifiers/IsbnTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,16 @@ public void Create_Empty_Throws()
{
Assert.ThrowsExactly<ArgumentException>(() => 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);
}
}
Loading