Skip to main content

winrm_rs/soap/
parser.rs

1//! Response parsers for shell IDs, command IDs, and output streams.
2
3use super::namespaces::COMMAND_STATE_DONE;
4use crate::error::SoapError;
5
6/// Parsed output from a single WinRM Receive response.
7///
8/// Each Receive poll may return partial output. Callers should accumulate
9/// [`stdout`](Self::stdout) and [`stderr`](Self::stderr) across polls and
10/// stop when [`done`](Self::done) is `true`.
11pub struct ReceiveOutput {
12    /// Decoded stdout bytes from this poll (may be empty).
13    pub stdout: Vec<u8>,
14    /// Decoded stderr bytes from this poll (may be empty).
15    pub stderr: Vec<u8>,
16    /// Process exit code, present only when the command has finished.
17    pub exit_code: Option<i32>,
18    /// `true` when the server reports `CommandState/Done`, indicating no more
19    /// output will follow.
20    pub done: bool,
21}
22
23/// Extract the `ShellId` from a Create Shell response body.
24///
25/// Searches for a `<ShellId>` element (with or without namespace prefix) and
26/// returns its text content. Returns [`SoapError::MissingElement`] if not found.
27#[allow(unreachable_pub)] // re-exported under the `__internal` feature for fuzz targets
28pub fn parse_shell_id(xml: &str) -> Result<String, SoapError> {
29    extract_element_text(xml, "ShellId").ok_or_else(|| SoapError::MissingElement("ShellId".into()))
30}
31
32/// Extract the `CommandId` from an Execute Command response body.
33///
34/// Searches for a `<CommandId>` element (with or without namespace prefix) and
35/// returns its text content. Returns [`SoapError::MissingElement`] if not found.
36#[allow(unreachable_pub)] // re-exported under the `__internal` feature for fuzz targets
37pub fn parse_command_id(xml: &str) -> Result<String, SoapError> {
38    extract_element_text(xml, "CommandId")
39        .ok_or_else(|| SoapError::MissingElement("CommandId".into()))
40}
41
42/// Parse a Receive response to extract stdout, stderr, exit code, and completion state.
43///
44/// Decodes base64 `<rsp:Stream>` elements for stdout and stderr, checks the
45/// `CommandState` for the `Done` URI, and extracts the `ExitCode` if present.
46/// Returns [`SoapError::ParseError`] if a stream contains invalid base64, or
47/// a [`SoapError::Fault`] if the response body contains a SOAP fault.
48#[allow(unreachable_pub)] // re-exported under the `__internal` feature for fuzz targets
49pub fn parse_receive_output(xml: &str) -> Result<ReceiveOutput, SoapError> {
50    use base64::Engine;
51    use base64::engine::general_purpose::STANDARD as B64;
52
53    let mut stdout = Vec::new();
54    let mut stderr = Vec::new();
55    let mut exit_code: Option<i32> = None;
56    let mut done = false;
57
58    // Parse Stream elements for stdout/stderr
59    // Format: <rsp:Stream Name="stdout" CommandId="...">base64data</rsp:Stream>
60    for (name, data) in extract_streams(xml) {
61        let decoded = B64
62            .decode(data.trim_ascii())
63            .map_err(|e| SoapError::ParseError(format!("base64 decode: {e}")))?;
64        match name.as_str() {
65            "stdout" => stdout.extend_from_slice(&decoded),
66            "stderr" => {
67                if decoded.starts_with(b"#< CLIXML") {
68                    stderr.extend_from_slice(&parse_clixml(&decoded));
69                } else {
70                    stderr.extend_from_slice(&decoded);
71                }
72            }
73            _ => {}
74        }
75    }
76
77    // Check CommandState for Done
78    if xml.contains(COMMAND_STATE_DONE) {
79        done = true;
80    }
81
82    // Extract ExitCode if present. A non-numeric ExitCode value from the
83    // server is left as `None` (fail-soft), but a `tracing::warn` is emitted
84    // so callers can distinguish "command still running" (no ExitCode element)
85    // from "server returned ExitCode element with unparseable text" (possible
86    // server malfunction or MITM tampering with the response).
87    if let Some(code_str) = extract_element_text(xml, "ExitCode") {
88        match code_str.parse::<i32>() {
89            Ok(c) => exit_code = Some(c),
90            Err(e) => {
91                tracing::warn!(
92                    raw = %code_str.escape_debug(),
93                    error = %e,
94                    "WinRM ExitCode element present but unparseable; treating as absent",
95                );
96            }
97        }
98    }
99
100    // Check for SOAP faults
101    if let Some(fault) = extract_soap_fault(xml) {
102        return Err(fault);
103    }
104
105    Ok(ReceiveOutput {
106        stdout,
107        stderr,
108        exit_code,
109        done,
110    })
111}
112
113/// Parse a WS-Enumeration Enumerate response.
114///
115/// Returns the XML items (raw text between `<w:Items>` tags) and an optional
116/// `EnumerationContext` for continuation via Pull.
117pub(crate) fn parse_enumerate_response(xml: &str) -> Result<(String, Option<String>), SoapError> {
118    check_soap_fault(xml)?;
119
120    // Extract items — may be inside <w:Items>, <wsen:Items>, or <n:Items>
121    let items = extract_element_text(xml, "Items").unwrap_or_default();
122
123    // Extract EnumerationContext for Pull continuation
124    let context = extract_element_text(xml, "EnumerationContext");
125
126    // Check for EndOfSequence — means no more items
127    let end_of_seq = xml.contains("EndOfSequence");
128
129    let context = if end_of_seq { None } else { context };
130
131    Ok((items, context))
132}
133
134/// Check a SOAP response for fault elements and return an error if found.
135///
136/// Scans the XML for `<Fault>` or `<s:Fault>` elements and extracts the
137/// fault code and reason text. Returns `Ok(())` if no fault is present.
138#[allow(unreachable_pub)] // re-exported under the `__internal` feature for fuzz targets
139pub fn check_soap_fault(xml: &str) -> Result<(), SoapError> {
140    if let Some(fault) = extract_soap_fault(xml) {
141        return Err(fault);
142    }
143    Ok(())
144}
145
146/// Parse PowerShell CLIXML stderr into human-readable text.
147///
148/// PowerShell wraps errors in CLIXML format (`#< CLIXML\r\n<Objs>...`).
149/// This function extracts the text from `<S S="Error">` tags and decodes
150/// CLIXML escape sequences like `_x000D_` (CR) and `_x000A_` (LF).
151fn parse_clixml(data: &[u8]) -> Vec<u8> {
152    let text = String::from_utf8_lossy(data);
153    let mut result = String::new();
154
155    // Extract content from <S S="Error">...</S> tags
156    let error_tag = "<S S=\"Error\">";
157    let close_tag = "</S>";
158    let mut search_from = 0;
159
160    while let Some(start) = text[search_from..].find(error_tag) {
161        let content_start = search_from + start + error_tag.len();
162        if let Some(end) = text[content_start..].find(close_tag) {
163            let fragment = &text[content_start..content_start + end];
164            result.push_str(fragment);
165            search_from = content_start + end + close_tag.len();
166        } else {
167            break;
168        }
169    }
170
171    // Decode CLIXML escape sequences
172    let result = result
173        .replace("_x000D_", "\r")
174        .replace("_x000A_", "\n")
175        .replace("_x0009_", "\t")
176        .replace("_x001B_", "\x1b");
177
178    result.into_bytes()
179}
180
181// --- Helpers ---
182
183/// Simple element text extraction by local name (namespace-agnostic).
184///
185/// Finds elements like `<prefix:Element>text</prefix:Element>` or `<Element>text</Element>`
186/// regardless of namespace prefix.
187fn extract_element_text(xml: &str, element: &str) -> Option<String> {
188    // Search for ":Element>" or "<Element>" to handle any namespace prefix
189    let suffixed = format!(":{element}>");
190    let bare_open = format!("<{element}>");
191
192    let mut search_from = 0;
193    while search_from < xml.len() {
194        let region = &xml[search_from..];
195
196        // Find next occurrence of the element (with or without prefix)
197        let (tag_content_start, ns_prefix) = if let Some(pos) = region.find(&suffixed) {
198            // Found ":Element>" -- walk back to find the '<'
199            let abs_pos = search_from + pos;
200            let before = &xml[..abs_pos];
201            let lt = before.rfind('<')?;
202            // Make sure this is an opening tag, not a closing tag
203            if xml[lt..].starts_with("</") {
204                search_from = abs_pos + suffixed.len();
205                continue;
206            }
207            let prefix = &xml[lt + 1..abs_pos];
208            (abs_pos + suffixed.len(), Some(prefix.to_string()))
209        } else if let Some(pos) = region.find(&bare_open) {
210            (search_from + pos + bare_open.len(), None)
211        } else {
212            return None;
213        };
214
215        // Build closing tag pattern
216        let close_tag = match &ns_prefix {
217            Some(p) => format!("</{p}:{element}>"),
218            None => format!("</{element}>"),
219        };
220
221        if let Some(end) = xml[tag_content_start..].find(&close_tag) {
222            let text = xml[tag_content_start..tag_content_start + end].trim();
223            if !text.is_empty() {
224                return Some(text.to_string());
225            }
226        }
227
228        search_from = tag_content_start;
229    }
230    None
231}
232
233/// Extract all Stream elements with their Name attribute and base64 content.
234fn extract_streams(xml: &str) -> Vec<(String, String)> {
235    let mut streams = Vec::new();
236    let mut search_from = 0;
237
238    let stream_tags = ["<rsp:Stream ", "<Stream "];
239
240    while search_from < xml.len() {
241        let found = stream_tags
242            .iter()
243            .filter_map(|tag| {
244                xml[search_from..]
245                    .find(tag)
246                    .map(|pos| (search_from + pos, *tag))
247            })
248            .min_by_key(|(pos, _)| *pos);
249
250        let Some((tag_start, _)) = found else {
251            break;
252        };
253
254        let tag_region = &xml[tag_start..];
255
256        // Find the end of the opening tag
257        let Some(tag_end) = tag_region.find('>') else {
258            break;
259        };
260        let opening_tag = &tag_region[..tag_end];
261
262        // Extract Name attribute
263        let name = extract_attribute(opening_tag, "Name").unwrap_or_default();
264
265        // Find content between > and closing tag
266        let content_start = tag_start + tag_end + 1;
267
268        let close_tags = ["</rsp:Stream>", "</Stream>"];
269        let close_pos = close_tags
270            .iter()
271            .filter_map(|close| xml[content_start..].find(close))
272            .min();
273
274        if let Some(end_offset) = close_pos {
275            let content = &xml[content_start..content_start + end_offset];
276            if !content.trim_ascii().is_empty() {
277                streams.push((name, content.to_string()));
278            }
279            search_from = content_start + end_offset + 1;
280        } else {
281            break;
282        }
283    }
284
285    streams
286}
287
288/// Extract an XML attribute value from a tag string.
289fn extract_attribute(tag: &str, attr_name: &str) -> Option<String> {
290    let pattern = format!("{attr_name}=\"");
291    let start = tag.find(&pattern)? + pattern.len();
292    let end = tag[start..].find('"')? + start;
293    Some(tag[start..end].to_string())
294}
295
296/// Extract a SOAP fault from the response, if present.
297fn extract_soap_fault(xml: &str) -> Option<SoapError> {
298    // Check for Fault element
299    let has_fault = xml.contains(":Fault>") || xml.contains("<Fault>");
300    if !has_fault {
301        return None;
302    }
303
304    // Prefer the subcode value (e.g. `w:TimedOut`) over the top-level
305    // SOAP code (`s:Receiver`/`s:Sender`) because it carries the
306    // actionable WS-Management error.
307    let code = extract_subcode_value(xml)
308        .or_else(|| extract_element_text(xml, "Value"))
309        .or_else(|| extract_element_text(xml, "faultcode"))
310        .unwrap_or_else(|| "unknown".into());
311    let reason = extract_element_text(xml, "Text")
312        .or_else(|| extract_element_text(xml, "Message"))
313        .or_else(|| extract_element_text(xml, "faultstring"))
314        .unwrap_or_else(|| "SOAP fault".into());
315
316    Some(SoapError::Fault { code, reason })
317}
318
319/// Extract the `<s:Subcode><s:Value>…</s:Value></s:Subcode>` text,
320/// falling back to the WSManFault `Code` attribute if present.
321fn extract_subcode_value(xml: &str) -> Option<String> {
322    // Look for <Subcode>…<Value>X</Value>…</Subcode>
323    let sub_start = xml.find("Subcode>")?;
324    let rest = &xml[sub_start..];
325    let inner = extract_element_text(rest, "Value")?;
326    if inner.is_empty() {
327        return None;
328    }
329    Some(inner)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn parse_shell_id_from_response() {
338        let xml = r"<s:Envelope><s:Body><rsp:Shell>
339            <rsp:ShellId>ABC-DEF-123</rsp:ShellId>
340        </rsp:Shell></s:Body></s:Envelope>";
341        let id = parse_shell_id(xml).unwrap();
342        assert_eq!(id, "ABC-DEF-123");
343    }
344
345    #[test]
346    fn parse_command_id_from_response() {
347        let xml = r"<s:Envelope><s:Body><rsp:CommandResponse>
348            <rsp:CommandId>CMD-456</rsp:CommandId>
349        </rsp:CommandResponse></s:Body></s:Envelope>";
350        let id = parse_command_id(xml).unwrap();
351        assert_eq!(id, "CMD-456");
352    }
353
354    #[test]
355    fn parse_receive_output_with_streams() {
356        // "hello" base64 = "aGVsbG8="
357        // "err" base64 = "ZXJy"
358        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
359            <rsp:Stream Name="stdout" CommandId="C1">aGVsbG8=</rsp:Stream>
360            <rsp:Stream Name="stderr" CommandId="C1">ZXJy</rsp:Stream>
361            <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
362                <rsp:ExitCode>0</rsp:ExitCode>
363            </rsp:CommandState>
364        </rsp:ReceiveResponse></s:Body></s:Envelope>"#;
365
366        let output = parse_receive_output(xml).unwrap();
367        assert_eq!(output.stdout, b"hello");
368        assert_eq!(output.stderr, b"err");
369        assert_eq!(output.exit_code, Some(0));
370        assert!(output.done);
371    }
372
373    #[test]
374    fn parse_receive_output_not_done() {
375        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
376            <rsp:Stream Name="stdout" CommandId="C1">dGVzdA==</rsp:Stream>
377            <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/>
378        </rsp:ReceiveResponse></s:Body></s:Envelope>"#;
379
380        let output = parse_receive_output(xml).unwrap();
381        assert_eq!(output.stdout, b"test");
382        assert!(!output.done);
383        assert!(output.exit_code.is_none());
384    }
385
386    #[test]
387    fn detect_soap_fault() {
388        let xml = r"<s:Envelope><s:Body><s:Fault>
389            <s:Code><s:Value>s:Receiver</s:Value></s:Code>
390            <s:Reason><s:Text>Access denied</s:Text></s:Reason>
391        </s:Fault></s:Body></s:Envelope>";
392
393        let result = check_soap_fault(xml);
394        assert!(result.is_err());
395        let err = result.unwrap_err();
396        match err {
397            SoapError::Fault { code, reason } => {
398                assert_eq!(code, "s:Receiver");
399                assert_eq!(reason, "Access denied");
400            }
401            _ => panic!("expected SoapFault"),
402        }
403    }
404
405    #[test]
406    fn extract_attribute_works() {
407        let tag = r#"<rsp:Stream Name="stdout" CommandId="C1""#;
408        assert_eq!(extract_attribute(tag, "Name"), Some("stdout".into()));
409        assert_eq!(extract_attribute(tag, "CommandId"), Some("C1".into()));
410        assert_eq!(extract_attribute(tag, "Missing"), None);
411    }
412
413    #[test]
414    fn parse_receive_output_with_soap_fault() {
415        let xml = r"<s:Envelope><s:Body>
416            <s:Fault>
417                <s:Code><s:Value>s:Sender</s:Value></s:Code>
418                <s:Reason><s:Text>Invalid input</s:Text></s:Reason>
419            </s:Fault>
420        </s:Body></s:Envelope>";
421        let result = parse_receive_output(xml);
422        assert!(result.is_err());
423    }
424
425    #[test]
426    fn check_soap_fault_no_fault() {
427        let xml = r"<s:Envelope><s:Body><Data>ok</Data></s:Body></s:Envelope>";
428        assert!(check_soap_fault(xml).is_ok());
429    }
430
431    #[test]
432    fn extract_element_text_closing_tag_first() {
433        // Test where closing tag appears before opening tag
434        let xml = r"</rsp:ShellId><rsp:ShellId>ABC</rsp:ShellId>";
435        let result = parse_shell_id(xml).unwrap();
436        assert_eq!(result, "ABC");
437    }
438
439    #[test]
440    fn extract_element_text_empty_content() {
441        let xml = r"<rsp:ShellId></rsp:ShellId>";
442        assert!(parse_shell_id(xml).is_err());
443    }
444
445    #[test]
446    fn extract_streams_empty_stream() {
447        // Stream with empty content should be skipped
448        let xml = r#"<rsp:Stream Name="stdout" CommandId="C1"></rsp:Stream>"#;
449        let streams = extract_streams(xml);
450        assert!(streams.is_empty());
451    }
452
453    #[test]
454    fn parse_receive_output_non_base64_stream() {
455        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
456            <rsp:Stream Name="stdout" CommandId="C1">!!!not-base64!!!</rsp:Stream>
457        </rsp:ReceiveResponse></s:Body></s:Envelope>"#;
458        let result = parse_receive_output(xml);
459        assert!(result.is_err());
460    }
461
462    #[test]
463    fn soap_error_display() {
464        let e = SoapError::MissingElement("ShellId".into());
465        assert_eq!(format!("{e}"), "missing element: ShellId");
466
467        let e = SoapError::ParseError("bad data".into());
468        assert_eq!(format!("{e}"), "parse error: bad data");
469
470        let e = SoapError::Fault {
471            code: "s:Sender".into(),
472            reason: "bad".into(),
473        };
474        assert_eq!(format!("{e}"), "SOAP fault [s:Sender]: bad");
475    }
476
477    #[test]
478    fn extract_streams_bare_tag() {
479        // Test with bare <Stream> (no namespace prefix)
480        let xml = r#"<Stream Name="stdout">aGVsbG8=</Stream>"#;
481        let streams = extract_streams(xml);
482        assert_eq!(streams.len(), 1);
483        assert_eq!(streams[0].0, "stdout");
484    }
485
486    #[test]
487    fn detect_soap_fault_with_legacy_tags() {
488        let xml = r"<Fault><faultcode>s:Client</faultcode><faultstring>oops</faultstring></Fault>";
489        let result = check_soap_fault(xml);
490        assert!(result.is_err());
491    }
492
493    #[test]
494    fn extract_streams_bare_before_namespaced() {
495        // Both <Stream and <rsp:Stream in same XML, bare tag appears first.
496        let xml =
497            r#"<Stream Name="stdout">aGVsbG8=</Stream><rsp:Stream Name="stderr">ZXJy</rsp:Stream>"#;
498        let streams = extract_streams(xml);
499        assert_eq!(streams.len(), 2);
500        assert_eq!(streams[0].0, "stdout");
501        assert_eq!(streams[1].0, "stderr");
502    }
503
504    #[test]
505    fn extract_streams_namespaced_before_bare_close() {
506        // <rsp:Stream> with content closed by </Stream> (bare close appearing before </rsp:Stream>)
507        let xml = r#"<rsp:Stream Name="stdout">dGVzdA==</Stream></rsp:Stream>"#;
508        let streams = extract_streams(xml);
509        assert_eq!(streams.len(), 1);
510        assert_eq!(streams[0].0, "stdout");
511    }
512
513    #[test]
514    fn parse_receive_output_no_exit_code() {
515        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
516            <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done"/>
517        </rsp:ReceiveResponse></s:Body></s:Envelope>"#;
518        let output = parse_receive_output(xml).unwrap();
519        assert!(output.done);
520        assert!(output.exit_code.is_none());
521        assert!(output.stdout.is_empty());
522    }
523
524    // --- Mutant-killing tests ---
525
526    #[test]
527    fn extract_element_text_skips_empty_finds_second() {
528        let xml = r"<rsp:ShellId></rsp:ShellId><rsp:ShellId>FOUND</rsp:ShellId>";
529        assert_eq!(parse_shell_id(xml).unwrap(), "FOUND");
530    }
531
532    #[test]
533    fn extract_element_bare_element() {
534        let xml = r"<ShellId>BARE-ID</ShellId>";
535        assert_eq!(parse_shell_id(xml).unwrap(), "BARE-ID");
536    }
537
538    #[test]
539    fn parse_receive_output_multiple_stdout_chunks() {
540        // "hel" = aGVs, "lo" = bG8=
541        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
542            <rsp:Stream Name="stdout" CommandId="C1">aGVs</rsp:Stream>
543            <rsp:Stream Name="stdout" CommandId="C1">bG8=</rsp:Stream>
544            <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
545                <rsp:ExitCode>0</rsp:ExitCode>
546            </rsp:CommandState>
547        </rsp:ReceiveResponse></s:Body></s:Envelope>"#;
548        let output = parse_receive_output(xml).unwrap();
549        assert_eq!(output.stdout, b"hello");
550    }
551
552    #[test]
553    fn parse_receive_output_interleaved_streams() {
554        // "AB" = QUI=, "err" = ZXJy, "CD" = Q0Q=
555        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
556            <rsp:Stream Name="stdout" CommandId="C1">QUI=</rsp:Stream>
557            <rsp:Stream Name="stderr" CommandId="C1">ZXJy</rsp:Stream>
558            <rsp:Stream Name="stdout" CommandId="C1">Q0Q=</rsp:Stream>
559            <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
560                <rsp:ExitCode>1</rsp:ExitCode>
561            </rsp:CommandState>
562        </rsp:ReceiveResponse></s:Body></s:Envelope>"#;
563        let output = parse_receive_output(xml).unwrap();
564        assert_eq!(output.stdout, b"ABCD");
565        assert_eq!(output.stderr, b"err");
566        assert_eq!(output.exit_code, Some(1));
567    }
568
569    #[test]
570    fn extract_element_text_multiple_closing_before_opening() {
571        let xml = r"</rsp:CommandId></rsp:CommandId><rsp:CommandId>REAL-CMD</rsp:CommandId>";
572        assert_eq!(parse_command_id(xml).unwrap(), "REAL-CMD");
573    }
574
575    #[test]
576    fn extract_streams_three_sequential() {
577        // "A" = QQ==, "B" = Qg==, "C" = Qw==
578        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
579            <rsp:Stream Name="stdout" CommandId="C1">QQ==</rsp:Stream>
580            <rsp:Stream Name="stderr" CommandId="C1">Qg==</rsp:Stream>
581            <rsp:Stream Name="stdout" CommandId="C1">Qw==</rsp:Stream>
582            <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
583                <rsp:ExitCode>0</rsp:ExitCode>
584            </rsp:CommandState>
585        </rsp:ReceiveResponse></s:Body></s:Envelope>"#;
586        let output = parse_receive_output(xml).unwrap();
587        assert_eq!(output.stdout, b"AC");
588        assert_eq!(output.stderr, b"B");
589    }
590
591    #[test]
592    fn extract_element_text_trims_whitespace() {
593        let xml = r"<rsp:ShellId>  TRIMMED  </rsp:ShellId>";
594        assert_eq!(parse_shell_id(xml).unwrap(), "TRIMMED");
595    }
596
597    #[test]
598    fn parse_receive_output_negative_exit_code() {
599        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
600            <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
601                <rsp:ExitCode>-1</rsp:ExitCode>
602            </rsp:CommandState>
603        </rsp:ReceiveResponse></s:Body></s:Envelope>"#;
604        let output = parse_receive_output(xml).unwrap();
605        assert!(output.done);
606        assert_eq!(output.exit_code, Some(-1));
607    }
608
609    #[test]
610    fn parse_receive_output_non_numeric_exit_code() {
611        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
612            <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
613                <rsp:ExitCode>notanumber</rsp:ExitCode>
614            </rsp:CommandState>
615        </rsp:ReceiveResponse></s:Body></s:Envelope>"#;
616        let output = parse_receive_output(xml).unwrap();
617        assert!(output.done);
618        assert!(output.exit_code.is_none());
619    }
620
621    #[test]
622    fn extract_element_text_at_end_of_string() {
623        let xml = "<rsp:CommandId>VAL</rsp:CommandId>";
624        assert_eq!(parse_command_id(xml).unwrap(), "VAL");
625    }
626
627    #[test]
628    fn extract_element_bare_at_start_of_string() {
629        let xml = "<CommandId>START-ID</CommandId>";
630        assert_eq!(parse_command_id(xml).unwrap(), "START-ID");
631    }
632
633    #[test]
634    fn extract_element_bare_with_prefix_text() {
635        let xml = "X<ShellId>OFFSET-ID</ShellId>";
636        assert_eq!(parse_shell_id(xml).unwrap(), "OFFSET-ID");
637    }
638
639    #[test]
640    fn extract_streams_at_end_of_string() {
641        // "ok" = b2s=
642        let xml = r#"<rsp:Stream Name="stdout" CommandId="C1">b2s=</rsp:Stream>"#;
643        let streams = extract_streams(xml);
644        assert_eq!(streams.len(), 1);
645        assert_eq!(streams[0].0, "stdout");
646    }
647
648    #[test]
649    fn extract_streams_rsp_before_bare_picks_first() {
650        // "A" = QQ==, "B" = Qg==
651        let xml =
652            r#"<rsp:Stream Name="stdout">QQ==</rsp:Stream> <Stream Name="stderr">Qg==</Stream>"#;
653        let streams = extract_streams(xml);
654        assert_eq!(streams.len(), 2);
655        assert_eq!(streams[0].0, "stdout");
656        assert_eq!(streams[0].1, "QQ==");
657        assert_eq!(streams[1].0, "stderr");
658        assert_eq!(streams[1].1, "Qg==");
659    }
660
661    #[test]
662    fn extract_streams_close_tag_ordering() {
663        // "X" = WA==
664        let xml = r#"<rsp:Stream Name="stdout">WA==</Stream>extra</rsp:Stream>"#;
665        let streams = extract_streams(xml);
666        assert_eq!(streams.len(), 1);
667        assert_eq!(streams[0].1, "WA==");
668    }
669
670    #[test]
671    fn extract_streams_adjacent_streams_search_from_advance() {
672        // "X" = WA==, "Y" = WQ==
673        let xml = r#"<rsp:Stream Name="stdout">WA==</rsp:Stream><rsp:Stream Name="stderr">WQ==</rsp:Stream>"#;
674        let streams = extract_streams(xml);
675        assert_eq!(streams.len(), 2, "both adjacent streams must be found");
676        assert_eq!(streams[0].0, "stdout");
677        assert_eq!(streams[0].1, "WA==");
678        assert_eq!(streams[1].0, "stderr");
679        assert_eq!(streams[1].1, "WQ==");
680    }
681
682    #[test]
683    fn extract_streams_three_adjacent_with_content() {
684        // "A" = QQ==, "B" = Qg==, "C" = Qw==
685        let xml = r#"<rsp:Stream Name="stdout">QQ==</rsp:Stream><rsp:Stream Name="stderr">Qg==</rsp:Stream><rsp:Stream Name="stdout">Qw==</rsp:Stream>"#;
686        let streams = extract_streams(xml);
687        assert_eq!(streams.len(), 3, "all three adjacent streams must be found");
688        assert_eq!(streams[0].1, "QQ==");
689        assert_eq!(streams[1].1, "Qg==");
690        assert_eq!(streams[2].1, "Qw==");
691    }
692
693    #[test]
694    fn extract_element_text_empty_then_filled_tight() {
695        let xml = "<rsp:ShellId></rsp:ShellId><rsp:ShellId>OK</rsp:ShellId>";
696        assert_eq!(parse_shell_id(xml).unwrap(), "OK");
697    }
698
699    #[test]
700    fn parse_receive_output_stream_at_xml_end() {
701        // "Z" = Wg==
702        let xml = r#"<s:Envelope><s:Body><rsp:ReceiveResponse><rsp:Stream Name="stdout" CommandId="C1">Wg==</rsp:Stream><rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done"><rsp:ExitCode>0</rsp:ExitCode></rsp:CommandState></rsp:ReceiveResponse></s:Body></s:Envelope>"#;
703        let output = parse_receive_output(xml).unwrap();
704        assert_eq!(output.stdout, b"Z");
705        assert_eq!(output.exit_code, Some(0));
706        assert!(output.done);
707    }
708
709    // --- Phase 8 tests ---
710
711    #[test]
712    fn extract_streams_long_content_with_second_stream() {
713        let long_b64 = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"; // 40 chars
714        let xml = format!(
715            r#"<rsp:Stream Name="stdout">{long_b64}</rsp:Stream><rsp:Stream Name="stderr">ZXJy</rsp:Stream>"#
716        );
717        let streams = extract_streams(&xml);
718        assert_eq!(streams.len(), 2, "must find both streams with long content");
719        assert_eq!(streams[0].0, "stdout");
720        assert_eq!(streams[0].1, long_b64);
721        assert_eq!(streams[1].0, "stderr");
722        assert_eq!(streams[1].1, "ZXJy");
723    }
724
725    #[test]
726    fn parse_receive_output_long_stdout_with_stderr() {
727        let long_b64 = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB";
728        let xml = format!(
729            r#"<s:Envelope><s:Body><rsp:ReceiveResponse>
730                <rsp:Stream Name="stdout" CommandId="C1">{long_b64}</rsp:Stream>
731                <rsp:Stream Name="stderr" CommandId="C1">ZXJy</rsp:Stream>
732                <rsp:CommandState CommandId="C1" State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
733                    <rsp:ExitCode>0</rsp:ExitCode>
734                </rsp:CommandState>
735            </rsp:ReceiveResponse></s:Body></s:Envelope>"#
736        );
737        let output = parse_receive_output(&xml).unwrap();
738        assert_eq!(output.stdout.len(), 30, "should decode 30 bytes of stdout");
739        assert_eq!(output.stderr, b"err", "should decode stderr correctly");
740        assert_eq!(output.exit_code, Some(0));
741    }
742
743    #[test]
744    fn parse_clixml_basic_error() {
745        let input = b"#< CLIXML\r\n<Objs Version=\"1.1.0.1\" xmlns=\"http://schemas.microsoft.com/powershell/2004/04\"><S S=\"Error\">Something went wrong</S></Objs>";
746        let result = parse_clixml(input);
747        assert_eq!(String::from_utf8_lossy(&result), "Something went wrong");
748    }
749
750    #[test]
751    fn parse_clixml_escaped_newlines() {
752        let input = b"#< CLIXML\r\n<Objs><S S=\"Error\">line1_x000D__x000A_line2</S></Objs>";
753        let result = parse_clixml(input);
754        assert_eq!(String::from_utf8_lossy(&result), "line1\r\nline2");
755    }
756
757    #[test]
758    fn parse_clixml_multiple_errors() {
759        let input = b"#< CLIXML\r\n<Objs><S S=\"Error\">err1</S><S S=\"Error\">err2</S></Objs>";
760        let result = parse_clixml(input);
761        assert_eq!(String::from_utf8_lossy(&result), "err1err2");
762    }
763
764    #[test]
765    fn parse_clixml_not_clixml_passthrough() {
766        // Non-CLIXML data should return empty (no <S S="Error"> tags)
767        let input = b"plain error text without CLIXML";
768        let result = parse_clixml(input);
769        assert!(result.is_empty());
770    }
771
772    #[test]
773    fn parse_receive_output_clixml_stderr() {
774        use base64::Engine;
775        use base64::engine::general_purpose::STANDARD as B64;
776
777        let clixml = b"#< CLIXML\r\n<Objs><S S=\"Error\">PowerShell error_x000D__x000A_</S></Objs>";
778        let encoded = B64.encode(clixml);
779        let xml = format!(
780            r#"<rsp:ReceiveResponse><rsp:Stream Name="stderr">{encoded}</rsp:Stream><rsp:CommandState State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done"><rsp:ExitCode>1</rsp:ExitCode></rsp:CommandState></rsp:ReceiveResponse>"#
781        );
782        let output = parse_receive_output(&xml).unwrap();
783        assert_eq!(
784            String::from_utf8_lossy(&output.stderr),
785            "PowerShell error\r\n"
786        );
787        assert_eq!(output.exit_code, Some(1));
788    }
789
790    // Kills extract_subcode_value returning None
791    #[test]
792    fn extract_subcode_from_soap_fault() {
793        let xml = r"<s:Fault>
794            <s:Code><s:Value>s:Sender</s:Value>
795            <s:Subcode><s:Value>wsa:DestinationUnreachable</s:Value></s:Subcode></s:Code>
796            <s:Reason><s:Text>no route</s:Text></s:Reason></s:Fault>";
797        let val = extract_subcode_value(xml);
798        assert_eq!(val.as_deref(), Some("wsa:DestinationUnreachable"));
799    }
800
801    // Kills extract_element_text:180 — < → <= on search_from
802    #[test]
803    fn extract_element_text_exact_boundary() {
804        // Element at the very end of the string
805        let xml = "<Root><Name>val</Name></Root>";
806        assert_eq!(extract_element_text(xml, "Name").as_deref(), Some("val"));
807    }
808
809    // Kills extract_streams:227 — < → <= on search_from
810    #[test]
811    fn extract_streams_at_string_boundary() {
812        // Streams that extend to the end of the XML
813        let xml = r#"<rsp:Stream Name="stdout">YWJD</rsp:Stream>"#;
814        let streams = extract_streams(xml);
815        assert_eq!(streams.len(), 1);
816        assert_eq!(streams[0].0, "stdout");
817        assert_eq!(streams[0].1, "YWJD");
818    }
819
820    // Tests parse_enumerate_response with and without continuation context
821    #[test]
822    fn parse_enumerate_response_with_items_and_end() {
823        let xml = r#"<s:Envelope xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
824            <s:Body><wsen:EnumerateResponse>
825                <wsen:Items><data>hello</data></wsen:Items>
826                <wsen:EndOfSequence/>
827            </wsen:EnumerateResponse></s:Body></s:Envelope>"#;
828        let (items, context) = parse_enumerate_response(xml).unwrap();
829        assert!(items.contains("hello"));
830        assert!(context.is_none(), "EndOfSequence means no continuation");
831    }
832
833    #[test]
834    fn parse_enumerate_response_with_continuation() {
835        let xml = r#"<s:Envelope xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
836            <s:Body><wsen:EnumerateResponse>
837                <wsen:Items><data>page1</data></wsen:Items>
838                <wsen:EnumerationContext>ctx-123</wsen:EnumerationContext>
839            </wsen:EnumerateResponse></s:Body></s:Envelope>"#;
840        let (items, context) = parse_enumerate_response(xml).unwrap();
841        assert!(items.contains("page1"));
842        assert_eq!(context.as_deref(), Some("ctx-123"));
843    }
844
845    // parse_clixml with multiple error fragments to test offset arithmetic
846    #[test]
847    fn parse_clixml_consecutive_error_fragments() {
848        let clixml = b"#< CLIXML\r\n<Objs><S S=\"Error\">aaa</S><S S=\"Error\">bbb</S></Objs>";
849        let result = parse_clixml(clixml);
850        assert_eq!(String::from_utf8_lossy(&result), "aaabbb");
851    }
852
853    #[test]
854    fn parse_command_id_missing_returns_missing_element() {
855        let xml = r"<s:Envelope><s:Body><something/></s:Body></s:Envelope>";
856        let err = parse_command_id(xml).unwrap_err();
857        assert!(matches!(err, SoapError::MissingElement(_)));
858    }
859
860    #[test]
861    fn extract_soap_fault_falls_back_to_faultstring() {
862        // Older SOAP 1.1 style fault uses <faultcode>/<faultstring>.
863        let xml = r"<s:Envelope><s:Body><s:Fault>
864            <faultcode>soap:Server</faultcode>
865            <faultstring>old style</faultstring>
866        </s:Fault></s:Body></s:Envelope>";
867        let fault = extract_soap_fault(xml).expect("fault parsed");
868        match fault {
869            SoapError::Fault { code, reason } => {
870                assert_eq!(code, "soap:Server");
871                assert_eq!(reason, "old style");
872            }
873            _ => panic!("expected Fault variant"),
874        }
875    }
876
877    #[test]
878    fn extract_soap_fault_returns_none_when_absent() {
879        let xml = "<s:Envelope><s:Body><something/></s:Body></s:Envelope>";
880        assert!(extract_soap_fault(xml).is_none());
881    }
882
883    #[test]
884    fn extract_subcode_value_empty_returns_none() {
885        // Subcode element exists but Value is empty.
886        let xml = r"<s:Subcode><s:Value></s:Value></s:Subcode>";
887        assert!(extract_subcode_value(xml).is_none());
888    }
889
890    #[test]
891    fn parse_enumerate_response_handles_empty_items() {
892        let xml = r#"<s:Envelope xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
893            <s:Body><wsen:EnumerateResponse>
894                <wsen:EndOfSequence/>
895            </wsen:EnumerateResponse></s:Body></s:Envelope>"#;
896        let (items, context) = parse_enumerate_response(xml).unwrap();
897        assert!(items.is_empty(), "no <Items> tag should yield empty");
898        assert!(context.is_none());
899    }
900}