[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:
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user