< 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@d9261bd752e45afa789d10bc0c82b7d5724d9589
Line coverage
46%
Covered lines: 214
Uncovered lines: 243
Coverable lines: 457
Total lines: 1125
Line coverage: 46.8%
Branch coverage
43%
Covered branches: 106
Total branches: 242
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@d9261bd752e45afa789d10bc0c82b7d5724d9589 02/05/2026 - 00:28:18 Line coverage: 46.8% (214/457) Branch coverage: 43.8% (106/242) Total lines: 1125 Tag: Kestrun/Kestrun@d9261bd752e45afa789d10bc0c82b7d5724d9589

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ParseAsync()100%11100%
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
 833        var (mediaType, normalizedMediaType) = ValidateAndNormalizeMediaType(context, options, logger);
 634        ApplyRequestBodyLimit(context, options, logger);
 35
 636        return await ParseByContentTypeAsync(context, mediaType, normalizedMediaType, options, logger, cancellationToken
 637            .ConfigureAwait(false);
 538    }
 39
 40    /// <summary>
 41    /// Resolves the logger to use for form parsing.
 42    /// </summary>
 43    /// <param name="context">The HTTP context.</param>
 44    /// <param name="options">The form parsing options.</param>
 45    /// <returns>The resolved logger.</returns>
 46    private static Logger ResolveLogger(HttpContext context, KrFormOptions options)
 47    {
 848        return options.Logger
 849            ?? context.RequestServices.GetService(typeof(Serilog.ILogger)) as Serilog.ILogger
 850            ?? Log.Logger;
 51    }
 52
 53    /// <summary>
 54    /// Validates the Content-Type header and returns the parsed and normalized media type.
 55    /// </summary>
 56    /// <param name="context">The HTTP context.</param>
 57    /// <param name="options">The form parsing options.</param>
 58    /// <param name="logger">The logger.</param>
 59    /// <returns>The parsed media type and normalized media type string.</returns>
 60    private static (MediaTypeHeaderValue MediaType, string NormalizedMediaType) ValidateAndNormalizeMediaType(
 61        HttpContext context,
 62        KrFormOptions options,
 63        Logger logger)
 64    {
 865        var contentTypeHeader = context.Request.ContentType;
 866        var contentEncoding = context.Request.Headers[HeaderNames.ContentEncoding].ToString();
 867        var requestDecompressionEnabled = DetectRequestDecompressionEnabled(context);
 868        if (logger.IsEnabled(LogEventLevel.Debug))
 69        {
 070            logger.DebugSanitized(
 071                "Form route start: Content-Type={ContentType}, Content-Encoding={ContentEncoding}, RequestDecompressionE
 072                contentTypeHeader,
 073                string.IsNullOrWhiteSpace(contentEncoding) ? "<none>" : contentEncoding,
 074                requestDecompressionEnabled);
 75        }
 76
 877        if (string.IsNullOrWhiteSpace(contentTypeHeader))
 78        {
 079            logger.Error("Missing Content-Type header for form parsing.");
 080            throw new KrFormException("Content-Type header is required for form parsing.", StatusCodes.Status415Unsuppor
 81        }
 82
 883        if (!MediaTypeHeaderValue.TryParse(contentTypeHeader, out var mediaType))
 84        {
 085            logger.WarningSanitized("Invalid Content-Type header: {ContentType}", contentTypeHeader);
 086            throw new KrFormException("Invalid Content-Type header.", StatusCodes.Status415UnsupportedMediaType);
 87        }
 88
 889        var normalizedMediaType = mediaType.MediaType.Value ?? string.Empty;
 890        if (!IsAllowedRequestContentType(normalizedMediaType, options.AllowedRequestContentTypes))
 91        {
 192            if (options.RejectUnknownRequestContentType)
 93            {
 194                logger.Error("Rejected request Content-Type: {ContentType}", normalizedMediaType);
 195                throw new KrFormException("Unsupported Content-Type for form parsing.", StatusCodes.Status415Unsupported
 96            }
 97
 098            logger.Warning("Unknown Content-Type allowed: {ContentType}", normalizedMediaType);
 99        }
 100
 7101        if (IsMultipartContentType(normalizedMediaType) && !mediaType.Boundary.HasValue)
 102        {
 1103            logger.Error("Missing multipart boundary for Content-Type: {ContentType}", normalizedMediaType);
 1104            throw new KrFormException("Missing multipart boundary.", StatusCodes.Status400BadRequest);
 105        }
 106
 6107        return (mediaType, normalizedMediaType);
 108    }
 109
 110    /// <summary>
 111    /// Parses the request body based on the normalized content type.
 112    /// </summary>
 113    /// <param name="context">The HTTP context.</param>
 114    /// <param name="mediaType">The parsed media type.</param>
 115    /// <param name="normalizedMediaType">The normalized media type string.</param>
 116    /// <param name="options">The form parsing options.</param>
 117    /// <param name="logger">The logger.</param>
 118    /// <param name="cancellationToken">The cancellation token.</param>
 119    /// <returns>The parsed payload.</returns>
 120    private static Task<IKrFormPayload> ParseByContentTypeAsync(
 121        HttpContext context,
 122        MediaTypeHeaderValue mediaType,
 123        string normalizedMediaType,
 124        KrFormOptions options,
 125        Logger logger,
 126        CancellationToken cancellationToken)
 127    {
 128        // application/x-www-form-urlencoded
 6129        if (normalizedMediaType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
 130        {
 3131            return ParseUrlEncodedAsync(context, options, logger, cancellationToken);
 132        }
 133        // multipart/form-data
 3134        if (normalizedMediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase))
 135        {
 0136            return ParseMultipartFormDataAsync(context, mediaType, options, logger, cancellationToken);
 137        }
 138        // ordered multipart types
 3139        if (normalizedMediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase))
 140        {
 3141            return ParseMultipartOrderedAsync(context, mediaType, options, logger, 0, cancellationToken);
 142        }
 143        // unsupported content type
 0144        throw new KrFormException("Unsupported Content-Type for form parsing.", StatusCodes.Status415UnsupportedMediaTyp
 145    }
 146
 147    /// <summary>
 148    /// Parses the incoming request into a normalized form payload. Synchronous wrapper.
 149    /// </summary>
 150    /// <param name="context">The HTTP context.</param>
 151    /// <param name="options">The form parsing options.</param>
 152    /// <param name="cancellationToken">The cancellation token.</param>
 153    /// <returns>The parsed payload.</returns>
 154    public static IKrFormPayload Parse(HttpContext context, KrFormOptions options, CancellationToken cancellationToken) 
 0155           ParseAsync(context, options, cancellationToken).GetAwaiter().GetResult();
 156
 157    /// <summary>
 158    /// Applies the request body size limit based on the provided options.
 159    /// </summary>
 160    /// <param name="context">The HTTP context of the current request.</param>
 161    /// <param name="options">The form parsing options containing limits.</param>
 162    /// <param name="logger">The logger for diagnostic messages.</param>
 163    private static void ApplyRequestBodyLimit(HttpContext context, KrFormOptions options, Logger logger)
 164    {
 6165        if (!options.Limits.MaxRequestBodyBytes.HasValue)
 166        {
 0167            return;
 168        }
 169
 6170        var feature = context.Features.Get<IHttpMaxRequestBodySizeFeature>();
 6171        if (feature == null || feature.IsReadOnly)
 172        {
 6173            logger.Debug("Request body size feature not available or read-only.");
 6174            return;
 175        }
 176
 0177        feature.MaxRequestBodySize = options.Limits.MaxRequestBodyBytes;
 0178        logger.Debug("Set MaxRequestBodySize to {MaxBytes}", options.Limits.MaxRequestBodyBytes);
 0179    }
 180
 181    private static async Task<IKrFormPayload> ParseUrlEncodedAsync(HttpContext context, KrFormOptions options, Logger lo
 182    {
 3183        var payload = new KrFormData();
 3184        var form = await context.Request.ReadFormAsync(cancellationToken).ConfigureAwait(false);
 12185        foreach (var key in form.Keys)
 186        {
 187            payload.Fields[key] = [.. form[key].Select(static v => v ?? string.Empty)];
 188        }
 189
 3190        var rules = CreateRuleMap(options, isRoot: true, scopeName: null);
 3191        ValidateRequiredRules(payload, rules, logger);
 192
 3193        logger.Information("Parsed x-www-form-urlencoded payload with {FieldCount} fields.", payload.Fields.Count);
 3194        return payload;
 3195    }
 196
 197    /// <summary>
 198    /// Parses a multipart/form-data payload from the request.
 199    /// </summary>
 200    /// <param name="context">The HTTP context.</param>
 201    /// <param name="mediaType">The media type header value.</param>
 202    /// <param name="options">The form parsing options.</param>
 203    /// <param name="logger">The logger for diagnostic messages.</param>
 204    /// <param name="cancellationToken">The cancellation token.</param>
 205    /// <returns>The parsed payload.</returns>
 206    /// <exception cref="KrFormLimitExceededException">Thrown when the multipart form exceeds configured limits.</except
 207    /// <exception cref="KrFormException">Thrown when a part is rejected by policy or other form errors occur.</exceptio
 208    private static async Task<IKrFormPayload> ParseMultipartFormDataAsync(HttpContext context, MediaTypeHeaderValue medi
 209    {
 0210        var boundary = GetBoundary(mediaType);
 0211        var reader = new MultipartReader(boundary, context.Request.Body)
 0212        {
 0213            HeadersLengthLimit = options.Limits.MaxHeaderBytesPerPart
 0214        };
 215
 0216        var payload = new KrFormData();
 0217        var rules = CreateRuleMap(options, isRoot: true, scopeName: null);
 0218        var partIndex = 0;
 0219        long totalBytes = 0;
 0220        var stopwatch = Stopwatch.StartNew();
 221
 222        MultipartSection? section;
 0223        while ((section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false)) != null)
 224        {
 0225            partIndex++;
 0226            if (partIndex > options.Limits.MaxParts)
 227            {
 0228                logger.Error("Multipart form exceeded MaxParts limit ({MaxParts}).", options.Limits.MaxParts);
 0229                throw new KrFormLimitExceededException("Too many multipart sections.");
 230            }
 0231            var partContext = BuildFormDataPartContext(section, rules, partIndex, logger);
 0232            LogFormDataPartDebug(logger, partContext, partIndex - 1);
 233
 0234            var contentEncoding = partContext.ContentEncoding;
 0235            if (await HandleFormDataPartActionAsync(section, options, partContext, logger, contentEncoding, cancellation
 236            {
 237                continue;
 238            }
 239
 0240            if (IsFilePart(partContext.FileName))
 241            {
 0242                totalBytes += await ProcessFormDataFilePartAsync(
 0243                    section,
 0244                    options,
 0245                    payload,
 0246                    partContext,
 0247                    logger,
 0248                    cancellationToken).ConfigureAwait(false);
 0249                continue;
 250            }
 251
 0252            totalBytes += await ProcessFormDataFieldPartAsync(
 0253                section,
 0254                options,
 0255                payload,
 0256                partContext,
 0257                logger,
 0258                cancellationToken).ConfigureAwait(false);
 0259        }
 260
 0261        ValidateRequiredRules(payload, rules, logger);
 0262        stopwatch.Stop();
 0263        logger.Information("Parsed multipart/form-data with {Parts} parts, {Files} files, {Bytes} bytes in {ElapsedMs} m
 264            partIndex, payload.Files.Sum(k => k.Value.Length), totalBytes, stopwatch.ElapsedMilliseconds);
 265
 0266        return payload;
 0267    }
 268
 269    /// <summary>
 270    /// Builds the part context for multipart/form-data sections.
 271    /// </summary>
 272    /// <param name="section">The multipart section.</param>
 273    /// <param name="rules">The form part rule map.</param>
 274    /// <param name="partIndex">The current part index (1-based).</param>
 275    /// <param name="logger">The logger instance.</param>
 276    /// <returns>The constructed part context.</returns>
 277    private static KrPartContext BuildFormDataPartContext(
 278        MultipartSection section,
 279        IReadOnlyDictionary<string, KrFormPartRule> rules,
 280        int partIndex,
 281        Logger logger)
 282    {
 0283        var headers = ToHeaderDictionary(section.Headers ?? []);
 0284        var (name, fileName, _) = GetContentDisposition(section, logger);
 0285        var contentType = section.ContentType ?? (string.IsNullOrWhiteSpace(fileName) ? "text/plain" : "application/octe
 0286        var contentEncoding = GetHeaderValue(headers, HeaderNames.ContentEncoding);
 0287        var declaredLength = GetHeaderLong(headers, HeaderNames.ContentLength);
 288
 0289        var rule = name != null && rules.TryGetValue(name, out var match) ? match : null;
 0290        return new KrPartContext
 0291        {
 0292            Index = partIndex - 1,
 0293            Name = name,
 0294            FileName = fileName,
 0295            ContentType = contentType,
 0296            ContentEncoding = contentEncoding,
 0297            DeclaredLength = declaredLength,
 0298            Headers = headers,
 0299            Rule = rule
 0300        };
 301    }
 302
 303    /// <summary>
 304    /// Logs multipart/form-data part details when debug logging is enabled.
 305    /// </summary>
 306    /// <param name="logger">The logger instance.</param>
 307    /// <param name="partContext">The part context.</param>
 308    /// <param name="index">The 0-based part index.</param>
 309    private static void LogFormDataPartDebug(Logger logger, KrPartContext partContext, int index)
 310    {
 0311        if (!logger.IsEnabled(LogEventLevel.Debug))
 312        {
 0313            return;
 314        }
 315
 0316        logger.Debug("Multipart part {Index} name={Name} filename={FileName} contentType={ContentType} contentEncoding={
 0317            index,
 0318            partContext.Name,
 0319            partContext.FileName,
 0320            partContext.ContentType,
 0321            string.IsNullOrWhiteSpace(partContext.ContentEncoding) ? "<none>" : partContext.ContentEncoding,
 0322            partContext.DeclaredLength);
 0323    }
 324
 325    /// <summary>
 326    /// Handles the OnPart hook for multipart/form-data sections.
 327    /// </summary>
 328    /// <param name="section">The multipart section.</param>
 329    /// <param name="options">The form options.</param>
 330    /// <param name="partContext">The part context.</param>
 331    /// <param name="logger">The logger instance.</param>
 332    /// <param name="contentEncoding">The content encoding.</param>
 333    /// <param name="cancellationToken">The cancellation token.</param>
 334    /// <returns><c>true</c> when the caller should skip further processing for this section.</returns>
 335    private static async Task<bool> HandleFormDataPartActionAsync(
 336        MultipartSection section,
 337        KrFormOptions options,
 338        KrPartContext partContext,
 339        Logger logger,
 340        string? contentEncoding,
 341        CancellationToken cancellationToken)
 342    {
 0343        var action = await InvokeOnPartAsync(options, partContext, logger).ConfigureAwait(false);
 0344        if (action == KrPartAction.Reject)
 345        {
 0346            logger.Error("Part rejected by hook: {PartIndex}", partContext.Index);
 0347            throw new KrFormException("Part rejected by policy.", StatusCodes.Status400BadRequest);
 348        }
 349
 0350        if (action == KrPartAction.Skip)
 351        {
 0352            logger.Warning("Part skipped by hook: {PartIndex}", partContext.Index);
 0353            await DrainSectionAsync(section.Body, options, contentEncoding, logger, cancellationToken).ConfigureAwait(fa
 0354            return true;
 355        }
 356
 0357        return false;
 0358    }
 359
 360    /// <summary>
 361    /// Determines whether a part represents a file based on the file name.
 362    /// </summary>
 363    /// <param name="fileName">The file name from the part.</param>
 364    /// <returns><c>true</c> if the part is a file; otherwise <c>false</c>.</returns>
 365    private static bool IsFilePart(string? fileName)
 0366        => !string.IsNullOrWhiteSpace(fileName);
 367
 368    /// <summary>
 369    /// Processes a file part in multipart/form-data payloads.
 370    /// </summary>
 371    /// <param name="section">The multipart section.</param>
 372    /// <param name="options">The form options.</param>
 373    /// <param name="payload">The form payload to populate.</param>
 374    /// <param name="partContext">The part context.</param>
 375    /// <param name="logger">The logger instance.</param>
 376    /// <param name="cancellationToken">The cancellation token.</param>
 377    /// <returns>The number of bytes processed.</returns>
 378    private static async Task<long> ProcessFormDataFilePartAsync(
 379        MultipartSection section,
 380        KrFormOptions options,
 381        KrFormData payload,
 382        KrPartContext partContext,
 383        Logger logger,
 384        CancellationToken cancellationToken)
 385    {
 0386        ValidateFilePart(partContext.Name, partContext.FileName!, partContext.ContentType, partContext.Rule, payload, lo
 0387        var result = await StorePartAsync(section.Body, options, partContext.Rule, partContext.FileName, partContext.Con
 0388            .ConfigureAwait(false);
 389
 0390        var filePart = new KrFilePart
 0391        {
 0392            Name = partContext.Name!,
 0393            OriginalFileName = partContext.FileName!,
 0394            ContentType = partContext.ContentType,
 0395            Length = result.Length,
 0396            TempPath = result.TempPath,
 0397            Sha256 = result.Sha256,
 0398            Headers = partContext.Headers
 0399        };
 400
 0401        AppendFile(payload.Files, filePart, partContext.Rule, logger);
 0402        LogStoredFilePart(logger, partContext, result);
 0403        return result.Length;
 0404    }
 405
 406    /// <summary>
 407    /// Processes a field part in multipart/form-data payloads.
 408    /// </summary>
 409    /// <param name="section">The multipart section.</param>
 410    /// <param name="options">The form options.</param>
 411    /// <param name="payload">The form payload to populate.</param>
 412    /// <param name="partContext">The part context.</param>
 413    /// <param name="logger">The logger instance.</param>
 414    /// <param name="cancellationToken">The cancellation token.</param>
 415    /// <returns>The number of bytes processed.</returns>
 416    private static async Task<long> ProcessFormDataFieldPartAsync(
 417        MultipartSection section,
 418        KrFormOptions options,
 419        KrFormData payload,
 420        KrPartContext partContext,
 421        Logger logger,
 422        CancellationToken cancellationToken)
 423    {
 0424        if (string.IsNullOrWhiteSpace(partContext.Name))
 425        {
 0426            logger.Error("Field part missing name.");
 0427            throw new KrFormException("Field part must include a name.", StatusCodes.Status400BadRequest);
 428        }
 429
 0430        var value = await ReadFieldValueAsync(section.Body, options, partContext.ContentEncoding, logger, cancellationTo
 0431            .ConfigureAwait(false);
 0432        AppendField(payload.Fields, partContext.Name ?? string.Empty, value);
 0433        var bytes = Encoding.UTF8.GetByteCount(value);
 0434        logger.Debug("Parsed field part {Index} name={Name} bytes={Bytes}", partContext.Index, partContext.Name, bytes);
 0435        return bytes;
 0436    }
 437
 438    /// <summary>
 439    /// Logs file-part storage results for multipart/form-data payloads.
 440    /// </summary>
 441    /// <param name="logger">The logger instance.</param>
 442    /// <param name="partContext">The part context.</param>
 443    /// <param name="result">The stored part result.</param>
 444    private static void LogStoredFilePart(Logger logger, KrPartContext partContext, KrPartWriteResult result)
 445    {
 0446        if (string.IsNullOrWhiteSpace(result.TempPath))
 447        {
 0448            logger.Warning("File part {Index} name={Name} was not stored to disk (bytes={Bytes}).", partContext.Index, p
 0449            return;
 450        }
 451
 0452        logger.Information("Stored file part {Index} name={Name} filename={FileName} contentType={ContentType} bytes={By
 0453            partContext.Index,
 0454            partContext.Name,
 0455            partContext.FileName,
 0456            partContext.ContentType,
 0457            result.Length);
 0458    }
 459
 460    /// <summary>
 461    /// Parses an ordered multipart payload from the request.
 462    /// </summary>
 463    /// <param name="context">The current HTTP context.</param>
 464    /// <param name="mediaType">The media type of the request.</param>
 465    /// <param name="options">The form options for parsing.</param>
 466    /// <param name="logger">The logger instance.</param>
 467    /// <param name="nestingDepth">The current nesting depth for multipart parsing.</param>
 468    /// <param name="cancellationToken">The cancellation token.</param>
 469    /// <returns>Returns the parsed multipart form payload.</returns>
 470    private static async Task<IKrFormPayload> ParseMultipartOrderedAsync(HttpContext context, MediaTypeHeaderValue media
 471    {
 3472        var boundary = GetBoundary(mediaType);
 3473        return await ParseMultipartFromStreamAsync(context.Request.Body, boundary, options, logger, nestingDepth, isRoot
 2474    }
 475
 476    /// <summary>
 477    /// Parses a multipart payload from the provided stream.
 478    /// </summary>
 479    /// <param name="body">The input stream containing the multipart payload.</param>
 480    /// <param name="boundary">The multipart boundary string.</param>
 481    /// <param name="options">The form options for parsing.</param>
 482    /// <param name="logger">The logger instance.</param>
 483    /// <param name="nestingDepth">The current nesting depth for multipart parsing.</param>
 484    /// <param name="isRoot">Indicates if this is the root multipart payload.</param>
 485    /// <param name="scopeName">The current scope name, or null if root.</param>
 486    /// <param name="cancellationToken">The cancellation token.</param>
 487    /// <returns>Returns the parsed multipart form payload.</returns>
 488    private static async Task<IKrFormPayload> ParseMultipartFromStreamAsync(Stream body, string boundary, KrFormOptions 
 489    {
 4490        var reader = new MultipartReader(boundary, body)
 4491        {
 4492            HeadersLengthLimit = options.Limits.MaxHeaderBytesPerPart
 4493        };
 494
 4495        var payload = new KrMultipart();
 4496        var rules = CreateRuleMap(options, isRoot, scopeName);
 4497        var partIndex = 0;
 4498        long totalBytes = 0;
 499
 500        MultipartSection? section;
 8501        while ((section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false)) != null)
 502        {
 5503            partIndex++;
 5504            if (partIndex > options.Limits.MaxParts)
 505            {
 0506                logger.Error("Multipart payload exceeded MaxParts limit ({MaxParts}).", options.Limits.MaxParts);
 0507                throw new KrFormLimitExceededException("Too many multipart sections.");
 508            }
 509
 5510            var partContext = BuildOrderedPartContext(section, rules, partIndex, logger);
 4511            LogOrderedPartDebug(logger, partContext, partIndex - 1);
 512
 4513            var contentEncoding = partContext.ContentEncoding;
 4514            if (await HandleOrderedPartActionAsync(section, options, partContext, logger, contentEncoding, cancellationT
 515            {
 516                continue;
 517            }
 518
 4519            var result = await StorePartAsync(section.Body, options, partContext.Rule, null, contentEncoding, logger, ca
 4520            totalBytes += result.Length;
 521
 4522            var nested = await TryParseNestedPayloadAsync(
 4523                partContext,
 4524                result,
 4525                options,
 4526                logger,
 4527                nestingDepth,
 4528                cancellationToken).ConfigureAwait(false);
 529
 4530            AddOrderedPart(payload, partContext, result, nested);
 4531            LogStoredOrderedPart(logger, partContext, partIndex - 1, result);
 4532        }
 533
 3534        logger.Information("Parsed multipart ordered payload with {Parts} parts and {Bytes} bytes.", partIndex, totalByt
 3535        return payload;
 3536    }
 537
 538    /// <summary>
 539    /// Builds the part context for an ordered multipart section.
 540    /// </summary>
 541    /// <param name="section">The multipart section.</param>
 542    /// <param name="rules">The form part rule map.</param>
 543    /// <param name="partIndex">The current part index (1-based).</param>
 544    /// <param name="logger">The logger instance.</param>
 545    /// <returns>The constructed part context.</returns>
 546    private static KrPartContext BuildOrderedPartContext(
 547        MultipartSection section,
 548        IReadOnlyDictionary<string, KrFormPartRule> rules,
 549        int partIndex,
 550        Logger logger)
 551    {
 5552        var headers = ToHeaderDictionary(section.Headers ?? []);
 5553        var contentType = section.ContentType ?? "application/octet-stream";
 5554        var allowMissingDisposition = IsMultipartContentType(contentType);
 5555        var (name, fileName, _) = GetContentDisposition(section, logger, allowMissing: allowMissingDisposition);
 4556        var contentEncoding = GetHeaderValue(headers, HeaderNames.ContentEncoding);
 4557        var declaredLength = GetHeaderLong(headers, HeaderNames.ContentLength);
 558
 4559        var rule = name != null && rules.TryGetValue(name, out var match) ? match : null;
 4560        return new KrPartContext
 4561        {
 4562            Index = partIndex - 1,
 4563            Name = name,
 4564            FileName = fileName,
 4565            ContentType = contentType,
 4566            ContentEncoding = contentEncoding,
 4567            DeclaredLength = declaredLength,
 4568            Headers = headers,
 4569            Rule = rule
 4570        };
 571    }
 572
 573    /// <summary>
 574    /// Logs ordered multipart part details when debug logging is enabled.
 575    /// </summary>
 576    /// <param name="logger">The logger instance.</param>
 577    /// <param name="partContext">The part context.</param>
 578    /// <param name="index">The 0-based part index.</param>
 579    private static void LogOrderedPartDebug(Logger logger, KrPartContext partContext, int index)
 580    {
 4581        if (!logger.IsEnabled(LogEventLevel.Debug))
 582        {
 4583            return;
 584        }
 585
 0586        logger.Debug("Ordered part {Index} name={Name} filename={FileName} contentType={ContentType} contentEncoding={Co
 0587            index,
 0588            partContext.Name,
 0589            partContext.FileName,
 0590            partContext.ContentType,
 0591            string.IsNullOrWhiteSpace(partContext.ContentEncoding) ? "<none>" : partContext.ContentEncoding,
 0592            partContext.DeclaredLength);
 0593    }
 594
 595    /// <summary>
 596    /// Handles the OnPart hook for ordered multipart sections.
 597    /// </summary>
 598    /// <param name="section">The multipart section.</param>
 599    /// <param name="options">The form options.</param>
 600    /// <param name="partContext">The part context.</param>
 601    /// <param name="logger">The logger instance.</param>
 602    /// <param name="contentEncoding">The content encoding.</param>
 603    /// <param name="cancellationToken">The cancellation token.</param>
 604    /// <returns><c>true</c> when the caller should skip further processing for this section.</returns>
 605    private static async Task<bool> HandleOrderedPartActionAsync(
 606        MultipartSection section,
 607        KrFormOptions options,
 608        KrPartContext partContext,
 609        Logger logger,
 610        string? contentEncoding,
 611        CancellationToken cancellationToken)
 612    {
 4613        var action = await InvokeOnPartAsync(options, partContext, logger).ConfigureAwait(false);
 4614        if (action == KrPartAction.Reject)
 615        {
 0616            logger.Error("Ordered part rejected by hook: {PartIndex}", partContext.Index);
 0617            throw new KrFormException("Part rejected by policy.", StatusCodes.Status400BadRequest);
 618        }
 619
 4620        if (action == KrPartAction.Skip)
 621        {
 0622            logger.Warning("Ordered part skipped by hook: {PartIndex}", partContext.Index);
 0623            await DrainSectionAsync(section.Body, options, contentEncoding, logger, cancellationToken).ConfigureAwait(fa
 0624            return true;
 625        }
 626
 4627        return false;
 4628    }
 629
 630    /// <summary>
 631    /// Attempts to parse a nested multipart payload when the part content type is multipart.
 632    /// </summary>
 633    /// <param name="partContext">The part context.</param>
 634    /// <param name="result">The stored part result.</param>
 635    /// <param name="options">The form options.</param>
 636    /// <param name="logger">The logger instance.</param>
 637    /// <param name="nestingDepth">The current nesting depth.</param>
 638    /// <param name="cancellationToken">The cancellation token.</param>
 639    /// <returns>The nested payload, or null if none was parsed.</returns>
 640    private static async Task<IKrFormPayload?> TryParseNestedPayloadAsync(
 641        KrPartContext partContext,
 642        KrPartWriteResult result,
 643        KrFormOptions options,
 644        Logger logger,
 645        int nestingDepth,
 646        CancellationToken cancellationToken)
 647    {
 4648        if (!IsMultipartContentType(partContext.ContentType))
 649        {
 3650            return null;
 651        }
 652
 1653        if (nestingDepth >= options.Limits.MaxNestingDepth)
 654        {
 0655            logger.Error("Nested multipart depth exceeded limit {MaxDepth}.", options.Limits.MaxNestingDepth);
 0656            throw new KrFormLimitExceededException("Nested multipart depth exceeded.");
 657        }
 658
 1659        if (!TryGetBoundary(partContext.ContentType, out var nestedBoundary))
 660        {
 0661            logger.Warning("Nested multipart part missing boundary header.");
 0662            return null;
 663        }
 664
 1665        if (string.IsNullOrWhiteSpace(result.TempPath))
 666        {
 0667            logger.Warning("Nested multipart part was not stored to disk; skipping nested parse.");
 0668            return null;
 669        }
 670
 1671        await using var nestedStream = File.OpenRead(result.TempPath);
 1672        return await ParseMultipartFromStreamAsync(
 1673            nestedStream,
 1674            nestedBoundary,
 1675            options,
 1676            logger,
 1677            nestingDepth + 1,
 1678            isRoot: false,
 1679            scopeName: partContext.Name,
 1680            cancellationToken).ConfigureAwait(false);
 4681    }
 682
 683    /// <summary>
 684    /// Adds a parsed ordered part to the payload.
 685    /// </summary>
 686    /// <param name="payload">The multipart payload.</param>
 687    /// <param name="partContext">The part context.</param>
 688    /// <param name="result">The stored part result.</param>
 689    /// <param name="nested">The nested payload.</param>
 690    private static void AddOrderedPart(KrMultipart payload, KrPartContext partContext, KrPartWriteResult result, IKrForm
 691    {
 4692        payload.Parts.Add(new KrRawPart
 4693        {
 4694            Name = partContext.Name,
 4695            ContentType = partContext.ContentType,
 4696            Length = result.Length,
 4697            TempPath = result.TempPath,
 4698            Headers = partContext.Headers,
 4699            NestedPayload = nested
 4700        });
 4701    }
 702
 703    /// <summary>
 704    /// Logs ordered multipart part storage results.
 705    /// </summary>
 706    /// <param name="logger">The logger instance.</param>
 707    /// <param name="partContext">The part context.</param>
 708    /// <param name="index">The 0-based part index.</param>
 709    /// <param name="result">The stored part result.</param>
 710    private static void LogStoredOrderedPart(Logger logger, KrPartContext partContext, int index, KrPartWriteResult resu
 711    {
 4712        if (string.IsNullOrWhiteSpace(result.TempPath))
 713        {
 3714            logger.Warning("Ordered part {Index} name={Name} was not stored to disk (bytes={Bytes}).", index, partContex
 3715            return;
 716        }
 717
 1718        logger.Information("Stored ordered part {Index} name={Name} contentType={ContentType} bytes={Bytes}", index, par
 1719    }
 720
 721    /// <summary>
 722    /// Stores a multipart part to disk or consumes it based on the provided options and rules.
 723    /// </summary>
 724    /// <param name="body">The input stream of the multipart part.</param>
 725    /// <param name="options">The form options for parsing.</param>
 726    /// <param name="rule">The form part rule, if any.</param>
 727    /// <param name="originalFileName">The original file name of the part, if any.</param>
 728    /// <param name="contentEncoding">The content encoding of the part, if any.</param>
 729    /// <param name="logger">The logger instance.</param>
 730    /// <param name="cancellationToken">The cancellation token.</param>
 731    /// <returns>Returns the result of storing the part.</returns>
 732    private static async Task<KrPartWriteResult> StorePartAsync(Stream body, KrFormOptions options, KrFormPartRule? rule
 733    {
 4734        var maxBytes = rule?.MaxBytes ?? options.Limits.MaxPartBodyBytes;
 4735        var effectiveMax = options.EnablePartDecompression ? Math.Min(maxBytes, options.MaxDecompressedBytesPerPart) : m
 736
 4737        var source = body;
 4738        if (options.EnablePartDecompression)
 739        {
 0740            var (decoded, normalizedEncoding) = KrPartDecompression.CreateDecodedStream(body, contentEncoding);
 0741            if (!IsEncodingAllowed(normalizedEncoding, options.AllowedPartContentEncodings))
 742            {
 0743                var message = $"Unsupported Content-Encoding '{normalizedEncoding}' for multipart part.";
 0744                if (options.RejectUnknownContentEncoding)
 745                {
 0746                    logger.Error(message);
 0747                    throw new KrFormException(message, StatusCodes.Status415UnsupportedMediaType);
 748                }
 0749                logger.Warning(message);
 750            }
 751            else
 752            {
 0753                logger.Debug("Part-level decompression enabled for encoding {Encoding}.", normalizedEncoding);
 754            }
 0755            source = decoded;
 756        }
 4757        else if (!string.IsNullOrWhiteSpace(contentEncoding) && !contentEncoding.Equals("identity", StringComparison.Ord
 758        {
 0759            var message = $"Part Content-Encoding '{contentEncoding}' was supplied but part decompression is disabled.";
 0760            if (options.RejectUnknownContentEncoding)
 761            {
 0762                logger.Error(message);
 0763                throw new KrFormException(message, StatusCodes.Status415UnsupportedMediaType);
 764            }
 0765            logger.Warning(message);
 766        }
 767
 4768        await using var limited = new LimitedReadStream(source, effectiveMax);
 769
 4770        if (rule?.StoreToDisk == false)
 771        {
 3772            var length = await ConsumeStreamAsync(limited, cancellationToken).ConfigureAwait(false);
 3773            return new KrPartWriteResult
 3774            {
 3775                TempPath = string.Empty,
 3776                Length = length,
 3777                Sha256 = null
 3778            };
 779        }
 780
 1781        var targetPath = rule?.DestinationPath ?? options.DefaultUploadPath;
 1782        _ = Directory.CreateDirectory(targetPath);
 1783        var sanitizedFileName = string.IsNullOrWhiteSpace(originalFileName) ? null : options.SanitizeFileName(originalFi
 1784        var sink = new KrDiskPartSink(targetPath, options.ComputeSha256, sanitizedFileName);
 1785        return await sink.WriteAsync(limited, cancellationToken).ConfigureAwait(false);
 4786    }
 787
 788    private static async Task<string> ReadFieldValueAsync(Stream body, KrFormOptions options, string? contentEncoding, L
 789    {
 0790        var source = body;
 0791        if (options.EnablePartDecompression)
 792        {
 0793            var (decoded, normalizedEncoding) = KrPartDecompression.CreateDecodedStream(body, contentEncoding);
 0794            if (!IsEncodingAllowed(normalizedEncoding, options.AllowedPartContentEncodings))
 795            {
 0796                var message = $"Unsupported Content-Encoding '{normalizedEncoding}' for multipart field.";
 0797                if (options.RejectUnknownContentEncoding)
 798                {
 0799                    logger.Error(message);
 0800                    throw new KrFormException(message, StatusCodes.Status415UnsupportedMediaType);
 801                }
 0802                logger.Warning(message);
 803            }
 804            else
 805            {
 0806                logger.Debug("Field-level decompression enabled for encoding {Encoding}.", normalizedEncoding);
 807            }
 0808            source = decoded;
 809        }
 810
 0811        await using var limited = new LimitedReadStream(source, options.Limits.MaxFieldValueBytes);
 0812        using var reader = new StreamReader(limited, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: f
 0813        var value = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 0814        return value;
 0815    }
 816
 817    private static async Task DrainSectionAsync(Stream body, KrFormOptions options, string? contentEncoding, Logger logg
 818    {
 0819        var source = body;
 0820        if (options.EnablePartDecompression)
 821        {
 0822            var (decoded, normalizedEncoding) = KrPartDecompression.CreateDecodedStream(body, contentEncoding);
 0823            source = decoded;
 0824            logger.Debug("Draining part with encoding {Encoding}.", normalizedEncoding);
 825        }
 826
 0827        await using var limited = new LimitedReadStream(source, options.Limits.MaxPartBodyBytes);
 0828        await limited.CopyToAsync(Stream.Null, cancellationToken).ConfigureAwait(false);
 0829    }
 830
 831    private static async Task<long> ConsumeStreamAsync(Stream body, CancellationToken cancellationToken)
 832    {
 3833        var buffer = new byte[81920];
 3834        long total = 0;
 835        int read;
 6836        while ((read = await body.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
 837        {
 3838            total += read;
 839        }
 3840        return total;
 3841    }
 842
 843    private static void ValidateFilePart(string? name, string fileName, string contentType, KrFormPartRule? rule, KrForm
 844    {
 0845        if (string.IsNullOrWhiteSpace(name))
 846        {
 0847            logger.Error("File part missing name.");
 0848            throw new KrFormException("File part must include a name.", StatusCodes.Status400BadRequest);
 849        }
 850
 0851        if (rule == null)
 852        {
 0853            return;
 854        }
 855
 0856        if (!rule.AllowMultiple && payload.Files.ContainsKey(name))
 857        {
 0858            logger.Error("Part rule disallows multiple files for name {Name}.", name);
 0859            throw new KrFormException($"Multiple files not allowed for '{name}'.", StatusCodes.Status400BadRequest);
 860        }
 861
 0862        if (rule.AllowedContentTypes.Count > 0 && !IsAllowedRequestContentType(contentType, rule.AllowedContentTypes))
 863        {
 0864            logger.Error("Rejected content type {ContentType} for part {Name}.", contentType, name);
 0865            throw new KrFormException("Content type is not allowed for this part.", StatusCodes.Status415UnsupportedMedi
 866        }
 867
 0868        if (rule.AllowedExtensions.Count > 0)
 869        {
 0870            var ext = Path.GetExtension(fileName);
 0871            if (string.IsNullOrWhiteSpace(ext) || !rule.AllowedExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase
 872            {
 0873                logger.Error("Rejected extension {Extension} for part {Name}.", ext, name);
 0874                throw new KrFormException("File extension is not allowed for this part.", StatusCodes.Status400BadReques
 875            }
 876        }
 877
 0878        if (rule.MaxBytes.HasValue && rule.MaxBytes.Value <= 0)
 879        {
 0880            logger.Warning("Part rule for {Name} has non-positive MaxBytes.", name);
 881        }
 0882    }
 883
 884    private static void AppendFile(Dictionary<string, KrFilePart[]> files, KrFilePart part, KrFormPartRule? rule, Logger
 885    {
 0886        files[part.Name] = files.TryGetValue(part.Name, out var existing)
 0887            ? [.. existing, part]
 0888            : [part];
 889
 0890        if (rule != null && !rule.AllowMultiple && files[part.Name].Length > 1)
 891        {
 0892            logger.Error("Rule disallows multiple files for {Name}.", part.Name);
 0893            throw new KrFormException($"Multiple files not allowed for '{part.Name}'.", StatusCodes.Status400BadRequest)
 894        }
 0895    }
 896
 897    private static void AppendField(Dictionary<string, string[]> fields, string name, string value)
 898    {
 0899        fields[name] = fields.TryGetValue(name, out var existing)
 0900            ? [.. existing, value]
 0901            : [value];
 0902    }
 903
 904    private static void ValidateRequiredRules(KrFormData payload, Dictionary<string, KrFormPartRule> rules, Logger logge
 905    {
 6906        foreach (var rule in rules.Values)
 907        {
 0908            if (!rule.Required)
 909            {
 910                continue;
 911            }
 912
 0913            var hasField = payload.Fields.ContainsKey(rule.Name);
 0914            var hasFile = payload.Files.ContainsKey(rule.Name);
 0915            if (!hasField && !hasFile)
 916            {
 0917                logger.Error("Required form part missing: {Name}", rule.Name);
 0918                throw new KrFormException($"Required form part '{rule.Name}' missing.", StatusCodes.Status400BadRequest)
 919            }
 920        }
 3921    }
 922
 923    private static Dictionary<string, KrFormPartRule> CreateRuleMap(KrFormOptions options, bool isRoot, string? scopeNam
 924    {
 7925        var map = new Dictionary<string, KrFormPartRule>(StringComparer.OrdinalIgnoreCase);
 30926        foreach (var rule in options.Rules)
 927        {
 8928            if (!IsRuleInScope(rule, isRoot, scopeName))
 929            {
 930                continue;
 931            }
 6932            map[rule.Name] = rule;
 933        }
 7934        return map;
 935    }
 936
 937    /// <summary>
 938    /// Determines if a rule applies to the current scope.
 939    /// </summary>
 940    /// <param name="rule">The form part rule.</param>
 941    /// <param name="isRoot">Indicates if the current scope is the root.</param>
 942    /// <param name="scopeName">The current scope name, or null if root.</param>
 943    /// <returns>True if the rule is in scope; otherwise, false.</returns>
 944    private static bool IsRuleInScope(KrFormPartRule rule, bool isRoot, string? scopeName)
 945    {
 8946        var ruleScope = string.IsNullOrWhiteSpace(rule.Scope) ? null : rule.Scope;
 8947        return isRoot
 8948            ? ruleScope is null
 8949            : !string.IsNullOrWhiteSpace(scopeName) && string.Equals(ruleScope, scopeName, StringComparison.OrdinalIgnor
 950    }
 951
 952    private static (string? Name, string? FileName, ContentDispositionHeaderValue? Disposition) GetContentDisposition(Mu
 953    {
 5954        if (string.IsNullOrWhiteSpace(section.ContentDisposition))
 955        {
 1956            if (allowMissing)
 957            {
 0958                return (null, null, null);
 959            }
 960
 1961            logger.Error("Multipart section missing Content-Disposition header.");
 1962            throw new KrFormException("Missing Content-Disposition header.", StatusCodes.Status400BadRequest);
 963        }
 964
 4965        if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var disposition))
 966        {
 0967            logger.Error("Invalid Content-Disposition header: {Header}", section.ContentDisposition);
 0968            throw new KrFormException("Invalid Content-Disposition header.", StatusCodes.Status400BadRequest);
 969        }
 970
 4971        var name = disposition.Name.HasValue ? HeaderUtilities.RemoveQuotes(disposition.Name).Value : null;
 4972        var fileName = disposition.FileNameStar.HasValue
 4973            ? HeaderUtilities.RemoveQuotes(disposition.FileNameStar).Value
 4974            : disposition.FileName.HasValue ? HeaderUtilities.RemoveQuotes(disposition.FileName).Value : null;
 975
 4976        return (name, fileName, disposition);
 977    }
 978
 979    private static string GetBoundary(MediaTypeHeaderValue mediaType)
 980    {
 3981        if (!mediaType.Boundary.HasValue)
 982        {
 0983            throw new KrFormException("Missing multipart boundary.", StatusCodes.Status400BadRequest);
 984        }
 985
 3986        var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
 3987        return string.IsNullOrWhiteSpace(boundary)
 3988            ? throw new KrFormException("Missing multipart boundary.", StatusCodes.Status400BadRequest)
 3989            : boundary;
 990    }
 991
 992    private static bool TryGetBoundary(string contentType, out string boundary)
 993    {
 1994        boundary = string.Empty;
 1995        if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaType))
 996        {
 0997            return false;
 998        }
 999
 11000        if (!mediaType.Boundary.HasValue)
 1001        {
 01002            return false;
 1003        }
 1004
 11005        var parsed = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
 11006        if (string.IsNullOrWhiteSpace(parsed))
 1007        {
 01008            return false;
 1009        }
 1010
 11011        boundary = parsed;
 11012        return true;
 1013    }
 1014
 1015    private static Dictionary<string, string[]> ToHeaderDictionary(IEnumerable<KeyValuePair<string, Microsoft.Extensions
 1016    {
 51017        var dict = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
 281018        foreach (var header in headers)
 1019        {
 181020            dict[header.Key] = [.. header.Value.Select(static v => v ?? string.Empty)];
 1021        }
 51022        return dict;
 1023    }
 1024
 1025    private static string? GetHeaderValue(IReadOnlyDictionary<string, string[]> headers, string name)
 41026        => headers.TryGetValue(name, out var values) ? values.FirstOrDefault() : null;
 1027
 1028    private static long? GetHeaderLong(IReadOnlyDictionary<string, string[]> headers, string name)
 41029        => headers.TryGetValue(name, out var values) && long.TryParse(values.FirstOrDefault(), out var result)
 41030            ? result
 41031            : null;
 1032    private static bool IsAllowedRequestContentType(string contentType, IEnumerable<string> allowed)
 1033    {
 391034        foreach (var allowedType in allowed)
 1035        {
 151036            if (string.IsNullOrWhiteSpace(allowedType))
 1037            {
 1038                continue;
 1039            }
 1040
 151041            if (allowedType.EndsWith("/*", StringComparison.Ordinal))
 1042            {
 01043                var prefix = allowedType[..^1];
 01044                if (contentType.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
 1045                {
 01046                    return true;
 1047                }
 1048            }
 151049            else if (contentType.Equals(allowedType, StringComparison.OrdinalIgnoreCase))
 1050            {
 71051                return true;
 1052            }
 1053        }
 11054        return false;
 71055    }
 1056
 1057    private static bool IsMultipartContentType(string contentType)
 161058        => contentType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase);
 1059
 1060    private static bool IsEncodingAllowed(string encoding, IEnumerable<string> allowed)
 01061        => allowed.Any(a => string.Equals(a, encoding, StringComparison.OrdinalIgnoreCase));
 1062
 1063    private static bool DetectRequestDecompressionEnabled(HttpContext context)
 1064    {
 81065        var type = Type.GetType("Microsoft.AspNetCore.RequestDecompression.IRequestDecompressionProvider, Microsoft.AspN
 81066        return type is not null && context.RequestServices.GetService(type) is not null;
 1067    }
 1068
 1069    private static async ValueTask<KrPartAction> InvokeOnPartAsync(KrFormOptions options, KrPartContext context, Logger 
 1070    {
 41071        if (options.OnPart == null)
 1072        {
 41073            return KrPartAction.Continue;
 1074        }
 1075
 1076        try
 1077        {
 01078            return await options.OnPart(context).ConfigureAwait(false);
 1079        }
 01080        catch (Exception ex)
 1081        {
 01082            logger.Error(ex, "Part hook failed for part {Index}.", context.Index);
 01083            throw new KrFormException("Part hook failed.", StatusCodes.Status400BadRequest);
 1084        }
 41085    }
 1086}
 1087
 1088internal static class LoggerExtensions
 1089{
 1090    /// <summary>
 1091    /// Adds a simple timed logging scope.
 1092    /// </summary>
 1093    /// <param name="logger">The logger.</param>
 1094    /// <param name="operation">The operation name.</param>
 1095    /// <returns>The disposable scope.</returns>
 1096    public static IDisposable BeginTimedOperation(this Logger logger, string operation)
 1097        => new TimedOperation(logger, operation);
 1098
 1099    private sealed class TimedOperation : IDisposable
 1100    {
 1101        private readonly Logger _logger;
 1102        private readonly string _operation;
 1103        private readonly Stopwatch _stopwatch;
 1104
 1105        public TimedOperation(Logger logger, string operation)
 1106        {
 1107            _logger = logger;
 1108            _operation = operation;
 1109            _stopwatch = Stopwatch.StartNew();
 1110            if (_logger.IsEnabled(LogEventLevel.Information))
 1111            {
 1112                _logger.Information("Form parsing started: {Operation}", _operation);
 1113            }
 1114        }
 1115
 1116        public void Dispose()
 1117        {
 1118            _stopwatch.Stop();
 1119            if (_logger.IsEnabled(LogEventLevel.Information))
 1120            {
 1121                _logger.Information("Form parsing completed: {Operation} in {ElapsedMs} ms", _operation, _stopwatch.Elap
 1122            }
 1123        }
 1124    }
 1125}

Methods/Properties

ParseAsync()
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()