< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Forms.LoggerExtensions
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Forms/KrFormParser.cs
Tag: Kestrun/Kestrun@eeafbe813231ed23417e7b339e170e307b2c86f9
Line coverage
100%
Covered lines: 12
Uncovered lines: 0
Coverable lines: 12
Total lines: 1152
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 02/05/2026 - 00:28:18 Line coverage: 100% (12/12) Branch coverage: 100% (4/4) Total lines: 1125 Tag: Kestrun/Kestrun@d9261bd752e45afa789d10bc0c82b7d5724d958902/18/2026 - 08:33:07 Line coverage: 100% (12/12) Branch coverage: 100% (4/4) Total lines: 1152 Tag: Kestrun/Kestrun@bf8a937cfb7e8936c225b9df4608f8ddd85558b1 02/05/2026 - 00:28:18 Line coverage: 100% (12/12) Branch coverage: 100% (4/4) Total lines: 1125 Tag: Kestrun/Kestrun@d9261bd752e45afa789d10bc0c82b7d5724d958902/18/2026 - 08:33:07 Line coverage: 100% (12/12) Branch coverage: 100% (4/4) Total lines: 1152 Tag: Kestrun/Kestrun@bf8a937cfb7e8936c225b9df4608f8ddd85558b1

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
BeginTimedOperation(...)100%11100%
.ctor(...)100%22100%
Dispose()100%22100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Forms/KrFormParser.cs

#LineLine coverage
 1using System.Diagnostics;
 2using System.Text;
 3using Kestrun.Logging;
 4using Microsoft.AspNetCore.Http.Features;
 5using Microsoft.AspNetCore.WebUtilities;
 6using Microsoft.Net.Http.Headers;
 7using Serilog;
 8using Serilog.Events;
 9using Logger = Serilog.ILogger;
 10
 11namespace Kestrun.Forms;
 12
 13/// <summary>
 14/// Parses incoming form payloads into normalized form payloads.
 15/// </summary>
 16public static class KrFormParser
 17{
 18    /// <summary>
 19    /// Parses the incoming request into a normalized form payload.
 20    /// </summary>
 21    /// <param name="context">The HTTP context.</param>
 22    /// <param name="options">The form parsing options.</param>
 23    /// <param name="cancellationToken">The cancellation token.</param>
 24    /// <returns>The parsed payload.</returns>
 25    public static async Task<IKrFormPayload> ParseAsync(HttpContext context, KrFormOptions options, CancellationToken ca
 26    {
 27        ArgumentNullException.ThrowIfNull(context);
 28        ArgumentNullException.ThrowIfNull(options);
 29
 30        var logger = ResolveLogger(context, options);
 31        using var _ = logger.BeginTimedOperation("KrFormParser.ParseAsync");
 32
 33        try
 34        {
 35            var (mediaType, normalizedMediaType) = ValidateAndNormalizeMediaType(context, options, logger);
 36            ApplyRequestBodyLimit(context, options, logger);
 37
 38            return await ParseByContentTypeAsync(context, mediaType, normalizedMediaType, options, logger, cancellationT
 39                .ConfigureAwait(false);
 40        }
 41        catch (KrFormException)
 42        {
 43            TryMarkConnectionClose(context, logger);
 44            throw;
 45        }
 46    }
 47
 48    /// <summary>
 49    /// Marks the current connection as non-keep-alive for HTTP/1.x responses.
 50    /// This avoids Kestrel attempting to parse unread request-body bytes as a new request line
 51    /// when the application rejects a form upload early (e.g., 415/400/413).
 52    /// </summary>
 53    /// <param name="context">The HTTP context.</param>
 54    /// <param name="logger">The logger.</param>
 55    private static void TryMarkConnectionClose(HttpContext context, Logger logger)
 56    {
 57        if (context.Response.HasStarted)
 58        {
 59            return;
 60        }
 61
 62        // Only meaningful on HTTP/1.x. For HTTP/2+, the header is ignored.
 63        context.Response.Headers[HeaderNames.Connection] = "close";
 64        logger.Debug("Form parsing error: setting Connection: close to avoid unread-body keep-alive issues.");
 65    }
 66
 67    /// <summary>
 68    /// Resolves the logger to use for form parsing.
 69    /// </summary>
 70    /// <param name="context">The HTTP context.</param>
 71    /// <param name="options">The form parsing options.</param>
 72    /// <returns>The resolved logger.</returns>
 73    private static Logger ResolveLogger(HttpContext context, KrFormOptions options)
 74    {
 75        return options.Logger
 76            ?? context.RequestServices.GetService(typeof(Serilog.ILogger)) as Serilog.ILogger
 77            ?? Log.Logger;
 78    }
 79
 80    /// <summary>
 81    /// Validates the Content-Type header and returns the parsed and normalized media type.
 82    /// </summary>
 83    /// <param name="context">The HTTP context.</param>
 84    /// <param name="options">The form parsing options.</param>
 85    /// <param name="logger">The logger.</param>
 86    /// <returns>The parsed media type and normalized media type string.</returns>
 87    private static (MediaTypeHeaderValue MediaType, string NormalizedMediaType) ValidateAndNormalizeMediaType(
 88        HttpContext context,
 89        KrFormOptions options,
 90        Logger logger)
 91    {
 92        var contentTypeHeader = context.Request.ContentType;
 93        var contentEncoding = context.Request.Headers[HeaderNames.ContentEncoding].ToString();
 94        var requestDecompressionEnabled = DetectRequestDecompressionEnabled(context);
 95        if (logger.IsEnabled(LogEventLevel.Debug))
 96        {
 97            logger.DebugSanitized(
 98                "Form route start: Content-Type={ContentType}, Content-Encoding={ContentEncoding}, RequestDecompressionE
 99                contentTypeHeader,
 100                string.IsNullOrWhiteSpace(contentEncoding) ? "<none>" : contentEncoding,
 101                requestDecompressionEnabled);
 102        }
 103
 104        if (string.IsNullOrWhiteSpace(contentTypeHeader))
 105        {
 106            logger.Error("Missing Content-Type header for form parsing.");
 107            throw new KrFormException("Content-Type header is required for form parsing.", StatusCodes.Status415Unsuppor
 108        }
 109
 110        if (!MediaTypeHeaderValue.TryParse(contentTypeHeader, out var mediaType))
 111        {
 112            logger.WarningSanitized("Invalid Content-Type header: {ContentType}", contentTypeHeader);
 113            throw new KrFormException("Invalid Content-Type header.", StatusCodes.Status415UnsupportedMediaType);
 114        }
 115
 116        var normalizedMediaType = mediaType.MediaType.Value ?? string.Empty;
 117        if (!IsAllowedRequestContentType(normalizedMediaType, options.AllowedContentTypes))
 118        {
 119            if (options.RejectUnknownRequestContentType)
 120            {
 121                logger.Error("Rejected request Content-Type: {ContentType}", normalizedMediaType);
 122                throw new KrFormException("Unsupported Content-Type for form parsing.", StatusCodes.Status415Unsupported
 123            }
 124
 125            logger.Warning("Unknown Content-Type allowed: {ContentType}", normalizedMediaType);
 126        }
 127
 128        if (IsMultipartContentType(normalizedMediaType) && !mediaType.Boundary.HasValue)
 129        {
 130            logger.Error("Missing multipart boundary for Content-Type: {ContentType}", normalizedMediaType);
 131            throw new KrFormException("Missing multipart boundary.", StatusCodes.Status400BadRequest);
 132        }
 133
 134        return (mediaType, normalizedMediaType);
 135    }
 136
 137    /// <summary>
 138    /// Parses the request body based on the normalized content type.
 139    /// </summary>
 140    /// <param name="context">The HTTP context.</param>
 141    /// <param name="mediaType">The parsed media type.</param>
 142    /// <param name="normalizedMediaType">The normalized media type string.</param>
 143    /// <param name="options">The form parsing options.</param>
 144    /// <param name="logger">The logger.</param>
 145    /// <param name="cancellationToken">The cancellation token.</param>
 146    /// <returns>The parsed payload.</returns>
 147    private static Task<IKrFormPayload> ParseByContentTypeAsync(
 148        HttpContext context,
 149        MediaTypeHeaderValue mediaType,
 150        string normalizedMediaType,
 151        KrFormOptions options,
 152        Logger logger,
 153        CancellationToken cancellationToken)
 154    {
 155        // application/x-www-form-urlencoded
 156        if (normalizedMediaType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
 157        {
 158            return ParseUrlEncodedAsync(context, options, logger, cancellationToken);
 159        }
 160        // multipart/form-data
 161        if (normalizedMediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase))
 162        {
 163            return ParseMultipartFormDataAsync(context, mediaType, options, logger, cancellationToken);
 164        }
 165        // ordered multipart types
 166        if (normalizedMediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase))
 167        {
 168            return ParseMultipartOrderedAsync(context, mediaType, options, logger, 0, cancellationToken);
 169        }
 170        // unsupported content type
 171        throw new KrFormException("Unsupported Content-Type for form parsing.", StatusCodes.Status415UnsupportedMediaTyp
 172    }
 173
 174    /// <summary>
 175    /// Parses the incoming request into a normalized form payload. Synchronous wrapper.
 176    /// </summary>
 177    /// <param name="context">The HTTP context.</param>
 178    /// <param name="options">The form parsing options.</param>
 179    /// <param name="cancellationToken">The cancellation token.</param>
 180    /// <returns>The parsed payload.</returns>
 181    public static IKrFormPayload Parse(HttpContext context, KrFormOptions options, CancellationToken cancellationToken) 
 182           ParseAsync(context, options, cancellationToken).GetAwaiter().GetResult();
 183
 184    /// <summary>
 185    /// Applies the request body size limit based on the provided options.
 186    /// </summary>
 187    /// <param name="context">The HTTP context of the current request.</param>
 188    /// <param name="options">The form parsing options containing limits.</param>
 189    /// <param name="logger">The logger for diagnostic messages.</param>
 190    private static void ApplyRequestBodyLimit(HttpContext context, KrFormOptions options, Logger logger)
 191    {
 192        if (!options.Limits.MaxRequestBodyBytes.HasValue)
 193        {
 194            return;
 195        }
 196
 197        var feature = context.Features.Get<IHttpMaxRequestBodySizeFeature>();
 198        if (feature == null || feature.IsReadOnly)
 199        {
 200            logger.Debug("Request body size feature not available or read-only.");
 201            return;
 202        }
 203
 204        feature.MaxRequestBodySize = options.Limits.MaxRequestBodyBytes;
 205        logger.Debug("Set MaxRequestBodySize to {MaxBytes}", options.Limits.MaxRequestBodyBytes);
 206    }
 207
 208    private static async Task<IKrFormPayload> ParseUrlEncodedAsync(HttpContext context, KrFormOptions options, Logger lo
 209    {
 210        var payload = new KrFormData();
 211        var form = await context.Request.ReadFormAsync(cancellationToken).ConfigureAwait(false);
 212        foreach (var key in form.Keys)
 213        {
 214            payload.Fields[key] = [.. form[key].Select(static v => v ?? string.Empty)];
 215        }
 216
 217        var rules = CreateRuleMap(options, isRoot: true, scopeName: null);
 218        ValidateRequiredRules(payload, rules, logger);
 219
 220        logger.Information("Parsed x-www-form-urlencoded payload with {FieldCount} fields.", payload.Fields.Count);
 221        return payload;
 222    }
 223
 224    /// <summary>
 225    /// Parses a multipart/form-data payload from the request.
 226    /// </summary>
 227    /// <param name="context">The HTTP context.</param>
 228    /// <param name="mediaType">The media type header value.</param>
 229    /// <param name="options">The form parsing options.</param>
 230    /// <param name="logger">The logger for diagnostic messages.</param>
 231    /// <param name="cancellationToken">The cancellation token.</param>
 232    /// <returns>The parsed payload.</returns>
 233    /// <exception cref="KrFormLimitExceededException">Thrown when the multipart form exceeds configured limits.</except
 234    /// <exception cref="KrFormException">Thrown when a part is rejected by policy or other form errors occur.</exceptio
 235    private static async Task<IKrFormPayload> ParseMultipartFormDataAsync(HttpContext context, MediaTypeHeaderValue medi
 236    {
 237        var boundary = GetBoundary(mediaType);
 238        var reader = new MultipartReader(boundary, context.Request.Body)
 239        {
 240            HeadersLengthLimit = options.Limits.MaxHeaderBytesPerPart
 241        };
 242
 243        var payload = new KrFormData();
 244        var rules = CreateRuleMap(options, isRoot: true, scopeName: null);
 245        var partIndex = 0;
 246        long totalBytes = 0;
 247        var stopwatch = Stopwatch.StartNew();
 248
 249        MultipartSection? section;
 250        while ((section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false)) != null)
 251        {
 252            partIndex++;
 253            if (partIndex > options.Limits.MaxParts)
 254            {
 255                logger.Error("Multipart form exceeded MaxParts limit ({MaxParts}).", options.Limits.MaxParts);
 256                throw new KrFormLimitExceededException("Too many multipart sections.");
 257            }
 258            var partContext = BuildFormDataPartContext(section, rules, partIndex, logger);
 259            LogFormDataPartDebug(logger, partContext, partIndex - 1);
 260
 261            var contentEncoding = partContext.ContentEncoding;
 262            if (await HandleFormDataPartActionAsync(section, options, partContext, logger, contentEncoding, cancellation
 263            {
 264                continue;
 265            }
 266
 267            if (IsFilePart(partContext.FileName))
 268            {
 269                totalBytes += await ProcessFormDataFilePartAsync(
 270                    section,
 271                    options,
 272                    payload,
 273                    partContext,
 274                    logger,
 275                    cancellationToken).ConfigureAwait(false);
 276                continue;
 277            }
 278
 279            totalBytes += await ProcessFormDataFieldPartAsync(
 280                section,
 281                options,
 282                payload,
 283                partContext,
 284                logger,
 285                cancellationToken).ConfigureAwait(false);
 286        }
 287
 288        ValidateRequiredRules(payload, rules, logger);
 289        stopwatch.Stop();
 290        logger.Information("Parsed multipart/form-data with {Parts} parts, {Files} files, {Bytes} bytes in {ElapsedMs} m
 291            partIndex, payload.Files.Sum(k => k.Value.Length), totalBytes, stopwatch.ElapsedMilliseconds);
 292
 293        return payload;
 294    }
 295
 296    /// <summary>
 297    /// Builds the part context for multipart/form-data sections.
 298    /// </summary>
 299    /// <param name="section">The multipart section.</param>
 300    /// <param name="rules">The form part rule map.</param>
 301    /// <param name="partIndex">The current part index (1-based).</param>
 302    /// <param name="logger">The logger instance.</param>
 303    /// <returns>The constructed part context.</returns>
 304    private static KrPartContext BuildFormDataPartContext(
 305        MultipartSection section,
 306        IReadOnlyDictionary<string, KrFormPartRule> rules,
 307        int partIndex,
 308        Logger logger)
 309    {
 310        var headers = ToHeaderDictionary(section.Headers ?? []);
 311        var (name, fileName, _) = GetContentDisposition(section, logger);
 312        var contentType = section.ContentType ?? (string.IsNullOrWhiteSpace(fileName) ? "text/plain" : "application/octe
 313        var contentEncoding = GetHeaderValue(headers, HeaderNames.ContentEncoding);
 314        var declaredLength = GetHeaderLong(headers, HeaderNames.ContentLength);
 315
 316        var rule = name != null && rules.TryGetValue(name, out var match) ? match : null;
 317        return new KrPartContext
 318        {
 319            Index = partIndex - 1,
 320            Name = name,
 321            FileName = fileName,
 322            ContentType = contentType,
 323            ContentEncoding = contentEncoding,
 324            DeclaredLength = declaredLength,
 325            Headers = headers,
 326            Rule = rule
 327        };
 328    }
 329
 330    /// <summary>
 331    /// Logs multipart/form-data part details when debug logging is enabled.
 332    /// </summary>
 333    /// <param name="logger">The logger instance.</param>
 334    /// <param name="partContext">The part context.</param>
 335    /// <param name="index">The 0-based part index.</param>
 336    private static void LogFormDataPartDebug(Logger logger, KrPartContext partContext, int index)
 337    {
 338        if (!logger.IsEnabled(LogEventLevel.Debug))
 339        {
 340            return;
 341        }
 342
 343        logger.Debug("Multipart part {Index} name={Name} filename={FileName} contentType={ContentType} contentEncoding={
 344            index,
 345            partContext.Name,
 346            partContext.FileName,
 347            partContext.ContentType,
 348            string.IsNullOrWhiteSpace(partContext.ContentEncoding) ? "<none>" : partContext.ContentEncoding,
 349            partContext.DeclaredLength);
 350    }
 351
 352    /// <summary>
 353    /// Handles the OnPart hook for multipart/form-data sections.
 354    /// </summary>
 355    /// <param name="section">The multipart section.</param>
 356    /// <param name="options">The form options.</param>
 357    /// <param name="partContext">The part context.</param>
 358    /// <param name="logger">The logger instance.</param>
 359    /// <param name="contentEncoding">The content encoding.</param>
 360    /// <param name="cancellationToken">The cancellation token.</param>
 361    /// <returns><c>true</c> when the caller should skip further processing for this section.</returns>
 362    private static async Task<bool> HandleFormDataPartActionAsync(
 363        MultipartSection section,
 364        KrFormOptions options,
 365        KrPartContext partContext,
 366        Logger logger,
 367        string? contentEncoding,
 368        CancellationToken cancellationToken)
 369    {
 370        var action = await InvokeOnPartAsync(options, partContext, logger).ConfigureAwait(false);
 371        if (action == KrPartAction.Reject)
 372        {
 373            logger.Error("Part rejected by hook: {PartIndex}", partContext.Index);
 374            throw new KrFormException("Part rejected by policy.", StatusCodes.Status400BadRequest);
 375        }
 376
 377        if (action == KrPartAction.Skip)
 378        {
 379            logger.Warning("Part skipped by hook: {PartIndex}", partContext.Index);
 380            await DrainSectionAsync(section.Body, options, contentEncoding, logger, cancellationToken).ConfigureAwait(fa
 381            return true;
 382        }
 383
 384        return false;
 385    }
 386
 387    /// <summary>
 388    /// Determines whether a part represents a file based on the file name.
 389    /// </summary>
 390    /// <param name="fileName">The file name from the part.</param>
 391    /// <returns><c>true</c> if the part is a file; otherwise <c>false</c>.</returns>
 392    private static bool IsFilePart(string? fileName)
 393        => !string.IsNullOrWhiteSpace(fileName);
 394
 395    /// <summary>
 396    /// Processes a file part in multipart/form-data payloads.
 397    /// </summary>
 398    /// <param name="section">The multipart section.</param>
 399    /// <param name="options">The form options.</param>
 400    /// <param name="payload">The form payload to populate.</param>
 401    /// <param name="partContext">The part context.</param>
 402    /// <param name="logger">The logger instance.</param>
 403    /// <param name="cancellationToken">The cancellation token.</param>
 404    /// <returns>The number of bytes processed.</returns>
 405    private static async Task<long> ProcessFormDataFilePartAsync(
 406        MultipartSection section,
 407        KrFormOptions options,
 408        KrFormData payload,
 409        KrPartContext partContext,
 410        Logger logger,
 411        CancellationToken cancellationToken)
 412    {
 413        ValidateFilePart(partContext.Name, partContext.FileName!, partContext.ContentType, partContext.Rule, payload, lo
 414        var result = await StorePartAsync(section.Body, options, partContext.Rule, partContext.FileName, partContext.Con
 415            .ConfigureAwait(false);
 416
 417        var filePart = new KrFilePart
 418        {
 419            Name = partContext.Name!,
 420            OriginalFileName = partContext.FileName!,
 421            ContentType = partContext.ContentType,
 422            Length = result.Length,
 423            TempPath = result.TempPath,
 424            Sha256 = result.Sha256,
 425            Headers = partContext.Headers
 426        };
 427
 428        AppendFile(payload.Files, filePart, partContext.Rule, logger);
 429        LogStoredFilePart(logger, partContext, result);
 430        return result.Length;
 431    }
 432
 433    /// <summary>
 434    /// Processes a field part in multipart/form-data payloads.
 435    /// </summary>
 436    /// <param name="section">The multipart section.</param>
 437    /// <param name="options">The form options.</param>
 438    /// <param name="payload">The form payload to populate.</param>
 439    /// <param name="partContext">The part context.</param>
 440    /// <param name="logger">The logger instance.</param>
 441    /// <param name="cancellationToken">The cancellation token.</param>
 442    /// <returns>The number of bytes processed.</returns>
 443    private static async Task<long> ProcessFormDataFieldPartAsync(
 444        MultipartSection section,
 445        KrFormOptions options,
 446        KrFormData payload,
 447        KrPartContext partContext,
 448        Logger logger,
 449        CancellationToken cancellationToken)
 450    {
 451        if (string.IsNullOrWhiteSpace(partContext.Name))
 452        {
 453            logger.Error("Field part missing name.");
 454            throw new KrFormException("Field part must include a name.", StatusCodes.Status400BadRequest);
 455        }
 456
 457        var value = await ReadFieldValueAsync(section.Body, options, partContext.ContentEncoding, logger, cancellationTo
 458            .ConfigureAwait(false);
 459        AppendField(payload.Fields, partContext.Name ?? string.Empty, value);
 460        var bytes = Encoding.UTF8.GetByteCount(value);
 461        logger.Debug("Parsed field part {Index} name={Name} bytes={Bytes}", partContext.Index, partContext.Name, bytes);
 462        return bytes;
 463    }
 464
 465    /// <summary>
 466    /// Logs file-part storage results for multipart/form-data payloads.
 467    /// </summary>
 468    /// <param name="logger">The logger instance.</param>
 469    /// <param name="partContext">The part context.</param>
 470    /// <param name="result">The stored part result.</param>
 471    private static void LogStoredFilePart(Logger logger, KrPartContext partContext, KrPartWriteResult result)
 472    {
 473        if (string.IsNullOrWhiteSpace(result.TempPath))
 474        {
 475            logger.Warning("File part {Index} name={Name} was not stored to disk (bytes={Bytes}).", partContext.Index, p
 476            return;
 477        }
 478
 479        logger.Information("Stored file part {Index} name={Name} filename={FileName} contentType={ContentType} bytes={By
 480            partContext.Index,
 481            partContext.Name,
 482            partContext.FileName,
 483            partContext.ContentType,
 484            result.Length);
 485    }
 486
 487    /// <summary>
 488    /// Parses an ordered multipart payload from the request.
 489    /// </summary>
 490    /// <param name="context">The current HTTP context.</param>
 491    /// <param name="mediaType">The media type of the request.</param>
 492    /// <param name="options">The form options for parsing.</param>
 493    /// <param name="logger">The logger instance.</param>
 494    /// <param name="nestingDepth">The current nesting depth for multipart parsing.</param>
 495    /// <param name="cancellationToken">The cancellation token.</param>
 496    /// <returns>Returns the parsed multipart form payload.</returns>
 497    private static async Task<IKrFormPayload> ParseMultipartOrderedAsync(HttpContext context, MediaTypeHeaderValue media
 498    {
 499        var boundary = GetBoundary(mediaType);
 500        return await ParseMultipartFromStreamAsync(context.Request.Body, boundary, options, logger, nestingDepth, isRoot
 501    }
 502
 503    /// <summary>
 504    /// Parses a multipart payload from the provided stream.
 505    /// </summary>
 506    /// <param name="body">The input stream containing the multipart payload.</param>
 507    /// <param name="boundary">The multipart boundary string.</param>
 508    /// <param name="options">The form options for parsing.</param>
 509    /// <param name="logger">The logger instance.</param>
 510    /// <param name="nestingDepth">The current nesting depth for multipart parsing.</param>
 511    /// <param name="isRoot">Indicates if this is the root multipart payload.</param>
 512    /// <param name="scopeName">The current scope name, or null if root.</param>
 513    /// <param name="cancellationToken">The cancellation token.</param>
 514    /// <returns>Returns the parsed multipart form payload.</returns>
 515    private static async Task<IKrFormPayload> ParseMultipartFromStreamAsync(Stream body, string boundary, KrFormOptions 
 516    {
 517        var reader = new MultipartReader(boundary, body)
 518        {
 519            HeadersLengthLimit = options.Limits.MaxHeaderBytesPerPart
 520        };
 521
 522        var payload = new KrMultipart();
 523        var rules = CreateRuleMap(options, isRoot, scopeName);
 524        var partIndex = 0;
 525        long totalBytes = 0;
 526
 527        MultipartSection? section;
 528        while ((section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false)) != null)
 529        {
 530            partIndex++;
 531            if (partIndex > options.Limits.MaxParts)
 532            {
 533                logger.Error("Multipart payload exceeded MaxParts limit ({MaxParts}).", options.Limits.MaxParts);
 534                throw new KrFormLimitExceededException("Too many multipart sections.");
 535            }
 536
 537            var partContext = BuildOrderedPartContext(section, rules, partIndex, logger);
 538            LogOrderedPartDebug(logger, partContext, partIndex - 1);
 539
 540            var contentEncoding = partContext.ContentEncoding;
 541            if (await HandleOrderedPartActionAsync(section, options, partContext, logger, contentEncoding, cancellationT
 542            {
 543                continue;
 544            }
 545
 546            var result = await StorePartAsync(section.Body, options, partContext.Rule, null, contentEncoding, logger, ca
 547            totalBytes += result.Length;
 548
 549            var nested = await TryParseNestedPayloadAsync(
 550                partContext,
 551                result,
 552                options,
 553                logger,
 554                nestingDepth,
 555                cancellationToken).ConfigureAwait(false);
 556
 557            AddOrderedPart(payload, partContext, result, nested);
 558            LogStoredOrderedPart(logger, partContext, partIndex - 1, result);
 559        }
 560
 561        logger.Information("Parsed multipart ordered payload with {Parts} parts and {Bytes} bytes.", partIndex, totalByt
 562        return payload;
 563    }
 564
 565    /// <summary>
 566    /// Builds the part context for an ordered multipart section.
 567    /// </summary>
 568    /// <param name="section">The multipart section.</param>
 569    /// <param name="rules">The form part rule map.</param>
 570    /// <param name="partIndex">The current part index (1-based).</param>
 571    /// <param name="logger">The logger instance.</param>
 572    /// <returns>The constructed part context.</returns>
 573    private static KrPartContext BuildOrderedPartContext(
 574        MultipartSection section,
 575        IReadOnlyDictionary<string, KrFormPartRule> rules,
 576        int partIndex,
 577        Logger logger)
 578    {
 579        var headers = ToHeaderDictionary(section.Headers ?? []);
 580        var contentType = section.ContentType ?? "application/octet-stream";
 581        var allowMissingDisposition = IsMultipartContentType(contentType);
 582        var (name, fileName, _) = GetContentDisposition(section, logger, allowMissing: allowMissingDisposition);
 583        var contentEncoding = GetHeaderValue(headers, HeaderNames.ContentEncoding);
 584        var declaredLength = GetHeaderLong(headers, HeaderNames.ContentLength);
 585
 586        var rule = name != null && rules.TryGetValue(name, out var match) ? match : null;
 587        return new KrPartContext
 588        {
 589            Index = partIndex - 1,
 590            Name = name,
 591            FileName = fileName,
 592            ContentType = contentType,
 593            ContentEncoding = contentEncoding,
 594            DeclaredLength = declaredLength,
 595            Headers = headers,
 596            Rule = rule
 597        };
 598    }
 599
 600    /// <summary>
 601    /// Logs ordered multipart part details when debug logging is enabled.
 602    /// </summary>
 603    /// <param name="logger">The logger instance.</param>
 604    /// <param name="partContext">The part context.</param>
 605    /// <param name="index">The 0-based part index.</param>
 606    private static void LogOrderedPartDebug(Logger logger, KrPartContext partContext, int index)
 607    {
 608        if (!logger.IsEnabled(LogEventLevel.Debug))
 609        {
 610            return;
 611        }
 612
 613        logger.Debug("Ordered part {Index} name={Name} filename={FileName} contentType={ContentType} contentEncoding={Co
 614            index,
 615            partContext.Name,
 616            partContext.FileName,
 617            partContext.ContentType,
 618            string.IsNullOrWhiteSpace(partContext.ContentEncoding) ? "<none>" : partContext.ContentEncoding,
 619            partContext.DeclaredLength);
 620    }
 621
 622    /// <summary>
 623    /// Handles the OnPart hook for ordered multipart sections.
 624    /// </summary>
 625    /// <param name="section">The multipart section.</param>
 626    /// <param name="options">The form options.</param>
 627    /// <param name="partContext">The part context.</param>
 628    /// <param name="logger">The logger instance.</param>
 629    /// <param name="contentEncoding">The content encoding.</param>
 630    /// <param name="cancellationToken">The cancellation token.</param>
 631    /// <returns><c>true</c> when the caller should skip further processing for this section.</returns>
 632    private static async Task<bool> HandleOrderedPartActionAsync(
 633        MultipartSection section,
 634        KrFormOptions options,
 635        KrPartContext partContext,
 636        Logger logger,
 637        string? contentEncoding,
 638        CancellationToken cancellationToken)
 639    {
 640        var action = await InvokeOnPartAsync(options, partContext, logger).ConfigureAwait(false);
 641        if (action == KrPartAction.Reject)
 642        {
 643            logger.Error("Ordered part rejected by hook: {PartIndex}", partContext.Index);
 644            throw new KrFormException("Part rejected by policy.", StatusCodes.Status400BadRequest);
 645        }
 646
 647        if (action == KrPartAction.Skip)
 648        {
 649            logger.Warning("Ordered part skipped by hook: {PartIndex}", partContext.Index);
 650            await DrainSectionAsync(section.Body, options, contentEncoding, logger, cancellationToken).ConfigureAwait(fa
 651            return true;
 652        }
 653
 654        return false;
 655    }
 656
 657    /// <summary>
 658    /// Attempts to parse a nested multipart payload when the part content type is multipart.
 659    /// </summary>
 660    /// <param name="partContext">The part context.</param>
 661    /// <param name="result">The stored part result.</param>
 662    /// <param name="options">The form options.</param>
 663    /// <param name="logger">The logger instance.</param>
 664    /// <param name="nestingDepth">The current nesting depth.</param>
 665    /// <param name="cancellationToken">The cancellation token.</param>
 666    /// <returns>The nested payload, or null if none was parsed.</returns>
 667    private static async Task<IKrFormPayload?> TryParseNestedPayloadAsync(
 668        KrPartContext partContext,
 669        KrPartWriteResult result,
 670        KrFormOptions options,
 671        Logger logger,
 672        int nestingDepth,
 673        CancellationToken cancellationToken)
 674    {
 675        if (!IsMultipartContentType(partContext.ContentType))
 676        {
 677            return null;
 678        }
 679
 680        if (nestingDepth >= options.Limits.MaxNestingDepth)
 681        {
 682            logger.Error("Nested multipart depth exceeded limit {MaxDepth}.", options.Limits.MaxNestingDepth);
 683            throw new KrFormLimitExceededException("Nested multipart depth exceeded.");
 684        }
 685
 686        if (!TryGetBoundary(partContext.ContentType, out var nestedBoundary))
 687        {
 688            logger.Warning("Nested multipart part missing boundary header.");
 689            return null;
 690        }
 691
 692        if (string.IsNullOrWhiteSpace(result.TempPath))
 693        {
 694            logger.Warning("Nested multipart part was not stored to disk; skipping nested parse.");
 695            return null;
 696        }
 697
 698        await using var nestedStream = File.OpenRead(result.TempPath);
 699        return await ParseMultipartFromStreamAsync(
 700            nestedStream,
 701            nestedBoundary,
 702            options,
 703            logger,
 704            nestingDepth + 1,
 705            isRoot: false,
 706            scopeName: partContext.Name,
 707            cancellationToken).ConfigureAwait(false);
 708    }
 709
 710    /// <summary>
 711    /// Adds a parsed ordered part to the payload.
 712    /// </summary>
 713    /// <param name="payload">The multipart payload.</param>
 714    /// <param name="partContext">The part context.</param>
 715    /// <param name="result">The stored part result.</param>
 716    /// <param name="nested">The nested payload.</param>
 717    private static void AddOrderedPart(KrMultipart payload, KrPartContext partContext, KrPartWriteResult result, IKrForm
 718    {
 719        payload.Parts.Add(new KrRawPart
 720        {
 721            Name = partContext.Name,
 722            ContentType = partContext.ContentType,
 723            Length = result.Length,
 724            TempPath = result.TempPath,
 725            Headers = partContext.Headers,
 726            NestedPayload = nested
 727        });
 728    }
 729
 730    /// <summary>
 731    /// Logs ordered multipart part storage results.
 732    /// </summary>
 733    /// <param name="logger">The logger instance.</param>
 734    /// <param name="partContext">The part context.</param>
 735    /// <param name="index">The 0-based part index.</param>
 736    /// <param name="result">The stored part result.</param>
 737    private static void LogStoredOrderedPart(Logger logger, KrPartContext partContext, int index, KrPartWriteResult resu
 738    {
 739        if (string.IsNullOrWhiteSpace(result.TempPath))
 740        {
 741            logger.Warning("Ordered part {Index} name={Name} was not stored to disk (bytes={Bytes}).", index, partContex
 742            return;
 743        }
 744
 745        logger.Information("Stored ordered part {Index} name={Name} contentType={ContentType} bytes={Bytes}", index, par
 746    }
 747
 748    /// <summary>
 749    /// Stores a multipart part to disk or consumes it based on the provided options and rules.
 750    /// </summary>
 751    /// <param name="body">The input stream of the multipart part.</param>
 752    /// <param name="options">The form options for parsing.</param>
 753    /// <param name="rule">The form part rule, if any.</param>
 754    /// <param name="originalFileName">The original file name of the part, if any.</param>
 755    /// <param name="contentEncoding">The content encoding of the part, if any.</param>
 756    /// <param name="logger">The logger instance.</param>
 757    /// <param name="cancellationToken">The cancellation token.</param>
 758    /// <returns>Returns the result of storing the part.</returns>
 759    private static async Task<KrPartWriteResult> StorePartAsync(Stream body, KrFormOptions options, KrFormPartRule? rule
 760    {
 761        var maxBytes = rule?.MaxBytes ?? options.Limits.MaxPartBodyBytes;
 762        var effectiveMax = options.EnablePartDecompression ? Math.Min(maxBytes, options.MaxDecompressedBytesPerPart) : m
 763
 764        var source = body;
 765        if (options.EnablePartDecompression)
 766        {
 767            var (decoded, normalizedEncoding) = KrPartDecompression.CreateDecodedStream(body, contentEncoding);
 768            if (!IsEncodingAllowed(normalizedEncoding, options.AllowedPartContentEncodings))
 769            {
 770                var message = $"Unsupported Content-Encoding '{normalizedEncoding}' for multipart part.";
 771                if (options.RejectUnknownContentEncoding)
 772                {
 773                    logger.Error(message);
 774                    throw new KrFormException(message, StatusCodes.Status415UnsupportedMediaType);
 775                }
 776                logger.Warning(message);
 777            }
 778            else
 779            {
 780                logger.Debug("Part-level decompression enabled for encoding {Encoding}.", normalizedEncoding);
 781            }
 782            source = decoded;
 783        }
 784        else if (!string.IsNullOrWhiteSpace(contentEncoding) && !contentEncoding.Equals("identity", StringComparison.Ord
 785        {
 786            var message = $"Part Content-Encoding '{contentEncoding}' was supplied but part decompression is disabled.";
 787            if (options.RejectUnknownContentEncoding)
 788            {
 789                logger.Error(message);
 790                throw new KrFormException(message, StatusCodes.Status415UnsupportedMediaType);
 791            }
 792            logger.Warning(message);
 793        }
 794
 795        await using var limited = new LimitedReadStream(source, effectiveMax);
 796
 797        if (rule?.StoreToDisk == false)
 798        {
 799            var length = await ConsumeStreamAsync(limited, cancellationToken).ConfigureAwait(false);
 800            return new KrPartWriteResult
 801            {
 802                TempPath = string.Empty,
 803                Length = length,
 804                Sha256 = null
 805            };
 806        }
 807
 808        var targetPath = rule?.DestinationPath ?? options.DefaultUploadPath;
 809        _ = Directory.CreateDirectory(targetPath);
 810        var sanitizedFileName = string.IsNullOrWhiteSpace(originalFileName) ? null : options.SanitizeFileName(originalFi
 811        var sink = new KrDiskPartSink(targetPath, options.ComputeSha256, sanitizedFileName);
 812        return await sink.WriteAsync(limited, cancellationToken).ConfigureAwait(false);
 813    }
 814
 815    private static async Task<string> ReadFieldValueAsync(Stream body, KrFormOptions options, string? contentEncoding, L
 816    {
 817        var source = body;
 818        if (options.EnablePartDecompression)
 819        {
 820            var (decoded, normalizedEncoding) = KrPartDecompression.CreateDecodedStream(body, contentEncoding);
 821            if (!IsEncodingAllowed(normalizedEncoding, options.AllowedPartContentEncodings))
 822            {
 823                var message = $"Unsupported Content-Encoding '{normalizedEncoding}' for multipart field.";
 824                if (options.RejectUnknownContentEncoding)
 825                {
 826                    logger.Error(message);
 827                    throw new KrFormException(message, StatusCodes.Status415UnsupportedMediaType);
 828                }
 829                logger.Warning(message);
 830            }
 831            else
 832            {
 833                logger.Debug("Field-level decompression enabled for encoding {Encoding}.", normalizedEncoding);
 834            }
 835            source = decoded;
 836        }
 837
 838        await using var limited = new LimitedReadStream(source, options.Limits.MaxFieldValueBytes);
 839        using var reader = new StreamReader(limited, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: f
 840        var value = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 841        return value;
 842    }
 843
 844    private static async Task DrainSectionAsync(Stream body, KrFormOptions options, string? contentEncoding, Logger logg
 845    {
 846        var source = body;
 847        if (options.EnablePartDecompression)
 848        {
 849            var (decoded, normalizedEncoding) = KrPartDecompression.CreateDecodedStream(body, contentEncoding);
 850            source = decoded;
 851            logger.Debug("Draining part with encoding {Encoding}.", normalizedEncoding);
 852        }
 853
 854        await using var limited = new LimitedReadStream(source, options.Limits.MaxPartBodyBytes);
 855        await limited.CopyToAsync(Stream.Null, cancellationToken).ConfigureAwait(false);
 856    }
 857
 858    private static async Task<long> ConsumeStreamAsync(Stream body, CancellationToken cancellationToken)
 859    {
 860        var buffer = new byte[81920];
 861        long total = 0;
 862        int read;
 863        while ((read = await body.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
 864        {
 865            total += read;
 866        }
 867        return total;
 868    }
 869
 870    private static void ValidateFilePart(string? name, string fileName, string contentType, KrFormPartRule? rule, KrForm
 871    {
 872        if (string.IsNullOrWhiteSpace(name))
 873        {
 874            logger.Error("File part missing name.");
 875            throw new KrFormException("File part must include a name.", StatusCodes.Status400BadRequest);
 876        }
 877
 878        if (rule == null)
 879        {
 880            return;
 881        }
 882
 883        if (!rule.AllowMultiple && payload.Files.ContainsKey(name))
 884        {
 885            logger.Error("Part rule disallows multiple files for name {Name}.", name);
 886            throw new KrFormException($"Multiple files not allowed for '{name}'.", StatusCodes.Status400BadRequest);
 887        }
 888
 889        if (rule.AllowedContentTypes.Count > 0 && !IsAllowedRequestContentType(contentType, rule.AllowedContentTypes))
 890        {
 891            logger.Error("Rejected content type {ContentType} for part {Name}.", contentType, name);
 892            throw new KrFormException("Content type is not allowed for this part.", StatusCodes.Status415UnsupportedMedi
 893        }
 894
 895        if (rule.AllowedExtensions.Count > 0)
 896        {
 897            var ext = Path.GetExtension(fileName);
 898            if (string.IsNullOrWhiteSpace(ext) || !rule.AllowedExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase
 899            {
 900                logger.Error("Rejected extension {Extension} for part {Name}.", ext, name);
 901                throw new KrFormException("File extension is not allowed for this part.", StatusCodes.Status400BadReques
 902            }
 903        }
 904
 905        if (rule.MaxBytes.HasValue && rule.MaxBytes.Value <= 0)
 906        {
 907            logger.Warning("Part rule for {Name} has non-positive MaxBytes.", name);
 908        }
 909    }
 910
 911    private static void AppendFile(Dictionary<string, KrFilePart[]> files, KrFilePart part, KrFormPartRule? rule, Logger
 912    {
 913        files[part.Name] = files.TryGetValue(part.Name, out var existing)
 914            ? [.. existing, part]
 915            : [part];
 916
 917        if (rule != null && !rule.AllowMultiple && files[part.Name].Length > 1)
 918        {
 919            logger.Error("Rule disallows multiple files for {Name}.", part.Name);
 920            throw new KrFormException($"Multiple files not allowed for '{part.Name}'.", StatusCodes.Status400BadRequest)
 921        }
 922    }
 923
 924    private static void AppendField(Dictionary<string, string[]> fields, string name, string value)
 925    {
 926        fields[name] = fields.TryGetValue(name, out var existing)
 927            ? [.. existing, value]
 928            : [value];
 929    }
 930
 931    private static void ValidateRequiredRules(KrFormData payload, Dictionary<string, KrFormPartRule> rules, Logger logge
 932    {
 933        foreach (var rule in rules.Values)
 934        {
 935            if (!rule.Required)
 936            {
 937                continue;
 938            }
 939
 940            var hasField = payload.Fields.ContainsKey(rule.Name);
 941            var hasFile = payload.Files.ContainsKey(rule.Name);
 942            if (!hasField && !hasFile)
 943            {
 944                logger.Error("Required form part missing: {Name}", rule.Name);
 945                throw new KrFormException($"Required form part '{rule.Name}' missing.", StatusCodes.Status400BadRequest)
 946            }
 947        }
 948    }
 949
 950    private static Dictionary<string, KrFormPartRule> CreateRuleMap(KrFormOptions options, bool isRoot, string? scopeNam
 951    {
 952        var map = new Dictionary<string, KrFormPartRule>(StringComparer.OrdinalIgnoreCase);
 953        foreach (var rule in options.Rules)
 954        {
 955            if (!IsRuleInScope(rule, isRoot, scopeName))
 956            {
 957                continue;
 958            }
 959            map[rule.Name] = rule;
 960        }
 961        return map;
 962    }
 963
 964    /// <summary>
 965    /// Determines if a rule applies to the current scope.
 966    /// </summary>
 967    /// <param name="rule">The form part rule.</param>
 968    /// <param name="isRoot">Indicates if the current scope is the root.</param>
 969    /// <param name="scopeName">The current scope name, or null if root.</param>
 970    /// <returns>True if the rule is in scope; otherwise, false.</returns>
 971    private static bool IsRuleInScope(KrFormPartRule rule, bool isRoot, string? scopeName)
 972    {
 973        var ruleScope = string.IsNullOrWhiteSpace(rule.Scope) ? null : rule.Scope;
 974        return isRoot
 975            ? ruleScope is null
 976            : !string.IsNullOrWhiteSpace(scopeName) && string.Equals(ruleScope, scopeName, StringComparison.OrdinalIgnor
 977    }
 978
 979    private static (string? Name, string? FileName, ContentDispositionHeaderValue? Disposition) GetContentDisposition(Mu
 980    {
 981        if (string.IsNullOrWhiteSpace(section.ContentDisposition))
 982        {
 983            if (allowMissing)
 984            {
 985                return (null, null, null);
 986            }
 987
 988            logger.Error("Multipart section missing Content-Disposition header.");
 989            throw new KrFormException("Missing Content-Disposition header.", StatusCodes.Status400BadRequest);
 990        }
 991
 992        if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var disposition))
 993        {
 994            logger.Error("Invalid Content-Disposition header: {Header}", section.ContentDisposition);
 995            throw new KrFormException("Invalid Content-Disposition header.", StatusCodes.Status400BadRequest);
 996        }
 997
 998        var name = disposition.Name.HasValue ? HeaderUtilities.RemoveQuotes(disposition.Name).Value : null;
 999        var fileName = disposition.FileNameStar.HasValue
 1000            ? HeaderUtilities.RemoveQuotes(disposition.FileNameStar).Value
 1001            : disposition.FileName.HasValue ? HeaderUtilities.RemoveQuotes(disposition.FileName).Value : null;
 1002
 1003        return (name, fileName, disposition);
 1004    }
 1005
 1006    private static string GetBoundary(MediaTypeHeaderValue mediaType)
 1007    {
 1008        if (!mediaType.Boundary.HasValue)
 1009        {
 1010            throw new KrFormException("Missing multipart boundary.", StatusCodes.Status400BadRequest);
 1011        }
 1012
 1013        var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
 1014        return string.IsNullOrWhiteSpace(boundary)
 1015            ? throw new KrFormException("Missing multipart boundary.", StatusCodes.Status400BadRequest)
 1016            : boundary;
 1017    }
 1018
 1019    private static bool TryGetBoundary(string contentType, out string boundary)
 1020    {
 1021        boundary = string.Empty;
 1022        if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaType))
 1023        {
 1024            return false;
 1025        }
 1026
 1027        if (!mediaType.Boundary.HasValue)
 1028        {
 1029            return false;
 1030        }
 1031
 1032        var parsed = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
 1033        if (string.IsNullOrWhiteSpace(parsed))
 1034        {
 1035            return false;
 1036        }
 1037
 1038        boundary = parsed;
 1039        return true;
 1040    }
 1041
 1042    private static Dictionary<string, string[]> ToHeaderDictionary(IEnumerable<KeyValuePair<string, Microsoft.Extensions
 1043    {
 1044        var dict = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
 1045        foreach (var header in headers)
 1046        {
 1047            dict[header.Key] = [.. header.Value.Select(static v => v ?? string.Empty)];
 1048        }
 1049        return dict;
 1050    }
 1051
 1052    private static string? GetHeaderValue(IReadOnlyDictionary<string, string[]> headers, string name)
 1053        => headers.TryGetValue(name, out var values) ? values.FirstOrDefault() : null;
 1054
 1055    private static long? GetHeaderLong(IReadOnlyDictionary<string, string[]> headers, string name)
 1056        => headers.TryGetValue(name, out var values) && long.TryParse(values.FirstOrDefault(), out var result)
 1057            ? result
 1058            : null;
 1059    private static bool IsAllowedRequestContentType(string contentType, IEnumerable<string> allowed)
 1060    {
 1061        foreach (var allowedType in allowed)
 1062        {
 1063            if (string.IsNullOrWhiteSpace(allowedType))
 1064            {
 1065                continue;
 1066            }
 1067
 1068            if (allowedType.EndsWith("/*", StringComparison.Ordinal))
 1069            {
 1070                var prefix = allowedType[..^1];
 1071                if (contentType.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
 1072                {
 1073                    return true;
 1074                }
 1075            }
 1076            else if (contentType.Equals(allowedType, StringComparison.OrdinalIgnoreCase))
 1077            {
 1078                return true;
 1079            }
 1080        }
 1081        return false;
 1082    }
 1083
 1084    private static bool IsMultipartContentType(string contentType)
 1085        => contentType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase);
 1086
 1087    private static bool IsEncodingAllowed(string encoding, IEnumerable<string> allowed)
 1088        => allowed.Any(a => string.Equals(a, encoding, StringComparison.OrdinalIgnoreCase));
 1089
 1090    private static bool DetectRequestDecompressionEnabled(HttpContext context)
 1091    {
 1092        var type = Type.GetType("Microsoft.AspNetCore.RequestDecompression.IRequestDecompressionProvider, Microsoft.AspN
 1093        return type is not null && context.RequestServices.GetService(type) is not null;
 1094    }
 1095
 1096    private static async ValueTask<KrPartAction> InvokeOnPartAsync(KrFormOptions options, KrPartContext context, Logger 
 1097    {
 1098        if (options.OnPart == null)
 1099        {
 1100            return KrPartAction.Continue;
 1101        }
 1102
 1103        try
 1104        {
 1105            return await options.OnPart(context).ConfigureAwait(false);
 1106        }
 1107        catch (Exception ex)
 1108        {
 1109            logger.Error(ex, "Part hook failed for part {Index}.", context.Index);
 1110            throw new KrFormException("Part hook failed.", StatusCodes.Status400BadRequest);
 1111        }
 1112    }
 1113}
 1114
 1115internal static class LoggerExtensions
 1116{
 1117    /// <summary>
 1118    /// Adds a simple timed logging scope.
 1119    /// </summary>
 1120    /// <param name="logger">The logger.</param>
 1121    /// <param name="operation">The operation name.</param>
 1122    /// <returns>The disposable scope.</returns>
 1123    public static IDisposable BeginTimedOperation(this Logger logger, string operation)
 81124        => new TimedOperation(logger, operation);
 1125
 1126    private sealed class TimedOperation : IDisposable
 1127    {
 1128        private readonly Logger _logger;
 1129        private readonly string _operation;
 1130        private readonly Stopwatch _stopwatch;
 1131
 81132        public TimedOperation(Logger logger, string operation)
 1133        {
 81134            _logger = logger;
 81135            _operation = operation;
 81136            _stopwatch = Stopwatch.StartNew();
 81137            if (_logger.IsEnabled(LogEventLevel.Information))
 1138            {
 81139                _logger.Information("Form parsing started: {Operation}", _operation);
 1140            }
 81141        }
 1142
 1143        public void Dispose()
 1144        {
 81145            _stopwatch.Stop();
 81146            if (_logger.IsEnabled(LogEventLevel.Information))
 1147            {
 81148                _logger.Information("Form parsing completed: {Operation} in {ElapsedMs} ms", _operation, _stopwatch.Elap
 1149            }
 81150        }
 1151    }
 1152}