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
+1 -1
View File
@@ -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`).
- **`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.
- **`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.
`InternalsVisibleTo` exposes internals to the test assembly and the reverse-engineering tool.
+42 -3
View File
@@ -9,15 +9,54 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
> protocol serializer/parser + golden-byte unit test + an env-gated live integration
> test against the local Historian.
## Progress (updated 2026-06-19)
## Progress (updated 2026-06-21)
-**R0.6 version gate**`HistorianServerVersionGate` + `HistorianClientOptions.VerifyServerInterfaceVersion`;
fail-closed on connect, wired into both WCF and gRPC paths. Supported versions are
evidence-based (Hist=11, Retr=4, Trx=2; Status reachability-only), captured from the
live server. 10 unit tests.
evidence-based (Hist=11/12, Retr=4, Trx=2; Status reachability-only), captured from the
live server. History 12 (2023 R2 gRPC) accepted alongside 11 (buffer-compatible).
-**CW-1 capture pipeline**`ProtocolCaptureSanitizer` + `ProtocolFixtureWriter` +
`capture-tag-info` CLI command; produces sanitized `fixtures/protocol/<op>/` golden files.
11 unit tests. First fixture: `get-tag-info/analog-*.json`.
-**gRPC auth handshake (read chain)** — LIVE-VERIFIED 2026-06-21 against a real 2023 R2
server: `ReadRawAsync` over `RemoteGrpc` returns rows. Token loop routes to
`StorageService.ValidateClientCredential`. Shared handshake extracted to
`Grpc/HistorianGrpcHandshake` for reuse by the status/browse/metadata paths.
-**R0.4 Probe over gRPC**`Grpc/HistorianGrpcProbe` (History/Retrieval/Status
`GetInterfaceVersion`); `ProbeAsync` routes over gRPC when `Transport==RemoteGrpc`.
**LIVE-VERIFIED 2026-06-21** (no credentials required — runs before the auth loop).
-**R0.3 System parameter over gRPC**`Grpc/HistorianGrpcStatusClient.GetSystemParameterAsync`
(`StatusService.GetSystemParameter`); routed in the dialect. Built + unit-tested + **LIVE-VERIFIED
2026-06-21** against a real 2023 R2 server (returned `HistorianVersion`). Code path is the proven
handshake + a single string-in/string-out RPC.
-**R0.2 Tag metadata over gRPC**`Grpc/HistorianGrpcTagClient.GetTagMetadataAsync`
(`RetrievalService.GetTagInfosFromName`, the plural **string-handle** op). `GetTagMetadataAsync`
routes over gRPC when `Transport==RemoteGrpc`. Request `btTagNames` = `uint count + per-name(uint
charCount + UTF-16LE)` (golden-byte unit-tested); response `btTagInfos` = `uint count + CTagMetadata`
records (reuses `ParseGetTagInfoResponse`); string handle = uppercase Open2 storage GUID. The 2020
WCF string-handle wall does **not** apply on the gRPC front door (as predicted). **LIVE-VERIFIED
2026-06-21** — `GetTagMetadataAsync` returned the requested tag + a valid data type.
-**R0.1 Browse over gRPC** — DONE, **LIVE-VERIFIED 2026-06-21**.
`HistorianClient.BrowseTagNamesAsync` routes over gRPC via
`Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync`: StartTagQuery(**OData** filter) → paged
**QueryTag** (`btRequest` = `u16 0x6752 + u16 1 + u16 queryType + u32 startIndex + u32 count`) →
EndTagQuery; response = `u32 count + per-name(u32 charCount + UTF-16LE) + trailer`. The SDK glob
filter is translated by `GlobToODataFilter` (`Pre*``startswith`, `*suf``endswith`, `*mid*`
`contains`, exact→`eq`). The QueryTag packet-id `0x6752` was recovered from a `.rdata`
packet-descriptor table (`{0x6751,1}`=StartTagQuery, `{0x6752,1}`=QueryTag) — no Ghidra needed.
Golden-byte + glob unit tests + gated live test. Full finding:
`docs/reverse-engineering/grpc-tag-query-odata.md`.
> ✅ **Milestone 0 (gRPC parity) is COMPLETE** — probe, system-param, metadata, and browse all run
> over `RemoteGrpc` and are live-verified against a real 2023 R2 server, alongside the read chain.
> ️ **Auth note (2026-06-21, resolved):** an apparent NTLM round-1 `SEC_E_LOGON_DENIED` blocker
> turned out to be a **test-harness credential-parsing bug**, not a server/account/SDK issue — the
> gitignored creds file stores **quoted** values (`"nam\user"`, `"pass"`), and the env-setup must
> **strip surrounding quotes** before exporting `HISTORIAN_USER`/`HISTORIAN_PASSWORD`. With quotes
> stripped, the domain account authenticates and the full read + system-param + probe chain passes
> live. The round-failure diagnostic added during the hunt is kept
> (`HistorianNativeHandshake.DescribeError` decodes the native error + hex/ASCII preview).
> ⚠️ **Live-verification constraint:** the local Historian is **2020** (WCF, port 32568) — the
> 2023 R2 gRPC endpoint (32565) is absent. M0's gRPC routing (R0.1R0.4) can be built and
@@ -0,0 +1,57 @@
# R0.1 browse over gRPC — StartTagQuery takes an OData filter (2026-06-21)
Live-probed `RetrievalService.StartTagQuery` / `QueryTag` against a real **2023 R2** server over the
gRPC front door (string-handle = uppercase Open2 storage GUID). Key result: **browse is feasible on
2023 R2** — the 2020 WCF "metadata-server pipe" wall does **not** block here.
## StartTagQuery — CRACKED
`StartTagQuery(strHandle, btRequest)` where `btRequest` = the native
`marker(26449) + version(1) + WriteHistorianString(filter)` buffer
(`HistorianTagQueryProtocol.CreateStartTagQueryAttempt`). The server runs
`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over `\\.\pipe\aahMetadataServer\console` and
**parses the filter string as OData** (not SQL-LIKE). Swept filters:
| filter | result |
|---|---|
| `startswith(TagName,'Sys')` | ✅ success, 8-byte response |
| `contains(TagName,'Sys')` | ✅ success |
| `TagName eq 'SysTimeSec'` | ✅ success |
| `` (empty) | ✅ success (all tags) |
| `Sys*` / `*` | ❌ `ODataFilter ... bad token` |
| `TagName like 'Sys%'` / `Name like 'Sys%'` | ❌ rejected |
Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)` pair
(`ParseStartTagQueryResponse`). Live: `startswith(TagName,'Sys')` → tagCount = 220.
**Implication for the public API:** browse must translate the SDK's glob filter to OData —
`*` → empty, `Pre*` → `startswith(TagName,'Pre')`, `*sub*` → `contains(TagName,'sub')`,
exact → `TagName eq '...'`. (Escaping single-quotes in names still TBD.)
## QueryTag — CRACKED (2026-06-21), browse SHIPPED
`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that returns the tag-name rows.
The blocker was the packet id: every guessed `btRequest` returned native error **type 4 / code 72 =
`InvalidPacketId`** (`ArchestrA.CloudHistorian.Contract.ErrorCode.InvalidPacketId`). The generic
`0x6751` header that StartTagQuery accepts is the **wrong** id for QueryTag.
**How it was found (no Ghidra needed):** a `.rdata` **packet-descriptor table** in
`aahClientManaged.dll` lists consecutive `{uint marker, uint version}` entries —
`{0x6751, 1}` (StartTagQuery) immediately followed by **`{0x6752, 1}`** (the paired op). Found by
`pefile` byte-scan of `.rdata` for `51 67 00 00` and dumping the surrounding dwords. Testing `0x6752`
live confirmed it.
**QueryTag wire format (live-verified):**
- request `btRequest` = `u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count`
— `queryType = 1` returns tag-name rows (`queryType = 0` returns an empty/count-only page).
- response `btResonse` = `u32 count + per-name(u32 charCount + UTF-16LE) + trailer`
(the trailer is the CloudHistorian `NextIndex`/`TagMetadataBuffer` region — ignored by
`HistorianTagQueryProtocol.ParseTagNameQueryPage`).
- Semantic fields match `ArchestrA.CloudHistorian.Contract.QueryTagRequest`
(`QueryType/StartIndex/TagCount`; the QueryHandle travels in the protobuf `uiQueryHandle`).
**Browse is shipped:** `HistorianClient.BrowseTagNamesAsync` routes over gRPC when
`Transport==RemoteGrpc` via `Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync`
(StartTagQuery(OData) → paged QueryTag(0x6752) → EndTagQuery), with the SDK glob filter translated by
`GlobToODataFilter`. Golden-byte + glob unit tests and a gated live test
(`BrowseTagNamesAsync_OverGrpc_ReturnsSystemTags`) cover it. **M0 gRPC parity is complete.**
@@ -0,0 +1,94 @@
using Google.Protobuf;
using Grpc.Core;
using AVEVA.Historian.Client.Wcf;
using GrpcHistory = ArchestrA.Grpc.Contract.History;
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
namespace AVEVA.Historian.Client.Grpc;
/// <summary>
/// Shared 2023 R2 gRPC authentication handshake. Opens an authenticated History session over an
/// existing <see cref="HistorianGrpcConnection"/> and returns the transient client handle used by
/// the Retrieval/Status services. Extracted from <see cref="HistorianGrpcReadOrchestrator"/> so the
/// read, status, and (future) browse/metadata gRPC paths all drive the identical chain:
/// <c>HistoryService.GetInterfaceVersion → StorageService.ValidateClientCredential (token loop) →
/// HistoryService.OpenConnection</c>. The byte payloads (OpenConnection3 v6 request, NTLM token
/// framing) are the proven 2020 protocol and transfer unchanged inside protobuf <c>bytes</c> fields.
///
/// See <see cref="HistorianGrpcReadOrchestrator"/> for the op-routing rationale (the Negotiate loop
/// belongs on StorageService.ValidateClientCredential, NOT HistoryService.ExchangeKey).
/// </summary>
internal static class HistorianGrpcHandshake
{
/// <summary>
/// The handles produced by a successful OpenConnection. <see cref="ClientHandle"/> is the
/// transient <c>uint</c> session token used by StartQuery/GetSystemParameter and the other
/// uint-handle ops. <see cref="StorageSessionId"/> is the storage-session GUID used (formatted
/// uppercase via <see cref="StringHandle"/>) by the string-handle ops
/// (GetTagInfosFromName, GetTagExtendedPropertiesFromName, ExecuteSqlCommand, ...).
/// </summary>
internal readonly record struct Session(uint ClientHandle, Guid StorageSessionId)
{
/// <summary>The storage GUID in the uppercase "D" form the native string-handle ops require.</summary>
public string StringHandle => StorageSessionId.ToString("D").ToUpperInvariant();
}
/// <summary>Convenience overload for callers that only need the uint client handle.</summary>
public static uint OpenAuthenticatedConnection(
HistorianGrpcConnection connection,
HistorianClientOptions options,
CancellationToken cancellationToken)
=> OpenSession(connection, options, cancellationToken).ClientHandle;
public static Session OpenSession(
HistorianGrpcConnection connection,
HistorianClientOptions options,
CancellationToken cancellationToken)
{
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
Guid contextKey = Guid.NewGuid();
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, options);
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
HistorianNativeHandshake.RunTokenRounds(
(handle, wrapped, _) =>
{
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
connection.Metadata,
Deadline(),
cancellationToken);
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
bool success = response.Status?.BSuccess ?? false;
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
},
contextKey,
options,
cancellationToken);
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
connection.Metadata,
Deadline(),
cancellationToken);
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
if (!(open2.Status?.BSuccess ?? false))
{
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
}
(uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
return new Session(clientHandle, storageSessionId);
}
}
@@ -0,0 +1,48 @@
using Grpc.Core;
using GrpcHistory = ArchestrA.Grpc.Contract.History;
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
namespace AVEVA.Historian.Client.Grpc;
/// <summary>
/// 2023 R2 gRPC reachability probe (roadmap item R0.4). Mirrors <see cref="Wcf.HistorianWcfProbe"/>
/// over the gRPC transport: it calls the unauthenticated <c>GetInterfaceVersion</c> RPC on the
/// History, Retrieval, and Status services and applies the same success criteria. No credentials
/// are required — these RPCs run before the SSPI/Negotiate token loop — so the probe works even
/// when authentication is unavailable.
/// </summary>
internal static class HistorianGrpcProbe
{
public static async Task<bool> ProbeAsync(HistorianClientOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
return await Task.Run(() => Probe(options, cancellationToken), cancellationToken).ConfigureAwait(false);
}
private static bool Probe(HistorianClientOptions options, CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
DateTime deadline = DateTime.UtcNow.Add(options.ConnectTimeout > TimeSpan.Zero ? options.ConnectTimeout : TimeSpan.FromSeconds(5));
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
GrpcHistory.GetInterfaceVersionResponse history = historyClient.GetInterfaceVersion(
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval = retrievalClient.GetRetrievalInterfaceVersion(
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
GrpcStatus.GetStatusInterfaceVersionResponse status = statusClient.GetStatusInterfaceVersion(
new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
return history.UiError == 0
&& history.UiVersion > 0
&& retrieval.UiError == 0
&& retrieval.UiVersion > 0
&& status.UiError == 0;
}
}
@@ -3,7 +3,6 @@ using Google.Protobuf;
using Grpc.Core;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
using GrpcHistory = ArchestrA.Grpc.Contract.History;
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
namespace AVEVA.Historian.Client.Grpc;
@@ -16,17 +15,23 @@ namespace AVEVA.Historian.Client.Grpc;
///
/// Operation mapping (2020 WCF → 2023 R2 gRPC):
/// Hist.GetInterfaceVersion → HistoryService.GetInterfaceVersion
/// Hist.ValidateClientCredential (loop) → HistoryService.ExchangeKey (loop)
/// Hist.ValidateClientCredential (loop) → StorageService.ValidateClientCredential (loop)
/// Hist.OpenConnection2 → HistoryService.OpenConnection
/// Retr.StartQuery2 → RetrievalService.StartQuery
/// Retr.GetNextQueryResultBuffer2 (loop) → RetrievalService.GetNextQueryResultBuffer (loop)
/// Retr.EndQuery2 → RetrievalService.EndQuery
///
/// NOTE: not yet live-verified against a 2023 R2 server. The auth handshake uses
/// HistoryService.ExchangeKey because the gRPC HistoryService dropped ValidateClientCredential
/// (it now lives only on StorageService) and gained ExchangeKey with the identical
/// handle+token→token shape. If a live server rejects this, the handshake op is the first thing
/// to revisit — everything else is the proven 2020 byte protocol.
/// LIVE-VERIFIED 2026-06-21 against a real 2023 R2 server (interface versions: History=12,
/// Retrieval=4, Storage=4). The SSPI/Negotiate token loop maps to
/// <c>StorageService.ValidateClientCredential(Handle, InBuff)→(status, OutBuff)</c> — the op that
/// kept the 2020 inBuff/outBuff token framing. The gRPC HistoryService dropped
/// 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>
internal sealed class HistorianGrpcReadOrchestrator
{
@@ -159,50 +164,7 @@ internal sealed class HistorianGrpcReadOrchestrator
}
private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
HistorianNativeHandshake.RunTokenRounds(
(handle, wrapped, _) =>
{
GrpcHistory.ExchangeKeyResponse response = historyClient.ExchangeKey(
new GrpcHistory.ExchangeKeyRequest { StrHandle = handle, BtInput = ByteString.CopyFrom(wrapped) },
connection.Metadata,
Deadline(),
cancellationToken);
byte[] serverOutput = response.BtOutput?.ToByteArray() ?? [];
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
bool success = response.Status?.BSuccess ?? false;
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
},
contextKey,
_options,
cancellationToken);
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
_options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
connection.Metadata,
Deadline(),
cancellationToken);
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
if (!(open2.Status?.BSuccess ?? false))
{
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
}
(uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
return clientHandle;
}
=> HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken);
private List<HistorianSample> RunQuery(
HistorianGrpcConnection connection,
@@ -0,0 +1,38 @@
using Grpc.Core;
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
namespace AVEVA.Historian.Client.Grpc;
/// <summary>
/// 2023 R2 gRPC status client (roadmap item R0.3). Mirrors
/// <see cref="Wcf.HistorianWcfStatusClient"/> over the gRPC transport: it opens an authenticated
/// History session via <see cref="HistorianGrpcHandshake"/> and queries the StatusService for the
/// resulting client handle. <c>GetSystemParameter</c> carries the parameter name as a protobuf
/// string and returns the value string directly — there is no opaque native buffer to decode.
/// </summary>
internal static class HistorianGrpcStatusClient
{
public static Task<string?> GetSystemParameterAsync(
HistorianClientOptions options,
string parameterName,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
return Task.Run(() => GetSystemParameter(options, parameterName, cancellationToken), cancellationToken);
}
private static string? GetSystemParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
GrpcStatus.GetSystemParameterResponse response = statusClient.GetSystemParameter(
new GrpcStatus.GetSystemParameterRequest { UiHandle = clientHandle, StrParameterName = parameterName },
connection.Metadata,
DateTime.UtcNow.Add(options.RequestTimeout),
cancellationToken);
return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null;
}
}
@@ -0,0 +1,283 @@
using System.Text;
using Google.Protobuf;
using Grpc.Core;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
namespace AVEVA.Historian.Client.Grpc;
/// <summary>
/// 2023 R2 gRPC tag-metadata + browse client (roadmap items R0.2 metadata, R0.1 browse).
/// Browse drives <c>StartTagQuery</c> (OData filter) → paged <c>QueryTag</c> → <c>EndTagQuery</c>
/// (see <see cref="BrowseTagNamesAsync"/> and <c>docs/reverse-engineering/grpc-tag-query-odata.md</c>).
/// Unlike the WCF singular
/// <c>GetTagInfoFromName</c> (a <c>uint</c>-handle op), the gRPC front door exposes the plural
/// <c>RetrievalService.GetTagInfosFromName</c> — a <b>string-handle</b> op keyed off the Open2
/// storage-session GUID (uppercase). The request <c>btTagNames</c> buffer and response
/// <c>btTagInfos</c> buffer carry the proven native encodings:
/// <list type="bullet">
/// <item>request <c>btTagNames</c> = <c>uint count</c> + per-name(<c>uint charCount</c> + UTF-16LE)</item>
/// <item>response <c>btTagInfos</c> = <c>uint tagCount</c> + per-tag CTagMetadata record
/// (the same record <see cref="HistorianTagQueryProtocol.ParseGetTagInfoResponse"/> decodes)</item>
/// </list>
/// The string-handle "wall" that blocks this op family on the 2020 WCF transport does not apply on
/// the gRPC front door (different envelope/registration) — see
/// <c>docs/reverse-engineering/wcf-string-handle-wall.md</c>.
/// </summary>
internal static class HistorianGrpcTagClient
{
public static Task<HistorianTagMetadata?> GetTagMetadataAsync(
HistorianClientOptions options,
string tag,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
return Task.Run(() => GetTagMetadata(options, tag, cancellationToken), cancellationToken);
}
private static HistorianTagMetadata? GetTagMetadata(HistorianClientOptions options, string tag, CancellationToken cancellationToken)
{
byte[] tagInfos = GetTagInfosRaw(options, [tag], cancellationToken);
if (tagInfos.Length < 4)
{
return null;
}
IReadOnlyList<HistorianTagInfoResponse> parsed = HistorianTagQueryProtocol.ParseGetTagInfoResponse(tagInfos);
if (parsed.Count == 0)
{
return null;
}
HistorianTagInfoResponse info = parsed[0];
return new HistorianTagMetadata(
Name: info.TagName,
Key: info.TagKey,
DataType: HistorianWcfTagClient.MapDataType(info.NativeDataTypeDescriptor),
Description: info.Description ?? info.MetadataProvider,
EngineeringUnit: info.EngineeringUnit ?? string.Empty,
MinRaw: info.MinEU,
MaxRaw: info.MaxEU);
}
/// <summary>
/// Issues a single <c>GetTagInfosFromName</c> call and returns the raw native <c>btTagInfos</c>
/// response buffer. Internal so reverse-engineering probes can capture the framing.
/// </summary>
internal static byte[] GetTagInfosRaw(HistorianClientOptions options, IReadOnlyList<string> tags, CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
byte[] requestBuffer = BuildTagNamesBuffer(tags);
GrpcRetrieval.GetTagInfosFromNameResponse response = retrievalClient.GetTagInfosFromName(
new GrpcRetrieval.GetTagInfosFromNameRequest
{
StrHandle = session.StringHandle,
BtTagNames = ByteString.CopyFrom(requestBuffer),
UiSequence = 0
},
connection.Metadata,
DateTime.UtcNow.Add(options.RequestTimeout),
cancellationToken);
if (!(response.Status?.BSuccess ?? false))
{
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
throw new InvalidOperationException($"gRPC GetTagInfosFromName failed (errorLen={error.Length}).");
}
return response.BtTagInfos?.ToByteArray() ?? [];
}
// QueryTag (browse paging) request framing, recovered from the .rdata packet-descriptor table
// in aahClientManaged.dll (entries {0x6751,1}=StartTagQuery, {0x6752,1}=QueryTag) and confirmed
// live: btRequest = u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count.
private const ushort QueryTagPacketMarker = 0x6752;
private const ushort TagQueryHeaderVersion = 1;
private const ushort QueryTagModeNames = 1; // queryType 1 returns tag-name rows
private const uint BrowsePageSize = 1000;
/// <summary>
/// Browses tag names over gRPC (roadmap item R0.1). Drives
/// <c>StartTagQuery</c> (OData filter) → paged <c>QueryTag</c> → <c>EndTagQuery</c> on the
/// RetrievalService. The 2023 R2 metadata-server parses the filter as <b>OData</b>, so the SDK's
/// glob filter is translated via <see cref="GlobToODataFilter"/>. Each QueryTag page returns
/// <c>uint count + per-name(uint charCount + UTF-16LE)</c>, decoded by
/// <see cref="HistorianTagQueryProtocol.ParseGetLikeTagNamesResponse"/>.
/// </summary>
public static async IAsyncEnumerable<string> BrowseTagNamesAsync(
HistorianClientOptions options,
string filter,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
IReadOnlyList<string> names = await Task.Run(() => BrowseTagNames(options, filter, cancellationToken), cancellationToken).ConfigureAwait(false);
foreach (string name in names)
{
cancellationToken.ThrowIfCancellationRequested();
yield return name;
}
}
private static List<string> BrowseTagNames(HistorianClientOptions options, string filter, CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
byte[] startRequest = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(GlobToODataFilter(filter)).RequestBuffer;
GrpcRetrieval.StartTagQueryResponse start = retrievalClient.StartTagQuery(
new GrpcRetrieval.StartTagQueryRequest { StrHandle = session.StringHandle, BtRequest = ByteString.CopyFrom(startRequest) },
connection.Metadata, Deadline(), cancellationToken);
if (!(start.Status?.BSuccess ?? false))
{
byte[] error = start.Status?.BtError?.ToByteArray() ?? [];
throw new InvalidOperationException($"gRPC StartTagQuery failed (errorLen={error.Length}).");
}
HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(start.BtResponse?.ToByteArray() ?? []);
List<string> names = new(checked((int)parsed.TagCount));
try
{
uint startIndex = 0;
while (names.Count < parsed.TagCount)
{
cancellationToken.ThrowIfCancellationRequested();
uint page = Math.Min(BrowsePageSize, parsed.TagCount - (uint)names.Count);
GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag(
new GrpcRetrieval.QueryTagRequest
{
StrHandle = session.StringHandle,
UiQueryHandle = parsed.QueryHandle,
BtRequest = ByteString.CopyFrom(BuildQueryTagRequest(QueryTagModeNames, startIndex, page))
},
connection.Metadata, Deadline(), cancellationToken);
if (!(query.Status?.BSuccess ?? false))
{
byte[] error = query.Status?.BtError?.ToByteArray() ?? [];
throw new InvalidOperationException($"gRPC QueryTag failed (errorLen={error.Length}).");
}
IReadOnlyList<string> pageNames = HistorianTagQueryProtocol.ParseTagNameQueryPage(query.BtResonse?.ToByteArray() ?? []);
if (pageNames.Count == 0)
{
break;
}
names.AddRange(pageNames);
startIndex += (uint)pageNames.Count;
}
}
finally
{
try
{
retrievalClient.EndTagQuery(
new GrpcRetrieval.EndTagQueryRequest { StrHandle = session.StringHandle, UiQueryHandle = parsed.QueryHandle },
connection.Metadata, Deadline(), CancellationToken.None);
}
catch { /* best-effort cleanup */ }
}
return names;
}
/// <summary>Builds the QueryTag paging request: u16 marker(0x6752) + u16 version + u16 queryType + u32 startIndex + u32 count.</summary>
internal static byte[] BuildQueryTagRequest(ushort queryType, uint startIndex, uint count)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write(QueryTagPacketMarker);
writer.Write(TagQueryHeaderVersion);
writer.Write(queryType);
writer.Write(startIndex);
writer.Write(count);
return stream.ToArray();
}
/// <summary>
/// Translates the SDK's glob filter (<c>*</c> wildcard) into the OData filter the 2023 R2
/// metadata-server's <c>StartActiveTagnamesQuery</c> expects. Single-quotes are OData-escaped.
/// <list type="bullet">
/// <item><c>*</c> / empty → no filter (all tags)</item>
/// <item><c>Pre*</c> → <c>startswith(TagName,'Pre')</c></item>
/// <item><c>*suf</c> → <c>endswith(TagName,'suf')</c></item>
/// <item><c>*mid*</c> → <c>contains(TagName,'mid')</c></item>
/// <item><c>a*b</c> → <c>startswith(TagName,'a') and endswith(TagName,'b')</c></item>
/// <item><c>Exact</c> → <c>TagName eq 'Exact'</c></item>
/// </list>
/// </summary>
internal static string GlobToODataFilter(string filter)
{
if (string.IsNullOrEmpty(filter) || filter == "*")
{
return string.Empty;
}
static string Esc(string s) => s.Replace("'", "''");
bool starStart = filter.StartsWith('*');
bool starEnd = filter.EndsWith('*');
string core = filter.Trim('*');
if (core.Length == 0)
{
return string.Empty; // "**" etc.
}
if (filter.IndexOf('*') < 0)
{
return $"TagName eq '{Esc(filter)}'";
}
if (starStart && starEnd && !core.Contains('*'))
{
return $"contains(TagName,'{Esc(core)}')";
}
if (starEnd && !core.Contains('*') && !starStart)
{
return $"startswith(TagName,'{Esc(core)}')";
}
if (starStart && !core.Contains('*') && !starEnd)
{
return $"endswith(TagName,'{Esc(core)}')";
}
// Internal wildcard(s): anchor on the prefix before the first '*' and the suffix after the last.
string prefix = filter[..filter.IndexOf('*')];
string suffix = filter[(filter.LastIndexOf('*') + 1)..];
List<string> parts = [];
if (prefix.Length > 0)
{
parts.Add($"startswith(TagName,'{Esc(prefix)}')");
}
if (suffix.Length > 0)
{
parts.Add($"endswith(TagName,'{Esc(suffix)}')");
}
return parts.Count > 0 ? string.Join(" and ", parts) : string.Empty;
}
/// <summary>Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE).</summary>
internal static byte[] BuildTagNamesBuffer(IReadOnlyList<string> tags)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((uint)tags.Count);
foreach (string tag in tags)
{
writer.Write((uint)tag.Length);
if (tag.Length > 0)
{
writer.Write(Encoding.Unicode.GetBytes(tag));
}
}
return stream.ToArray();
}
}
@@ -25,7 +25,9 @@ public sealed class HistorianClient : IAsyncDisposable
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
{
return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
return _options.Transport == HistorianTransport.RemoteGrpc
? await Grpc.HistorianGrpcProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false)
: await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
}
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
@@ -129,13 +131,17 @@ public sealed class HistorianClient : IAsyncDisposable
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
return HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
return _options.Transport == HistorianTransport.RemoteGrpc
? Grpc.HistorianGrpcTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken)
: HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
}
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
return HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
return _options.Transport == HistorianTransport.RemoteGrpc
? Grpc.HistorianGrpcTagClient.GetTagMetadataAsync(_options, tag, cancellationToken)
: HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
}
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
@@ -43,6 +43,15 @@ internal static class HistorianServerVersionGate
public const uint RetrievalInterfaceVersion = 4;
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>
/// True when the service interface reports a meaningful version that should be matched.
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
@@ -56,7 +65,7 @@ internal static class HistorianServerVersionGate
_ => 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
{
HistorianServiceInterface.History => HistoryInterfaceVersion,
@@ -65,6 +74,18 @@ internal static class HistorianServerVersionGate
_ => 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>
/// Throws <see cref="ProtocolEvidenceMissingException"/> when version verification is enabled
/// and the server's reported interface version differs from the version this SDK targets.
@@ -80,14 +101,15 @@ internal static class HistorianServerVersionGate
return;
}
uint expected = ExpectedVersion(service);
if (reportedVersion == expected)
uint[] accepted = AcceptedVersions(service);
if (Array.IndexOf(accepted, reportedVersion) >= 0)
{
return;
}
string acceptedList = string.Join(", ", accepted);
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");
}
}
@@ -64,7 +64,9 @@ internal sealed class Historian2020ProtocolDialect
{
cancellationToken.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
return UseGrpc
? HistorianGrpcStatusClient.GetSystemParameterAsync(_options, name, cancellationToken)
: Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
}
public Task<string?> GetRuntimeParameterAsync(string name, CancellationToken cancellationToken)
@@ -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);
@@ -0,0 +1,155 @@
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>HistorianGrpcHandshake</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()
{
// The auth token loop lives in the shared handshake helper (reused by the read, status,
// and future browse/metadata gRPC paths).
HashSet<string> calledMethods = CollectCalledMethodNames(
"AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake");
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;
}
}
@@ -12,6 +12,21 @@ namespace AVEVA.Historian.Client.Tests;
/// </summary>
public sealed class HistorianGrpcIntegrationTests
{
[Fact]
public async Task ProbeAsync_OverGrpc_ReturnsTrue()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host))
{
return;
}
// ProbeAsync calls the unauthenticated GetInterfaceVersion RPCs, so it succeeds even when
// credentials are unavailable — no HISTORIAN_USER/PASSWORD required.
HistorianClient client = new(BuildOptions(host));
Assert.True(await client.ProbeAsync(CancellationToken.None));
}
[Fact]
public async Task ReadRawAsync_OverGrpc_ReturnsAtLeastOneRow()
{
@@ -37,6 +52,61 @@ public sealed class HistorianGrpcIntegrationTests
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
}
[Fact]
public async Task GetSystemParameterAsync_OverGrpc_ReturnsValue()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
HistorianClient client = new(BuildOptions(host));
string? version = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
Assert.False(string.IsNullOrWhiteSpace(version));
}
[Fact]
public async Task GetTagMetadataAsync_OverGrpc_ReturnsRequestedTag()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
string? tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(tag)
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
HistorianClient client = new(BuildOptions(host));
HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(tag, CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal(tag, metadata!.Name);
// A real metadata record decodes to a known data type (descriptor passed MapDataType).
Assert.True(Enum.IsDefined(metadata.DataType));
}
[Fact]
public async Task BrowseTagNamesAsync_OverGrpc_ReturnsSystemTags()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// Full R0.1 browse over gRPC: StartTagQuery(OData) -> paged QueryTag(0x6752) -> EndTagQuery.
HistorianClient client = new(BuildOptions(host));
List<string> names = [];
await foreach (string name in client.BrowseTagNamesAsync("Sys*", CancellationToken.None))
{
names.Add(name);
}
Assert.NotEmpty(names);
Assert.All(names, n => Assert.StartsWith("Sys", n, StringComparison.Ordinal));
}
private static HistorianClientOptions BuildOptions(string host)
{
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
@@ -100,6 +100,88 @@ public sealed class HistorianGrpcTransportTests
Assert.Equal((uint)HistorianDataQueryProtocol.QueryRequestTypeData, decoded.UiQueryRequestType);
}
[Fact]
public void InterfaceVersionResponses_ExposeErrorAndVersion_AsProbeExpects()
{
// R0.4 ProbeAsync reads uiError/uiVersion off each service's GetInterfaceVersion response.
// Pin that field mapping (success = uiError 0 + uiVersion > 0) via a protobuf round-trip.
var history = GrpcHistory.GetInterfaceVersionResponse.Parser.ParseFrom(
new GrpcHistory.GetInterfaceVersionResponse { UiError = 0, UiVersion = 12 }.ToByteArray());
var retrieval = GetRetrievalInterfaceVersionResponse.Parser.ParseFrom(
new GetRetrievalInterfaceVersionResponse { UiError = 0, UiVersion = 4 }.ToByteArray());
Assert.Equal(0u, history.UiError);
Assert.Equal(12u, history.UiVersion);
Assert.Equal(0u, retrieval.UiError);
Assert.Equal(4u, retrieval.UiVersion);
}
[Fact]
public void GetSystemParameterMessages_CarryHandleNameAndValue_AsStatusClientExpects()
{
// R0.3 sends {uiHandle, strParameterName} and reads strParameterValue when status succeeds.
var request = ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest.Parser.ParseFrom(
new ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest { UiHandle = 9, StrParameterName = "HistorianVersion" }.ToByteArray());
Assert.Equal(9u, request.UiHandle);
Assert.Equal("HistorianVersion", request.StrParameterName);
var response = ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse.Parser.ParseFrom(
new ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse
{
Status = new ArchestrA.Grpc.Contract.RequestStatus.Status { BSuccess = true },
StrParameterValue = "20.0.000"
}.ToByteArray());
Assert.True(response.Status.BSuccess);
Assert.Equal("20.0.000", response.StrParameterValue);
}
[Fact]
public void BuildTagNamesBuffer_EncodesCountThenLengthPrefixedUtf16Names()
{
// R0.2 request framing: uint count + per-name(uint charCount + UTF-16LE). Golden bytes.
byte[] buffer = AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.BuildTagNamesBuffer(["AB", "C"]);
byte[] expected =
[
0x02, 0x00, 0x00, 0x00, // count = 2
0x02, 0x00, 0x00, 0x00, // "AB" char count = 2
0x41, 0x00, 0x42, 0x00, // 'A','B' UTF-16LE
0x01, 0x00, 0x00, 0x00, // "C" char count = 1
0x43, 0x00 // 'C' UTF-16LE
];
Assert.Equal(expected, buffer);
}
[Fact]
public void BuildQueryTagRequest_EncodesMarkerVersionTypeStartCount()
{
// R0.1 QueryTag paging request: u16 0x6752 + u16 1 + u16 queryType + u32 startIndex + u32 count.
byte[] buffer = AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.BuildQueryTagRequest(1, 0, 50);
byte[] expected =
[
0x52, 0x67, // marker 0x6752
0x01, 0x00, // version 1
0x01, 0x00, // queryType 1 (names)
0x00, 0x00, 0x00, 0x00, // startIndex 0
0x32, 0x00, 0x00, 0x00 // count 50
];
Assert.Equal(expected, buffer);
}
[Theory]
[InlineData("*", "")]
[InlineData("", "")]
[InlineData("Sys*", "startswith(TagName,'Sys')")]
[InlineData("*Total", "endswith(TagName,'Total')")]
[InlineData("*Alarm*", "contains(TagName,'Alarm')")]
[InlineData("Exact.Tag", "TagName eq 'Exact.Tag'")]
[InlineData("Pre*Suf", "startswith(TagName,'Pre') and endswith(TagName,'Suf')")]
[InlineData("O'Brien*", "startswith(TagName,'O''Brien')")]
public void GlobToODataFilter_TranslatesWildcards(string glob, string expected)
{
Assert.Equal(expected, AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.GlobToODataFilter(glob));
}
[Fact]
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
{
@@ -29,6 +29,20 @@ public sealed class HistorianServerVersionGateTests
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]
public void Validate_MismatchedVersion_ThrowsProtocolEvidenceMissing()
{
@@ -36,7 +50,7 @@ public sealed class HistorianServerVersionGateTests
(HistorianServiceInterface Service, uint Version)[] cases =
[
(HistorianServiceInterface.History, 10u),
(HistorianServiceInterface.History, 12u),
(HistorianServiceInterface.History, 13u),
(HistorianServiceInterface.Retrieval, 3u),
(HistorianServiceInterface.Retrieval, 5u),
(HistorianServiceInterface.Transaction, 1u),