[M4] mxaccess: Session::write_value_at + write_value_secured_at

Adds the timestamped + verified-write paths on top of the wave 1
write path. Plus a SystemTime → FILETIME helper so callers don't have
to do the 1970→1601 epoch arithmetic by hand.

New
- Session::write_value_at(reference, value, timestamp_filetime) —
  port of MxNativeSession.Write2Async (cs:187-209). Delegates to
  NmxClient::write2 with the same routing as write_value.
- Session::write_value_secured_at(reference, value, ts, security) —
  port of MxNativeSession.WriteSecured2Async (cs:223-248). Uses the
  session's options.engine_name as the client name (matches cs:239's
  _options.EngineName convention). Single-user secured writes pass
  current_user_id == verifier_user_id per R6 verification.
- system_time_to_filetime(SystemTime) -> Result<i64, Error>: converts
  via the canonical 11_644_473_600s offset between 1970-01-01 and
  1601-01-01. Pre-1970 values map to Configuration::InvalidArgument.

Tests (7 new in mxaccess; total 26)
- write_value_at round-trip via in-memory resolver + hand-rolled server.
- write_value_secured_at round-trip with single-user (same id twice).
- write_value_at propagates non-zero HRESULT as InvalidArgument.
- system_time_to_filetime: Unix-epoch known value
  (11_644_473_600 * 10_000_000), +1s offset, +500ms subsecond
  conversion, pre-1970 rejection.

One targeted fix: rewrote a doc comment that started a continuation
line with `+ verifier user pair` — clippy parsed `+` as a markdown
list bullet (clippy::doc_lazy_continuation).

Test count delta: 487 -> 494 (+7). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 09:08:22 -04:00
parent 12cb10c3a1
commit bf95995573
+253 -1
View File
@@ -26,6 +26,7 @@
//! - Auto-resolving COM activation (followup F12).
use std::sync::Arc;
use std::time::SystemTime;
use mxaccess_galaxy::{GalaxyTagMetadata, Resolver, ResolverError};
use mxaccess_nmx::{NmxClient, NmxClientError, WriteValue};
@@ -35,7 +36,46 @@ use mxaccess_rpc::transport::TransportError;
use std::net::SocketAddr;
use tokio::sync::Mutex;
use crate::{ConfigError, ConnectionError, Error, RecoveryPolicy, Session, SessionOptions};
use crate::{
ConfigError, ConnectionError, Error, RecoveryPolicy, SecurityContext, Session, SessionOptions,
};
/// Convert a `SystemTime` to a Windows FILETIME tick count (100-ns
/// intervals since 1601-01-01 UTC). Mirrors `DateTime.ToFileTime()`
/// (referenced at `NmxWriteMessage.cs:248` and used by every
/// `Write2*` path).
///
/// # Errors
/// [`Error::Configuration`] when `time` is before the Windows epoch
/// (1601-01-01 UTC). FILETIME values are always non-negative on the
/// wire; pre-1601 values cannot be represented.
pub fn system_time_to_filetime(time: SystemTime) -> Result<i64, Error> {
// Unix epoch = 1970-01-01 UTC; FILETIME epoch = 1601-01-01 UTC.
// Offset = 11644473600 seconds (134,774 days × 86,400 s/day).
const FILETIME_TO_UNIX_EPOCH_SECONDS: i64 = 11_644_473_600;
let duration_since_unix_epoch = time.duration_since(SystemTime::UNIX_EPOCH).map_err(|_| {
Error::Configuration(ConfigError::InvalidArgument {
detail: "timestamp is before the Unix epoch (1970-01-01 UTC)".to_string(),
})
})?;
let unix_secs = i64::try_from(duration_since_unix_epoch.as_secs()).map_err(|_| {
Error::Configuration(ConfigError::InvalidArgument {
detail: "timestamp seconds-since-epoch overflows i64".to_string(),
})
})?;
let unix_nanos = duration_since_unix_epoch.subsec_nanos() as i64;
let filetime_secs = unix_secs + FILETIME_TO_UNIX_EPOCH_SECONDS;
let filetime_ticks = filetime_secs
.checked_mul(10_000_000)
.and_then(|v| v.checked_add(unix_nanos / 100))
.ok_or_else(|| {
Error::Configuration(ConfigError::InvalidArgument {
detail: "timestamp ticks overflow i64".to_string(),
})
})?;
Ok(filetime_ticks)
}
/// Inner state of [`Session`] when connected over NMX. Held inside the
/// public type's `Arc<SessionInner>` so the public clone surface stays
@@ -180,6 +220,102 @@ impl Session {
Ok(())
}
/// Write a value with an explicit Windows FILETIME timestamp. Mirrors
/// `MxNativeSession.Write2Async` (`MxNativeSession.cs:187-209`).
///
/// `timestamp_filetime` is a 64-bit Windows FILETIME tick count
/// (100-ns intervals since 1601-01-01 UTC). Use
/// [`system_time_to_filetime`] to convert from a `SystemTime`.
///
/// # Errors
/// As for [`Self::write_value`].
pub async fn write_value_at(
&self,
reference: &str,
value: WriteValue,
timestamp_filetime: i64,
) -> Result<(), Error> {
self.ensure_connected()?;
let inner = self.inner.clone();
let metadata = inner
.resolver
.resolve(reference)
.await
.map_err(map_resolver)?;
let opts = &inner.options;
let mut nmx = inner.nmx.lock().await;
let hr = nmx
.write2(
opts.local_engine_id,
&metadata,
&value,
timestamp_filetime,
/* write_index */ 1,
/* client_token */ 0,
opts.galaxy_id,
/* source_galaxy_id */ i32::from(opts.galaxy_id),
opts.source_platform_id,
)
.await
.map_err(map_nmx)?;
ensure_hresult_ok(hr)?;
Ok(())
}
/// Verified write — secured-classification tags require a pair of
/// user ids (current + verifier). Single-user secured writes pass
/// the same id twice, per `wwtools/mxaccesscli/` verification at R6
/// in `design/70-risks-and-open-questions.md`. Mirrors
/// `MxNativeSession.WriteSecured2Async` (`cs:223-248`).
///
/// `client_name` defaults to the session's [`SessionOptions::engine_name`]
/// — same convention as `cs:239` which passes `_options.EngineName`.
/// The Rust API exposes the engine name automatically; if a caller
/// needs a different identity, change `engine_name` on the
/// `SessionOptions` before connect.
///
/// `timestamp_filetime` is a Windows FILETIME (see
/// [`Self::write_value_at`]).
///
/// # Errors
/// As for [`Self::write_value`].
pub async fn write_value_secured_at(
&self,
reference: &str,
value: WriteValue,
timestamp_filetime: i64,
security: SecurityContext,
) -> Result<(), Error> {
self.ensure_connected()?;
let inner = self.inner.clone();
let metadata = inner
.resolver
.resolve(reference)
.await
.map_err(map_resolver)?;
let opts = &inner.options;
let mut nmx = inner.nmx.lock().await;
let hr = nmx
.write_secured2(
opts.local_engine_id,
&metadata,
&value,
timestamp_filetime,
&opts.engine_name,
security.current_user_id,
security.verifier_user_id,
/* write_index */ 1,
/* client_token */ 0,
opts.galaxy_id,
/* source_galaxy_id */ i32::from(opts.galaxy_id),
opts.source_platform_id,
)
.await
.map_err(map_nmx)?;
ensure_hresult_ok(hr)?;
Ok(())
}
/// Pre-resolve the wire kind a tag expects without dispatching a
/// write. Convenience wrapper that pulls the metadata through the
/// configured resolver and delegates to
@@ -654,4 +790,120 @@ mod tests {
assert_eq!(NmxTransferMessageKind::Write as u8, 3);
assert_eq!(NmxTransferEnvelope::HEADER_LEN, 46);
}
// ---- write_value_at + write_value_secured_at ----------------------
#[tokio::test]
async fn write_value_at_round_trip() {
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new())]).await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt",
sample_metadata(),
)]));
let session = connect_test_session(addr, resolver).await.unwrap();
// 0x1F0E_2D60_4C00_0000 ≈ FILETIME for sometime in early 2030.
// Exact value doesn't matter for the round-trip — just that it
// round-trips end-to-end.
session
.write_value_at(
"TestObj.TestInt",
WriteValue::Int32(42),
0x1F0E_2D60_4C00_0000,
)
.await
.unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn write_value_secured_at_round_trip_single_user() {
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new())]).await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt",
sample_metadata(),
)]));
let session = connect_test_session(addr, resolver).await.unwrap();
// Single-user secured write — same id twice per R6 verification.
let security = SecurityContext {
current_user_id: 7,
verifier_user_id: 7,
};
session
.write_value_secured_at(
"TestObj.TestInt",
WriteValue::Int32(99),
0x1F0E_2D60_4C00_0000,
security,
)
.await
.unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn write_value_at_propagates_non_zero_hresult() {
let (addr, handle) = unauthenticated_server(vec![(0x4242, Vec::new())]).await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt",
sample_metadata(),
)]));
let session = connect_test_session(addr, resolver).await.unwrap();
let err = session
.write_value_at(
"TestObj.TestInt",
WriteValue::Int32(0),
0x1F0E_2D60_4C00_0000,
)
.await
.unwrap_err();
match err {
Error::Configuration(ConfigError::InvalidArgument { detail }) => {
assert!(detail.contains("0x00004242"));
}
other => panic!("expected InvalidArgument with HRESULT, got {other:?}"),
}
handle.await.unwrap();
}
// ---- system_time_to_filetime helper -------------------------------
#[test]
fn system_time_to_filetime_unix_epoch_is_known_value() {
// Unix epoch = FILETIME 0 + 11644473600 * 10000000 ticks.
let expected_ticks: i64 = 11_644_473_600 * 10_000_000;
let got = system_time_to_filetime(SystemTime::UNIX_EPOCH).unwrap();
assert_eq!(got, expected_ticks);
}
#[test]
fn system_time_to_filetime_round_trip_through_duration() {
// A small offset above epoch should produce epoch ticks + offset
// ticks (in 100-ns units).
let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1);
let expected: i64 = (11_644_473_600 + 1) * 10_000_000;
assert_eq!(system_time_to_filetime(t).unwrap(), expected);
}
#[test]
fn system_time_to_filetime_subsec_nanos_count_in_100ns() {
// 0.5s after epoch = 5_000_000 additional ticks (100-ns units).
let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(500);
let expected: i64 = 11_644_473_600 * 10_000_000 + 5_000_000;
assert_eq!(system_time_to_filetime(t).unwrap(), expected);
}
#[test]
fn system_time_to_filetime_pre_1970_rejected() {
// SystemTime::UNIX_EPOCH - 1 second is before the Unix epoch on
// platforms where SystemTime supports it. duration_since fails
// with a SystemTimeError; we map it to InvalidArgument.
let t = SystemTime::UNIX_EPOCH
.checked_sub(std::time::Duration::from_secs(1))
.expect("platform supports pre-epoch SystemTime");
let err = system_time_to_filetime(t).unwrap_err();
assert!(matches!(
err,
Error::Configuration(ConfigError::InvalidArgument { .. })
));
}
}