using System.Reflection; using System.Reflection.Emit; namespace AVEVA.Historian.Client.Tests; /// /// Structural guardrail pinning the 2023 R2 gRPC auth-handshake op routing. The SSPI/Negotiate /// token loop MUST be carried by StorageService.ValidateClientCredential (the op that kept /// the 2020 inBuff/outBuff token framing), NOT by HistoryService.ExchangeKey — ExchangeKey /// is a separate key-exchange/cert-path op that rejects an NTLM token at round 0 regardless of /// credentials (live-confirmed against a real 2023 R2 server, 2026-06-21). An earlier revision /// routed the loop to ExchangeKey; this test fails if that regression returns. /// /// It works by disassembling the IL of HistorianGrpcReadOrchestrator (and its /// compiler-generated nested closure types — the token-loop call lives inside a lambda) and /// collecting every method invoked. /// public sealed class HistorianGrpcHandshakeRoutingTests { [Fact] public void Handshake_UsesValidateClientCredential_NotExchangeKey() { HashSet calledMethods = CollectCalledMethodNames( "AVEVA.Historian.Client.Grpc.HistorianGrpcReadOrchestrator"); Assert.Contains("ValidateClientCredential", calledMethods); Assert.DoesNotContain("ExchangeKey", calledMethods); } private static HashSet CollectCalledMethodNames(string typeFullName) { Assembly sdk = typeof(HistorianClientOptions).Assembly; Type orchestrator = sdk.GetType(typeFullName, throwOnError: true)!; Module module = orchestrator.Module; // The orchestrator type plus its compiler-generated nested types (lambda closures). IEnumerable types = new[] { orchestrator } .Concat(orchestrator.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic)); var names = new HashSet(StringComparer.Ordinal); foreach (Type t in types) { Type[] typeArgs = t.IsGenericType ? t.GetGenericArguments() : Type.EmptyTypes; foreach (MethodInfo m in t.GetMethods( BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) { byte[]? il = m.GetMethodBody()?.GetILAsByteArray(); if (il is null) { continue; } Type[] methodArgs = m.IsGenericMethodDefinition ? m.GetGenericArguments() : Type.EmptyTypes; foreach (int token in EnumerateMethodTokens(il)) { try { MethodBase? resolved = module.ResolveMethod(token, typeArgs, methodArgs); if (resolved is not null) { names.Add(resolved.Name); } } catch (Exception ex) when (ex is ArgumentException or BadImageFormatException) { // vararg / MethodSpec tokens that don't resolve cleanly — irrelevant here. } } } } return names; } /// /// Walks an IL byte stream, yielding the 4-byte metadata token of every call/callvirt/newobj /// (any opcode). Uses the reflection-emit opcode table so /// operands of other instructions are skipped correctly rather than misread as opcodes. /// private static IEnumerable EnumerateMethodTokens(byte[] il) { int pos = 0; while (pos < il.Length) { OpCode op; if (il[pos] == 0xFE && pos + 1 < il.Length) { op = TwoByteOpCodes[il[pos + 1]]; pos += 2; } else { op = OneByteOpCodes[il[pos]]; pos += 1; } switch (op.OperandType) { case OperandType.InlineMethod: yield return BitConverter.ToInt32(il, pos); pos += 4; break; case OperandType.InlineNone: break; case OperandType.ShortInlineBrTarget: case OperandType.ShortInlineI: case OperandType.ShortInlineVar: pos += 1; break; case OperandType.InlineVar: pos += 2; break; case OperandType.InlineI8: case OperandType.InlineR: pos += 8; break; case OperandType.InlineSwitch: int count = BitConverter.ToInt32(il, pos); pos += 4 + (4 * count); break; default: // InlineBrTarget, InlineField, InlineI, InlineSig, InlineString, InlineTok, // InlineType, ShortInlineR — all 4-byte operands. pos += 4; break; } } } private static readonly OpCode[] OneByteOpCodes = BuildOpCodeTable(twoByte: false); private static readonly OpCode[] TwoByteOpCodes = BuildOpCodeTable(twoByte: true); private static OpCode[] BuildOpCodeTable(bool twoByte) { var table = new OpCode[256]; foreach (FieldInfo f in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) { if (f.GetValue(null) is not OpCode op) { continue; } ushort value = unchecked((ushort)op.Value); bool isTwoByte = (value & 0xFF00) == 0xFE00; if (isTwoByte == twoByte) { table[value & 0xFF] = op; } } return table; } }