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
82        let result = tokio::time::timeout(timeout_duration, async {
83            let mut stdout = Vec::new();
84            let mut stderr = Vec::new();
85            let mut exit_code: Option<i32> = None;
86
87            loop {
88                let output: ReceiveOutput = self
89                    .client
90                    .receive_output(&self.host, &self.shell_id, &command_id)
91                    .await?;
92                stdout.extend_from_slice(&output.stdout);
93                stderr.extend_from_slice(&output.stderr);
94
95                exit_code = output.exit_code.or(exit_code);
96
97                if output.done {
98                    break;
99                }
100            }
101
102            // Best-effort signal terminate
103            self.client
104                .signal_terminate(&self.host, &self.shell_id, &command_id)
105                .await
106                .ok();
107
108            Ok(CommandOutput {
109                stdout,
110                stderr,
111                exit_code: exit_code.unwrap_or(-1),
112            })
113        })
114        .await;
115
116        match result {
117            Ok(inner) => inner,
118            Err(_) => Err(WinrmError::Timeout(
119                self.client.config().operation_timeout_secs * 2,
120            )),
121        }
122    }
123
124    /// Execute a command with cancellation support.
125    ///
126    /// Like [`run_command`](Self::run_command), but can be cancelled via a
127    /// [`CancellationToken`](crate::CancellationToken). When cancelled, a Ctrl+C signal is sent to the
128    /// running command and [`WinrmError::Cancelled`] is returned.
129    pub async fn run_command_with_cancel(
130        &self,
131        command: &str,
132        args: &[&str],
133        cancel: tokio_util::sync::CancellationToken,
134    ) -> Result<CommandOutput, WinrmError> {
135        tokio::select! {
136            result = self.run_command(command, args) => result,
137            () = cancel.cancelled() => {
138                Err(WinrmError::Cancelled)
139            }
140        }
141    }
142
143    /// Execute a PowerShell script in this shell.
144    ///
145    /// The script is encoded as UTF-16LE base64 and executed via
146    /// `powershell.exe -EncodedCommand`.
147    pub async fn run_powershell(&self, script: &str) -> Result<CommandOutput, WinrmError> {
148        let encoded = crate::command::encode_powershell_command(script);
149        self.run_command("powershell.exe", &["-EncodedCommand", &encoded])
150            .await
151    }
152
153    /// Execute a PowerShell script with cancellation support.
154    ///
155    /// Like [`run_powershell`](Self::run_powershell), but can be cancelled via a
156    /// [`CancellationToken`](crate::CancellationToken).
157    pub async fn run_powershell_with_cancel(
158        &self,
159        script: &str,
160        cancel: tokio_util::sync::CancellationToken,
161    ) -> Result<CommandOutput, WinrmError> {
162        let encoded = crate::command::encode_powershell_command(script);
163        self.run_command_with_cancel("powershell.exe", &["-EncodedCommand", &encoded], cancel)
164            .await
165    }
166
167    /// Send input data (stdin) to a running command.
168    ///
169    /// The `command_id` identifies which command receives the input.
170    /// Set `end_of_stream` to `true` to signal EOF on stdin.
171    pub async fn send_input(
172        &self,
173        command_id: &str,
174        data: &[u8],
175        end_of_stream: bool,
176    ) -> Result<(), WinrmError> {
177        // send_input doesn't have a _with_uri variant yet — the resource URI
178        // in the header doesn't affect the Send action on most servers, but
179        // let's be correct and use the shell's URI anyway by calling the
180        // low-level builder directly.
181        let endpoint = self.client.endpoint(&self.host);
182        let config = self.client.config();
183        let envelope = if command_id.is_empty() || command_id == self.shell_id {
184            // PSRP without a command: no CommandId on the stream.
185            soap::send_psrp_request(
186                &endpoint,
187                &self.shell_id,
188                data,
189                config.operation_timeout_secs,
190                config.max_envelope_size,
191                &self.resource_uri,
192            )
193        } else {
194            soap::send_input_request_with_uri(
195                &endpoint,
196                &self.shell_id,
197                command_id,
198                data,
199                end_of_stream,
200                config.operation_timeout_secs,
201                config.max_envelope_size,
202                &self.resource_uri,
203            )
204        };
205        self.client.send_soap_raw(&self.host, envelope).await?;
206        Ok(())
207    }
208
209    /// Send Ctrl+C signal to a running command.
210    ///
211    /// Requests graceful interruption of the command identified by `command_id`.
212    pub async fn signal_ctrl_c(&self, command_id: &str) -> Result<(), WinrmError> {
213        let endpoint = self.client.endpoint(&self.host);
214        let config = self.client.config();
215        let envelope = soap::signal_ctrl_c_request(
216            &endpoint,
217            &self.shell_id,
218            command_id,
219            config.operation_timeout_secs,
220            config.max_envelope_size,
221        );
222        self.client.send_soap_raw(&self.host, envelope).await?;
223        Ok(())
224    }
225
226    /// Start a command and return the command ID for manual polling.
227    ///
228    /// Use with [`receive_next`](Self::receive_next) and
229    /// [`signal_ctrl_c`](Self::signal_ctrl_c) for fine-grained control over
230    /// long-running commands.
231    ///
232    /// # Example
233    ///
234    /// ```no_run
235    /// # async fn example(shell: &winrm_rs::Shell<'_>) -> Result<(), winrm_rs::WinrmError> {
236    /// let cmd_id = shell.start_command("ping", &["-t", "10.0.0.1"]).await?;
237    /// loop {
238    ///     let chunk = shell.receive_next(&cmd_id).await?;
239    ///     print!("{}", String::from_utf8_lossy(&chunk.stdout));
240    ///     if chunk.done { break; }
241    /// }
242    /// # Ok(())
243    /// # }
244    /// ```
245    /// Like [`start_command`](Self::start_command) but lets the caller
246    /// specify the `CommandId` that the server should assign to the
247    /// command. Used by PSRP where the pipeline UUID must match the
248    /// CommandId.
249    pub async fn start_command_with_id(
250        &self,
251        command: &str,
252        args: &[&str],
253        command_id: &str,
254    ) -> Result<String, WinrmError> {
255        let endpoint = self.client.endpoint(&self.host);
256        let config = self.client.config();
257        let envelope = soap::execute_command_with_id_request(
258            &endpoint,
259            &self.shell_id,
260            command,
261            args,
262            command_id,
263            config.operation_timeout_secs,
264            config.max_envelope_size,
265            &self.resource_uri,
266        );
267        let response = self.client.send_soap_raw(&self.host, envelope).await?;
268        let returned_id = soap::parse_command_id(&response).map_err(WinrmError::Soap)?;
269        debug!(command_id = %returned_id, "shell command started with specified ID");
270        Ok(returned_id)
271    }
272
273    pub async fn start_command(&self, command: &str, args: &[&str]) -> Result<String, WinrmError> {
274        let endpoint = self.client.endpoint(&self.host);
275        let config = self.client.config();
276        let envelope = soap::execute_command_request_with_uri(
277            &endpoint,
278            &self.shell_id,
279            command,
280            args,
281            config.operation_timeout_secs,
282            config.max_envelope_size,
283            &self.resource_uri,
284        );
285        let response = self.client.send_soap_raw(&self.host, envelope).await?;
286        let command_id = soap::parse_command_id(&response).map_err(WinrmError::Soap)?;
287        debug!(command_id = %command_id, "shell command started (streaming)");
288        Ok(command_id)
289    }
290
291    /// Poll for the next output chunk from a running command.
292    ///
293    /// Returns a single [`ReceiveOutput`] representing one poll cycle.
294    /// Callers should accumulate stdout/stderr and stop when
295    /// [`done`](ReceiveOutput::done) is `true`.
296    pub async fn receive_next(&self, command_id: &str) -> Result<ReceiveOutput, WinrmError> {
297        let endpoint = self.client.endpoint(&self.host);
298        let config = self.client.config();
299        let is_psrp = self.resource_uri.contains("powershell");
300        let envelope = if is_psrp {
301            // PSRP: stdout only, optional CommandId.
302            let cid = if command_id.is_empty() || command_id == self.shell_id {
303                None
304            } else {
305                Some(command_id)
306            };
307            soap::receive_psrp_request(
308                &endpoint,
309                &self.shell_id,
310                cid,
311                config.operation_timeout_secs,
312                config.max_envelope_size,
313                &self.resource_uri,
314            )
315        } else {
316            soap::receive_output_request_with_uri(
317                &endpoint,
318                &self.shell_id,
319                command_id,
320                config.operation_timeout_secs,
321                config.max_envelope_size,
322                &self.resource_uri,
323            )
324        };
325        let response = self.client.send_soap_raw(&self.host, envelope).await?;
326        soap::parse_receive_output(&response).map_err(WinrmError::Soap)
327    }
328
329    /// Get the shell ID.
330    pub fn shell_id(&self) -> &str {
331        &self.shell_id
332    }
333
334    /// Explicitly close the shell, releasing server-side resources.
335    pub async fn close(mut self) -> Result<(), WinrmError> {
336        self.closed = true;
337        self.client
338            .delete_shell_raw(&self.host, &self.shell_id)
339            .await
340    }
341
342    /// Disconnect from the shell while leaving it alive on the server.
343    ///
344    /// Returns the `shell_id` so the caller can later reconnect via
345    /// [`WinrmClient::reconnect_shell`]. The local `Shell` value is
346    /// consumed; calling [`close`](Self::close) on a disconnected shell
347    /// is unnecessary because the server-side resources are still
348    /// owned by the runspace.
349    pub async fn disconnect(mut self) -> Result<String, WinrmError> {
350        self.closed = true;
351        let endpoint = self.client.endpoint(&self.host);
352        let config = self.client.config();
353        let envelope = soap::disconnect_shell_request_with_uri(
354            &endpoint,
355            &self.shell_id,
356            config.operation_timeout_secs,
357            config.max_envelope_size,
358            &self.resource_uri,
359        );
360        self.client.send_soap_raw(&self.host, envelope).await?;
361        Ok(std::mem::take(&mut self.shell_id))
362    }
363}
364
365impl Drop for Shell<'_> {
366    fn drop(&mut self) {
367        if !self.closed {
368            tracing::warn!(
369                shell_id = %self.shell_id,
370                "shell dropped without close -- resources may leak on server"
371            );
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use crate::client::WinrmClient;
379    use crate::config::{AuthMethod, WinrmConfig, WinrmCredentials};
380    use wiremock::matchers::method;
381    use wiremock::{Mock, MockServer, ResponseTemplate};
382
383    fn test_creds() -> WinrmCredentials {
384        WinrmCredentials::new("admin", "pass", "")
385    }
386
387    fn basic_config(port: u16) -> WinrmConfig {
388        WinrmConfig {
389            port,
390            auth_method: AuthMethod::Basic,
391            connect_timeout_secs: 5,
392            operation_timeout_secs: 10,
393            ..Default::default()
394        }
395    }
396
397    #[tokio::test]
398    async fn run_command_polls_until_done() {
399        let server = MockServer::start().await;
400        let port = server.address().port();
401
402        // Shell create
403        Mock::given(method("POST"))
404            .respond_with(ResponseTemplate::new(200).set_body_string(
405                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-RUN</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
406            ))
407            .up_to_n_times(1)
408            .mount(&server)
409            .await;
410
411        // Execute command
412        Mock::given(method("POST"))
413            .respond_with(ResponseTemplate::new(200).set_body_string(
414                r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>SH-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
415            ))
416            .up_to_n_times(1)
417            .mount(&server)
418            .await;
419
420        // Receive: not done
421        Mock::given(method("POST"))
422            .respond_with(ResponseTemplate::new(200).set_body_string(
423                r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
424                    <rsp:Stream Name="stdout" CommandId="SH-CMD">YWJD</rsp:Stream>
425                    <rsp:CommandState CommandId="SH-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/>
426                </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
427            ))
428            .up_to_n_times(1)
429            .mount(&server)
430            .await;
431
432        // Receive: done with exit code
433        Mock::given(method("POST"))
434            .respond_with(ResponseTemplate::new(200).set_body_string(
435                r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
436                    <rsp:Stream Name="stdout" CommandId="SH-CMD">REVG</rsp:Stream>
437                    <rsp:CommandState CommandId="SH-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
438                        <rsp:ExitCode>7</rsp:ExitCode>
439                    </rsp:CommandState>
440                </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
441            ))
442            .up_to_n_times(1)
443            .mount(&server)
444            .await;
445
446        // Cleanup
447        Mock::given(method("POST"))
448            .respond_with(
449                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
450            )
451            .mount(&server)
452            .await;
453
454        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
455        let shell = client.open_shell("127.0.0.1").await.unwrap();
456        let output = shell.run_command("cmd", &["/c", "dir"]).await.unwrap();
457        assert_eq!(output.exit_code, 7);
458        assert_eq!(output.stdout, b"abCDEF");
459    }
460
461    #[tokio::test]
462    async fn send_input_exercises_shell_method() {
463        let server = MockServer::start().await;
464        let port = server.address().port();
465
466        // Shell create
467        Mock::given(method("POST"))
468            .respond_with(ResponseTemplate::new(200).set_body_string(
469                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-INP</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
470            ))
471            .up_to_n_times(1)
472            .mount(&server)
473            .await;
474
475        // Send input response
476        Mock::given(method("POST"))
477            .respond_with(
478                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
479            )
480            .mount(&server)
481            .await;
482
483        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
484        let shell = client.open_shell("127.0.0.1").await.unwrap();
485        shell.send_input("CMD-X", b"data", false).await.unwrap();
486        shell.send_input("CMD-X", b"", true).await.unwrap();
487    }
488
489    #[tokio::test]
490    async fn disconnect_returns_shell_id_and_suppresses_drop_warning() {
491        let server = MockServer::start().await;
492        let port = server.address().port();
493
494        // Shell create
495        Mock::given(method("POST"))
496            .respond_with(ResponseTemplate::new(200).set_body_string(
497                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-DISC</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
498            ))
499            .up_to_n_times(1)
500            .mount(&server)
501            .await;
502
503        // Disconnect response
504        Mock::given(method("POST"))
505            .respond_with(
506                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
507            )
508            .mount(&server)
509            .await;
510
511        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
512        let shell = client.open_shell("127.0.0.1").await.unwrap();
513        let id = shell.disconnect().await.unwrap();
514        assert_eq!(id, "SH-DISC");
515    }
516
517    #[tokio::test]
518    async fn reconnect_shell_returns_handle_with_existing_id() {
519        let server = MockServer::start().await;
520        let port = server.address().port();
521
522        // Reconnect response
523        Mock::given(method("POST"))
524            .respond_with(
525                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
526            )
527            .mount(&server)
528            .await;
529
530        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
531        let shell = client
532            .reconnect_shell(
533                "127.0.0.1",
534                "SH-EXISTING",
535                crate::soap::namespaces::RESOURCE_URI_CMD,
536            )
537            .await
538            .unwrap();
539        assert_eq!(shell.shell_id(), "SH-EXISTING");
540        // Close cleanly so the drop warning doesn't fire.
541        shell.close().await.unwrap();
542    }
543
544    #[tokio::test]
545    async fn signal_ctrl_c_exercises_shell_method() {
546        let server = MockServer::start().await;
547        let port = server.address().port();
548
549        // Shell create
550        Mock::given(method("POST"))
551            .respond_with(ResponseTemplate::new(200).set_body_string(
552                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-SIG</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
553            ))
554            .up_to_n_times(1)
555            .mount(&server)
556            .await;
557
558        // Signal response
559        Mock::given(method("POST"))
560            .respond_with(
561                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
562            )
563            .mount(&server)
564            .await;
565
566        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
567        let shell = client.open_shell("127.0.0.1").await.unwrap();
568        shell.signal_ctrl_c("CMD-Y").await.unwrap();
569    }
570
571    #[tokio::test]
572    async fn start_command_returns_id() {
573        let server = MockServer::start().await;
574        let port = server.address().port();
575
576        // Shell create
577        Mock::given(method("POST"))
578            .respond_with(ResponseTemplate::new(200).set_body_string(
579                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-START</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
580            ))
581            .up_to_n_times(1)
582            .mount(&server)
583            .await;
584
585        // Execute command
586        Mock::given(method("POST"))
587            .respond_with(ResponseTemplate::new(200).set_body_string(
588                r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>SH-START-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
589            ))
590            .up_to_n_times(1)
591            .mount(&server)
592            .await;
593
594        // Cleanup
595        Mock::given(method("POST"))
596            .respond_with(
597                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
598            )
599            .mount(&server)
600            .await;
601
602        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
603        let shell = client.open_shell("127.0.0.1").await.unwrap();
604        let cmd_id = shell.start_command("ping", &["localhost"]).await.unwrap();
605        assert_eq!(cmd_id, "SH-START-CMD");
606    }
607
608    // --- Mutant-killing tests ---
609
610    // Kills shell.rs:88 — unwrap_or(-1) with "delete -" making it unwrap_or(1).
611    // Goes through Shell::run_command (not client::run_command which has its own copy).
612    #[tokio::test]
613    async fn shell_run_command_missing_exit_code_returns_minus_one() {
614        let server = MockServer::start().await;
615        let port = server.address().port();
616
617        // Shell create
618        Mock::given(method("POST"))
619            .respond_with(ResponseTemplate::new(200).set_body_string(
620                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-NEG1</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
621            ))
622            .up_to_n_times(1)
623            .mount(&server)
624            .await;
625
626        // Execute command
627        Mock::given(method("POST"))
628            .respond_with(ResponseTemplate::new(200).set_body_string(
629                r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>SH-NEG1-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
630            ))
631            .up_to_n_times(1)
632            .mount(&server)
633            .await;
634
635        // Receive: done but NO ExitCode element
636        Mock::given(method("POST"))
637            .respond_with(ResponseTemplate::new(200).set_body_string(
638                r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
639                    <rsp:CommandState CommandId="SH-NEG1-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done"/>
640                </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
641            ))
642            .up_to_n_times(1)
643            .mount(&server)
644            .await;
645
646        // Cleanup (signal + delete)
647        Mock::given(method("POST"))
648            .respond_with(
649                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
650            )
651            .mount(&server)
652            .await;
653
654        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
655        let shell = client.open_shell("127.0.0.1").await.unwrap();
656        let output = shell.run_command("test", &[]).await.unwrap();
657        // Must be -1 (not 1). Kills the "delete -" mutant on unwrap_or(-1).
658        assert_eq!(output.exit_code, -1);
659    }
660
661    // Kills shell.rs:55 — timeout Duration * 2 replaced with + 2.
662    // With operation_timeout_secs=3: * 2 = 6s, + 2 = 5s.
663    // Mock delays 5.5s. With * 2 (6s): 5.5 < 6 → command completes (success).
664    // With + 2 (5s): 5.5 > 5 → timeout. We assert success to kill the + mutant.
665    #[tokio::test]
666    async fn shell_timeout_duration_kills_plus_mutant() {
667        let server = MockServer::start().await;
668        let port = server.address().port();
669
670        // Shell create
671        Mock::given(method("POST"))
672            .respond_with(ResponseTemplate::new(200).set_body_string(
673                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>TO2-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
674            ))
675            .up_to_n_times(1)
676            .mount(&server)
677            .await;
678
679        // Command execute
680        Mock::given(method("POST"))
681            .respond_with(ResponseTemplate::new(200).set_body_string(
682                r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>TO2-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
683            ))
684            .up_to_n_times(1)
685            .mount(&server)
686            .await;
687
688        // Receive: delayed 5500ms, returns Done (completes within 6s but not 5s)
689        Mock::given(method("POST"))
690            .respond_with(
691                ResponseTemplate::new(200)
692                    .set_body_string(
693                        r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
694                            <rsp:CommandState CommandId="TO2-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
695                                <rsp:ExitCode>0</rsp:ExitCode>
696                            </rsp:CommandState>
697                        </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
698                    )
699                    .set_delay(std::time::Duration::from_millis(5500)),
700            )
701            .up_to_n_times(1)
702            .mount(&server)
703            .await;
704
705        // Cleanup (signal + delete)
706        Mock::given(method("POST"))
707            .respond_with(
708                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
709            )
710            .mount(&server)
711            .await;
712
713        let config = WinrmConfig {
714            port,
715            auth_method: AuthMethod::Basic,
716            connect_timeout_secs: 30,
717            operation_timeout_secs: 3, // * 2 = 6s timeout, + 2 = 5s
718            ..Default::default()
719        };
720        let client = WinrmClient::new(config, test_creds()).unwrap();
721        let shell = client.open_shell("127.0.0.1").await.unwrap();
722        let result = shell.run_command("slow", &[]).await;
723
724        // With * 2 = 6s: 5.5s < 6s → success (correct)
725        // With + 2 = 5s: 5.5s > 5s → timeout (mutant killed by this assertion)
726        assert!(
727            result.is_ok(),
728            "should complete within 6s timeout (* 2), got: {:?}",
729            result.err()
730        );
731        assert_eq!(result.unwrap().exit_code, 0);
732    }
733
734    // Kills shell.rs:55 — timeout Duration * 2 replaced with / 2.
735    // With operation_timeout_secs=4: * 2 = 8s, / 2 = 2s.
736    // Mock returns instantly. With * 2 (8s): completes fine. With / 2 (2s): should still
737    // complete since response is instant. BUT with operation_timeout_secs=1: / 2 = 0s.
738    // A 0-second tokio timeout fires immediately before any I/O completes.
739    // So use operation_timeout_secs=1 and instant response: * 2 = 2s → success,
740    // / 2 = 0s → immediate timeout.
741    #[tokio::test]
742    async fn shell_timeout_duration_kills_div_mutant() {
743        let server = MockServer::start().await;
744        let port = server.address().port();
745
746        // Shell create
747        Mock::given(method("POST"))
748            .respond_with(ResponseTemplate::new(200).set_body_string(
749                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>TO3-SHELL</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
750            ))
751            .up_to_n_times(1)
752            .mount(&server)
753            .await;
754
755        // Command execute
756        Mock::given(method("POST"))
757            .respond_with(ResponseTemplate::new(200).set_body_string(
758                r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>TO3-CMD</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
759            ))
760            .up_to_n_times(1)
761            .mount(&server)
762            .await;
763
764        // Receive: immediate response with Done + exit code
765        Mock::given(method("POST"))
766            .respond_with(ResponseTemplate::new(200).set_body_string(
767                r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
768                    <rsp:CommandState CommandId="TO3-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
769                        <rsp:ExitCode>0</rsp:ExitCode>
770                    </rsp:CommandState>
771                </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
772            ))
773            .up_to_n_times(1)
774            .mount(&server)
775            .await;
776
777        // Cleanup
778        Mock::given(method("POST"))
779            .respond_with(
780                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
781            )
782            .mount(&server)
783            .await;
784
785        let config = WinrmConfig {
786            port,
787            auth_method: AuthMethod::Basic,
788            connect_timeout_secs: 30,
789            operation_timeout_secs: 1, // * 2 = 2s (ample), / 2 = 0s (instant timeout)
790            ..Default::default()
791        };
792        let client = WinrmClient::new(config, test_creds()).unwrap();
793        let shell = client.open_shell("127.0.0.1").await.unwrap();
794        let result = shell.run_command("fast", &[]).await;
795
796        // With * 2 = 2s: instant response → success (correct)
797        // With / 2 = 0s: 0-second timeout fires before I/O → Timeout (mutant killed)
798        assert!(
799            result.is_ok(),
800            "instant response should succeed with 2s timeout, got: {:?}",
801            result.err()
802        );
803    }
804
805    // Kills shell.rs:208 — Drop body → () and delete ! in condition.
806    // Uses tracing-test to capture log output and verify the warning is emitted
807    // when a shell is dropped without close.
808    #[tracing_test::traced_test]
809    #[tokio::test]
810    async fn shell_drop_without_close_emits_warning() {
811        let server = MockServer::start().await;
812        let port = server.address().port();
813
814        // Shell create
815        Mock::given(method("POST"))
816            .respond_with(ResponseTemplate::new(200).set_body_string(
817                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DROP-WARN</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
818            ))
819            .up_to_n_times(1)
820            .mount(&server)
821            .await;
822
823        // Cleanup (for any requests)
824        Mock::given(method("POST"))
825            .respond_with(
826                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
827            )
828            .mount(&server)
829            .await;
830
831        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
832
833        {
834            let shell = client.open_shell("127.0.0.1").await.unwrap();
835            assert_eq!(shell.shell_id(), "DROP-WARN");
836            // Drop shell without calling close()
837        }
838
839        // Kills "replace drop body with ()" — the warning would not be emitted.
840        // Kills "delete !" — the condition becomes `if self.closed` which is false,
841        // so the warning would NOT be emitted for unclosed shells (and WOULD for closed ones).
842        assert!(logs_contain("shell dropped without close"));
843    }
844
845    // Verify that a properly closed shell does NOT emit the drop warning.
846    // This kills the "delete !" mutant: with `if self.closed` (instead of `if !self.closed`),
847    // a closed shell would emit the warning (wrong), and an unclosed one would not.
848    #[tracing_test::traced_test]
849    #[tokio::test]
850    async fn shell_close_does_not_emit_drop_warning() {
851        let server = MockServer::start().await;
852        let port = server.address().port();
853
854        // Shell create
855        Mock::given(method("POST"))
856            .respond_with(ResponseTemplate::new(200).set_body_string(
857                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>DROP-OK</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
858            ))
859            .up_to_n_times(1)
860            .mount(&server)
861            .await;
862
863        // Delete shell
864        Mock::given(method("POST"))
865            .respond_with(
866                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
867            )
868            .mount(&server)
869            .await;
870
871        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
872        let shell = client.open_shell("127.0.0.1").await.unwrap();
873        shell.close().await.unwrap();
874
875        // After close, the drop should NOT warn.
876        // Kills "delete !" mutant: `if self.closed` would warn for closed shell.
877        assert!(!logs_contain("shell dropped without close"));
878    }
879
880    // Kills shell.rs:62 — resource_uri() returning "" or "xyzzy"
881    #[tokio::test]
882    async fn resource_uri_matches_default_cmd() {
883        let server = MockServer::start().await;
884        let port = server.address().port();
885
886        Mock::given(method("POST"))
887            .respond_with(ResponseTemplate::new(200).set_body_string(
888                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-URI</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
889            ))
890            .up_to_n_times(1)
891            .mount(&server)
892            .await;
893
894        // Cleanup
895        Mock::given(method("POST"))
896            .respond_with(
897                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
898            )
899            .mount(&server)
900            .await;
901
902        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
903        let shell = client.open_shell("127.0.0.1").await.unwrap();
904        assert!(
905            shell.resource_uri().contains("cmd"),
906            "resource_uri should contain 'cmd', got: {}",
907            shell.resource_uri()
908        );
909    }
910
911    // Kills shell.rs:255 — start_command_with_id returning Ok("") or Ok("xyzzy")
912    #[tokio::test]
913    async fn start_command_with_id_returns_server_command_id() {
914        let server = MockServer::start().await;
915        let port = server.address().port();
916
917        // Shell create
918        Mock::given(method("POST"))
919            .respond_with(ResponseTemplate::new(200).set_body_string(
920                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-WCID</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
921            ))
922            .up_to_n_times(1)
923            .mount(&server)
924            .await;
925
926        // Execute command (returns a specific command ID)
927        Mock::given(method("POST"))
928            .respond_with(ResponseTemplate::new(200).set_body_string(
929                r"<s:Envelope><s:Body><rsp:CommandResponse><rsp:CommandId>MY-CMD-ID</rsp:CommandId></rsp:CommandResponse></s:Body></s:Envelope>",
930            ))
931            .up_to_n_times(1)
932            .mount(&server)
933            .await;
934
935        // Cleanup
936        Mock::given(method("POST"))
937            .respond_with(
938                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
939            )
940            .mount(&server)
941            .await;
942
943        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
944        let shell = client.open_shell("127.0.0.1").await.unwrap();
945        let cmd_id = shell
946            .start_command_with_id("test", &[], "MY-CMD-ID")
947            .await
948            .unwrap();
949        assert_eq!(cmd_id, "MY-CMD-ID");
950    }
951
952    // Kills shell.rs:183 — send_input conditional (|| → && and == → !=)
953    // Tests the PSRP branch: when command_id equals shell_id, it should use send_psrp_request
954    #[tokio::test]
955    async fn send_input_psrp_path_when_command_id_is_shell_id() {
956        let server = MockServer::start().await;
957        let port = server.address().port();
958
959        // Reconnect a PSRP shell (uses powershell resource URI)
960        Mock::given(method("POST"))
961            .respond_with(
962                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
963            )
964            .mount(&server)
965            .await;
966
967        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
968        let shell = client
969            .reconnect_shell(
970                "127.0.0.1",
971                "PSRP-SHELL",
972                crate::soap::namespaces::RESOURCE_URI_PSRP,
973            )
974            .await
975            .unwrap();
976        // Send input with command_id == shell_id → takes PSRP path
977        shell
978            .send_input("PSRP-SHELL", b"data", false)
979            .await
980            .unwrap();
981        // Send input with empty command_id → also takes PSRP path
982        shell.send_input("", b"data", false).await.unwrap();
983    }
984
985    // Kills shell.rs:302 — receive_next conditional (|| → && and == → !=)
986    #[tokio::test]
987    async fn receive_next_with_real_command_id() {
988        let server = MockServer::start().await;
989        let port = server.address().port();
990
991        // Shell create
992        Mock::given(method("POST"))
993            .respond_with(ResponseTemplate::new(200).set_body_string(
994                r"<s:Envelope><s:Body><rsp:Shell><rsp:ShellId>SH-RECV</rsp:ShellId></rsp:Shell></s:Body></s:Envelope>",
995            ))
996            .up_to_n_times(1)
997            .mount(&server)
998            .await;
999
1000        // Receive output
1001        Mock::given(method("POST"))
1002            .respond_with(ResponseTemplate::new(200).set_body_string(
1003                r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
1004                    <rsp:Stream Name="stdout" CommandId="RECV-CMD">YWJD</rsp:Stream>
1005                    <rsp:CommandState CommandId="RECV-CMD" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
1006                        <rsp:ExitCode>0</rsp:ExitCode>
1007                    </rsp:CommandState>
1008                </rsp:ReceiveResponse></s:Body></s:Envelope>"#,
1009            ))
1010            .up_to_n_times(1)
1011            .mount(&server)
1012            .await;
1013
1014        // Cleanup
1015        Mock::given(method("POST"))
1016            .respond_with(
1017                ResponseTemplate::new(200).set_body_string("<s:Envelope><s:Body/></s:Envelope>"),
1018            )
1019            .mount(&server)
1020            .await;
1021
1022        let client = WinrmClient::new(basic_config(port), test_creds()).unwrap();
1023        let shell = client.open_shell("127.0.0.1").await.unwrap();
1024        let output = shell.receive_next("RECV-CMD").await.unwrap();
1025        assert!(output.done);
1026        assert!(!output.stdout.is_empty());
1027    }
1028}