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