1use tracing::debug;
7
8use crate::builder::WinrmClientBuilder;
9use crate::command::{CommandOutput, encode_powershell_command};
10use crate::config::{WinrmConfig, WinrmCredentials};
11use crate::error::WinrmError;
12use crate::shell::Shell;
13use crate::soap;
14use crate::transport::HttpTransport;
15
16pub struct WinrmClient {
27 pub(crate) transport: HttpTransport,
28}
29
30impl WinrmClient {
31 #[tracing::instrument(level = "debug", skip(credentials))]
37 pub fn new(config: WinrmConfig, credentials: WinrmCredentials) -> Result<Self, WinrmError> {
38 Ok(Self {
39 transport: HttpTransport::new(config, credentials)?,
40 })
41 }
42
43 pub(crate) fn endpoint(&self, host: &str) -> String {
45 self.transport.endpoint(host)
46 }
47
48 pub(crate) fn config(&self) -> &WinrmConfig {
50 self.transport.config()
51 }
52
53 pub fn builder(config: WinrmConfig) -> WinrmClientBuilder {
56 WinrmClientBuilder::new(config)
57 }
58
59 pub(crate) async fn send_soap_raw(
61 &self,
62 host: &str,
63 body: String,
64 ) -> Result<String, WinrmError> {
65 self.transport.send_soap_raw(host, body).await
66 }
67
68 #[tracing::instrument(level = "debug", skip(self))]
76 pub async fn create_shell(&self, host: &str) -> Result<String, WinrmError> {
77 let config = self.transport.config();
78 let envelope = soap::create_shell_request(&self.transport.endpoint(host), config);
79 let response = self.transport.send_soap_with_retry(host, envelope).await?;
80 soap::parse_shell_id(&response).map_err(WinrmError::Soap)
81 }
82
83 #[tracing::instrument(level = "debug", skip(self, creation_xml_b64))]
92 pub async fn open_psrp_shell(
93 &self,
94 host: &str,
95 creation_xml_b64: &str,
96 resource_uri: &str,
97 ) -> Result<Shell<'_>, WinrmError> {
98 let config = self.transport.config();
99 let proposed_id = uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase();
102 let envelope = soap::create_psrp_shell_request(
103 &self.transport.endpoint(host),
104 config,
105 creation_xml_b64,
106 resource_uri,
107 &proposed_id,
108 );
109 let response = self.transport.send_soap_with_retry(host, envelope).await?;
110 let shell_id = soap::parse_shell_id(&response).map_err(WinrmError::Soap)?;
111 debug!(shell_id = %shell_id, "PSRP shell opened");
112 Ok(Shell::new_with_resource_uri(
113 self,
114 host.to_string(),
115 shell_id,
116 resource_uri.to_string(),
117 ))
118 }
119
120 #[tracing::instrument(level = "debug", skip(self))]
125 pub async fn execute_command(
126 &self,
127 host: &str,
128 shell_id: &str,
129 command: &str,
130 args: &[&str],
131 ) -> Result<String, WinrmError> {
132 let config = self.transport.config();
133 let envelope = soap::execute_command_request(
134 &self.transport.endpoint(host),
135 shell_id,
136 command,
137 args,
138 config.operation_timeout_secs,
139 config.max_envelope_size,
140 );
141 let response = self.transport.send_soap_with_retry(host, envelope).await?;
142 soap::parse_command_id(&response).map_err(WinrmError::Soap)
143 }
144
145 #[tracing::instrument(level = "debug", skip(self))]
150 pub async fn receive_output(
151 &self,
152 host: &str,
153 shell_id: &str,
154 command_id: &str,
155 ) -> Result<soap::ReceiveOutput, WinrmError> {
156 let config = self.transport.config();
157 let envelope = soap::receive_output_request(
158 &self.transport.endpoint(host),
159 shell_id,
160 command_id,
161 config.operation_timeout_secs,
162 config.max_envelope_size,
163 );
164 let response = self.transport.send_soap_with_retry(host, envelope).await?;
165 soap::parse_receive_output(&response).map_err(WinrmError::Soap)
166 }
167
168 #[tracing::instrument(level = "debug", skip(self))]
173 pub async fn signal_terminate(
174 &self,
175 host: &str,
176 shell_id: &str,
177 command_id: &str,
178 ) -> Result<(), WinrmError> {
179 let config = self.transport.config();
180 let envelope = soap::signal_terminate_request(
181 &self.transport.endpoint(host),
182 shell_id,
183 command_id,
184 config.operation_timeout_secs,
185 config.max_envelope_size,
186 );
187 self.transport.send_soap_with_retry(host, envelope).await?;
188 Ok(())
189 }
190
191 #[tracing::instrument(level = "debug", skip(self))]
198 pub async fn delete_shell(&self, host: &str, shell_id: &str) -> Result<(), WinrmError> {
199 let config = self.transport.config();
200 let envelope = soap::delete_shell_request(
201 &self.transport.endpoint(host),
202 shell_id,
203 config.operation_timeout_secs,
204 config.max_envelope_size,
205 );
206 self.transport.send_soap_with_retry(host, envelope).await?;
207 Ok(())
208 }
209
210 #[tracing::instrument(level = "debug", skip(self))]
218 pub async fn run_command(
219 &self,
220 host: &str,
221 command: &str,
222 args: &[&str],
223 ) -> Result<CommandOutput, WinrmError> {
224 let shell_id = self.create_shell(host).await?;
225 debug!(shell_id = %shell_id, "WinRM shell created");
226
227 let result = self.run_in_shell(host, &shell_id, command, args).await;
228
229 self.delete_shell(host, &shell_id)
231 .await
232 .inspect_err(|e| debug!(error = %e, "failed to delete WinRM shell (best-effort)"))
233 .ok();
234
235 result
236 }
237
238 async fn run_in_shell(
240 &self,
241 host: &str,
242 shell_id: &str,
243 command: &str,
244 args: &[&str],
245 ) -> Result<CommandOutput, WinrmError> {
246 let command_id = self.execute_command(host, shell_id, command, args).await?;
247 debug!(command_id = %command_id, "WinRM command started");
248
249 let max_output = self.transport.config().max_output_bytes;
250 let mut stdout = Vec::new();
251 let mut stderr = Vec::new();
252 let mut exit_code: Option<i32> = None;
253
254 loop {
255 let output = self.receive_output(host, shell_id, &command_id).await?;
256 stdout.extend_from_slice(&output.stdout);
257 stderr.extend_from_slice(&output.stderr);
258
259 if let Some(cap) = max_output
260 && stdout.len() + stderr.len() > cap
261 {
262 self.signal_terminate(host, shell_id, &command_id)
263 .await
264 .ok();
265 return Err(WinrmError::Transfer(format!(
266 "command output exceeded max_output_bytes ({cap})"
267 )));
268 }
269
270 if output.exit_code.is_some() {
271 exit_code = output.exit_code;
272 }
273
274 if output.done {
275 break;
276 }
277 }
278
279 self.signal_terminate(host, shell_id, &command_id)
281 .await
282 .ok();
283
284 Ok(CommandOutput {
285 stdout,
286 stderr,
287 exit_code: exit_code.unwrap_or(-1),
288 })
289 }
290
291 #[tracing::instrument(level = "debug", skip(self, script))]
297 pub async fn run_powershell(
298 &self,
299 host: &str,
300 script: &str,
301 ) -> Result<CommandOutput, WinrmError> {
302 let encoded = encode_powershell_command(script);
303 self.run_command(host, "powershell.exe", &["-EncodedCommand", &encoded])
304 .await
305 }
306
307 pub async fn run_command_with_cancel(
312 &self,
313 host: &str,
314 command: &str,
315 args: &[&str],
316 cancel: tokio_util::sync::CancellationToken,
317 ) -> Result<CommandOutput, WinrmError> {
318 if cancel.is_cancelled() {
324 return Err(WinrmError::Cancelled);
325 }
326 tokio::select! {
327 result = self.run_command(host, command, args) => result,
328 () = cancel.cancelled() => Err(WinrmError::Cancelled),
329 }
330 }
331
332 pub async fn run_powershell_with_cancel(
337 &self,
338 host: &str,
339 script: &str,
340 cancel: tokio_util::sync::CancellationToken,
341 ) -> Result<CommandOutput, WinrmError> {
342 let encoded = encode_powershell_command(script);
343 self.run_command_with_cancel(
344 host,
345 "powershell.exe",
346 &["-EncodedCommand", &encoded],
347 cancel,
348 )
349 .await
350 }
351
352 #[tracing::instrument(level = "debug", skip(self))]
368 pub async fn run_wql(
369 &self,
370 host: &str,
371 query: &str,
372 namespace: Option<&str>,
373 ) -> Result<String, WinrmError> {
374 let config = self.transport.config();
375 let endpoint = self.transport.endpoint(host);
376
377 let envelope = soap::enumerate_wql_request(
379 &endpoint,
380 query,
381 namespace,
382 config.operation_timeout_secs,
383 config.max_envelope_size,
384 );
385 let response = self.transport.send_soap_with_retry(host, envelope).await?;
386 let (mut items, mut context) =
387 soap::parse_enumerate_response(&response).map_err(WinrmError::Soap)?;
388
389 let max_output = config.max_output_bytes;
390
391 while let Some(ctx) = context {
393 let pull_envelope = soap::pull_request(
394 &endpoint,
395 &ctx,
396 config.operation_timeout_secs,
397 config.max_envelope_size,
398 );
399 let pull_response = self
400 .transport
401 .send_soap_with_retry(host, pull_envelope)
402 .await?;
403 let (more_items, next_ctx) =
404 soap::parse_enumerate_response(&pull_response).map_err(WinrmError::Soap)?;
405 items.push_str(&more_items);
406 context = next_ctx;
407
408 if let Some(cap) = max_output
409 && items.len() > cap
410 {
411 return Err(WinrmError::Transfer(format!(
412 "WQL enumeration exceeded max_output_bytes ({cap})"
413 )));
414 }
415 }
416
417 Ok(items)
418 }
419
420 #[tracing::instrument(level = "debug", skip(self))]
428 pub async fn open_shell(&self, host: &str) -> Result<Shell<'_>, WinrmError> {
429 let shell_id = self.create_shell(host).await?;
430 debug!(shell_id = %shell_id, "WinRM shell opened for reuse");
431 Ok(Shell::new(self, host.to_string(), shell_id))
432 }
433
434 pub(crate) async fn delete_shell_raw(
436 &self,
437 host: &str,
438 shell_id: &str,
439 ) -> Result<(), WinrmError> {
440 self.delete_shell(host, shell_id).await
441 }
442
443 #[tracing::instrument(level = "debug", skip(self))]
449 pub async fn reconnect_shell(
450 &self,
451 host: &str,
452 shell_id: &str,
453 resource_uri: &str,
454 ) -> Result<Shell<'_>, WinrmError> {
455 let config = self.transport.config();
456 let envelope = soap::reconnect_shell_request_with_uri(
457 &self.transport.endpoint(host),
458 shell_id,
459 config.operation_timeout_secs,
460 config.max_envelope_size,
461 resource_uri,
462 );
463 self.transport.send_soap_with_retry(host, envelope).await?;
464 debug!(shell_id = %shell_id, "WinRM shell reconnected");
465 Ok(Shell::new_with_resource_uri(
466 self,
467 host.to_string(),
468 shell_id.to_string(),
469 resource_uri.to_string(),
470 ))
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use crate::config::{AuthMethod, EncryptionMode};
478 use base64::Engine;
479 use base64::engine::general_purpose::STANDARD as B64;
480 use wiremock::matchers::{header, method};
481 use wiremock::{Mock, MockServer, ResponseTemplate};
482
483 fn test_creds() -> WinrmCredentials {
484 WinrmCredentials::new("admin", "pass", "")
485 }
486
487 fn basic_config(port: u16) -> WinrmConfig {
488 WinrmConfig {
489 port,
490 auth_method: AuthMethod::Basic,
491 connect_timeout_secs: 5,
492 operation_timeout_secs: 10,
493 ..Default::default()
494 }
495 }
496
497 fn ntlm_config(port: u16) -> WinrmConfig {
498 WinrmConfig {
499 port,
500 auth_method: AuthMethod::Ntlm,
501 connect_timeout_secs: 5,
502 operation_timeout_secs: 10,
503 encryption: EncryptionMode::Never,
505 ..Default::default()
506 }
507 }
508
509 #[test]
510 fn client_builds_correct_endpoint() {
511 let config = WinrmConfig::default();
512 let creds = WinrmCredentials::new("admin", "pass", "");
513 let client = WinrmClient::new(config, creds).unwrap();
514 assert_eq!(client.endpoint("win-01"), "http://win-01:5985/wsman");
515 }
516
517 #[test]
518 fn client_builds_https_endpoint() {
519 let config = WinrmConfig {
520 port: 5986,
521 use_tls: true,
522 ..Default::default()
523 };
524 let creds = WinrmCredentials::new("admin", "pass", "");
525 let client = WinrmClient::new(config, creds).unwrap();
526 assert_eq!(client.endpoint("win-01"), "https://win-01:5986/wsman");
527 }
528
529 #[tokio::test]
530 async fn send_basic_success() {
531 let server = MockServer::start().await;
532 let port = server.address().port();
533
534 Mock::given(method("POST"))
535 .and(header("Authorization", "Basic YWRtaW46cGFzcw=="))
536 .respond_with(ResponseTemplate::new(200).set_body_string(
537 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SHELL-1</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
538 ))
539 .mount(&server)
540 .await;
541
542 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
543 let result = client.create_shell("127.0.0.1").await;
544 assert!(result.is_ok());
545 assert_eq!(result.unwrap(), "SHELL-1");
546 }
547
548 #[tokio::test]
549 async fn send_basic_auth_failure() {
550 let server = MockServer::start().await;
551 let port = server.address().port();
552
553 Mock::given(method("POST"))
554 .respond_with(ResponseTemplate::new(401))
555 .mount(&server)
556 .await;
557
558 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
559 let result = client.create_shell("127.0.0.1").await;
560 assert!(result.is_err());
561 let err = format!("{}", result.unwrap_err());
562 assert!(err.contains("auth failed") || err.contains("401"));
563 }
564
565 #[tokio::test]
566 async fn send_basic_soap_fault() {
567 let server = MockServer::start().await;
568 let port = server.address().port();
569
570 Mock::given(method("POST"))
571 .respond_with(ResponseTemplate::new(200).set_body_string(
572 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>",
573 ))
574 .mount(&server)
575 .await;
576
577 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
578 let result = client.create_shell("127.0.0.1").await;
579 assert!(result.is_err());
580 let err = format!("{}", result.unwrap_err());
581 assert!(err.contains("SOAP") || err.contains("Access denied"));
582 }
583
584 #[tokio::test]
585 async fn execute_command_and_receive_output() {
586 let server = MockServer::start().await;
587 let port = server.address().port();
588
589 Mock::given(method("POST"))
591 .respond_with(ResponseTemplate::new(200).set_body_string(
592 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>S1</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
593 ))
594 .up_to_n_times(1)
595 .mount(&server)
596 .await;
597
598 Mock::given(method("POST"))
600 .respond_with(ResponseTemplate::new(200).set_body_string(
601 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>C1</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
602 ))
603 .up_to_n_times(1)
604 .mount(&server)
605 .await;
606
607 Mock::given(method("POST"))
610 .respond_with(ResponseTemplate::new(200).set_body_string(
611 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
612 <rsp:Stream Name="stdout" CommandId="C1">aGVsbG8=</rsp:Stream>
613 <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
614 <rsp:ExitCode>0</rsp:ExitCode>
615 </rsp:CommandState>
616 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
617 ))
618 .up_to_n_times(1)
619 .mount(&server)
620 .await;
621
622 Mock::given(method("POST"))
624 .respond_with(
625 ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
626 )
627 .mount(&server)
628 .await;
629
630 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
631 let output = client
632 .run_command("127.0.0.1", "whoami", &[])
633 .await
634 .unwrap();
635 assert_eq!(output.exit_code, 0);
636 assert_eq!(output.stdout, b"hello");
637 }
638
639 #[tokio::test]
640 async fn run_powershell_encodes_and_executes() {
641 let server = MockServer::start().await;
642 let port = server.address().port();
643
644 Mock::given(method("POST"))
646 .respond_with(ResponseTemplate::new(200).set_body_string(
647 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>S2</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
648 ))
649 .up_to_n_times(1)
650 .mount(&server)
651 .await;
652
653 Mock::given(method("POST"))
655 .respond_with(ResponseTemplate::new(200).set_body_string(
656 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>C2</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
657 ))
658 .up_to_n_times(1)
659 .mount(&server)
660 .await;
661
662 Mock::given(method("POST"))
664 .respond_with(ResponseTemplate::new(200).set_body_string(
665 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
666 <rsp:CommandState CommandId="C2" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
667 <rsp:ExitCode>0</rsp:ExitCode>
668 </rsp:CommandState>
669 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
670 ))
671 .up_to_n_times(1)
672 .mount(&server)
673 .await;
674
675 Mock::given(method("POST"))
677 .respond_with(
678 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
679 )
680 .mount(&server)
681 .await;
682
683 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
684 let output = client
685 .run_powershell("127.0.0.1", "Get-Process")
686 .await
687 .unwrap();
688 assert_eq!(output.exit_code, 0);
689 }
690
691 #[tokio::test]
692 async fn delete_shell_success() {
693 let server = MockServer::start().await;
694 let port = server.address().port();
695
696 Mock::given(method("POST"))
697 .respond_with(
698 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
699 )
700 .mount(&server)
701 .await;
702
703 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
704 let result = client.delete_shell("127.0.0.1", "SHELL-1").await;
705 assert!(result.is_ok());
706 }
707
708 #[tokio::test]
709 async fn signal_terminate_success() {
710 let server = MockServer::start().await;
711 let port = server.address().port();
712
713 Mock::given(method("POST"))
714 .respond_with(
715 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
716 )
717 .mount(&server)
718 .await;
719
720 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
721 let result = client.signal_terminate("127.0.0.1", "S1", "C1").await;
722 assert!(result.is_ok());
723 }
724
725 #[tokio::test]
726 async fn ntlm_handshake_success() {
727 let server = MockServer::start().await;
728 let port = server.address().port();
729
730 let mut type2 = vec![0u8; 48];
732 type2[0..8].copy_from_slice(b"NTLMSSP\0");
733 type2[8..12].copy_from_slice(&2u32.to_le_bytes());
734 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);
738
739 Mock::given(method("POST"))
741 .respond_with(
742 ResponseTemplate::new(401)
743 .append_header("WWW-Authenticate", format!("Negotiate {type2_b64}")),
744 )
745 .up_to_n_times(1)
746 .mount(&server)
747 .await;
748
749 Mock::given(method("POST"))
751 .respond_with(ResponseTemplate::new(200).set_body_string(
752 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>NTLM-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
753 ))
754 .mount(&server)
755 .await;
756
757 let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
758 let shell_id = client.create_shell("127.0.0.1").await.unwrap();
759 assert_eq!(shell_id, "NTLM-SHELL");
760 }
761
762 #[tokio::test]
763 async fn ntlm_rejected_credentials() {
764 let server = MockServer::start().await;
765 let port = server.address().port();
766
767 let mut type2 = vec![0u8; 48];
768 type2[0..8].copy_from_slice(b"NTLMSSP\0");
769 type2[8..12].copy_from_slice(&2u32.to_le_bytes());
770 type2[20..24].copy_from_slice(&0x0008_8201_u32.to_le_bytes());
771 type2[24..32].copy_from_slice(&[0x01; 8]);
772 let type2_b64 = B64.encode(&type2);
773
774 Mock::given(method("POST"))
776 .respond_with(
777 ResponseTemplate::new(401)
778 .append_header("WWW-Authenticate", format!("Negotiate {type2_b64}")),
779 )
780 .up_to_n_times(1)
781 .mount(&server)
782 .await;
783
784 Mock::given(method("POST"))
786 .respond_with(ResponseTemplate::new(401))
787 .mount(&server)
788 .await;
789
790 let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
791 let result = client.create_shell("127.0.0.1").await;
792 assert!(result.is_err());
793 let err = format!("{}", result.unwrap_err());
794 assert!(err.contains("auth") || err.contains("rejected"));
795 }
796
797 #[tokio::test]
798 async fn ntlm_unexpected_status_on_negotiate() {
799 let server = MockServer::start().await;
800 let port = server.address().port();
801
802 Mock::given(method("POST"))
804 .respond_with(ResponseTemplate::new(200).set_body_string("<ok/>"))
805 .mount(&server)
806 .await;
807
808 let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
809 let result = client.create_shell("127.0.0.1").await;
810 assert!(result.is_err());
811 let err = format!("{}", result.unwrap_err());
812 assert!(err.contains("expected 401"));
813 }
814
815 #[tokio::test]
816 async fn ntlm_missing_www_authenticate() {
817 let server = MockServer::start().await;
818 let port = server.address().port();
819
820 Mock::given(method("POST"))
822 .respond_with(ResponseTemplate::new(401))
823 .mount(&server)
824 .await;
825
826 let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
827 let result = client.create_shell("127.0.0.1").await;
828 assert!(result.is_err());
829 let err = format!("{}", result.unwrap_err());
830 assert!(err.contains("WWW-Authenticate") || err.contains("auth"));
831 }
832
833 #[tokio::test]
834 async fn ntlm_non_success_after_auth() {
835 let server = MockServer::start().await;
836 let port = server.address().port();
837
838 let mut type2 = vec![0u8; 48];
839 type2[0..8].copy_from_slice(b"NTLMSSP\0");
840 type2[8..12].copy_from_slice(&2u32.to_le_bytes());
841 type2[20..24].copy_from_slice(&0x0008_8201_u32.to_le_bytes());
842 type2[24..32].copy_from_slice(&[0x01; 8]);
843 let type2_b64 = B64.encode(&type2);
844
845 Mock::given(method("POST"))
847 .respond_with(
848 ResponseTemplate::new(401)
849 .append_header("WWW-Authenticate", format!("Negotiate {type2_b64}")),
850 )
851 .up_to_n_times(1)
852 .mount(&server)
853 .await;
854
855 Mock::given(method("POST"))
857 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Error"))
858 .mount(&server)
859 .await;
860
861 let client = WinrmClient::new(ntlm_config(port), test_creds()).unwrap();
862 let result = client.create_shell("127.0.0.1").await;
863 assert!(result.is_err());
864 let err = format!("{}", result.unwrap_err());
865 assert!(err.contains("500") || err.contains("Internal"));
866 }
867
868 #[tokio::test]
869 async fn ntlm_uses_explicit_domain_when_set() {
870 let server = MockServer::start().await;
871 let port = server.address().port();
872
873 let mut type2 = vec![0u8; 48];
874 type2[0..8].copy_from_slice(b"NTLMSSP\0");
875 type2[8..12].copy_from_slice(&2u32.to_le_bytes());
876 type2[20..24].copy_from_slice(&0x0008_8201_u32.to_le_bytes());
877 type2[24..32].copy_from_slice(&[0x01; 8]);
878 let type2_b64 = B64.encode(&type2);
879
880 Mock::given(method("POST"))
882 .respond_with(
883 ResponseTemplate::new(401)
884 .append_header("WWW-Authenticate", format!("Negotiate {type2_b64}")),
885 )
886 .up_to_n_times(1)
887 .mount(&server)
888 .await;
889
890 Mock::given(method("POST"))
892 .respond_with(ResponseTemplate::new(200).set_body_string(
893 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DOMAIN-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
894 ))
895 .mount(&server)
896 .await;
897
898 let creds = WinrmCredentials::new("admin", "pass", "EXPLICIT-DOM");
900 let client = WinrmClient::new(ntlm_config(port), creds).unwrap();
901 let shell_id = client.create_shell("127.0.0.1").await.unwrap();
902 assert_eq!(shell_id, "DOMAIN-SHELL");
903 }
904
905 #[test]
906 fn winrm_error_display() {
907 let err = WinrmError::AuthFailed("bad creds".into());
908 assert_eq!(format!("{err}"), "WinRM auth failed: bad creds");
909
910 let err = WinrmError::Soap(crate::error::SoapError::MissingElement("ShellId".into()));
911 assert!(format!("{err}").contains("SOAP"));
912
913 let err = WinrmError::Ntlm(crate::error::NtlmError::InvalidMessage("bad".into()));
914 assert!(format!("{err}").contains("NTLM"));
915 }
916
917 #[tokio::test]
921 async fn execute_command_returns_server_command_id() {
922 let server = MockServer::start().await;
923 let port = server.address().port();
924
925 Mock::given(method("POST"))
926 .respond_with(ResponseTemplate::new(200).set_body_string(
927 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>EXACT-CMD-ID-789</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
928 ))
929 .mount(&server)
930 .await;
931
932 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
933 let cmd_id = client
934 .execute_command("127.0.0.1", "S1", "whoami", &[])
935 .await
936 .unwrap();
937 assert_eq!(cmd_id, "EXACT-CMD-ID-789");
939 }
940
941 #[tokio::test]
943 async fn delete_shell_sends_request_to_server() {
944 let server = MockServer::start().await;
945 let port = server.address().port();
946
947 let mock = Mock::given(method("POST"))
948 .respond_with(
949 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
950 )
951 .expect(1) .mount_as_scoped(&server)
953 .await;
954
955 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
956 client.delete_shell("127.0.0.1", "S1").await.unwrap();
957 drop(mock);
959 }
960
961 #[tokio::test]
963 async fn signal_terminate_sends_request_to_server() {
964 let server = MockServer::start().await;
965 let port = server.address().port();
966
967 let mock = Mock::given(method("POST"))
968 .respond_with(
969 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
970 )
971 .expect(1) .mount_as_scoped(&server)
973 .await;
974
975 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
976 client
977 .signal_terminate("127.0.0.1", "S1", "C1")
978 .await
979 .unwrap();
980 drop(mock);
981 }
982
983 #[tokio::test]
985 async fn run_command_exit_code_defaults_to_minus_one() {
986 let server = MockServer::start().await;
987 let port = server.address().port();
988
989 Mock::given(method("POST"))
991 .respond_with(ResponseTemplate::new(200).set_body_string(
992 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>S1</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
993 ))
994 .up_to_n_times(1)
995 .mount(&server)
996 .await;
997
998 Mock::given(method("POST"))
1000 .respond_with(ResponseTemplate::new(200).set_body_string(
1001 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>C1</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
1002 ))
1003 .up_to_n_times(1)
1004 .mount(&server)
1005 .await;
1006
1007 Mock::given(method("POST"))
1009 .respond_with(ResponseTemplate::new(200).set_body_string(
1010 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1011 <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done"/>
1012 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1013 ))
1014 .up_to_n_times(1)
1015 .mount(&server)
1016 .await;
1017
1018 Mock::given(method("POST"))
1020 .respond_with(
1021 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
1022 )
1023 .mount(&server)
1024 .await;
1025
1026 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1027 let output = client.run_command("127.0.0.1", "test", &[]).await.unwrap();
1028 assert_eq!(output.exit_code, -1);
1030 }
1031
1032 #[tokio::test]
1035 async fn shell_reuse_multiple_commands() {
1036 let server = MockServer::start().await;
1037 let port = server.address().port();
1038
1039 Mock::given(method("POST"))
1041 .respond_with(ResponseTemplate::new(200).set_body_string(
1042 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>REUSE-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1043 ))
1044 .up_to_n_times(1)
1045 .mount(&server)
1046 .await;
1047
1048 Mock::given(method("POST"))
1050 .respond_with(ResponseTemplate::new(200).set_body_string(
1051 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>CMD-1</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
1052 ))
1053 .up_to_n_times(1)
1054 .mount(&server)
1055 .await;
1056
1057 Mock::given(method("POST"))
1059 .respond_with(ResponseTemplate::new(200).set_body_string(
1060 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1061 <rsp:Stream Name="stdout" CommandId="CMD-1">aGVsbG8=</rsp:Stream>
1062 <rsp:CommandState CommandId="CMD-1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
1063 <rsp:ExitCode>0</rsp:ExitCode>
1064 </rsp:CommandState>
1065 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1066 ))
1067 .up_to_n_times(1)
1068 .mount(&server)
1069 .await;
1070
1071 Mock::given(method("POST"))
1073 .respond_with(
1074 ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
1075 )
1076 .mount(&server)
1077 .await;
1078
1079 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1080 let shell = client.open_shell("127.0.0.1").await.unwrap();
1081 assert_eq!(shell.shell_id(), "REUSE-SHELL");
1082
1083 let output1 = shell.run_command("whoami", &[]).await.unwrap();
1084 assert_eq!(output1.stdout, b"hello");
1085 assert_eq!(output1.exit_code, 0);
1086
1087 shell.close().await.unwrap();
1089 }
1090
1091 #[tokio::test]
1092 async fn shell_close_cleanup() {
1093 let server = MockServer::start().await;
1094 let port = server.address().port();
1095
1096 Mock::given(method("POST"))
1098 .respond_with(ResponseTemplate::new(200).set_body_string(
1099 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>CLOSE-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1100 ))
1101 .up_to_n_times(1)
1102 .mount(&server)
1103 .await;
1104
1105 let delete_mock = Mock::given(method("POST"))
1107 .respond_with(
1108 ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
1109 )
1110 .expect(1)
1111 .mount_as_scoped(&server)
1112 .await;
1113
1114 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1115 let shell = client.open_shell("127.0.0.1").await.unwrap();
1116 assert_eq!(shell.shell_id(), "CLOSE-SHELL");
1117 shell.close().await.unwrap();
1118
1119 drop(delete_mock);
1120 }
1121
1122 #[tokio::test]
1123 async fn shell_send_input() {
1124 let server = MockServer::start().await;
1125 let port = server.address().port();
1126
1127 Mock::given(method("POST"))
1129 .respond_with(ResponseTemplate::new(200).set_body_string(
1130 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>INPUT-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1131 ))
1132 .up_to_n_times(1)
1133 .mount(&server)
1134 .await;
1135
1136 let input_mock = Mock::given(method("POST"))
1138 .respond_with(
1139 ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
1140 )
1141 .expect(1)
1142 .mount_as_scoped(&server)
1143 .await;
1144
1145 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1146 let shell = client.open_shell("127.0.0.1").await.unwrap();
1147 let result = shell.send_input("CMD-1", b"hello\n", true).await;
1148 assert!(result.is_ok());
1149
1150 drop(input_mock);
1151 }
1152
1153 #[tokio::test]
1154 async fn shell_signal_ctrl_c() {
1155 let server = MockServer::start().await;
1156 let port = server.address().port();
1157
1158 Mock::given(method("POST"))
1160 .respond_with(ResponseTemplate::new(200).set_body_string(
1161 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>CTRLC-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1162 ))
1163 .up_to_n_times(1)
1164 .mount(&server)
1165 .await;
1166
1167 let signal_mock = Mock::given(method("POST"))
1169 .respond_with(
1170 ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
1171 )
1172 .expect(1)
1173 .mount_as_scoped(&server)
1174 .await;
1175
1176 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1177 let shell = client.open_shell("127.0.0.1").await.unwrap();
1178 let result = shell.signal_ctrl_c("CMD-1").await;
1179 assert!(result.is_ok());
1180
1181 drop(signal_mock);
1182 }
1183
1184 #[test]
1185 fn builder_pattern_constructs_client() {
1186 let client = WinrmClient::builder(WinrmConfig::default())
1187 .credentials(WinrmCredentials::new("admin", "pass", ""))
1188 .build()
1189 .unwrap();
1190 assert_eq!(client.endpoint("host"), "http://host:5985/wsman");
1191 }
1192
1193 #[tokio::test]
1194 async fn shell_run_powershell() {
1195 let server = MockServer::start().await;
1196 let port = server.address().port();
1197
1198 Mock::given(method("POST"))
1200 .respond_with(ResponseTemplate::new(200).set_body_string(
1201 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>PS-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1202 ))
1203 .up_to_n_times(1)
1204 .mount(&server)
1205 .await;
1206
1207 Mock::given(method("POST"))
1209 .respond_with(ResponseTemplate::new(200).set_body_string(
1210 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>PS-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
1211 ))
1212 .up_to_n_times(1)
1213 .mount(&server)
1214 .await;
1215
1216 Mock::given(method("POST"))
1218 .respond_with(ResponseTemplate::new(200).set_body_string(
1219 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1220 <rsp:CommandState CommandId="PS-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
1221 <rsp:ExitCode>0</rsp:ExitCode>
1222 </rsp:CommandState>
1223 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1224 ))
1225 .up_to_n_times(1)
1226 .mount(&server)
1227 .await;
1228
1229 Mock::given(method("POST"))
1231 .respond_with(
1232 ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
1233 )
1234 .mount(&server)
1235 .await;
1236
1237 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1238 let shell = client.open_shell("127.0.0.1").await.unwrap();
1239 let output = shell.run_powershell("Get-Process").await.unwrap();
1240 assert_eq!(output.exit_code, 0);
1241 }
1242
1243 fn retry_config(port: u16, max_retries: u32) -> WinrmConfig {
1246 WinrmConfig {
1247 port,
1248 auth_method: AuthMethod::Basic,
1249 connect_timeout_secs: 5,
1250 operation_timeout_secs: 10,
1251 max_retries,
1252 ..Default::default()
1253 }
1254 }
1255
1256 #[tokio::test]
1257 async fn retry_succeeds_after_transient_errors() {
1258 let server = MockServer::start().await;
1259 let port = server.address().port();
1260
1261 Mock::given(method("POST"))
1263 .and(header("Authorization", "Basic YWRtaW46cGFzcw=="))
1264 .respond_with(ResponseTemplate::new(200).set_body_string(
1265 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>RETRY-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1266 ))
1267 .mount(&server)
1268 .await;
1269
1270 let client = WinrmClient::new(retry_config(port, 2), test_creds()).unwrap();
1271 let shell_id = client.create_shell("127.0.0.1").await.unwrap();
1272 assert_eq!(shell_id, "RETRY-SHELL");
1273 }
1274
1275 #[tokio::test]
1276 async fn retry_not_applied_to_auth_errors() {
1277 let server = MockServer::start().await;
1278 let port = server.address().port();
1279
1280 Mock::given(method("POST"))
1282 .respond_with(ResponseTemplate::new(401))
1283 .mount(&server)
1284 .await;
1285
1286 let client = WinrmClient::new(retry_config(port, 3), test_creds()).unwrap();
1287 let result = client.create_shell("127.0.0.1").await;
1288 assert!(result.is_err());
1289 let err = format!("{}", result.unwrap_err());
1291 assert!(err.contains("auth") || err.contains("401"));
1292 }
1293
1294 #[tokio::test]
1295 async fn retry_not_applied_to_soap_faults() {
1296 let server = MockServer::start().await;
1297 let port = server.address().port();
1298
1299 Mock::given(method("POST"))
1301 .and(header("Authorization", "Basic YWRtaW46cGFzcw=="))
1302 .respond_with(ResponseTemplate::new(200).set_body_string(
1303 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>",
1304 ))
1305 .mount(&server)
1306 .await;
1307
1308 let client = WinrmClient::new(retry_config(port, 3), test_creds()).unwrap();
1309 let result = client.create_shell("127.0.0.1").await;
1310 assert!(result.is_err());
1311 let err = format!("{}", result.unwrap_err());
1312 assert!(err.contains("SOAP") || err.contains("Access denied"));
1313 }
1314
1315 #[tokio::test]
1316 async fn retry_zero_means_no_retry() {
1317 let server = MockServer::start().await;
1319 let port = server.address().port();
1320
1321 Mock::given(method("POST"))
1322 .and(header("Authorization", "Basic YWRtaW46cGFzcw=="))
1323 .respond_with(ResponseTemplate::new(200).set_body_string(
1324 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>NO-RETRY</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1325 ))
1326 .mount(&server)
1327 .await;
1328
1329 let client = WinrmClient::new(retry_config(port, 0), test_creds()).unwrap();
1330 let shell_id = client.create_shell("127.0.0.1").await.unwrap();
1331 assert_eq!(shell_id, "NO-RETRY");
1332 }
1333
1334 #[test]
1337 fn kerberos_without_feature_returns_helpful_error() {
1338 let config = WinrmConfig {
1339 auth_method: AuthMethod::Kerberos,
1340 ..Default::default()
1341 };
1342 let client = WinrmClient::new(config, test_creds()).unwrap();
1343 assert_eq!(client.endpoint("host"), "http://host:5985/wsman");
1344 }
1345
1346 #[cfg(not(feature = "kerberos"))]
1347 #[tokio::test]
1348 async fn kerberos_send_returns_feature_error() {
1349 let config = WinrmConfig {
1350 auth_method: AuthMethod::Kerberos,
1351 ..Default::default()
1352 };
1353 let client = WinrmClient::new(config, test_creds()).unwrap();
1354 let result = client.create_shell("127.0.0.1").await;
1355 assert!(result.is_err());
1356 let err = format!("{}", result.unwrap_err());
1357 assert!(
1358 err.contains("kerberos"),
1359 "error should mention kerberos feature: {err}"
1360 );
1361 }
1362
1363 #[test]
1364 fn certificate_auth_requires_cert_pem() {
1365 let config = WinrmConfig {
1366 auth_method: AuthMethod::Certificate,
1367 ..Default::default()
1369 };
1370 let result = WinrmClient::new(config, test_creds());
1371 let err = result.err().expect("should fail without client_cert_pem");
1372 let msg = format!("{err}");
1373 assert!(
1374 msg.contains("client_cert_pem"),
1375 "error should mention client_cert_pem: {msg}"
1376 );
1377 }
1378
1379 #[test]
1380 fn certificate_auth_requires_key_pem() {
1381 let config = WinrmConfig {
1382 auth_method: AuthMethod::Certificate,
1383 client_cert_pem: Some("/tmp/nonexistent-cert.pem".into()),
1384 client_key_pem: None,
1385 ..Default::default()
1386 };
1387 let result = WinrmClient::new(config, test_creds());
1388 let err = result.err().expect("should fail without client_key_pem");
1389 let msg = format!("{err}");
1390 assert!(
1391 msg.contains("client_key_pem"),
1392 "error should mention client_key_pem: {msg}"
1393 );
1394 }
1395
1396 #[tokio::test]
1397 async fn certificate_auth_dispatch_with_wiremock() {
1398 let dir = std::env::temp_dir().join("winrm-rs-test-cert");
1399 std::fs::create_dir_all(&dir).unwrap();
1400 let cert_path = dir.join("cert.pem");
1401 let key_path = dir.join("key.pem");
1402 std::fs::write(&cert_path, b"not a real cert").unwrap();
1403 std::fs::write(&key_path, b"not a real key").unwrap();
1404
1405 let config = WinrmConfig {
1406 auth_method: AuthMethod::Certificate,
1407 client_cert_pem: Some(cert_path.to_string_lossy().into()),
1408 client_key_pem: Some(key_path.to_string_lossy().into()),
1409 ..Default::default()
1410 };
1411 let result = WinrmClient::new(config, test_creds());
1413 assert!(result.is_err());
1414
1415 std::fs::remove_dir_all(&dir).ok();
1417 }
1418
1419 #[test]
1420 fn proxy_config_is_preserved() {
1421 let config = WinrmConfig {
1422 proxy: Some("http://proxy:8080".into()),
1423 ..Default::default()
1424 };
1425 let client = WinrmClient::new(config.clone(), test_creds()).unwrap();
1426 assert_eq!(client.config().proxy.as_deref(), Some("http://proxy:8080"));
1427 }
1428
1429 #[test]
1432 fn winrm_error_transfer_display() {
1433 let err = WinrmError::Transfer("upload chunk 3 failed".into());
1434 assert_eq!(
1435 format!("{err}"),
1436 "file transfer error: upload chunk 3 failed"
1437 );
1438 }
1439
1440 #[tokio::test]
1441 async fn start_command_and_receive_next() {
1442 let server = MockServer::start().await;
1443 let port = server.address().port();
1444
1445 Mock::given(method("POST"))
1447 .respond_with(ResponseTemplate::new(200).set_body_string(
1448 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>STREAM-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1449 ))
1450 .up_to_n_times(1)
1451 .mount(&server)
1452 .await;
1453
1454 Mock::given(method("POST"))
1456 .respond_with(ResponseTemplate::new(200).set_body_string(
1457 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>STREAM-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
1458 ))
1459 .up_to_n_times(1)
1460 .mount(&server)
1461 .await;
1462
1463 Mock::given(method("POST"))
1466 .respond_with(ResponseTemplate::new(200).set_body_string(
1467 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1468 <rsp:Stream Name="stdout" CommandId="STREAM-CMD">Y2h1bmsx</rsp:Stream>
1469 <rsp:CommandState CommandId="STREAM-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/>
1470 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1471 ))
1472 .up_to_n_times(1)
1473 .mount(&server)
1474 .await;
1475
1476 Mock::given(method("POST"))
1479 .respond_with(ResponseTemplate::new(200).set_body_string(
1480 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1481 <rsp:Stream Name="stdout" CommandId="STREAM-CMD">Y2h1bmsy</rsp:Stream>
1482 <rsp:CommandState CommandId="STREAM-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
1483 <rsp:ExitCode>0</rsp:ExitCode>
1484 </rsp:CommandState>
1485 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1486 ))
1487 .up_to_n_times(1)
1488 .mount(&server)
1489 .await;
1490
1491 Mock::given(method("POST"))
1493 .respond_with(
1494 ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
1495 )
1496 .mount(&server)
1497 .await;
1498
1499 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1500 let shell = client.open_shell("127.0.0.1").await.unwrap();
1501
1502 let cmd_id = shell
1503 .start_command("ping", &["-t", "10.0.0.1"])
1504 .await
1505 .unwrap();
1506 assert_eq!(cmd_id, "STREAM-CMD");
1507
1508 let chunk1 = shell.receive_next(&cmd_id).await.unwrap();
1510 assert_eq!(chunk1.stdout, b"chunk1");
1511 assert!(!chunk1.done);
1512
1513 let chunk2 = shell.receive_next(&cmd_id).await.unwrap();
1515 assert_eq!(chunk2.stdout, b"chunk2");
1516 assert!(chunk2.done);
1517 assert_eq!(chunk2.exit_code, Some(0));
1518 }
1519
1520 #[tokio::test]
1521 async fn download_file_with_wiremock() {
1522 let server = MockServer::start().await;
1523 let port = server.address().port();
1524
1525 let ps_output_b64 = base64::engine::general_purpose::STANDARD.encode(b"hello file content");
1527 let stdout_b64 = B64.encode(ps_output_b64.as_bytes());
1528
1529 Mock::given(method("POST"))
1531 .respond_with(ResponseTemplate::new(200).set_body_string(
1532 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DL-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1533 ))
1534 .up_to_n_times(1)
1535 .mount(&server)
1536 .await;
1537
1538 Mock::given(method("POST"))
1540 .respond_with(ResponseTemplate::new(200).set_body_string(
1541 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DL-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
1542 ))
1543 .up_to_n_times(1)
1544 .mount(&server)
1545 .await;
1546
1547 let receive_body = format!(
1549 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1550 <rsp:Stream Name="stdout" CommandId="DL-CMD">{stdout_b64}</rsp:Stream>
1551 <rsp:CommandState CommandId="DL-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
1552 <rsp:ExitCode>0</rsp:ExitCode>
1553 </rsp:CommandState>
1554 </rsp:ReceiveResponse></s:Body></s:Envelope>"#
1555 );
1556 Mock::given(method("POST"))
1557 .respond_with(ResponseTemplate::new(200).set_body_string(receive_body))
1558 .up_to_n_times(1)
1559 .mount(&server)
1560 .await;
1561
1562 Mock::given(method("POST"))
1564 .respond_with(
1565 ResponseTemplate::new(200).set_body_string(r"<s:Envelope><s:Body/></s:Envelope>"),
1566 )
1567 .mount(&server)
1568 .await;
1569
1570 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1571 let local_path = std::env::temp_dir().join("winrm-rs-test-download.bin");
1572 let result = client
1573 .download_file("127.0.0.1", "C:\\remote\\file.txt", &local_path)
1574 .await;
1575 assert!(result.is_ok(), "download_file failed: {result:?}");
1576 let bytes = result.unwrap();
1577 assert_eq!(bytes, 18); let content = std::fs::read(&local_path).unwrap();
1579 assert_eq!(content, b"hello file content");
1580
1581 std::fs::remove_file(&local_path).ok();
1583 }
1584
1585 const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
1587 MIIBXjCCAQWgAwIBAgIUMMmMPCKhqsfVxxq36Hmd4IHUTNgwCgYIKoZIzj0EAwIw\n\
1588 ITEfMB0GA1UEAwwWcmNnZW4gc2VsZiBzaWduZWQgY2VydDAgFw03NTAxMDEwMDAw\n\
1589 MDBaGA80MDk2MDEwMTAwMDAwMFowITEfMB0GA1UEAwwWcmNnZW4gc2VsZiBzaWdu\n\
1590 ZWQgY2VydDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPmdXVVusEfNSmt6aKUf\n\
1591 lw2+69/9LSYPVO0KUgALGqjUvoAMAwE/6AWQDrN2EH/swrMHJbM5l2y4Y7GEYbav\n\
1592 glKjGTAXMBUGA1UdEQQOMAyCCnRlc3QubG9jYWwwCgYIKoZIzj0EAwIDRwAwRAIg\n\
1593 JjoSt8p+3HBP3/EGZ/icOAC/N0o03a6SUOjMwgFiCbQCIDc2+ShrQhU3FNeE4Gu1\n\
1594 hOMpiIz+2YFoGkzaDJ6fFB6B\n\
1595 -----END CERTIFICATE-----\n";
1596 const TEST_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
1597 MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+nMC1P5m4rXIR86n\n\
1598 DVStYeCVDra7xdrnpbNklaXDbkWhRANCAAT5nV1VbrBHzUpremilH5cNvuvf/S0m\n\
1599 D1TtClIACxqo1L6ADAMBP+gFkA6zdhB/7MKzByWzOZdsuGOxhGG2r4JS\n\
1600 -----END PRIVATE KEY-----\n";
1601
1602 #[test]
1603 fn certificate_auth_reads_valid_pem_files() {
1604 let dir = std::env::temp_dir().join("winrm-rs-test-cert-valid");
1605 std::fs::create_dir_all(&dir).unwrap();
1606 let cert_path = dir.join("test_cert.pem");
1607 let key_path = dir.join("test_key.pem");
1608
1609 std::fs::write(&cert_path, TEST_CERT_PEM).unwrap();
1610 std::fs::write(&key_path, TEST_KEY_PEM).unwrap();
1611
1612 let config = WinrmConfig {
1613 auth_method: AuthMethod::Certificate,
1614 client_cert_pem: Some(cert_path.to_string_lossy().into()),
1615 client_key_pem: Some(key_path.to_string_lossy().into()),
1616 use_tls: true,
1617 ..Default::default()
1618 };
1619 let result = WinrmClient::new(config, test_creds());
1620 assert!(
1621 result.is_ok(),
1622 "valid PEM should construct client: {}",
1623 result.err().map(|e| format!("{e}")).unwrap_or_default()
1624 );
1625
1626 std::fs::remove_dir_all(&dir).ok();
1627 }
1628
1629 #[test]
1630 fn certificate_auth_reads_pem_files_invalid() {
1631 let dir = std::env::temp_dir().join("winrm-rs-test-cert-read");
1632 std::fs::create_dir_all(&dir).unwrap();
1633 let cert_path = dir.join("test_cert.pem");
1634 let key_path = dir.join("test_key.pem");
1635
1636 std::fs::write(
1637 &cert_path,
1638 b"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n",
1639 )
1640 .unwrap();
1641 std::fs::write(
1642 &key_path,
1643 b"-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n",
1644 )
1645 .unwrap();
1646
1647 let config = WinrmConfig {
1648 auth_method: AuthMethod::Certificate,
1649 client_cert_pem: Some(cert_path.to_string_lossy().into()),
1650 client_key_pem: Some(key_path.to_string_lossy().into()),
1651 use_tls: true,
1652 ..Default::default()
1653 };
1654 let result = WinrmClient::new(config, test_creds());
1655 assert!(result.is_err());
1656
1657 std::fs::remove_dir_all(&dir).ok();
1658 }
1659
1660 #[test]
1661 fn certificate_auth_missing_key_path_with_valid_cert_file() {
1662 let dir = std::env::temp_dir().join("winrm-rs-test-cert-nokey");
1663 std::fs::create_dir_all(&dir).unwrap();
1664 let cert_path = dir.join("test_cert.pem");
1665 std::fs::write(&cert_path, b"cert data").unwrap();
1666
1667 let config = WinrmConfig {
1668 auth_method: AuthMethod::Certificate,
1669 client_cert_pem: Some(cert_path.to_string_lossy().into()),
1670 client_key_pem: None,
1671 use_tls: true,
1672 ..Default::default()
1673 };
1674 let result = WinrmClient::new(config, test_creds());
1675 assert!(result.is_err());
1676 assert!(format!("{}", result.err().unwrap()).contains("client_key_pem"));
1677
1678 std::fs::remove_dir_all(&dir).ok();
1679 }
1680
1681 #[test]
1682 fn certificate_auth_nonexistent_cert_file() {
1683 let config = WinrmConfig {
1684 auth_method: AuthMethod::Certificate,
1685 client_cert_pem: Some("/nonexistent/cert.pem".into()),
1686 client_key_pem: Some("/nonexistent/key.pem".into()),
1687 use_tls: true,
1688 ..Default::default()
1689 };
1690 let result = WinrmClient::new(config, test_creds());
1691 assert!(result.is_err());
1692 let err = format!("{}", result.err().unwrap());
1693 assert!(
1694 err.contains("failed to read") || err.contains("cert"),
1695 "error should mention cert read failure: {err}"
1696 );
1697 }
1698
1699 #[test]
1700 fn certificate_auth_nonexistent_key_file() {
1701 let dir = std::env::temp_dir().join("winrm-rs-test-cert-nokey2");
1702 std::fs::create_dir_all(&dir).unwrap();
1703 let cert_path = dir.join("test_cert.pem");
1704 std::fs::write(&cert_path, b"cert data").unwrap();
1705
1706 let config = WinrmConfig {
1707 auth_method: AuthMethod::Certificate,
1708 client_cert_pem: Some(cert_path.to_string_lossy().into()),
1709 client_key_pem: Some("/nonexistent/key.pem".into()),
1710 use_tls: true,
1711 ..Default::default()
1712 };
1713 let result = WinrmClient::new(config, test_creds());
1714 assert!(result.is_err());
1715 let err = format!("{}", result.err().unwrap());
1716 assert!(
1717 err.contains("failed to read") || err.contains("key"),
1718 "error should mention key read failure: {err}"
1719 );
1720
1721 std::fs::remove_dir_all(&dir).ok();
1722 }
1723
1724 #[tokio::test]
1725 async fn certificate_auth_dispatch_in_send_soap() {
1726 let server = MockServer::start().await;
1727 let port = server.address().port();
1728
1729 Mock::given(method("POST"))
1730 .respond_with(ResponseTemplate::new(200).set_body_string(
1731 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>CERT-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1732 ))
1733 .mount(&server)
1734 .await;
1735
1736 let dir = std::env::temp_dir().join("winrm-rs-test-cert-dispatch");
1737 std::fs::create_dir_all(&dir).unwrap();
1738 let cert_path = dir.join("cert.pem");
1739 let key_path = dir.join("key.pem");
1740 std::fs::write(&cert_path, TEST_CERT_PEM).unwrap();
1741 std::fs::write(&key_path, TEST_KEY_PEM).unwrap();
1742
1743 let config = WinrmConfig {
1744 port,
1745 auth_method: AuthMethod::Certificate,
1746 client_cert_pem: Some(cert_path.to_string_lossy().into()),
1747 client_key_pem: Some(key_path.to_string_lossy().into()),
1748 connect_timeout_secs: 5,
1749 operation_timeout_secs: 10,
1750 ..Default::default()
1751 };
1752
1753 let client = WinrmClient::new(config, test_creds()).unwrap();
1754 let result = client.create_shell("127.0.0.1").await;
1755 assert!(result.is_ok(), "cert auth dispatch failed: {result:?}");
1756 assert_eq!(result.unwrap(), "CERT-SHELL");
1757
1758 std::fs::remove_dir_all(&dir).ok();
1759 }
1760
1761 #[tokio::test]
1762 async fn shell_run_command_full_lifecycle() {
1763 let server = MockServer::start().await;
1764 let port = server.address().port();
1765
1766 Mock::given(method("POST"))
1768 .respond_with(ResponseTemplate::new(200).set_body_string(
1769 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>RUN-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1770 ))
1771 .up_to_n_times(1)
1772 .mount(&server)
1773 .await;
1774
1775 Mock::given(method("POST"))
1777 .respond_with(ResponseTemplate::new(200).set_body_string(
1778 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>RUN-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
1779 ))
1780 .up_to_n_times(1)
1781 .mount(&server)
1782 .await;
1783
1784 Mock::given(method("POST"))
1786 .respond_with(ResponseTemplate::new(200).set_body_string(
1787 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1788 <rsp:Stream Name="stdout" CommandId="RUN-CMD">cGFydDE=</rsp:Stream>
1789 <rsp:CommandState CommandId="RUN-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/>
1790 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1791 ))
1792 .up_to_n_times(1)
1793 .mount(&server)
1794 .await;
1795
1796 Mock::given(method("POST"))
1798 .respond_with(ResponseTemplate::new(200).set_body_string(
1799 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1800 <rsp:Stream Name="stdout" CommandId="RUN-CMD">cGFydDI=</rsp:Stream>
1801 <rsp:Stream Name="stderr" CommandId="RUN-CMD">ZXJyMQ==</rsp:Stream>
1802 <rsp:CommandState CommandId="RUN-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
1803 <rsp:ExitCode>42</rsp:ExitCode>
1804 </rsp:CommandState>
1805 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1806 ))
1807 .up_to_n_times(1)
1808 .mount(&server)
1809 .await;
1810
1811 Mock::given(method("POST"))
1813 .respond_with(
1814 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
1815 )
1816 .mount(&server)
1817 .await;
1818
1819 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1820 let shell = client.open_shell("127.0.0.1").await.unwrap();
1821 let output = shell.run_command("cmd.exe", &["/c", "echo"]).await.unwrap();
1822
1823 assert_eq!(output.stdout, b"part1part2");
1824 assert_eq!(output.stderr, b"err1");
1825 assert_eq!(output.exit_code, 42);
1826
1827 shell.close().await.unwrap();
1828 }
1829
1830 #[tokio::test]
1831 async fn shell_start_command_returns_command_id() {
1832 let server = MockServer::start().await;
1833 let port = server.address().port();
1834
1835 Mock::given(method("POST"))
1837 .respond_with(ResponseTemplate::new(200).set_body_string(
1838 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>START-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1839 ))
1840 .up_to_n_times(1)
1841 .mount(&server)
1842 .await;
1843
1844 Mock::given(method("POST"))
1846 .respond_with(ResponseTemplate::new(200).set_body_string(
1847 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>START-CMD-123</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
1848 ))
1849 .up_to_n_times(1)
1850 .mount(&server)
1851 .await;
1852
1853 Mock::given(method("POST"))
1855 .respond_with(
1856 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
1857 )
1858 .mount(&server)
1859 .await;
1860
1861 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1862 let shell = client.open_shell("127.0.0.1").await.unwrap();
1863 let cmd_id = shell.start_command("whoami", &[]).await.unwrap();
1864 assert_eq!(cmd_id, "START-CMD-123");
1865 }
1866
1867 #[tokio::test]
1868 async fn upload_file_success_with_wiremock() {
1869 let server = MockServer::start().await;
1870 let port = server.address().port();
1871
1872 let dir = std::env::temp_dir().join("winrm-rs-test-upload");
1873 std::fs::create_dir_all(&dir).unwrap();
1874 let local_file = dir.join("upload_test.txt");
1875 std::fs::write(&local_file, b"hello upload content").unwrap();
1876
1877 Mock::given(method("POST"))
1879 .respond_with(ResponseTemplate::new(200).set_body_string(
1880 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>UL-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1881 ))
1882 .up_to_n_times(1)
1883 .mount(&server)
1884 .await;
1885
1886 Mock::given(method("POST"))
1888 .respond_with(ResponseTemplate::new(200).set_body_string(
1889 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>UL-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
1890 ))
1891 .up_to_n_times(1)
1892 .mount(&server)
1893 .await;
1894
1895 Mock::given(method("POST"))
1897 .respond_with(ResponseTemplate::new(200).set_body_string(
1898 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1899 <rsp:CommandState CommandId="UL-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
1900 <rsp:ExitCode>0</rsp:ExitCode>
1901 </rsp:CommandState>
1902 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1903 ))
1904 .up_to_n_times(1)
1905 .mount(&server)
1906 .await;
1907
1908 Mock::given(method("POST"))
1910 .respond_with(
1911 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
1912 )
1913 .mount(&server)
1914 .await;
1915
1916 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1917 let result = client
1918 .upload_file("127.0.0.1", &local_file, "C:\\remote\\file.txt")
1919 .await;
1920 assert!(result.is_ok(), "upload_file failed: {result:?}");
1921 assert_eq!(result.unwrap(), 20);
1922
1923 std::fs::remove_dir_all(&dir).ok();
1924 }
1925
1926 #[tokio::test]
1927 async fn upload_file_chunk_failure() {
1928 let server = MockServer::start().await;
1929 let port = server.address().port();
1930
1931 let dir = std::env::temp_dir().join("winrm-rs-test-upload-fail");
1932 std::fs::create_dir_all(&dir).unwrap();
1933 let local_file = dir.join("upload_fail.txt");
1934 std::fs::write(&local_file, b"test data").unwrap();
1935
1936 Mock::given(method("POST"))
1937 .respond_with(ResponseTemplate::new(200).set_body_string(
1938 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>ULF-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1939 ))
1940 .up_to_n_times(1)
1941 .mount(&server)
1942 .await;
1943
1944 Mock::given(method("POST"))
1945 .respond_with(ResponseTemplate::new(200).set_body_string(
1946 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>ULF-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
1947 ))
1948 .up_to_n_times(1)
1949 .mount(&server)
1950 .await;
1951
1952 Mock::given(method("POST"))
1953 .respond_with(ResponseTemplate::new(200).set_body_string(
1954 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1955 <rsp:Stream Name="stderr" CommandId="ULF-CMD">YWNjZXNzIGRlbmllZA==</rsp:Stream>
1956 <rsp:CommandState CommandId="ULF-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
1957 <rsp:ExitCode>1</rsp:ExitCode>
1958 </rsp:CommandState>
1959 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1960 ))
1961 .up_to_n_times(1)
1962 .mount(&server)
1963 .await;
1964
1965 Mock::given(method("POST"))
1966 .respond_with(
1967 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
1968 )
1969 .mount(&server)
1970 .await;
1971
1972 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1973 let result = client
1974 .upload_file("127.0.0.1", &local_file, "C:\\remote\\file.txt")
1975 .await;
1976 assert!(result.is_err());
1977 let err = format!("{}", result.unwrap_err());
1978 assert!(
1979 err.contains("upload chunk") || err.contains("transfer"),
1980 "error should mention upload chunk failure: {err}"
1981 );
1982
1983 std::fs::remove_dir_all(&dir).ok();
1984 }
1985
1986 #[tokio::test]
1987 async fn download_file_ps_failure() {
1988 let server = MockServer::start().await;
1989 let port = server.address().port();
1990
1991 Mock::given(method("POST"))
1992 .respond_with(ResponseTemplate::new(200).set_body_string(
1993 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DLF-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1994 ))
1995 .up_to_n_times(1)
1996 .mount(&server)
1997 .await;
1998
1999 Mock::given(method("POST"))
2000 .respond_with(ResponseTemplate::new(200).set_body_string(
2001 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DLF-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2002 ))
2003 .up_to_n_times(1)
2004 .mount(&server)
2005 .await;
2006
2007 Mock::given(method("POST"))
2008 .respond_with(ResponseTemplate::new(200).set_body_string(
2009 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2010 <rsp:Stream Name="stderr" CommandId="DLF-CMD">ZmlsZSBub3QgZm91bmQ=</rsp:Stream>
2011 <rsp:CommandState CommandId="DLF-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
2012 <rsp:ExitCode>1</rsp:ExitCode>
2013 </rsp:CommandState>
2014 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
2015 ))
2016 .up_to_n_times(1)
2017 .mount(&server)
2018 .await;
2019
2020 Mock::given(method("POST"))
2021 .respond_with(
2022 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
2023 )
2024 .mount(&server)
2025 .await;
2026
2027 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
2028 let local_path = std::env::temp_dir().join("winrm-rs-test-dlfail.bin");
2029 let result = client
2030 .download_file("127.0.0.1", "C:\\nonexistent.txt", &local_path)
2031 .await;
2032 assert!(result.is_err());
2033 let err = format!("{}", result.unwrap_err());
2034 assert!(
2035 err.contains("download") || err.contains("transfer"),
2036 "error should mention download failure: {err}"
2037 );
2038 }
2039
2040 #[tokio::test]
2041 async fn upload_file_multi_chunk() {
2042 let server = MockServer::start().await;
2043 let port = server.address().port();
2044
2045 let dir = std::env::temp_dir().join("winrm-rs-test-upload-multi");
2046 std::fs::create_dir_all(&dir).unwrap();
2047 let local_file = dir.join("small.txt");
2048 std::fs::write(&local_file, b"tiny").unwrap();
2049
2050 Mock::given(method("POST"))
2051 .respond_with(ResponseTemplate::new(200).set_body_string(
2052 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>MC-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
2053 ))
2054 .up_to_n_times(1)
2055 .mount(&server)
2056 .await;
2057
2058 Mock::given(method("POST"))
2059 .respond_with(ResponseTemplate::new(200).set_body_string(
2060 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MC-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2061 ))
2062 .up_to_n_times(1)
2063 .mount(&server)
2064 .await;
2065
2066 Mock::given(method("POST"))
2067 .respond_with(ResponseTemplate::new(200).set_body_string(
2068 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2069 <rsp:CommandState CommandId="MC-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
2070 <rsp:ExitCode>0</rsp:ExitCode>
2071 </rsp:CommandState>
2072 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
2073 ))
2074 .up_to_n_times(1)
2075 .mount(&server)
2076 .await;
2077
2078 Mock::given(method("POST"))
2079 .respond_with(
2080 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
2081 )
2082 .mount(&server)
2083 .await;
2084
2085 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
2086 let result = client
2087 .upload_file("127.0.0.1", &local_file, "C:\\remote\\small.txt")
2088 .await;
2089 assert!(result.is_ok());
2090 assert_eq!(result.unwrap(), 4);
2091
2092 std::fs::remove_dir_all(&dir).ok();
2093 }
2094
2095 #[tokio::test]
2096 async fn download_file_write_local_success() {
2097 let server = MockServer::start().await;
2098 let port = server.address().port();
2099
2100 let ps_output_b64 = B64.encode(b"test bytes");
2101 let stdout_b64 = B64.encode(ps_output_b64.as_bytes());
2102
2103 Mock::given(method("POST"))
2104 .respond_with(ResponseTemplate::new(200).set_body_string(
2105 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DL2-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
2106 ))
2107 .up_to_n_times(1)
2108 .mount(&server)
2109 .await;
2110
2111 Mock::given(method("POST"))
2112 .respond_with(ResponseTemplate::new(200).set_body_string(
2113 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DL2-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2114 ))
2115 .up_to_n_times(1)
2116 .mount(&server)
2117 .await;
2118
2119 let receive_body = format!(
2120 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2121 <rsp:Stream Name="stdout" CommandId="DL2-CMD">{stdout_b64}</rsp:Stream>
2122 <rsp:CommandState CommandId="DL2-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
2123 <rsp:ExitCode>0</rsp:ExitCode>
2124 </rsp:CommandState>
2125 </rsp:ReceiveResponse></s:Body></s:Envelope>"#
2126 );
2127 Mock::given(method("POST"))
2128 .respond_with(ResponseTemplate::new(200).set_body_string(receive_body))
2129 .up_to_n_times(1)
2130 .mount(&server)
2131 .await;
2132
2133 Mock::given(method("POST"))
2134 .respond_with(
2135 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
2136 )
2137 .mount(&server)
2138 .await;
2139
2140 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
2141 let local_path = std::env::temp_dir().join("winrm-rs-test-dl2.bin");
2142 let result = client
2143 .download_file("127.0.0.1", "C:\\remote\\data.bin", &local_path)
2144 .await;
2145 assert!(result.is_ok());
2146 assert_eq!(result.unwrap(), 10);
2147 let content = std::fs::read(&local_path).unwrap();
2148 assert_eq!(content, b"test bytes");
2149
2150 std::fs::remove_file(&local_path).ok();
2151 }
2152
2153 #[tokio::test]
2154 async fn run_command_delete_shell_failure_is_ignored() {
2155 let server = MockServer::start().await;
2156 let port = server.address().port();
2157
2158 Mock::given(method("POST"))
2159 .respond_with(ResponseTemplate::new(200).set_body_string(
2160 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DEL-FAIL-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
2161 ))
2162 .up_to_n_times(1)
2163 .mount(&server)
2164 .await;
2165
2166 Mock::given(method("POST"))
2167 .respond_with(ResponseTemplate::new(200).set_body_string(
2168 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DEL-FAIL-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2169 ))
2170 .up_to_n_times(1)
2171 .mount(&server)
2172 .await;
2173
2174 Mock::given(method("POST"))
2175 .respond_with(ResponseTemplate::new(200).set_body_string(
2176 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2177 <rsp:Stream Name="stdout" CommandId="DEL-FAIL-CMD">b2s=</rsp:Stream>
2178 <rsp:CommandState CommandId="DEL-FAIL-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
2179 <rsp:ExitCode>0</rsp:ExitCode>
2180 </rsp:CommandState>
2181 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
2182 ))
2183 .up_to_n_times(1)
2184 .mount(&server)
2185 .await;
2186
2187 Mock::given(method("POST"))
2188 .respond_with(
2189 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
2190 )
2191 .up_to_n_times(1)
2192 .mount(&server)
2193 .await;
2194
2195 Mock::given(method("POST"))
2196 .respond_with(ResponseTemplate::new(200).set_body_string(
2197 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>",
2198 ))
2199 .mount(&server)
2200 .await;
2201
2202 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
2203 let output = client
2204 .run_command("127.0.0.1", "whoami", &[])
2205 .await
2206 .unwrap();
2207 assert_eq!(output.exit_code, 0);
2208 assert_eq!(output.stdout, b"ok");
2209 }
2210
2211 #[tokio::test]
2212 #[cfg_attr(windows, ignore)]
2217 async fn retry_backoff_on_http_error() {
2218 use std::sync::Arc;
2219 use std::sync::atomic::{AtomicU32, Ordering};
2220
2221 let counter = Arc::new(AtomicU32::new(0));
2222 let counter_clone = counter.clone();
2223
2224 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2225 let port = listener.local_addr().unwrap().port();
2226
2227 let handle = tokio::spawn(async move {
2228 loop {
2229 let Ok((mut stream, _)) = listener.accept().await else {
2230 break;
2231 };
2232 let call = counter_clone.fetch_add(1, Ordering::SeqCst);
2233 if call == 0 {
2234 drop(stream);
2235 } else {
2236 use tokio::io::AsyncWriteExt;
2237 let body = r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>RETRY-BACKOFF</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>";
2238 let response = format!(
2239 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/xml\r\n\r\n{}",
2240 body.len(),
2241 body
2242 );
2243 let _ = stream.write_all(response.as_bytes()).await;
2244 }
2245 }
2246 });
2247
2248 let config = WinrmConfig {
2249 port,
2250 auth_method: AuthMethod::Basic,
2251 connect_timeout_secs: 2,
2252 operation_timeout_secs: 5,
2253 max_retries: 2,
2254 ..Default::default()
2255 };
2256 let client = WinrmClient::new(config, test_creds()).unwrap();
2257 let result = client.create_shell("127.0.0.1").await;
2258 assert!(
2259 result.is_ok(),
2260 "retry should have succeeded: {}",
2261 result.err().map(|e| format!("{e}")).unwrap_or_default()
2262 );
2263 assert_eq!(result.unwrap(), "RETRY-BACKOFF");
2264 assert!(
2265 counter.load(Ordering::SeqCst) >= 2,
2266 "should have retried at least once"
2267 );
2268
2269 handle.abort();
2270 }
2271
2272 #[tokio::test]
2273 async fn upload_file_multi_chunk_with_append() {
2274 let server = MockServer::start().await;
2275 let port = server.address().port();
2276
2277 let dir = std::env::temp_dir().join("winrm-rs-test-upload-multi-chunk");
2278 std::fs::create_dir_all(&dir).unwrap();
2279 let local_file = dir.join("multi.bin");
2280 let data = vec![0xABu8; 4001];
2282 std::fs::write(&local_file, &data).unwrap();
2283
2284 Mock::given(method("POST"))
2286 .respond_with(ResponseTemplate::new(200).set_body_string(
2287 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>MCH-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
2288 ))
2289 .up_to_n_times(1)
2290 .mount(&server)
2291 .await;
2292
2293 Mock::given(method("POST"))
2295 .respond_with(ResponseTemplate::new(200).set_body_string(
2296 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MCH-CMD1</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2297 ))
2298 .up_to_n_times(1)
2299 .mount(&server)
2300 .await;
2301 Mock::given(method("POST"))
2302 .respond_with(ResponseTemplate::new(200).set_body_string(
2303 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2304 <rsp:CommandState CommandId="MCH-CMD1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
2305 <rsp:ExitCode>0</rsp:ExitCode>
2306 </rsp:CommandState>
2307 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
2308 ))
2309 .up_to_n_times(1)
2310 .mount(&server)
2311 .await;
2312 Mock::given(method("POST"))
2313 .respond_with(
2314 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
2315 )
2316 .up_to_n_times(1)
2317 .mount(&server)
2318 .await;
2319
2320 Mock::given(method("POST"))
2322 .respond_with(ResponseTemplate::new(200).set_body_string(
2323 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MCH-CMD2</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2324 ))
2325 .up_to_n_times(1)
2326 .mount(&server)
2327 .await;
2328 Mock::given(method("POST"))
2329 .respond_with(ResponseTemplate::new(200).set_body_string(
2330 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2331 <rsp:CommandState CommandId="MCH-CMD2" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
2332 <rsp:ExitCode>0</rsp:ExitCode>
2333 </rsp:CommandState>
2334 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
2335 ))
2336 .up_to_n_times(1)
2337 .mount(&server)
2338 .await;
2339 Mock::given(method("POST"))
2340 .respond_with(
2341 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
2342 )
2343 .up_to_n_times(1)
2344 .mount(&server)
2345 .await;
2346
2347 Mock::given(method("POST"))
2349 .respond_with(ResponseTemplate::new(200).set_body_string(
2350 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MCH-CMD3</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2351 ))
2352 .up_to_n_times(1)
2353 .mount(&server)
2354 .await;
2355 Mock::given(method("POST"))
2356 .respond_with(ResponseTemplate::new(200).set_body_string(
2357 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2358 <rsp:CommandState CommandId="MCH-CMD3" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
2359 <rsp:ExitCode>0</rsp:ExitCode>
2360 </rsp:CommandState>
2361 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
2362 ))
2363 .up_to_n_times(1)
2364 .mount(&server)
2365 .await;
2366
2367 Mock::given(method("POST"))
2369 .respond_with(
2370 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
2371 )
2372 .mount(&server)
2373 .await;
2374
2375 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
2376 let result = client
2377 .upload_file("127.0.0.1", &local_file, "C:\\remote\\multi.bin")
2378 .await;
2379 assert!(result.is_ok(), "multi-chunk upload failed: {result:?}");
2380 assert_eq!(result.unwrap(), 4001);
2381
2382 std::fs::remove_dir_all(&dir).ok();
2383 }
2384
2385 #[tokio::test]
2386 async fn download_file_write_error() {
2387 let server = MockServer::start().await;
2388 let port = server.address().port();
2389
2390 let ps_output_b64 = B64.encode(b"test");
2391 let stdout_b64 = B64.encode(ps_output_b64.as_bytes());
2392
2393 Mock::given(method("POST"))
2394 .respond_with(ResponseTemplate::new(200).set_body_string(
2395 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DLW-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
2396 ))
2397 .up_to_n_times(1)
2398 .mount(&server)
2399 .await;
2400
2401 Mock::given(method("POST"))
2402 .respond_with(ResponseTemplate::new(200).set_body_string(
2403 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>DLW-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2404 ))
2405 .up_to_n_times(1)
2406 .mount(&server)
2407 .await;
2408
2409 let receive_body = format!(
2410 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2411 <rsp:Stream Name="stdout" CommandId="DLW-CMD">{stdout_b64}</rsp:Stream>
2412 <rsp:CommandState CommandId="DLW-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
2413 <rsp:ExitCode>0</rsp:ExitCode>
2414 </rsp:CommandState>
2415 </rsp:ReceiveResponse></s:Body></s:Envelope>"#
2416 );
2417 Mock::given(method("POST"))
2418 .respond_with(ResponseTemplate::new(200).set_body_string(receive_body))
2419 .up_to_n_times(1)
2420 .mount(&server)
2421 .await;
2422
2423 Mock::given(method("POST"))
2424 .respond_with(
2425 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
2426 )
2427 .mount(&server)
2428 .await;
2429
2430 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
2431 let bad_path = std::path::Path::new("/nonexistent/dir/file.bin");
2432 let result = client
2433 .download_file("127.0.0.1", "C:\\remote.txt", bad_path)
2434 .await;
2435 assert!(result.is_err());
2436 let err = format!("{}", result.unwrap_err());
2437 assert!(
2438 err.contains("failed to write") || err.contains("transfer"),
2439 "error should mention write failure: {err}"
2440 );
2441 }
2442
2443 #[test]
2444 fn upload_file_nonexistent_local_file() {
2445 let rt = tokio::runtime::Runtime::new().unwrap();
2446 let config = WinrmConfig::default();
2447 let client = WinrmClient::new(config, test_creds()).unwrap();
2448
2449 let result = rt.block_on(client.upload_file(
2450 "127.0.0.1",
2451 std::path::Path::new("/nonexistent/file.bin"),
2452 "C:\\remote\\dest.bin",
2453 ));
2454 assert!(result.is_err());
2455 let err = format!("{}", result.unwrap_err());
2456 assert!(
2457 err.contains("transfer error") || err.contains("failed to read"),
2458 "error should mention transfer: {err}"
2459 );
2460 }
2461
2462 #[cfg(not(feature = "kerberos"))]
2465 #[tokio::test]
2466 async fn kerberos_auth_dispatch_returns_auth_error() {
2467 let config = WinrmConfig {
2468 auth_method: AuthMethod::Kerberos,
2469 connect_timeout_secs: 5,
2470 operation_timeout_secs: 10,
2471 ..Default::default()
2472 };
2473 let client = WinrmClient::new(config, test_creds()).unwrap();
2474 let result = client.create_shell("127.0.0.1").await;
2475 assert!(result.is_err());
2476 let err = result.unwrap_err();
2477 let err_msg = format!("{err}");
2478 assert!(
2479 err_msg.contains("kerberos") && err_msg.contains("feature"),
2480 "error should specifically mention kerberos feature, got: {err_msg}"
2481 );
2482 assert!(
2483 matches!(err, WinrmError::AuthFailed(_)),
2484 "error variant should be AuthFailed, got: {err:?}"
2485 );
2486 }
2487
2488 #[tokio::test]
2490 async fn retry_max_one_makes_exactly_two_attempts() {
2491 use std::sync::Arc;
2492 use std::sync::atomic::{AtomicU32, Ordering};
2493
2494 let counter = Arc::new(AtomicU32::new(0));
2495 let counter_clone = counter.clone();
2496
2497 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2498 let port = listener.local_addr().unwrap().port();
2499
2500 let handle = tokio::spawn(async move {
2501 loop {
2502 let Ok((stream, _)) = listener.accept().await else {
2503 break;
2504 };
2505 counter_clone.fetch_add(1, Ordering::SeqCst);
2506 drop(stream);
2507 }
2508 });
2509
2510 let config = WinrmConfig {
2511 port,
2512 auth_method: AuthMethod::Basic,
2513 connect_timeout_secs: 2,
2514 operation_timeout_secs: 5,
2515 max_retries: 1,
2516 ..Default::default()
2517 };
2518 let client = WinrmClient::new(config, test_creds()).unwrap();
2519 let result = client.create_shell("127.0.0.1").await;
2520 assert!(result.is_err(), "should fail after all retries exhausted");
2521
2522 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2523
2524 let attempts = counter.load(Ordering::SeqCst);
2525 assert_eq!(
2526 attempts, 2,
2527 "max_retries=1 should make exactly 2 attempts, got {attempts}"
2528 );
2529
2530 handle.abort();
2531 }
2532
2533 #[tokio::test]
2534 async fn retry_max_zero_makes_exactly_one_attempt() {
2535 use std::sync::Arc;
2536 use std::sync::atomic::{AtomicU32, Ordering};
2537
2538 let counter = Arc::new(AtomicU32::new(0));
2539 let counter_clone = counter.clone();
2540
2541 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2542 let port = listener.local_addr().unwrap().port();
2543
2544 let handle = tokio::spawn(async move {
2545 loop {
2546 let Ok((stream, _)) = listener.accept().await else {
2547 break;
2548 };
2549 counter_clone.fetch_add(1, Ordering::SeqCst);
2550 drop(stream);
2551 }
2552 });
2553
2554 let config = WinrmConfig {
2555 port,
2556 auth_method: AuthMethod::Basic,
2557 connect_timeout_secs: 2,
2558 operation_timeout_secs: 5,
2559 max_retries: 0,
2560 ..Default::default()
2561 };
2562 let client = WinrmClient::new(config, test_creds()).unwrap();
2563 let result = client.create_shell("127.0.0.1").await;
2564 assert!(result.is_err());
2565
2566 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2567
2568 let attempts = counter.load(Ordering::SeqCst);
2569 assert_eq!(
2570 attempts, 1,
2571 "max_retries=0 should make exactly 1 attempt, got {attempts}"
2572 );
2573
2574 handle.abort();
2575 }
2576
2577 #[tokio::test]
2579 async fn shell_run_command_timeout_uses_double_operation_timeout() {
2580 let server = MockServer::start().await;
2581 let port = server.address().port();
2582
2583 Mock::given(method("POST"))
2584 .respond_with(ResponseTemplate::new(200).set_body_string(
2585 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>TO-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
2586 ))
2587 .up_to_n_times(1)
2588 .mount(&server)
2589 .await;
2590
2591 Mock::given(method("POST"))
2592 .respond_with(ResponseTemplate::new(200).set_body_string(
2593 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>TO-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2594 ))
2595 .up_to_n_times(1)
2596 .mount(&server)
2597 .await;
2598
2599 Mock::given(method("POST"))
2600 .respond_with(
2601 ResponseTemplate::new(200)
2602 .set_body_string(
2603 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2604 <rsp:CommandState CommandId="TO-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/>
2605 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
2606 )
2607 .set_delay(std::time::Duration::from_secs(10)),
2608 )
2609 .mount(&server)
2610 .await;
2611
2612 let config = WinrmConfig {
2613 port,
2614 auth_method: AuthMethod::Basic,
2615 connect_timeout_secs: 30,
2616 operation_timeout_secs: 1,
2617 ..Default::default()
2618 };
2619 let client = WinrmClient::new(config, test_creds()).unwrap();
2620 let shell = client.open_shell("127.0.0.1").await.unwrap();
2621 let result = shell.run_command("slow", &[]).await;
2622
2623 assert!(result.is_err(), "should have timed out");
2624 let err = result.unwrap_err();
2625 match err {
2626 WinrmError::Timeout(secs) => {
2627 assert_eq!(
2628 secs, 2,
2629 "timeout should be operation_timeout_secs * 2 = 2, got {secs}"
2630 );
2631 }
2632 other => panic!("expected Timeout error, got: {other:?}"),
2633 }
2634 }
2635
2636 #[tokio::test]
2639 async fn upload_first_chunk_uses_write_all_bytes() {
2640 let server = MockServer::start().await;
2641 let port = server.address().port();
2642
2643 let dir = std::env::temp_dir().join("winrm-rs-test-upload-writebytes");
2644 std::fs::create_dir_all(&dir).unwrap();
2645 let local_file = dir.join("small.txt");
2646 std::fs::write(&local_file, b"test-data").unwrap();
2647
2648 Mock::given(method("POST"))
2649 .respond_with(ResponseTemplate::new(200).set_body_string(
2650 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>WB-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
2651 ))
2652 .up_to_n_times(1)
2653 .mount(&server)
2654 .await;
2655
2656 Mock::given(method("POST"))
2657 .respond_with(ResponseTemplate::new(200).set_body_string(
2658 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>WB-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2659 ))
2660 .up_to_n_times(1)
2661 .mount(&server)
2662 .await;
2663
2664 Mock::given(method("POST"))
2665 .respond_with(ResponseTemplate::new(200).set_body_string(
2666 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2667 <rsp:CommandState CommandId="WB-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
2668 <rsp:ExitCode>0</rsp:ExitCode>
2669 </rsp:CommandState>
2670 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
2671 ))
2672 .up_to_n_times(1)
2673 .mount(&server)
2674 .await;
2675
2676 Mock::given(method("POST"))
2677 .respond_with(
2678 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
2679 )
2680 .mount(&server)
2681 .await;
2682
2683 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
2684 let result = client
2685 .upload_file("127.0.0.1", &local_file, "C:\\dest.txt")
2686 .await;
2687 assert!(result.is_ok(), "upload failed: {result:?}");
2688
2689 let requests = server.received_requests().await.unwrap();
2690 let mut found_write_all_bytes = false;
2691 let mut found_append = false;
2692 for req in &requests {
2693 let body = String::from_utf8_lossy(&req.body);
2694 if !body.contains("-EncodedCommand") {
2695 continue;
2696 }
2697 let tag_open = "<rsp:Arguments>";
2698 let tag_close = "</rsp:Arguments>";
2699 let mut pos = 0;
2700 while let Some(start) = body[pos..].find(tag_open) {
2701 let content_start = pos + start + tag_open.len();
2702 if let Some(end) = body[content_start..].find(tag_close) {
2703 let arg_val = &body[content_start..content_start + end];
2704 if let Ok(bytes) = B64.decode(arg_val.trim()) {
2705 let u16s: Vec<u16> = bytes
2706 .chunks_exact(2)
2707 .map(|c| u16::from_le_bytes([c[0], c[1]]))
2708 .collect();
2709 if let Ok(script) = String::from_utf16(&u16s) {
2710 if script.contains("WriteAllBytes") {
2711 found_write_all_bytes = true;
2712 }
2713 if script.contains("Append") {
2714 found_append = true;
2715 }
2716 }
2717 }
2718 pos = content_start + end + tag_close.len();
2719 } else {
2720 break;
2721 }
2722 }
2723 }
2724
2725 assert!(
2726 found_write_all_bytes,
2727 "first chunk must use WriteAllBytes for new file creation"
2728 );
2729 assert!(
2730 !found_append,
2731 "single-chunk upload must NOT use Append (that's for subsequent chunks)"
2732 );
2733
2734 std::fs::remove_dir_all(&dir).ok();
2735 }
2736
2737 #[tokio::test]
2738 async fn retry_backoff_is_exponential_not_additive() {
2739 use std::sync::Arc;
2740 use std::sync::atomic::{AtomicU32, Ordering};
2741
2742 let counter = Arc::new(AtomicU32::new(0));
2743 let counter_clone = counter.clone();
2744
2745 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2746 let port = listener.local_addr().unwrap().port();
2747
2748 let handle = tokio::spawn(async move {
2749 loop {
2750 let Ok((stream, _)) = listener.accept().await else {
2751 break;
2752 };
2753 counter_clone.fetch_add(1, Ordering::SeqCst);
2754 drop(stream);
2755 }
2756 });
2757
2758 let config = WinrmConfig {
2759 port,
2760 auth_method: AuthMethod::Basic,
2761 connect_timeout_secs: 2,
2762 operation_timeout_secs: 5,
2763 max_retries: 2,
2764 ..Default::default()
2765 };
2766
2767 let client = WinrmClient::new(config, test_creds()).unwrap();
2768 let start = std::time::Instant::now();
2769 let result = client.create_shell("127.0.0.1").await;
2770 let elapsed = start.elapsed();
2771
2772 assert!(result.is_err());
2773
2774 assert!(
2775 elapsed >= std::time::Duration::from_millis(280),
2776 "exponential backoff should take >= 280ms total, took {}ms (catches + and / mutants)",
2777 elapsed.as_millis()
2778 );
2779
2780 handle.abort();
2781 }
2782
2783 #[tokio::test]
2785 async fn run_wql_returns_items() {
2786 let server = MockServer::start().await;
2787 let port = server.address().port();
2788
2789 let enumerate_response = r#"<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
2791 xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
2792 <s:Body>
2793 <wsen:EnumerateResponse>
2794 <wsen:Items><p:Win32_OS><p:Name>Windows</p:Name></p:Win32_OS></wsen:Items>
2795 <wsen:EndOfSequence/>
2796 </wsen:EnumerateResponse>
2797 </s:Body>
2798 </s:Envelope>"#;
2799
2800 Mock::given(method("POST"))
2801 .respond_with(ResponseTemplate::new(200).set_body_string(enumerate_response))
2802 .mount(&server)
2803 .await;
2804
2805 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
2806 let result = client
2807 .run_wql("127.0.0.1", "SELECT * FROM Win32_OS", None)
2808 .await
2809 .unwrap();
2810 assert!(
2811 result.contains("Windows"),
2812 "run_wql should return items, got: {result}"
2813 );
2814 }
2815
2816 #[tokio::test]
2817 async fn open_psrp_shell_returns_shell_with_id() {
2818 let server = MockServer::start().await;
2819 let port = server.address().port();
2820
2821 Mock::given(method("POST"))
2822 .respond_with(ResponseTemplate::new(200).set_body_string(
2823 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>PSRP-SH</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
2824 ))
2825 .mount(&server)
2826 .await;
2827
2828 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
2829 let shell = client
2830 .open_psrp_shell(
2831 "127.0.0.1",
2832 "AAAA",
2833 "http://schemas.microsoft.com/powershell/Microsoft.PowerShell",
2834 )
2835 .await
2836 .unwrap();
2837 assert_eq!(shell.shell_id(), "PSRP-SH");
2838 }
2839
2840 #[tokio::test]
2841 async fn run_wql_caps_items_at_max_output_bytes() {
2842 let server = MockServer::start().await;
2843 let port = server.address().port();
2844
2845 let big = "X".repeat(2048);
2848 let chunk = format!(
2849 r#"<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
2850 xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
2851 <s:Body>
2852 <wsen:EnumerateResponse>
2853 <wsen:EnumerationContext>CTX-1</wsen:EnumerationContext>
2854 <wsen:Items><p:Win32_OS><p:Name>{big}</p:Name></p:Win32_OS></wsen:Items>
2855 </wsen:EnumerateResponse>
2856 </s:Body>
2857 </s:Envelope>"#
2858 );
2859
2860 Mock::given(method("POST"))
2861 .respond_with(ResponseTemplate::new(200).set_body_string(chunk))
2862 .mount(&server)
2863 .await;
2864
2865 let mut config = basic_config(port);
2866 config.max_output_bytes = Some(1024);
2867 let client = WinrmClient::new(config, test_creds()).unwrap();
2868 let err = client
2869 .run_wql("127.0.0.1", "SELECT * FROM Win32_OS", None)
2870 .await
2871 .unwrap_err();
2872 assert!(
2873 matches!(err, WinrmError::Transfer(_)),
2874 "expected Transfer cap error, got: {err}"
2875 );
2876 }
2877
2878 #[tokio::test]
2879 async fn run_in_shell_caps_output_at_max_output_bytes() {
2880 let server = MockServer::start().await;
2885 let port = server.address().port();
2886
2887 let big_b64 = base64::engine::general_purpose::STANDARD.encode(vec![b'A'; 2048]);
2888 let receive_response = format!(
2889 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
2890 <rsp:Stream Name="stdout" CommandId="C">{big_b64}</rsp:Stream>
2891 </rsp:ReceiveResponse></s:Body></s:Envelope>"#
2892 );
2893
2894 Mock::given(method("POST"))
2896 .respond_with(ResponseTemplate::new(200).set_body_string(
2897 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
2898 ))
2899 .up_to_n_times(1)
2900 .mount(&server)
2901 .await;
2902
2903 Mock::given(method("POST"))
2905 .respond_with(ResponseTemplate::new(200).set_body_string(
2906 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>C</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
2907 ))
2908 .up_to_n_times(1)
2909 .mount(&server)
2910 .await;
2911
2912 Mock::given(method("POST"))
2914 .respond_with(ResponseTemplate::new(200).set_body_string(receive_response))
2915 .mount(&server)
2916 .await;
2917
2918 let mut config = basic_config(port);
2919 config.max_output_bytes = Some(1024);
2920 let client = WinrmClient::new(config, test_creds()).unwrap();
2921 let err = client
2922 .run_command("127.0.0.1", "ipconfig", &[])
2923 .await
2924 .unwrap_err();
2925 assert!(
2926 matches!(err, WinrmError::Transfer(_)),
2927 "expected Transfer cap error, got: {err}"
2928 );
2929 }
2930
2931 #[tokio::test]
2932 async fn run_command_with_cancel_returns_cancelled_when_token_pre_cancelled() {
2933 let token = tokio_util::sync::CancellationToken::new();
2934 token.cancel();
2935 let client = WinrmClient::new(basic_config(0), test_creds()).unwrap();
2936 let err = client
2937 .run_command_with_cancel("127.0.0.1", "ipconfig", &[], token)
2938 .await
2939 .unwrap_err();
2940 assert!(matches!(err, WinrmError::Cancelled), "got: {err}");
2941 }
2942
2943 #[tokio::test]
2944 async fn run_powershell_with_cancel_returns_cancelled_when_token_pre_cancelled() {
2945 let token = tokio_util::sync::CancellationToken::new();
2946 token.cancel();
2947 let client = WinrmClient::new(basic_config(0), test_creds()).unwrap();
2948 let err = client
2949 .run_powershell_with_cancel("127.0.0.1", "Get-Date", token)
2950 .await
2951 .unwrap_err();
2952 assert!(matches!(err, WinrmError::Cancelled), "got: {err}");
2953 }
2954}