1use std::time::Duration;
7
8use tracing::debug;
9
10use crate::client::WinrmClient;
11use crate::command::CommandOutput;
12use crate::error::WinrmError;
13use crate::soap::{self, ReceiveOutput};
14
15pub struct Shell<'a> {
24 client: &'a WinrmClient,
25 host: String,
26 shell_id: String,
27 closed: bool,
28 resource_uri: String,
32}
33
34impl<'a> Shell<'a> {
35 pub(crate) fn new(client: &'a WinrmClient, host: String, shell_id: String) -> Self {
36 Self {
37 client,
38 host,
39 shell_id,
40 closed: false,
41 resource_uri: crate::soap::namespaces::RESOURCE_URI_CMD.to_string(),
42 }
43 }
44
45 pub(crate) fn new_with_resource_uri(
46 client: &'a WinrmClient,
47 host: String,
48 shell_id: String,
49 resource_uri: String,
50 ) -> Self {
51 Self {
52 client,
53 host,
54 shell_id,
55 closed: false,
56 resource_uri,
57 }
58 }
59
60 pub fn resource_uri(&self) -> &str {
62 &self.resource_uri
63 }
64
65 pub async fn run_command(
70 &self,
71 command: &str,
72 args: &[&str],
73 ) -> Result<CommandOutput, WinrmError> {
74 let command_id = self
75 .client
76 .execute_command(&self.host, &self.shell_id, command, args)
77 .await?;
78 debug!(command_id = %command_id, "shell command started");
79
80 let timeout_duration = Duration::from_secs(self.client.config().operation_timeout_secs * 2);
81 let max_output = self.client.config().max_output_bytes;
82
83 let result = tokio::time::timeout(timeout_duration, async {
84 let mut stdout = Vec::new();
85 let mut stderr = Vec::new();
86 let mut exit_code: Option<i32> = None;
87
88 loop {
89 let output: ReceiveOutput = self
90 .client
91 .receive_output(&self.host, &self.shell_id, &command_id)
92 .await?;
93 stdout.extend_from_slice(&output.stdout);
94 stderr.extend_from_slice(&output.stderr);
95
96 if let Some(cap) = max_output
97 && stdout.len() + stderr.len() > cap
98 {
99 return Err(WinrmError::Transfer(format!(
100 "command output exceeded max_output_bytes ({cap})"
101 )));
102 }
103
104 exit_code = output.exit_code.or(exit_code);
105
106 if output.done {
107 break;
108 }
109 }
110
111 self.client
113 .signal_terminate(&self.host, &self.shell_id, &command_id)
114 .await
115 .ok();
116
117 Ok(CommandOutput {
118 stdout,
119 stderr,
120 exit_code: exit_code.unwrap_or(-1),
121 })
122 })
123 .await;
124
125 match result {
126 Ok(inner) => inner,
127 Err(_) => Err(WinrmError::Timeout(
128 self.client.config().operation_timeout_secs * 2,
129 )),
130 }
131 }
132
133 pub async fn run_command_with_cancel(
139 &self,
140 command: &str,
141 args: &[&str],
142 cancel: tokio_util::sync::CancellationToken,
143 ) -> Result<CommandOutput, WinrmError> {
144 if cancel.is_cancelled() {
147 return Err(WinrmError::Cancelled);
148 }
149 tokio::select! {
150 result = self.run_command(command, args) => result,
151 () = cancel.cancelled() => {
152 Err(WinrmError::Cancelled)
153 }
154 }
155 }
156
157 pub async fn run_powershell(&self, script: &str) -> Result<CommandOutput, WinrmError> {
162 let encoded = crate::command::encode_powershell_command(script);
163 self.run_command("powershell.exe", &["-EncodedCommand", &encoded])
164 .await
165 }
166
167 pub async fn run_powershell_with_cancel(
172 &self,
173 script: &str,
174 cancel: tokio_util::sync::CancellationToken,
175 ) -> Result<CommandOutput, WinrmError> {
176 let encoded = crate::command::encode_powershell_command(script);
177 self.run_command_with_cancel("powershell.exe", &["-EncodedCommand", &encoded], cancel)
178 .await
179 }
180
181 pub async fn send_input(
186 &self,
187 command_id: &str,
188 data: &[u8],
189 end_of_stream: bool,
190 ) -> Result<(), WinrmError> {
191 let endpoint = self.client.endpoint(&self.host);
196 let config = self.client.config();
197 let envelope = if command_id.is_empty() || command_id == self.shell_id {
198 soap::send_psrp_request(
200 &endpoint,
201 &self.shell_id,
202 data,
203 config.operation_timeout_secs,
204 config.max_envelope_size,
205 &self.resource_uri,
206 )
207 } else {
208 soap::send_input_request_with_uri(
209 &endpoint,
210 &self.shell_id,
211 command_id,
212 data,
213 end_of_stream,
214 config.operation_timeout_secs,
215 config.max_envelope_size,
216 &self.resource_uri,
217 )
218 };
219 self.client.send_soap_raw(&self.host, envelope).await?;
220 Ok(())
221 }
222
223 pub async fn signal_ctrl_c(&self, command_id: &str) -> Result<(), WinrmError> {
227 let endpoint = self.client.endpoint(&self.host);
228 let config = self.client.config();
229 let envelope = soap::signal_ctrl_c_request(
230 &endpoint,
231 &self.shell_id,
232 command_id,
233 config.operation_timeout_secs,
234 config.max_envelope_size,
235 );
236 self.client.send_soap_raw(&self.host, envelope).await?;
237 Ok(())
238 }
239
240 pub async fn start_command_with_id(
264 &self,
265 command: &str,
266 args: &[&str],
267 command_id: &str,
268 ) -> Result<String, WinrmError> {
269 let endpoint = self.client.endpoint(&self.host);
270 let config = self.client.config();
271 let envelope = soap::execute_command_with_id_request(
272 &endpoint,
273 &self.shell_id,
274 command,
275 args,
276 command_id,
277 config.operation_timeout_secs,
278 config.max_envelope_size,
279 &self.resource_uri,
280 );
281 let response = self.client.send_soap_raw(&self.host, envelope).await?;
282 let returned_id = soap::parse_command_id(&response).map_err(WinrmError::Soap)?;
283 debug!(command_id = %returned_id, "shell command started with specified ID");
284 Ok(returned_id)
285 }
286
287 pub async fn start_command(&self, command: &str, args: &[&str]) -> Result<String, WinrmError> {
288 let endpoint = self.client.endpoint(&self.host);
289 let config = self.client.config();
290 let envelope = soap::execute_command_request_with_uri(
291 &endpoint,
292 &self.shell_id,
293 command,
294 args,
295 config.operation_timeout_secs,
296 config.max_envelope_size,
297 &self.resource_uri,
298 );
299 let response = self.client.send_soap_raw(&self.host, envelope).await?;
300 let command_id = soap::parse_command_id(&response).map_err(WinrmError::Soap)?;
301 debug!(command_id = %command_id, "shell command started (streaming)");
302 Ok(command_id)
303 }
304
305 pub async fn receive_next(&self, command_id: &str) -> Result<ReceiveOutput, WinrmError> {
311 let endpoint = self.client.endpoint(&self.host);
312 let config = self.client.config();
313 let is_psrp = self.resource_uri.contains("powershell");
314 let envelope = if is_psrp {
315 let cid = if command_id.is_empty() || command_id == self.shell_id {
317 None
318 } else {
319 Some(command_id)
320 };
321 soap::receive_psrp_request(
322 &endpoint,
323 &self.shell_id,
324 cid,
325 config.operation_timeout_secs,
326 config.max_envelope_size,
327 &self.resource_uri,
328 )
329 } else {
330 soap::receive_output_request_with_uri(
331 &endpoint,
332 &self.shell_id,
333 command_id,
334 config.operation_timeout_secs,
335 config.max_envelope_size,
336 &self.resource_uri,
337 )
338 };
339 let response = self.client.send_soap_raw(&self.host, envelope).await?;
340 soap::parse_receive_output(&response).map_err(WinrmError::Soap)
341 }
342
343 pub fn shell_id(&self) -> &str {
345 &self.shell_id
346 }
347
348 pub async fn close(mut self) -> Result<(), WinrmError> {
350 self.closed = true;
351 self.client
352 .delete_shell_raw(&self.host, &self.shell_id)
353 .await
354 }
355
356 pub async fn disconnect(mut self) -> Result<String, WinrmError> {
364 self.closed = true;
365 let endpoint = self.client.endpoint(&self.host);
366 let config = self.client.config();
367 let envelope = soap::disconnect_shell_request_with_uri(
368 &endpoint,
369 &self.shell_id,
370 config.operation_timeout_secs,
371 config.max_envelope_size,
372 &self.resource_uri,
373 );
374 self.client.send_soap_raw(&self.host, envelope).await?;
375 Ok(std::mem::take(&mut self.shell_id))
376 }
377}
378
379impl Drop for Shell<'_> {
380 fn drop(&mut self) {
381 if !self.closed {
382 tracing::warn!(
383 shell_id = %self.shell_id,
384 "shell dropped without close -- resources may leak on server"
385 );
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use crate::client::WinrmClient;
393 use crate::config::{AuthMethod, WinrmConfig, WinrmCredentials};
394 use crate::error::WinrmError;
395 use wiremock::matchers::method;
396 use wiremock::{Mock, MockServer, ResponseTemplate};
397
398 fn test_creds() -> WinrmCredentials {
399 WinrmCredentials::new("admin", "pass", "")
400 }
401
402 fn basic_config(port: u16) -> WinrmConfig {
403 WinrmConfig {
404 port,
405 auth_method: AuthMethod::Basic,
406 connect_timeout_secs: 5,
407 operation_timeout_secs: 10,
408 ..Default::default()
409 }
410 }
411
412 #[tokio::test]
413 async fn run_command_polls_until_done() {
414 let server = MockServer::start().await;
415 let port = server.address().port();
416
417 Mock::given(method("POST"))
419 .respond_with(ResponseTemplate::new(200).set_body_string(
420 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-RUN</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
421 ))
422 .up_to_n_times(1)
423 .mount(&server)
424 .await;
425
426 Mock::given(method("POST"))
428 .respond_with(ResponseTemplate::new(200).set_body_string(
429 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>SH-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
430 ))
431 .up_to_n_times(1)
432 .mount(&server)
433 .await;
434
435 Mock::given(method("POST"))
437 .respond_with(ResponseTemplate::new(200).set_body_string(
438 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
439 <rsp:Stream Name="stdout" CommandId="SH-CMD">YWJD</rsp:Stream>
440 <rsp:CommandState CommandId="SH-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/>
441 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
442 ))
443 .up_to_n_times(1)
444 .mount(&server)
445 .await;
446
447 Mock::given(method("POST"))
449 .respond_with(ResponseTemplate::new(200).set_body_string(
450 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
451 <rsp:Stream Name="stdout" CommandId="SH-CMD">REVG</rsp:Stream>
452 <rsp:CommandState CommandId="SH-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
453 <rsp:ExitCode>7</rsp:ExitCode>
454 </rsp:CommandState>
455 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
456 ))
457 .up_to_n_times(1)
458 .mount(&server)
459 .await;
460
461 Mock::given(method("POST"))
463 .respond_with(
464 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
465 )
466 .mount(&server)
467 .await;
468
469 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
470 let shell = client.open_shell("127.0.0.1").await.unwrap();
471 let output = shell.run_command("cmd", &["/c", "dir"]).await.unwrap();
472 assert_eq!(output.exit_code, 7);
473 assert_eq!(output.stdout, b"abCDEF");
474 }
475
476 #[tokio::test]
477 async fn send_input_exercises_shell_method() {
478 let server = MockServer::start().await;
479 let port = server.address().port();
480
481 Mock::given(method("POST"))
483 .respond_with(ResponseTemplate::new(200).set_body_string(
484 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-INP</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
485 ))
486 .up_to_n_times(1)
487 .mount(&server)
488 .await;
489
490 Mock::given(method("POST"))
492 .respond_with(
493 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
494 )
495 .mount(&server)
496 .await;
497
498 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
499 let shell = client.open_shell("127.0.0.1").await.unwrap();
500 shell.send_input("CMD-X", b"data", false).await.unwrap();
501 shell.send_input("CMD-X", b"", true).await.unwrap();
502 }
503
504 #[tokio::test]
505 async fn disconnect_returns_shell_id_and_suppresses_drop_warning() {
506 let server = MockServer::start().await;
507 let port = server.address().port();
508
509 Mock::given(method("POST"))
511 .respond_with(ResponseTemplate::new(200).set_body_string(
512 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-DISC</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
513 ))
514 .up_to_n_times(1)
515 .mount(&server)
516 .await;
517
518 Mock::given(method("POST"))
520 .respond_with(
521 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
522 )
523 .mount(&server)
524 .await;
525
526 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
527 let shell = client.open_shell("127.0.0.1").await.unwrap();
528 let id = shell.disconnect().await.unwrap();
529 assert_eq!(id, "SH-DISC");
530 }
531
532 #[tokio::test]
533 async fn reconnect_shell_returns_handle_with_existing_id() {
534 let server = MockServer::start().await;
535 let port = server.address().port();
536
537 Mock::given(method("POST"))
539 .respond_with(
540 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
541 )
542 .mount(&server)
543 .await;
544
545 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
546 let shell = client
547 .reconnect_shell(
548 "127.0.0.1",
549 "SH-EXISTING",
550 crate::soap::namespaces::RESOURCE_URI_CMD,
551 )
552 .await
553 .unwrap();
554 assert_eq!(shell.shell_id(), "SH-EXISTING");
555 shell.close().await.unwrap();
557 }
558
559 #[tokio::test]
560 async fn signal_ctrl_c_exercises_shell_method() {
561 let server = MockServer::start().await;
562 let port = server.address().port();
563
564 Mock::given(method("POST"))
566 .respond_with(ResponseTemplate::new(200).set_body_string(
567 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-SIG</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
568 ))
569 .up_to_n_times(1)
570 .mount(&server)
571 .await;
572
573 Mock::given(method("POST"))
575 .respond_with(
576 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
577 )
578 .mount(&server)
579 .await;
580
581 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
582 let shell = client.open_shell("127.0.0.1").await.unwrap();
583 shell.signal_ctrl_c("CMD-Y").await.unwrap();
584 }
585
586 #[tokio::test]
587 async fn start_command_returns_id() {
588 let server = MockServer::start().await;
589 let port = server.address().port();
590
591 Mock::given(method("POST"))
593 .respond_with(ResponseTemplate::new(200).set_body_string(
594 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-START</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
595 ))
596 .up_to_n_times(1)
597 .mount(&server)
598 .await;
599
600 Mock::given(method("POST"))
602 .respond_with(ResponseTemplate::new(200).set_body_string(
603 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>SH-START-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
604 ))
605 .up_to_n_times(1)
606 .mount(&server)
607 .await;
608
609 Mock::given(method("POST"))
611 .respond_with(
612 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
613 )
614 .mount(&server)
615 .await;
616
617 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
618 let shell = client.open_shell("127.0.0.1").await.unwrap();
619 let cmd_id = shell.start_command("ping", &["localhost"]).await.unwrap();
620 assert_eq!(cmd_id, "SH-START-CMD");
621 }
622
623 #[tokio::test]
628 async fn shell_run_command_missing_exit_code_returns_minus_one() {
629 let server = MockServer::start().await;
630 let port = server.address().port();
631
632 Mock::given(method("POST"))
634 .respond_with(ResponseTemplate::new(200).set_body_string(
635 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-NEG1</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
636 ))
637 .up_to_n_times(1)
638 .mount(&server)
639 .await;
640
641 Mock::given(method("POST"))
643 .respond_with(ResponseTemplate::new(200).set_body_string(
644 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>SH-NEG1-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
645 ))
646 .up_to_n_times(1)
647 .mount(&server)
648 .await;
649
650 Mock::given(method("POST"))
652 .respond_with(ResponseTemplate::new(200).set_body_string(
653 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
654 <rsp:CommandState CommandId="SH-NEG1-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done"/>
655 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
656 ))
657 .up_to_n_times(1)
658 .mount(&server)
659 .await;
660
661 Mock::given(method("POST"))
663 .respond_with(
664 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
665 )
666 .mount(&server)
667 .await;
668
669 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
670 let shell = client.open_shell("127.0.0.1").await.unwrap();
671 let output = shell.run_command("test", &[]).await.unwrap();
672 assert_eq!(output.exit_code, -1);
674 }
675
676 #[tokio::test]
681 async fn shell_timeout_duration_kills_plus_mutant() {
682 let server = MockServer::start().await;
683 let port = server.address().port();
684
685 Mock::given(method("POST"))
687 .respond_with(ResponseTemplate::new(200).set_body_string(
688 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>TO2-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
689 ))
690 .up_to_n_times(1)
691 .mount(&server)
692 .await;
693
694 Mock::given(method("POST"))
696 .respond_with(ResponseTemplate::new(200).set_body_string(
697 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>TO2-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
698 ))
699 .up_to_n_times(1)
700 .mount(&server)
701 .await;
702
703 Mock::given(method("POST"))
705 .respond_with(
706 ResponseTemplate::new(200)
707 .set_body_string(
708 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
709 <rsp:CommandState CommandId="TO2-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
710 <rsp:ExitCode>0</rsp:ExitCode>
711 </rsp:CommandState>
712 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
713 )
714 .set_delay(std::time::Duration::from_millis(5500)),
715 )
716 .up_to_n_times(1)
717 .mount(&server)
718 .await;
719
720 Mock::given(method("POST"))
722 .respond_with(
723 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
724 )
725 .mount(&server)
726 .await;
727
728 let config = WinrmConfig {
729 port,
730 auth_method: AuthMethod::Basic,
731 connect_timeout_secs: 30,
732 operation_timeout_secs: 3, ..Default::default()
734 };
735 let client = WinrmClient::new(config, test_creds()).unwrap();
736 let shell = client.open_shell("127.0.0.1").await.unwrap();
737 let result = shell.run_command("slow", &[]).await;
738
739 assert!(
742 result.is_ok(),
743 "should complete within 6s timeout (* 2), got: {:?}",
744 result.err()
745 );
746 assert_eq!(result.unwrap().exit_code, 0);
747 }
748
749 #[tokio::test]
757 async fn shell_timeout_duration_kills_div_mutant() {
758 let server = MockServer::start().await;
759 let port = server.address().port();
760
761 Mock::given(method("POST"))
763 .respond_with(ResponseTemplate::new(200).set_body_string(
764 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>TO3-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
765 ))
766 .up_to_n_times(1)
767 .mount(&server)
768 .await;
769
770 Mock::given(method("POST"))
772 .respond_with(ResponseTemplate::new(200).set_body_string(
773 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>TO3-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
774 ))
775 .up_to_n_times(1)
776 .mount(&server)
777 .await;
778
779 Mock::given(method("POST"))
781 .respond_with(ResponseTemplate::new(200).set_body_string(
782 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
783 <rsp:CommandState CommandId="TO3-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
784 <rsp:ExitCode>0</rsp:ExitCode>
785 </rsp:CommandState>
786 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
787 ))
788 .up_to_n_times(1)
789 .mount(&server)
790 .await;
791
792 Mock::given(method("POST"))
794 .respond_with(
795 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
796 )
797 .mount(&server)
798 .await;
799
800 let config = WinrmConfig {
801 port,
802 auth_method: AuthMethod::Basic,
803 connect_timeout_secs: 30,
804 operation_timeout_secs: 1, ..Default::default()
806 };
807 let client = WinrmClient::new(config, test_creds()).unwrap();
808 let shell = client.open_shell("127.0.0.1").await.unwrap();
809 let result = shell.run_command("fast", &[]).await;
810
811 assert!(
814 result.is_ok(),
815 "instant response should succeed with 2s timeout, got: {:?}",
816 result.err()
817 );
818 }
819
820 #[tracing_test::traced_test]
824 #[tokio::test]
825 async fn shell_drop_without_close_emits_warning() {
826 let server = MockServer::start().await;
827 let port = server.address().port();
828
829 Mock::given(method("POST"))
831 .respond_with(ResponseTemplate::new(200).set_body_string(
832 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DROP-WARN</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
833 ))
834 .up_to_n_times(1)
835 .mount(&server)
836 .await;
837
838 Mock::given(method("POST"))
840 .respond_with(
841 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
842 )
843 .mount(&server)
844 .await;
845
846 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
847
848 {
849 let shell = client.open_shell("127.0.0.1").await.unwrap();
850 assert_eq!(shell.shell_id(), "DROP-WARN");
851 }
853
854 assert!(logs_contain("shell dropped without close"));
858 }
859
860 #[tracing_test::traced_test]
864 #[tokio::test]
865 async fn shell_close_does_not_emit_drop_warning() {
866 let server = MockServer::start().await;
867 let port = server.address().port();
868
869 Mock::given(method("POST"))
871 .respond_with(ResponseTemplate::new(200).set_body_string(
872 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DROP-OK</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
873 ))
874 .up_to_n_times(1)
875 .mount(&server)
876 .await;
877
878 Mock::given(method("POST"))
880 .respond_with(
881 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
882 )
883 .mount(&server)
884 .await;
885
886 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
887 let shell = client.open_shell("127.0.0.1").await.unwrap();
888 shell.close().await.unwrap();
889
890 assert!(!logs_contain("shell dropped without close"));
893 }
894
895 #[tokio::test]
897 async fn resource_uri_matches_default_cmd() {
898 let server = MockServer::start().await;
899 let port = server.address().port();
900
901 Mock::given(method("POST"))
902 .respond_with(ResponseTemplate::new(200).set_body_string(
903 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-URI</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
904 ))
905 .up_to_n_times(1)
906 .mount(&server)
907 .await;
908
909 Mock::given(method("POST"))
911 .respond_with(
912 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
913 )
914 .mount(&server)
915 .await;
916
917 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
918 let shell = client.open_shell("127.0.0.1").await.unwrap();
919 assert!(
920 shell.resource_uri().contains("cmd"),
921 "resource_uri should contain 'cmd', got: {}",
922 shell.resource_uri()
923 );
924 }
925
926 #[tokio::test]
928 async fn start_command_with_id_returns_server_command_id() {
929 let server = MockServer::start().await;
930 let port = server.address().port();
931
932 Mock::given(method("POST"))
934 .respond_with(ResponseTemplate::new(200).set_body_string(
935 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-WCID</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
936 ))
937 .up_to_n_times(1)
938 .mount(&server)
939 .await;
940
941 Mock::given(method("POST"))
943 .respond_with(ResponseTemplate::new(200).set_body_string(
944 r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MY-CMD-ID</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
945 ))
946 .up_to_n_times(1)
947 .mount(&server)
948 .await;
949
950 Mock::given(method("POST"))
952 .respond_with(
953 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
954 )
955 .mount(&server)
956 .await;
957
958 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
959 let shell = client.open_shell("127.0.0.1").await.unwrap();
960 let cmd_id = shell
961 .start_command_with_id("test", &[], "MY-CMD-ID")
962 .await
963 .unwrap();
964 assert_eq!(cmd_id, "MY-CMD-ID");
965 }
966
967 #[tokio::test]
970 async fn send_input_psrp_path_when_command_id_is_shell_id() {
971 let server = MockServer::start().await;
972 let port = server.address().port();
973
974 Mock::given(method("POST"))
976 .respond_with(
977 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
978 )
979 .mount(&server)
980 .await;
981
982 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
983 let shell = client
984 .reconnect_shell(
985 "127.0.0.1",
986 "PSRP-SHELL",
987 crate::soap::namespaces::RESOURCE_URI_PSRP,
988 )
989 .await
990 .unwrap();
991 shell
993 .send_input("PSRP-SHELL", b"data", false)
994 .await
995 .unwrap();
996 shell.send_input("", b"data", false).await.unwrap();
998 }
999
1000 #[tokio::test]
1002 async fn receive_next_with_real_command_id() {
1003 let server = MockServer::start().await;
1004 let port = server.address().port();
1005
1006 Mock::given(method("POST"))
1008 .respond_with(ResponseTemplate::new(200).set_body_string(
1009 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-RECV</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1010 ))
1011 .up_to_n_times(1)
1012 .mount(&server)
1013 .await;
1014
1015 Mock::given(method("POST"))
1017 .respond_with(ResponseTemplate::new(200).set_body_string(
1018 r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1019 <rsp:Stream Name="stdout" CommandId="RECV-CMD">YWJD</rsp:Stream>
1020 <rsp:CommandState CommandId="RECV-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
1021 <rsp:ExitCode>0</rsp:ExitCode>
1022 </rsp:CommandState>
1023 </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1024 ))
1025 .up_to_n_times(1)
1026 .mount(&server)
1027 .await;
1028
1029 Mock::given(method("POST"))
1031 .respond_with(
1032 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
1033 )
1034 .mount(&server)
1035 .await;
1036
1037 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1038 let shell = client.open_shell("127.0.0.1").await.unwrap();
1039 let output = shell.receive_next("RECV-CMD").await.unwrap();
1040 assert!(output.done);
1041 assert!(!output.stdout.is_empty());
1042 }
1043
1044 #[tokio::test]
1045 async fn shell_send_input_posts_to_endpoint() {
1046 let server = MockServer::start().await;
1047 let port = server.address().port();
1048
1049 Mock::given(method("POST"))
1051 .respond_with(ResponseTemplate::new(200).set_body_string(
1052 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1053 ))
1054 .up_to_n_times(1)
1055 .mount(&server)
1056 .await;
1057
1058 Mock::given(method("POST"))
1060 .respond_with(
1061 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
1062 )
1063 .mount(&server)
1064 .await;
1065
1066 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1067 let shell = client.open_shell("127.0.0.1").await.unwrap();
1068 shell.send_input("CMD-1", b"hello", true).await.unwrap();
1069 }
1070
1071 #[tokio::test]
1072 async fn shell_signal_ctrl_c_posts_to_endpoint() {
1073 let server = MockServer::start().await;
1074 let port = server.address().port();
1075
1076 Mock::given(method("POST"))
1078 .respond_with(ResponseTemplate::new(200).set_body_string(
1079 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1080 ))
1081 .up_to_n_times(1)
1082 .mount(&server)
1083 .await;
1084
1085 Mock::given(method("POST"))
1087 .respond_with(
1088 ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
1089 )
1090 .mount(&server)
1091 .await;
1092
1093 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1094 let shell = client.open_shell("127.0.0.1").await.unwrap();
1095 shell.signal_ctrl_c("CMD-1").await.unwrap();
1096 }
1097
1098 #[tokio::test]
1099 async fn shell_run_command_with_cancel_returns_cancelled_when_pre_cancelled() {
1100 let server = MockServer::start().await;
1101 let port = server.address().port();
1102 Mock::given(method("POST"))
1103 .respond_with(ResponseTemplate::new(200).set_body_string(
1104 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1105 ))
1106 .mount(&server)
1107 .await;
1108
1109 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1110 let shell = client.open_shell("127.0.0.1").await.unwrap();
1111
1112 let token = tokio_util::sync::CancellationToken::new();
1113 token.cancel();
1114 let err = shell
1115 .run_command_with_cancel("ipconfig", &[], token)
1116 .await
1117 .unwrap_err();
1118 assert!(matches!(err, WinrmError::Cancelled), "got: {err}");
1119 }
1120
1121 #[tokio::test]
1122 async fn shell_run_powershell_with_cancel_returns_cancelled_when_pre_cancelled() {
1123 let server = MockServer::start().await;
1124 let port = server.address().port();
1125 Mock::given(method("POST"))
1126 .respond_with(ResponseTemplate::new(200).set_body_string(
1127 r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
1128 ))
1129 .mount(&server)
1130 .await;
1131
1132 let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1133 let shell = client.open_shell("127.0.0.1").await.unwrap();
1134
1135 let token = tokio_util::sync::CancellationToken::new();
1136 token.cancel();
1137 let err = shell
1138 .run_powershell_with_cancel("Get-Date", token)
1139 .await
1140 .unwrap_err();
1141 assert!(matches!(err, WinrmError::Cancelled), "got: {err}");
1142 }
1143}