use crate::error::{MetadataError, MetadataResult, SoapFault};
use quick_xml::Reader;
use quick_xml::Writer;
use quick_xml::events::{BytesText, Event};
const SOAP_NS: &str = "http://schemas.xmlsoap.org/soap/envelope/";
const METADATA_NS: &str = "http://soap.sforce.com/2006/04/metadata";
const XSI_NS: &str = "http://www.w3.org/2001/XMLSchema-instance";
#[derive(Debug)]
pub(crate) enum EnvelopeBody {
Success(Vec<u8>),
Fault(SoapFault),
}
pub(crate) fn build_envelope(session_token: &str, operation_name: &str, body_xml: &str) -> String {
let token = xml_escape(session_token);
format!(
concat!(
r#"<?xml version="1.0" encoding="UTF-8"?>"#,
r#"<soapenv:Envelope xmlns:soapenv="{soap}" xmlns:met="{met}" xmlns:xsi="{xsi}">"#,
r#"<soapenv:Header>"#,
r#"<met:SessionHeader><met:sessionId>{token}</met:sessionId></met:SessionHeader>"#,
r#"</soapenv:Header>"#,
r#"<soapenv:Body><met:{op}>{body}</met:{op}></soapenv:Body>"#,
r#"</soapenv:Envelope>"#,
),
soap = SOAP_NS,
met = METADATA_NS,
xsi = XSI_NS,
token = token,
op = operation_name,
body = body_xml,
)
}
pub(crate) fn parse_envelope(
xml: &[u8],
expected_response_local_name: &str,
) -> MetadataResult<EnvelopeBody> {
let s = std::str::from_utf8(xml)
.map_err(|e| MetadataError::InvalidResponse(format!("response is not valid UTF-8: {e}")))?;
let mut reader = Reader::from_str(s);
reader.config_mut().trim_text(true);
loop {
match reader.read_event()? {
Event::Start(e) if e.name().local_name().as_ref() == b"Body" => {
return parse_body(&mut reader, expected_response_local_name);
}
Event::Eof => {
return Err(MetadataError::InvalidResponse(
"SOAP envelope missing <Body>".into(),
));
}
_ => {}
}
}
}
fn parse_body(
reader: &mut Reader<&[u8]>,
expected_response_local_name: &str,
) -> MetadataResult<EnvelopeBody> {
loop {
match reader.read_event()? {
Event::Start(e) => {
let local = e.name().local_name();
if local.as_ref() == b"Fault" {
return parse_fault(reader).map(EnvelopeBody::Fault);
}
if local.as_ref() == expected_response_local_name.as_bytes() {
let owned = e.into_owned();
let mut buf = Vec::new();
collect_element(reader, owned, &mut buf)?;
return Ok(EnvelopeBody::Success(buf));
}
return Err(MetadataError::InvalidResponse(format!(
"unexpected <Body> child <{}>: expected <{}> or <Fault>",
String::from_utf8_lossy(local.as_ref()),
expected_response_local_name,
)));
}
Event::Empty(e) => {
let local = e.name().local_name();
if local.as_ref() == expected_response_local_name.as_bytes() {
let owned = e.into_owned();
let mut buf = Vec::new();
{
let mut writer = Writer::new(&mut buf);
writer.write_event(Event::Empty(owned))?;
}
return Ok(EnvelopeBody::Success(buf));
}
return Err(MetadataError::InvalidResponse(format!(
"unexpected empty <Body> child <{}/>: expected <{}> or <Fault>",
String::from_utf8_lossy(local.as_ref()),
expected_response_local_name,
)));
}
Event::Eof => {
return Err(MetadataError::InvalidResponse("empty SOAP <Body>".into()));
}
_ => {}
}
}
}
fn collect_element(
reader: &mut Reader<&[u8]>,
start: quick_xml::events::BytesStart<'static>,
out: &mut Vec<u8>,
) -> MetadataResult<()> {
let mut writer = Writer::new(out);
let end = start.to_end().into_owned();
writer.write_event(Event::Start(start))?;
let mut depth: i32 = 1;
loop {
match reader.read_event()? {
Event::Start(e) => {
depth += 1;
writer.write_event(Event::Start(e))?;
}
Event::End(e) => {
depth -= 1;
if depth == 0 {
writer.write_event(Event::End(end))?;
return Ok(());
}
writer.write_event(Event::End(e))?;
}
Event::Empty(e) => {
writer.write_event(Event::Empty(e))?;
}
Event::Text(e) => {
writer.write_event(Event::Text(e))?;
}
Event::CData(e) => {
writer.write_event(Event::CData(e))?;
}
Event::Comment(e) => {
writer.write_event(Event::Comment(e))?;
}
Event::Eof => {
return Err(MetadataError::InvalidResponse(
"unexpected EOF inside response element".into(),
));
}
_ => {}
}
}
}
fn parse_fault(reader: &mut Reader<&[u8]>) -> MetadataResult<SoapFault> {
let mut faultcode = String::new();
let mut faultstring = String::new();
#[derive(Clone, Copy)]
enum Field {
Code,
String_,
}
let mut field: Option<Field> = None;
let mut depth: i32 = 1;
let mut tracked_child_depth: i32 = 0;
loop {
match reader.read_event()? {
Event::Start(e) => {
depth += 1;
if depth == 2 {
field = match e.name().local_name().as_ref() {
b"faultcode" => Some(Field::Code),
b"faultstring" => Some(Field::String_),
_ => None,
};
if field.is_some() {
tracked_child_depth = depth;
}
}
}
Event::Text(t) => {
if depth == tracked_child_depth
&& let Some(f) = field
{
let s = unescape_text(&t)?;
match f {
Field::Code => faultcode.push_str(&s),
Field::String_ => faultstring.push_str(&s),
}
}
}
Event::CData(c) => {
if depth == tracked_child_depth
&& let Some(f) = field
{
let bytes = c.into_inner();
let s = std::str::from_utf8(&bytes).map_err(|e| {
MetadataError::InvalidResponse(format!(
"<Fault> CDATA contained invalid UTF-8: {e}"
))
})?;
match f {
Field::Code => faultcode.push_str(s),
Field::String_ => faultstring.push_str(s),
}
}
}
Event::End(_) => {
if depth == tracked_child_depth {
field = None;
tracked_child_depth = 0;
}
depth -= 1;
if depth == 0 {
return Ok(SoapFault {
faultcode,
faultstring,
});
}
}
Event::Eof => {
return Err(MetadataError::InvalidResponse(
"truncated <Fault>: EOF before closing tag".into(),
));
}
_ => {}
}
}
}
fn unescape_text(t: &BytesText<'_>) -> MetadataResult<String> {
Ok(t.unescape().map_err(MetadataError::from)?.into_owned())
}
pub(crate) fn xml_escape(s: &str) -> std::borrow::Cow<'_, str> {
if !s
.bytes()
.any(|b| matches!(b, b'<' | b'>' | b'&' | b'\'' | b'"'))
{
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
'\'' => out.push_str("'"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
std::borrow::Cow::Owned(out)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn build_envelope_round_trip_shape() {
let env = build_envelope("TOKEN", "ping", "<inner/>");
assert!(env.contains("xmlns:soapenv="));
assert!(env.contains("xmlns:met="));
assert!(env.contains("xmlns:xsi="));
assert!(env.contains("<met:sessionId>TOKEN</met:sessionId>"));
assert!(env.contains("<met:ping><inner/></met:ping>"));
}
#[test]
fn build_envelope_escapes_token() {
let env = build_envelope("a<b&c", "ping", "");
assert!(env.contains("a<b&c"));
assert!(!env.contains("a<b&c</"));
}
#[test]
fn parse_envelope_returns_success_with_wrapper() {
let xml = br#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns="http://soap.sforce.com/2006/04/metadata">
<soapenv:Body>
<pingResponse>
<result><msg>ok</msg></result>
</pingResponse>
</soapenv:Body>
</soapenv:Envelope>"#;
let body = parse_envelope(xml, "pingResponse").unwrap();
let EnvelopeBody::Success(bytes) = body else {
panic!("expected Success, got {body:?}");
};
let s = String::from_utf8(bytes).unwrap();
assert!(s.contains("<pingResponse>"));
assert!(s.contains("</pingResponse>"));
assert!(s.contains("<result>"));
assert!(s.contains("<msg>ok</msg>"));
}
#[test]
fn parse_envelope_returns_fault() {
let xml = br#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<soapenv:Fault>
<faultcode>sf:INVALID_SESSION_ID</faultcode>
<faultstring>INVALID_SESSION_ID: Session expired or invalid</faultstring>
</soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>"#;
let body = parse_envelope(xml, "pingResponse").unwrap();
let EnvelopeBody::Fault(f) = body else {
panic!("expected Fault, got {body:?}");
};
assert_eq!(f.faultcode, "sf:INVALID_SESSION_ID");
assert_eq!(f.code(), "INVALID_SESSION_ID");
assert!(f.faultstring.contains("Session expired"));
assert!(f.is_invalid_session());
}
#[test]
fn parse_envelope_ignores_fault_detail_structure() {
let xml = br#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<soapenv:Fault>
<faultcode>sf:INVALID_TYPE</faultcode>
<faultstring>no such metadata type</faultstring>
<detail>
<sf:fault xmlns:sf="urn:fault.metadata.soap.sforce.com">
<sf:exceptionCode>INVALID_TYPE</sf:exceptionCode>
<sf:exceptionMessage>...</sf:exceptionMessage>
</sf:fault>
</detail>
</soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>"#;
let body = parse_envelope(xml, "anything").unwrap();
let EnvelopeBody::Fault(f) = body else {
panic!("expected Fault");
};
assert_eq!(f.code(), "INVALID_TYPE");
assert_eq!(f.faultstring, "no such metadata type");
}
#[test]
fn parse_envelope_extracts_cdata_faultstring() {
let xml = br#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<soapenv:Fault>
<faultcode>sf:INVALID_SESSION_ID</faultcode>
<faultstring><![CDATA[INVALID_SESSION_ID: Session expired or invalid <token>]]></faultstring>
</soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>"#;
let body = parse_envelope(xml, "anything").unwrap();
let EnvelopeBody::Fault(f) = body else {
panic!("expected Fault, got {body:?}");
};
assert_eq!(f.code(), "INVALID_SESSION_ID");
assert!(f.faultstring.contains("Session expired"));
assert!(f.faultstring.contains("<token>"));
assert!(f.is_invalid_session());
}
#[test]
fn parse_envelope_errors_on_unexpected_body_child() {
let xml = br#"<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body><surpriseResponse/></Body>
</Envelope>"#;
let err = parse_envelope(xml, "pingResponse").unwrap_err();
assert!(matches!(err, MetadataError::InvalidResponse(_)));
assert!(err.to_string().contains("surpriseResponse"));
}
#[test]
fn parse_envelope_rejects_truncated_fault() {
let xml = br#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<soapenv:Fault>
<faultcode>sf:INVALID_SESSION_ID</faultcode>
"#;
let err = parse_envelope(xml, "ignored").unwrap_err();
assert!(matches!(err, MetadataError::InvalidResponse(_)));
assert!(err.to_string().contains("truncated"));
}
#[test]
fn parse_envelope_preserves_nested_response_content() {
let xml = br#"<?xml version="1.0"?>
<E xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<listMetadataResponse>
<result><type>ApexClass</type><fullName>Foo</fullName></result>
<result><type>ApexClass</type><fullName>Bar</fullName></result>
</listMetadataResponse>
</Body>
</E>"#;
let body = parse_envelope(xml, "listMetadataResponse").unwrap();
let EnvelopeBody::Success(bytes) = body else {
panic!("expected Success");
};
let s = String::from_utf8(bytes).unwrap();
assert!(s.contains("<listMetadataResponse>"));
assert_eq!(s.matches("<result>").count(), 2);
assert!(s.contains("<fullName>Foo</fullName>"));
assert!(s.contains("<fullName>Bar</fullName>"));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod property_tests {
use super::*;
use proptest::prelude::*;
fn xml_text() -> impl Strategy<Value = String> {
proptest::collection::vec(
prop_oneof![
Just('<'),
Just('>'),
Just('&'),
Just('\''),
Just('"'),
Just(' '),
Just('\t'),
Just('\n'),
Just('\r'),
"[!#$%()*+,\\-./0-9:;=?@A-Z\\[\\]^_`a-z{|}~]"
.prop_map(|s| s.chars().next().unwrap_or('a')),
"[\\u{00A0}-\\u{024F}]".prop_map(|s| s.chars().next().unwrap_or('a')),
"[\\u{0370}-\\u{D7FF}]".prop_map(|s| s.chars().next().unwrap_or('a')),
],
0..32,
)
.prop_map(|chars| chars.into_iter().collect())
}
proptest! {
#[test]
fn xml_escape_round_trips_through_quick_xml(s in xml_text()) {
let escaped = xml_escape(&s);
let doc = format!("<x>{escaped}</x>");
let mut reader = quick_xml::Reader::from_str(&doc);
let mut got = String::new();
loop {
match reader.read_event() {
Ok(quick_xml::events::Event::Text(t)) => {
got.push_str(&t.unescape().unwrap());
}
Ok(quick_xml::events::Event::Eof) => break,
Ok(_) => {}
Err(e) => prop_assert!(false, "parser rejected escaped doc: {e} (input={s:?}, escaped={escaped:?})"),
}
}
prop_assert_eq!(&got, &s);
}
#[test]
fn xml_escape_is_borrow_when_no_metachars(s in "[A-Za-z0-9_./:-]{0,32}") {
let escaped = xml_escape(&s);
prop_assert!(
matches!(escaped, std::borrow::Cow::Borrowed(_)),
"expected Borrowed for metachar-free input {s:?}",
);
prop_assert_eq!(escaped.as_ref(), s.as_str());
}
}
fn fault_body() -> impl Strategy<Value = String> {
let safe_text = "[A-Za-z0-9 ._:-]{0,32}";
let element_name = "[a-z][a-z0-9_]{0,8}";
let fragment = prop_oneof![
safe_text.prop_map(|t| format!("<faultcode>{t}</faultcode>")),
safe_text.prop_map(|t| format!("<faultstring>{t}</faultstring>")),
safe_text.prop_map(|t| format!("<faultstring><![CDATA[{t}]]></faultstring>")),
(element_name, safe_text)
.prop_map(|(n, t)| { format!("<detail><{n}>{t}</{n}></detail>") }),
safe_text.prop_map(|t| format!("<faultactor>{t}</faultactor>")),
"[ \\t\\n]{0,4}".prop_map(|s| s),
];
proptest::collection::vec(fragment, 0..6).prop_map(|frags| frags.concat())
}
proptest! {
#[test]
fn parse_fault_never_panics_on_shaped_input(body in fault_body()) {
let envelope = format!(
r#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<soapenv:Fault>{body}</soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>"#
);
match parse_envelope(envelope.as_bytes(), "anyResponse") {
Ok(EnvelopeBody::Fault(_)) => {} Ok(EnvelopeBody::Success(_)) => {
prop_assert!(false, "Fault body parsed as Success: {body}");
}
Err(MetadataError::InvalidResponse(_)) => {} Err(e) => prop_assert!(
false,
"unexpected error kind from parse_envelope: {e:?} on body {body:?}",
),
}
}
}
}