diff --git a/rust/crates/mxaccess/src/session.rs b/rust/crates/mxaccess/src/session.rs index 27a4c2a..2c03a80 100644 --- a/rust/crates/mxaccess/src/session.rs +++ b/rust/crates/mxaccess/src/session.rs @@ -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 { + // 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` 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 = 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 = 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 = 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 { .. }) + )); + } }