fix(rust-client): async CA file read; typed ping/session-state/worker-info/drain-events

This commit is contained in:
Joseph Doherty
2026-10-22 06:09:51 -04:00
parent 488649ce99
commit 0345726a79
7 changed files with 265 additions and 29 deletions
+1 -1
View File
@@ -187,7 +187,7 @@ impl GatewayClient {
})?;
endpoint = endpoint.connect_timeout(options.connect_timeout());
if let Some(tls) = build_tls_config(&options)? {
if let Some(tls) = build_tls_config(&options).await? {
endpoint = endpoint.tls_config(tls)?;
}
+1 -1
View File
@@ -182,7 +182,7 @@ impl GalaxyClient {
})?;
endpoint = endpoint.connect_timeout(options.connect_timeout());
if let Some(tls) = build_tls_config(&options)? {
if let Some(tls) = build_tls_config(&options).await? {
endpoint = endpoint.tls_config(tls)?;
}
+28 -15
View File
@@ -3,7 +3,6 @@
//! chain of `with_*` setters; the `Debug` impl redacts the API key.
use std::fmt;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
@@ -235,7 +234,9 @@ pub(crate) fn tls_trust_decision(options: &ClientOptions) -> TlsTrustDecision {
/// rejects the configuration early with an actionable error. The strict opt-in
/// instead loads the system trust roots so a certificate chaining to an
/// already-trusted root validates.
pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientTlsConfig>, Error> {
pub(crate) async fn build_tls_config(
options: &ClientOptions,
) -> Result<Option<ClientTlsConfig>, Error> {
let decision = tls_trust_decision(options);
if decision == TlsTrustDecision::None {
return Ok(None);
@@ -248,10 +249,13 @@ pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientT
match decision {
TlsTrustDecision::PinnedCa => {
let ca_file = options.ca_file().expect("PinnedCa implies a CA file");
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
})?;
let certificate =
tokio::fs::read(ca_file)
.await
.map_err(|source| Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
})?;
tls = tls.ca_certificate(Certificate::from_pem(certificate));
}
TlsTrustDecision::SystemRoots => {
@@ -334,11 +338,11 @@ mod tests {
assert!(!debug.contains("mxgw_secret"));
}
#[test]
fn plaintext_needs_no_tls() {
#[tokio::test]
async fn plaintext_needs_no_tls() {
let options = ClientOptions::new("http://127.0.0.1:5000").with_plaintext(true);
assert_eq!(tls_trust_decision(&options), TlsTrustDecision::None);
assert!(build_tls_config(&options).unwrap().is_none());
assert!(build_tls_config(&options).await.unwrap().is_none());
}
#[test]
@@ -349,8 +353,8 @@ mod tests {
assert_eq!(tls_trust_decision(&options), TlsTrustDecision::PinnedCa);
}
#[test]
fn strict_without_ca_uses_system_roots() {
#[tokio::test]
async fn strict_without_ca_uses_system_roots() {
// Regression for Client.Rust-031: strict verification with no pinned CA
// must verify against the system trust roots, not produce a config with
// zero trust anchors. The trust decision proves roots are consulted; the
@@ -365,15 +369,24 @@ mod tests {
"strict-no-CA must request the system trust roots"
);
assert!(
build_tls_config(&options).unwrap().is_some(),
build_tls_config(&options).await.unwrap().is_some(),
"strict-no-CA must build a usable TLS config"
);
}
#[test]
fn lenient_without_ca_is_rejected() {
#[tokio::test]
async fn lenient_without_ca_is_rejected() {
let options = ClientOptions::new("https://127.0.0.1:5000").with_plaintext(false);
assert_eq!(tls_trust_decision(&options), TlsTrustDecision::RejectNoCa);
assert!(build_tls_config(&options).is_err());
assert!(build_tls_config(&options).await.is_err());
}
#[tokio::test]
async fn missing_pinned_ca_file_errors_without_blocking_read() {
let options = ClientOptions::new("https://127.0.0.1:5000")
.with_plaintext(false)
.with_ca_file("/nonexistent/definitely-missing-ca.pem");
let error = build_tls_config(&options).await.unwrap_err();
assert!(error.to_string().contains("failed to read CA file"));
}
}
+92 -7
View File
@@ -17,14 +17,15 @@ use crate::generated::mxaccess_gateway::v1::mx_command_reply;
use crate::generated::mxaccess_gateway::v1::{
ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest,
DrainEventsCommand, GetSessionStateCommand, GetWorkerInfoCommand, MxCommand, MxCommandKind,
MxCommandReply, MxCommandRequest, MxDataType, MxEvent, MxSparseArray, MxSparseElement,
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, ReadBulkCommand, RegisterCommand,
RemoveItemBulkCommand, RemoveItemCommand, SessionState, SetBufferedUpdateIntervalCommand,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, Write2BulkCommand,
Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand,
WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, WorkerInfoReply,
Write2BulkCommand, Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry,
WriteCommand, WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand,
};
use crate::value::{MxStatus, MxValue, MxValueProjection};
@@ -960,6 +961,90 @@ impl Session {
.await
}
/// Send a `Ping` diagnostic command to the worker. A successful return
/// means the worker answered; `message` is forwarded for log correlation.
///
/// # Errors
///
/// Returns [`Error::Command`] for non-OK statuses plus transport errors.
pub async fn ping(&self, message: &str) -> Result<(), Error> {
self.invoke(
MxCommandKind::Ping,
Payload::Ping(PingCommand {
message: message.to_owned(),
}),
)
.await?;
Ok(())
}
/// Fetch the gateway-side [`SessionState`] of this session.
///
/// # Errors
///
/// Returns [`Error::MalformedReply`] when the reply lacks a session-state
/// payload, plus the errors of [`invoke`](Self::invoke).
pub async fn get_session_state(&self) -> Result<SessionState, Error> {
let reply = self
.invoke(
MxCommandKind::GetSessionState,
Payload::GetSessionState(GetSessionStateCommand {}),
)
.await?;
match reply.payload {
Some(mx_command_reply::Payload::SessionState(state)) => {
Ok(SessionState::try_from(state.state).unwrap_or(SessionState::Unspecified))
}
_ => Err(Error::MalformedReply {
detail: "get_session_state reply did not carry a SessionState payload".to_owned(),
}),
}
}
/// Fetch worker process details (pid, version, MXAccess ProgID/CLSID).
///
/// # Errors
///
/// Returns [`Error::MalformedReply`] when the reply lacks a worker-info
/// payload, plus the errors of [`invoke`](Self::invoke).
pub async fn get_worker_info(&self) -> Result<WorkerInfoReply, Error> {
let reply = self
.invoke(
MxCommandKind::GetWorkerInfo,
Payload::GetWorkerInfo(GetWorkerInfoCommand {}),
)
.await?;
match reply.payload {
Some(mx_command_reply::Payload::WorkerInfo(info)) => Ok(info),
_ => Err(Error::MalformedReply {
detail: "get_worker_info reply did not carry a WorkerInfo payload".to_owned(),
}),
}
}
/// Drain up to `max_events` queued events through the diagnostic command
/// (distinct from the streaming [`events`](Self::events) surface). The
/// reply is bounded server-side, so call repeatedly until it is empty.
///
/// # Errors
///
/// Returns [`Error::MalformedReply`] when the reply lacks a drain-events
/// payload, plus the errors of [`invoke`](Self::invoke).
pub async fn drain_events(&self, max_events: u32) -> Result<Vec<MxEvent>, Error> {
let reply = self
.invoke(
MxCommandKind::DrainEvents,
Payload::DrainEvents(DrainEventsCommand { max_events }),
)
.await?;
match reply.payload {
Some(mx_command_reply::Payload::DrainEvents(drain)) => Ok(drain.events),
_ => Err(Error::MalformedReply {
detail: "drain_events reply did not carry a DrainEvents payload".to_owned(),
}),
}
}
/// Issue a raw `Invoke` for an arbitrary command, without filtering on
/// the protocol status. Useful when callers need the full reply for
/// commands not yet wrapped by `Session`.