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