fix(driver-historian-wonderware): resolve Low code-review findings (Driver.Historian.Wonderware-004,005,007,008,010,011,012)

- Driver.Historian.Wonderware-004: ToHistorianEvent synthesises a fresh
  Guid when the upstream EventId is unparseable and logs the substitution
  instead of writing the historian with Guid.Empty.
- Driver.Historian.Wonderware-005: GetHealthSnapshot derives the
  connection-open booleans from the active-node fields so the snapshot
  is self-consistent without depending on the secondary lock.
- Driver.Historian.Wonderware-007: SID-mismatch branch in PipeServer now
  sends a HelloAck { Accepted=false, RejectReason } so the client sees a
  symmetric rejection.
- Driver.Historian.Wonderware-008: classify StartQuery failures —
  connection-class codes drop the connection, query-class codes throw
  QueryClassStartQueryException so the IPC layer surfaces Success=false.
- Driver.Historian.Wonderware-010: RequestTimeoutSeconds now enforced
  via BuildRequestCts linked to the caller's CancellationToken.
- Driver.Historian.Wonderware-011: refreshed XML docs to describe the
  current sidecar / named-pipe architecture (Galaxy.Host / Proxy
  references reframed as historical context).
- Driver.Historian.Wonderware-012: pinned the previously-uncovered
  HistorianDataSource behaviours with five new test files; also removed
  the stale empty tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests
  directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 08:18:10 -04:00
parent 42aa82de29
commit 1f29b215c8
14 changed files with 910 additions and 53 deletions
@@ -21,16 +21,33 @@ public sealed class PipeServer : IDisposable
private readonly string _sharedSecret;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts = new();
private readonly CallerVerifier _verifier;
private NamedPipeServerStream? _current;
/// <summary>
/// Pluggable caller-verification seam. Default implementation calls
/// <see cref="VerifyCaller"/>; tests can substitute one that ignores the pipe ACL
/// to exercise the rejection paths.
/// </summary>
internal delegate bool CallerVerifier(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason);
public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
: this(pipeName, allowedSid, sharedSecret, logger, DefaultVerifier) { }
internal PipeServer(
string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger,
CallerVerifier verifier)
{
_pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName));
_allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid));
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
}
private static bool DefaultVerifier(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason)
=> VerifyCaller(pipe, allowedSid, out reason);
/// <summary>
/// Accepts one connection, performs Hello handshake, then dispatches frames to
/// <paramref name="handler"/> until EOF or cancel. Returns when the client disconnects.
@@ -67,8 +84,15 @@ public sealed class PipeServer : IDisposable
return;
}
if (!VerifyCaller(_current, out var reason))
if (!_verifier(_current, _allowedSid, out var reason))
{
// Driver.Historian.Wonderware-007: send a rejecting HelloAck so the client
// learns why instead of having to wait for its own read timeout. The reason
// tag "caller-sid-mismatch" is symmetric with the shared-secret-mismatch and
// major-version-mismatch acks the two other rejection paths emit below.
await writer.WriteAsync(MessageKind.HelloAck,
new HelloAck { Accepted = false, RejectReason = $"caller-sid-mismatch: {reason}" },
linked.Token).ConfigureAwait(false);
_logger.Warning("Sidecar IPC caller rejected: {Reason}", reason);
_current.Disconnect();
return;
@@ -172,7 +196,7 @@ public sealed class PipeServer : IDisposable
}
}
private bool VerifyCaller(NamedPipeServerStream pipe, out string reason)
private static bool VerifyCaller(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason)
{
try
{
@@ -181,9 +205,9 @@ public sealed class PipeServer : IDisposable
using var wi = WindowsIdentity.GetCurrent();
if (wi.User is null)
throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller");
if (wi.User != _allowedSid)
if (wi.User != allowedSid)
throw new UnauthorizedAccessException(
$"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}");
$"caller SID {wi.User.Value} does not match allowed {allowedSid.Value}");
});
reason = string.Empty;
return true;