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
3 changes: 3 additions & 0 deletions Semantics.Strings.Identifiers/CreditCardNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ protected override string MakeCanonical(string input)
Ensure.NotNull(input);
return input.Replace(" ", string.Empty).Replace("-", string.Empty);
}

/// <summary>A rejected value is sensitive, so it is left out of the validation exception message.</summary>
protected override bool IncludeValueInErrors => false;
}
3 changes: 3 additions & 0 deletions Semantics.Strings.Identifiers/Iban.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,7 @@ protected override string MakeCanonical(string input)

return canonical.ToString();
}

/// <summary>A rejected value is sensitive, so it is left out of the validation exception message.</summary>
protected override bool IncludeValueInErrors => false;
}
2 changes: 2 additions & 0 deletions Semantics.Strings.Identifiers/JwtToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ namespace ktsu.Semantics.Strings.Identifiers;
[IsJwtToken]
public sealed record JwtToken : SemanticString<JwtToken>
{
/// <summary>A rejected value is sensitive, so it is left out of the validation exception message.</summary>
protected override bool IncludeValueInErrors => false;
}
29 changes: 28 additions & 1 deletion Semantics.Strings/SemanticString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,17 @@ public TDest As<TDest>()
/// </remarks>
public virtual bool ValidateAttributes() => AttributeValidation.ValidateAttributes(this, GetType());

/// <summary>
/// Gets whether the exception thrown for a value that fails validation quotes that value.
/// </summary>
/// <remarks>
/// <see langword="true"/> by default. Override it to return <see langword="false"/> 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.
/// </remarks>
protected internal virtual bool IncludeValueInErrors => true;

// Note: Explicit conversion operators were removed in favor of the .As<T>() extension method pattern
// for better discoverability, consistency, and clearer intent at call sites.

Expand Down Expand Up @@ -635,11 +646,27 @@ private static TDest PerformValidation<TDest>(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;
}

/// <summary>
/// Builds the message for a value that failed validation: the rejected value, unless the type opts out
/// through <see cref="IncludeValueInErrors"/>, followed by the reasons its validation attributes give.
/// </summary>
/// <typeparam name="TDest">The semantic string type being validated.</typeparam>
/// <param name="value">The semantic string instance that failed validation.</param>
/// <returns>The exception message.</returns>
private static string ValidationFailureMessage<TDest>(TDest? value)
where TDest : SemanticString<TDest>
{
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)}";
}

/// <summary>
/// Attempts to create a new instance of this semantic string type from a string value without throwing exceptions.
/// </summary>
Expand Down
37 changes: 37 additions & 0 deletions Semantics.Strings/Validation/SemanticStringValidationAttributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace ktsu.Semantics.Strings;

using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// Helper class for validating using attributes
Expand All @@ -24,4 +26,39 @@ public static bool ValidateAttributes(ISemanticString semanticString, Type type)
IValidationStrategy strategy = ValidationStrategyFactory.CreateStrategy(type);
return strategy.Validate(semanticString, type);
}

/// <summary>
/// Gets the reasons the validation attributes defined on a type give for rejecting a SemanticString.
/// </summary>
/// <param name="semanticString">The SemanticString that failed validation</param>
/// <param name="type">The type of the SemanticString, which contains the validation attributes</param>
/// <returns>The error messages of every attribute that rejects the string, in declaration order</returns>
/// <remarks>
/// Under <see cref="ValidateAllAttribute"/> these are the attributes that failed; under
/// <see cref="ValidateAnyAttribute"/> every attribute failed, so this is the union of their errors.
/// An attribute that reports no reason of its own is named instead.
/// </remarks>
public static IEnumerable<string> GetValidationErrors(ISemanticString semanticString, Type type)
{
Ensure.NotNull(type);

foreach (SemanticStringValidationAttribute attribute in type.GetCustomAttributes(typeof(SemanticStringValidationAttribute), true)
.Cast<SemanticStringValidationAttribute>()
.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;
}
}
}
}
52 changes: 52 additions & 0 deletions Semantics.Test/Identifiers/ValidationMessageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Test.Identifiers;

using ktsu.Semantics.Strings.Identifiers;

/// <summary>
/// Issue #333: a rejected sensitive value stays out of the exception message, and the message says
/// which rule the value broke.
/// </summary>
[TestClass]
public sealed class ValidationMessageTests
{
[TestMethod]
public void CreditCardNumber_Rejected_MessageOmitsValueAndGivesReason()
{
ArgumentException ex = Assert.ThrowsExactly<ArgumentException>(() => 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<ArgumentException>(() => JwtToken.Create(token));

Assert.DoesNotContain("eyJzdWIiOiIxMjM0NTY3ODkwIn0", ex.Message);
Assert.Contains("JWT header", ex.Message);
}

[TestMethod]
public void Iban_Rejected_MessageOmitsValueAndGivesReason()
{
ArgumentException ex = Assert.ThrowsExactly<ArgumentException>(() => 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<ArgumentException>(() => Uuid.Create("not-a-uuid"));

Assert.Contains("not-a-uuid", ex.Message);
Assert.Contains(nameof(Uuid), ex.Message);
Assert.Contains("RFC 4122", ex.Message);
}
}
Loading