Skip to main content

winrm_rs/
client.rs

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