Merge re/grpc-2023r2-handshake: M0 gRPC parity (probe/system-param/metadata/browse) + handshake fix

This commit is contained in:
Joseph Doherty
2026-06-21 16:32:02 -04:00
17 changed files with 995 additions and 66 deletions
@@ -33,7 +33,7 @@ internal static class HistorianNativeHandshake
/// upper-case context-key GUID, <paramref name="wrappedToken"/> is the AVEVA-wrapped SSPI
/// token (round byte + length + token). The WCF path maps this to
/// <c>Hist.ValidateClientCredential</c>; the gRPC path maps it to
/// <c>HistoryService.ExchangeKey</c> (the renamed handshake op).
/// <c>StorageService.ValidateClientCredential</c> (the op that kept the 2020 token framing).
/// </summary>
internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
@@ -70,7 +70,8 @@ internal static class HistorianNativeHandshake
if (!result.Success)
{
throw new InvalidOperationException($"Credential token round {round} rejected (errorLen={error.Length}).");
throw new InvalidOperationException(
$"Credential token round {round} rejected (errorLen={error.Length}).{DescribeError(error)}");
}
ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput);
@@ -162,4 +163,32 @@ internal static class HistorianNativeHandshake
int slash = userName.IndexOf('\\');
return slash > 0 ? userName[(slash + 1)..] : userName;
}
/// <summary>
/// Renders a diagnostic suffix for a rejected credential round: the decoded native error
/// (type/code/name) plus a short hex + printable-ASCII preview of the server error buffer.
/// Keeps secrets out — error buffers carry server status codes/messages, not credentials.
/// </summary>
private static string DescribeError(byte[] error)
{
if (error.Length == 0)
{
return string.Empty;
}
HistorianNativeError? native = HistorianOpen2Protocol.TryReadNativeError(error);
string nativePart = native is null
? string.Empty
: $" native(type={native.Type}, code={native.Code}{(native.Name is null ? string.Empty : $", {native.Name}")})";
ReadOnlySpan<byte> preview = error.AsSpan(0, Math.Min(error.Length, 64));
string hex = Convert.ToHexString(preview);
char[] ascii = new char[preview.Length];
for (int i = 0; i < preview.Length; i++)
{
ascii[i] = preview[i] is >= 0x20 and < 0x7F ? (char)preview[i] : '.';
}
return $"{nativePart} hex={hex} ascii=\"{new string(ascii)}\"";
}
}
@@ -102,6 +102,34 @@ internal static class HistorianTagQueryProtocol
return tagNames;
}
/// <summary>
/// Parses one page of a gRPC <c>QueryTag</c> tag-name response: <c>uint count + per-name(uint
/// charCount + UTF-16LE)</c>, then a trailing region (NextIndex + optional metadata buffer) that
/// is intentionally ignored. Unlike <see cref="ParseGetLikeTagNamesResponse"/> this tolerates the
/// trailer rather than requiring the buffer to end exactly after the names.
/// </summary>
public static IReadOnlyList<string> ParseTagNameQueryPage(ReadOnlySpan<byte> response)
{
if (response.Length < 4)
{
return [];
}
int cursor = 0;
uint count = ReadUInt32(response, ref cursor);
List<string> tagNames = new(checked((int)count));
for (uint index = 0; index < count; index++)
{
uint charLength = ReadUInt32(response, ref cursor);
int byteLength = checked((int)charLength * 2);
EnsureAvailable(response, cursor, byteLength);
tagNames.Add(Encoding.Unicode.GetString(response.Slice(cursor, byteLength)));
cursor += byteLength;
}
return tagNames;
}
private static void WriteHistorianString(BinaryWriter writer, string value)
{
writer.Write((uint)value.Length);