fix(api): #265 review — quote-aware If-Match scanner, RFC OWS trim, de-BOM
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Failing after 2m30s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Independent review fix commit (cold fork MERGEABLE-WITH-NITS + Codex BLOCKED, 2 Highs):

- Codex H1: a comma (0x2C) is a valid etagc and can appear INSIDE a quoted opaque-tag
  ("3,5" is ONE tag). The old Split(',') broke it into two malformed tokens → 400. Replaced
  with a quote-aware position scanner that treats a comma as a separator only outside the
  quotes; "3,5" is now one valid non-canonical tag → 412.
- Codex H2: RFC 7230 OWS is SP/HTAB only. string.Trim() also strips NBSP and other Unicode
  whitespace, letting " * " masquerade as the "*" force-write escape. Trim only
  (' ', '\t'); such input is now Malformed → 400.
- Fork nit: corrected the canonical-guard comment (interior-whitespace tags are rejected by
  IsEtagc, not NumberStyles.None).
- CI Formatting gate: de-BOM the 8 touched legacy Application .cs (charset=utf-8, #311/#310).
- Tests: added comma-in-tag ("3,5", "x,y","3"), empty-element tolerance, NBSP-not-OWS,
  trailing-junk, lowercase-weak, wildcard-in-list cases. Full ErsatzTV.Tests green (1556).

Refs #253 #197
This commit is contained in:
2026-07-12 23:09:36 +02:00
parent 8090e10408
commit 50cd29d841
10 changed files with 94 additions and 66 deletions
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -1,4 +1,4 @@
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -52,6 +52,10 @@ public class ConcurrencyHeadersTests
[TestCase("\"3\",\"5\"", new[] { 3, 5 })] // OWS between members is optional
[TestCase("W/\"3\", \"5\"", new[] { 5 })] // weak members never strong-match, so they drop out
[TestCase("\"03\", \"5\"", new[] { 5 })] // a non-canonical member drops out; the canonical one stays
[TestCase("\"x,y\", \"3\"", new[] { 3 })] // comma is a valid etagc: "x,y" is ONE (non-canonical) tag
[TestCase("\"3\",,\"5\"", new[] { 3, 5 })] // empty list element tolerated (RFC 7230 §7)
[TestCase(",\"3\"", new[] { 3 })] // leading empty element tolerated
[TestCase("\t\"3\"\t", new[] { 3 })] // outer OWS is SP/HTAB
public void Entity_Tag_List_Collects_The_Strong_Canonical_Versions(string header, int[] expected)
{
IfMatchCondition result = Parse(header);
@@ -71,6 +75,7 @@ public class ConcurrencyHeadersTests
[TestCase("\"\"")] // empty tag
[TestCase("W/\"3\"")] // weak tag
[TestCase("\"99999999999999999999\"")] // overflows int
[TestCase("\"3,5\"")] // comma is a valid etagc: one syntactically-valid, non-canonical tag
[TestCase("W/\"3\", W/\"5\"")] // a list of only weak tags
public void Valid_But_Non_Matching_Tags_Are_A_Guaranteed_Precondition_Failure(string header)
{
@@ -88,6 +93,10 @@ public class ConcurrencyHeadersTests
[TestCase("\" 3 \"")] // SP is not a valid etagc
[TestCase("\"3")] // unterminated
[TestCase("W/3")] // weak prefix without a quoted opaque-tag
[TestCase("w/\"3\"")] // the weak indicator is case-sensitive (%s"W/"); lowercase is malformed
[TestCase("\"3\", *")] // "*" is only valid as the whole value, never a list member
[TestCase("\"3\"junk")] // trailing junk after a well-formed tag
[TestCase("\u00A0*\u00A0")] // NBSP is NOT RFC OWS: must not be trimmed into a bare "*" force-write
[TestCase("garbage")]
[TestCase(",")] // no entity-tag at all
public void Grammar_Violations_Are_Malformed(string header)
+77 -58
View File
@@ -55,44 +55,92 @@ public static class ConcurrencyHeaders
return new IfMatchCondition(IfMatchKind.Absent, LanguageExt.Seq<int>.Empty);
}
string value = raw.ToString().Trim();
// RFC 7230 OWS is SP / HTAB only. Do NOT use string.Trim(), which also strips NBSP and other
// Unicode whitespace — that would let " * " masquerade as the "*" force-write escape.
string value = raw.ToString().Trim(' ', '\t');
if (value == "*")
{
return new IfMatchCondition(IfMatchKind.Any, LanguageExt.Seq<int>.Empty);
}
// If-Match = "*" / 1#entity-tag (RFC 7232 §3.1). An entity-tag's opaque-tag is DQUOTE-delimited
// and etagc never includes DQUOTE, so commas unambiguously separate list members. Any member that
// is not a well-formed entity-tag makes the whole header malformed → 400. A member that IS a valid
// entity-tag but does not strong-match — a weak tag, or a strong tag whose opaque text is not the
// exact canonical decimal we emit ("03", "3.0", "", an out-of-range value) — simply contributes no
// version. If no member strong-matches, evaluation is a guaranteed 412 (fail-safe), never 400: the
// pre-#265 behaviour of 400-ing any non-canonical/weak/list value was RFC-incorrect (issue #265).
// If-Match = "*" / 1#entity-tag (RFC 7232 §3.1). We SCAN the list rather than Split(',') because a
// comma (0x2C) is a valid etagc and may appear INSIDE a quoted opaque-tag ("3,5" is one tag, not
// two) — a comma separates members only outside the quotes. Each member is [ "W/" ] DQUOTE *etagc
// DQUOTE; any grammar violation → Malformed (400). A well-formed member that does not strong-match
// (a weak tag, or a strong tag whose opaque text isn't the exact canonical decimal we emit — "03",
// "3.0", "", an out-of-range value) contributes no version; if none strong-match the result is a
// guaranteed 412, never 400 (the pre-#265 400-of-any-non-canonical value was RFC-incorrect, #265).
var versions = new List<int>();
bool sawTag = false;
foreach (string token in value.Split(','))
int i = 0;
int n = value.Length;
while (i < n)
{
string tag = token.Trim();
if (tag.Length == 0)
// Skip OWS and empty list elements (RFC 7230 §7: tolerate stray/empty members).
while (i < n && (value[i] is ' ' or '\t' or ','))
{
// RFC 7230 §7 list rule: tolerate (ignore) empty list elements from stray commas.
continue;
i++;
}
sawTag = true;
if (!TryParseEntityTag(tag, out bool weak, out int matchedVersion, out bool hasVersion))
if (i >= n)
{
break;
}
// Optional weak indicator — case-sensitive "W/" per %s"W/"; a weak tag never strong-matches.
bool weak = false;
if (i + 1 < n && value[i] == 'W' && value[i + 1] == '/')
{
weak = true;
i += 2;
}
// opaque-tag = DQUOTE *etagc DQUOTE
if (i >= n || value[i] != '"')
{
return new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
}
// Strong comparison only (RFC 7232 §3.1): a weak tag never strong-matches a PUT precondition.
if (!weak && hasVersion)
i++; // opening quote
int innerStart = i;
while (i < n && value[i] != '"')
{
versions.Add(matchedVersion);
if (!IsEtagc(value[i]))
{
return new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
}
i++;
}
if (i >= n) // unterminated opaque-tag
{
return new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
}
string inner = value[innerStart..i];
i++; // closing quote
sawTag = true;
if (!weak && TryCanonicalVersion(inner, out int version))
{
versions.Add(version);
}
// After a member, only OWS then a comma (or end) is legal — anything else ("3"junk, "3""5")
// is a grammar violation. The comma itself is consumed by the OWS/empty-element skip above.
while (i < n && value[i] is ' ' or '\t')
{
i++;
}
if (i < n && value[i] != ',')
{
return new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
}
}
// A header that contained only separators/whitespace has no entity-tag at all → grammar violation.
// A header that held only separators/whitespace has no entity-tag at all → grammar violation.
return sawTag
? new IfMatchCondition(IfMatchKind.Version, versions.ToSeq())
: new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
@@ -116,50 +164,21 @@ public static class ConcurrencyHeaders
});
/// <summary>
/// Parse a single RFC 7232 entity-tag: <c>[ "W/" ] DQUOTE *etagc DQUOTE</c>. <paramref name="weak" />
/// reports the <c>W/</c> prefix. <paramref name="hasVersion" />/<paramref name="matchedVersion" />
/// report whether the (strong) tag's opaque text is the exact canonical decimal we emit — the only
/// value that can strong-match our ETag. Returns <c>false</c> only for a genuine grammar violation.
/// Whether a strong tag's opaque text is the exact canonical decimal we emit — the only value that
/// can strong-match our ETag. An ETag is opaque, so only the canonical form matches: a non-negative
/// decimal, no sign, no surrounding whitespace, no leading zero (`NumberStyles.None` + the
/// leading-zero guard reject "+3", " 3 ", and "03", which must NOT be treated as equal to "3").
/// </summary>
private static bool TryParseEntityTag(string tag, out bool weak, out int matchedVersion, out bool hasVersion)
private static bool TryCanonicalVersion(string inner, out int version)
{
weak = false;
matchedVersion = 0;
hasVersion = false;
string opaque = tag;
if (opaque.StartsWith("W/", StringComparison.Ordinal))
if (inner.Length > 0 && (inner.Length == 1 || inner[0] != '0') &&
int.TryParse(inner, NumberStyles.None, CultureInfo.InvariantCulture, out version))
{
weak = true;
opaque = opaque[2..];
return true;
}
// opaque-tag = DQUOTE *etagc DQUOTE
if (opaque.Length < 2 || opaque[0] != '"' || opaque[^1] != '"')
{
return false;
}
string inner = opaque[1..^1];
foreach (char c in inner)
{
if (!IsEtagc(c))
{
return false;
}
}
// An ETag is opaque, so only the exact canonical form we emit strong-matches: a non-negative
// decimal with no sign, no surrounding whitespace, and no leading zero (`NumberStyles.None` + the
// leading-zero guard reject "+3", " 3 ", and "03", which must NOT be treated as equal to "3").
if (!weak && inner.Length > 0 && (inner.Length == 1 || inner[0] != '0') &&
int.TryParse(inner, NumberStyles.None, CultureInfo.InvariantCulture, out int version))
{
matchedVersion = version;
hasVersion = true;
}
return true;
version = 0;
return false;
}
// RFC 7232 §2.3: etagc = %x21 / %x23-7E / obs-text (%x80-FF). Excludes DQUOTE (%x22) and controls.