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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-21 14:58:12 -04:00
parent 0e19adae68
commit 26ef5e5645
4 changed files with 165 additions and 0 deletions
@@ -89,6 +89,97 @@ internal static class HistorianGrpcTagClient
return response.BtTagInfos?.ToByteArray() ?? [];
}
/// <summary>Outcome of a StartTagQuery probe — whether the gRPC front door accepts the op.</summary>
internal readonly record struct StartTagQueryProbeResult(bool Success, int ResponseLength, byte[] Response, int ErrorLength, byte[] Error);
/// <summary>
/// Reverse-engineering probe for R0.1 (browse): drives <c>RetrievalService.StartTagQuery</c> 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).
/// </summary>
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);
}
/// <summary>Outcome of a full StartTagQuery → QueryTag → EndTagQuery probe.</summary>
internal readonly record struct TagQuerySequenceResult(
bool StartSuccess, uint QueryHandle, uint TagCount,
bool QuerySuccess, byte[] QueryResponse, byte[] QueryError);
/// <summary>
/// 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. <paramref name="queryRequest"/> is the candidate
/// QueryTag <c>btRequest</c> buffer (format unknown — swept by the probe).
/// </summary>
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 */ }
}
}
/// <summary>Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE).</summary>
internal static byte[] BuildTagNamesBuffer(IReadOnlyList<string> tags)
{