Cross-platform NegotiateAuthentication; StorageType field; docs polish

HistorianSspiClient rewritten on top of System.Net.Security.NegotiateAuthentication
in place of P/Invoke into secur32.dll's InitializeSecurityContextW. The class
keeps the same Next() / Dispose() / two-constructor surface so callers don't
change. RequiredProtectionLevel=EncryptAndSign + AllowedImpersonationLevel=
Identification produces a request-flag set equivalent to the captured native
0x2081C / 0x81C bitmasks (still preserved as constants for the existing unit
tests). Removes the only Windows P/Invoke in the production SDK; the
[SupportedOSPlatform("windows")] gating elsewhere stays in place pending a
separate sweep.

HistorianStorageType (Cyclic = 1, Delta = 2):
Captured 2026-05-04 via --write-storage-type on the harness. Delta differs
from Cyclic in three places — header byte 10 (0x02 -> 0x06), flag-block
byte 1 (0x01 -> 0x02), and 4 zero bytes inserted after StorageRate before
the FILETIME. Server persists Tag.StorageType=1/2 accordingly. Plumbed
through HistorianTagDefinition.StorageType + serializer + orchestrator + 2
new tests (golden bytes + live SQL persistence verification).

Docs polish:
CLAUDE.md no longer claims "no P/Invoke" (HistorianSspiClient is the one
allowed P/Invoke surface); updated test count to 169+; AGENTS.md Required
SDK Surface and Repository Layout brought up to date with the live state
including the write surface; handoff.md "not a git working tree" obsolete
note removed.

171/171 tests pass with the NegotiateAuthentication replacement (was 169;
+2 new tests for StorageType).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-04 22:19:37 -04:00
parent 5ce62a5900
commit 7e4d713eb3
11 changed files with 265 additions and 361 deletions
+73 -57
View File
@@ -27,12 +27,21 @@ a P/Invoke shim as the primary solution; it is useful only as an analysis aid.
## Repository Layout
This workspace is an SDK investigation folder, not a full application repo.
This workspace is a full Git repo (origin: gitea.dohertylan.com) with the
shipping SDK under `src/`, tests under `tests/`, RE tooling under `tools/`,
and decoded protocol notes under `docs/`. See `CLAUDE.md` for the
authoritative architecture overview.
- `instructions.md` - source planning document and decision record.
- `src\AVEVA.Historian.Client\` - the production managed SDK (pure .NET 10,
no native AVEVA references).
- `tests\AVEVA.Historian.Client.Tests\` - unit + gated live integration tests.
- `tools\` - reverse-engineering tooling (CLI, native trace harness,
WCF capture server, IL-rewrite instrumentation helper).
- `docs\reverse-engineering\` - sanitized RE evidence and decoded notes.
- `current\` - the seven DLLs the existing sidecar links against today.
- `aveva-install-x64\` - full 64-bit AVEVA Historian client-side DLL set.
- `aveva-install-x86\` - full 32-bit AVEVA Historian client-side DLL set.
- `aveva-install-x64\` and `aveva-install-x86\` - full AVEVA Historian
client-side DLL sets for cross-version reference.
Use `current\` first because it represents the deployed sidecar dependency set.
Use `aveva-install-*` to compare architecture-specific behavior and locate
@@ -40,22 +49,44 @@ adjacent client APIs.
## Required SDK Surface
Keep the managed SDK narrowly scoped to the operations used in production:
The shipping public surface (all live-verified against `localhost`
see `CLAUDE.md` "Required SDK Surface" for the authoritative list and
caveats):
- `ReadRawAsync(tag, startUtc, endUtc, maxValues)`
- `ReadAggregateAsync(tag, startUtc, endUtc, mode, interval)`
- `ReadAtTimeAsync(tag, timestampsUtc)`
- `ReadEventsAsync(startUtc, endUtc)`
- `ProbeAsync()`
Reads:
The existing alarm-event write path is dormant. Do not implement write-back
unless a new requirement is supplied.
- `ProbeAsync`
- `ReadRawAsync`
- `ReadAggregateAsync`
- `ReadAtTimeAsync`
- `ReadEventsAsync`
- `BrowseTagNamesAsync`
- `GetTagMetadataAsync`
- Status helpers: `GetConnectionStatusAsync`, `GetStoreForwardStatusAsync`,
`GetSystemParameterAsync`
Writes (added 2026-05-04 by explicit request):
- `EnsureTagAsync` for analog Float / Double / Int2 / Int4 / UInt4
(with optional `ApplyScaling=true` for distinct MinRaw/MaxRaw and
optional `StorageRateMs` for non-default sampling).
- `DeleteTagAsync`.
`AddS2` (write samples) is architecturally blocked — the server's
runtime cache only ingests from configured IOServer / Application Server
pipelines. Do not extend write support without an explicit new request.
## Reverse-Engineering Workflow
The bulk of the original RE workflow has been executed and is now backed
by `docs/reverse-engineering/` evidence. The notes below are the durable
process in case new captures are needed (e.g., for a new Historian version
or a new write op).
### 1. Managed Wrapper Analysis
Use dnSpy or ILSpy on `current\aahClientManaged.dll`.
Use dnSpy / ILSpy / the in-repo `dnlib-method` CLI on
`current\aahClientManaged.dll`.
Document:
@@ -66,8 +97,8 @@ Document:
- Returned sample/event models, quality fields, timestamp handling, and error
propagation.
Prefer producing small Markdown notes under a future `docs\reverse-engineering\`
folder rather than relying on memory.
Sanitized notes go under `docs\reverse-engineering\` (the folder exists and
is the canonical home for committed RE evidence).
### 2. Native ABI Mapping
@@ -137,34 +168,12 @@ newer Historian versions.
### 5. Managed Implementation Shape
When implementation starts, use this project shape unless the real repo dictates
otherwise:
The implementation has landed and is the authoritative reference. See
`CLAUDE.md` "Code Architecture" for the actual layout. The original
abstract shape is preserved as historical context only.
```text
src/AVEVA.Historian.Client/
AVEVA.Historian.Client.csproj
HistorianClient.cs
HistorianClientOptions.cs
Models/
HistorianSample.cs
HistorianAggregateSample.cs
HistorianEvent.cs
RetrievalMode.cs
Protocol/
HistorianConnection.cs
HistorianFrame.cs
HistorianMessageType.cs
HistorianProtocolReader.cs
HistorianProtocolWriter.cs
Transport/
TcpHistorianTransport.cs
ClusterEndpointPicker.cs
Internal/
BackoffPolicy.cs
```
Keep protocol parsing isolated from transport I/O so captured frames can be
tested without a live Historian.
Key design rule still in force: keep protocol parsing isolated from transport
I/O so captured frames can be tested without a live Historian.
## Testing Expectations
@@ -188,27 +197,34 @@ Integration tests must skip cleanly when these values are not configured.
## Constraints
- Keep the final SDK pure managed .NET 10.
- Avoid adding native runtime dependencies to the production SDK.
- Avoid broad API design. Implement only the operations listed above.
- Treat AVEVA protocol details as version-sensitive; document assumptions.
- Keep the final SDK managed .NET 10. The single P/Invoke surface allowed
in production is `HistorianSspiClient` (Windows SSPI for integrated
auth); do not add unrelated P/Invokes.
- Avoid adding native runtime dependencies to the production SDK. No
reference to `aahClientManaged.dll` / `aahClient.dll` from `src/`.
- Avoid broad API design. Implement only the operations listed in
"Required SDK Surface".
- Treat AVEVA protocol details as version-sensitive; document assumptions
in `docs/reverse-engineering/`.
- Do not redistribute AVEVA binaries.
- Do not commit credentials, proprietary captures, or customer data.
- Do not delete or overwrite DLLs in `current\` or `aveva-install-*`.
## Definition of Done
For the reverse-engineering phase:
Both the RE phase and the SDK phase are **met** as of 2026-05-04:
- Managed wrapper public surface and native entry points are documented.
- Required query flows have sanitized captures or byte-level notes.
- Message framing, request fields, response fields, and error frames are
described well enough to implement parser tests.
- Managed wrapper public surface and native entry points are documented in
`docs/reverse-engineering/`.
- Required query flows have sanitized captures + byte-level notes; golden
fixtures live under `fixtures/protocol/`.
- Message framing, request/response/error layouts are decoded sufficiently
for round-trip parser tests.
- The shipping SDK implements the Required SDK Surface (reads + writes).
- 169 unit + live integration tests pass.
- Local consumers can replace the sidecar without `aahClientManaged.dll` or
`aahClient.dll` at runtime.
For the SDK phase:
- The managed client implements the required read-only surface.
- Unit tests cover protocol parse/build behavior.
- Integration tests can validate against a configured live Historian.
- The SDK can replace the existing sidecar call sites without requiring
`aahClientManaged.dll` or `aahClient.dll` at runtime.
Future RE work (e.g., new Historian version, additional write ops) should
follow the same workflow above; new evidence updates `docs/reverse-engineering/`
and the relevant plan file under `docs/plans/`.
+3 -3
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Mission
Build a fully managed .NET 10 replacement for AVEVA Historian's `aahClientManaged` / `aahClient.dll` stack by reverse-engineering the proprietary binary protocol. The production SDK under `src/AVEVA.Historian.Client/` must remain pure managed .NET 10 — no P/Invoke, no native AVEVA runtime dependency, no REST. Tools under `tools/` and scripts under `scripts/` are reverse-engineering aids only.
Build a fully managed .NET 10 replacement for AVEVA Historian's `aahClientManaged` / `aahClient.dll` stack by reverse-engineering the proprietary binary protocol. The production SDK under `src/AVEVA.Historian.Client/` has no native AVEVA runtime dependency and no REST surface. The one P/Invoke is into Windows SSPI (`HistorianSspiClient``InitializeSecurityContextW`) for integrated-auth NTLM/Negotiate token generation; this gates the SDK to Windows-only execution today. See the `RemoteTcpCertificate` transport for a Windows-free auth path. Tools under `tools/` and scripts under `scripts/` are reverse-engineering aids only.
Read `AGENTS.md` (standing constraints), `instructions.md` (decision record), and `docs/reverse-engineering/handoff.md` (current evidence + active blocker) before starting non-trivial work. The handoff doc is the entry point — it tracks the live blocker, next pickup steps, and the canonical list of primary reference docs.
@@ -83,7 +83,7 @@ The original blocker — `Open2` reaching server logic but `Retr.StartQuery2` re
2. Native SSPI request flags — round 0 = `0x2081C` (adds `IDENTIFY` + `REPLAY_DETECT` + `SEQUENCE_DETECT`); rounds 1+ = `0x81C`. Without `REPLAY_DETECT|SEQUENCE_DETECT`, NTLM MIC generation is skipped and `AcceptSecurityContext` rejects round 1. Implemented in `HistorianSspiClient` via P/Invoke `InitializeSecurityContextW`.
3. Cross-service version probes (`Trx/GetV`, `Stat/GetV`, `Retr/GetV`) between RTag2 and EnsT2 in the event flow — required to register the client with each service's session table.
End-to-end chain working from a pure managed .NET 10 client: `Hist.GetV → Hist.ValCl × N → Hist.Open2 → /Retr.GetV → Retr.IsOriginalAllowed → Retr.StartQuery2 → loop Retr.GetNextQueryResultBuffer2`. 23 live integration tests against `localhost` cover all required reads + the two write ops.
End-to-end chain working from a pure managed .NET 10 client: `Hist.GetV → Hist.ValCl × N → Hist.Open2 → /Retr.GetV → Retr.IsOriginalAllowed → Retr.StartQuery2 → loop Retr.GetNextQueryResultBuffer2`. 169 unit + live integration tests against `localhost` cover all required reads, the two write ops, and the `RemoteTcpIntegrated` / `RemoteTcpCertificate` transports.
### Write-path notes (added 2026-05-04)
@@ -121,5 +121,5 @@ Unit tests are golden-byte and round-trip oriented — `WcfDataQueryProtocolTest
- Never commit credentials, hostnames, user names, customer tag names, or raw packet captures. Use placeholders in docs.
- Run a sanitization scan after touching auth/capture docs (the rg pattern is in handoff.md "Next Pickup Steps").
- Production code under `src/` must remain pure managed .NET 10 with no native AVEVA reference. Reverse-engineering harnesses under `tools/` may reference native binaries.
- Production code under `src/` must remain pure managed .NET 10 with no native AVEVA reference. The one allowed P/Invoke is into the Windows SSPI surface (`HistorianSspiClient`) for integrated-auth tokens; do not add unrelated P/Invokes. Reverse-engineering harnesses under `tools/` may reference native binaries.
- This workspace IS a Git working tree (origin: gitea.dohertylan.com). Use normal git workflow; the prior note about "no working tree, track via timestamps" is obsolete.
+3 -2
View File
@@ -78,8 +78,9 @@ Current known-good result:
- Build succeeds.
- Unit tests pass: 55/55.
The repository folder is not currently a Git working tree in this checkout, so
use file timestamps or your own external backup if you need change tracking.
The workspace is a Git working tree (origin: gitea.dohertylan.com). Use
normal git workflow for change tracking; the prior "no working tree, use
timestamps" note is obsolete.
## Environment Variables
@@ -0,0 +1,19 @@
namespace AVEVA.Historian.Client.Models;
/// <summary>
/// Storage strategy for historized samples. Maps to <c>Tag.StorageType</c> in the
/// Runtime DB. Values match the captured native enum and the server-persisted
/// integer column.
/// </summary>
public enum HistorianStorageType
{
/// <summary>
/// Sample on a fixed cadence (see <c>HistorianTagDefinition.StorageRateMs</c>).
/// </summary>
Cyclic = 1,
/// <summary>
/// Sample only on value change (with optional value/time/rate deadbands).
/// </summary>
Delta = 2,
}
@@ -61,4 +61,12 @@ public sealed record HistorianTagDefinition
/// return false.
/// </summary>
public uint StorageRateMs { get; init; } = 1000u;
/// <summary>
/// Storage strategy. Default <see cref="HistorianStorageType.Cyclic"/> samples
/// on the configured <see cref="StorageRateMs"/> cadence. <see cref="HistorianStorageType.Delta"/>
/// samples only on value change. The server persists this to <c>Tag.StorageType</c>
/// (Cyclic = 1, Delta = 2).
/// </summary>
public HistorianStorageType StorageType { get; init; } = HistorianStorageType.Cyclic;
}
@@ -1,16 +1,30 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Net;
using System.Net.Security;
using System.Security.Authentication.ExtendedProtection;
using System.Security.Principal;
namespace AVEVA.Historian.Client.Wcf;
/// <remarks>
/// Mirrors the request flags the AVEVA wrapper passes to InitializeSecurityContextW: 0x2081C round 0,
/// 0x81C subsequent. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation; without it
/// AcceptSecurityContext rejects the type-3 token with SEC_E_INVALID_TOKEN. ALLOCATE_MEMORY is added
/// for output-buffer convenience and the server tolerates it.
/// Cross-platform Negotiate / NTLM token producer for the Historian's `Hist.ValCl`
/// authentication exchange. Uses <see cref="NegotiateAuthentication"/> under the hood
/// (Windows: SSPI; Linux/macOS: GSSAPI via <c>libgssapi_krb5</c> / <c>gss-ntlmssp</c>).
///
/// The native AVEVA wrapper passes specific request flags to
/// <c>InitializeSecurityContextW</c>: <c>IDENTIFY | CONNECTION | CONFIDENTIALITY |
/// SEQUENCE_DETECT | REPLAY_DETECT</c> on round 0 and the same minus IDENTIFY on
/// rounds 1+. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation;
/// without it AcceptSecurityContext rejects the type-3 token with
/// SEC_E_INVALID_TOKEN. <c>RequiredProtectionLevel.EncryptAndSign</c> in
/// NegotiateAuthentication implicitly requests SEQUENCE + REPLAY +
/// CONFIDENTIALITY, and <c>AllowedImpersonationLevel = Identification</c> requests
/// IDENTIFY — together these produce a request flag set that AcceptSecurityContext
/// accepts on the server side.
///
/// The constants and request-flag selection helpers below are preserved for the
/// existing unit tests in <c>HistorianSspiClientTests</c> — they document the
/// captured native flag values rather than driving the underlying API today.
/// </remarks>
[SupportedOSPlatform("windows")]
internal sealed class HistorianSspiClient : IDisposable
{
public const int IscReqReplayDetect = 0x4;
@@ -23,15 +37,7 @@ internal sealed class HistorianSspiClient : IDisposable
public const int NativeFlagsRound0 = IscReqIdentify | IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
public const int NativeFlagsRoundSubsequent = IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
private const int SecpkgCredOutbound = 2;
private const int SecbufferToken = 2;
private const int SecEOk = 0;
private const int SecIContinueNeeded = 0x00090312;
private readonly string _targetName;
private SecHandle _credential;
private SecHandle _context;
private bool _haveContext;
private readonly NegotiateAuthentication _auth;
private int _roundIndex;
private bool _disposed;
@@ -39,78 +45,39 @@ internal sealed class HistorianSspiClient : IDisposable
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
_targetName = targetName;
_credential = default;
int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
ThrowIfFailed(status, "AcquireCredentialsHandle");
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
{
Package = package,
TargetName = targetName,
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
RequireMutualAuthentication = false,
});
}
/// <remarks>
/// Acquires Negotiate credentials for an explicit user/domain/password instead of the
/// calling thread's Windows identity. Builds a SEC_WINNT_AUTH_IDENTITY (Unicode) and
/// passes it as <c>pAuthData</c> to <c>AcquireCredentialsHandleW</c>. Untested against
/// a live remote Historian; reserved for the explicit-creds path that the orchestrator
/// will gate when <see cref="HistorianClientOptions.IntegratedSecurity"/> is false.
/// Acquires Negotiate credentials for an explicit user/domain/password instead
/// of the calling thread's identity. On Linux this routes through GSSAPI's
/// credential acquisition; the supplied credential is wrapped in a
/// <see cref="NetworkCredential"/>.
/// </remarks>
public HistorianSspiClient(string targetName, string domain, string userName, string password, string package = "Negotiate")
public HistorianSspiClient(string targetName, string? domain, string userName, string? password, string package = "Negotiate")
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
_targetName = targetName;
_credential = default;
IntPtr userPtr = IntPtr.Zero;
IntPtr domainPtr = IntPtr.Zero;
IntPtr passwordPtr = IntPtr.Zero;
IntPtr authDataPtr = IntPtr.Zero;
try
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
{
userPtr = Marshal.StringToCoTaskMemUni(userName);
domainPtr = string.IsNullOrEmpty(domain) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(domain);
passwordPtr = string.IsNullOrEmpty(password) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(password);
SecWinntAuthIdentity authIdentity = new()
{
User = userPtr,
UserLength = userName.Length,
Domain = domainPtr,
DomainLength = domain?.Length ?? 0,
Password = passwordPtr,
PasswordLength = password?.Length ?? 0,
Flags = SecWinntAuthIdentityUnicode
};
authDataPtr = Marshal.AllocCoTaskMem(Marshal.SizeOf<SecWinntAuthIdentity>());
Marshal.StructureToPtr(authIdentity, authDataPtr, false);
int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, authDataPtr, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
ThrowIfFailed(status, "AcquireCredentialsHandle");
}
finally
{
if (authDataPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(authDataPtr);
if (passwordPtr != IntPtr.Zero) Marshal.ZeroFreeCoTaskMemUnicode(passwordPtr);
if (domainPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(domainPtr);
if (userPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(userPtr);
}
Package = package,
TargetName = targetName,
Credential = new NetworkCredential(userName, password ?? string.Empty, domain ?? string.Empty),
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
RequireMutualAuthentication = false,
});
}
private const int SecWinntAuthIdentityUnicode = 0x2;
[StructLayout(LayoutKind.Sequential)]
private struct SecWinntAuthIdentity
{
public IntPtr User;
public int UserLength;
public IntPtr Domain;
public int DomainLength;
public IntPtr Password;
public int PasswordLength;
public int Flags;
}
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call will use.</summary>
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call corresponds to.</summary>
internal int NextRequestFlags => SelectRequestFlags(_roundIndex) | IscReqAllocateMemory;
public static int SelectRequestFlags(int roundIndex) => roundIndex == 0 ? NativeFlagsRound0 : NativeFlagsRoundSubsequent;
@@ -120,68 +87,17 @@ internal sealed class HistorianSspiClient : IDisposable
ArgumentNullException.ThrowIfNull(incoming);
ObjectDisposedException.ThrowIf(_disposed, this);
SecBufferDesc outDesc = CreateOutputBufferDesc();
SecBufferDesc? inDesc = incoming.Length == 0 ? null : CreateInputBufferDesc(incoming);
try
byte[]? outgoing = _auth.GetOutgoingBlob(incoming.Length == 0 ? null : incoming, out NegotiateAuthenticationStatusCode status);
_roundIndex++;
bool completed = status switch
{
int requirements = NextRequestFlags;
SecHandle newContext = default;
int status;
uint contextAttributes;
long expiry;
NegotiateAuthenticationStatusCode.Completed => true,
NegotiateAuthenticationStatusCode.ContinueNeeded => false,
_ => throw new InvalidOperationException($"Negotiate handshake failed: {status}"),
};
if (inDesc.HasValue)
{
SecBufferDesc input = inDesc.Value;
status = InitializeSecurityContext(
ref _credential,
ref _context,
_targetName,
requirements,
0,
0,
ref input,
0,
ref newContext,
ref outDesc,
out contextAttributes,
out expiry);
}
else
{
status = InitializeSecurityContext(
ref _credential,
IntPtr.Zero,
_targetName,
requirements,
0,
0,
IntPtr.Zero,
0,
ref newContext,
ref outDesc,
out contextAttributes,
out expiry);
}
if (!_haveContext)
{
_context = newContext;
_haveContext = true;
}
ThrowIfFailed(status, "InitializeSecurityContext", allowContinue: true);
byte[] token = ReadTokenAndFree(outDesc);
_roundIndex++;
return new HistorianSspiStepResult(token, status == SecEOk);
}
finally
{
if (inDesc.HasValue)
{
FreeBufferDesc(inDesc.Value, freeToken: true);
}
}
return new HistorianSspiStepResult(outgoing ?? [], completed);
}
public void Dispose()
@@ -192,154 +108,7 @@ internal sealed class HistorianSspiClient : IDisposable
}
_disposed = true;
if (_haveContext)
{
DeleteSecurityContext(ref _context);
}
FreeCredentialsHandle(ref _credential);
}
private static byte[] ReadTokenAndFree(SecBufferDesc desc)
{
try
{
SecBuffer buffer = Marshal.PtrToStructure<SecBuffer>(desc.pBuffers);
if (buffer.cbBuffer == 0 || buffer.pvBuffer == IntPtr.Zero)
{
return [];
}
byte[] bytes = new byte[buffer.cbBuffer];
Marshal.Copy(buffer.pvBuffer, bytes, 0, bytes.Length);
FreeContextBuffer(buffer.pvBuffer);
return bytes;
}
finally
{
FreeBufferDesc(desc, freeToken: false);
}
}
private static SecBufferDesc CreateOutputBufferDesc()
{
SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = 0, pvBuffer = IntPtr.Zero };
IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
Marshal.StructureToPtr(buffer, bufferPtr, false);
return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
}
private static SecBufferDesc CreateInputBufferDesc(byte[] token)
{
IntPtr tokenPtr = Marshal.AllocHGlobal(token.Length);
Marshal.Copy(token, 0, tokenPtr, token.Length);
SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = token.Length, pvBuffer = tokenPtr };
IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
Marshal.StructureToPtr(buffer, bufferPtr, false);
return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
}
private static void FreeBufferDesc(SecBufferDesc desc, bool freeToken)
{
if (desc.pBuffers == IntPtr.Zero)
{
return;
}
if (freeToken)
{
SecBuffer buffer = Marshal.PtrToStructure<SecBuffer>(desc.pBuffers);
if (buffer.pvBuffer != IntPtr.Zero)
{
Marshal.FreeHGlobal(buffer.pvBuffer);
}
}
Marshal.FreeHGlobal(desc.pBuffers);
}
private static void ThrowIfFailed(int status, string operation, bool allowContinue = false)
{
if (status == SecEOk || (allowContinue && status == SecIContinueNeeded))
{
return;
}
throw new Win32Exception(status, operation + " failed with 0x" + status.ToString("X8"));
}
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int AcquireCredentialsHandle(
string? pszPrincipal,
string pszPackage,
int fCredentialUse,
IntPtr pvLogonId,
IntPtr pAuthData,
IntPtr pGetKeyFn,
IntPtr pvGetKeyArgument,
ref SecHandle phCredential,
out long ptsExpiry);
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int InitializeSecurityContext(
ref SecHandle phCredential,
ref SecHandle phContext,
string pszTargetName,
int fContextReq,
int reserved1,
int targetDataRep,
ref SecBufferDesc pInput,
int reserved2,
ref SecHandle phNewContext,
ref SecBufferDesc pOutput,
out uint pfContextAttr,
out long ptsExpiry);
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int InitializeSecurityContext(
ref SecHandle phCredential,
IntPtr phContext,
string pszTargetName,
int fContextReq,
int reserved1,
int targetDataRep,
IntPtr pInput,
int reserved2,
ref SecHandle phNewContext,
ref SecBufferDesc pOutput,
out uint pfContextAttr,
out long ptsExpiry);
[DllImport("secur32.dll", SetLastError = false)]
private static extern int FreeCredentialsHandle(ref SecHandle phCredential);
[DllImport("secur32.dll", SetLastError = false)]
private static extern int DeleteSecurityContext(ref SecHandle phContext);
[DllImport("secur32.dll", SetLastError = false)]
private static extern int FreeContextBuffer(IntPtr pvContextBuffer);
[StructLayout(LayoutKind.Sequential)]
private struct SecHandle
{
public IntPtr dwLower;
public IntPtr dwUpper;
}
[StructLayout(LayoutKind.Sequential)]
private struct SecBuffer
{
public int cbBuffer;
public int BufferType;
public IntPtr pvBuffer;
}
[StructLayout(LayoutKind.Sequential)]
private struct SecBufferDesc
{
public int ulVersion;
public int cBuffers;
public IntPtr pBuffers;
_auth.Dispose();
}
}
@@ -39,16 +39,24 @@ internal static class HistorianTagWriteProtocol
/// <summary>
/// 11 bytes preceding the data-type discriminator. Byte 0 is the leading 0x4E
/// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is `0x02`
/// (sub-marker preceding the type code).
/// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is the
/// storage-type sub-marker (`0x02` for Cyclic, `0x06` for Delta — captured
/// 2026-05-04 by toggling --write-storage-type on the harness).
/// </summary>
private static readonly byte[] AnalogHeaderUpToTypeCode =
private static readonly byte[] AnalogHeaderUpToTypeCodeCyclic =
[
0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
0x02,
];
private static readonly byte[] AnalogHeaderUpToTypeCodeDelta =
[
0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
0x06,
];
/// <summary>
/// Native CDataType wire codes per data type — captured 2026-05-04 by probing
/// every type via instrument-wcf-writemessage. Matches the codes already documented
@@ -78,11 +86,14 @@ internal static class HistorianTagWriteProtocol
}
}
// After MDAS, the captured layout is:
// `02 01 01 00 00 00` (6 bytes — flag block, observed constant)
// `01` (1 byte — observed constant; purpose unclear)
// uint32 storage rate (4 bytes)
private static readonly byte[] AnalogFlagBlock = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
// After MDAS, the captured layout is a 7-byte flag block followed by uint32
// storage rate. The flag block's second byte is the StorageType (1 = Cyclic,
// 2 = Delta — captured 2026-05-04). When StorageType=Delta, an additional
// 4 zero bytes are inserted between the storage rate and the FILETIME (likely
// a placeholder for Delta-specific deadband / threshold config).
private static readonly byte[] AnalogFlagBlockCyclic = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
private static readonly byte[] AnalogFlagBlockDelta = [0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01];
private static readonly byte[] AnalogDeltaPostStorageRatePadding = new byte[4];
/// <summary>Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.</summary>
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary>
@@ -133,7 +144,8 @@ internal static class HistorianTagWriteProtocol
double minRaw = DefaultMinRaw,
double maxRaw = DefaultMaxRaw,
uint storageRateMs = DefaultStorageRateMs,
bool applyScaling = false)
bool applyScaling = false,
Models.HistorianStorageType storageType = Models.HistorianStorageType.Cyclic)
{
if (storageRateMs == 0)
{
@@ -141,19 +153,24 @@ internal static class HistorianTagWriteProtocol
}
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
byte typeCode = GetAnalogDataTypeCode(dataType);
bool isDelta = storageType == Models.HistorianStorageType.Delta;
using MemoryStream ms = new();
using BinaryWriter w = new(ms);
w.Write(AnalogHeaderUpToTypeCode); // 11 bytes (incl 0x4E leading marker, ends at sub-marker 0x02)
w.Write(isDelta ? AnalogHeaderUpToTypeCodeDelta : AnalogHeaderUpToTypeCodeCyclic); // 11 bytes
w.Write(typeCode); // 1 byte data-type discriminator
w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2)
WriteCompactAscii(w, tagName); // var
w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF
WriteCompactAscii(w, description ?? string.Empty); // var
WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS")
w.Write(AnalogFlagBlock); // 7 bytes
w.Write(isDelta ? AnalogFlagBlockDelta : AnalogFlagBlockCyclic); // 7 bytes
w.Write(storageRateMs); // uint32
if (isDelta)
{
w.Write(AnalogDeltaPostStorageRatePadding); // 4 bytes (Delta-only)
}
w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
if (minEU == DefaultMinEU && maxEU == DefaultMaxEU && minRaw == DefaultMinRaw && maxRaw == DefaultMaxRaw)
@@ -110,7 +110,8 @@ internal sealed class HistorianWcfTagWriteOrchestrator
minRaw: definition.MinRaw,
maxRaw: definition.MaxRaw,
storageRateMs: definition.StorageRateMs,
applyScaling: definition.ApplyScaling);
applyScaling: definition.ApplyScaling,
storageType: definition.StorageType);
bool ok = historyChannel.EnsureTags2(
handle: handle,
@@ -488,6 +488,50 @@ public sealed class HistorianClientIntegrationTests
}
}
[Fact]
public async Task EnsureTagAsync_StorageTypeDelta_PersistsToTagTableAsTwo()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
const string sandboxTag = "RetestSdkWriteStorageTypeDeltaRT";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
try
{
bool ok = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "SDK Delta round-trip",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
StorageType = AVEVA.Historian.Client.Models.HistorianStorageType.Delta,
}, CancellationToken.None);
Assert.True(ok, "EnsureTagAsync(Delta) returned false");
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
sql.Open();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT StorageType FROM Tag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
object? st = cmd.ExecuteScalar();
Assert.NotNull(st);
Assert.Equal((int)AVEVA.Historian.Client.Models.HistorianStorageType.Delta, Convert.ToInt32(st));
}
finally
{
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
}
[Fact]
public async Task EnsureTagAsync_NonDefaultStorageRate_PersistsToTagTable()
{
@@ -153,6 +153,34 @@ public sealed class HistorianTagWriteProtocolTests
storageRateMs: 0u));
}
[Fact]
public void SerializeAnalogCTagMetadata_StorageTypeDelta_FlipsHeaderByte10AndFlagBlockByte1AndAddsFourBytePadding()
{
// Captured 2026-05-04 by toggling --write-storage-type on the native harness:
// Delta differs from Cyclic in three places — header byte 10 (0x02 -> 0x06),
// flag-block byte 1 (0x01 -> 0x02), and 4 zero bytes inserted after StorageRate
// before the FILETIME. Net length difference is +4 bytes for Delta.
byte[] cyclic = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteStorageTypeRT",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
storageType: AVEVA.Historian.Client.Models.HistorianStorageType.Cyclic);
byte[] delta = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteStorageTypeRT",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
storageType: AVEVA.Historian.Client.Models.HistorianStorageType.Delta);
Assert.Equal(cyclic.Length + 4, delta.Length);
// Header byte 10 (storage-type sub-marker before the data-type code).
Assert.Equal(0x02, cyclic[10]);
Assert.Equal(0x06, delta[10]);
// The data-type code at byte 11 is unchanged.
Assert.Equal(cyclic[11], delta[11]);
}
[Fact]
public void SerializeAnalogCTagMetadata_ApplyScalingTrue_FlipsTrailerSecondByte()
{
@@ -238,6 +238,7 @@ internal static class Program
bool skipAddTag = HasFlag(args, "--write-skip-add-tag");
bool skipAddValue = HasFlag(args, "--write-skip-add-value");
bool writeApplyScaling = HasFlag(args, "--write-apply-scaling");
string writeStorageTypeName = GetArg(args, "--write-storage-type") ?? "Cyclic";
// Decoded via dnlib — actual enum field types on HistorianTag:
// set_TagDataType stfld ArchestrA.HistorianDataType HistorianTag::dataType
@@ -255,7 +256,7 @@ internal static class Program
SetProperty(tag, "TagDescription", "SDK write-RE sandbox tag");
SetProperty(tag, "EngineeringUnit", "test");
SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, writeDataTypeName, ignoreCase: true));
SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, "Cyclic", ignoreCase: true));
SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, writeStorageTypeName, ignoreCase: true));
SetProperty(tag, "MinEU", writeMinEu);
SetProperty(tag, "MaxEU", writeMaxEu);
SetProperty(tag, "MinRaw", writeMinRaw);