108220c36b
Ship tag extended-property reads over the 2020 WCF aa/Retr/GetTepByNm op: HistorianClient.GetTagExtendedPropertiesAsync(tag) -> name/value pairs. String-handle op reached with the Open2 storage-session GUID formatted uppercase (same format that unlocked GETRP/GETHI/ExeC). Routed via the name-based native path (GetTagExtendedPropertiesByName, server-fetch flag), not the index-based TagQuery path. Evidence-backed findings from the capture: - GetTepByNm (and GetTgByNm) succeed with the uppercase handle -- further validates the resolved string-handle wall. - QTB (StartTagQuery) does NOT punch through: captured uppercase, it still fails server-side (CMdServer::StartActiveTagnamesQuery over the aahMetadataServer pipe) -- a metadata-server blocker, not handle format. - R1.6 (localized properties) has NO distinct op (only error-message/UI-text localization in the managed client); collapses into R1.5. Closed, not throwing. Wire format (golden-pinned, synthetic bytes -- no dev tag names committed): - request tagNames = uint count + per-name(uint charCount + UTF-16) - response = uint tagCount + per-tag(marker + compact-ASCII name + uint propCount + per-prop(marker + compact-ASCII name + 0x43 VT_BSTR value) + trailer); sequence-paged. Adds: HistorianTagExtendedProperty model, HistorianTagExtendedPropertyProtocol (codec), HistorianWcfTagExtendedPropertyClient (orchestration), dialect + public API; golden WcfTagExtendedPropertyProtocolTests (4) + gated live test (HISTORIAN_TEP_TAG). Tooling: Capture-TagExtendedProperties.ps1, decode-tag-properties-capture.py, harness tag-extended-properties scenario. Docs: wcf-tag-extended-properties.md; roadmap R1.5 DONE / R1.6 collapsed; wall doc + memory updated with the QTB-server-side nuance. 228 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
76 lines
2.9 KiB
Python
76 lines
2.9 KiB
Python
"""Decode the GetTagExtendedPropertiesFromName (GetTepByNm) WCF request/response (HCAL R1.5).
|
|
|
|
Reads the chained WriteMessage+ReadMessage capture produced by
|
|
scripts/Capture-TagExtendedProperties.ps1, locates the aa/Retr/GetTepByNm exchange, and
|
|
dumps the tagNames request buffer + tagExtendedProperties response buffer so the op name,
|
|
the uppercase string handle, the tagNames layout, and the extended-property response layout
|
|
can be read off.
|
|
|
|
Request tagNames buffer:
|
|
uint32 count + per name: uint32 charCount + UTF-16LE chars.
|
|
|
|
Response tagExtendedProperties buffer:
|
|
uint32 tagCount
|
|
per tag: byte marker(0x01) + compact-ASCII tagName(0x09 + uint16 len + ascii)
|
|
uint32 propCount
|
|
per prop: byte marker(0x02) + compact-ASCII propName(0x09 + uint16 len + ascii)
|
|
value: 0x43 (VT_BSTR) + uint16 payloadLen + uint16 charCount + UTF-16LE
|
|
trailing byte(0x01)
|
|
|
|
Output is diagnostic. Sanitize before copying into docs/ (tag names / values are dev data).
|
|
"""
|
|
import base64
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-tag-extended-properties"
|
|
DEFAULT_CAP = CAPDIR / "tep-localized-capture.ndjson"
|
|
|
|
ACTION = re.compile(rb"aa/[A-Za-z0-9]+/[A-Za-z0-9_]+")
|
|
|
|
|
|
def hexdump(label, buf):
|
|
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" {off:04X} {hp:<48} |{ap}|")
|
|
print()
|
|
|
|
|
|
def main() -> int:
|
|
cap = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_CAP
|
|
if not cap.exists():
|
|
print(f"Missing capture: {cap}\nRun scripts/Capture-TagExtendedProperties.ps1 -Localized first.")
|
|
return 1
|
|
|
|
records = [json.loads(l) for l in cap.open(encoding="utf-8-sig") if l.strip()]
|
|
print(f"== {len(records)} MDAS bodies captured ==")
|
|
for idx, rec in enumerate(records):
|
|
body = base64.b64decode(rec["Base64"])
|
|
acts = sorted({m.decode() for m in ACTION.findall(body)})
|
|
flag = " <== GetTepByNm" if any("Tep" in a for a in acts) else ""
|
|
print(f" [{idx:02d}] {rec.get('Phase'):24s} len={len(body):5d} {acts}{flag}")
|
|
|
|
print("\n== GetTepByNm request(s) [WriteMessage] ==")
|
|
for idx, rec in enumerate(records):
|
|
body = base64.b64decode(rec["Base64"])
|
|
if rec.get("Phase") == "WCF.WriteMessage.Body" and b"GetTepByNm" in body:
|
|
hexdump(f"[{idx}] request", body)
|
|
|
|
print("\n== GetTepByNm response(s) [ReadMessage] ==")
|
|
for idx, rec in enumerate(records):
|
|
body = base64.b64decode(rec["Base64"])
|
|
if rec.get("Phase") == "WCF.ReadMessage.Body" and b"GetTepByNmResponse" in body:
|
|
hexdump(f"[{idx}] response", body)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|