fix(rust-client): async CA file read; typed ping/session-state/worker-info/drain-events
This commit is contained in:
@@ -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)?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user