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
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:
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user