use tracing::debug;
use crate::builder::WinrmClientBuilder;
use crate::command::{CommandOutput, encode_powershell_command};
use crate::config::{WinrmConfig, WinrmCredentials};
use crate::error::WinrmError;
use crate::shell::Shell;
use crate::soap;
use crate::transport::HttpTransport;
pub struct WinrmClient {
pub(crate) transport: HttpTransport,
}
impl WinrmClient {
#[tracing::instrument(level = "debug", skip(credentials))]
pub fn new(config: WinrmConfig, credentials: WinrmCredentials) -> Result<Self, WinrmError> {
Ok(Self {
transport: HttpTransport::new(config, credentials)?,
})
}
pub(crate) fn endpoint(&self, host: &str) -> String {
self.transport.endpoint(host)
}
pub(crate) fn config(&self) -> &WinrmConfig {
self.transport.config()
}
pub fn builder(config: WinrmConfig) -> WinrmClientBuilder {
WinrmClientBuilder::new(config)
}
pub(crate) async fn send_soap_raw(
&self,
host: &str,
body: String,
) -> Result<String, WinrmError> {
self.transport.send_soap_raw(host, body).await
}
#[tracing::instrument(level = "debug", skip(self))]
pub async fn create_shell(&self, host: &str) -> Result<String, WinrmError> {
let config = self.transport.config();
let envelope = soap::create_shell_request(&self.transport.endpoint(host), config);
let response = self.transport.send_soap_with_retry(host, envelope).await?;
soap::parse_shell_id(&response).map_err(WinrmError::Soap)
}
#[tracing::instrument(level = "debug", skip(self, creation_xml_b64))]
pub async fn open_psrp_shell(
&self,
host: &str,
creation_xml_b64: &str,
resource_uri: &str,
) -> Result<Shell<'_>, WinrmError> {
let config = self.transport.config();
let proposed_id = uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase();
let envelope = soap::create_psrp_shell_request(
&self.transport.endpoint(host),
config,
creation_xml_b64,
resource_uri,
&proposed_id,
);
let response = self.transport.send_soap_with_retry(host, envelope).await?;
let shell_id = soap::parse_shell_id(&response).map_err(WinrmError::Soap)?;
debug!(shell_id = %shell_id, "PSRP shell opened");
Ok(Shell::new_with_resource_uri(
self,
host.to_string(),
shell_id,
resource_uri.to_string(),
))
}
#[tracing::instrument(level = "debug", skip(self))]
pub async fn execute_command(
&self,
host: &str,
shell_id: &str,
command: &str,
args: &[&str],
) -> Result<String, WinrmError> {
let config = self.transport.config();
let envelope = soap::execute_command_request(
&self.transport.endpoint(host),
shell_id,
command,
args,
config.operation_timeout_secs,
config.max_envelope_size,
);
let response = self.transport.send_soap_with_retry(host, envelope).await?;
soap::parse_command_id(&response).map_err(WinrmError::Soap)
}
#[tracing::instrument(level = "debug", skip(self))]
pub async fn receive_output(
&self,
host: &str,
shell_id: &str,
command_id: &str,
) -> Result<soap::ReceiveOutput, WinrmError> {
let config = self.transport.config();
let envelope = soap::receive_output_request(
&self.transport.endpoint(host),
shell_id,
command_id,
config.operation_timeout_secs,
config.max_envelope_size,
);
let response = self.transport.send_soap_with_retry(host, envelope).await?;
soap::parse_receive_output(&response).map_err(WinrmError::Soap)
}
#[tracing::instrument(level = "debug", skip(self))]
pub async fn signal_terminate(
&self,
host: &str,
shell_id: &str,
command_id: &str,
) -> Result<(), WinrmError> {
let config = self.transport.config();
let envelope = soap::signal_terminate_request(
&self.transport.endpoint(host),
shell_id,
command_id,
config.operation_timeout_secs,
config.max_envelope_size,
);
self.transport.send_soap_with_retry(host, envelope).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip(self))]
pub async fn delete_shell(&self, host: &str, shell_id: &str) -> Result<(), WinrmError> {
let config = self.transport.config();
let envelope = soap::delete_shell_request(
&self.transport.endpoint(host),
shell_id,
config.operation_timeout_secs,
config.max_envelope_size,
);
self.transport.send_soap_with_retry(host, envelope).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip(self))]
pub async fn run_command(
&self,
host: &str,
command: &str,
args: &[&str],
) -> Result<CommandOutput, WinrmError> {
let shell_id = self.create_shell(host).await?;
debug!(shell_id = %shell_id, "WinRM shell created");
let result = self.run_in_shell(host, &shell_id, command, args).await;
self.delete_shell(host, &shell_id)
.await
.inspect_err(|e| debug!(error = %e, "failed to delete WinRM shell (best-effort)"))
.ok();
result
}
async fn run_in_shell(
&self,
host: &str,
shell_id: &str,
command: &str,
args: &[&str],
) -> Result<CommandOutput, WinrmError> {
let command_id = self.execute_command(host, shell_id, command, args).await?;
debug!(command_id = %command_id, "WinRM command started");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code: Option<i32> = None;
loop {
let output = self.receive_output(host, shell_id, &command_id).await?;
stdout.extend_from_slice(&output.stdout);
stderr.extend_from_slice(&output.stderr);
if output.exit_code.is_some() {
exit_code = output.exit_code;
}
if output.done {
break;
}
}
self.signal_terminate(host, shell_id, &command_id)
.await
.ok();
Ok(CommandOutput {
stdout,
stderr,
exit_code: exit_code.unwrap_or(-1),
})
}
#[tracing::instrument(level = "debug", skip(self, script))]
pub async fn run_powershell(
&self,
host: &str,
script: &str,
) -> Result<CommandOutput, WinrmError> {
let encoded = encode_powershell_command(script);
self.run_command(host, "powershell.exe", &["-EncodedCommand", &encoded])
.await
}
pub async fn run_command_with_cancel(
&self,
host: &str,
command: &str,
args: &[&str],
cancel: tokio_util::sync::CancellationToken,
) -> Result<CommandOutput, WinrmError> {
tokio::select! {
result = self.run_command(host, command, args) => result,
() = cancel.cancelled() => Err(WinrmError::Cancelled),
}
}
pub async fn run_powershell_with_cancel(
&self,
host: &str,
script: &str,
cancel: tokio_util::sync::CancellationToken,
) -> Result<CommandOutput, WinrmError> {
let encoded = encode_powershell_command(script);
self.run_command_with_cancel(
host,
"powershell.exe",
&["-EncodedCommand", &encoded],
cancel,
)
.await
}
#[tracing::instrument(level = "debug", skip(self))]
pub async fn run_wql(
&self,
host: &str,
query: &str,
namespace: Option<&str>,
) -> Result<String, WinrmError> {
let config = self.transport.config();
let endpoint = self.transport.endpoint(host);
let envelope = soap::enumerate_wql_request(
&endpoint,
query,
namespace,
config.operation_timeout_secs,
config.max_envelope_size,
);
let response = self.transport.send_soap_with_retry(host, envelope).await?;
let (mut items, mut context) =
soap::parse_enumerate_response(&response).map_err(WinrmError::Soap)?;
while let Some(ctx) = context {
let pull_envelope = soap::pull_request(
&endpoint,
&ctx,
config.operation_timeout_secs,
config.max_envelope_size,
);
let pull_response = self
.transport
.send_soap_with_retry(host, pull_envelope)
.await?;
let (more_items, next_ctx) =
soap::parse_enumerate_response(&pull_response).map_err(WinrmError::Soap)?;
items.push_str(&more_items);
context = next_ctx;
}
Ok(items)
}
#[tracing::instrument(level = "debug", skip(self))]
pub async fn open_shell(&self, host: &str) -> Result<Shell<'_>, WinrmError> {
let shell_id = self.create_shell(host).await?;
debug!(shell_id = %shell_id, "WinRM shell opened for reuse");
Ok(Shell::new(self, host.to_string(), shell_id))
}
pub(crate) async fn delete_shell_raw(
&self,
host: &str,
shell_id: &str,
) -> Result<(), WinrmError> {
self.delete_shell(host, shell_id).await
}
#[tracing::instrument(level = "debug", skip(self))]
pub async fn reconnect_shell(
&self,
host: &str,
shell_id: &str,
resource_uri: &str,
) -> Result<Shell<'_>, WinrmError> {
let config = self.transport.config();
let envelope = soap::reconnect_shell_request_with_uri(
&self.transport.endpoint(host),
shell_id,
config.operation_timeout_secs,
config.max_envelope_size,
resource_uri,
);
self.transport.send_soap_with_retry(host, envelope).await?;
debug!(shell_id = %shell_id, "WinRM shell reconnected");
Ok(Shell::new_with_resource_uri(
self,
host.to_string(),
shell_id.to_string(),
resource_uri.to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AuthMethod, EncryptionMode};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use wiremock::matchers::{header, method};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_creds() -> WinrmCredentials {
WinrmCredentials::new("admin", "pass", "")
}
fn basic_config(port: u16) -> WinrmConfig {
WinrmConfig {
port,
auth_method: AuthMethod::Basic,
connect_timeout_secs: 5,
operation_timeout_secs: 10,
..Default::default()
}
}
fn ntlm_config(port: u16) -> WinrmConfig {
WinrmConfig {
port,
auth_method: AuthMethod::Ntlm,
connect_timeout_secs: 5,
operation_timeout_secs: 10,
encryption: EncryptionMode::Never,
..Default::default()
}
}
#[test]
fn client_builds_correct_endpoint() {
let config = WinrmConfig::default();
let creds = WinrmCredentials::new("admin", "pass", "");
let client = WinrmClient::new(config, creds).unwrap();
assert_eq!(client.endpoint("win-01"), "http://win-01:5985/wsman");
}
#[test]
fn client_builds_https_endpoint() {
let config = WinrmConfig {
port: 5986,
use_tls: true,
..Default::default()
};
let creds = WinrmCredentials::new("admin", "pass", "");
let client = WinrmClient::new(config, creds).unwrap();
assert_eq!(client.endpoint("win-01"), "https://win-01:5986/wsman");
}
#[tokio::test]
async fn send_basic_success() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.and(header("Authorization", "Basic YWRtaW46cGFzcw=="))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SHELL-1</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "SHELL-1");
}
#[tokio::test]
async fn send_basic_auth_failure() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("auth failed") || err.contains("401"));
}
#[tokio::test]
async fn send_basic_soap_fault() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><s:Fault><s:Code><s:Value>s:Receiver</s:Value></s:Code><s:Reason><s:Text>Access denied</s:Text></s:Reason></s:Fault></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("SOAP") || err.contains("Access denied"));
}
#[tokio::test]
async fn execute_command_and_receive_output() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>S1</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>C1</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="C1">aGVsbG8=</rsp:Stream>
<rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let output = client
.run_command("127.0.0.1", "whoami", &[])
.await
.unwrap();
assert_eq!(output.exit_code, 0);
assert_eq!(output.stdout, b"hello");
}
#[tokio::test]
async fn run_powershell_encodes_and_executes() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>S2</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>C2</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="C2" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let output = client
.run_powershell("127.0.0.1", "Get-Process")
.await
.unwrap();
assert_eq!(output.exit_code, 0);
}
#[tokio::test]
async fn delete_shell_success() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client.delete_shell("127.0.0.1", "SHELL-1").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn signal_terminate_success() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client.signal_terminate("127.0.0.1", "S1", "C1").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn ntlm_handshake_success() {
let server = MockServer::start().await;
let port = server.address().port();
let mut type2 = vec![0u8; 48];
type2[0..8].copy_from_slice(b"NTLMSSP\0");
type2[8..12].copy_from_slice(&2u32.to_le_bytes());
type2[20..24].copy_from_slice(&0x0008_8201_u32.to_le_bytes()); type2[24..32].copy_from_slice(&[0x01; 8]); let type2_b64 = B64.encode(&type2);
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(401)
.append_header("WWW-Authenticate", format!("Negotiate {type2_b64}")),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>NTLM-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
let shell_id = client.create_shell("127.0.0.1").await.unwrap();
assert_eq!(shell_id, "NTLM-SHELL");
}
#[tokio::test]
async fn ntlm_rejected_credentials() {
let server = MockServer::start().await;
let port = server.address().port();
let mut type2 = vec![0u8; 48];
type2[0..8].copy_from_slice(b"NTLMSSP\0");
type2[8..12].copy_from_slice(&2u32.to_le_bytes());
type2[20..24].copy_from_slice(&0x0008_8201_u32.to_le_bytes());
type2[24..32].copy_from_slice(&[0x01; 8]);
let type2_b64 = B64.encode(&type2);
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(401)
.append_header("WWW-Authenticate", format!("Negotiate {type2_b64}")),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("auth") || err.contains("rejected"));
}
#[tokio::test]
async fn ntlm_unexpected_status_on_negotiate() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string("<ok/>"))
.mount(&server)
.await;
let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("expected 401"));
}
#[tokio::test]
async fn ntlm_missing_www_authenticate() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("WWW-Authenticate") || err.contains("auth"));
}
#[tokio::test]
async fn ntlm_non_success_after_auth() {
let server = MockServer::start().await;
let port = server.address().port();
let mut type2 = vec![0u8; 48];
type2[0..8].copy_from_slice(b"NTLMSSP\0");
type2[8..12].copy_from_slice(&2u32.to_le_bytes());
type2[20..24].copy_from_slice(&0x0008_8201_u32.to_le_bytes());
type2[24..32].copy_from_slice(&[0x01; 8]);
let type2_b64 = B64.encode(&type2);
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(401)
.append_header("WWW-Authenticate", format!("Negotiate {type2_b64}")),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Error"))
.mount(&server)
.await;
let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("500") || err.contains("Internal"));
}
#[tokio::test]
async fn ntlm_uses_explicit_domain_when_set() {
let server = MockServer::start().await;
let port = server.address().port();
let mut type2 = vec![0u8; 48];
type2[0..8].copy_from_slice(b"NTLMSSP\0");
type2[8..12].copy_from_slice(&2u32.to_le_bytes());
type2[20..24].copy_from_slice(&0x0008_8201_u32.to_le_bytes());
type2[24..32].copy_from_slice(&[0x01; 8]);
let type2_b64 = B64.encode(&type2);
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(401)
.append_header("WWW-Authenticate", format!("Negotiate {type2_b64}")),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DOMAIN-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let creds = WinrmCredentials::new("admin", "pass", "EXPLICIT-DOM");
let client = WinrmClient::new(ntlm_config(port), creds).unwrap();
let shell_id = client.create_shell("127.0.0.1").await.unwrap();
assert_eq!(shell_id, "DOMAIN-SHELL");
}
#[test]
fn winrm_error_display() {
let err = WinrmError::AuthFailed("bad creds".into());
assert_eq!(format!("{err}"), "WinRM auth failed: bad creds");
let err = WinrmError::Soap(crate::error::SoapError::MissingElement("ShellId".into()));
assert!(format!("{err}").contains("SOAP"));
let err = WinrmError::Ntlm(crate::error::NtlmError::InvalidMessage("bad".into()));
assert!(format!("{err}").contains("NTLM"));
}
#[tokio::test]
async fn execute_command_returns_server_command_id() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>EXACT-CMD-ID-789</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let cmd_id = client
.execute_command("127.0.0.1", "S1", "whoami", &[])
.await
.unwrap();
assert_eq!(cmd_id, "EXACT-CMD-ID-789");
}
#[tokio::test]
async fn delete_shell_sends_request_to_server() {
let server = MockServer::start().await;
let port = server.address().port();
let mock = Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.expect(1) .mount_as_scoped(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
client.delete_shell("127.0.0.1", "S1").await.unwrap();
drop(mock);
}
#[tokio::test]
async fn signal_terminate_sends_request_to_server() {
let server = MockServer::start().await;
let port = server.address().port();
let mock = Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.expect(1) .mount_as_scoped(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
client
.signal_terminate("127.0.0.1", "S1", "C1")
.await
.unwrap();
drop(mock);
}
#[tokio::test]
async fn run_command_exit_code_defaults_to_minus_one() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>S1</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>C1</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done"/>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let output = client.run_command("127.0.0.1", "test", &[]).await.unwrap();
assert_eq!(output.exit_code, -1);
}
#[tokio::test]
async fn shell_reuse_multiple_commands() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>REUSE-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>CMD-1</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="CMD-1">aGVsbG8=</rsp:Stream>
<rsp:CommandState CommandId="CMD-1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let shell = client.open_shell("127.0.0.1").await.unwrap();
assert_eq!(shell.shell_id(), "REUSE-SHELL");
let output1 = shell.run_command("whoami", &[]).await.unwrap();
assert_eq!(output1.stdout, b"hello");
assert_eq!(output1.exit_code, 0);
shell.close().await.unwrap();
}
#[tokio::test]
async fn shell_close_cleanup() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>CLOSE-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
let delete_mock = Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
)
.expect(1)
.mount_as_scoped(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let shell = client.open_shell("127.0.0.1").await.unwrap();
assert_eq!(shell.shell_id(), "CLOSE-SHELL");
shell.close().await.unwrap();
drop(delete_mock);
}
#[tokio::test]
async fn shell_send_input() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>INPUT-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
let input_mock = Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
)
.expect(1)
.mount_as_scoped(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let shell = client.open_shell("127.0.0.1").await.unwrap();
let result = shell.send_input("CMD-1", b"hello\n", true).await;
assert!(result.is_ok());
drop(input_mock);
}
#[tokio::test]
async fn shell_signal_ctrl_c() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>CTRLC-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
let signal_mock = Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
)
.expect(1)
.mount_as_scoped(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let shell = client.open_shell("127.0.0.1").await.unwrap();
let result = shell.signal_ctrl_c("CMD-1").await;
assert!(result.is_ok());
drop(signal_mock);
}
#[test]
fn builder_pattern_constructs_client() {
let client = WinrmClient::builder(WinrmConfig::default())
.credentials(WinrmCredentials::new("admin", "pass", ""))
.build()
.unwrap();
assert_eq!(client.endpoint("host"), "http://host:5985/wsman");
}
#[tokio::test]
async fn shell_run_powershell() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>PS-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>PS-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="PS-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let shell = client.open_shell("127.0.0.1").await.unwrap();
let output = shell.run_powershell("Get-Process").await.unwrap();
assert_eq!(output.exit_code, 0);
}
fn retry_config(port: u16, max_retries: u32) -> WinrmConfig {
WinrmConfig {
port,
auth_method: AuthMethod::Basic,
connect_timeout_secs: 5,
operation_timeout_secs: 10,
max_retries,
..Default::default()
}
}
#[tokio::test]
async fn retry_succeeds_after_transient_errors() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.and(header("Authorization", "Basic YWRtaW46cGFzcw=="))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>RETRY-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let client = WinrmClient::new(retry_config(port, 2), test_creds()).unwrap();
let shell_id = client.create_shell("127.0.0.1").await.unwrap();
assert_eq!(shell_id, "RETRY-SHELL");
}
#[tokio::test]
async fn retry_not_applied_to_auth_errors() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = WinrmClient::new(retry_config(port, 3), test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("auth") || err.contains("401"));
}
#[tokio::test]
async fn retry_not_applied_to_soap_faults() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.and(header("Authorization", "Basic YWRtaW46cGFzcw=="))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><s:Fault><s:Code><s:Value>s:Receiver</s:Value></s:Code><s:Reason><s:Text>Access denied</s:Text></s:Reason></s:Fault></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let client = WinrmClient::new(retry_config(port, 3), test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("SOAP") || err.contains("Access denied"));
}
#[tokio::test]
async fn retry_zero_means_no_retry() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.and(header("Authorization", "Basic YWRtaW46cGFzcw=="))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>NO-RETRY</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let client = WinrmClient::new(retry_config(port, 0), test_creds()).unwrap();
let shell_id = client.create_shell("127.0.0.1").await.unwrap();
assert_eq!(shell_id, "NO-RETRY");
}
#[test]
fn kerberos_without_feature_returns_helpful_error() {
let config = WinrmConfig {
auth_method: AuthMethod::Kerberos,
..Default::default()
};
let client = WinrmClient::new(config, test_creds()).unwrap();
assert_eq!(client.endpoint("host"), "http://host:5985/wsman");
}
#[cfg(not(feature = "kerberos"))]
#[tokio::test]
async fn kerberos_send_returns_feature_error() {
let config = WinrmConfig {
auth_method: AuthMethod::Kerberos,
..Default::default()
};
let client = WinrmClient::new(config, test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("kerberos"),
"error should mention kerberos feature: {err}"
);
}
#[test]
fn certificate_auth_requires_cert_pem() {
let config = WinrmConfig {
auth_method: AuthMethod::Certificate,
..Default::default()
};
let result = WinrmClient::new(config, test_creds());
let err = result.err().expect("should fail without client_cert_pem");
let msg = format!("{err}");
assert!(
msg.contains("client_cert_pem"),
"error should mention client_cert_pem: {msg}"
);
}
#[test]
fn certificate_auth_requires_key_pem() {
let config = WinrmConfig {
auth_method: AuthMethod::Certificate,
client_cert_pem: Some("/tmp/nonexistent-cert.pem".into()),
client_key_pem: None,
..Default::default()
};
let result = WinrmClient::new(config, test_creds());
let err = result.err().expect("should fail without client_key_pem");
let msg = format!("{err}");
assert!(
msg.contains("client_key_pem"),
"error should mention client_key_pem: {msg}"
);
}
#[tokio::test]
async fn certificate_auth_dispatch_with_wiremock() {
let dir = std::env::temp_dir().join("winrm-rs-test-cert");
std::fs::create_dir_all(&dir).unwrap();
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
std::fs::write(&cert_path, b"not a real cert").unwrap();
std::fs::write(&key_path, b"not a real key").unwrap();
let config = WinrmConfig {
auth_method: AuthMethod::Certificate,
client_cert_pem: Some(cert_path.to_string_lossy().into()),
client_key_pem: Some(key_path.to_string_lossy().into()),
..Default::default()
};
let result = WinrmClient::new(config, test_creds());
assert!(result.is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn proxy_config_is_preserved() {
let config = WinrmConfig {
proxy: Some("http://proxy:8080".into()),
..Default::default()
};
let client = WinrmClient::new(config.clone(), test_creds()).unwrap();
assert_eq!(client.config().proxy.as_deref(), Some("http://proxy:8080"));
}
#[test]
fn winrm_error_transfer_display() {
let err = WinrmError::Transfer("upload chunk 3 failed".into());
assert_eq!(
format!("{err}"),
"file transfer error: upload chunk 3 failed"
);
}
#[tokio::test]
async fn start_command_and_receive_next() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>STREAM-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>STREAM-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="STREAM-CMD">Y2h1bmsx</rsp:Stream>
<rsp:CommandState CommandId="STREAM-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="STREAM-CMD">Y2h1bmsy</rsp:Stream>
<rsp:CommandState CommandId="STREAM-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let shell = client.open_shell("127.0.0.1").await.unwrap();
let cmd_id = shell
.start_command("ping", &["-t", "10.0.0.1"])
.await
.unwrap();
assert_eq!(cmd_id, "STREAM-CMD");
let chunk1 = shell.receive_next(&cmd_id).await.unwrap();
assert_eq!(chunk1.stdout, b"chunk1");
assert!(!chunk1.done);
let chunk2 = shell.receive_next(&cmd_id).await.unwrap();
assert_eq!(chunk2.stdout, b"chunk2");
assert!(chunk2.done);
assert_eq!(chunk2.exit_code, Some(0));
}
#[tokio::test]
async fn download_file_with_wiremock() {
let server = MockServer::start().await;
let port = server.address().port();
let ps_output_b64 = base64::engine::general_purpose::STANDARD.encode(b"hello file content");
let stdout_b64 = B64.encode(ps_output_b64.as_bytes());
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DL-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DL-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
let receive_body = format!(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="DL-CMD">{stdout_b64}</rsp:Stream>
<rsp:CommandState CommandId="DL-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#
);
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(receive_body))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let local_path = std::env::temp_dir().join("winrm-rs-test-download.bin");
let result = client
.download_file("127.0.0.1", "C:\\remote\\file.txt", &local_path)
.await;
assert!(result.is_ok(), "download_file failed: {result:?}");
let bytes = result.unwrap();
assert_eq!(bytes, 18); let content = std::fs::read(&local_path).unwrap();
assert_eq!(content, b"hello file content");
std::fs::remove_file(&local_path).ok();
}
const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
MIIBXjCCAQWgAwIBAgIUMMmMPCKhqsfVxxq36Hmd4IHUTNgwCgYIKoZIzj0EAwIw\n\
ITEfMB0GA1UEAwwWcmNnZW4gc2VsZiBzaWduZWQgY2VydDAgFw03NTAxMDEwMDAw\n\
MDBaGA80MDk2MDEwMTAwMDAwMFowITEfMB0GA1UEAwwWcmNnZW4gc2VsZiBzaWdu\n\
ZWQgY2VydDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPmdXVVusEfNSmt6aKUf\n\
lw2+69/9LSYPVO0KUgALGqjUvoAMAwE/6AWQDrN2EH/swrMHJbM5l2y4Y7GEYbav\n\
glKjGTAXMBUGA1UdEQQOMAyCCnRlc3QubG9jYWwwCgYIKoZIzj0EAwIDRwAwRAIg\n\
JjoSt8p+3HBP3/EGZ/icOAC/N0o03a6SUOjMwgFiCbQCIDc2+ShrQhU3FNeE4Gu1\n\
hOMpiIz+2YFoGkzaDJ6fFB6B\n\
-----END CERTIFICATE-----\n";
const TEST_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+nMC1P5m4rXIR86n\n\
DVStYeCVDra7xdrnpbNklaXDbkWhRANCAAT5nV1VbrBHzUpremilH5cNvuvf/S0m\n\
D1TtClIACxqo1L6ADAMBP+gFkA6zdhB/7MKzByWzOZdsuGOxhGG2r4JS\n\
-----END PRIVATE KEY-----\n";
#[test]
fn certificate_auth_reads_valid_pem_files() {
let dir = std::env::temp_dir().join("winrm-rs-test-cert-valid");
std::fs::create_dir_all(&dir).unwrap();
let cert_path = dir.join("test_cert.pem");
let key_path = dir.join("test_key.pem");
std::fs::write(&cert_path, TEST_CERT_PEM).unwrap();
std::fs::write(&key_path, TEST_KEY_PEM).unwrap();
let config = WinrmConfig {
auth_method: AuthMethod::Certificate,
client_cert_pem: Some(cert_path.to_string_lossy().into()),
client_key_pem: Some(key_path.to_string_lossy().into()),
use_tls: true,
..Default::default()
};
let result = WinrmClient::new(config, test_creds());
assert!(
result.is_ok(),
"valid PEM should construct client: {}",
result.err().map(|e| format!("{e}")).unwrap_or_default()
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn certificate_auth_reads_pem_files_invalid() {
let dir = std::env::temp_dir().join("winrm-rs-test-cert-read");
std::fs::create_dir_all(&dir).unwrap();
let cert_path = dir.join("test_cert.pem");
let key_path = dir.join("test_key.pem");
std::fs::write(
&cert_path,
b"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n",
)
.unwrap();
std::fs::write(
&key_path,
b"-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n",
)
.unwrap();
let config = WinrmConfig {
auth_method: AuthMethod::Certificate,
client_cert_pem: Some(cert_path.to_string_lossy().into()),
client_key_pem: Some(key_path.to_string_lossy().into()),
use_tls: true,
..Default::default()
};
let result = WinrmClient::new(config, test_creds());
assert!(result.is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn certificate_auth_missing_key_path_with_valid_cert_file() {
let dir = std::env::temp_dir().join("winrm-rs-test-cert-nokey");
std::fs::create_dir_all(&dir).unwrap();
let cert_path = dir.join("test_cert.pem");
std::fs::write(&cert_path, b"cert data").unwrap();
let config = WinrmConfig {
auth_method: AuthMethod::Certificate,
client_cert_pem: Some(cert_path.to_string_lossy().into()),
client_key_pem: None,
use_tls: true,
..Default::default()
};
let result = WinrmClient::new(config, test_creds());
assert!(result.is_err());
assert!(format!("{}", result.err().unwrap()).contains("client_key_pem"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn certificate_auth_nonexistent_cert_file() {
let config = WinrmConfig {
auth_method: AuthMethod::Certificate,
client_cert_pem: Some("/nonexistent/cert.pem".into()),
client_key_pem: Some("/nonexistent/key.pem".into()),
use_tls: true,
..Default::default()
};
let result = WinrmClient::new(config, test_creds());
assert!(result.is_err());
let err = format!("{}", result.err().unwrap());
assert!(
err.contains("failed to read") || err.contains("cert"),
"error should mention cert read failure: {err}"
);
}
#[test]
fn certificate_auth_nonexistent_key_file() {
let dir = std::env::temp_dir().join("winrm-rs-test-cert-nokey2");
std::fs::create_dir_all(&dir).unwrap();
let cert_path = dir.join("test_cert.pem");
std::fs::write(&cert_path, b"cert data").unwrap();
let config = WinrmConfig {
auth_method: AuthMethod::Certificate,
client_cert_pem: Some(cert_path.to_string_lossy().into()),
client_key_pem: Some("/nonexistent/key.pem".into()),
use_tls: true,
..Default::default()
};
let result = WinrmClient::new(config, test_creds());
assert!(result.is_err());
let err = format!("{}", result.err().unwrap());
assert!(
err.contains("failed to read") || err.contains("key"),
"error should mention key read failure: {err}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn certificate_auth_dispatch_in_send_soap() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>CERT-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let dir = std::env::temp_dir().join("winrm-rs-test-cert-dispatch");
std::fs::create_dir_all(&dir).unwrap();
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
std::fs::write(&cert_path, TEST_CERT_PEM).unwrap();
std::fs::write(&key_path, TEST_KEY_PEM).unwrap();
let config = WinrmConfig {
port,
auth_method: AuthMethod::Certificate,
client_cert_pem: Some(cert_path.to_string_lossy().into()),
client_key_pem: Some(key_path.to_string_lossy().into()),
connect_timeout_secs: 5,
operation_timeout_secs: 10,
..Default::default()
};
let client = WinrmClient::new(config, test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_ok(), "cert auth dispatch failed: {result:?}");
assert_eq!(result.unwrap(), "CERT-SHELL");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn shell_run_command_full_lifecycle() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>RUN-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>RUN-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="RUN-CMD">cGFydDE=</rsp:Stream>
<rsp:CommandState CommandId="RUN-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="RUN-CMD">cGFydDI=</rsp:Stream>
<rsp:Stream Name="stderr" CommandId="RUN-CMD">ZXJyMQ==</rsp:Stream>
<rsp:CommandState CommandId="RUN-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>42</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let shell = client.open_shell("127.0.0.1").await.unwrap();
let output = shell.run_command("cmd.exe", &["/c", "echo"]).await.unwrap();
assert_eq!(output.stdout, b"part1part2");
assert_eq!(output.stderr, b"err1");
assert_eq!(output.exit_code, 42);
shell.close().await.unwrap();
}
#[tokio::test]
async fn shell_start_command_returns_command_id() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>START-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>START-CMD-123</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let shell = client.open_shell("127.0.0.1").await.unwrap();
let cmd_id = shell.start_command("whoami", &[]).await.unwrap();
assert_eq!(cmd_id, "START-CMD-123");
}
#[tokio::test]
async fn upload_file_success_with_wiremock() {
let server = MockServer::start().await;
let port = server.address().port();
let dir = std::env::temp_dir().join("winrm-rs-test-upload");
std::fs::create_dir_all(&dir).unwrap();
let local_file = dir.join("upload_test.txt");
std::fs::write(&local_file, b"hello upload content").unwrap();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>UL-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>UL-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="UL-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client
.upload_file("127.0.0.1", &local_file, "C:\\remote\\file.txt")
.await;
assert!(result.is_ok(), "upload_file failed: {result:?}");
assert_eq!(result.unwrap(), 20);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn upload_file_chunk_failure() {
let server = MockServer::start().await;
let port = server.address().port();
let dir = std::env::temp_dir().join("winrm-rs-test-upload-fail");
std::fs::create_dir_all(&dir).unwrap();
let local_file = dir.join("upload_fail.txt");
std::fs::write(&local_file, b"test data").unwrap();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>ULF-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>ULF-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stderr" CommandId="ULF-CMD">YWNjZXNzIGRlbmllZA==</rsp:Stream>
<rsp:CommandState CommandId="ULF-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>1</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client
.upload_file("127.0.0.1", &local_file, "C:\\remote\\file.txt")
.await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("upload chunk") || err.contains("transfer"),
"error should mention upload chunk failure: {err}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn download_file_ps_failure() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DLF-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DLF-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stderr" CommandId="DLF-CMD">ZmlsZSBub3QgZm91bmQ=</rsp:Stream>
<rsp:CommandState CommandId="DLF-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>1</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let local_path = std::env::temp_dir().join("winrm-rs-test-dlfail.bin");
let result = client
.download_file("127.0.0.1", "C:\\nonexistent.txt", &local_path)
.await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("download") || err.contains("transfer"),
"error should mention download failure: {err}"
);
}
#[tokio::test]
async fn upload_file_multi_chunk() {
let server = MockServer::start().await;
let port = server.address().port();
let dir = std::env::temp_dir().join("winrm-rs-test-upload-multi");
std::fs::create_dir_all(&dir).unwrap();
let local_file = dir.join("small.txt");
std::fs::write(&local_file, b"tiny").unwrap();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>MC-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MC-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="MC-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client
.upload_file("127.0.0.1", &local_file, "C:\\remote\\small.txt")
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 4);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn download_file_write_local_success() {
let server = MockServer::start().await;
let port = server.address().port();
let ps_output_b64 = B64.encode(b"test bytes");
let stdout_b64 = B64.encode(ps_output_b64.as_bytes());
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DL2-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DL2-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
let receive_body = format!(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="DL2-CMD">{stdout_b64}</rsp:Stream>
<rsp:CommandState CommandId="DL2-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#
);
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(receive_body))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let local_path = std::env::temp_dir().join("winrm-rs-test-dl2.bin");
let result = client
.download_file("127.0.0.1", "C:\\remote\\data.bin", &local_path)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 10);
let content = std::fs::read(&local_path).unwrap();
assert_eq!(content, b"test bytes");
std::fs::remove_file(&local_path).ok();
}
#[tokio::test]
async fn run_command_delete_shell_failure_is_ignored() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DEL-FAIL-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DEL-FAIL-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="DEL-FAIL-CMD">b2s=</rsp:Stream>
<rsp:CommandState CommandId="DEL-FAIL-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><s:Fault><s:Code><s:Value>s:Receiver</s:Value></s:Code><s:Reason><s:Text>Shell not found</s:Text></s:Reason></s:Fault></s:Body></s:Envelope>",
))
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let output = client
.run_command("127.0.0.1", "whoami", &[])
.await
.unwrap();
assert_eq!(output.exit_code, 0);
assert_eq!(output.stdout, b"ok");
}
#[tokio::test]
#[cfg_attr(windows, ignore)]
async fn retry_backoff_on_http_error() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let call = counter_clone.fetch_add(1, Ordering::SeqCst);
if call == 0 {
drop(stream);
} else {
use tokio::io::AsyncWriteExt;
let body = r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>RETRY-BACKOFF</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/xml\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes()).await;
}
}
});
let config = WinrmConfig {
port,
auth_method: AuthMethod::Basic,
connect_timeout_secs: 2,
operation_timeout_secs: 5,
max_retries: 2,
..Default::default()
};
let client = WinrmClient::new(config, test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(
result.is_ok(),
"retry should have succeeded: {}",
result.err().map(|e| format!("{e}")).unwrap_or_default()
);
assert_eq!(result.unwrap(), "RETRY-BACKOFF");
assert!(
counter.load(Ordering::SeqCst) >= 2,
"should have retried at least once"
);
handle.abort();
}
#[tokio::test]
async fn upload_file_multi_chunk_with_append() {
let server = MockServer::start().await;
let port = server.address().port();
let dir = std::env::temp_dir().join("winrm-rs-test-upload-multi-chunk");
std::fs::create_dir_all(&dir).unwrap();
let local_file = dir.join("multi.bin");
let data = vec![0xABu8; 4001];
std::fs::write(&local_file, &data).unwrap();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>MCH-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MCH-CMD1</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="MCH-CMD1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MCH-CMD2</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="MCH-CMD2" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MCH-CMD3</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="MCH-CMD3" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client
.upload_file("127.0.0.1", &local_file, "C:\\remote\\multi.bin")
.await;
assert!(result.is_ok(), "multi-chunk upload failed: {result:?}");
assert_eq!(result.unwrap(), 4001);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn download_file_write_error() {
let server = MockServer::start().await;
let port = server.address().port();
let ps_output_b64 = B64.encode(b"test");
let stdout_b64 = B64.encode(ps_output_b64.as_bytes());
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DLW-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DLW-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
let receive_body = format!(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:Stream Name="stdout" CommandId="DLW-CMD">{stdout_b64}</rsp:Stream>
<rsp:CommandState CommandId="DLW-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#
);
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(receive_body))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let bad_path = std::path::Path::new("/nonexistent/dir/file.bin");
let result = client
.download_file("127.0.0.1", "C:\\remote.txt", bad_path)
.await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("failed to write") || err.contains("transfer"),
"error should mention write failure: {err}"
);
}
#[test]
fn upload_file_nonexistent_local_file() {
let rt = tokio::runtime::Runtime::new().unwrap();
let config = WinrmConfig::default();
let client = WinrmClient::new(config, test_creds()).unwrap();
let result = rt.block_on(client.upload_file(
"127.0.0.1",
std::path::Path::new("/nonexistent/file.bin"),
"C:\\remote\\dest.bin",
));
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("transfer error") || err.contains("failed to read"),
"error should mention transfer: {err}"
);
}
#[cfg(not(feature = "kerberos"))]
#[tokio::test]
async fn kerberos_auth_dispatch_returns_auth_error() {
let config = WinrmConfig {
auth_method: AuthMethod::Kerberos,
connect_timeout_secs: 5,
operation_timeout_secs: 10,
..Default::default()
};
let client = WinrmClient::new(config, test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{err}");
assert!(
err_msg.contains("kerberos") && err_msg.contains("feature"),
"error should specifically mention kerberos feature, got: {err_msg}"
);
assert!(
matches!(err, WinrmError::AuthFailed(_)),
"error variant should be AuthFailed, got: {err:?}"
);
}
#[tokio::test]
async fn retry_max_one_makes_exactly_two_attempts() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
counter_clone.fetch_add(1, Ordering::SeqCst);
drop(stream);
}
});
let config = WinrmConfig {
port,
auth_method: AuthMethod::Basic,
connect_timeout_secs: 2,
operation_timeout_secs: 5,
max_retries: 1,
..Default::default()
};
let client = WinrmClient::new(config, test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err(), "should fail after all retries exhausted");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let attempts = counter.load(Ordering::SeqCst);
assert_eq!(
attempts, 2,
"max_retries=1 should make exactly 2 attempts, got {attempts}"
);
handle.abort();
}
#[tokio::test]
async fn retry_max_zero_makes_exactly_one_attempt() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
counter_clone.fetch_add(1, Ordering::SeqCst);
drop(stream);
}
});
let config = WinrmConfig {
port,
auth_method: AuthMethod::Basic,
connect_timeout_secs: 2,
operation_timeout_secs: 5,
max_retries: 0,
..Default::default()
};
let client = WinrmClient::new(config, test_creds()).unwrap();
let result = client.create_shell("127.0.0.1").await;
assert!(result.is_err());
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let attempts = counter.load(Ordering::SeqCst);
assert_eq!(
attempts, 1,
"max_retries=0 should make exactly 1 attempt, got {attempts}"
);
handle.abort();
}
#[tokio::test]
async fn shell_run_command_timeout_uses_double_operation_timeout() {
let server = MockServer::start().await;
let port = server.address().port();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>TO-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>TO-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="TO-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
)
.set_delay(std::time::Duration::from_secs(10)),
)
.mount(&server)
.await;
let config = WinrmConfig {
port,
auth_method: AuthMethod::Basic,
connect_timeout_secs: 30,
operation_timeout_secs: 1,
..Default::default()
};
let client = WinrmClient::new(config, test_creds()).unwrap();
let shell = client.open_shell("127.0.0.1").await.unwrap();
let result = shell.run_command("slow", &[]).await;
assert!(result.is_err(), "should have timed out");
let err = result.unwrap_err();
match err {
WinrmError::Timeout(secs) => {
assert_eq!(
secs, 2,
"timeout should be operation_timeout_secs * 2 = 2, got {secs}"
);
}
other => panic!("expected Timeout error, got: {other:?}"),
}
}
#[tokio::test]
async fn upload_first_chunk_uses_write_all_bytes() {
let server = MockServer::start().await;
let port = server.address().port();
let dir = std::env::temp_dir().join("winrm-rs-test-upload-writebytes");
std::fs::create_dir_all(&dir).unwrap();
let local_file = dir.join("small.txt");
std::fs::write(&local_file, b"test-data").unwrap();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>WB-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>WB-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
<rsp:CommandState CommandId="WB-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
<rsp:ExitCode>0</rsp:ExitCode>
</rsp:CommandState>
</rsp:ReceiveResponse></s:Body></s:Envelope>"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
)
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client
.upload_file("127.0.0.1", &local_file, "C:\\dest.txt")
.await;
assert!(result.is_ok(), "upload failed: {result:?}");
let requests = server.received_requests().await.unwrap();
let mut found_write_all_bytes = false;
let mut found_append = false;
for req in &requests {
let body = String::from_utf8_lossy(&req.body);
if !body.contains("-EncodedCommand") {
continue;
}
let tag_open = "<rsp:Arguments>";
let tag_close = "</rsp:Arguments>";
let mut pos = 0;
while let Some(start) = body[pos..].find(tag_open) {
let content_start = pos + start + tag_open.len();
if let Some(end) = body[content_start..].find(tag_close) {
let arg_val = &body[content_start..content_start + end];
if let Ok(bytes) = B64.decode(arg_val.trim()) {
let u16s: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
if let Ok(script) = String::from_utf16(&u16s) {
if script.contains("WriteAllBytes") {
found_write_all_bytes = true;
}
if script.contains("Append") {
found_append = true;
}
}
}
pos = content_start + end + tag_close.len();
} else {
break;
}
}
}
assert!(
found_write_all_bytes,
"first chunk must use WriteAllBytes for new file creation"
);
assert!(
!found_append,
"single-chunk upload must NOT use Append (that's for subsequent chunks)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn retry_backoff_is_exponential_not_additive() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
counter_clone.fetch_add(1, Ordering::SeqCst);
drop(stream);
}
});
let config = WinrmConfig {
port,
auth_method: AuthMethod::Basic,
connect_timeout_secs: 2,
operation_timeout_secs: 5,
max_retries: 2,
..Default::default()
};
let client = WinrmClient::new(config, test_creds()).unwrap();
let start = std::time::Instant::now();
let result = client.create_shell("127.0.0.1").await;
let elapsed = start.elapsed();
assert!(result.is_err());
assert!(
elapsed >= std::time::Duration::from_millis(280),
"exponential backoff should take >= 280ms total, took {}ms (catches + and / mutants)",
elapsed.as_millis()
);
handle.abort();
}
#[tokio::test]
async fn run_wql_returns_items() {
let server = MockServer::start().await;
let port = server.address().port();
let enumerate_response = r#"<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
<s:Body>
<wsen:EnumerateResponse>
<wsen:Items><p:Win32_OS><p:Name>Windows</p:Name></p:Win32_OS></wsen:Items>
<wsen:EndOfSequence/>
</wsen:EnumerateResponse>
</s:Body>
</s:Envelope>"#;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(enumerate_response))
.mount(&server)
.await;
let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
let result = client
.run_wql("127.0.0.1", "SELECT * FROM Win32_OS", None)
.await
.unwrap();
assert!(
result.contains("Windows"),
"run_wql should return items, got: {result}"
);
}
}