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