1use super::namespaces::COMMAND_STATE_DONE;
4use crate::error::SoapError;
5
6pub struct ReceiveOutput {
12 pub stdout: Vec<u8>,
14 pub stderr: Vec<u8>,
16 pub exit_code: Option<i32>,
18 pub done: bool,
21}
22
23#[allow(unreachable_pub)] pub 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#[allow(unreachable_pub)] pub 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#[allow(unreachable_pub)] pub 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 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 if xml.contains(COMMAND_STATE_DONE) {
79 done = true;
80 }
81
82 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 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
113pub(crate) fn parse_enumerate_response(xml: &str) -> Result<(String, Option<String>), SoapError> {
118 check_soap_fault(xml)?;
119
120 let items = extract_element_text(xml, "Items").unwrap_or_default();
122
123 let context = extract_element_text(xml, "EnumerationContext");
125
126 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#[allow(unreachable_pub)] pub 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
146fn parse_clixml(data: &[u8]) -> Vec<u8> {
152 let text = String::from_utf8_lossy(data);
153 let mut result = String::new();
154
155 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 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
181fn extract_element_text(xml: &str, element: &str) -> Option<String> {
188 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 let (tag_content_start, ns_prefix) = if let Some(pos) = region.find(&suffixed) {
198 let abs_pos = search_from + pos;
200 let before = &xml[..abs_pos];
201 let lt = before.rfind('<')?;
202 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 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
233fn 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 let Some(tag_end) = tag_region.find('>') else {
258 break;
259 };
260 let opening_tag = &tag_region[..tag_end];
261
262 let name = extract_attribute(opening_tag, "Name").unwrap_or_default();
264
265 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
288fn 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
296fn extract_soap_fault(xml: &str) -> Option<SoapError> {
298 let has_fault = xml.contains(":Fault>") || xml.contains("<Fault>");
300 if !has_fault {
301 return None;
302 }
303
304 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
319fn extract_subcode_value(xml: &str) -> Option<String> {
322 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 #[test]
712 fn extract_streams_long_content_with_second_stream() {
713 let long_b64 = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"; 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 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 #[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 #[test]
803 fn extract_element_text_exact_boundary() {
804 let xml = "<Root><Name>val</Name></Root>";
806 assert_eq!(extract_element_text(xml, "Name").as_deref(), Some("val"));
807 }
808
809 #[test]
811 fn extract_streams_at_string_boundary() {
812 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 #[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 #[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 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 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}