Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled

Layout:
- src/                    .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
                          MxAsbClient, probes, tests, harnesses. Executable spec.
- design/                 Architectural plan for the Rust port (M0–M6), error
                          model, protocol invariants, risks (R1–R16), adversarial
                          review log (review.md).
- rust/                   Rust workspace. M0 skeleton + M1 codec parity.
                          mxaccess-codec: 215 unit tests + 2 cross-implementation
                          parity tests (byte-identical against .NET reference).
                          Other crates are M0 stubs awaiting M2+.
- captures/               Frida + netsh + pcap evidence per CLAUDE.md
                          ("captures are evidence, not throwaway logs").
- analysis/               Decompiled C# (frida/proxy/decompiled-*),
                          Ghidra exports for native DLLs (`exports/` only —
                          working state at `projects/` and AVEVA's input
                          binaries at `input/` are gitignored).
- docs/                   Reverse-engineering reference docs.
- tools/                  Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
                          Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/      Rust CI: fmt + build + test + clippy on Windows.
- LICENSE                 MIT (Joseph Doherty, 2026).

Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly

Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 06:21:00 -04:00
parent 43733699b0
commit fe2a6db786
3849 changed files with 352975 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "mxaccess"
description = "Async Tokio façade for AVEVA / Wonderware MXAccess. Exposes Session, Subscription, Transport trait, and the public Error model."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
mxaccess-codec = { path = "../mxaccess-codec" }
thiserror = { workspace = true }
[features]
default = []
# Transport feature gates land in M2-M5.
nmx = []
asb = []
metrics = []
serde = ["mxaccess-codec/serde"]
# `live` gates integration tests that hit a running AVEVA install. Driven by
# the `MX_LIVE` env var via `tools/Setup-LiveProbeEnv.ps1`.
live = []
[lints]
workspace = true
@@ -0,0 +1,7 @@
//! `asb-subscribe` — subscribe via the ASB transport.
//!
//! M0 stub. See `design/60-roadmap.md` M5.
fn main() {
eprintln!("asb-subscribe: stubbed (M0). See design/60-roadmap.md M5.");
}
@@ -0,0 +1,7 @@
//! `connect-write-read` — connect, write a value, read it back.
//!
//! M0 stub. Filled in as M4 lands. See `design/60-roadmap.md` M4 DoD.
fn main() {
eprintln!("connect-write-read: stubbed (M0). See design/60-roadmap.md M4.");
}
@@ -0,0 +1,8 @@
//! `multi-tag` — `subscribe_many` over several tags. Non-atomic per-tag
//! advise loop matching `MxNativeSession.SubscribeAsync` (`MxNativeSession.cs:250-270`).
//!
//! M0 stub.
fn main() {
eprintln!("multi-tag: stubbed (M0). See design/60-roadmap.md M4.");
}
+10
View File
@@ -0,0 +1,10 @@
//! `recovery` — caller-driven `Session::recover_connection(policy)` after
//! heartbeat-loss.
//!
//! Recovery is opt-in, not automatic — see `design/20-async-layer.md` and
//! `MxNativeSession.cs:383-440`. In-flight calls during recovery fail.
//! M0 stub.
fn main() {
eprintln!("recovery: stubbed (M0). See design/60-roadmap.md M4.");
}
@@ -0,0 +1,11 @@
//! `secured-write` — Verified Write demonstrations.
//!
//! `write_secured` (no timestamp) and `write_secured_at` (timestamped), each
//! taking `(current_user_id, verifier_user_id)`. Demonstrates both the
//! single-user path (`current == verifier`) and the two-person verification
//! path. Per `wwtools/mxaccesscli/`, the LMX `WriteSecured` always takes two
//! ids — single-user is just same-id-twice, not a separate API. M0 stub.
fn main() {
eprintln!("secured-write: stubbed (M0). See design/60-roadmap.md M4.");
}
@@ -0,0 +1,9 @@
//! `subscribe-buffered` — buffered subscription with delivery cadence.
//!
//! Per `wwtools/mxaccesscli/docs/api-notes.md:138-140`, "buffered" is a
//! delivery-cadence knob (`SetBufferedUpdateInterval`), not multi-sample
//! payload bundling. M0 stub.
fn main() {
eprintln!("subscribe-buffered: stubbed (M0). See design/60-roadmap.md M6.");
}
@@ -0,0 +1,7 @@
//! `subscribe` — subscribe to a single tag and stream `DataChange` events.
//!
//! M0 stub.
fn main() {
eprintln!("subscribe: stubbed (M0). See design/60-roadmap.md M4.");
}
+347
View File
@@ -0,0 +1,347 @@
//! `mxaccess` — async Tokio façade for AVEVA / Wonderware MXAccess.
//!
//! Public API surface: `Session`, `Subscription`, `DataChange`, `Transport`
//! trait, `Error` taxonomy. Two transports plug into the same trait
//! (`NmxTransport`, `AsbTransport`) so M5 (ASB) can develop in parallel with
//! M3/M4 (NMX) — see `design/60-roadmap.md` sequencing notes.
//!
//! M0 stub. Everything `todo!()`s. Real implementation lands across M3
//! (`mxaccess-nmx` wiring), M4 (NMX façade complete), and M5 (ASB transport).
//! See `design/20-async-layer.md` for the full API specification.
#![forbid(unsafe_code)]
use std::borrow::Cow;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
pub use mxaccess_codec::{
MxDataType, MxReferenceHandle, MxStatus, MxStatusCategory, MxStatusSource, MxValue, MxValueKind,
};
// ---- Public types --------------------------------------------------------
/// Async session façade. Cheap clones share the inner state; drop of the last
/// clone fires `UnregisterEngine` best-effort. For deterministic shutdown,
/// call `Session::shutdown(timeout).await`.
#[derive(Debug, Clone)]
pub struct Session {
_inner: Arc<SessionInner>,
}
#[derive(Debug)]
struct SessionInner;
/// Stream of `DataChange` items. Drop sends `UnAdvise` via the long-lived
/// connection task (no `tokio::spawn` from `Drop`).
#[derive(Debug)]
pub struct Subscription;
/// One inbound update. Carries both `quality: u16` (legacy 16-bit OPC quality,
/// e.g. `0xC0` = "Good") and `status: MxStatus` (the richer category model).
/// Both are surfaced because real MxAccess emits them as separate fields per
/// `wwtools/mxaccesscli/docs/api-notes.md:104-105`.
#[derive(Debug, Clone)]
pub struct DataChange {
pub reference: Arc<str>,
pub value: MxValue,
/// Legacy 16-bit OPC quality. Distinct from `status: MxStatus`.
pub quality: u16,
pub timestamp: SystemTime,
pub status: MxStatus,
}
/// Buffered subscription delivery — single-sample-per-event with a configurable
/// flush cadence. **Not** multi-sample payload bundles per
/// `wwtools/mxaccesscli/docs/api-notes.md:138-140` (R2 verified).
#[derive(Debug, Clone)]
pub struct BufferedSubscription;
#[derive(Debug, Clone, Copy)]
pub struct BufferedOptions {
pub update_interval_ms: u32,
}
#[derive(Debug, Clone)]
pub struct SecurityContext {
pub current_user_id: i32,
pub verifier_user_id: i32,
}
/// Build-time transport selection. NMX-only, ASB-only, or both.
#[derive(Debug, Clone)]
pub struct ConnectionOptions;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TransportKind {
Nmx,
Asb,
}
#[derive(Debug, Clone, Copy)]
pub struct TransportCapabilities {
pub buffered_subscribe: bool,
pub activate_suspend: bool,
pub operation_complete_frame: bool,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RecoveryPolicy;
/// Not `Clone` — `Error` is not `Clone`-able (thiserror chains an
/// `io::Error` source which is not `Clone`). Consumers that need to clone an
/// event should wrap it in `Arc`.
#[derive(Debug)]
#[non_exhaustive]
pub enum RecoveryEvent {
Started {
attempt: u32,
},
Failed {
attempt: u32,
error: Error,
/// Whether the configured policy will retry. Mirrors
/// `MxNativeRecoveryFailureEvent.WillRetry` (`MxNativeSession.cs:47-51`).
will_retry: bool,
},
Recovered {
attempt: u32,
},
}
// ---- Error taxonomy ------------------------------------------------------
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("connection: {0}")]
Connection(#[from] ConnectionError),
#[error("authentication: {0}")]
Auth(#[from] AuthError),
#[error("protocol: {0}")]
Protocol(#[from] ProtocolError),
#[error("configuration: {0}")]
Configuration(#[from] ConfigError),
#[error("type mismatch on {reference}: expected {expected:?}, got {actual:?}")]
TypeMismatch {
reference: Arc<str>,
expected: MxValueKind,
actual: MxValueKind,
},
#[error("security: {0}")]
Security(#[from] SecurityError),
#[error("unsupported on {transport:?} transport: {operation}")]
Unsupported {
operation: Cow<'static, str>,
transport: TransportKind,
},
#[error("operation timed out after {0:?}")]
Timeout(Duration),
#[error("operation cancelled")]
Cancelled,
// Field is named `detected_by` (not `source`) to match the codec's
// `MxStatus.detected_by` and to avoid thiserror's `#[source]` attribute
// semantics (which would require `MxStatusSource: std::error::Error`).
#[error(
"status: success={success} category={category:?} detected_by={detected_by:?} detail={detail}"
)]
Status {
success: i16,
category: MxStatusCategory,
detected_by: MxStatusSource,
detail: i16,
},
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConnectionError {
#[error("RPC server unavailable")]
ServerUnavailable,
#[error("callback proxy/stub not registered (REGDB_E_CLASSNOTREG)")]
CallbackProxyMissing,
#[error("engine not registered (UninitializedObject / ERROR_INVALID_STATE)")]
EngineNotRegistered,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AuthError {
#[error("NTLM rejected: {reason}")]
Ntlm { reason: String },
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ProtocolError {
#[error("decode at offset {offset} ({reason}); buffer len {buffer_len}")]
Decode {
offset: usize,
reason: &'static str,
buffer_len: usize,
},
#[error("inner length {declared} does not match body length {actual}")]
InnerLengthMismatch { declared: i32, actual: usize },
#[error("unexpected opcode {0:#x}")]
UnexpectedOpcode(u8),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("invalid argument: {detail}")]
InvalidArgument { detail: String },
#[error("galaxy resolver: {reason}")]
Galaxy { reason: String },
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SecurityError {
#[error("callback OBJREF rejected (HRESULT 0x8001011D)")]
CallbackObjRefRejected,
#[error("verifier user token required for secured write")]
VerifierRequired,
}
// ---- Transport trait -----------------------------------------------------
/// Generic-only trait — `dyn Transport` is intentionally unsupported (see
/// design/20-async-layer.md L53 fix). Consumers parameterise on `<T: Transport>`.
pub trait Transport: Send + Sync + 'static {
fn capabilities(&self) -> TransportCapabilities;
fn kind(&self) -> TransportKind;
}
// ---- Session API surface (stubs) -----------------------------------------
impl Session {
pub async fn connect(_options: ConnectionOptions) -> Result<Self, Error> {
// M3+
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::connect"),
transport: TransportKind::Nmx,
})
}
pub async fn write(&self, _reference: &str, _value: MxValue) -> Result<(), Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::write"),
transport: TransportKind::Nmx,
})
}
pub async fn write_with_completion(
&self,
_reference: &str,
_value: MxValue,
_client_token: u32,
) -> Result<(), Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::write_with_completion"),
transport: TransportKind::Nmx,
})
}
pub async fn write_with_timestamp(
&self,
_reference: &str,
_value: MxValue,
_timestamp: SystemTime,
) -> Result<(), Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::write_with_timestamp"),
transport: TransportKind::Nmx,
})
}
/// Verified Write — always two-id per `wwtools/mxaccesscli/`. Single-user
/// secured writes pass `current_user_id == verifier_user_id`.
pub async fn write_secured(
&self,
_reference: &str,
_value: MxValue,
_security: SecurityContext,
) -> Result<(), Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::write_secured"),
transport: TransportKind::Nmx,
})
}
pub async fn write_secured_at(
&self,
_reference: &str,
_value: MxValue,
_timestamp: SystemTime,
_security: SecurityContext,
) -> Result<(), Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::write_secured_at"),
transport: TransportKind::Nmx,
})
}
/// Read-as-subscribe per `MxNativeSession.ReadAsync` — requires a positive
/// timeout, drop guarantees `UnAdvise`.
pub async fn read(&self, _reference: &str, _timeout: Duration) -> Result<DataChange, Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::read"),
transport: TransportKind::Nmx,
})
}
pub async fn subscribe(&self, _reference: &str) -> Result<Subscription, Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::subscribe"),
transport: TransportKind::Nmx,
})
}
pub async fn subscribe_many(&self, _references: &[&str]) -> Result<Subscription, Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::subscribe_many"),
transport: TransportKind::Nmx,
})
}
pub async fn subscribe_buffered(
&self,
_reference: &str,
_options: BufferedOptions,
) -> Result<Subscription, Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::subscribe_buffered"),
transport: TransportKind::Nmx,
})
}
pub async fn recover_connection(&self, _policy: RecoveryPolicy) -> Result<(), Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::recover_connection"),
transport: TransportKind::Nmx,
})
}
/// Orderly shutdown — flushes `UnAdvise` for every live subscription,
/// then `UnregisterEngine`. Recommended exit path for production code.
pub async fn shutdown(self, _timeout: Duration) -> Result<(), Error> {
Err(Error::Unsupported {
operation: Cow::Borrowed("Session::shutdown"),
transport: TransportKind::Nmx,
})
}
}