< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Forms.KrFormParser
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Forms/KrFormParser.cs
Tag: Kestrun/Kestrun@eeafbe813231ed23417e7b339e170e307b2c86f9
Line coverage
47%
Covered lines: 221
Uncovered lines: 244
Coverable lines: 465
Total lines: 1152
Line coverage: 47.5%
Branch coverage
43%
Covered branches: 107
Total branches: 244
Branch coverage: 43.8%
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: 46.8% (214/457) Branch coverage: 43.8% (106/242) Total lines: 1125 Tag: Kestrun/Kestrun@d9261bd752e45afa789d10bc0c82b7d5724d958902/18/2026 - 08:33:07 Line coverage: 47.5% (221/465) Branch coverage: 43.8% (107/244) Total lines: 1152 Tag: Kestrun/Kestrun@bf8a937cfb7e8936c225b9df4608f8ddd85558b1 02/05/2026 - 00:28:18 Line coverage: 46.8% (214/457) Branch coverage: 43.8% (106/242) Total lines: 1125 Tag: Kestrun/Kestrun@d9261bd752e45afa789d10bc0c82b7d5724d958902/18/2026 - 08:33:07 Line coverage: 47.5% (221/465) Branch coverage: 43.8% (107/244) Total lines: 1152 Tag: Kestrun/Kestrun@bf8a937cfb7e8936c225b9df4608f8ddd85558b1

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ParseAsync()100%11100%
TryMarkConnectionClose(...)50%2280%
ResolveLogger(...)50%44100%
ValidateAndNormalizeMediaType(...)61.11%391860%
ParseByContentTypeAsync(...)66.66%7671.42%
Parse(...)100%210%
ApplyRequestBodyLimit(...)66.66%9655.55%
ParseUrlEncodedAsync()100%22100%
ParseMultipartFormDataAsync()0%7280%
BuildFormDataPartContext(...)0%110100%
LogFormDataPartDebug(...)0%2040%
HandleFormDataPartActionAsync()0%2040%
IsFilePart(...)100%210%
ProcessFormDataFilePartAsync()100%210%
ProcessFormDataFieldPartAsync()0%2040%
LogStoredFilePart(...)0%620%
ParseMultipartOrderedAsync()100%11100%
ParseMultipartFromStreamAsync()83.33%6693.75%
BuildOrderedPartContext(...)75%88100%
LogOrderedPartDebug(...)25%12420%
HandleOrderedPartActionAsync()50%6450%
TryParseNestedPayloadAsync()70%121072.72%
AddOrderedPart(...)100%11100%
LogStoredOrderedPart(...)100%22100%
StorePartAsync()56.66%933058.82%
ReadFieldValueAsync()0%7280%
DrainSectionAsync()0%2040%
ConsumeStreamAsync()100%22100%
ValidateFilePart(...)0%506220%
AppendFile(...)0%7280%
AppendField(...)0%620%
ValidateRequiredRules(...)50%35825%
CreateRuleMap(...)100%44100%
IsRuleInScope(...)83.33%66100%
GetContentDisposition(...)58.33%141276.92%
GetBoundary(...)50%4483.33%
TryGetBoundary(...)50%7670%
ToHeaderDictionary(...)75%44100%
GetHeaderValue(...)50%22100%
GetHeaderLong(...)50%44100%
IsAllowedRequestContentType(...)80%131070%
IsMultipartContentType(...)100%11100%
IsEncodingAllowed(...)100%210%
DetectRequestDecompressionEnabled(...)50%22100%
InvokeOnPartAsync()50%3242.85%

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    {
 827        ArgumentNullException.ThrowIfNull(context);
 828        ArgumentNullException.ThrowIfNull(options);
 29
 830        var logger = ResolveLogger(context, options);
 831        using var _ = logger.BeginTimedOperation("KrFormParser.ParseAsync");
 32
 33        try
 34        {
 835            var (mediaType, normalizedMediaType) = ValidateAndNormalizeMediaType(context, options, logger);
 636            ApplyRequestBodyLimit(context, options, logger);
 37
 638            return await ParseByContentTypeAsync(context, mediaType, normalizedMediaType, options, logger, cancellationT
 639                .ConfigureAwait(false);
 40        }
 341        catch (KrFormException)
 42        {
 343            TryMarkConnectionClose(context, logger);
 344            throw;
 45        }
 546    }
 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    {
 357        if (context.Response.HasStarted)
 58        {
 059            return;
 60        }
 61
 62        // Only meaningful on HTTP/1.x. For HTTP/2+, the header is ignored.
 363        context.Response.Headers[HeaderNames.Connection] = "close";
 364        logger.Debug("Form parsing error: setting Connection: close to avoid unread-body keep-alive issues.");
 365    }
 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    {
 875        return options.Logger
 876            ?? context.RequestServices.GetService(typeof(Serilog.ILogger)) as Serilog.ILogger
 877            ?? 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    {
 892        var contentTypeHeader = context.Request.ContentType;
 893        var contentEncoding = context.Request.Headers[HeaderNames.ContentEncoding].ToString();
 894        var requestDecompressionEnabled = DetectRequestDecompressionEnabled(context);
 895        if (logger.IsEnabled(LogEventLevel.Debug))
 96        {
 097            logger.DebugSanitized(
 098                "Form route start: Content-Type={ContentType}, Content-Encoding={ContentEncoding}, RequestDecompressionE
 099                contentTypeHeader,
 0100                string.IsNullOrWhiteSpace(contentEncoding) ? "<none>" : contentEncoding,
 0101                requestDecompressionEnabled);
 102        }
 103
 8104        if (string.IsNullOrWhiteSpace(contentTypeHeader))
 105        {
 0106            logger.Error("Missing Content-Type header for form parsing.");
 0107            throw new KrFormException("Content-Type header is required for form parsing.", StatusCodes.Status415Unsuppor
 108        }
 109
 8110        if (!MediaTypeHeaderValue.TryParse(contentTypeHeader, out var mediaType))
 111        {
 0112            logger.WarningSanitized("Invalid Content-Type header: {ContentType}", contentTypeHeader);
 0113            throw new KrFormException("Invalid Content-Type header.", StatusCodes.Status415UnsupportedMediaType);
 114        }
 115
 8116        var normalizedMediaType = mediaType.MediaType.Value ?? string.Empty;
 8117        if (!IsAllowedRequestContentType(normalizedMediaType, options.AllowedContentTypes))
 118        {
 1119            if (options.RejectUnknownRequestContentType)
 120            {
 1121                logger.Error("Rejected request Content-Type: {ContentType}", normalizedMediaType);
 1122                throw new KrFormException("Unsupported Content-Type for form parsing.", StatusCodes.Status415Unsupported
 123            }
 124
 0125            logger.Warning("Unknown Content-Type allowed: {ContentType}", normalizedMediaType);
 126        }
 127
 7128        if (IsMultipartContentType(normalizedMediaType) && !mediaType.Boundary.HasValue)
 129        {
 1130            logger.Error("Missing multipart boundary for Content-Type: {ContentType}", normalizedMediaType);
 1131            throw new KrFormException("Missing multipart boundary.", StatusCodes.Status400BadRequest);
 132        }
 133
 6134        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
 6156        if (normalizedMediaType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
 157        {
 3158            return ParseUrlEncodedAsync(context, options, logger, cancellationToken);
 159        }
 160        // multipart/form-data
 3161        if (normalizedMediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase))
 162        {
 0163            return ParseMultipartFormDataAsync(context, mediaType, options, logger, cancellationToken);
 164        }
 165        // ordered multipart types
 3166        if (normalizedMediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase))
 167        {
 3168            return ParseMultipartOrderedAsync(context, mediaType, options, logger, 0, cancellationToken);
 169        }
 170        // unsupported content type
 0171        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) 
 0182           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    {
 6192        if (!options.Limits.MaxRequestBodyBytes.HasValue)
 193        {
 0194            return;
 195        }
 196
 6197        var feature = context.Features.Get<IHttpMaxRequestBodySizeFeature>();
 6198        if (feature == null || feature.IsReadOnly)
 199        {
 6200            logger.Debug("Request body size feature not available or read-only.");
 6201            return;
 202        }
 203
 0204        feature.MaxRequestBodySize = options.Limits.MaxRequestBodyBytes;
 0205        logger.Debug("Set MaxRequestBodySize to {MaxBytes}", options.Limits.MaxRequestBodyBytes);
 0206    }
 207
 208    private static async Task<IKrFormPayload> ParseUrlEncodedAsync(HttpContext context, KrFormOptions options, Logger lo
 209    {
 3210        var payload = new KrFormData();
 3211        var form = await context.Request.ReadFormAsync(cancellationToken).ConfigureAwait(false);
 12212        foreach (var key in form.Keys)
 213        {
 214            payload.Fields[key] = [.. form[key].Select(static v => v ?? string.Empty)];
 215        }
 216
 3217        var rules = CreateRuleMap(options, isRoot: true, scopeName: null);
 3218        ValidateRequiredRules(payload, rules, logger);
 219
 3220        logger.Information("Parsed x-www-form-urlencoded payload with {FieldCount} fields.", payload.Fields.Count);
 3221        return payload;
 3222    }
 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    {
 0237        var boundary = GetBoundary(mediaType);
 0238        var reader = new MultipartReader(boundary, context.Request.Body)
 0239        {
 0240            HeadersLengthLimit = options.Limits.MaxHeaderBytesPerPart
 0241        };
 242
 0243        var payload = new KrFormData();
 0244        var rules = CreateRuleMap(options, isRoot: true, scopeName: null);
 0245        var partIndex = 0;
 0246        long totalBytes = 0;
 0247        var stopwatch = Stopwatch.StartNew();
 248
 249        MultipartSection? section;
 0250        while ((section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false)) != null)
 251        {
 0252            partIndex++;
 0253            if (partIndex > options.Limits.MaxParts)
 254            {
 0255                logger.Error("Multipart form exceeded MaxParts limit ({MaxParts}).", options.Limits.MaxParts);
 0256                throw new KrFormLimitExceededException("Too many multipart sections.");
 257            }
 0258            var partContext = BuildFormDataPartContext(section, rules, partIndex, logger);
 0259            LogFormDataPartDebug(logger, partContext, partIndex - 1);
 260
 0261            var contentEncoding = partContext.ContentEncoding;
 0262            if (await HandleFormDataPartActionAsync(section, options, partContext, logger, contentEncoding, cancellation
 263            {
 264                continue;
 265            }
 266
 0267            if (IsFilePart(partContext.FileName))
 268            {
 0269                totalBytes += await ProcessFormDataFilePartAsync(
 0270                    section,
 0271                    options,
 0272                    payload,
 0273                    partContext,
 0274                    logger,
 0275                    cancellationToken).ConfigureAwait(false);
 0276                continue;
 277            }
 278
 0279            totalBytes += await ProcessFormDataFieldPartAsync(
 0280                section,
 0281                options,
 0282                payload,
 0283                partContext,
 0284                logger,
 0285                cancellationToken).ConfigureAwait(false);
 0286        }
 287
 0288        ValidateRequiredRules(payload, rules, logger);
 0289        stopwatch.Stop();
 0290        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
 0293        return payload;
 0294    }
 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    {
 0310        var headers = ToHeaderDictionary(section.Headers ?? []);
 0311        var (name, fileName, _) = GetContentDisposition(section, logger);
 0312        var contentType = section.ContentType ?? (string.IsNullOrWhiteSpace(fileName) ? "text/plain" : "application/octe
 0313        var contentEncoding = GetHeaderValue(headers, HeaderNames.ContentEncoding);
 0314        var declaredLength = GetHeaderLong(headers, HeaderNames.ContentLength);
 315
 0316        var rule = name != null && rules.TryGetValue(name, out var match) ? match : null;
 0317        return new KrPartContext
 0318        {
 0319            Index = partIndex - 1,
 0320            Name = name,
 0321            FileName = fileName,
 0322            ContentType = contentType,
 0323            ContentEncoding = contentEncoding,
 0324            DeclaredLength = declaredLength,
 0325            Headers = headers,
 0326            Rule = rule
 0327        };
 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    {
 0338        if (!logger.IsEnabled(LogEventLevel.Debug))
 339        {
 0340            return;
 341        }
 342
 0343        logger.Debug("Multipart part {Index} name={Name} filename={FileName} contentType={ContentType} contentEncoding={
 0344            index,
 0345            partContext.Name,
 0346            partContext.FileName,
 0347            partContext.ContentType,
 0348            string.IsNullOrWhiteSpace(partContext.ContentEncoding) ? "<none>" : partContext.ContentEncoding,
 0349            partContext.DeclaredLength);
 0350    }
 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    {
 0370        var action = await InvokeOnPartAsync(options, partContext, logger).ConfigureAwait(false);
 0371        if (action == KrPartAction.Reject)
 372        {
 0373            logger.Error("Part rejected by hook: {PartIndex}", partContext.Index);
 0374            throw new KrFormException("Part rejected by policy.", StatusCodes.Status400BadRequest);
 375        }
 376
 0377        if (action == KrPartAction.Skip)
 378        {
 0379            logger.Warning("Part skipped by hook: {PartIndex}", partContext.Index);
 0380            await DrainSectionAsync(section.Body, options, contentEncoding, logger, cancellationToken).ConfigureAwait(fa
 0381            return true;
 382        }
 383
 0384        return false;
 0385    }
 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)
 0393        => !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    {
 0413        ValidateFilePart(partContext.Name, partContext.FileName!, partContext.ContentType, partContext.Rule, payload, lo
 0414        var result = await StorePartAsync(section.Body, options, partContext.Rule, partContext.FileName, partContext.Con
 0415            .ConfigureAwait(false);
 416
 0417        var filePart = new KrFilePart
 0418        {
 0419            Name = partContext.Name!,
 0420            OriginalFileName = partContext.FileName!,
 0421            ContentType = partContext.ContentType,
 0422            Length = result.Length,
 0423            TempPath = result.TempPath,
 0424            Sha256 = result.Sha256,
 0425            Headers = partContext.Headers
 0426        };
 427
 0428        AppendFile(payload.Files, filePart, partContext.Rule, logger);
 0429        LogStoredFilePart(logger, partContext, result);
 0430        return result.Length;
 0431    }
 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    {
 0451        if (string.IsNullOrWhiteSpace(partContext.Name))
 452        {
 0453            logger.Error("Field part missing name.");
 0454            throw new KrFormException("Field part must include a name.", StatusCodes.Status400BadRequest);
 455        }
 456
 0457        var value = await ReadFieldValueAsync(section.Body, options, partContext.ContentEncoding, logger, cancellationTo
 0458            .ConfigureAwait(false);
 0459        AppendField(payload.Fields, partContext.Name ?? string.Empty, value);
 0460        var bytes = Encoding.UTF8.GetByteCount(value);
 0461        logger.Debug("Parsed field part {Index} name={Name} bytes={Bytes}", partContext.Index, partContext.Name, bytes);
 0462        return bytes;
 0463    }
 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    {
 0473        if (string.IsNullOrWhiteSpace(result.TempPath))
 474        {
 0475            logger.Warning("File part {Index} name={Name} was not stored to disk (bytes={Bytes}).", partContext.Index, p
 0476            return;
 477        }
 478
 0479        logger.Information("Stored file part {Index} name={Name} filename={FileName} contentType={ContentType} bytes={By
 0480            partContext.Index,
 0481            partContext.Name,
 0482            partContext.FileName,
 0483            partContext.ContentType,
 0484            result.Length);
 0485    }
 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    {
 3499        var boundary = GetBoundary(mediaType);
 3500        return await ParseMultipartFromStreamAsync(context.Request.Body, boundary, options, logger, nestingDepth, isRoot
 2501    }
 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    {
 4517        var reader = new MultipartReader(boundary, body)
 4518        {
 4519            HeadersLengthLimit = options.Limits.MaxHeaderBytesPerPart
 4520        };
 521
 4522        var payload = new KrMultipart();
 4523        var rules = CreateRuleMap(options, isRoot, scopeName);
 4524        var partIndex = 0;
 4525        long totalBytes = 0;
 526
 527        MultipartSection? section;
 8528        while ((section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false)) != null)
 529        {
 5530            partIndex++;
 5531            if (partIndex > options.Limits.MaxParts)
 532            {
 0533                logger.Error("Multipart payload exceeded MaxParts limit ({MaxParts}).", options.Limits.MaxParts);
 0534                throw new KrFormLimitExceededException("Too many multipart sections.");
 535            }
 536
 5537            var partContext = BuildOrderedPartContext(section, rules, partIndex, logger);
 4538            LogOrderedPartDebug(logger, partContext, partIndex - 1);
 539
 4540            var contentEncoding = partContext.ContentEncoding;
 4541            if (await HandleOrderedPartActionAsync(section, options, partContext, logger, contentEncoding, cancellationT
 542            {
 543                continue;
 544            }
 545
 4546            var result = await StorePartAsync(section.Body, options, partContext.Rule, null, contentEncoding, logger, ca
 4547            totalBytes += result.Length;
 548
 4549            var nested = await TryParseNestedPayloadAsync(
 4550                partContext,
 4551                result,
 4552                options,
 4553                logger,
 4554                nestingDepth,
 4555                cancellationToken).ConfigureAwait(false);
 556
 4557            AddOrderedPart(payload, partContext, result, nested);
 4558            LogStoredOrderedPart(logger, partContext, partIndex - 1, result);
 4559        }
 560
 3561        logger.Information("Parsed multipart ordered payload with {Parts} parts and {Bytes} bytes.", partIndex, totalByt
 3562        return payload;
 3563    }
 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    {
 5579        var headers = ToHeaderDictionary(section.Headers ?? []);
 5580        var contentType = section.ContentType ?? "application/octet-stream";
 5581        var allowMissingDisposition = IsMultipartContentType(contentType);
 5582        var (name, fileName, _) = GetContentDisposition(section, logger, allowMissing: allowMissingDisposition);
 4583        var contentEncoding = GetHeaderValue(headers, HeaderNames.ContentEncoding);
 4584        var declaredLength = GetHeaderLong(headers, HeaderNames.ContentLength);
 585
 4586        var rule = name != null && rules.TryGetValue(name, out var match) ? match : null;
 4587        return new KrPartContext
 4588        {
 4589            Index = partIndex - 1,
 4590            Name = name,
 4591            FileName = fileName,
 4592            ContentType = contentType,
 4593            ContentEncoding = contentEncoding,
 4594            DeclaredLength = declaredLength,
 4595            Headers = headers,
 4596            Rule = rule
 4597        };
 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    {
 4608        if (!logger.IsEnabled(LogEventLevel.Debug))
 609        {
 4610            return;
 611        }
 612
 0613        logger.Debug("Ordered part {Index} name={Name} filename={FileName} contentType={ContentType} contentEncoding={Co
 0614            index,
 0615            partContext.Name,
 0616            partContext.FileName,
 0617            partContext.ContentType,
 0618            string.IsNullOrWhiteSpace(partContext.ContentEncoding) ? "<none>" : partContext.ContentEncoding,
 0619            partContext.DeclaredLength);
 0620    }
 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    {
 4640        var action = await InvokeOnPartAsync(options, partContext, logger).ConfigureAwait(false);
 4641        if (action == KrPartAction.Reject)
 642        {
 0643            logger.Error("Ordered part rejected by hook: {PartIndex}", partContext.Index);
 0644            throw new KrFormException("Part rejected by policy.", StatusCodes.Status400BadRequest);
 645        }
 646
 4647        if (action == KrPartAction.Skip)
 648        {
 0649            logger.Warning("Ordered part skipped by hook: {PartIndex}", partContext.Index);
 0650            await DrainSectionAsync(section.Body, options, contentEncoding, logger, cancellationToken).ConfigureAwait(fa
 0651            return true;
 652        }
 653
 4654        return false;
 4655    }
 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    {
 4675        if (!IsMultipartContentType(partContext.ContentType))
 676        {
 3677            return null;
 678        }
 679
 1680        if (nestingDepth >= options.Limits.MaxNestingDepth)
 681        {
 0682            logger.Error("Nested multipart depth exceeded limit {MaxDepth}.", options.Limits.MaxNestingDepth);
 0683            throw new KrFormLimitExceededException("Nested multipart depth exceeded.");
 684        }
 685
 1686        if (!TryGetBoundary(partContext.ContentType, out var nestedBoundary))
 687        {
 0688            logger.Warning("Nested multipart part missing boundary header.");
 0689            return null;
 690        }
 691
 1692        if (string.IsNullOrWhiteSpace(result.TempPath))
 693        {
 0694            logger.Warning("Nested multipart part was not stored to disk; skipping nested parse.");
 0695            return null;
 696        }
 697
 1698        await using var nestedStream = File.OpenRead(result.TempPath);
 1699        return await ParseMultipartFromStreamAsync(
 1700            nestedStream,
 1701            nestedBoundary,
 1702            options,
 1703            logger,
 1704            nestingDepth + 1,
 1705            isRoot: false,
 1706            scopeName: partContext.Name,
 1707            cancellationToken).ConfigureAwait(false);
 4708    }
 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    {
 4719        payload.Parts.Add(new KrRawPart
 4720        {
 4721            Name = partContext.Name,
 4722            ContentType = partContext.ContentType,
 4723            Length = result.Length,
 4724            TempPath = result.TempPath,
 4725            Headers = partContext.Headers,
 4726            NestedPayload = nested
 4727        });
 4728    }
 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    {
 4739        if (string.IsNullOrWhiteSpace(result.TempPath))
 740        {
 3741            logger.Warning("Ordered part {Index} name={Name} was not stored to disk (bytes={Bytes}).", index, partContex
 3742            return;
 743        }
 744
 1745        logger.Information("Stored ordered part {Index} name={Name} contentType={ContentType} bytes={Bytes}", index, par
 1746    }
 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    {
 4761        var maxBytes = rule?.MaxBytes ?? options.Limits.MaxPartBodyBytes;
 4762        var effectiveMax = options.EnablePartDecompression ? Math.Min(maxBytes, options.MaxDecompressedBytesPerPart) : m
 763
 4764        var source = body;
 4765        if (options.EnablePartDecompression)
 766        {
 0767            var (decoded, normalizedEncoding) = KrPartDecompression.CreateDecodedStream(body, contentEncoding);
 0768            if (!IsEncodingAllowed(normalizedEncoding, options.AllowedPartContentEncodings))
 769            {
 0770                var message = $"Unsupported Content-Encoding '{normalizedEncoding}' for multipart part.";
 0771                if (options.RejectUnknownContentEncoding)
 772                {
 0773                    logger.Error(message);
 0774                    throw new KrFormException(message, StatusCodes.Status415UnsupportedMediaType);
 775                }
 0776                logger.Warning(message);
 777            }
 778            else
 779            {
 0780                logger.Debug("Part-level decompression enabled for encoding {Encoding}.", normalizedEncoding);
 781            }
 0782            source = decoded;
 783        }
 4784        else if (!string.IsNullOrWhiteSpace(contentEncoding) && !contentEncoding.Equals("identity", StringComparison.Ord
 785        {
 0786            var message = $"Part Content-Encoding '{contentEncoding}' was supplied but part decompression is disabled.";
 0787            if (options.RejectUnknownContentEncoding)
 788            {
 0789                logger.Error(message);
 0790                throw new KrFormException(message, StatusCodes.Status415UnsupportedMediaType);
 791            }
 0792            logger.Warning(message);
 793        }
 794
 4795        await using var limited = new LimitedReadStream(source, effectiveMax);
 796
 4797        if (rule?.StoreToDisk == false)
 798        {
 3799            var length = await ConsumeStreamAsync(limited, cancellationToken).ConfigureAwait(false);
 3800            return new KrPartWriteResult
 3801            {
 3802                TempPath = string.Empty,
 3803                Length = length,
 3804                Sha256 = null
 3805            };
 806        }
 807
 1808        var targetPath = rule?.DestinationPath ?? options.DefaultUploadPath;
 1809        _ = Directory.CreateDirectory(targetPath);
 1810        var sanitizedFileName = string.IsNullOrWhiteSpace(originalFileName) ? null : options.SanitizeFileName(originalFi
 1811        var sink = new KrDiskPartSink(targetPath, options.ComputeSha256, sanitizedFileName);
 1812        return await sink.WriteAsync(limited, cancellationToken).ConfigureAwait(false);
 4813    }
 814
 815    private static async Task<string> ReadFieldValueAsync(Stream body, KrFormOptions options, string? contentEncoding, L
 816    {
 0817        var source = body;
 0818        if (options.EnablePartDecompression)
 819        {
 0820            var (decoded, normalizedEncoding) = KrPartDecompression.CreateDecodedStream(body, contentEncoding);
 0821            if (!IsEncodingAllowed(normalizedEncoding, options.AllowedPartContentEncodings))
 822            {
 0823                var message = $"Unsupported Content-Encoding '{normalizedEncoding}' for multipart field.";
 0824                if (options.RejectUnknownContentEncoding)
 825                {
 0826                    logger.Error(message);
 0827                    throw new KrFormException(message, StatusCodes.Status415UnsupportedMediaType);
 828                }
 0829                logger.Warning(message);
 830            }
 831            else
 832            {
 0833                logger.Debug("Field-level decompression enabled for encoding {Encoding}.", normalizedEncoding);
 834            }
 0835            source = decoded;
 836        }
 837
 0838        await using var limited = new LimitedReadStream(source, options.Limits.MaxFieldValueBytes);
 0839        using var reader = new StreamReader(limited, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: f
 0840        var value = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 0841        return value;
 0842    }
 843
 844    private static async Task DrainSectionAsync(Stream body, KrFormOptions options, string? contentEncoding, Logger logg
 845    {
 0846        var source = body;
 0847        if (options.EnablePartDecompression)
 848        {
 0849            var (decoded, normalizedEncoding) = KrPartDecompression.CreateDecodedStream(body, contentEncoding);
 0850            source = decoded;
 0851            logger.Debug("Draining part with encoding {Encoding}.", normalizedEncoding);
 852        }
 853
 0854        await using var limited = new LimitedReadStream(source, options.Limits.MaxPartBodyBytes);
 0855        await limited.CopyToAsync(Stream.Null, cancellationToken).ConfigureAwait(false);
 0856    }
 857
 858    private static async Task<long> ConsumeStreamAsync(Stream body, CancellationToken cancellationToken)
 859    {
 3860        var buffer = new byte[81920];
 3861        long total = 0;
 862        int read;
 6863        while ((read = await body.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
 864        {
 3865            total += read;
 866        }
 3867        return total;
 3868    }
 869
 870    private static void ValidateFilePart(string? name, string fileName, string contentType, KrFormPartRule? rule, KrForm
 871    {
 0872        if (string.IsNullOrWhiteSpace(name))
 873        {
 0874            logger.Error("File part missing name.");
 0875            throw new KrFormException("File part must include a name.", StatusCodes.Status400BadRequest);
 876        }
 877
 0878        if (rule == null)
 879        {
 0880            return;
 881        }
 882
 0883        if (!rule.AllowMultiple && payload.Files.ContainsKey(name))
 884        {
 0885            logger.Error("Part rule disallows multiple files for name {Name}.", name);
 0886            throw new KrFormException($"Multiple files not allowed for '{name}'.", StatusCodes.Status400BadRequest);
 887        }
 888
 0889        if (rule.AllowedContentTypes.Count > 0 && !IsAllowedRequestContentType(contentType, rule.AllowedContentTypes))
 890        {
 0891            logger.Error("Rejected content type {ContentType} for part {Name}.", contentType, name);
 0892            throw new KrFormException("Content type is not allowed for this part.", StatusCodes.Status415UnsupportedMedi
 893        }
 894
 0895        if (rule.AllowedExtensions.Count > 0)
 896        {
 0897            var ext = Path.GetExtension(fileName);
 0898            if (string.IsNullOrWhiteSpace(ext) || !rule.AllowedExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase
 899            {
 0900                logger.Error("Rejected extension {Extension} for part {Name}.", ext, name);
 0901                throw new KrFormException("File extension is not allowed for this part.", StatusCodes.Status400BadReques
 902            }
 903        }
 904
 0905        if (rule.MaxBytes.HasValue && rule.MaxBytes.Value <= 0)
 906        {
 0907            logger.Warning("Part rule for {Name} has non-positive MaxBytes.", name);
 908        }
 0909    }
 910
 911    private static void AppendFile(Dictionary<string, KrFilePart[]> files, KrFilePart part, KrFormPartRule? rule, Logger
 912    {
 0913        files[part.Name] = files.TryGetValue(part.Name, out var existing)
 0914            ? [.. existing, part]
 0915            : [part];
 916
 0917        if (rule != null && !rule.AllowMultiple && files[part.Name].Length > 1)
 918        {
 0919            logger.Error("Rule disallows multiple files for {Name}.", part.Name);
 0920            throw new KrFormException($"Multiple files not allowed for '{part.Name}'.", StatusCodes.Status400BadRequest)
 921        }
 0922    }
 923
 924    private static void AppendField(Dictionary<string, string[]> fields, string name, string value)
 925    {
 0926        fields[name] = fields.TryGetValue(name, out var existing)
 0927            ? [.. existing, value]
 0928            : [value];
 0929    }
 930
 931    private static void ValidateRequiredRules(KrFormData payload, Dictionary<string, KrFormPartRule> rules, Logger logge
 932    {
 6933        foreach (var rule in rules.Values)
 934        {
 0935            if (!rule.Required)
 936            {
 937                continue;
 938            }
 939
 0940            var hasField = payload.Fields.ContainsKey(rule.Name);
 0941            var hasFile = payload.Files.ContainsKey(rule.Name);
 0942            if (!hasField && !hasFile)
 943            {
 0944                logger.Error("Required form part missing: {Name}", rule.Name);
 0945                throw new KrFormException($"Required form part '{rule.Name}' missing.", StatusCodes.Status400BadRequest)
 946            }
 947        }
 3948    }
 949
 950    private static Dictionary<string, KrFormPartRule> CreateRuleMap(KrFormOptions options, bool isRoot, string? scopeNam
 951    {
 7952        var map = new Dictionary<string, KrFormPartRule>(StringComparer.OrdinalIgnoreCase);
 30953        foreach (var rule in options.Rules)
 954        {
 8955            if (!IsRuleInScope(rule, isRoot, scopeName))
 956            {
 957                continue;
 958            }
 6959            map[rule.Name] = rule;
 960        }
 7961        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    {
 8973        var ruleScope = string.IsNullOrWhiteSpace(rule.Scope) ? null : rule.Scope;
 8974        return isRoot
 8975            ? ruleScope is null
 8976            : !string.IsNullOrWhiteSpace(scopeName) && string.Equals(ruleScope, scopeName, StringComparison.OrdinalIgnor
 977    }
 978
 979    private static (string? Name, string? FileName, ContentDispositionHeaderValue? Disposition) GetContentDisposition(Mu
 980    {
 5981        if (string.IsNullOrWhiteSpace(section.ContentDisposition))
 982        {
 1983            if (allowMissing)
 984            {
 0985                return (null, null, null);
 986            }
 987
 1988            logger.Error("Multipart section missing Content-Disposition header.");
 1989            throw new KrFormException("Missing Content-Disposition header.", StatusCodes.Status400BadRequest);
 990        }
 991
 4992        if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var disposition))
 993        {
 0994            logger.Error("Invalid Content-Disposition header: {Header}", section.ContentDisposition);
 0995            throw new KrFormException("Invalid Content-Disposition header.", StatusCodes.Status400BadRequest);
 996        }
 997
 4998        var name = disposition.Name.HasValue ? HeaderUtilities.RemoveQuotes(disposition.Name).Value : null;
 4999        var fileName = disposition.FileNameStar.HasValue
 41000            ? HeaderUtilities.RemoveQuotes(disposition.FileNameStar).Value
 41001            : disposition.FileName.HasValue ? HeaderUtilities.RemoveQuotes(disposition.FileName).Value : null;
 1002
 41003        return (name, fileName, disposition);
 1004    }
 1005
 1006    private static string GetBoundary(MediaTypeHeaderValue mediaType)
 1007    {
 31008        if (!mediaType.Boundary.HasValue)
 1009        {
 01010            throw new KrFormException("Missing multipart boundary.", StatusCodes.Status400BadRequest);
 1011        }
 1012
 31013        var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
 31014        return string.IsNullOrWhiteSpace(boundary)
 31015            ? throw new KrFormException("Missing multipart boundary.", StatusCodes.Status400BadRequest)
 31016            : boundary;
 1017    }
 1018
 1019    private static bool TryGetBoundary(string contentType, out string boundary)
 1020    {
 11021        boundary = string.Empty;
 11022        if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaType))
 1023        {
 01024            return false;
 1025        }
 1026
 11027        if (!mediaType.Boundary.HasValue)
 1028        {
 01029            return false;
 1030        }
 1031
 11032        var parsed = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
 11033        if (string.IsNullOrWhiteSpace(parsed))
 1034        {
 01035            return false;
 1036        }
 1037
 11038        boundary = parsed;
 11039        return true;
 1040    }
 1041
 1042    private static Dictionary<string, string[]> ToHeaderDictionary(IEnumerable<KeyValuePair<string, Microsoft.Extensions
 1043    {
 51044        var dict = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
 281045        foreach (var header in headers)
 1046        {
 181047            dict[header.Key] = [.. header.Value.Select(static v => v ?? string.Empty)];
 1048        }
 51049        return dict;
 1050    }
 1051
 1052    private static string? GetHeaderValue(IReadOnlyDictionary<string, string[]> headers, string name)
 41053        => headers.TryGetValue(name, out var values) ? values.FirstOrDefault() : null;
 1054
 1055    private static long? GetHeaderLong(IReadOnlyDictionary<string, string[]> headers, string name)
 41056        => headers.TryGetValue(name, out var values) && long.TryParse(values.FirstOrDefault(), out var result)
 41057            ? result
 41058            : null;
 1059    private static bool IsAllowedRequestContentType(string contentType, IEnumerable<string> allowed)
 1060    {
 391061        foreach (var allowedType in allowed)
 1062        {
 151063            if (string.IsNullOrWhiteSpace(allowedType))
 1064            {
 1065                continue;
 1066            }
 1067
 151068            if (allowedType.EndsWith("/*", StringComparison.Ordinal))
 1069            {
 01070                var prefix = allowedType[..^1];
 01071                if (contentType.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
 1072                {
 01073                    return true;
 1074                }
 1075            }
 151076            else if (contentType.Equals(allowedType, StringComparison.OrdinalIgnoreCase))
 1077            {
 71078                return true;
 1079            }
 1080        }
 11081        return false;
 71082    }
 1083
 1084    private static bool IsMultipartContentType(string contentType)
 161085        => contentType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase);
 1086
 1087    private static bool IsEncodingAllowed(string encoding, IEnumerable<string> allowed)
 01088        => allowed.Any(a => string.Equals(a, encoding, StringComparison.OrdinalIgnoreCase));
 1089
 1090    private static bool DetectRequestDecompressionEnabled(HttpContext context)
 1091    {
 81092        var type = Type.GetType("Microsoft.AspNetCore.RequestDecompression.IRequestDecompressionProvider, Microsoft.AspN
 81093        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    {
 41098        if (options.OnPart == null)
 1099        {
 41100            return KrPartAction.Continue;
 1101        }
 1102
 1103        try
 1104        {
 01105            return await options.OnPart(context).ConfigureAwait(false);
 1106        }
 01107        catch (Exception ex)
 1108        {
 01109            logger.Error(ex, "Part hook failed for part {Index}.", context.Index);
 01110            throw new KrFormException("Part hook failed.", StatusCodes.Status400BadRequest);
 1111        }
 41112    }
 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)
 1124        => 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
 1132        public TimedOperation(Logger logger, string operation)
 1133        {
 1134            _logger = logger;
 1135            _operation = operation;
 1136            _stopwatch = Stopwatch.StartNew();
 1137            if (_logger.IsEnabled(LogEventLevel.Information))
 1138            {
 1139                _logger.Information("Form parsing started: {Operation}", _operation);
 1140            }
 1141        }
 1142
 1143        public void Dispose()
 1144        {
 1145            _stopwatch.Stop();
 1146            if (_logger.IsEnabled(LogEventLevel.Information))
 1147            {
 1148                _logger.Information("Form parsing completed: {Operation} in {ElapsedMs} ms", _operation, _stopwatch.Elap
 1149            }
 1150        }
 1151    }
 1152}

Methods/Properties

ParseAsync()
TryMarkConnectionClose(Microsoft.AspNetCore.Http.HttpContext,Serilog.ILogger)
ResolveLogger(Microsoft.AspNetCore.Http.HttpContext,Kestrun.Forms.KrFormOptions)
ValidateAndNormalizeMediaType(Microsoft.AspNetCore.Http.HttpContext,Kestrun.Forms.KrFormOptions,Serilog.ILogger)
ParseByContentTypeAsync(Microsoft.AspNetCore.Http.HttpContext,Microsoft.Net.Http.Headers.MediaTypeHeaderValue,System.String,Kestrun.Forms.KrFormOptions,Serilog.ILogger,System.Threading.CancellationToken)
Parse(Microsoft.AspNetCore.Http.HttpContext,Kestrun.Forms.KrFormOptions,System.Threading.CancellationToken)
ApplyRequestBodyLimit(Microsoft.AspNetCore.Http.HttpContext,Kestrun.Forms.KrFormOptions,Serilog.ILogger)
ParseUrlEncodedAsync()
ParseMultipartFormDataAsync()
BuildFormDataPartContext(Microsoft.AspNetCore.WebUtilities.MultipartSection,System.Collections.Generic.IReadOnlyDictionary`2<System.String,Kestrun.Forms.KrFormPartRule>,System.Int32,Serilog.ILogger)
LogFormDataPartDebug(Serilog.ILogger,Kestrun.Forms.KrPartContext,System.Int32)
HandleFormDataPartActionAsync()
IsFilePart(System.String)
ProcessFormDataFilePartAsync()
ProcessFormDataFieldPartAsync()
LogStoredFilePart(Serilog.ILogger,Kestrun.Forms.KrPartContext,Kestrun.Forms.KrPartWriteResult)
ParseMultipartOrderedAsync()
ParseMultipartFromStreamAsync()
BuildOrderedPartContext(Microsoft.AspNetCore.WebUtilities.MultipartSection,System.Collections.Generic.IReadOnlyDictionary`2<System.String,Kestrun.Forms.KrFormPartRule>,System.Int32,Serilog.ILogger)
LogOrderedPartDebug(Serilog.ILogger,Kestrun.Forms.KrPartContext,System.Int32)
HandleOrderedPartActionAsync()
TryParseNestedPayloadAsync()
AddOrderedPart(Kestrun.Forms.KrMultipart,Kestrun.Forms.KrPartContext,Kestrun.Forms.KrPartWriteResult,Kestrun.Forms.IKrFormPayload)
LogStoredOrderedPart(Serilog.ILogger,Kestrun.Forms.KrPartContext,System.Int32,Kestrun.Forms.KrPartWriteResult)
StorePartAsync()
ReadFieldValueAsync()
DrainSectionAsync()
ConsumeStreamAsync()
ValidateFilePart(System.String,System.String,System.String,Kestrun.Forms.KrFormPartRule,Kestrun.Forms.KrFormData,Serilog.ILogger)
AppendFile(System.Collections.Generic.Dictionary`2<System.String,Kestrun.Forms.KrFilePart[]>,Kestrun.Forms.KrFilePart,Kestrun.Forms.KrFormPartRule,Serilog.ILogger)
AppendField(System.Collections.Generic.Dictionary`2<System.String,System.String[]>,System.String,System.String)
ValidateRequiredRules(Kestrun.Forms.KrFormData,System.Collections.Generic.Dictionary`2<System.String,Kestrun.Forms.KrFormPartRule>,Serilog.ILogger)
CreateRuleMap(Kestrun.Forms.KrFormOptions,System.Boolean,System.String)
IsRuleInScope(Kestrun.Forms.KrFormPartRule,System.Boolean,System.String)
GetContentDisposition(Microsoft.AspNetCore.WebUtilities.MultipartSection,Serilog.ILogger,System.Boolean)
GetBoundary(Microsoft.Net.Http.Headers.MediaTypeHeaderValue)
TryGetBoundary(System.String,System.String&)
ToHeaderDictionary(System.Collections.Generic.IEnumerable`1<System.Collections.Generic.KeyValuePair`2<System.String,Microsoft.Extensions.Primitives.StringValues>>)
GetHeaderValue(System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.String[]>,System.String)
GetHeaderLong(System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.String[]>,System.String)
IsAllowedRequestContentType(System.String,System.Collections.Generic.IEnumerable`1<System.String>)
IsMultipartContentType(System.String)
IsEncodingAllowed(System.String,System.Collections.Generic.IEnumerable`1<System.String>)
DetectRequestDecompressionEnabled(Microsoft.AspNetCore.Http.HttpContext)
InvokeOnPartAsync()