< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Utilities.CacheRevalidation
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Utilities/Conditional304.cs
Tag: Kestrun/Kestrun@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
86%
Covered lines: 106
Uncovered lines: 17
Coverable lines: 123
Total lines: 284
Line coverage: 86.1%
Branch coverage
74%
Covered branches: 104
Total branches: 140
Branch coverage: 74.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 09/12/2025 - 21:57:57 Line coverage: 0% (0/119) Branch coverage: 0% (0/140) Total lines: 244 Tag: Kestrun/Kestrun@7f3035804f966a691bd6936a199a8086730a784510/13/2025 - 16:52:37 Line coverage: 86.1% (106/123) Branch coverage: 74.2% (104/140) Total lines: 284 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e 09/12/2025 - 21:57:57 Line coverage: 0% (0/119) Branch coverage: 0% (0/140) Total lines: 244 Tag: Kestrun/Kestrun@7f3035804f966a691bd6936a199a8086730a784510/13/2025 - 16:52:37 Line coverage: 86.1% (106/123) Branch coverage: 74.2% (104/140) Total lines: 284 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
TryWrite304(...)100%88100%
IsSafeMethod(...)100%22100%
GetOrDeriveETag(...)100%1010100%
ExtractBytesFromPayload(...)75%201675%
ReadAllBytesFromFormFile(...)100%210%
ETagMatchesClient(...)80%1010100%
LastModifiedSatisfied(...)66.66%6677.77%
ComputeETagFromBytes(...)50%22100%
NormalizeETag(...)12.5%27833.33%
WriteValidators(...)100%44100%
TruncateToSeconds(...)100%11100%
ReadAllBytesPreservePosition(...)66.66%121290%
ChooseEncodingFromAcceptCharset(...)75%44100%
MapEncodingName(...)66.66%713670%
ParseAcceptCharsetHeader(...)75%121295.23%
SelectBestEncodingCandidate(...)100%1010100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Utilities/Conditional304.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using Microsoft.Net.Http.Headers;
 4
 5namespace Kestrun.Utilities;
 6
 7/// <summary>
 8/// Helper for writing conditional 304 Not Modified responses based on ETag and Last-Modified headers.
 9/// </summary>
 10internal static class CacheRevalidation
 11{
 12    /// <summary>
 13    /// Returns true if a 304 Not Modified was written. Otherwise sets validators on the response and returns false.
 14    /// Does NOT write a body on miss; the caller should write the payload/status.
 15    /// </summary>
 16    /// <param name="ctx">The current HTTP context.</param>
 17    /// <param name="payload">The response payload, used to derive an ETag if none is provided. Can be a byte[], ReadOnl
 18    /// <param name="etag">An optional ETag to use. If not provided, an ETag is derived from the payload if possible. Qu
 19    /// <param name="weakETag">If true, the provided or derived ETag is marked as weak (prefixed with W/).</param>
 20    /// <param name="lastModified">An optional Last-Modified timestamp to use.</param>
 21    /// <returns>True if a 304 Not Modified was written; otherwise false.</returns>
 22    public static bool TryWrite304(
 23       HttpContext ctx,
 24       object? payload,
 25       string? etag = null,
 26       bool weakETag = false,
 27       DateTimeOffset? lastModified = null)
 28    {
 1629        var req = ctx.Request;
 1630        var resp = ctx.Response;
 1631        var isSafe = IsSafeMethod(req.Method);
 32
 33        // 1. Normalize or derive ETag
 1634        var normalizedETag = GetOrDeriveETag(etag, payload, req, weakETag);
 35
 36        // 2. If-None-Match precedence
 1637        if (isSafe && ETagMatchesClient(req, normalizedETag))
 38        {
 139            WriteValidators(resp, normalizedETag, lastModified);
 140            resp.StatusCode = StatusCodes.Status304NotModified;
 141            return true;
 42        }
 43
 44        // 3. If-Modified-Since fallback
 1545        if (isSafe && LastModifiedSatisfied(req, lastModified))
 46        {
 147            WriteValidators(resp, normalizedETag, lastModified);
 148            resp.StatusCode = StatusCodes.Status304NotModified;
 149            return true;
 50        }
 51
 52        // 4. Miss - set validators for fresh response
 1453        WriteValidators(resp, normalizedETag, lastModified);
 1454        return false;
 55    }
 56
 57    /// <summary>Determines if the HTTP method is cache validator safe (GET/HEAD).</summary>
 1658    private static bool IsSafeMethod(string method) => HttpMethods.IsGet(method) || HttpMethods.IsHead(method);
 59
 60    /// <summary>Returns provided ETag (normalized) or derives one from payload if absent.</summary>
 61    private static string? GetOrDeriveETag(string? etag, object? payload, HttpRequest req, bool weak)
 62    {
 1663        var normalized = NormalizeETag(etag);
 1664        if (normalized is null && payload is not null)
 65        {
 1666            var bytes = ExtractBytesFromPayload(payload, req);
 1667            normalized = ComputeETagFromBytes(bytes, weakETag: false); // derive strong first
 68        }
 1669        if (weak && normalized is not null && !normalized.StartsWith("W/", StringComparison.Ordinal))
 70        {
 171            normalized = "W/" + normalized;
 72        }
 1673        return normalized;
 74    }
 75
 76    /// <summary>Extracts a raw byte array from supported payload types or throws.</summary>
 77    private static byte[] ExtractBytesFromPayload(object payload, HttpRequest req)
 78    {
 1679        return payload switch
 1680        {
 381            byte[] b => b,
 182            ReadOnlyMemory<byte> rom => rom.ToArray(),
 183            Memory<byte> mem => mem.ToArray(),
 184            ArraySegment<byte> seg => seg.Array is null ? [] : seg.Array.AsSpan(seg.Offset, seg.Count).ToArray(),
 985            string text => ChooseEncodingFromAcceptCharset(req.Headers[HeaderNames.AcceptCharset]).GetBytes(text),
 186            Stream s => ReadAllBytesPreservePosition(s),
 087            IFormFile formFile => ReadAllBytesFromFormFile(formFile),
 088            _ => throw new ArgumentException(
 089                $"Cannot derive bytes from payload of type '{payload.GetType().FullName}'. Provide an explicit ETag or p
 1690        };
 91    }
 92
 93    /// <summary>
 94    /// Reads all bytes from an IFormFile, disposing the stream after reading.
 95    /// </summary>
 96    private static byte[] ReadAllBytesFromFormFile(IFormFile formFile)
 97    {
 098        using var stream = formFile.OpenReadStream();
 099        return ReadAllBytesPreservePosition(stream);
 0100    }
 101    /// <summary>Determines whether client's If-None-Match header matches the normalized ETag (or *).</summary>
 102    private static bool ETagMatchesClient(HttpRequest req, string? normalizedETag)
 103    {
 6104        return normalizedETag is not null && req.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var inm) &&
 7105                             inm.Any(v => !string.IsNullOrEmpty(v) &&
 7106                             v.Split(',', StringSplitOptions.RemoveEmptyEntries)
 1107                              .Select(t => t.Trim())
 8108                              .Any(tok => tok == normalizedETag || tok == "*"));
 109    }
 110
 111    /// <summary>Checks If-Modified-Since header against lastModified (second precision).</summary>
 112    private static bool LastModifiedSatisfied(HttpRequest req, DateTimeOffset? lastModified)
 113    {
 5114        if (!lastModified.HasValue)
 115        {
 4116            return false;
 117        }
 1118        if (!req.Headers.TryGetValue(HeaderNames.IfModifiedSince, out var imsRaw))
 119        {
 0120            return false;
 121        }
 1122        if (!DateTimeOffset.TryParse(imsRaw, out var ims))
 123        {
 0124            return false;
 125        }
 1126        var imsTrunc = TruncateToSeconds(ims.ToUniversalTime());
 1127        var lmTrunc = TruncateToSeconds(lastModified.Value.ToUniversalTime());
 1128        return lmTrunc <= imsTrunc;
 129    }
 130
 131    /// <summary>
 132    /// Computes a strong or weak ETag from the given byte data using SHA-256 hashing.
 133    /// </summary>
 134    /// <param name="data">The byte data to hash.</param>
 135    /// <param name="weakETag">If true, the resulting ETag is marked as weak (prefixed with W/).</param>
 136    /// <returns>The computed ETag string, including quotes.</returns>
 137    private static string ComputeETagFromBytes(ReadOnlySpan<byte> data, bool weakETag)
 138    {
 16139        var hash = SHA256.HashData(data.ToArray());
 16140        var tag = $"\"{Convert.ToHexString(hash).ToLowerInvariant()}\"";
 16141        return weakETag ? "W/" + tag : tag;
 142    }
 143
 144    // ---- helpers ----
 145    private static string? NormalizeETag(string? raw)
 146    {
 16147        if (string.IsNullOrWhiteSpace(raw))
 148        {
 16149            return null;
 150        }
 151
 0152        var v = raw.Trim();
 0153        return v.StartsWith("W/", StringComparison.Ordinal)
 0154            ? v
 0155            : v.StartsWith('"') && v.EndsWith('"') ? v : $"\"{v}\"";
 156    }
 157    private static void WriteValidators(HttpResponse resp, string? etag, DateTimeOffset? lastModified)
 158    {
 16159        if (etag is not null)
 160        {
 16161            resp.Headers[HeaderNames.ETag] = etag;
 162        }
 163
 16164        if (lastModified.HasValue)
 165        {
 2166            resp.Headers[HeaderNames.LastModified] = lastModified.Value.ToString("R");
 167        }
 16168    }
 169
 170    private static DateTimeOffset TruncateToSeconds(DateTimeOffset dto)
 2171        => dto.Subtract(TimeSpan.FromTicks(dto.Ticks % TimeSpan.TicksPerSecond));
 172    private static byte[] ReadAllBytesPreservePosition(Stream s)
 173    {
 1174        if (s is MemoryStream ms && ms.TryGetBuffer(out var seg))
 175        {
 0176            return [.. seg];
 177        }
 178
 1179        long? pos = s.CanSeek ? s.Position : null;
 1180        using var buffer = new MemoryStream();
 1181        s.CopyTo(buffer);
 1182        var bytes = buffer.ToArray();
 1183        if (pos is not null && s.CanSeek)
 184        {
 1185            s.Position = pos.Value;
 186        }
 187
 1188        return bytes;
 1189    }
 190
 191    /// <summary>
 192    /// Chooses an encoding from the Accept-Charset header value. Defaults to UTF-8 if no match found or header missing.
 193    /// Supports a small set of common charsets; extend the Map function as needed.
 194    /// Supports q-values and wildcard. E.g., "utf-8;q=0.9, iso-8859-1;q=0.5, *;q=0.1"
 195    /// </summary>
 196    /// <param name="acceptCharset">The Accept-Charset header value.</param>
 197    /// <returns>The chosen encoding.</returns>
 198    private static Encoding ChooseEncodingFromAcceptCharset(Microsoft.Extensions.Primitives.StringValues acceptCharset)
 199    {
 9200        if (acceptCharset.Count == 0)
 201        {
 6202            return Encoding.UTF8; // Fast path: header missing
 203        }
 204
 3205        var candidates = ParseAcceptCharsetHeader(acceptCharset);
 7206        var (best, _) = SelectBestEncodingCandidate(candidates, static n => MapEncodingName(n));
 3207        return best ?? Encoding.UTF8;
 208    }
 209
 210    /// <summary>
 211    /// Maps a charset token to an <see cref="Encoding"/> instance if it is recognized, otherwise null.
 212    /// </summary>
 4213    private static Encoding? MapEncodingName(string name) => name.ToLowerInvariant() switch
 4214    {
 1215        "utf-8" or "utf8" => Encoding.UTF8,
 1216        "utf-16" => Encoding.Unicode,
 0217        "utf-16le" => Encoding.Unicode,
 0218        "utf-16be" => Encoding.BigEndianUnicode,
 1219        "iso-8859-1" => Encoding.GetEncoding("iso-8859-1"),
 0220        "us-ascii" or "ascii" => Encoding.ASCII,
 1221        _ => null
 4222    };
 223
 224    /// <summary>
 225    /// Parses an Accept-Charset header (possibly multi-valued) into a sequence of (name,q) tuples.
 226    /// Assumes implicit q=1.0 when missing; ignores empty tokens.
 227    /// </summary>
 228    private static IEnumerable<(string name, double q)> ParseAcceptCharsetHeader(Microsoft.Extensions.Primitives.StringV
 229    {
 3230        return values
 3231            .SelectMany(static line => line?.Split(',') ?? [])
 3232            .Select(static tok =>
 3233            {
 5234                var t = tok.Trim();
 5235                if (string.IsNullOrEmpty(t))
 3236                {
 0237                    return (name: string.Empty, q: 0.0);
 3238                }
 3239
 5240                var parts = t.Split(';', 2, StringSplitOptions.TrimEntries);
 5241                var name = parts[0].ToLowerInvariant();
 5242                var q = 1.0;
 5243                if (parts.Length == 2 && parts[1].StartsWith("q=", StringComparison.OrdinalIgnoreCase) &&
 5244                    double.TryParse(parts[1].AsSpan(2), out var qv))
 3245                {
 5246                    q = qv;
 3247                }
 5248                return (name, q);
 3249            })
 8250            .Where(static x => x.name.Length > 0);
 251    }
 252
 253    /// <summary>
 254    /// Selects the highest q-valued encoding candidate from the provided sequence.
 255    /// Wildcard (*) yields UTF-8 at the given q if no prior candidate chosen.
 256    /// </summary>
 257    /// <param name="candidates">Sequence of (name,q) pairs.</param>
 258    /// <param name="resolver">Function mapping charset name to Encoding (may return null).</param>
 259    /// <returns>Tuple of best encoding (or null) and its q value.</returns>
 260    private static (Encoding? best, double q) SelectBestEncodingCandidate(
 261        IEnumerable<(string name, double q)> candidates,
 262        Func<string, Encoding?> resolver)
 263    {
 3264        Encoding? best = null;
 3265        double bestQ = -1;
 16266        foreach (var (name, q) in candidates)
 267        {
 5268            if (name == "*")
 269            {
 1270                if (best is null)
 271                {
 2272                    best = Encoding.UTF8; bestQ = q;
 273                }
 1274                continue;
 275            }
 4276            var enc = resolver(name);
 4277            if (enc is not null && q > bestQ)
 278            {
 6279                best = enc; bestQ = q;
 280            }
 281        }
 3282        return (best, bestQ);
 283    }
 284}