Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled
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:
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user