diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs index d37241c..72d2d5d 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs @@ -163,7 +163,9 @@ internal sealed class HistorianGrpcReadOrchestrator Guid contextKey = Guid.NewGuid(); var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); - historyClient.GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken); + GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion( + new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken); + HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options); HistorianNativeHandshake.RunTokenRounds( (handle, wrapped, _) => @@ -210,7 +212,9 @@ internal sealed class HistorianGrpcReadOrchestrator CancellationToken cancellationToken) { var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); - retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken); + GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion( + new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken); + HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options); byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request); uint queryHandle = StartQuery(retrievalClient, clientHandle, requestBuffer, "raw", cancellationToken); @@ -260,7 +264,9 @@ internal sealed class HistorianGrpcReadOrchestrator CancellationToken cancellationToken) { var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); - retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken); + GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion( + new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken); + HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options); HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildAggregateQueryRequest(tag, startUtc, endUtc, mode, interval); byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request); diff --git a/src/AVEVA.Historian.Client/HistorianClientOptions.cs b/src/AVEVA.Historian.Client/HistorianClientOptions.cs index afd7174..23fc53e 100644 --- a/src/AVEVA.Historian.Client/HistorianClientOptions.cs +++ b/src/AVEVA.Historian.Client/HistorianClientOptions.cs @@ -62,4 +62,16 @@ public sealed class HistorianClientOptions /// true the server certificate chain is not validated. Default false. /// public bool GrpcUseTls { get; init; } + + /// + /// When true (default) the SDK verifies, at connect time, that the Historian server + /// reports the native interface versions its byte serializers were built against + /// (History=11, Retrieval=4, Transaction=2 — evidence from a live AVEVA Historian 2020 + /// server). A mismatch throws rather than + /// risk misparsing version-framed native buffers. Set false only when you have + /// independently confirmed wire compatibility with a different server version — e.g. + /// when bringing up a 2023 R2 gRPC server whose reported interface integers have not yet + /// been captured. See . + /// + public bool VerifyServerInterfaceVersion { get; init; } = true; } diff --git a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs new file mode 100644 index 0000000..aa05f3c --- /dev/null +++ b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs @@ -0,0 +1,93 @@ +namespace AVEVA.Historian.Client; + +/// +/// Identifies a versioned native Historian service interface whose reported interface +/// version is validated at connect time by . +/// +internal enum HistorianServiceInterface +{ + History, + Retrieval, + Status, + Transaction +} + +/// +/// Fail-closed check (roadmap item R0.6) that a Historian server reports the native +/// interface version this SDK's byte serializers were built against. +/// +/// The opaque native buffers carried inside the WCF/MDAS message body — and, on 2023 R2, +/// inside the gRPC bytes fields — are framed per native interface version. Parsing +/// them against an unexpected version risks silent misinterpretation, so per the +/// "version-pin, fail closed" principle this throws +/// rather than best-effort parsing. +/// +/// Supported versions are evidence-based, discovered from a live AVEVA Historian 2020 +/// server (product 20.0.000) via the reverse-engineering wcf-probe command: +/// +/// History (Hist) interface version = 11 +/// Retrieval (Retr) interface version = 4 +/// Transaction (Trx) interface version = 2 +/// +/// The Status (Stat) service's GetInterfaceVersion returns 0 (not a real +/// version), so the Status interface is validated for reachability only, never value. +/// +/// A 2023 R2 gRPC server may report different integers even though it carries the same +/// proven 2020 native buffers; until those integers are captured, point such a server at +/// this gate with set to +/// . +/// +internal static class HistorianServerVersionGate +{ + public const uint HistoryInterfaceVersion = 11; + public const uint RetrievalInterfaceVersion = 4; + public const uint TransactionInterfaceVersion = 2; + + /// + /// True when the service interface reports a meaningful version that should be matched. + /// Status is reachability-only (its GetInterfaceVersion returns 0). + /// + public static bool IsValueGated(HistorianServiceInterface service) => service switch + { + HistorianServiceInterface.History => true, + HistorianServiceInterface.Retrieval => true, + HistorianServiceInterface.Transaction => true, + HistorianServiceInterface.Status => false, + _ => false + }; + + /// The interface version this SDK's serializers target for a value-gated service. + public static uint ExpectedVersion(HistorianServiceInterface service) => service switch + { + HistorianServiceInterface.History => HistoryInterfaceVersion, + 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. + /// No-op when is + /// , when the service is not value-gated (Status), or on a match. + /// + public static void Validate(HistorianServiceInterface service, uint reportedVersion, HistorianClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (!options.VerifyServerInterfaceVersion || !IsValueGated(service)) + { + return; + } + + uint expected = ExpectedVersion(service); + if (reportedVersion == expected) + { + return; + } + + throw new ProtocolEvidenceMissingException( + $"{service} interface version {reportedVersion} (this SDK's serializers target version {expected}); " + + $"set {nameof(HistorianClientOptions)}.{nameof(HistorianClientOptions.VerifyServerInterfaceVersion)}=false to bypass at your own risk"); + } +} diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs index ed0a52e..5031b24 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs @@ -45,7 +45,8 @@ internal static class HistorianWcfAuthChainHelper ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel; try { - historyChannel.GetInterfaceVersion(out _); + historyChannel.GetInterfaceVersion(out uint historyVersion); + HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion, options); RunValClRounds(historyChannel, contextKey, options, cancellationToken); byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode); diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs index 7cb44be..2638cb6 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs @@ -187,7 +187,8 @@ internal sealed class HistorianWcfReadOrchestrator ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel; try { - retrievalChannel.GetInterfaceVersion(out _); + retrievalChannel.GetInterfaceVersion(out uint retrievalVersion); + HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion, _options); uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed); if (isAllowedReturn != 0 || !isAllowed) @@ -289,7 +290,8 @@ internal sealed class HistorianWcfReadOrchestrator ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel; try { - retrievalChannel.GetInterfaceVersion(out _); + retrievalChannel.GetInterfaceVersion(out uint retrievalVersion); + HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion, _options); uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed); if (isAllowedReturn != 0 || !isAllowed) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs new file mode 100644 index 0000000..9c3a98e --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs @@ -0,0 +1,98 @@ +namespace AVEVA.Historian.Client.Tests; + +/// +/// Unit coverage for the R0.6 connect-time interface-version gate. The supported versions +/// (History=11, Retrieval=4, Transaction=2) are evidence-based, captured from a live AVEVA +/// Historian 2020 server via the reverse-engineering wcf-probe command. +/// +public sealed class HistorianServerVersionGateTests +{ + private static HistorianClientOptions Options(bool verify = true) => new() + { + Host = "histserver", + IntegratedSecurity = true, + VerifyServerInterfaceVersion = verify + }; + + [Fact] + public void VerifyServerInterfaceVersion_DefaultsToTrue() + { + Assert.True(new HistorianClientOptions { Host = "h" }.VerifyServerInterfaceVersion); + } + + [Fact] + public void Validate_MatchingVersion_DoesNotThrow() + { + // Each value-gated service accepts exactly the version this SDK targets. + HistorianServerVersionGate.Validate(HistorianServiceInterface.History, HistorianServerVersionGate.HistoryInterfaceVersion, Options()); + HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, HistorianServerVersionGate.RetrievalInterfaceVersion, Options()); + HistorianServerVersionGate.Validate(HistorianServiceInterface.Transaction, HistorianServerVersionGate.TransactionInterfaceVersion, Options()); + } + + [Fact] + public void Validate_MismatchedVersion_ThrowsProtocolEvidenceMissing() + { + // (service, wrong version) cases — one below and one above each expected value. + (HistorianServiceInterface Service, uint Version)[] cases = + [ + (HistorianServiceInterface.History, 10u), + (HistorianServiceInterface.History, 12u), + (HistorianServiceInterface.Retrieval, 3u), + (HistorianServiceInterface.Retrieval, 5u), + (HistorianServiceInterface.Transaction, 1u), + ]; + + foreach ((HistorianServiceInterface service, uint version) in cases) + { + ProtocolEvidenceMissingException ex = Assert.Throws( + () => HistorianServerVersionGate.Validate(service, version, Options())); + + // The message must name the reported version, the expected version, and the bypass knob. + Assert.Contains(version.ToString(System.Globalization.CultureInfo.InvariantCulture), ex.Operation); + Assert.Contains(HistorianServerVersionGate.ExpectedVersion(service).ToString(System.Globalization.CultureInfo.InvariantCulture), ex.Operation); + Assert.Contains(nameof(HistorianClientOptions.VerifyServerInterfaceVersion), ex.Operation); + } + } + + [Fact] + public void Validate_VerificationDisabled_NeverThrows() + { + // A wildly wrong version is tolerated when the operator opts out (e.g. bringing up a + // 2023 R2 gRPC server whose reported integers have not yet been captured). + HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 999u, Options(verify: false)); + HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, 0u, Options(verify: false)); + } + + [Theory] + [InlineData(0u)] + [InlineData(7u)] + [InlineData(999u)] + public void Validate_StatusService_IsReachabilityOnly_NeverThrows(uint anyVersion) + { + // Status' GetInterfaceVersion returns 0 on the live server; it is not value-gated. + HistorianServerVersionGate.Validate(HistorianServiceInterface.Status, anyVersion, Options()); + Assert.False(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.Status)); + } + + [Fact] + public void IsValueGated_HistoryRetrievalTransaction_AreGated() + { + Assert.True(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.History)); + Assert.True(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.Retrieval)); + Assert.True(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.Transaction)); + } + + [Fact] + public void ExpectedVersion_Status_Throws() + { + Assert.Throws( + () => HistorianServerVersionGate.ExpectedVersion(HistorianServiceInterface.Status)); + } + + [Fact] + public void Validate_NullOptions_Throws() + { + Assert.Throws( + () => HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 11u, null!)); + } +}