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 HistorianGrpcHandshake (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_TokenLoop_UsesValidateClientCredential_NotExchangeKey() { // The auth token loop is a compiler-generated lambda (nested closure) passed to RunTokenRounds. // It MUST carry the SSPI/Negotiate token via StorageService.ValidateClientCredential, never via // HistoryService.ExchangeKey (the earlier regression). ExchangeKey is now legitimately called // directly in OpenSession for the SEPARATE v8 event-connection key exchange (ECDH) — that is // allowed, so the guardrail scopes the no-ExchangeKey rule to the token-loop closures only. HashSet tokenLoopCalls = CollectCalledMethodNames( "AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake", nestedClosuresOnly: true); Assert.Contains("ValidateClientCredential", tokenLoopCalls); Assert.DoesNotContain("ExchangeKey", tokenLoopCalls); } private static HashSet CollectCalledMethodNames(string typeFullName, bool nestedClosuresOnly = false) { Assembly sdk = typeof(HistorianClientOptions).Assembly; Type orchestrator = sdk.GetType(typeFullName, throwOnError: true)!; Module module = orchestrator.Module; // The compiler-generated nested types (lambda closures) — optionally with the orchestrator type // itself. The token loop lives inside a closure; ExchangeKey (event key exchange) lives in the // OpenSession body, so scoping to closures isolates the token-loop routing. IEnumerable nested = orchestrator.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic); IEnumerable types = nestedClosuresOnly ? nested : new[] { orchestrator }.Concat(nested); 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; } }