From 74cf6327ebf5adf4fc8fbf86a1f33848136773f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:27:13 +0000 Subject: [PATCH] [patch] Require the 978/979 Bookland prefix on an ISBN-13 IsIsbnAttribute only checked an ISBN-13's mod-10 check digit, which every EAN-13 barcode passes, so Isbn.Create("4006381333931") accepted a non-book product code. An ISBN-13 must now start with 978 or 979. Fixes ktsu-dev/Semantics#334 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LoEzLXruuTHCcJnXtVa49Z --- .../IsIsbnAttribute.cs | 10 ++++++-- Semantics.Strings.Identifiers/Isbn.cs | 5 ++-- Semantics.Strings.Identifiers/README.md | 2 +- Semantics.Test/Identifiers/IsbnTests.cs | 24 +++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Semantics.Strings.Identifiers/IsIsbnAttribute.cs b/Semantics.Strings.Identifiers/IsIsbnAttribute.cs index 4a57653e..02acbf5c 100644 --- a/Semantics.Strings.Identifiers/IsIsbnAttribute.cs +++ b/Semantics.Strings.Identifiers/IsIsbnAttribute.cs @@ -8,8 +8,8 @@ namespace ktsu.Semantics.Strings.Identifiers; /// /// Validates that the string is an ISBN-10 (weighted mod-11, final digit may be X) or an ISBN-13 -/// (mod-10), evaluated on the separator-stripped value. Registration-group and publisher ranges are -/// not validated. +/// (mod-10, with the 978 or 979 Bookland prefix), evaluated on the separator-stripped +/// value. Registration-group and publisher ranges are not validated. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] public sealed class IsIsbnAttribute : NativeSemanticStringValidationAttribute @@ -63,6 +63,12 @@ private static bool IsValidIsbn10(string s) private static bool IsValidIsbn13(string s) { + // An ISBN-13 is an EAN-13 in the Bookland range; any other prefix is a non-book barcode. + if (!s.StartsWith("978", StringComparison.Ordinal) && !s.StartsWith("979", StringComparison.Ordinal)) + { + return false; + } + int sum = 0; for (int i = 0; i < 13; i++) { diff --git a/Semantics.Strings.Identifiers/Isbn.cs b/Semantics.Strings.Identifiers/Isbn.cs index 74c9d040..81c00b01 100644 --- a/Semantics.Strings.Identifiers/Isbn.cs +++ b/Semantics.Strings.Identifiers/Isbn.cs @@ -7,8 +7,9 @@ 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 -/// X check digit) on creation. The check digit is validated; registration-group and publisher -/// ranges are not. +/// X check digit) on creation. The check digit is validated, and an ISBN-13 must start with +/// the 978 or 979 Bookland prefix; registration-group and publisher ranges are not +/// validated. /// [IsIsbn] public sealed record Isbn : SemanticString diff --git a/Semantics.Strings.Identifiers/README.md b/Semantics.Strings.Identifiers/README.md index 4e70ea77..f2f3b45f 100644 --- a/Semantics.Strings.Identifiers/README.md +++ b/Semantics.Strings.Identifiers/README.md @@ -87,7 +87,7 @@ 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 (mod-10) | Registration-group/publisher ranges are not validated. | +| `Isbn` | Strip `-` and spaces, uppercase | ISBN-10 (weighted mod-11, `X` allowed) or ISBN-13 (`978`/`979` prefix, 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. | | `JwtToken` | None (stored verbatim, case-sensitive) | Three `.`-separated segments, non-empty header and payload that base64url-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. | diff --git a/Semantics.Test/Identifiers/IsbnTests.cs b/Semantics.Test/Identifiers/IsbnTests.cs index 8d404ec2..cc044222 100644 --- a/Semantics.Test/Identifiers/IsbnTests.cs +++ b/Semantics.Test/Identifiers/IsbnTests.cs @@ -40,6 +40,30 @@ public void Create_BadIsbn13CheckDigit_Throws() Assert.ThrowsExactly(() => Isbn.Create("978-0-306-40615-8")); } + [TestMethod] + public void Create_ValidIsbn13With979Prefix_Succeeds() + { + Isbn isbn = Isbn.Create("979-10-90636-07-1"); + Assert.AreEqual("9791090636071", isbn.WeakString); + } + + [TestMethod] + [DataRow("4006381333931")] + [DataRow("5901234123457")] + [DataRow("9770317847001")] + public void Create_Ean13OutsideBooklandRange_Throws(string ean13) + { + Assert.ThrowsExactly(() => Isbn.Create(ean13)); + } + + [TestMethod] + public void TryCreate_Ean13OutsideBooklandRange_ReturnsFalse() + { + bool created = Isbn.TryCreate("4006381333931", out Isbn? result); + Assert.IsFalse(created); + Assert.IsNull(result); + } + [TestMethod] public void Create_WrongLength_Throws() {