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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.0.1] - 2026-09-29

Fixes from an audit of 3.0.0. Each one has a test that fails on 3.0.0.

### Fixed

- **A `MultiResult` failure leaked exception messages into the response.** The
multi-error path wrote `[CODE] message` for every error and ignored
`IncludeErrorMessage`, `IncludeErrorCode` and `IncludeExceptionDetails`. It now
applies them the same way the single-error path does, and a new
`CreateFromMultiResult(result, options)` overload takes options directly. This
predates 3.0.0.
- **A success mapped to 204 or 304 no longer writes a body.** `ToHttpResult` and
`ToActionResult` serialised the value anyway, and Kestrel throws when a
response with those codes has a body.
- **The `ValueTask` fast path no longer throws out of the call.** When the source
had already completed, an exception from the delegate escaped synchronously,
while the pending path put it in the task. Both now fault the task. An
already cancelled token gives a canceled task instead of a synchronous throw.
- **Message neutralising missed C1 controls, NEL, U+2028 and U+2029**, which
some log viewers and JavaScript treat as line breaks.
- **Truncation could split a surrogate pair**, leaving a lone high surrogate that
an encoder rejects or turns into U+FFFD.
- **`ErrorStatusCodeMapper.GetStatusCode(default)` threw** on the null code. It
now returns 400, or 500 when an exception is attached.
- **Reading `Value` on a failed `MultiResult` after `DisposeErrors`** threw
`ObjectDisposedException` instead of the documented
`InvalidOperationException`.
- `ResultLogger` awaits with `ConfigureAwait(false)`.

### Changed

- **`MultiResult.Failure` with no errors now throws `ArgumentException`.** It
used to build a failure that reported nothing and rendered as an empty 400.
- **A `MultiResult` that holds an error with an exception now maps to 500**,
matching the single-error path, rather than 400.

## [3.0.0] - 2026-08-31

The release where the zero-allocation claim became something the build enforces
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<!-- Single source of truth for the release version. The publish workflow
reads this rather than taking a hand-typed input, so what shipped is
always recorded in git. -->
<VersionPrefix>3.0.0</VersionPrefix>
<VersionPrefix>3.0.1</VersionPrefix>

<!-- Dependency advisories fail the build rather than waiting for someone to
run the check by hand. Mode 'all' because the packages that matter here
Expand Down
7 changes: 7 additions & 0 deletions src/Verdict.AspNetCore/ErrorStatusCodeMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ public static class ErrorStatusCodeMapper
/// <returns>HTTP status code.</returns>
public static int GetStatusCode(Error error)
{
// default(Error) never ran its constructor, so its Code is null, and a
// null key throws from both dictionaries. It has no mapping to find.
if (string.IsNullOrEmpty(error.Code))
{
return error.Exception is not null ? 500 : 400;
}

// Check custom mappings first
if (_customMappings.TryGetValue(error.Code, out var customCode))
{
Expand Down
53 changes: 51 additions & 2 deletions src/Verdict.AspNetCore/ProblemDetailsFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,40 @@ public static ProblemDetails CreateFromError(Error error, int statusCode, Verdic
}

/// <summary>
/// Creates ValidationProblemDetails from MultiResult errors.
/// Creates ValidationProblemDetails from MultiResult errors, using the
/// default options.
/// </summary>
/// <typeparam name="T">The result value type.</typeparam>
/// <param name="result">The multi-result with errors.</param>
/// <returns>RFC 7807 compliant ValidationProblemDetails.</returns>
public static ValidationProblemDetails CreateFromMultiResult<T>(Verdict.Extensions.MultiResult<T> result)
{
var options = Volatile.Read(ref _defaultOptions);
return CreateFromMultiResult(result, options);
}

/// <summary>
/// Creates ValidationProblemDetails from MultiResult errors, applying the
/// same message, code and exception rules as <see cref="CreateFromError(Error, int, VerdictProblemDetailsOptions)"/>
/// to every error.
/// </summary>
/// <remarks>
/// This path used to print every error as <c>[CODE] message</c> whatever the
/// options said, so an exception message in a multi-error result reached the
/// client with every leak-prevention option turned off. When any error
/// carries an exception the status is 500, for the same reason
/// <see cref="ErrorStatusCodeMapper.GetStatusCode(Error)"/> returns 500 for one.
/// </remarks>
/// <typeparam name="T">The result value type.</typeparam>
/// <param name="result">The multi-result with errors.</param>
/// <param name="options">The options to apply. Null uses the defaults.</param>
/// <returns>RFC 7807 compliant ValidationProblemDetails.</returns>
public static ValidationProblemDetails CreateFromMultiResult<T>(
Verdict.Extensions.MultiResult<T> result,
VerdictProblemDetailsOptions options)
{
options ??= Volatile.Read(ref _defaultOptions);

// Reading a disposed collection throws, and this runs while the handler is
// already building an error response, so throwing here turns a reported
// validation failure into an unhandled 500 and loses the errors entirely.
Expand All @@ -133,18 +160,40 @@ public static ValidationProblemDetails CreateFromMultiResult<T>(Verdict.Extensio
// Pre-allocate array with exact size to avoid List resizing
var errorCount = result.ErrorCount;
var errorMessages = new string[errorCount];
var anyException = false;

int index = 0;
foreach (var error in result.Errors)
{
errorMessages[index++] = $"[{error.Code}] {error.Message}";
var carriesException = error.Exception is not null;
var withheld = carriesException && !options.IncludeExceptionDetails;
anyException |= carriesException;

var message = !options.IncludeErrorMessage || withheld
? options.GenericErrorMessage
: error.Message;

errorMessages[index++] = options.IncludeErrorCode && !withheld
? $"[{error.Code}] {message}"
: message;
}

var errors = new Dictionary<string, string[]>(1)
{
["errors"] = errorMessages
};

if (anyException)
{
return new ValidationProblemDetails(errors)
{
Type = GetProblemType(500),
Title = GetTitle(500),
Status = 500,
Detail = $"{errorCount} error(s) occurred."
};
}

return new ValidationProblemDetails(errors)
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Expand Down
28 changes: 24 additions & 4 deletions src/Verdict.AspNetCore/ResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ public static IResult ToHttpResult<T>(
{
if (result.IsSuccess)
{
return Results.Json(result.Value, statusCode: successStatusCode);
return ForbidsBody(successStatusCode)
? Results.StatusCode(successStatusCode)
: Results.Json(result.Value, statusCode: successStatusCode);
}

var statusCode = errorStatusCodeMapper?.Invoke(result.Error)
Expand Down Expand Up @@ -78,7 +80,9 @@ public static IResult ToHttpResult<T>(

if (result.IsSuccess)
{
return Results.Json(result.Value, statusCode: successStatusCode);
return ForbidsBody(successStatusCode)
? Results.StatusCode(successStatusCode)
: Results.Json(result.Value, statusCode: successStatusCode);
}

var (statusCode, problemDetails) = Describe(context, result.Error);
Expand Down Expand Up @@ -127,7 +131,9 @@ public static ActionResult<T> ToActionResult<T>(

if (result.IsSuccess)
{
return new ObjectResult(result.Value) { StatusCode = successStatusCode };
return ForbidsBody(successStatusCode)
? new StatusCodeResult(successStatusCode)
: new ObjectResult(result.Value) { StatusCode = successStatusCode };
}

var (statusCode, problemDetails) = Describe(context, result.Error);
Expand All @@ -142,6 +148,14 @@ public static ActionResult<T> ToActionResult<T>(
/// <summary>
/// Resolves the container's mapper and factory, falling back to the statics.
/// </summary>
/// <summary>
/// Whether HTTP forbids a body with this status. Writing the value anyway
/// made Kestrel throw after the handler returned, so the client got an
/// empty 204 and the server logged an unhandled exception.
/// </summary>
private static bool ForbidsBody(int statusCode) =>
statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200);

private static (int StatusCode, ProblemDetails Details) Describe(HttpContext context, Error error)
{
var services = context.RequestServices;
Expand Down Expand Up @@ -187,6 +201,13 @@ public static ActionResult<T> ToActionResult<T>(
{
if (result.IsSuccess)
{
if (ForbidsBody(successStatusCode))
{
return successStatusCode == 204
? new NoContentResult()
: new StatusCodeResult(successStatusCode);
}

if (locationUri != null)
{
return successStatusCode switch
Expand All @@ -202,7 +223,6 @@ public static ActionResult<T> ToActionResult<T>(
200 => new OkObjectResult(result.Value),
201 => new ObjectResult(result.Value) { StatusCode = 201 },
202 => new ObjectResult(result.Value) { StatusCode = 202 },
204 => new NoContentResult(),
_ => new ObjectResult(result.Value) { StatusCode = successStatusCode }
};
}
Expand Down
Loading
Loading