diff --git a/Semantics.Strings.Identifiers/CreditCardNumber.cs b/Semantics.Strings.Identifiers/CreditCardNumber.cs
index a5f2b06a..5a3812fa 100644
--- a/Semantics.Strings.Identifiers/CreditCardNumber.cs
+++ b/Semantics.Strings.Identifiers/CreditCardNumber.cs
@@ -20,4 +20,7 @@ protected override string MakeCanonical(string input)
Ensure.NotNull(input);
return input.Replace(" ", string.Empty).Replace("-", string.Empty);
}
+
+ /// A rejected value is sensitive, so it is left out of the validation exception message.
+ protected override bool IncludeValueInErrors => false;
}
diff --git a/Semantics.Strings.Identifiers/Iban.cs b/Semantics.Strings.Identifiers/Iban.cs
index ff44ac15..bb0b36b7 100644
--- a/Semantics.Strings.Identifiers/Iban.cs
+++ b/Semantics.Strings.Identifiers/Iban.cs
@@ -31,4 +31,7 @@ protected override string MakeCanonical(string input)
return canonical.ToString();
}
+
+ /// A rejected value is sensitive, so it is left out of the validation exception message.
+ protected override bool IncludeValueInErrors => false;
}
diff --git a/Semantics.Strings.Identifiers/JwtToken.cs b/Semantics.Strings.Identifiers/JwtToken.cs
index da11da1b..d7a59f17 100644
--- a/Semantics.Strings.Identifiers/JwtToken.cs
+++ b/Semantics.Strings.Identifiers/JwtToken.cs
@@ -13,4 +13,6 @@ namespace ktsu.Semantics.Strings.Identifiers;
[IsJwtToken]
public sealed record JwtToken : SemanticString
{
+ /// A rejected value is sensitive, so it is left out of the validation exception message.
+ protected override bool IncludeValueInErrors => false;
}
diff --git a/Semantics.Strings/SemanticString.cs b/Semantics.Strings/SemanticString.cs
index fcbb33ae..0eacd4cb 100644
--- a/Semantics.Strings/SemanticString.cs
+++ b/Semantics.Strings/SemanticString.cs
@@ -421,6 +421,17 @@ public TDest As()
///
public virtual bool ValidateAttributes() => AttributeValidation.ValidateAttributes(this, GetType());
+ ///
+ /// Gets whether the exception thrown for a value that fails validation quotes that value.
+ ///
+ ///
+ /// by default. Override it to return on a type whose
+ /// values are sensitive, such as a card number or a credential, so that a rejected value does not
+ /// reach logs, error trackers or error responses through the exception message. The message still
+ /// names the type and the reasons the validation attributes give.
+ ///
+ protected internal virtual bool IncludeValueInErrors => true;
+
// Note: Explicit conversion operators were removed in favor of the .As() extension method pattern
// for better discoverability, consistency, and clearer intent at call sites.
@@ -635,11 +646,27 @@ private static TDest PerformValidation(TDest? value)
{
if (value == null || !value.IsValid())
{
- throw new ArgumentException(message: $"Cannot convert \"{value}\" to {typeof(TDest).Name}");
+ throw new ArgumentException(message: ValidationFailureMessage(value));
}
return value;
}
+ ///
+ /// Builds the message for a value that failed validation: the rejected value, unless the type opts out
+ /// through , followed by the reasons its validation attributes give.
+ ///
+ /// The semantic string type being validated.
+ /// The semantic string instance that failed validation.
+ /// The exception message.
+ private static string ValidationFailureMessage(TDest? value)
+ where TDest : SemanticString
+ {
+ string subject = value is null || value.IncludeValueInErrors ? $"\"{value}\"" : "the value";
+ string message = $"Cannot convert {subject} to {typeof(TDest).Name}";
+ string[] errors = value is null ? [] : [.. AttributeValidation.GetValidationErrors(value, value.GetType())];
+ return errors.Length == 0 ? message : $"{message}: {string.Join(" ", errors)}";
+ }
+
///
/// Attempts to create a new instance of this semantic string type from a string value without throwing exceptions.
///
diff --git a/Semantics.Strings/Validation/SemanticStringValidationAttributes.cs b/Semantics.Strings/Validation/SemanticStringValidationAttributes.cs
index 82913976..360c80bd 100644
--- a/Semantics.Strings/Validation/SemanticStringValidationAttributes.cs
+++ b/Semantics.Strings/Validation/SemanticStringValidationAttributes.cs
@@ -3,6 +3,8 @@
namespace ktsu.Semantics.Strings;
using System;
+using System.Collections.Generic;
+using System.Linq;
///
/// Helper class for validating using attributes
@@ -24,4 +26,39 @@ public static bool ValidateAttributes(ISemanticString semanticString, Type type)
IValidationStrategy strategy = ValidationStrategyFactory.CreateStrategy(type);
return strategy.Validate(semanticString, type);
}
+
+ ///
+ /// Gets the reasons the validation attributes defined on a type give for rejecting a SemanticString.
+ ///
+ /// The SemanticString that failed validation
+ /// The type of the SemanticString, which contains the validation attributes
+ /// The error messages of every attribute that rejects the string, in declaration order
+ ///
+ /// Under these are the attributes that failed; under
+ /// every attribute failed, so this is the union of their errors.
+ /// An attribute that reports no reason of its own is named instead.
+ ///
+ public static IEnumerable GetValidationErrors(ISemanticString semanticString, Type type)
+ {
+ Ensure.NotNull(type);
+
+ foreach (SemanticStringValidationAttribute attribute in type.GetCustomAttributes(typeof(SemanticStringValidationAttribute), true)
+ .Cast()
+ .Where(attribute => !attribute.Validate(semanticString)))
+ {
+ string[] errors = attribute is NativeSemanticStringValidationAttribute native
+ ? [.. native.GetValidationErrors(semanticString)]
+ : [];
+ if (errors.Length == 0)
+ {
+ yield return $"{attribute.GetType().Name} validation failed.";
+ continue;
+ }
+
+ foreach (string error in errors)
+ {
+ yield return error;
+ }
+ }
+ }
}
diff --git a/Semantics.Test/Identifiers/ValidationMessageTests.cs b/Semantics.Test/Identifiers/ValidationMessageTests.cs
new file mode 100644
index 00000000..17e63dcc
--- /dev/null
+++ b/Semantics.Test/Identifiers/ValidationMessageTests.cs
@@ -0,0 +1,52 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Semantics.Test.Identifiers;
+
+using ktsu.Semantics.Strings.Identifiers;
+
+///
+/// Issue #333: a rejected sensitive value stays out of the exception message, and the message says
+/// which rule the value broke.
+///
+[TestClass]
+public sealed class ValidationMessageTests
+{
+ [TestMethod]
+ public void CreditCardNumber_Rejected_MessageOmitsValueAndGivesReason()
+ {
+ ArgumentException ex = Assert.ThrowsExactly(() => CreditCardNumber.Create("4111 1111 1111 1112"));
+
+ Assert.DoesNotContain("4111111111111112", ex.Message);
+ Assert.Contains(nameof(CreditCardNumber), ex.Message);
+ Assert.Contains("Luhn", ex.Message);
+ }
+
+ [TestMethod]
+ public void JwtToken_Rejected_MessageOmitsValueAndGivesReason()
+ {
+ const string token = "WyJhIl0.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
+ ArgumentException ex = Assert.ThrowsExactly(() => JwtToken.Create(token));
+
+ Assert.DoesNotContain("eyJzdWIiOiIxMjM0NTY3ODkwIn0", ex.Message);
+ Assert.Contains("JWT header", ex.Message);
+ }
+
+ [TestMethod]
+ public void Iban_Rejected_MessageOmitsValueAndGivesReason()
+ {
+ ArgumentException ex = Assert.ThrowsExactly(() => Iban.Create("GB82 WEST 1234 5698 7654 33"));
+
+ Assert.DoesNotContain("GB82WEST12345698765433", ex.Message);
+ Assert.Contains("mod-97", ex.Message);
+ }
+
+ [TestMethod]
+ public void Uuid_Rejected_MessageKeepsValueAndGivesReason()
+ {
+ ArgumentException ex = Assert.ThrowsExactly(() => Uuid.Create("not-a-uuid"));
+
+ Assert.Contains("not-a-uuid", ex.Message);
+ Assert.Contains(nameof(Uuid), ex.Message);
+ Assert.Contains("RFC 4122", ex.Message);
+ }
+}