Files
histsdk/tools/AVEVA.Historian.ReverseInstrumentation/CaptureLogger.cs
T
dohertj2 c95824a65d Initial commit: managed .NET 10 AVEVA Historian SDK + reverse-engineering toolkit
Full read-only SDK (src/AVEVA.Historian.Client) implementing the CLAUDE.md required
surface against AVEVA Historian's binary WCF protocol — no native AVEVA runtime
dependency. All operations live-verified against a local Historian:

- ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, ReadEventsAsync
- BrowseTagNamesAsync, GetTagMetadataAsync (17 native data-type codes mapped)
- GetConnectionStatusAsync, GetStoreForwardStatusAsync, GetSystemParameterAsync
- 108/108 unit + integration tests pass

Includes the reverse-engineering toolkit (tools/AVEVA.Historian.ReverseEngineering)
used to decode the protocol: WCF probes, IL inspection via dnlib, and IL-rewrite
instrumentation (instrument-wcf-{write,read}message etc.) plus the .NET Framework
trace harness (tools/AVEVA.Historian.NativeTraceHarness) for parity testing.

Sanitized handoff evidence under docs/reverse-engineering/. Native AVEVA binaries
(current/, aveva-install-x64/, aveva-install-x86/) are gitignored — fetch separately
from the AVEVA installer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 06:31:48 -04:00

502 lines
19 KiB
C#

using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
namespace AVEVA.Historian.ReverseInstrumentation
{
public static class CaptureLogger
{
private static readonly object Gate = new object();
public static void LogBuffer(string phase, IntPtr data, ulong length)
{
try
{
int byteCount = checked((int)Math.Min(length, 1024UL * 1024UL));
byte[] bytes = new byte[byteCount];
if (data != IntPtr.Zero && byteCount > 0 && IsReadableMemoryRange(data, byteCount))
{
Marshal.Copy(data, bytes, 0, byteCount);
}
string path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE");
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Path.GetTempPath(), "aveva-historian-re-capture.ndjson");
}
string directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
string json = "{"
+ "\"TimestampUtc\":\"" + JsonEscape(DateTimeOffset.UtcNow.ToString("O")) + "\","
+ "\"Phase\":\"" + JsonEscape(phase) + "\","
+ "\"Length\":" + length + ","
+ "\"CapturedLength\":" + byteCount + ","
+ "\"Sha256\":\"" + ComputeSha256(bytes) + "\","
+ "\"Base64\":\"" + Convert.ToBase64String(bytes) + "\""
+ "}";
lock (Gate)
{
File.AppendAllText(path, json + Environment.NewLine, Encoding.UTF8);
}
}
catch
{
// Reverse-engineering instrumentation must not perturb the native query path.
}
}
private static bool IsReadableMemoryRange(IntPtr address, int byteCount)
{
if (address == IntPtr.Zero || byteCount <= 0)
{
return false;
}
long current = address.ToInt64();
long end = checked(current + byteCount);
while (current < end)
{
if (VirtualQuery(new IntPtr(current), out MEMORY_BASIC_INFORMATION info, (UIntPtr)Marshal.SizeOf(typeof(MEMORY_BASIC_INFORMATION))) == UIntPtr.Zero)
{
return false;
}
if (info.State != MEM_COMMIT || (info.Protect & PAGE_GUARD) != 0 || (info.Protect & PAGE_NOACCESS) != 0)
{
return false;
}
long regionEnd = checked(info.BaseAddress.ToInt64() + (long)info.RegionSize.ToUInt64());
if (regionEnd <= current)
{
return false;
}
current = regionEnd;
}
return true;
}
private const uint MEM_COMMIT = 0x1000;
private const uint PAGE_NOACCESS = 0x01;
private const uint PAGE_GUARD = 0x100;
[DllImport("kernel32.dll")]
private static extern UIntPtr VirtualQuery(IntPtr lpAddress, out MEMORY_BASIC_INFORMATION lpBuffer, UIntPtr dwLength);
[StructLayout(LayoutKind.Sequential)]
private struct MEMORY_BASIC_INFORMATION
{
public IntPtr BaseAddress;
public IntPtr AllocationBase;
public uint AllocationProtect;
public UIntPtr RegionSize;
public uint State;
public uint Protect;
public uint Type;
}
public static void LogByteArraySegment(string phase, byte[] bytes, int offset, int count)
{
try
{
if (bytes == null || count <= 0 || offset < 0 || offset > bytes.Length)
{
WriteRecord(phase, 0UL, new byte[0]);
return;
}
int captureCount = Math.Min(count, bytes.Length - offset);
if (captureCount > 1024 * 1024)
{
captureCount = 1024 * 1024;
}
byte[] captured = new byte[captureCount];
Buffer.BlockCopy(bytes, offset, captured, 0, captureCount);
WriteRecord(phase, (ulong)count, captured);
}
catch
{
// Reverse-engineering instrumentation must not perturb the native query path.
}
}
public static void LogByteArray(string phase, byte[] bytes)
{
try
{
byte[] captured = bytes ?? new byte[0];
if (captured.Length > 1024 * 1024)
{
byte[] truncated = new byte[1024 * 1024];
Buffer.BlockCopy(captured, 0, truncated, 0, truncated.Length);
captured = truncated;
}
WriteRecord(phase, bytes == null ? 0UL : (ulong)bytes.Length, captured);
}
catch
{
// Reverse-engineering instrumentation must not perturb the native query path.
}
}
public static void LogByteArraySummary(string phase, byte[] bytes)
{
try
{
byte[] captured = bytes ?? new byte[0];
if (captured.Length > 1024 * 1024)
{
byte[] truncated = new byte[1024 * 1024];
Buffer.BlockCopy(captured, 0, truncated, 0, truncated.Length);
captured = truncated;
}
WriteSummaryRecord(phase, bytes == null ? 0UL : (ulong)bytes.Length, captured);
}
catch
{
// Reverse-engineering instrumentation must not perturb the native query path.
}
}
public static void LogString(string phase, string value)
{
try
{
string captured = value ?? string.Empty;
byte[] bytes = Encoding.UTF8.GetBytes(captured);
string path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE");
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Path.GetTempPath(), "aveva-historian-re-capture.ndjson");
}
string directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
string json = "{"
+ "\"TimestampUtc\":\"" + JsonEscape(DateTimeOffset.UtcNow.ToString("O")) + "\","
+ "\"Phase\":\"" + JsonEscape(phase) + "\","
+ "\"Length\":" + captured.Length + ","
+ "\"Sha256\":\"" + ComputeSha256(bytes) + "\","
+ "\"Value\":\"" + JsonEscape(captured) + "\""
+ "}";
lock (Gate)
{
File.AppendAllText(path, json + Environment.NewLine, Encoding.UTF8);
}
}
catch
{
// Reverse-engineering instrumentation must not perturb the native query path.
}
}
public static void LogStringSummary(string phase, string value)
{
try
{
string captured = value ?? string.Empty;
byte[] bytes = Encoding.UTF8.GetBytes(captured);
int hyphenCount = 0;
int uppercaseCount = 0;
int lowercaseCount = 0;
int digitCount = 0;
for (int index = 0; index < captured.Length; index++)
{
char current = captured[index];
if (current == '-')
{
hyphenCount++;
}
else if (current >= 'A' && current <= 'Z')
{
uppercaseCount++;
}
else if (current >= 'a' && current <= 'z')
{
lowercaseCount++;
}
else if (current >= '0' && current <= '9')
{
digitCount++;
}
}
string path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE");
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Path.GetTempPath(), "aveva-historian-re-capture.ndjson");
}
string directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
string json = "{"
+ "\"TimestampUtc\":\"" + JsonEscape(DateTimeOffset.UtcNow.ToString("O")) + "\","
+ "\"Phase\":\"" + JsonEscape(phase) + "\","
+ "\"Length\":" + captured.Length + ","
+ "\"Sha256\":\"" + ComputeSha256(bytes) + "\","
+ "\"HyphenCount\":" + hyphenCount + ","
+ "\"UppercaseCount\":" + uppercaseCount + ","
+ "\"LowercaseCount\":" + lowercaseCount + ","
+ "\"DigitCount\":" + digitCount + ","
+ "\"StartsWithBrace\":" + (captured.StartsWith("{", StringComparison.Ordinal) ? "true" : "false") + ","
+ "\"EndsWithBrace\":" + (captured.EndsWith("}", StringComparison.Ordinal) ? "true" : "false") + ","
+ "\"ContainsBackslash\":" + (captured.IndexOf('\\') >= 0 ? "true" : "false")
+ "}";
lock (Gate)
{
File.AppendAllText(path, json + Environment.NewLine, Encoding.UTF8);
}
}
catch
{
// Reverse-engineering instrumentation must not perturb the native query path.
}
}
public static void LogUInt32(string phase, uint value)
{
try
{
string path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE");
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Path.GetTempPath(), "aveva-historian-re-capture.ndjson");
}
string directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
string json = "{"
+ "\"TimestampUtc\":\"" + JsonEscape(DateTimeOffset.UtcNow.ToString("O")) + "\","
+ "\"Phase\":\"" + JsonEscape(phase) + "\","
+ "\"Value\":" + value
+ "}";
lock (Gate)
{
File.AppendAllText(path, json + Environment.NewLine, Encoding.UTF8);
}
}
catch
{
// Reverse-engineering instrumentation must not perturb the native query path.
}
}
public static void LogStdVector(string phase, IntPtr vector, ulong elementSize, ulong maxElements)
{
try
{
if (vector == IntPtr.Zero || elementSize == 0 || maxElements == 0)
{
WriteVectorRecord(phase, elementSize, 0, 0, 0, new byte[0]);
return;
}
IntPtr begin = Marshal.ReadIntPtr(vector, 0);
IntPtr end = Marshal.ReadIntPtr(vector, IntPtr.Size);
if (begin == IntPtr.Zero || end == IntPtr.Zero)
{
WriteVectorRecord(phase, elementSize, 0, 0, 0, new byte[0]);
return;
}
long byteLength = end.ToInt64() - begin.ToInt64();
if (byteLength <= 0 || byteLength % checked((long)elementSize) != 0)
{
WriteVectorRecord(phase, elementSize, 0, 0, 0, new byte[0]);
return;
}
ulong count = checked((ulong)byteLength / elementSize);
ulong capturedCount = Math.Min(count, maxElements);
ulong capturedLength = Math.Min(checked(capturedCount * elementSize), 1024UL * 1024UL);
byte[] captured = new byte[checked((int)capturedLength)];
if (captured.Length > 0)
{
Marshal.Copy(begin, captured, 0, captured.Length);
}
WriteVectorRecord(phase, elementSize, count, capturedCount, (ulong)byteLength, captured);
}
catch
{
// Reverse-engineering instrumentation must not perturb the native query path.
}
}
private static void WriteSummaryRecord(string phase, ulong length, byte[] bytes)
{
string path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE");
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Path.GetTempPath(), "aveva-historian-re-capture.ndjson");
}
string directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
string json = "{"
+ "\"TimestampUtc\":\"" + JsonEscape(DateTimeOffset.UtcNow.ToString("O")) + "\","
+ "\"Phase\":\"" + JsonEscape(phase) + "\","
+ "\"Length\":" + length + ","
+ "\"CapturedLength\":" + bytes.Length + ","
+ "\"Sha256\":\"" + ComputeSha256(bytes) + "\","
+ "\"PrefixHex\":\"" + ToPrefixHex(bytes, 32) + "\","
+ "\"PrefixAscii\":\"" + JsonEscape(ToSafeAscii(bytes, 32)) + "\""
+ "}";
lock (Gate)
{
File.AppendAllText(path, json + Environment.NewLine, Encoding.UTF8);
}
}
private static void WriteRecord(string phase, ulong length, byte[] bytes)
{
string path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE");
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Path.GetTempPath(), "aveva-historian-re-capture.ndjson");
}
string directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
string json = "{"
+ "\"TimestampUtc\":\"" + JsonEscape(DateTimeOffset.UtcNow.ToString("O")) + "\","
+ "\"Phase\":\"" + JsonEscape(phase) + "\","
+ "\"Length\":" + length + ","
+ "\"CapturedLength\":" + bytes.Length + ","
+ "\"Sha256\":\"" + ComputeSha256(bytes) + "\","
+ "\"Base64\":\"" + Convert.ToBase64String(bytes) + "\""
+ "}";
lock (Gate)
{
File.AppendAllText(path, json + Environment.NewLine, Encoding.UTF8);
}
}
private static void WriteVectorRecord(
string phase,
ulong elementSize,
ulong count,
ulong capturedCount,
ulong length,
byte[] bytes)
{
string path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE");
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Path.GetTempPath(), "aveva-historian-re-capture.ndjson");
}
string directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
string json = "{"
+ "\"TimestampUtc\":\"" + JsonEscape(DateTimeOffset.UtcNow.ToString("O")) + "\","
+ "\"Phase\":\"" + JsonEscape(phase) + "\","
+ "\"ElementSize\":" + elementSize + ","
+ "\"Count\":" + count + ","
+ "\"CapturedCount\":" + capturedCount + ","
+ "\"Length\":" + length + ","
+ "\"CapturedLength\":" + bytes.Length + ","
+ "\"Sha256\":\"" + ComputeSha256(bytes) + "\","
+ "\"Base64\":\"" + Convert.ToBase64String(bytes) + "\""
+ "}";
lock (Gate)
{
File.AppendAllText(path, json + Environment.NewLine, Encoding.UTF8);
}
}
private static string ComputeSha256(byte[] bytes)
{
using (SHA256 sha256 = SHA256.Create())
{
byte[] hash = sha256.ComputeHash(bytes);
StringBuilder builder = new StringBuilder(hash.Length * 2);
foreach (byte value in hash)
{
builder.Append(value.ToString("x2"));
}
return builder.ToString();
}
}
private static string ToPrefixHex(byte[] bytes, int maxBytes)
{
int count = Math.Min(bytes.Length, maxBytes);
StringBuilder builder = new StringBuilder(count * 2);
for (int index = 0; index < count; index++)
{
builder.Append(bytes[index].ToString("x2"));
}
return builder.ToString();
}
private static string ToSafeAscii(byte[] bytes, int maxBytes)
{
int count = Math.Min(bytes.Length, maxBytes);
StringBuilder builder = new StringBuilder(count);
for (int index = 0; index < count; index++)
{
byte value = bytes[index];
builder.Append(value >= 32 && value <= 126 ? (char)value : '.');
}
return builder.ToString();
}
private static string JsonEscape(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\r", "\\r")
.Replace("\n", "\\n");
}
}
}