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_UsesValidateClientCredential_NotExchangeKey()
{
// The auth token loop lives in the shared handshake helper (reused by the read, status,
// and future browse/metadata gRPC paths).
HashSet calledMethods = CollectCalledMethodNames(
"AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake");
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;
}
}