Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Codex re-review of the fix commit confirmed both prior findings resolved and raised one new Medium: RFC 7232 would 412 (not 400) a syntactically-valid but non-matching If-Match (non-canonical "03", weak W/"3", tag lists, empty, overflow). Deferred to #197 (cold contract pass) as #265 — fail-safe today (the mutation is rejected, never applied) and no first-party client is affected. Records the deferral where the #253 fan-out will copy the parser: a code comment in ConcurrencyHeaders + a note in api-conventions §7a. Refs #253 #265 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
3.4 KiB
C#
78 lines
3.4 KiB
C#
using System.Globalization;
|
|
using LanguageExt;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Primitives;
|
|
|
|
namespace ErsatzTV.Extensions;
|
|
|
|
/// <summary>Classification of a request's <c>If-Match</c> header for the #253 concurrency contract.</summary>
|
|
public enum IfMatchKind
|
|
{
|
|
/// <summary>No <c>If-Match</c> header — Phase 1 force-write (Phase 2 will make this a 428).</summary>
|
|
Absent,
|
|
|
|
/// <summary><c>If-Match: *</c> — the scripted force-write escape hatch; skip the version check.</summary>
|
|
Any,
|
|
|
|
/// <summary>A strong entity-tag of a decimal aggregate version, e.g. <c>"3"</c>.</summary>
|
|
Version,
|
|
|
|
/// <summary>An unparseable value — the controller returns 400.</summary>
|
|
Malformed
|
|
}
|
|
|
|
public readonly record struct IfMatchCondition(IfMatchKind Kind, int Version)
|
|
{
|
|
/// <summary>The version to check against, or <c>None</c> for absent/wildcard (force-write).</summary>
|
|
public Option<int> ExpectedVersion => Kind == IfMatchKind.Version ? Version : Option<int>.None;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse/emit the optimistic-concurrency HTTP headers (issue #253). GET responses carry a strong
|
|
/// <c>ETag</c> of the aggregate's integer <c>Version</c>; PUT requests carry the last-seen version
|
|
/// in <c>If-Match</c>. See <c>docs/api-conventions.md</c> §7a.
|
|
/// </summary>
|
|
public static class ConcurrencyHeaders
|
|
{
|
|
public static IfMatchCondition ParseIfMatch(HttpRequest request)
|
|
{
|
|
StringValues raw = request.Headers.IfMatch;
|
|
if (StringValues.IsNullOrEmpty(raw))
|
|
{
|
|
return new IfMatchCondition(IfMatchKind.Absent, 0);
|
|
}
|
|
|
|
string value = raw.ToString().Trim();
|
|
if (value == "*")
|
|
{
|
|
return new IfMatchCondition(IfMatchKind.Any, 0);
|
|
}
|
|
|
|
// Strong entity-tag of a decimal version, e.g. "3". Weak tags (W/"…") are not honored:
|
|
// this contract's ETags are always strong. An ETag is an opaque token, so only the exact
|
|
// canonical form we emit is accepted — a non-negative decimal with no sign, surrounding
|
|
// whitespace, or leading zeros (`NumberStyles.None` + the leading-zero guard reject "+3",
|
|
// " 3 ", and "03", which must NOT be treated as equal to the emitted "3").
|
|
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
|
|
{
|
|
string inner = value[1..^1];
|
|
if (inner.Length > 0 && (inner.Length == 1 || inner[0] != '0') &&
|
|
int.TryParse(inner, NumberStyles.None, CultureInfo.InvariantCulture, out int version))
|
|
{
|
|
return new IfMatchCondition(IfMatchKind.Version, version);
|
|
}
|
|
}
|
|
|
|
// Everything else (a valid-but-non-canonical strong tag like "03", a weak tag W/"3", an
|
|
// entity-tag list, or plain garbage) is treated as Malformed → 400. Strictly, RFC 7232 would
|
|
// 412 a syntactically-valid tag that merely doesn't strong-match; that refinement (plus
|
|
// weak-tag comparison, list support, and 412-vs-404 ordering) is deferred to the #197 cold
|
|
// contract pass — see #265. This is fail-safe (the mutation is rejected, never applied) and the
|
|
// first-party SPA only ever echoes the single canonical tag we emit.
|
|
return new IfMatchCondition(IfMatchKind.Malformed, 0);
|
|
}
|
|
|
|
public static void SetETag(HttpResponse response, int version) =>
|
|
response.Headers.ETag = $"\"{version}\"";
|
|
}
|