R1.4 GetHistorianInfo: bounded out on 2020 WCF (named-value-only, no struct)
Captured the native HistorianAccess.GetHistorianInfo(out HistorianInfo, out err) and decoded the wire: over 2020 WCF, GETHI is a named-value query whose only working key is "HistorianVersion" (response ~30 bytes = the version string). Probed 7 storage-mode key names -> all ok=False/err. The 518-byte HISTORIAN_INFO struct + EventStorageMode@514 is the 2023R2 HCAL-native/gRPC model (confirmed from the decompiled 2023R2 source); on 2020 the native client derives the mode outside the WCF wire. Version is already exposed (ProbeAsync/GetRuntimeParameterAsync), so no hollow GetHistorianInfoAsync is shipped (same disposition as R1.3 timezone). This completes the reachable 2020-WCF M1 read surface; remaining M1 = config writes (gated on explicit request) or gRPC/2023R2-only items. RE aids kept: harness `historian-info` scenario, Capture-HistorianInfo.ps1, decode-historian-info-capture.py, and StringHandleProbeDiagnosticTests .GETHI_CandidateInfoNames (asserts the named-value-only finding; gated). Docs: wcf-historian-info.md (new) + roadmap/matrix/wall-doc updates. 230 tests green. 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:
@@ -99,7 +99,7 @@ blob needs RE).
|
||||
|---|---|---|---|---|---|
|
||||
| System parameter | `GetSystemParameter` | `Status.GetSystemParameter` | ✅ | DONE | |
|
||||
| Runtime parameter | `GetRuntimeParameter` | `Status.GetRuntimeParameter` | ⬜ | TRIVIAL | same shape as GetSystemParameter |
|
||||
| Historian info | `GetHistorianInfo` | `Status.GetHistorianInfo` | 🟗 | BOUNDED | GETHI buffer; partially decoded (incl. EventStorageMode @ offset 514) |
|
||||
| Historian info | `GetHistorianInfo` | `Status.GetHistorianInfo` | 🟗 | BOUNDED | **2020 WCF = version-only** (GETHI is a named-value query; `EventStorageMode` not on the wire). 518-byte struct + `EventStorageMode`@514 is gRPC/2023R2-only. See `wcf-historian-info.md` |
|
||||
| Server timezone | `GetSystemTimeZoneInfo` | `Status.GetSystemTimeZoneName` | ⬜ | TRIVIAL | |
|
||||
| Historization status | `GetHistorizationStatus` | `Status` op | ⬜ | BOUNDED | |
|
||||
| Store-and-forward status | `GetStoreForwardStatus` | (push events / pull GETHI) | 🟗 | HARD | currently synthesized; real read needs duplex push or a decoded pull endpoint — see store-forward plan |
|
||||
|
||||
@@ -41,10 +41,14 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
|
||||
> (✅ 2026-06-20 — `ReadEventsAsync(…, HistorianEventFilter)`, live-honored). M2 event send is
|
||||
> also done (✅ WCF `AddS2`). **R1.2 `GetRuntimeParameterAsync` is also done** (✅ 2026-06-20,
|
||||
> `aa/Stat/GETRP`, live-verified) — notably a *string-handle* op that punches through the wall
|
||||
> using the Open2 storage-session GUID as an **uppercase** string handle, which is a strong lead
|
||||
> that the GETHI/ExeC failures are (at least partly) a handle-*format* issue rather than only a
|
||||
> missing native registration. **Cheap high-value follow-up: retry GETHI/ExeC with the uppercased
|
||||
> storage GUID** before assuming the registration wall (see `wcf-string-handle-wall.md` §Update).
|
||||
> using the Open2 storage-session GUID as an **uppercase** string handle, which proved the
|
||||
> GETHI/ExeC failures were a handle-*format* issue rather than a missing native registration.
|
||||
> **Follow-up done:** R1.1 `ExecuteSqlCommandAsync` shipped; R1.5 extended-property read shipped
|
||||
> (R1.6 collapsed into it — no distinct localized op). **R1.4 `GetHistorianInfo` bounded out on
|
||||
> 2020 WCF** — GETHI there is a named-value query (only `HistorianVersion`); `EventStorageMode` is
|
||||
> 2023R2-gRPC-only (see `wcf-historian-info.md`). Net: the **reachable 2020-WCF M1 read surface is
|
||||
> complete**; what remains is config *writes* (M1c — gated on an explicit user request) and the
|
||||
> gRPC/2023R2-only items (R1.3 timezone, R1.4 EventStorageMode — need a live 2023 R2 server).
|
||||
|
||||
## Guiding principles
|
||||
|
||||
@@ -106,7 +110,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
|
||||
### 1b. Bounded (decode one `bytes` payload; S–M each)
|
||||
| ID | Capability | gRPC op | Payload to decode | Depends |
|
||||
|---|---|---|---|---|
|
||||
| R1.4 | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | ✅ **REACHABLE (2026-06-20, live-probed)** via the uppercase storage GUID — `GETHI` returns data (`StringHandleProbeDiagnosticTests`). The version-keyed request returns `uint charCount + UTF-16`; the full info struct (incl. `EventStorageMode`@514) needs its own request capture. **Public API not yet shipped.** | uppercase string handle |
|
||||
| R1.4 | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | ⚠ **Bounded out on 2020 WCF (2026-06-20) — `EventStorageMode` not on the wire; version-only.** Captured the native `GetHistorianInfo(out HistorianInfo, out err)`: over 2020 WCF, `GETHI` is a **named-value query** whose only working key is `HistorianVersion` (returns `uint charCount + UTF-16` version, ~30 bytes). Probed 7 storage-mode key names → all `ok=False`/err. The 518-byte `HISTORIAN_INFO` struct + `EventStorageMode`@514 is the **2023R2 HCAL-native/gRPC** model (confirmed from the decompiled 2023R2 source); on 2020 the native client derives `EventStorageMode` **outside the WCF wire**. Version is already exposed (`ProbeAsync`/`GetRuntimeParameterAsync`), so **not shipped** here — same status as R1.3. Ship `HistorianInfo`/`HistorianEventStorageMode` on the gRPC path against a live 2023 R2 server. RE aids: `scripts/Capture-HistorianInfo.ps1`, `scripts/decode-historian-info-capture.py`, harness `historian-info` scenario, `StringHandleProbeDiagnosticTests.GETHI_CandidateInfoNames`. See `docs/reverse-engineering/wcf-historian-info.md`. | gRPC/2023R2 |
|
||||
| R1.5 | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` | 🟡 **Likely reachable** via uppercase string handle (GetTepByNm family) — not yet individually re-probed. TEP result buffer. | uppercase string handle |
|
||||
| R1.6 | Localized-property **read** | `Retrieval.GetTagLocalizedPropertiesFromName` | 🟡 **Likely reachable** (same family) — not yet re-probed. | uppercase string handle |
|
||||
| ~~R1.7~~ | Event **filters** | filter bytes in `Retrieval.StartEventQuery` | ✅ **DONE (2026-06-20), live-honored.** `ReadEventsAsync(start, end, HistorianEventFilter)`. The filter rides `StartEventQuery`'s `pRequestBuff` (captured via `EventQuery.AddEventFilter` + instrument-wcf-writemessage; Equal vs Contains diffed to isolate the op). Filter block: `ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-len-0x00 compact-ASCII) + byte 0`. **REAL, not inert** (a non-matching predicate returns 0 events; matching returns the subset). Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via `AddEventFilterCondition`) framing not yet fully captured. See `HistorianEventFilter`, golden `WcfEventQueryProtocolTests`. | — |
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# GetHistorianInfo over 2020 WCF — GETHI is named-value-only (HCAL R1.4)
|
||||
|
||||
**Status: ⚠ Bounded out on 2020 WCF (2026-06-20).** `GetHistorianInfoAsync` is **not shipped**:
|
||||
the one field that motivates it — `EventStorageMode` — is **not on the 2020 WCF wire**. The
|
||||
version field that GETHI *does* return over WCF is already exposed (`ProbeAsync`,
|
||||
`GetRuntimeParameterAsync("HistorianVersion")`), so there is nothing new to ship here without a
|
||||
2023 R2 gRPC server. This parallels R1.3 (`GetServerTimeZone`), which is likewise 2023R2-only.
|
||||
|
||||
## What the capture showed
|
||||
|
||||
`scripts/Capture-HistorianInfo.ps1` drives the native `HistorianAccess.GetHistorianInfo(out
|
||||
HistorianInfo, out error)` through the instrumented (`instrument-wcf-{write,read}message`)
|
||||
`current/aahClientManaged.dll`. The native call **succeeds** and returns
|
||||
`EventStorageMode = Blocks`, `ServerVersion = 20,0,000,000`, no error.
|
||||
|
||||
But the wire tells a different story (`scripts/decode-historian-info-capture.py`):
|
||||
|
||||
- The only `GETHI` op on the wire is **`aa/Stat/GETHI(handle, pRequestBuff)`** with
|
||||
`pRequestBuff = 53 67 02 00` (sig `0x6753` + version `2`) `+ uint charCount(16) + UTF-16
|
||||
"HistorianVersion"` — i.e. the **named-value request**, identical to the GETRP/version shape.
|
||||
- Its response `pResponseBuff` is **~30 bytes**: `uint charCount(12) + UTF-16 "20,0,000,000"`
|
||||
(+ a `02 00 01 00` trailer). **Just the version** — not a 518-byte struct.
|
||||
- The post-GETHI ops in the same capture are `Hist/UpdC3` + a run of `Stat/GetSystemParameter`
|
||||
(`AllowOriginals`, `HistorianPartner`, `HistorianVersion`, `MaxCyclicStorageTimeout`,
|
||||
`RealTimeWindow`, `FutureTimeThreshold`, `AllowRenameTags`). **None carries a storage-mode
|
||||
value.** So the native wrapper's `EventStorageMode` is derived by the C++ HCAL **outside the
|
||||
WCF wire**, not fetched over it.
|
||||
|
||||
## Probe: does GETHI expose storage mode under any name?
|
||||
|
||||
`StringHandleProbeDiagnosticTests.GETHI_CandidateInfoNames_AgainstLocalHistorian` (gated on
|
||||
`HISTORIAN_HOST=localhost`) issues GETHI for `HistorianVersion` plus seven storage-mode name
|
||||
guesses. Result on the live 2020 server:
|
||||
|
||||
| GETHI parameter name | result |
|
||||
|---|---|
|
||||
| `HistorianVersion` | **ok=True**, respLen=32 (version) |
|
||||
| `EventStorageMode`, `EventStorageType`, `StorageType`, `HistorianEventStorageMode`, `EventStorage`, `StorageMode`, `HistorianInfo` | **ok=False**, errLen=5, empty |
|
||||
|
||||
So GETHI on 2020 WCF is a strict named-value lookup with exactly one known-good key
|
||||
(`HistorianVersion`). There is no storage-mode key, no full-struct request.
|
||||
|
||||
## Why the 518-byte struct doesn't apply here
|
||||
|
||||
The 2023 R2 decompiled `ArchestrA.HistorianAccess.GetHistorianInfo` (analysis folder) allocates
|
||||
a **518-byte `HISTORIAN_INFO`** struct, pre-inits `int32 @514` to `-1`, calls native HCAL
|
||||
(vtable+648) which fills it, then reads version (UTF-16 @0) + `EventStorageMode` (`@514`:
|
||||
`-1`=Unsupported, `0`=Database, else=Blocks). That is the **HCAL-native / 2023R2 gRPC**
|
||||
front-door model (`StatusService.GetHistorianInfo` returns `bytes btHistorianInfo`). On **2020
|
||||
WCF** that struct is never marshaled across the wire — only the version named-value is. The
|
||||
native client's `EventStorageMode` therefore comes from C++-internal state the managed WCF
|
||||
replay cannot observe or reproduce.
|
||||
|
||||
## Conclusion / where it lands
|
||||
|
||||
- **2020 WCF:** `GetHistorianInfoAsync` would add nothing over existing surface (version only) and
|
||||
could not report a real `EventStorageMode` — so it is intentionally **not shipped** (no hollow
|
||||
`Unsupported`-returning API; project discipline: don't ship misleading behavior).
|
||||
- **2023 R2 gRPC:** `Status.GetHistorianInfo` returns the full 518-byte `btHistorianInfo`; decode
|
||||
version@0 + `EventStorageMode`@514 there. Build + verify against a live 2023 R2 server. The
|
||||
`HistorianInfo` / `HistorianEventStorageMode` public types should land alongside that path.
|
||||
|
||||
## Tooling kept as RE aids
|
||||
|
||||
- `tools/AVEVA.Historian.NativeTraceHarness` `historian-info` scenario (drives the native call).
|
||||
- `scripts/Capture-HistorianInfo.ps1` + `scripts/decode-historian-info-capture.py`.
|
||||
- `StringHandleProbeDiagnosticTests.GETHI_CandidateInfoNames_AgainstLocalHistorian` (locks the
|
||||
named-value-only finding; gated).
|
||||
@@ -111,7 +111,11 @@ GETHI and **ExeC both return data with the uppercased storage-session GUID**.
|
||||
read-only with `System.Formats.Nrbf` + `XDocument` (BinaryFormatter is gone from .NET 10).
|
||||
Shipped: `HistorianSqlResultProtocol`, `HistorianWcfSqlClient`, golden `WcfSqlResultProtocolTests`,
|
||||
gated live tests. See `docs/reverse-engineering/wcf-exec-sql.md`.
|
||||
- **GETHI (R1.4)** also returns data with the uppercase handle (probe; public API not yet shipped).
|
||||
- **GETHI (R1.4)** returns data with the uppercase handle, **but only the named `HistorianVersion`
|
||||
value** — over 2020 WCF GETHI is a named-value query (the only working key), *not* a full-struct
|
||||
read. `EventStorageMode` (the 518-byte-struct `@514` field) is **not on the 2020 WCF wire**; it is
|
||||
the 2023R2 HCAL-native/gRPC model. So R1.4 is **bounded out on WCF / gRPC-2023R2-only** and the
|
||||
public API is intentionally not shipped. Full analysis: `docs/reverse-engineering/wcf-historian-info.md`.
|
||||
|
||||
So the "wall" collapses to the handle **format** for the Retrieval/Status string-handle ops.
|
||||
**Exception — QTB/QTG:** `StartTagQuery` does *not* punch through; captured with a correctly
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's GetHistorianInfo wire traffic (HCAL roadmap R1.4)
|
||||
so the WCF GETHI request that returns the FULL HISTORIAN_INFO struct can be decoded
|
||||
instead of guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness's `historian-info` scenario against the live
|
||||
Historian with an IL-rewritten copy of aahClientManaged.dll whose
|
||||
ClientMessageEncoder.WriteMessage AND ReadMessage are instrumented to log every MDAS body
|
||||
(the same pipeline that produced every other proven request/response shape). The harness
|
||||
opens a normal authenticated process connection and calls
|
||||
HistorianAccess.GetHistorianInfo(out HistorianInfo, out err).
|
||||
|
||||
Decode with scripts/decode-historian-info-capture.py: locate the WCF.WriteMessage.Body
|
||||
whose op is GETHI -> that is the GetHistorianInfo request; read off the leading string
|
||||
handle and the pRequestBuff layout (distinct from the named-value "HistorianVersion"
|
||||
request). The paired WCF.ReadMessage.Body is the pResponseBuff = the 518-byte
|
||||
HISTORIAN_INFO struct (version string @0 UTF-16 null-terminated, EventStorageMode int32 @514).
|
||||
|
||||
.NOTES
|
||||
Read-only status call; no data is written. Artifacts are diagnostic and gitignored.
|
||||
Sanitize before copying anything into docs/ -- never commit raw capture NDJSON,
|
||||
credentials, hostnames, or customer tag names.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-historian-info"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "historian-info-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
# Chain via a distinct intermediate file (reading+writing the same path drops the second
|
||||
# hook on the mixed-mode native image). Final dll carries both hooks with distinct Phase
|
||||
# strings: WCF.WriteMessage.Body and WCF.ReadMessage.Body.
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing historian-info ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "historian-info",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
|
||||
$harnessJson = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} catch {
|
||||
Write-Host " (historian-info raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
Write-Host "Harness output (GetHistorianInfoReturned / HistorianInfo):" -ForegroundColor Cyan
|
||||
$harnessJson | Select-Object -Last 24
|
||||
Write-Host "`nDecode with: python scripts\decode-historian-info-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Decode the GetHistorianInfo (GETHI) WCF request/response (HCAL R1.4).
|
||||
|
||||
Reads the chained WriteMessage+ReadMessage capture produced by
|
||||
scripts/Capture-HistorianInfo.ps1 and locates the GetHistorianInfo exchange. The goal is
|
||||
to learn (a) the pRequestBuff that returns the FULL HISTORIAN_INFO struct (distinct from the
|
||||
named-value "HistorianVersion" request) and (b) the response struct layout: the analysis
|
||||
folder says it's 518 bytes with the version string (UTF-16, null-terminated) at offset 0 and
|
||||
EventStorageMode (int32) at offset 514.
|
||||
|
||||
We flag candidate bodies by the GETHI op action, by the server version value, and by a
|
||||
response length near 518, then dump bytes + the int32 at offset 514 so the layout can be
|
||||
read off directly.
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-historian-info"
|
||||
CAP = CAPDIR / "historian-info-capture-latest.ndjson"
|
||||
|
||||
# The GETHI op action (WS-Addressing) the native client sends. The server version value is
|
||||
# version-shaped, not secret; used only to locate the response.
|
||||
OP_ASCII = b"GetHistorianInfo"
|
||||
OP_GETHI = b"GETHI"
|
||||
VERSION = "20,0,000,000"
|
||||
VERSION_U16 = VERSION.encode("utf-16-le")
|
||||
VERSION_ASCII = VERSION.encode("ascii")
|
||||
|
||||
|
||||
def hexdump(label, buf, base=0):
|
||||
print(f"=== {label}: {len(buf)} bytes ===")
|
||||
for off in range(0, len(buf), 16):
|
||||
c = buf[off:off + 16]
|
||||
hp = " ".join(f"{x:02X}" for x in c)
|
||||
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
||||
print(f" {base + off:04X} {hp:<48} |{ap}|")
|
||||
print()
|
||||
|
||||
|
||||
def ascii_strings(buf, minlen=3):
|
||||
out, cur, start = [], [], 0
|
||||
for i, x in enumerate(buf):
|
||||
if 32 <= x < 127:
|
||||
if not cur:
|
||||
start = i
|
||||
cur.append(chr(x))
|
||||
else:
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
cur = []
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
return out
|
||||
|
||||
|
||||
def u16_strings(buf, minlen=3):
|
||||
out, i = [], 0
|
||||
while i < len(buf) - 1:
|
||||
j, chars = i, []
|
||||
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
|
||||
chars.append(chr(buf[j]))
|
||||
j += 2
|
||||
if len(chars) >= minlen:
|
||||
out.append((i, "".join(chars)))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAP.exists():
|
||||
print(f"Missing capture: {CAP}\nRun scripts/Capture-HistorianInfo.ps1 first.")
|
||||
return 1
|
||||
|
||||
records = []
|
||||
for line in CAP.open(encoding="utf-8-sig"):
|
||||
if line.strip():
|
||||
records.append(json.loads(line))
|
||||
|
||||
print(f"== {len(records)} MDAS bodies captured ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
flags = []
|
||||
if OP_ASCII in body or OP_GETHI in body:
|
||||
flags.append("GETHI-OP")
|
||||
if VERSION_U16 in body or VERSION_ASCII in body:
|
||||
flags.append("VERSION")
|
||||
# A ~518-byte embedded struct is the tell for the full-info response.
|
||||
if 500 <= len(body) <= 4096:
|
||||
flags.append(f"len={len(body)}")
|
||||
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
|
||||
|
||||
def find(predicate):
|
||||
hits = []
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if predicate(rec, body):
|
||||
hits.append((idx, rec, body))
|
||||
return hits
|
||||
|
||||
print("\n== Request candidate(s): WriteMessage bodies tagged GETHI-OP ==")
|
||||
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body"
|
||||
and (OP_ASCII in b or OP_GETHI in b)):
|
||||
hexdump(f"[{idx}] WriteMessage", body)
|
||||
print(" UTF-16 strings:")
|
||||
for off, s in u16_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print(" ASCII strings:")
|
||||
for off, s in ascii_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print()
|
||||
|
||||
print("\n== Response candidate(s): ReadMessage bodies carrying VERSION ==")
|
||||
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.ReadMessage.Body"
|
||||
and (VERSION_U16 in b or VERSION_ASCII in b)):
|
||||
hexdump(f"[{idx}] ReadMessage", body)
|
||||
print(" UTF-16 strings:")
|
||||
for off, s in u16_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
# The analysis folder pins EventStorageMode @ offset 514 (int32) inside the
|
||||
# 518-byte struct. The struct is embedded in the MDAS body at some base; scan for
|
||||
# a plausible version@0 run and print the int32 514 bytes after each candidate base.
|
||||
print(" Candidate struct decodes (version@base, int32 @ base+514):")
|
||||
for base_off, s in u16_strings(body):
|
||||
if any(ch.isdigit() for ch in s) and "," in s:
|
||||
idx514 = base_off + 514
|
||||
if idx514 + 4 <= len(body):
|
||||
mode = struct.unpack_from("<i", body, idx514)[0]
|
||||
print(f" base=0x{base_off:04X} version={s!r} int32@+514={mode}")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -66,6 +66,80 @@ public sealed class StringHandleProbeDiagnosticTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R1.4 probe: GETHI on 2020 WCF is a named-value query (capture showed the native client
|
||||
/// only ever asks for "HistorianVersion"; the 518-byte struct + EventStorageMode@514 is the
|
||||
/// 2023R2 HCAL-native/gRPC model). This probes whether the 2020 server exposes any
|
||||
/// storage-mode / extended info value through GETHI under other parameter names, so we can
|
||||
/// decide honestly what GetHistorianInfoAsync can return over WCF.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GETHI_CandidateInfoNames_AgainstLocalHistorian()
|
||||
{
|
||||
if (!ShouldRun(out string host)) return;
|
||||
|
||||
HistorianClientOptions options = new()
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
};
|
||||
|
||||
string[] candidates =
|
||||
{
|
||||
"HistorianVersion", "EventStorageMode", "EventStorageType", "StorageType",
|
||||
"HistorianEventStorageMode", "EventStorage", "StorageMode", "HistorianInfo",
|
||||
};
|
||||
|
||||
Dictionary<string, (bool ok, int respLen)> results = new();
|
||||
ProbeOnStatusChannel(options, (channel, handle) =>
|
||||
{
|
||||
foreach (string name in candidates)
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (BinaryWriter w = new(ms, Encoding.Unicode, leaveOpen: true))
|
||||
{
|
||||
w.Write(new byte[] { 0x53, 0x67, 0x02, 0x00 });
|
||||
w.Write((uint)name.Length);
|
||||
w.Write(Encoding.Unicode.GetBytes(name));
|
||||
}
|
||||
byte[] req = ms.ToArray();
|
||||
byte[]? resp = null;
|
||||
byte[]? err = null;
|
||||
bool ok;
|
||||
try
|
||||
{
|
||||
ok = channel.GetHistorianInfo(handle, req, out resp, out err);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_output.WriteLine($" {name,-26} EXCEPTION {ex.GetType().Name}: {ex.Message}");
|
||||
results[name] = (false, -1);
|
||||
continue;
|
||||
}
|
||||
string respHex = resp is { Length: > 0 }
|
||||
? Convert.ToHexString(resp.AsSpan(0, Math.Min(48, resp.Length)))
|
||||
: "(empty)";
|
||||
_output.WriteLine($" {name,-26} ok={ok} respLen={resp?.Length ?? 0} errLen={err?.Length ?? 0} resp={respHex}");
|
||||
results[name] = (ok, resp?.Length ?? 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Locked finding (2026-06-20): on 2020 WCF, GETHI is a named-value query whose only
|
||||
// working key is HistorianVersion. No storage-mode key is honored — EventStorageMode is
|
||||
// not on the 2020 WCF wire (it is the 2023R2 HCAL-native/gRPC 518-byte-struct path).
|
||||
Assert.True(results["HistorianVersion"].ok);
|
||||
Assert.True(results["HistorianVersion"].respLen > 0);
|
||||
foreach (string storageName in new[]
|
||||
{
|
||||
"EventStorageMode", "EventStorageType", "StorageType",
|
||||
"HistorianEventStorageMode", "EventStorage", "StorageMode",
|
||||
})
|
||||
{
|
||||
Assert.False(results[storageName].ok, $"unexpected: GETHI honored '{storageName}'");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExeC_WithUppercaseStorageGuid_AgainstLocalHistorian()
|
||||
{
|
||||
|
||||
@@ -246,6 +246,33 @@ internal static class Program
|
||||
}));
|
||||
return 0;
|
||||
}
|
||||
else if (openSuccess && status.ConnectedToServer && IsHistorianInfoScenario(scenario))
|
||||
{
|
||||
// R1.4 capture: drive HistorianAccess.GetHistorianInfo(out HistorianInfo, out error)
|
||||
// so instrument-wcf-{write,read}message can observe the WCF GETHI pRequestBuff that
|
||||
// returns the full 518-byte HISTORIAN_INFO struct (version@0 + EventStorageMode@514),
|
||||
// distinct from the named-value "HistorianVersion" request. Pure status read.
|
||||
MethodInfo getInfoMethod = accessType.GetMethods()
|
||||
.First(m => m.Name == "GetHistorianInfo"
|
||||
&& m.GetParameters().Length == 2
|
||||
&& m.GetParameters()[0].ParameterType.IsByRef
|
||||
&& m.GetParameters()[1].ParameterType.IsByRef);
|
||||
|
||||
object infoError = Activator.CreateInstance(errorType)!;
|
||||
object?[] infoArgs = new object?[] { null, infoError };
|
||||
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-get-historian-info");
|
||||
bool infoOk = (bool)getInfoMethod.Invoke(access, infoArgs)!;
|
||||
|
||||
Console.WriteLine(Serialize(new
|
||||
{
|
||||
Scenario = scenario,
|
||||
GetHistorianInfoReturned = infoOk,
|
||||
HistorianInfoType = getInfoMethod.GetParameters()[0].ParameterType.GetElementType()?.FullName,
|
||||
HistorianInfo = infoArgs[0] is null ? null : SnapshotObject(infoArgs[0]!),
|
||||
Error = SnapshotObject(infoArgs[1]!),
|
||||
}));
|
||||
return 0;
|
||||
}
|
||||
else if (openSuccess && status.ConnectedToServer && IsEventSendScenario(scenario))
|
||||
{
|
||||
// R2.1 capture: drive AddStreamedValue(HistorianEvent) and let instrument-wcf-*
|
||||
@@ -1502,6 +1529,19 @@ internal static class Program
|
||||
|| scenario.Equals("runtime-parameter", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historian-info scenario (R1.4 capture): opens a normal authenticated process connection and
|
||||
/// calls <c>GetHistorianInfo(out HistorianInfo, out error)</c> so instrument-wcf-{write,read}message
|
||||
/// can observe the WCF GETHI <c>pRequestBuff</c>/<c>pResponseBuff</c> that returns the full
|
||||
/// 518-byte HISTORIAN_INFO struct (version@0 + EventStorageMode@514). Pure status read.
|
||||
/// </summary>
|
||||
private static bool IsHistorianInfoScenario(string scenario)
|
||||
{
|
||||
return scenario.Equals("historian-info", StringComparison.OrdinalIgnoreCase)
|
||||
|| scenario.Equals("hist-info", StringComparison.OrdinalIgnoreCase)
|
||||
|| scenario.Equals("gethi", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQL-command scenario (R1.1 capture): opens a normal authenticated process connection and
|
||||
/// calls <c>ExecuteSqlCommand</c> (Retr.ExeC + Retr.GetR) so the string-handle SQL surface
|
||||
|
||||
Reference in New Issue
Block a user