c95824a65d
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>
502 lines
19 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|