| | | 1 | | namespace AsyncResponse; |
| | | 2 | | |
| | | 3 | | /// <summary> |
| | | 4 | | /// Text rules shared by the two identifier contracts — correlation ids |
| | | 5 | | /// (<see cref="AsyncResponseChannelOptions.CorrelationIdNotPortable"/>) and flow ids |
| | | 6 | | /// (<c>FlowStateConcurrency.FlowIdNotPortable</c>). They are checked in one place because an |
| | | 7 | | /// identifier crosses the same boundaries either way: it is encoded to UTF-8 for a subject, a key, |
| | | 8 | | /// or a column, and compared ordinally by the engine on the way back. |
| | | 9 | | /// </summary> |
| | | 10 | | internal static class PortableText |
| | | 11 | | { |
| | | 12 | | /// <summary> |
| | | 13 | | /// Finds the first ill-formed UTF-16 code unit — an unpaired surrogate — or <c>-1</c> when the |
| | | 14 | | /// string is well-formed. |
| | | 15 | | /// <para> |
| | | 16 | | /// A .NET <see cref="string"/> can hold one, and it is not merely exotic: every UTF-8 encoder |
| | | 17 | | /// in the framework defaults to REPLACING it with U+FFFD rather than failing. That silent |
| | | 18 | | /// substitution is what makes an unpaired surrogate dangerous here rather than merely invalid. |
| | | 19 | | /// Two ids the engine considers different — a lone <c>U+D800</c> and a literal <c>U+FFFD</c> — |
| | | 20 | | /// encode to identical bytes, so they collide on anything derived from those bytes: a NATS |
| | | 21 | | /// subject, a recovery key, a hash. One conversation's response then reaches the other's |
| | | 22 | | /// waiter, which is exactly the failure the ordinal-identity contract exists to prevent. |
| | | 23 | | /// </para> |
| | | 24 | | /// </summary> |
| | | 25 | | internal static int IndexOfIllFormedUtf16(string value) |
| | | 26 | | { |
| | 1568150 | 27 | | for (var index = 0; index < value.Length; index++) |
| | | 28 | | { |
| | 763753 | 29 | | if (!char.IsSurrogate(value[index])) |
| | | 30 | | continue; |
| | | 31 | | |
| | | 32 | | // A high surrogate followed by a low one is a well-formed pair: skip both. |
| | 52 | 33 | | if (char.IsHighSurrogate(value[index]) |
| | 52 | 34 | | && index + 1 < value.Length |
| | 52 | 35 | | && char.IsLowSurrogate(value[index + 1])) |
| | | 36 | | { |
| | 22 | 37 | | index++; |
| | 22 | 38 | | continue; |
| | | 39 | | } |
| | | 40 | | |
| | | 41 | | // Anything else is unpaired: a high surrogate at the end or before a non-low unit, or |
| | | 42 | | // a low surrogate with no high unit before it. |
| | 30 | 43 | | return index; |
| | | 44 | | } |
| | | 45 | | |
| | 20322 | 46 | | return -1; |
| | | 47 | | } |
| | | 48 | | |
| | | 49 | | /// <summary> |
| | | 50 | | /// Finds the first control character, or <c>-1</c> when there is none. |
| | | 51 | | /// <para> |
| | | 52 | | /// Control characters are not merely ugly in diagnostics: U+0000 in particular is rejected |
| | | 53 | | /// outright by PostgreSQL's <c>text</c> type (SQLSTATE 22021, "invalid byte sequence for |
| | | 54 | | /// encoding UTF8: 0x00") while SQL Server's <c>nvarchar</c> stores it happily, so an id |
| | | 55 | | /// carrying one exists on one store and fails at its first write on another — the opposite of |
| | | 56 | | /// portable, and diagnosed only as an opaque driver error far from the call site. |
| | | 57 | | /// </para> |
| | | 58 | | /// </summary> |
| | | 59 | | internal static int IndexOfControlCharacter(string value) |
| | | 60 | | { |
| | 1135066 | 61 | | for (var index = 0; index < value.Length; index++) |
| | | 62 | | { |
| | 552374 | 63 | | if (char.IsControl(value[index])) |
| | 14 | 64 | | return index; |
| | | 65 | | } |
| | | 66 | | |
| | 15159 | 67 | | return -1; |
| | | 68 | | } |
| | | 69 | | |
| | | 70 | | /// <summary>The rejection message for a control character, worded for the given kind of id.</summary> |
| | | 71 | | internal static string ControlCharacterRejection(string kind, string excerpt, char offending, int index) |
| | 14 | 72 | | => $"{kind} '{excerpt}' contains the control character \\u{(int)offending:x4} at index {index}. Control characte |
| | 14 | 73 | | "portable: PostgreSQL rejects U+0000 in a text column outright (22021) while SQL Server stores it, so the sa |
| | 14 | 74 | | "succeeds on one store and fails at its first write on another, and control characters corrupt diagnostics " |
| | 14 | 75 | | "everywhere. Use a printable id."; |
| | | 76 | | |
| | | 77 | | /// <summary> |
| | | 78 | | /// The shared 40-character excerpt used when quoting an offending id back to the caller. Cut |
| | | 79 | | /// through <see cref="TruncateWellFormed"/>: a fixed-index slice used to split a surrogate pair |
| | | 80 | | /// that straddled the cut, so the helper that quotes an id in the "unpaired surrogate" |
| | | 81 | | /// rejection could mint an unpaired surrogate of its own. |
| | | 82 | | /// </summary> |
| | | 83 | | internal static string Excerpt(string value) |
| | 40 | 84 | | => value.Length <= 40 ? value : string.Concat(TruncateWellFormed(value, 40), "…"); |
| | | 85 | | |
| | | 86 | | /// <summary> |
| | | 87 | | /// The longest prefix of <paramref name="value"/> that fits <paramref name="maxLength"/> UTF-16 |
| | | 88 | | /// code units WITHOUT ending inside a surrogate pair — one unit shorter than the budget when |
| | | 89 | | /// the pair straddles it. <c>value[..maxLength]</c> keeps the high surrogate and drops its low |
| | | 90 | | /// half; every UTF-8 encoder then substitutes U+FFFD for the orphan (see |
| | | 91 | | /// <see cref="IndexOfIllFormedUtf16"/>), silently corrupting the text at the cut — or, under a |
| | | 92 | | /// strict encoder, failing the write that carried it. Every length-capped diagnostic string |
| | | 93 | | /// (id excerpts, dead-letter reasons, generated consumer names) is cut here. A surrogate that |
| | | 94 | | /// was already unpaired in the input is not repaired: this never makes text worse, it only |
| | | 95 | | /// refuses to break a pair that was whole. |
| | | 96 | | /// </summary> |
| | | 97 | | internal static string TruncateWellFormed(string value, int maxLength) |
| | | 98 | | { |
| | 186 | 99 | | ArgumentOutOfRangeException.ThrowIfNegative(maxLength); |
| | 184 | 100 | | if (value.Length <= maxLength) |
| | 116 | 101 | | return value; |
| | | 102 | | |
| | 68 | 103 | | var cut = maxLength; |
| | 68 | 104 | | if (cut > 0 && char.IsHighSurrogate(value[cut - 1]) && char.IsLowSurrogate(value[cut])) |
| | 14 | 105 | | cut--; |
| | | 106 | | |
| | 68 | 107 | | return value[..cut]; |
| | | 108 | | } |
| | | 109 | | |
| | | 110 | | /// <summary>The rejection message for an unpaired surrogate, worded for the given kind of id.</summary> |
| | | 111 | | internal static string IllFormedUtf16Rejection(string kind, string excerpt, char offending, int index) |
| | 30 | 112 | | => $"{kind} '{excerpt}' is not well-formed UTF-16: the code unit at index {index} (\\u{(int)offending:x4}) is an |
| | 30 | 113 | | "surrogate. Encoders substitute U+FFFD for it rather than failing, so this id and one containing a literal U |
| | 30 | 114 | | "produce identical bytes — and therefore the same NATS subject, recovery key, and stored value — while the e |
| | 30 | 115 | | "compares them ordinally and treats them as two different conversations. Send a well-formed id."; |
| | | 116 | | } |