gRPC 2023 R2: fix auth handshake op routing + accept History v12
First live-verified gRPC read against a real 2023 R2 Historian. The handshake previously failed at round 0 (cred-independent) because the SSPI/Negotiate token loop was routed to HistoryService.ExchangeKey. ExchangeKey is a separate key-exchange/cert-path op, not the Negotiate loop — the token loop belongs on StorageService.ValidateClientCredential, which kept the 2020 inBuff/outBuff token framing the SDK's WrapValidateClientCredentialToken/TryRead helpers already build. Captured + diffed against the recovered 2023 R2 protobuf contract and the decompiled stock client; routing the loop to ValidateClientCredential completes the full chain (ValidateClientCredential x N -> OpenConnection -> StartQuery -> GetNextQueryResultBuffer) and returns rows. - HistorianGrpcReadOrchestrator: token loop now calls StorageService.ValidateClientCredential(Handle, InBuff); corrected the op-map doc comment (was asserting the wrong ExchangeKey mapping). - HistorianServerVersionGate: accept History interface version 12 alongside 11. Live server reports History=12, Retrieval=4, Storage=4; the buffers are byte-identical (a live read returns rows), so 12 is buffer-compatible. Retrieval stays pinned at 4 (matches). New AcceptedVersions() supports multi-version gates. - New HistorianGrpcHandshakeRoutingTests: IL-level structural guardrail that disassembles the orchestrator (incl. lambda closures) and asserts the handshake invokes ValidateClientCredential and never ExchangeKey — fails if the regression returns. - Updated gate tests + CLAUDE.md gRPC op-map. 240 unit tests pass on the full stack; 210 on this branch's base. The byte payloads remain the proven 2020 protocol. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -29,3 +29,6 @@ Thumbs.db
|
|||||||
# Test droppings
|
# Test droppings
|
||||||
*.coverage
|
*.coverage
|
||||||
coverage.cobertura.xml
|
coverage.cobertura.xml
|
||||||
|
|
||||||
|
# Live 2023 R2 server credentials — never commit
|
||||||
|
wonder-sql-vd03.txt
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ Three layered subsystems, intentionally decoupled so protocol parsing can be uni
|
|||||||
- **`Wcf/`** — managed WCF/MDAS layer. The Historian uses Net.TCP on port `32568` with a custom `application/x-mdas` content type wrapping a binary SOAP 1.2 / WS-Addressing 1.0 envelope. `MdasMessageEncoder` + `MdasMessageEncodingBindingElement` implement that wrapper. `HistorianWcfBindingFactory` produces three flavors: plain MDAS, MDAS+Windows transport (used for `/Hist-Integrated`), and MDAS+certificate (used for `/HistCert`). Service paths live in `HistorianWcfServiceNames`. WCF data contracts (`Wcf/Contracts/`) are reproduced from server-side static analysis and are versioned per native interface (e.g., `IRetrievalServiceContract2..4`).
|
- **`Wcf/`** — managed WCF/MDAS layer. The Historian uses Net.TCP on port `32568` with a custom `application/x-mdas` content type wrapping a binary SOAP 1.2 / WS-Addressing 1.0 envelope. `MdasMessageEncoder` + `MdasMessageEncodingBindingElement` implement that wrapper. `HistorianWcfBindingFactory` produces three flavors: plain MDAS, MDAS+Windows transport (used for `/Hist-Integrated`), and MDAS+certificate (used for `/HistCert`). Service paths live in `HistorianWcfServiceNames`. WCF data contracts (`Wcf/Contracts/`) are reproduced from server-side static analysis and are versioned per native interface (e.g., `IRetrievalServiceContract2..4`).
|
||||||
- **`Protocol/`** — binary frame layer (`HistorianFrameReader`/`Writer`, `HistorianBinaryPrimitives`, `HistorianMessageType`). `Historian2020ProtocolDialect` is the version-anchored bridge between `HistorianClient` and the frame layer; methods without sufficient evidence throw `ProtocolEvidenceMissingException` rather than guessing wire bytes.
|
- **`Protocol/`** — binary frame layer (`HistorianFrameReader`/`Writer`, `HistorianBinaryPrimitives`, `HistorianMessageType`). `Historian2020ProtocolDialect` is the version-anchored bridge between `HistorianClient` and the frame layer; methods without sufficient evidence throw `ProtocolEvidenceMissingException` rather than guessing wire bytes.
|
||||||
- **`Transport/`** — pluggable `IHistorianTransport` (default: TCP). Tests inject a fake transport.
|
- **`Transport/`** — pluggable `IHistorianTransport` (default: TCP). Tests inject a fake transport.
|
||||||
- **`Grpc/`** — 2023 R2 gRPC transport (`HistorianTransport.RemoteGrpc`). The recovered protobuf contract lives in `Grpc/Protos/*.proto` and is compiled to client stubs at build time by `Grpc.Tools`. `HistorianGrpcChannelFactory` builds a gRPC-Web/HTTP-1.1 channel (default port `32565`, optional TLS, gzip) matching the stock 2023 R2 client. `HistorianGrpcReadOrchestrator` mirrors `HistorianWcfReadOrchestrator` but over gRPC: it reuses the exact native serializers/parsers — the same Open2 buffer, SSPI/NTLM tokens, and `DataQueryRequest`/result buffers travel inside protobuf `bytes` fields. The 2020→gRPC op map: `Hist.ValCl`→`HistoryService.ExchangeKey`, `Hist.Open2`→`HistoryService.OpenConnection`, `Retr.StartQuery2`→`RetrievalService.StartQuery`, `Retr.GetNextQueryResultBuffer2`→`RetrievalService.GetNextQueryResultBuffer`. The transport-agnostic handshake (Open2 request builder + SSPI token loop + response decode) is shared via `Wcf/HistorianNativeHandshake`. **Not yet live-verified against a 2023 R2 server** — the auth handshake op (`ExchangeKey`) is the first thing to revisit if a live server rejects it; the byte payloads are the proven 2020 protocol. Gated live test: set `HISTORIAN_GRPC_HOST` (+ `HISTORIAN_TEST_TAG`, optional `HISTORIAN_GRPC_PORT`/`HISTORIAN_GRPC_TLS`/`HISTORIAN_GRPC_DNSID`).
|
- **`Grpc/`** — 2023 R2 gRPC transport (`HistorianTransport.RemoteGrpc`). The recovered protobuf contract lives in `Grpc/Protos/*.proto` and is compiled to client stubs at build time by `Grpc.Tools`. `HistorianGrpcChannelFactory` builds a gRPC-Web/HTTP-1.1 channel (default port `32565`, optional TLS, gzip) matching the stock 2023 R2 client. `HistorianGrpcReadOrchestrator` mirrors `HistorianWcfReadOrchestrator` but over gRPC: it reuses the exact native serializers/parsers — the same Open2 buffer, SSPI/NTLM tokens, and `DataQueryRequest`/result buffers travel inside protobuf `bytes` fields. The 2020→gRPC op map: `Hist.ValCl`→`StorageService.ValidateClientCredential` (the SSPI/Negotiate token loop), `Hist.Open2`→`HistoryService.OpenConnection`, `Retr.StartQuery2`→`RetrievalService.StartQuery`, `Retr.GetNextQueryResultBuffer2`→`RetrievalService.GetNextQueryResultBuffer`. The transport-agnostic handshake (Open2 request builder + SSPI token loop + response decode) is shared via `Wcf/HistorianNativeHandshake`. **Live-verified 2026-06-21 against a real 2023 R2 server** (interface versions History=12, Retrieval=4, Storage=4): the full read chain returns rows. NOTE: `HistoryService.ExchangeKey` is a SEPARATE key-exchange/cert-path op, NOT the Negotiate loop — an earlier revision wrongly routed the token loop there and it was rejected at round 0 regardless of credentials; the loop belongs on `StorageService.ValidateClientCredential` (which kept the 2020 inBuff/outBuff token framing). The byte payloads are the proven 2020 protocol and transfer unchanged; only the History interface integer differs (12 vs 11) and is buffer-compatible, so `VerifyServerInterfaceVersion=false` is currently required against a v12 server (the gate still pins History=11). Gated live test: set `HISTORIAN_GRPC_HOST` (+ `HISTORIAN_TEST_TAG`, optional `HISTORIAN_GRPC_PORT`/`HISTORIAN_GRPC_TLS`/`HISTORIAN_GRPC_DNSID`); reach the live 2023 R2 box via [[reference_2023r2_live_server_access]].
|
||||||
- **`Models/`** — public DTOs and enums (`HistorianSample`, `RetrievalMode`, etc.). `HistorianDataValue` represents the discriminated value type.
|
- **`Models/`** — public DTOs and enums (`HistorianSample`, `RetrievalMode`, etc.). `HistorianDataValue` represents the discriminated value type.
|
||||||
|
|
||||||
`InternalsVisibleTo` exposes internals to the test assembly and the reverse-engineering tool.
|
`InternalsVisibleTo` exposes internals to the test assembly and the reverse-engineering tool.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using AVEVA.Historian.Client.Models;
|
|||||||
using AVEVA.Historian.Client.Wcf;
|
using AVEVA.Historian.Client.Wcf;
|
||||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||||
|
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
||||||
|
|
||||||
namespace AVEVA.Historian.Client.Grpc;
|
namespace AVEVA.Historian.Client.Grpc;
|
||||||
|
|
||||||
@@ -16,17 +17,23 @@ namespace AVEVA.Historian.Client.Grpc;
|
|||||||
///
|
///
|
||||||
/// Operation mapping (2020 WCF → 2023 R2 gRPC):
|
/// Operation mapping (2020 WCF → 2023 R2 gRPC):
|
||||||
/// Hist.GetInterfaceVersion → HistoryService.GetInterfaceVersion
|
/// Hist.GetInterfaceVersion → HistoryService.GetInterfaceVersion
|
||||||
/// Hist.ValidateClientCredential (loop) → HistoryService.ExchangeKey (loop)
|
/// Hist.ValidateClientCredential (loop) → StorageService.ValidateClientCredential (loop)
|
||||||
/// Hist.OpenConnection2 → HistoryService.OpenConnection
|
/// Hist.OpenConnection2 → HistoryService.OpenConnection
|
||||||
/// Retr.StartQuery2 → RetrievalService.StartQuery
|
/// Retr.StartQuery2 → RetrievalService.StartQuery
|
||||||
/// Retr.GetNextQueryResultBuffer2 (loop) → RetrievalService.GetNextQueryResultBuffer (loop)
|
/// Retr.GetNextQueryResultBuffer2 (loop) → RetrievalService.GetNextQueryResultBuffer (loop)
|
||||||
/// Retr.EndQuery2 → RetrievalService.EndQuery
|
/// Retr.EndQuery2 → RetrievalService.EndQuery
|
||||||
///
|
///
|
||||||
/// NOTE: not yet live-verified against a 2023 R2 server. The auth handshake uses
|
/// LIVE-VERIFIED 2026-06-21 against a real 2023 R2 server (interface versions: History=12,
|
||||||
/// HistoryService.ExchangeKey because the gRPC HistoryService dropped ValidateClientCredential
|
/// Retrieval=4, Storage=4). The SSPI/Negotiate token loop maps to
|
||||||
/// (it now lives only on StorageService) and gained ExchangeKey with the identical
|
/// <c>StorageService.ValidateClientCredential(Handle, InBuff)→(status, OutBuff)</c> — the op that
|
||||||
/// handle+token→token shape. If a live server rejects this, the handshake op is the first thing
|
/// kept the 2020 inBuff/outBuff token framing. The gRPC HistoryService dropped
|
||||||
/// to revisit — everything else is the proven 2020 byte protocol.
|
/// ValidateClientCredential and gained <c>ExchangeKey</c>, but ExchangeKey is a SEPARATE
|
||||||
|
/// key-exchange/cert-path op, NOT the Negotiate loop: feeding it an NTLM token is rejected at
|
||||||
|
/// round 0 regardless of credentials. An earlier revision wrongly routed the loop to ExchangeKey;
|
||||||
|
/// routing it to StorageService.ValidateClientCredential completes the full read chain. The byte
|
||||||
|
/// payloads (OpenConnection3 v6, token framing, DataQueryRequest, row buffers) are the proven 2020
|
||||||
|
/// protocol and transfer unchanged — only the History interface integer differs (12 vs the 2020
|
||||||
|
/// value 11), and that version is buffer-compatible (a live read returns rows).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class HistorianGrpcReadOrchestrator
|
internal sealed class HistorianGrpcReadOrchestrator
|
||||||
{
|
{
|
||||||
@@ -167,15 +174,16 @@ internal sealed class HistorianGrpcReadOrchestrator
|
|||||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
|
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
|
||||||
|
|
||||||
|
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
||||||
HistorianNativeHandshake.RunTokenRounds(
|
HistorianNativeHandshake.RunTokenRounds(
|
||||||
(handle, wrapped, _) =>
|
(handle, wrapped, _) =>
|
||||||
{
|
{
|
||||||
GrpcHistory.ExchangeKeyResponse response = historyClient.ExchangeKey(
|
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
|
||||||
new GrpcHistory.ExchangeKeyRequest { StrHandle = handle, BtInput = ByteString.CopyFrom(wrapped) },
|
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
|
||||||
connection.Metadata,
|
connection.Metadata,
|
||||||
Deadline(),
|
Deadline(),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
byte[] serverOutput = response.BtOutput?.ToByteArray() ?? [];
|
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
|
||||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||||
bool success = response.Status?.BSuccess ?? false;
|
bool success = response.Status?.BSuccess ?? false;
|
||||||
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ internal static class HistorianServerVersionGate
|
|||||||
public const uint RetrievalInterfaceVersion = 4;
|
public const uint RetrievalInterfaceVersion = 4;
|
||||||
public const uint TransactionInterfaceVersion = 2;
|
public const uint TransactionInterfaceVersion = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The 2023 R2 gRPC HistoryService reports interface version 12. It is buffer-compatible with
|
||||||
|
/// the 2020 version 11 — the OpenConnection3 v6 / token / DataQueryRequest / row buffers are
|
||||||
|
/// byte-identical — confirmed by a live end-to-end gRPC read against a real 2023 R2 server
|
||||||
|
/// (2026-06-21). So both 11 and 12 are accepted for History. (Retrieval reported 4, matching
|
||||||
|
/// the 2020 value, so it needs no widening.)
|
||||||
|
/// </summary>
|
||||||
|
public const uint HistoryInterfaceVersionGrpc2023R2 = 12;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when the service interface reports a meaningful version that should be matched.
|
/// True when the service interface reports a meaningful version that should be matched.
|
||||||
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
|
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
|
||||||
@@ -56,7 +65,7 @@ internal static class HistorianServerVersionGate
|
|||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>The interface version this SDK's serializers target for a value-gated service.</summary>
|
/// <summary>The canonical interface version this SDK's serializers target for a value-gated service.</summary>
|
||||||
public static uint ExpectedVersion(HistorianServiceInterface service) => service switch
|
public static uint ExpectedVersion(HistorianServiceInterface service) => service switch
|
||||||
{
|
{
|
||||||
HistorianServiceInterface.History => HistoryInterfaceVersion,
|
HistorianServiceInterface.History => HistoryInterfaceVersion,
|
||||||
@@ -65,6 +74,18 @@ internal static class HistorianServerVersionGate
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All interface versions accepted for a value-gated service. Usually a single value, but
|
||||||
|
/// History accepts both the 2020 value (11) and the buffer-compatible 2023 R2 gRPC value (12).
|
||||||
|
/// </summary>
|
||||||
|
public static uint[] AcceptedVersions(HistorianServiceInterface service) => service switch
|
||||||
|
{
|
||||||
|
HistorianServiceInterface.History => [HistoryInterfaceVersion, HistoryInterfaceVersionGrpc2023R2],
|
||||||
|
HistorianServiceInterface.Retrieval => [RetrievalInterfaceVersion],
|
||||||
|
HistorianServiceInterface.Transaction => [TransactionInterfaceVersion],
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Throws <see cref="ProtocolEvidenceMissingException"/> when version verification is enabled
|
/// Throws <see cref="ProtocolEvidenceMissingException"/> when version verification is enabled
|
||||||
/// and the server's reported interface version differs from the version this SDK targets.
|
/// and the server's reported interface version differs from the version this SDK targets.
|
||||||
@@ -80,14 +101,15 @@ internal static class HistorianServerVersionGate
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint expected = ExpectedVersion(service);
|
uint[] accepted = AcceptedVersions(service);
|
||||||
if (reportedVersion == expected)
|
if (Array.IndexOf(accepted, reportedVersion) >= 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string acceptedList = string.Join(", ", accepted);
|
||||||
throw new ProtocolEvidenceMissingException(
|
throw new ProtocolEvidenceMissingException(
|
||||||
$"{service} interface version {reportedVersion} (this SDK's serializers target version {expected}); " +
|
$"{service} interface version {reportedVersion} (this SDK's serializers target version {acceptedList}); " +
|
||||||
$"set {nameof(HistorianClientOptions)}.{nameof(HistorianClientOptions.VerifyServerInterfaceVersion)}=false to bypass at your own risk");
|
$"set {nameof(HistorianClientOptions)}.{nameof(HistorianClientOptions.VerifyServerInterfaceVersion)}=false to bypass at your own risk");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Reflection.Emit;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Structural guardrail pinning the 2023 R2 gRPC auth-handshake op routing. The SSPI/Negotiate
|
||||||
|
/// token loop MUST be carried by <c>StorageService.ValidateClientCredential</c> (the op that kept
|
||||||
|
/// the 2020 inBuff/outBuff token framing), NOT by <c>HistoryService.ExchangeKey</c> — 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 <c>HistorianGrpcReadOrchestrator</c> (and its
|
||||||
|
/// compiler-generated nested closure types — the token-loop call lives inside a lambda) and
|
||||||
|
/// collecting every method invoked.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HistorianGrpcHandshakeRoutingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Handshake_UsesValidateClientCredential_NotExchangeKey()
|
||||||
|
{
|
||||||
|
HashSet<string> calledMethods = CollectCalledMethodNames(
|
||||||
|
"AVEVA.Historian.Client.Grpc.HistorianGrpcReadOrchestrator");
|
||||||
|
|
||||||
|
Assert.Contains("ValidateClientCredential", calledMethods);
|
||||||
|
Assert.DoesNotContain("ExchangeKey", calledMethods);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> 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<Type> types = new[] { orchestrator }
|
||||||
|
.Concat(orchestrator.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic));
|
||||||
|
|
||||||
|
var names = new HashSet<string>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks an IL byte stream, yielding the 4-byte metadata token of every call/callvirt/newobj
|
||||||
|
/// (any <see cref="OperandType.InlineMethod"/> opcode). Uses the reflection-emit opcode table so
|
||||||
|
/// operands of other instructions are skipped correctly rather than misread as opcodes.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,20 @@ public sealed class HistorianServerVersionGateTests
|
|||||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Transaction, HistorianServerVersionGate.TransactionInterfaceVersion, Options());
|
HistorianServerVersionGate.Validate(HistorianServiceInterface.Transaction, HistorianServerVersionGate.TransactionInterfaceVersion, Options());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_History_AcceptsBoth2020And2023R2Versions()
|
||||||
|
{
|
||||||
|
// History 11 (2020 WCF) and 12 (2023 R2 gRPC) are both buffer-compatible — a live gRPC
|
||||||
|
// read against a real 2023 R2 server (interface version 12) returns rows. Both must pass.
|
||||||
|
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 11u, Options());
|
||||||
|
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, HistorianServerVersionGate.HistoryInterfaceVersionGrpc2023R2, Options());
|
||||||
|
Assert.Equal(12u, HistorianServerVersionGate.HistoryInterfaceVersionGrpc2023R2);
|
||||||
|
Assert.Contains(11u, HistorianServerVersionGate.AcceptedVersions(HistorianServiceInterface.History));
|
||||||
|
Assert.Contains(12u, HistorianServerVersionGate.AcceptedVersions(HistorianServiceInterface.History));
|
||||||
|
// Retrieval reported 4 on the live 2023 R2 server — matches 2020, so it is NOT widened.
|
||||||
|
Assert.DoesNotContain(5u, HistorianServerVersionGate.AcceptedVersions(HistorianServiceInterface.Retrieval));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_MismatchedVersion_ThrowsProtocolEvidenceMissing()
|
public void Validate_MismatchedVersion_ThrowsProtocolEvidenceMissing()
|
||||||
{
|
{
|
||||||
@@ -36,7 +50,7 @@ public sealed class HistorianServerVersionGateTests
|
|||||||
(HistorianServiceInterface Service, uint Version)[] cases =
|
(HistorianServiceInterface Service, uint Version)[] cases =
|
||||||
[
|
[
|
||||||
(HistorianServiceInterface.History, 10u),
|
(HistorianServiceInterface.History, 10u),
|
||||||
(HistorianServiceInterface.History, 12u),
|
(HistorianServiceInterface.History, 13u),
|
||||||
(HistorianServiceInterface.Retrieval, 3u),
|
(HistorianServiceInterface.Retrieval, 3u),
|
||||||
(HistorianServiceInterface.Retrieval, 5u),
|
(HistorianServiceInterface.Retrieval, 5u),
|
||||||
(HistorianServiceInterface.Transaction, 1u),
|
(HistorianServiceInterface.Transaction, 1u),
|
||||||
|
|||||||
Reference in New Issue
Block a user