From 22e9c5e5f87e3cd8489240bfe6cd60ffd87e9655 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 12:34:04 -0400 Subject: [PATCH 1/8] gRPC 2023 R2: fix auth handshake op routing + accept History v12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .gitignore | 3 + CLAUDE.md | 2 +- .../Grpc/HistorianGrpcReadOrchestrator.cs | 26 +-- .../HistorianServerVersionGate.cs | 30 +++- .../HistorianGrpcHandshakeRoutingTests.cs | 153 ++++++++++++++++++ .../HistorianServerVersionGateTests.cs | 16 +- 6 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs diff --git a/.gitignore b/.gitignore index 463f374..680e2bb 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ Thumbs.db # Test droppings *.coverage coverage.cobertura.xml + +# Live 2023 R2 server credentials — never commit +wonder-sql-vd03.txt diff --git a/CLAUDE.md b/CLAUDE.md index 92f1e90..e4331e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs index 72d2d5d..979ec9a 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs @@ -5,6 +5,7 @@ using AVEVA.Historian.Client.Models; using AVEVA.Historian.Client.Wcf; using GrpcHistory = ArchestrA.Grpc.Contract.History; using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval; +using GrpcStorage = ArchestrA.Grpc.Contract.Storage; namespace AVEVA.Historian.Client.Grpc; @@ -16,17 +17,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 +/// StorageService.ValidateClientCredential(Handle, InBuff)→(status, OutBuff) — the op that +/// kept the 2020 inBuff/outBuff token framing. The gRPC HistoryService dropped +/// ValidateClientCredential and gained ExchangeKey, 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). /// internal sealed class HistorianGrpcReadOrchestrator { @@ -167,15 +174,16 @@ internal sealed class HistorianGrpcReadOrchestrator 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, _) => { - GrpcHistory.ExchangeKeyResponse response = historyClient.ExchangeKey( - new GrpcHistory.ExchangeKeyRequest { StrHandle = handle, BtInput = ByteString.CopyFrom(wrapped) }, + GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential( + new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) }, connection.Metadata, Deadline(), cancellationToken); - byte[] serverOutput = response.BtOutput?.ToByteArray() ?? []; + byte[] serverOutput = response.OutBuff?.ToByteArray() ?? []; byte[] error = response.Status?.BtError?.ToByteArray() ?? []; bool success = response.Status?.BSuccess ?? false; return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error); diff --git a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs index aa05f3c..ba9f984 100644 --- a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs +++ b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs @@ -43,6 +43,15 @@ internal static class HistorianServerVersionGate public const uint RetrievalInterfaceVersion = 4; public const uint TransactionInterfaceVersion = 2; + /// + /// 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.) + /// + public const uint HistoryInterfaceVersionGrpc2023R2 = 12; + /// /// True when the service interface reports a meaningful version that should be matched. /// Status is reachability-only (its GetInterfaceVersion returns 0). @@ -56,7 +65,7 @@ internal static class HistorianServerVersionGate _ => false }; - /// The interface version this SDK's serializers target for a value-gated service. + /// The canonical interface version this SDK's serializers target for a value-gated service. 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.") }; + /// + /// 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). + /// + 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.") + }; + /// /// Throws 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"); } } diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs new file mode 100644 index 0000000..7958f28 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs @@ -0,0 +1,153 @@ +using System.Reflection; +using System.Reflection.Emit; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// Structural guardrail pinning the 2023 R2 gRPC auth-handshake op routing. The SSPI/Negotiate +/// token loop MUST be carried by StorageService.ValidateClientCredential (the op that kept +/// the 2020 inBuff/outBuff token framing), NOT by HistoryService.ExchangeKey — ExchangeKey +/// is a separate key-exchange/cert-path op that rejects an NTLM token at round 0 regardless of +/// credentials (live-confirmed against a real 2023 R2 server, 2026-06-21). An earlier revision +/// routed the loop to ExchangeKey; this test fails if that regression returns. +/// +/// It works by disassembling the IL of HistorianGrpcReadOrchestrator (and its +/// compiler-generated nested closure types — the token-loop call lives inside a lambda) and +/// collecting every method invoked. +/// +public sealed class HistorianGrpcHandshakeRoutingTests +{ + [Fact] + public void Handshake_UsesValidateClientCredential_NotExchangeKey() + { + HashSet calledMethods = CollectCalledMethodNames( + "AVEVA.Historian.Client.Grpc.HistorianGrpcReadOrchestrator"); + + Assert.Contains("ValidateClientCredential", calledMethods); + Assert.DoesNotContain("ExchangeKey", calledMethods); + } + + private static HashSet CollectCalledMethodNames(string typeFullName) + { + Assembly sdk = typeof(HistorianClientOptions).Assembly; + Type orchestrator = sdk.GetType(typeFullName, throwOnError: true)!; + Module module = orchestrator.Module; + + // The orchestrator type plus its compiler-generated nested types (lambda closures). + IEnumerable types = new[] { orchestrator } + .Concat(orchestrator.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic)); + + var names = new HashSet(StringComparer.Ordinal); + foreach (Type t in types) + { + Type[] typeArgs = t.IsGenericType ? t.GetGenericArguments() : Type.EmptyTypes; + foreach (MethodInfo m in t.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + byte[]? il = m.GetMethodBody()?.GetILAsByteArray(); + if (il is null) + { + continue; + } + + Type[] methodArgs = m.IsGenericMethodDefinition ? m.GetGenericArguments() : Type.EmptyTypes; + foreach (int token in EnumerateMethodTokens(il)) + { + try + { + MethodBase? resolved = module.ResolveMethod(token, typeArgs, methodArgs); + if (resolved is not null) + { + names.Add(resolved.Name); + } + } + catch (Exception ex) when (ex is ArgumentException or BadImageFormatException) + { + // vararg / MethodSpec tokens that don't resolve cleanly — irrelevant here. + } + } + } + } + + return names; + } + + /// + /// Walks an IL byte stream, yielding the 4-byte metadata token of every call/callvirt/newobj + /// (any opcode). Uses the reflection-emit opcode table so + /// operands of other instructions are skipped correctly rather than misread as opcodes. + /// + private static IEnumerable EnumerateMethodTokens(byte[] il) + { + int pos = 0; + while (pos < il.Length) + { + OpCode op; + if (il[pos] == 0xFE && pos + 1 < il.Length) + { + op = TwoByteOpCodes[il[pos + 1]]; + pos += 2; + } + else + { + op = OneByteOpCodes[il[pos]]; + pos += 1; + } + + switch (op.OperandType) + { + case OperandType.InlineMethod: + yield return BitConverter.ToInt32(il, pos); + pos += 4; + break; + case OperandType.InlineNone: + break; + case OperandType.ShortInlineBrTarget: + case OperandType.ShortInlineI: + case OperandType.ShortInlineVar: + pos += 1; + break; + case OperandType.InlineVar: + pos += 2; + break; + case OperandType.InlineI8: + case OperandType.InlineR: + pos += 8; + break; + case OperandType.InlineSwitch: + int count = BitConverter.ToInt32(il, pos); + pos += 4 + (4 * count); + break; + default: + // InlineBrTarget, InlineField, InlineI, InlineSig, InlineString, InlineTok, + // InlineType, ShortInlineR — all 4-byte operands. + pos += 4; + break; + } + } + } + + private static readonly OpCode[] OneByteOpCodes = BuildOpCodeTable(twoByte: false); + private static readonly OpCode[] TwoByteOpCodes = BuildOpCodeTable(twoByte: true); + + private static OpCode[] BuildOpCodeTable(bool twoByte) + { + var table = new OpCode[256]; + foreach (FieldInfo f in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (f.GetValue(null) is not OpCode op) + { + continue; + } + + ushort value = unchecked((ushort)op.Value); + bool isTwoByte = (value & 0xFF00) == 0xFE00; + if (isTwoByte == twoByte) + { + table[value & 0xFF] = op; + } + } + + return table; + } +} diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs index 9c3a98e..cbc7539 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs @@ -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), From c4b8d0dde40b808158c1c2c518982450cab9e406 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 13:32:04 -0400 Subject: [PATCH 2/8] gRPC M0: probe (R0.4, live-verified) + system-param (R0.3) + shared handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap docs/plans/hcal-roadmap.md, milestone M0 (gRPC parity for the DONE surface). Now unblocked for live verification by a reachable 2023 R2 server. - R0.4 Probe over gRPC: new HistorianGrpcProbe calls History/Retrieval/Status GetInterfaceVersion (unauthenticated). ProbeAsync routes over gRPC when Transport==RemoteGrpc. LIVE-VERIFIED against a real 2023 R2 server — needs no credentials (runs before the auth loop), so it works despite the auth blocker. - R0.3 System parameter over gRPC: new HistorianGrpcStatusClient calls StatusService.GetSystemParameter over the authenticated session; routed in the dialect. Built + unit-tested (request/response field mapping pinned). Live-verification pending an auth fix (see below). - Extracted the proven auth handshake from HistorianGrpcReadOrchestrator into shared Grpc/HistorianGrpcHandshake (reused by read + status + future browse/metadata). Repointed the IL structural guardrail test to it. - Diagnostics: round-failure now decodes the native server error + hex/ASCII preview (HistorianNativeHandshake.DescribeError). This surfaced the live auth blocker as SEC_E_LOGON_DENIED (0x8009030C) at NTLM round 1 — framing is correct, the credential did not validate. Probable cause: stale file password or NAM-domain NTLM restriction (Kerberos/RDP works, NTLM denied; no SPN path over the tunnel). 216 unit tests pass; live gRPC probe passes. Sanitization scan clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- docs/plans/hcal-roadmap.md | 30 +++++++- .../Grpc/HistorianGrpcHandshake.cs | 74 +++++++++++++++++++ .../Grpc/HistorianGrpcProbe.cs | 48 ++++++++++++ .../Grpc/HistorianGrpcReadOrchestrator.cs | 48 +----------- .../Grpc/HistorianGrpcStatusClient.cs | 38 ++++++++++ src/AVEVA.Historian.Client/HistorianClient.cs | 4 +- .../Protocol/Historian2020ProtocolDialect.cs | 4 +- .../Wcf/HistorianNativeHandshake.cs | 33 ++++++++- .../HistorianGrpcHandshakeRoutingTests.cs | 6 +- .../HistorianGrpcIntegrationTests.cs | 29 ++++++++ .../HistorianGrpcTransportTests.cs | 35 +++++++++ 11 files changed, 293 insertions(+), 56 deletions(-) create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcProbe.cs create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index d777c22..f0b5b4b 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -9,15 +9,39 @@ 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//` 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 + (request/response field mapping pinned). **Live-verification pending an auth fix** — see blocker. + Code path is the proven handshake + a single string-in/string-out RPC. + +> ⚠️ **Auth blocker (2026-06-21):** live gRPC ops needing a client handle (R0.1/R0.2/R0.3 and the +> read chain) fail at NTLM **round 1**. The decoded server error is +> `SEC_E_LOGON_DENIED` (0x8009030C) from `aahClientAccessPoint::CServerContext::ProcessClient…` — +> round 0 (NEGOTIATE) succeeds, round 1 (the password-bearing AUTHENTICATE) is denied. The token +> **framing is correct** (a framing fault would surface as `SEC_E_INVALID_TOKEN`); the server parsed +> a valid NTLM message but the credential did not validate. The creds in the gitignored file are +> byte-faithfully parsed (verified) and the domain user logs in via **RDP (Kerberos)**, so the +> probable cause is one of: (a) the file password is **stale** vs the account, or (b) the NAM domain +> **restricts NTLM** ("Network security: Restrict NTLM") — Kerberos/RDP works but NTLM is denied, and +> over the SOCKS/SSH tunnel (host→127.0.0.1, no SPN) the client cannot use Kerberos. Probe (R0.4) is +> unaffected (unauthenticated). Diagnostic: the round-failure exception now decodes the native error +> + a hex/ASCII preview (`HistorianNativeHandshake.DescribeError`). > ⚠️ **Live-verification constraint:** the local Historian is **2020** (WCF, port 32568) — the > 2023 R2 gRPC endpoint (32565) is absent. M0's gRPC routing (R0.1–R0.4) can be built and diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs new file mode 100644 index 0000000..a65ed1c --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs @@ -0,0 +1,74 @@ +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; + +/// +/// Shared 2023 R2 gRPC authentication handshake. Opens an authenticated History session over an +/// existing and returns the transient client handle used by +/// the Retrieval/Status services. Extracted from so the +/// read, status, and (future) browse/metadata gRPC paths all drive the identical chain: +/// HistoryService.GetInterfaceVersion → StorageService.ValidateClientCredential (token loop) → +/// HistoryService.OpenConnection. The byte payloads (OpenConnection3 v6 request, NTLM token +/// framing) are the proven 2020 protocol and transfer unchanged inside protobuf bytes fields. +/// +/// See for the op-routing rationale (the Negotiate loop +/// belongs on StorageService.ValidateClientCredential, NOT HistoryService.ExchangeKey). +/// +internal static class HistorianGrpcHandshake +{ + public static uint OpenAuthenticatedConnection( + 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, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); + return clientHandle; + } +} diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcProbe.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcProbe.cs new file mode 100644 index 0000000..1886987 --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcProbe.cs @@ -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; + +/// +/// 2023 R2 gRPC reachability probe (roadmap item R0.4). Mirrors +/// over the gRPC transport: it calls the unauthenticated GetInterfaceVersion 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. +/// +internal static class HistorianGrpcProbe +{ + public static async Task 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; + } +} diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs index 979ec9a..74d0bad 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs @@ -3,9 +3,7 @@ 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; -using GrpcStorage = ArchestrA.Grpc.Contract.Storage; namespace AVEVA.Historian.Client.Grpc; @@ -166,51 +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); - - 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, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); - return clientHandle; - } + => HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken); private List RunQuery( HistorianGrpcConnection connection, diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs new file mode 100644 index 0000000..c0538f1 --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs @@ -0,0 +1,38 @@ +using Grpc.Core; +using GrpcStatus = ArchestrA.Grpc.Contract.Status; + +namespace AVEVA.Historian.Client.Grpc; + +/// +/// 2023 R2 gRPC status client (roadmap item R0.3). Mirrors +/// over the gRPC transport: it opens an authenticated +/// History session via and queries the StatusService for the +/// resulting client handle. GetSystemParameter carries the parameter name as a protobuf +/// string and returns the value string directly — there is no opaque native buffer to decode. +/// +internal static class HistorianGrpcStatusClient +{ + public static Task 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; + } +} diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index 05e68b3..277b362 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -25,7 +25,9 @@ public sealed class HistorianClient : IAsyncDisposable public async Task 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 ReadRawAsync( diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index 8f9a2fd..98c5294 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -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); } private static async IAsyncEnumerable Missing( diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs index e4760a1..fcec618 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs @@ -33,7 +33,7 @@ internal static class HistorianNativeHandshake /// upper-case context-key GUID, is the AVEVA-wrapped SSPI /// token (round byte + length + token). The WCF path maps this to /// Hist.ValidateClientCredential; the gRPC path maps it to - /// HistoryService.ExchangeKey (the renamed handshake op). + /// StorageService.ValidateClientCredential (the op that kept the 2020 token framing). /// 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; } + + /// + /// 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. + /// + 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 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)}\""; + } } diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs index 7958f28..fee9f3f 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs @@ -11,7 +11,7 @@ namespace AVEVA.Historian.Client.Tests; /// credentials (live-confirmed against a real 2023 R2 server, 2026-06-21). An earlier revision /// routed the loop to ExchangeKey; this test fails if that regression returns. /// -/// It works by disassembling the IL of HistorianGrpcReadOrchestrator (and its +/// 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. /// @@ -20,8 +20,10 @@ 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.HistorianGrpcReadOrchestrator"); + "AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake"); Assert.Contains("ValidateClientCredential", calledMethods); Assert.DoesNotContain("ExchangeKey", calledMethods); diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index dcd71d6..6cbafa1 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -12,6 +12,21 @@ namespace AVEVA.Historian.Client.Tests; /// 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,20 @@ 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)); + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs index a67de94..ca62a4c 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs @@ -100,6 +100,41 @@ 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 OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged() { From b0703ebf8087e411e1114aeceb81d96450ea1b0f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 14:29:12 -0400 Subject: [PATCH 3/8] docs: R0.3 live-verified; correct the auth-blocker note (harness quote bug) R0.3 system-param over gRPC is now LIVE-VERIFIED against the real 2023 R2 server (returned HistorianVersion), alongside the re-confirmed read chain and probe. The apparent NTLM round-1 SEC_E_LOGON_DENIED "blocker" was a test-harness credential-parsing bug, not a server/account/SDK issue: the gitignored creds file stores quoted values and the env-setup must strip surrounding quotes before exporting HISTORIAN_USER/PASSWORD. With quotes stripped, the NAM domain account authenticates and the full chain passes. The round-failure diagnostic added during the hunt (HistorianNativeHandshake.DescribeError) is kept. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- docs/plans/hcal-roadmap.md | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index f0b5b4b..37a1d0b 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -25,23 +25,18 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from - ✅ **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 - (request/response field mapping pinned). **Live-verification pending an auth fix** — see blocker. - Code path is the proven handshake + a single string-in/string-out RPC. +- ✅ **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. -> ⚠️ **Auth blocker (2026-06-21):** live gRPC ops needing a client handle (R0.1/R0.2/R0.3 and the -> read chain) fail at NTLM **round 1**. The decoded server error is -> `SEC_E_LOGON_DENIED` (0x8009030C) from `aahClientAccessPoint::CServerContext::ProcessClient…` — -> round 0 (NEGOTIATE) succeeds, round 1 (the password-bearing AUTHENTICATE) is denied. The token -> **framing is correct** (a framing fault would surface as `SEC_E_INVALID_TOKEN`); the server parsed -> a valid NTLM message but the credential did not validate. The creds in the gitignored file are -> byte-faithfully parsed (verified) and the domain user logs in via **RDP (Kerberos)**, so the -> probable cause is one of: (a) the file password is **stale** vs the account, or (b) the NAM domain -> **restricts NTLM** ("Network security: Restrict NTLM") — Kerberos/RDP works but NTLM is denied, and -> over the SOCKS/SSH tunnel (host→127.0.0.1, no SPN) the client cannot use Kerberos. Probe (R0.4) is -> unaffected (unauthenticated). Diagnostic: the round-failure exception now decodes the native error -> + a hex/ASCII preview (`HistorianNativeHandshake.DescribeError`). +> ℹ️ **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.1–R0.4) can be built and From 0e19adae68820cfd935362b4f75b1d175229c500 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 14:35:52 -0400 Subject: [PATCH 4/8] gRPC M0 R0.2: tag metadata over gRPC (GetTagInfosFromName, live-verified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes HistorianClient.GetTagMetadataAsync over gRPC when Transport==RemoteGrpc, via the new Grpc/HistorianGrpcTagClient calling RetrievalService.GetTagInfosFromName (the plural string-handle metadata op). - String handle = the Open2 storage-session GUID formatted uppercase (the format that resolves the native string-handle path); threaded out of the shared handshake via a new HistorianGrpcHandshake.Session { ClientHandle, StorageSessionId, StringHandle }. - Request btTagNames = uint count + per-name(uint charCount + UTF-16LE) — golden-byte unit-tested (BuildTagNamesBuffer). - Response btTagInfos = uint count + CTagMetadata records — decoded by the existing HistorianTagQueryProtocol.ParseGetTagInfoResponse; data type via the shared MapDataType. The 2020 WCF string-handle wall does NOT apply on the gRPC front door, as the string-handle-wall RE note predicted. LIVE-VERIFIED against a real 2023 R2 server: GetTagMetadataAsync returns the requested tag with a valid decoded data type. 216 unit tests pass. Captured framing confirmed live then discarded; no tag names or identities committed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- docs/plans/hcal-roadmap.md | 7 ++ .../Grpc/HistorianGrpcHandshake.cs | 24 +++- .../Grpc/HistorianGrpcTagClient.cs | 110 ++++++++++++++++++ src/AVEVA.Historian.Client/HistorianClient.cs | 4 +- .../HistorianGrpcIntegrationTests.cs | 20 ++++ .../HistorianGrpcTransportTests.cs | 17 +++ 6 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index 37a1d0b..685aa10 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -29,6 +29,13 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from (`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. > ℹ️ **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 diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs index a65ed1c..787adf0 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs @@ -20,10 +20,30 @@ namespace AVEVA.Historian.Client.Grpc; /// internal static class HistorianGrpcHandshake { + /// + /// The handles produced by a successful OpenConnection. is the + /// transient uint session token used by StartQuery/GetSystemParameter and the other + /// uint-handle ops. is the storage-session GUID used (formatted + /// uppercase via ) by the string-handle ops + /// (GetTagInfosFromName, GetTagExtendedPropertiesFromName, ExecuteSqlCommand, ...). + /// + internal readonly record struct Session(uint ClientHandle, Guid StorageSessionId) + { + /// The storage GUID in the uppercase "D" form the native string-handle ops require. + public string StringHandle => StorageSessionId.ToString("D").ToUpperInvariant(); + } + + /// Convenience overload for callers that only need the uint client handle. 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); @@ -68,7 +88,7 @@ internal static class HistorianGrpcHandshake throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length})."); } - (uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); - return clientHandle; + (uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); + return new Session(clientHandle, storageSessionId); } } diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs new file mode 100644 index 0000000..83433bf --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -0,0 +1,110 @@ +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; + +/// +/// 2023 R2 gRPC tag-metadata client (roadmap item R0.2). Unlike the WCF singular +/// GetTagInfoFromName (a uint-handle op), the gRPC front door exposes the plural +/// RetrievalService.GetTagInfosFromName — a string-handle op keyed off the Open2 +/// storage-session GUID (uppercase). The request btTagNames buffer and response +/// btTagInfos buffer carry the proven native encodings: +/// +/// request btTagNames = uint count + per-name(uint charCount + UTF-16LE) +/// response btTagInfos = uint tagCount + per-tag CTagMetadata record +/// (the same record decodes) +/// +/// 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 +/// docs/reverse-engineering/wcf-string-handle-wall.md. +/// +internal static class HistorianGrpcTagClient +{ + public static Task 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 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); + } + + /// + /// Issues a single GetTagInfosFromName call and returns the raw native btTagInfos + /// response buffer. Internal so reverse-engineering probes can capture the framing. + /// + internal static byte[] GetTagInfosRaw(HistorianClientOptions options, IReadOnlyList 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() ?? []; + } + + /// Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE). + internal static byte[] BuildTagNamesBuffer(IReadOnlyList 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(); + } +} diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index 277b362..7b5513a 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -104,7 +104,9 @@ public sealed class HistorianClient : IAsyncDisposable public Task 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 GetConnectionStatusAsync(CancellationToken cancellationToken = default) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 6cbafa1..a140dfe 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -66,6 +66,26 @@ public sealed class HistorianGrpcIntegrationTests 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)); + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs index ca62a4c..5c2e771 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs @@ -135,6 +135,23 @@ public sealed class HistorianGrpcTransportTests 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 OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged() { From 26ef5e5645eecc6b9040148180eef5df6356a47f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 14:58:12 -0400 Subject: [PATCH 5/8] R0.1 browse probe: StartTagQuery over gRPC takes an OData filter (live) Probes the 2023 R2 gRPC browse path and records the finding. The front door does NOT hit the 2020 WCF metadata-server-pipe wall. - RetrievalService.StartTagQuery is cracked: the server (CMdServer::StartActiveTagnamesQuery over \.\pipe\aahMetadataServer\console) parses the filter as OData. startswith()/ contains()/eq/empty succeed and return the 8-byte (queryHandle, tagCount); SQL-LIKE "%" and glob "*" fail with "ODataFilter: bad token". Live: 220 Sys* tags counted. - QueryTag (paging) remains: every guessed btRequest returns a constant native error type 4 / code 72 (content-independent) -> framing needs a native capture, not guessing. Adds RE probe helpers Grpc/HistorianGrpcTagClient.ProbeStartTagQuery + ProbeTagQuerySequence, a gated StartTagQuery_OverGrpc_AcceptsODataFilter test, and the finding doc docs/reverse-engineering/grpc-tag-query-odata.md. Browse is not yet wired (QueryTag open). 217 unit tests pass; 5/5 live gRPC tests pass. No tag names/identities committed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- docs/plans/hcal-roadmap.md | 8 ++ .../grpc-tag-query-odata.md | 42 +++++++++ .../Grpc/HistorianGrpcTagClient.cs | 91 +++++++++++++++++++ .../HistorianGrpcIntegrationTests.cs | 24 +++++ 4 files changed, 165 insertions(+) create mode 100644 docs/reverse-engineering/grpc-tag-query-odata.md diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index 685aa10..8df028c 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -36,6 +36,14 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from 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** — PARTIAL. Probed live 2026-06-21: the 2023 R2 gRPC front door does + **not** hit the 2020 metadata-server-pipe wall. `RetrievalService.StartTagQuery` is **cracked** — it + parses the filter as **OData** (`startswith(TagName,'Sys')`/`contains`/`eq`/empty succeed, returning + the 8-byte `(queryHandle, tagCount)`; SQL-LIKE `%`/glob `*` → `ODataFilter: bad token`). Live: 220 + Sys* tags counted. **Remaining:** the `QueryTag` paging request format (constant native error + type 4 / code 72 across guessed `btRequest` shapes) — needs a native capture, not guessing. Probe + helpers (`HistorianGrpcTagClient.ProbeStartTagQuery`/`ProbeTagQuerySequence`) + a gated StartTagQuery + test are committed. Full finding: `docs/reverse-engineering/grpc-tag-query-odata.md`. > ℹ️ **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 diff --git a/docs/reverse-engineering/grpc-tag-query-odata.md b/docs/reverse-engineering/grpc-tag-query-odata.md new file mode 100644 index 0000000..1650f6a --- /dev/null +++ b/docs/reverse-engineering/grpc-tag-query-odata.md @@ -0,0 +1,42 @@ +# 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 — OPEN (one capture away) + +`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that should return the actual +tag-name rows. Every `btRequest` shape tried returns a constant native error **type 4 / code 72** +(independent of content: empty, count, column-name historian-string, `$select=TagName`, +marker+version+name all give the same `04 48000000`). The constant code regardless of input means the +request *framing* is wrong, not the field values — this needs a **native capture** of the real 2023 R2 +client driving a browse to recover the exact `QueryTag` `btRequest` (and the row framing in +`btResonse`). Do not ship a guessed QueryTag request (project discipline: no guessed wire bytes). + +Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`) +and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result. diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs index 83433bf..487de4c 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -89,6 +89,97 @@ internal static class HistorianGrpcTagClient return response.BtTagInfos?.ToByteArray() ?? []; } + /// Outcome of a StartTagQuery probe — whether the gRPC front door accepts the op. + internal readonly record struct StartTagQueryProbeResult(bool Success, int ResponseLength, byte[] Response, int ErrorLength, byte[] Error); + + /// + /// Reverse-engineering probe for R0.1 (browse): drives RetrievalService.StartTagQuery with + /// the native filter request and reports whether the 2023 R2 front door accepts it (on 2020 WCF this + /// op fails server-side on the aahMetadataServer pipe regardless of handle format). + /// + internal static StartTagQueryProbeResult ProbeStartTagQuery(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); + byte[] requestBuffer = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(filter).RequestBuffer; + GrpcRetrieval.StartTagQueryResponse response = retrievalClient.StartTagQuery( + new GrpcRetrieval.StartTagQueryRequest + { + StrHandle = session.StringHandle, + BtRequest = ByteString.CopyFrom(requestBuffer) + }, + connection.Metadata, + DateTime.UtcNow.Add(options.RequestTimeout), + cancellationToken); + + bool success = response.Status?.BSuccess ?? false; + byte[] resp = response.BtResponse?.ToByteArray() ?? []; + byte[] error = response.Status?.BtError?.ToByteArray() ?? []; + return new StartTagQueryProbeResult(success, resp.Length, resp, error.Length, error); + } + + /// Outcome of a full StartTagQuery → QueryTag → EndTagQuery probe. + internal readonly record struct TagQuerySequenceResult( + bool StartSuccess, uint QueryHandle, uint TagCount, + bool QuerySuccess, byte[] QueryResponse, byte[] QueryError); + + /// + /// Reverse-engineering probe for R0.1 (browse): runs the full StartTagQuery (OData filter) → + /// QueryTag (paging) → EndTagQuery sequence and returns the raw QueryTag response buffer so its + /// tag-name framing can be discovered. is the candidate + /// QueryTag btRequest buffer (format unknown — swept by the probe). + /// + internal static TagQuerySequenceResult ProbeTagQuerySequence( + HistorianClientOptions options, string odataFilter, byte[] queryRequest, 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(odataFilter).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)) + { + return new TagQuerySequenceResult(false, 0, 0, false, [], start.Status?.BtError?.ToByteArray() ?? []); + } + + byte[] startResp = start.BtResponse?.ToByteArray() ?? []; + HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(startResp); + + try + { + GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag( + new GrpcRetrieval.QueryTagRequest + { + StrHandle = session.StringHandle, + UiQueryHandle = parsed.QueryHandle, + BtRequest = ByteString.CopyFrom(queryRequest) + }, + connection.Metadata, Deadline(), cancellationToken); + + return new TagQuerySequenceResult( + true, parsed.QueryHandle, parsed.TagCount, + query.Status?.BSuccess ?? false, + query.BtResonse?.ToByteArray() ?? [], + query.Status?.BtError?.ToByteArray() ?? []); + } + finally + { + try + { + retrievalClient.EndTagQuery( + new GrpcRetrieval.EndTagQueryRequest { StrHandle = session.StringHandle, UiQueryHandle = parsed.QueryHandle }, + connection.Metadata, Deadline(), CancellationToken.None); + } + catch { /* best-effort cleanup */ } + } + } + /// Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE). internal static byte[] BuildTagNamesBuffer(IReadOnlyList tags) { diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index a140dfe..0015e87 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -86,6 +86,30 @@ public sealed class HistorianGrpcIntegrationTests Assert.True(Enum.IsDefined(metadata.DataType)); } + [Fact] + public async Task StartTagQuery_OverGrpc_AcceptsODataFilter() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + return; + } + + // R0.1 finding (2026-06-21): on the 2023 R2 gRPC front door the metadata-server pipe IS + // reachable (unlike 2020 WCF) and StartActiveTagnamesQuery parses the filter as OData — + // startswith/contains/eq succeed; SQL-LIKE "%"/glob "*" fail with "ODataFilter: bad token". + // StartTagQuery returns the 8-byte (queryHandle, tagCount) response. The follow-on QueryTag + // paging request format is not yet captured (see roadmap R0.1), so browse is not yet wired. + HistorianClientOptions options = BuildOptions(host); + var result = await Task.Run(() => + AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.ProbeStartTagQuery(options, "startswith(TagName,'Sys')", CancellationToken.None)); + + Assert.True(result.Success, + $"StartTagQuery(OData) should succeed; errLen={result.ErrorLength} " + + $"err=\"{System.Text.Encoding.ASCII.GetString(result.Error).Replace('\0', '.')}\""); + Assert.Equal(8, result.ResponseLength); // (queryHandle, tagCount) + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); From 4c9f0d476c51c163b2a817d795538834067f9c57 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 15:16:19 -0400 Subject: [PATCH 6/8] docs: QueryTag error = InvalidPacketId (72); needs native aahClient.dll RE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deepened the R0.1 browse finding. QueryTag's constant rejection decodes to ArchestrA.CloudHistorian.Contract.ErrorCode.InvalidPacketId (72): the btRequest needs a QueryTag-specific packet-id header (the generic 0x6751/v1 header StartTagQuery accepts is rejected). The semantic fields are known from CloudHistorian.Contract (QueryHandle/QueryType/StartIndex/TagCount request; TagNames[]+TagMetadataBuffer response), but the binary packet framing lives in native aahClient.dll — aahClientManaged.dll is mixed-mode (ilspycmd cannot decompile it) and no managed assembly builds the buffer. Finishing QueryTag needs native RE (Ghidra/IDA) or a live gRPC capture of the stock client. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../grpc-tag-query-odata.md | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/reverse-engineering/grpc-tag-query-odata.md b/docs/reverse-engineering/grpc-tag-query-odata.md index 1650f6a..26c18db 100644 --- a/docs/reverse-engineering/grpc-tag-query-odata.md +++ b/docs/reverse-engineering/grpc-tag-query-odata.md @@ -28,15 +28,26 @@ Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)` `*` → empty, `Pre*` → `startswith(TagName,'Pre')`, `*sub*` → `contains(TagName,'sub')`, exact → `TagName eq '...'`. (Escaping single-quotes in names still TBD.) -## QueryTag — OPEN (one capture away) +## QueryTag — OPEN (needs native RE of aahClient.dll) -`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that should return the actual -tag-name rows. Every `btRequest` shape tried returns a constant native error **type 4 / code 72** -(independent of content: empty, count, column-name historian-string, `$select=TagName`, -marker+version+name all give the same `04 48000000`). The constant code regardless of input means the -request *framing* is wrong, not the field values — this needs a **native capture** of the real 2023 R2 -client driving a browse to recover the exact `QueryTag` `btRequest` (and the row framing in -`btResonse`). Do not ship a guessed QueryTag request (project discipline: no guessed wire bytes). +`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that returns the tag-name rows. +Every `btRequest` shape tried returns the constant native error **type 4 / code 72 = +`InvalidPacketId`** (`ArchestrA.CloudHistorian.Contract.ErrorCode.InvalidPacketId = 72`; the empty +buffer alone gives a different code). So the `btRequest` must carry a **packet-id header specific to +QueryTag** that we don't have — the generic `0x6751`/version-1 header (which StartTagQuery accepts) is +rejected here. + +**Semantic fields are known** from `ArchestrA.CloudHistorian.Contract`: +- request `QueryTagRequest` = `QueryHandle:uint("q") + QueryType:ushort("t") + StartIndex:uint("s") + TagCount:uint("c")` +- response `QueryTagResponse` = `QueryHandle:uint + TagNames:string[]("t") + NextIndex:uint("i") + TagMetadataBuffer:byte[]("tb")` + — so QueryTag returns the names directly (plus an optional metadata buffer), and pages via StartIndex/NextIndex. + +What's missing is the **binary `btRequest` packet framing** (the QueryTag packet id + how those fields +are laid out). That serializer lives in **native `aahClient.dll`** — `aahClientManaged.dll` is +**mixed-mode (C++/CLI)** so ilspycmd cannot decompile it, and no managed assembly builds the buffer. +Completing QueryTag therefore requires **native RE (Ghidra/IDA on `aahClient.dll`)** or a **live gRPC +capture** of the stock 2023 R2 client browsing. Do not ship a guessed QueryTag request (project +discipline: no guessed wire bytes). Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`) and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result. From 630295bd18bc6e2479075804aee719c624270724 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 15:46:54 -0400 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20QueryTag=20native-RE=20attempt=20?= =?UTF-8?q?=E2=80=94=20lightweight=20tooling=20insufficient,=20needs=20Ghi?= =?UTF-8?q?dra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recorded the native-disassembly attempt on aahClientManaged.dll (mixed-mode): ilspycmd cannot decompile it; capstone byte-search can't locate the StartTagQuery 0x6751 marker (not a plain immediate — it's an .rdata constant loaded RIP-relative, the .text "51 67 00 00" hits are coincidental jump-table data). Managed metadata gives QueryTag field semantics but not the binary packet-id. Finishing QueryTag needs Ghidra/IDA xref analysis or the live IL-rewrite capture. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../grpc-tag-query-odata.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/reverse-engineering/grpc-tag-query-odata.md b/docs/reverse-engineering/grpc-tag-query-odata.md index 26c18db..b86a97e 100644 --- a/docs/reverse-engineering/grpc-tag-query-odata.md +++ b/docs/reverse-engineering/grpc-tag-query-odata.md @@ -51,3 +51,22 @@ discipline: no guessed wire bytes). Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`) and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result. + +### Native-RE attempt (2026-06-21) — needs Ghidra/IDA + +The QueryTag packet-id is built in native code (the C++ HCAL inside the **mixed-mode** +`aahClientManaged.dll`). Lightweight tooling was exhausted and is insufficient: +- **ilspycmd** cannot decompile or even list the mixed-mode assembly (throws). +- **capstone** byte-search: the StartTagQuery marker `0x6751` is **not** a plain `mov` immediate in + `.text` (no `66 C7 … 51 67` and no clean `B8 51 67 00 00`); the three `51 67 00 00` hits in `.text` + are coincidental jump-table data (disassembly around them is garbage). The constant lives in the + `.rdata` pool (which holds the `51 67` bytes) and is loaded RIP-relative — so the serializer can't be + found by immediate-scanning without cross-reference analysis. +- Managed metadata (`ArchestrA.CloudHistorian.Contract`) gives the field semantics — + `QueryTagRequest{SessionId, QueryHandle:uint, QueryType:ushort, StartIndex:uint, TagCount:uint}`, + DataContract code `QTLQ` (StartTagQuery = `TLQR`) — but **not** the binary packet-id/framing. + +Finishing QueryTag therefore requires **Ghidra/IDA** (decompiler + xref to the `.rdata` marker constant +to find the serializer and read the QueryTag packet-id + byte layout), or the **live capture** option +(IL-rewrite `Archestra.Historian.GrpcClient.QueryTag` to log `requestBuffer` while a real 2023 R2 +client browses). StartTagQuery (the hard OData part) remains cracked and live-verified. From 85ff1b48df163bcf34b66d440364df11626096bf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 16:01:15 -0400 Subject: [PATCH 8/8] =?UTF-8?q?R0.1=20browse=20over=20gRPC=20SHIPPED=20?= =?UTF-8?q?=E2=80=94=20QueryTag=20cracked,=20M0=20gRPC=20parity=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires HistorianClient.BrowseTagNamesAsync over gRPC (Transport==RemoteGrpc) via Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync: StartTagQuery(OData) -> paged QueryTag -> EndTagQuery. Live-verified against a real 2023 R2 server (returns Sys* tags). QueryTag packet-id recovered WITHOUT native disassembly: a .rdata packet-descriptor table in aahClientManaged.dll lists {0x6751,1}=StartTagQuery immediately followed by {0x6752,1}=QueryTag (found via pefile byte-scan of .rdata), confirmed live. Wire format (live-verified): - request btRequest = u16 0x6752 + u16 version(1) + u16 queryType(1=names) + u32 startIndex + u32 count - response btResonse = u32 count + per-name(u32 charCount + UTF-16LE) + trailer (NextIndex/metadata, ignored) - new HistorianTagQueryProtocol.ParseTagNameQueryPage tolerates the trailer - GlobToODataFilter translates the SDK glob filter to OData (Pre*->startswith, *suf->endswith, *mid*->contains, exact->eq); the 2023 R2 metadata-server parses filters as OData. Replaces the earlier RE probe helpers with the shipped browse path. Adds golden-byte (BuildQueryTagRequest) + 8 glob-translation unit tests + gated live browse test. 226 unit tests pass; 5/5 live gRPC tests pass (read, probe, system-param, metadata, browse). Milestone 0 (full gRPC parity) is complete. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- docs/plans/hcal-roadmap.md | 21 +- .../grpc-tag-query-odata.md | 59 ++---- .../Grpc/HistorianGrpcTagClient.cs | 194 +++++++++++++----- src/AVEVA.Historian.Client/HistorianClient.cs | 4 +- .../Wcf/HistorianTagQueryProtocol.cs | 28 +++ .../HistorianGrpcIntegrationTests.cs | 23 +-- .../HistorianGrpcTransportTests.cs | 30 +++ 7 files changed, 244 insertions(+), 115 deletions(-) diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index 8df028c..cb99003 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -36,14 +36,19 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from 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** — PARTIAL. Probed live 2026-06-21: the 2023 R2 gRPC front door does - **not** hit the 2020 metadata-server-pipe wall. `RetrievalService.StartTagQuery` is **cracked** — it - parses the filter as **OData** (`startswith(TagName,'Sys')`/`contains`/`eq`/empty succeed, returning - the 8-byte `(queryHandle, tagCount)`; SQL-LIKE `%`/glob `*` → `ODataFilter: bad token`). Live: 220 - Sys* tags counted. **Remaining:** the `QueryTag` paging request format (constant native error - type 4 / code 72 across guessed `btRequest` shapes) — needs a native capture, not guessing. Probe - helpers (`HistorianGrpcTagClient.ProbeStartTagQuery`/`ProbeTagQuerySequence`) + a gated StartTagQuery - test are committed. Full finding: `docs/reverse-engineering/grpc-tag-query-odata.md`. +- ✅ **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 diff --git a/docs/reverse-engineering/grpc-tag-query-odata.md b/docs/reverse-engineering/grpc-tag-query-odata.md index b86a97e..4fc28c8 100644 --- a/docs/reverse-engineering/grpc-tag-query-odata.md +++ b/docs/reverse-engineering/grpc-tag-query-odata.md @@ -28,45 +28,30 @@ Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)` `*` → empty, `Pre*` → `startswith(TagName,'Pre')`, `*sub*` → `contains(TagName,'sub')`, exact → `TagName eq '...'`. (Escaping single-quotes in names still TBD.) -## QueryTag — OPEN (needs native RE of aahClient.dll) +## QueryTag — CRACKED (2026-06-21), browse SHIPPED `QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that returns the tag-name rows. -Every `btRequest` shape tried returns the constant native error **type 4 / code 72 = -`InvalidPacketId`** (`ArchestrA.CloudHistorian.Contract.ErrorCode.InvalidPacketId = 72`; the empty -buffer alone gives a different code). So the `btRequest` must carry a **packet-id header specific to -QueryTag** that we don't have — the generic `0x6751`/version-1 header (which StartTagQuery accepts) is -rejected here. +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. -**Semantic fields are known** from `ArchestrA.CloudHistorian.Contract`: -- request `QueryTagRequest` = `QueryHandle:uint("q") + QueryType:ushort("t") + StartIndex:uint("s") + TagCount:uint("c")` -- response `QueryTagResponse` = `QueryHandle:uint + TagNames:string[]("t") + NextIndex:uint("i") + TagMetadataBuffer:byte[]("tb")` - — so QueryTag returns the names directly (plus an optional metadata buffer), and pages via StartIndex/NextIndex. +**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. -What's missing is the **binary `btRequest` packet framing** (the QueryTag packet id + how those fields -are laid out). That serializer lives in **native `aahClient.dll`** — `aahClientManaged.dll` is -**mixed-mode (C++/CLI)** so ilspycmd cannot decompile it, and no managed assembly builds the buffer. -Completing QueryTag therefore requires **native RE (Ghidra/IDA on `aahClient.dll`)** or a **live gRPC -capture** of the stock 2023 R2 client browsing. Do not ship a guessed QueryTag request (project -discipline: no guessed wire bytes). +**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`). -Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`) -and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result. - -### Native-RE attempt (2026-06-21) — needs Ghidra/IDA - -The QueryTag packet-id is built in native code (the C++ HCAL inside the **mixed-mode** -`aahClientManaged.dll`). Lightweight tooling was exhausted and is insufficient: -- **ilspycmd** cannot decompile or even list the mixed-mode assembly (throws). -- **capstone** byte-search: the StartTagQuery marker `0x6751` is **not** a plain `mov` immediate in - `.text` (no `66 C7 … 51 67` and no clean `B8 51 67 00 00`); the three `51 67 00 00` hits in `.text` - are coincidental jump-table data (disassembly around them is garbage). The constant lives in the - `.rdata` pool (which holds the `51 67` bytes) and is loaded RIP-relative — so the serializer can't be - found by immediate-scanning without cross-reference analysis. -- Managed metadata (`ArchestrA.CloudHistorian.Contract`) gives the field semantics — - `QueryTagRequest{SessionId, QueryHandle:uint, QueryType:ushort, StartIndex:uint, TagCount:uint}`, - DataContract code `QTLQ` (StartTagQuery = `TLQR`) — but **not** the binary packet-id/framing. - -Finishing QueryTag therefore requires **Ghidra/IDA** (decompiler + xref to the `.rdata` marker constant -to find the serializer and read the QueryTag packet-id + byte layout), or the **live capture** option -(IL-rewrite `Archestra.Historian.GrpcClient.QueryTag` to log `requestBuffer` while a real 2023 R2 -client browses). StartTagQuery (the hard OData part) remains cracked and live-verified. +**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.** diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs index 487de4c..7600d80 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -8,7 +8,10 @@ using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval; namespace AVEVA.Historian.Client.Grpc; /// -/// 2023 R2 gRPC tag-metadata client (roadmap item R0.2). Unlike the WCF singular +/// 2023 R2 gRPC tag-metadata + browse client (roadmap items R0.2 metadata, R0.1 browse). +/// Browse drives StartTagQuery (OData filter) → paged QueryTagEndTagQuery +/// (see and docs/reverse-engineering/grpc-tag-query-odata.md). +/// Unlike the WCF singular /// GetTagInfoFromName (a uint-handle op), the gRPC front door exposes the plural /// RetrievalService.GetTagInfosFromName — a string-handle op keyed off the Open2 /// storage-session GUID (uppercase). The request btTagNames buffer and response @@ -89,84 +92,84 @@ internal static class HistorianGrpcTagClient return response.BtTagInfos?.ToByteArray() ?? []; } - /// Outcome of a StartTagQuery probe — whether the gRPC front door accepts the op. - internal readonly record struct StartTagQueryProbeResult(bool Success, int ResponseLength, byte[] Response, int ErrorLength, byte[] Error); + // 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; /// - /// Reverse-engineering probe for R0.1 (browse): drives RetrievalService.StartTagQuery with - /// the native filter request and reports whether the 2023 R2 front door accepts it (on 2020 WCF this - /// op fails server-side on the aahMetadataServer pipe regardless of handle format). + /// Browses tag names over gRPC (roadmap item R0.1). Drives + /// StartTagQuery (OData filter) → paged QueryTagEndTagQuery on the + /// RetrievalService. The 2023 R2 metadata-server parses the filter as OData, so the SDK's + /// glob filter is translated via . Each QueryTag page returns + /// uint count + per-name(uint charCount + UTF-16LE), decoded by + /// . /// - internal static StartTagQueryProbeResult ProbeStartTagQuery(HistorianClientOptions options, string filter, CancellationToken cancellationToken) + public static async IAsyncEnumerable BrowseTagNamesAsync( + HistorianClientOptions options, + string filter, + [System.Runtime.CompilerServices.EnumeratorCancellation] 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 = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(filter).RequestBuffer; - GrpcRetrieval.StartTagQueryResponse response = retrievalClient.StartTagQuery( - new GrpcRetrieval.StartTagQueryRequest - { - StrHandle = session.StringHandle, - BtRequest = ByteString.CopyFrom(requestBuffer) - }, - connection.Metadata, - DateTime.UtcNow.Add(options.RequestTimeout), - cancellationToken); - - bool success = response.Status?.BSuccess ?? false; - byte[] resp = response.BtResponse?.ToByteArray() ?? []; - byte[] error = response.Status?.BtError?.ToByteArray() ?? []; - return new StartTagQueryProbeResult(success, resp.Length, resp, error.Length, error); + IReadOnlyList names = await Task.Run(() => BrowseTagNames(options, filter, cancellationToken), cancellationToken).ConfigureAwait(false); + foreach (string name in names) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return name; + } } - /// Outcome of a full StartTagQuery → QueryTag → EndTagQuery probe. - internal readonly record struct TagQuerySequenceResult( - bool StartSuccess, uint QueryHandle, uint TagCount, - bool QuerySuccess, byte[] QueryResponse, byte[] QueryError); - - /// - /// Reverse-engineering probe for R0.1 (browse): runs the full StartTagQuery (OData filter) → - /// QueryTag (paging) → EndTagQuery sequence and returns the raw QueryTag response buffer so its - /// tag-name framing can be discovered. is the candidate - /// QueryTag btRequest buffer (format unknown — swept by the probe). - /// - internal static TagQuerySequenceResult ProbeTagQuerySequence( - HistorianClientOptions options, string odataFilter, byte[] queryRequest, CancellationToken cancellationToken) + private static List 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(odataFilter).RequestBuffer; + 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)) { - return new TagQuerySequenceResult(false, 0, 0, false, [], start.Status?.BtError?.ToByteArray() ?? []); + byte[] error = start.Status?.BtError?.ToByteArray() ?? []; + throw new InvalidOperationException($"gRPC StartTagQuery failed (errorLen={error.Length})."); } - byte[] startResp = start.BtResponse?.ToByteArray() ?? []; - HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(startResp); - + HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(start.BtResponse?.ToByteArray() ?? []); + List names = new(checked((int)parsed.TagCount)); try { - GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag( - new GrpcRetrieval.QueryTagRequest + 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)) { - StrHandle = session.StringHandle, - UiQueryHandle = parsed.QueryHandle, - BtRequest = ByteString.CopyFrom(queryRequest) - }, - connection.Metadata, Deadline(), cancellationToken); + byte[] error = query.Status?.BtError?.ToByteArray() ?? []; + throw new InvalidOperationException($"gRPC QueryTag failed (errorLen={error.Length})."); + } - return new TagQuerySequenceResult( - true, parsed.QueryHandle, parsed.TagCount, - query.Status?.BSuccess ?? false, - query.BtResonse?.ToByteArray() ?? [], - query.Status?.BtError?.ToByteArray() ?? []); + IReadOnlyList pageNames = HistorianTagQueryProtocol.ParseTagNameQueryPage(query.BtResonse?.ToByteArray() ?? []); + if (pageNames.Count == 0) + { + break; + } + + names.AddRange(pageNames); + startIndex += (uint)pageNames.Count; + } } finally { @@ -178,6 +181,85 @@ internal static class HistorianGrpcTagClient } catch { /* best-effort cleanup */ } } + + return names; + } + + /// Builds the QueryTag paging request: u16 marker(0x6752) + u16 version + u16 queryType + u32 startIndex + u32 count. + 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(); + } + + /// + /// Translates the SDK's glob filter (* wildcard) into the OData filter the 2023 R2 + /// metadata-server's StartActiveTagnamesQuery expects. Single-quotes are OData-escaped. + /// + /// * / empty → no filter (all tags) + /// Pre*startswith(TagName,'Pre') + /// *sufendswith(TagName,'suf') + /// *mid*contains(TagName,'mid') + /// a*bstartswith(TagName,'a') and endswith(TagName,'b') + /// ExactTagName eq 'Exact' + /// + /// + 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 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; } /// Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE). diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index 7b5513a..2e7f829 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -98,7 +98,9 @@ public sealed class HistorianClient : IAsyncDisposable public IAsyncEnumerable 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 GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs index d14a362..d118c33 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs @@ -102,6 +102,34 @@ internal static class HistorianTagQueryProtocol return tagNames; } + /// + /// Parses one page of a gRPC QueryTag tag-name response: uint count + per-name(uint + /// charCount + UTF-16LE), then a trailing region (NextIndex + optional metadata buffer) that + /// is intentionally ignored. Unlike this tolerates the + /// trailer rather than requiring the buffer to end exactly after the names. + /// + public static IReadOnlyList ParseTagNameQueryPage(ReadOnlySpan response) + { + if (response.Length < 4) + { + return []; + } + + int cursor = 0; + uint count = ReadUInt32(response, ref cursor); + List 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); diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 0015e87..e932476 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -87,7 +87,7 @@ public sealed class HistorianGrpcIntegrationTests } [Fact] - public async Task StartTagQuery_OverGrpc_AcceptsODataFilter() + public async Task BrowseTagNamesAsync_OverGrpc_ReturnsSystemTags() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) @@ -95,19 +95,16 @@ public sealed class HistorianGrpcIntegrationTests return; } - // R0.1 finding (2026-06-21): on the 2023 R2 gRPC front door the metadata-server pipe IS - // reachable (unlike 2020 WCF) and StartActiveTagnamesQuery parses the filter as OData — - // startswith/contains/eq succeed; SQL-LIKE "%"/glob "*" fail with "ODataFilter: bad token". - // StartTagQuery returns the 8-byte (queryHandle, tagCount) response. The follow-on QueryTag - // paging request format is not yet captured (see roadmap R0.1), so browse is not yet wired. - HistorianClientOptions options = BuildOptions(host); - var result = await Task.Run(() => - AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.ProbeStartTagQuery(options, "startswith(TagName,'Sys')", CancellationToken.None)); + // Full R0.1 browse over gRPC: StartTagQuery(OData) -> paged QueryTag(0x6752) -> EndTagQuery. + HistorianClient client = new(BuildOptions(host)); + List names = []; + await foreach (string name in client.BrowseTagNamesAsync("Sys*", CancellationToken.None)) + { + names.Add(name); + } - Assert.True(result.Success, - $"StartTagQuery(OData) should succeed; errLen={result.ErrorLength} " + - $"err=\"{System.Text.Encoding.ASCII.GetString(result.Error).Replace('\0', '.')}\""); - Assert.Equal(8, result.ResponseLength); // (queryHandle, tagCount) + Assert.NotEmpty(names); + Assert.All(names, n => Assert.StartsWith("Sys", n, StringComparison.Ordinal)); } private static HistorianClientOptions BuildOptions(string host) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs index 5c2e771..f50a0d8 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs @@ -152,6 +152,36 @@ public sealed class HistorianGrpcTransportTests 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() {