Skip to main content

winrm_rs/
shell.rs

1// Reusable WinRM shell session.
2//
3// Wraps a shell ID and provides methods to run commands, send input,
4// and signal Ctrl+C within a persistent shell.
5
6use 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
15/// A reusable WinRM shell session.
16///
17/// Created via [`WinrmClient::open_shell`]. The shell persists across
18/// multiple command executions, avoiding the overhead of creating and
19/// deleting a shell per command.
20///
21/// The shell is automatically closed when dropped (best-effort).
22/// For reliable cleanup, call [`close`](Self::close) explicitly.
23pub struct Shell<'a> {
24    client: &'a WinrmClient,
25    host: String,
26    shell_id: String,
27    closed: bool,
28    /// Resource URI used for all subsequent SOAP operations on this shell.
29    /// Defaults to `RESOURCE_URI_CMD` for standard command shells;
30    /// PSRP shells use `RESOURCE_URI_PSRP`.
31    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    /// The resource URI this shell was created with.
61    pub fn resource_uri(&self) -> &str {
62        &self.resource_uri
63    }
64
65    /// Execute a command in this shell.
66    ///
67    /// Runs the command, polls for output until completion or timeout, and
68    /// returns the collected stdout, stderr, and exit code.
69    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            // Best-effort signal terminate
112            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    /// Execute a command with cancellation support.
134    ///
135    /// Like [`run_command`](Self::run_command), but can be cancelled via a
136    /// [`CancellationToken`](crate::CancellationToken). When cancelled, a Ctrl+C signal is sent to the
137    /// running command and [`WinrmError::Cancelled`] is returned.
138    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        // Honour a pre-cancelled token deterministically; see the
145        // equivalent fix on `WinrmClient::run_command_with_cancel`.
146        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    /// Execute a PowerShell script in this shell.
158    ///
159    /// The script is encoded as UTF-16LE base64 and executed via
160    /// `powershell.exe -EncodedCommand`.
161    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    /// Execute a PowerShell script with cancellation support.
168    ///
169    /// Like [`run_powershell`](Self::run_powershell), but can be cancelled via a
170    /// [`CancellationToken`](crate::CancellationToken).
171    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    /// Send input data (stdin) to a running command.
182    ///
183    /// The `command_id` identifies which command receives the input.
184    /// Set `end_of_stream` to `true` to signal EOF on stdin.
185    pub async fn send_input(
186        &self,
187        command_id: &str,
188        data: &[u8],
189        end_of_stream: bool,
190    ) -> Result<(), WinrmError> {
191        // send_input doesn't have a _with_uri variant yet — the resource URI
192        // in the header doesn't affect the Send action on most servers, but
193        // let's be correct and use the shell's URI anyway by calling the
194        // low-level builder directly.
195        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            // PSRP without a command: no CommandId on the stream.
199            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    /// Send Ctrl+C signal to a running command.
224    ///
225    /// Requests graceful interruption of the command identified by `command_id`.
226    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    /// Start a command and return the command ID for manual polling.
241    ///
242    /// Use with [`receive_next`](Self::receive_next) and
243    /// [`signal_ctrl_c`](Self::signal_ctrl_c) for fine-grained control over
244    /// long-running commands.
245    ///
246    /// # Example
247    ///
248    /// ```no_run
249    /// # async fn example(shell: &winrm_rs::Shell<'_>) -> Result<(), winrm_rs::WinrmError> {
250    /// let cmd_id = shell.start_command("ping", &["-t", "10.0.0.1"]).await?;
251    /// loop {
252    ///     let chunk = shell.receive_next(&cmd_id).await?;
253    ///     print!("{}", String::from_utf8_lossy(&chunk.stdout));
254    ///     if chunk.done { break; }
255    /// }
256    /// # Ok(())
257    /// # }
258    /// ```
259    /// Like [`start_command`](Self::start_command) but lets the caller
260    /// specify the `CommandId` that the server should assign to the
261    /// command. Used by PSRP where the pipeline UUID must match the
262    /// CommandId.
263    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    /// Poll for the next output chunk from a running command.
306    ///
307    /// Returns a single [`ReceiveOutput`] representing one poll cycle.
308    /// Callers should accumulate stdout/stderr and stop when
309    /// [`done`](ReceiveOutput::done) is `true`.
310    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            // PSRP: stdout only, optional CommandId.
316            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    /// Get the shell ID.
344    pub fn shell_id(&self) -> &str {
345        &self.shell_id
346    }
347
348    /// Explicitly close the shell, releasing server-side resources.
349    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    /// Disconnect from the shell while leaving it alive on the server.
357    ///
358    /// Returns the `shell_id` so the caller can later reconnect via
359    /// [`WinrmClient::reconnect_shell`]. The local `Shell` value is
360    /// consumed; calling [`close`](Self::close) on a disconnected shell
361    /// is unnecessary because the server-side resources are still
362    /// owned by the runspace.
363    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        // Shell create
418        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        // Execute command
427        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        // Receive: not done
436        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        // Receive: done with exit code
448        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        // Cleanup
462        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        // Shell create
482        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        // Send input response
491        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        // Shell create
510        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        // Disconnect response
519        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        // Reconnect response
538        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        // Close cleanly so the drop warning doesn't fire.
556        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        // Shell create
565        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        // Signal response
574        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        // Shell create
592        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        // Execute command
601        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        // Cleanup
610        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    // --- Mutant-killing tests ---
624
625    // Kills shell.rs:88 — unwrap_or(-1) with "delete -" making it unwrap_or(1).
626    // Goes through Shell::run_command (not client::run_command which has its own copy).
627    #[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        // Shell create
633        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        // Execute command
642        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        // Receive: done but NO ExitCode element
651        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        // Cleanup (signal + delete)
662        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        // Must be -1 (not 1). Kills the "delete -" mutant on unwrap_or(-1).
673        assert_eq!(output.exit_code, -1);
674    }
675
676    // Kills shell.rs:55 — timeout Duration * 2 replaced with + 2.
677    // With operation_timeout_secs=3: * 2 = 6s, + 2 = 5s.
678    // Mock delays 5.5s. With * 2 (6s): 5.5 < 6 → command completes (success).
679    // With + 2 (5s): 5.5 > 5 → timeout. We assert success to kill the + mutant.
680    #[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        // Shell create
686        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        // Command execute
695        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        // Receive: delayed 5500ms, returns Done (completes within 6s but not 5s)
704        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        // Cleanup (signal + delete)
721        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, // * 2 = 6s timeout, + 2 = 5s
733            ..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        // With * 2 = 6s: 5.5s < 6s → success (correct)
740        // With + 2 = 5s: 5.5s > 5s → timeout (mutant killed by this assertion)
741        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    // Kills shell.rs:55 — timeout Duration * 2 replaced with / 2.
750    // With operation_timeout_secs=4: * 2 = 8s, / 2 = 2s.
751    // Mock returns instantly. With * 2 (8s): completes fine. With / 2 (2s): should still
752    // complete since response is instant. BUT with operation_timeout_secs=1: / 2 = 0s.
753    // A 0-second tokio timeout fires immediately before any I/O completes.
754    // So use operation_timeout_secs=1 and instant response: * 2 = 2s → success,
755    // / 2 = 0s → immediate timeout.
756    #[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        // Shell create
762        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        // Command execute
771        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        // Receive: immediate response with Done + exit code
780        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        // Cleanup
793        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, // * 2 = 2s (ample), / 2 = 0s (instant timeout)
805            ..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        // With * 2 = 2s: instant response → success (correct)
812        // With / 2 = 0s: 0-second timeout fires before I/O → Timeout (mutant killed)
813        assert!(
814            result.is_ok(),
815            "instant response should succeed with 2s timeout, got: {:?}",
816            result.err()
817        );
818    }
819
820    // Kills shell.rs:208 — Drop body → () and delete ! in condition.
821    // Uses tracing-test to capture log output and verify the warning is emitted
822    // when a shell is dropped without close.
823    #[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        // Shell create
830        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        // Cleanup (for any requests)
839        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            // Drop shell without calling close()
852        }
853
854        // Kills "replace drop body with ()" — the warning would not be emitted.
855        // Kills "delete !" — the condition becomes `if self.closed` which is false,
856        // so the warning would NOT be emitted for unclosed shells (and WOULD for closed ones).
857        assert!(logs_contain("shell dropped without close"));
858    }
859
860    // Verify that a properly closed shell does NOT emit the drop warning.
861    // This kills the "delete !" mutant: with `if self.closed` (instead of `if !self.closed`),
862    // a closed shell would emit the warning (wrong), and an unclosed one would not.
863    #[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        // Shell create
870        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        // Delete shell
879        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        // After close, the drop should NOT warn.
891        // Kills "delete !" mutant: `if self.closed` would warn for closed shell.
892        assert!(!logs_contain("shell dropped without close"));
893    }
894
895    // Kills shell.rs:62 — resource_uri() returning "" or "xyzzy"
896    #[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        // Cleanup
910        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    // Kills shell.rs:255 — start_command_with_id returning Ok("") or Ok("xyzzy")
927    #[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        // Shell create
933        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        // Execute command (returns a specific command ID)
942        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        // Cleanup
951        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    // Kills shell.rs:183 — send_input conditional (|| → && and == → !=)
968    // Tests the PSRP branch: when command_id equals shell_id, it should use send_psrp_request
969    #[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        // Reconnect a PSRP shell (uses powershell resource URI)
975        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        // Send input with command_id == shell_id → takes PSRP path
992        shell
993            .send_input("PSRP-SHELL", b"data", false)
994            .await
995            .unwrap();
996        // Send input with empty command_id → also takes PSRP path
997        shell.send_input("", b"data", false).await.unwrap();
998    }
999
1000    // Kills shell.rs:302 — receive_next conditional (|| → && and == → !=)
1001    #[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        // Shell create
1007        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        // Receive output
1016        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        // Cleanup
1030        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        // Create
1050        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        // Send
1059        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        // Create
1077        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        // Signal
1086        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}