Add craft-ews: the EWS SOAP layer from MailCraft (#988)

MailCraft crates/ews at a44a450: the request envelope and the escaping
element builder (soap.rs), and the judging of an answer's envelope, faults
and response messages (reply.rs, the response codes of error.rs), now read
with craft-xml in place of MailCraft's own XML reader. The operations, HTTP,
Autodiscover, authentication and sync stay in MailCraft.

Co-authored-by: Martin Ball <98639962+benandez@users.noreply.github.com>
This commit is contained in:
Dylan M. Taylor
2026-11-11 19:46:19 -05:00
committed by GitHub
co-authored by Martin Ball
parent be7fafd14f
commit 977208a013
10 changed files with 890 additions and 0 deletions
Generated
+8
View File
@@ -1030,6 +1030,14 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-ews"
version = "0.1.0"
dependencies = [
"craft-xml",
"proptest",
]
[[package]]
name = "craft-exr"
version = "0.1.0"
+1
View File
@@ -114,6 +114,7 @@ craft-xml = { path = "crates/xml" }
craft-zip = { path = "crates/zip" }
craft-y4m = { path = "crates/y4m" }
craft-epub = { path = "crates/epub" }
craft-ews = { path = "crates/ews" }
craft-video = { path = "crates/video" }
craft-web-api = { path = "crates/web-api" }
craft-window = { path = "crates/window" }
+4
View File
@@ -89,6 +89,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
and message, images, and the vector network and geometry blobs. Bounded on hostile input.
Carved from FigCraft.
- [`craft-epub`](crates/epub/README.md): EPUB packages: the OCF ZIP container, the package document (metadata, manifest, spine) and the navigation document, written as EPUB 3 (reflowable and fixed layout) and read (EPUB 2 and 3) with bounded entry sizes. Content documents are the caller's.
- [`craft-ews`](crates/ews/README.md): the SOAP layer of Exchange Web Services, from MailCraft, without
sockets: the request envelope and an escaping element builder, and the answer envelope, faults
and response messages with their codes, read with `craft-xml`. The operations and everything
over HTTP stay in each app.
- [`craft-exr`](crates/exr/src/lib.rs): OpenEXR pieces shared by PhotoCraft and EffectCraft: layer
and channel names, the channels that make a colour picture, and Cryptomatte IDs, metadata keys,
manifests and rank channels. Takes names and attribute text; decoding is not part of it yet.
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "craft-ews"
description = "Exchange Web Services SOAP layer without sockets: the request envelope and an escaping element builder, and the answer envelope, faults and response messages read with craft-xml."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-xml = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+40
View File
@@ -0,0 +1,40 @@
# craft-ews
The SOAP layer of Exchange Web Services, without sockets.
- **Requests**: `Element` builds an operation's elements (`Element::new("m:GetFolder").child(…)`)
and escapes everything it is given: attribute values lose every control character, text keeps
its tabs and line feeds and writes a carriage return as `&#13;`, names keep only what a
qualified name is made of. `envelope(Version, &operation)` writes the SOAP 1.1 envelope with
`RequestServerVersion`.
- **Answers**: `Answer::parse` reads the envelope with `craft-xml`. A fault is the error it names;
`Answer::messages("GetFolder")` gives the `GetFolderResponseMessage`s with their
`ResponseClass` and `ResponseCode`, and `ResponseMessage::ok` the element or the error. A busy
server (`ErrorServerBusy`, or that code inside `ErrorInternalServerError`) gives its wait,
clamped to 1 s to 5 min.
Names in an answer carry fixed prefixes whatever the server used: `s:` envelope, `m:` messages,
`t:` types, `e:` errors.
Written from Microsoft's public documentation: the EWS reference, [MS-OXWSCDATA] and
[MS-OXWSCORE].
## Hostile input
A document type declaration, malformed markup, more than 40 levels, more than 100,000 nodes or
more than 64 MiB is `Error::Malformed`; an entity XML does not define is left out. Errors hold no
text a server wrote except a response code that is a short identifier (64 letters and digits at
most). `Debug` output of an answer or a message shows nothing a server chose.
## What stays in the apps
HTTP and its status codes, Autodiscover, NTLM and Basic authentication, the operations (which
elements to send, what to make of what comes back), sync state and the mapping onto a mailbox.
## Origin
MailCraft `crates/ews` (`soap.rs`, `reply.rs`, the response codes of `error.rs`, the escaping of
`xml.rs`) at
[a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5),
by Martin Ball and the MailCraft contributors (MIT OR Apache-2.0), with the tests of that layer.
MailCraft's own bounded XML reader (`xml.rs`) is replaced by `craft-xml`.
+210
View File
@@ -0,0 +1,210 @@
//! Reading an answer: the SOAP envelope, a fault, and the response messages of an operation with
//! their classes and codes.
//!
//! The answer is read with `craft-xml` into a tree whose names carry fixed prefixes whatever the
//! document used: `s:` for the envelope ([`SOAP`]), `m:` for the messages ([`MESSAGES`]), `t:` for
//! the types ([`TYPES`]), `e:` for the errors ([`ERRORS`]); a name in any other namespace starts
//! with `?:`. Elements are found by namespace and local name, never by the document's prefix;
//! everything else in an answer (an element not asked about, a header, a remark) is skipped.
use std::fmt;
use craft_xml::{Canonical, Doctype, El, Limits, Local, Options, UnknownEntity};
use crate::codes::{DEFAULT_BACK_OFF_MS, MAX_BACK_OFF_MS, MIN_BACK_OFF_MS, ResponseClass, ResponseCode};
use crate::soap::{ERRORS, MESSAGES, SOAP, TYPES};
/// Deepest the elements of an answer may nest.
pub const MAX_DEPTH: usize = 40;
/// Most nodes (elements and stretches of text) in one answer.
pub const MAX_NODES: usize = 100_000;
/// Most bytes of an answer: a `GetItem` answer carries whole messages in base64.
pub const MAX_BYTES: usize = 64 * 1024 * 1024;
const NAMESPACES: [(&str, &str); 4] = [("s", SOAP), ("m", MESSAGES), ("t", TYPES), ("e", ERRORS)];
const CANONICAL: Canonical<'static> = Canonical::new(&NAMESPACES);
/// Why an answer says nothing an operation can use.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Error {
/// Not XML, or XML past the limits, or with a document type declaration.
Malformed,
/// XML, but not the envelope or the answer that was asked for.
Unexpected,
/// The server is busy: ask again after this many milliseconds.
Busy { back_off_ms: u32 },
/// The server said no, with this code.
Server(ResponseCode),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Malformed => f.write_str("the answer is not well-formed XML within the limits"),
Error::Unexpected => f.write_str("the answer is not the one asked for"),
Error::Busy { back_off_ms } => write!(f, "the server is busy: ask again in {back_off_ms} ms"),
Error::Server(code) => write!(f, "the server answered {code}"),
}
}
}
impl std::error::Error for Error {}
/// Read `bytes` into a tree with the fixed prefixes (see the module's documentation). No document
/// type declaration is read, and a reference to an entity XML does not define is left out.
pub fn parse_xml(bytes: &[u8]) -> Result<El, Error> {
if bytes.len() > MAX_BYTES {
return Err(Error::Malformed);
}
let limits = Limits { max_input: MAX_BYTES, max_depth: MAX_DEPTH, max_nodes: MAX_NODES, ..Limits::DEFAULT };
let options =
Options { doctype: Doctype::Reject, unknown_entity: UnknownEntity::Drop, ..Options::canonical(&CANONICAL).strict().with_limits(limits) };
options.parse(bytes).map_err(|_| Error::Malformed)
}
/// The body of a SOAP answer that was not a fault.
pub struct Answer {
body: El,
}
impl fmt::Debug for Answer {
/// Nothing of the answer: it may hold someone's mail.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Answer(..)")
}
}
/// One response message of an operation's answer.
pub struct ResponseMessage<'a> {
class: ResponseClass,
code: ResponseCode,
/// The server says it is busy (`ErrorServerBusy`, or inside `ErrorInternalServerError`).
busy: bool,
back_off_ms: Option<u32>,
el: &'a El,
}
impl fmt::Debug for ResponseMessage<'_> {
/// The class and the code: the rest is the server's.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ResponseMessage").field("class", &self.class).field("code", &self.code).finish()
}
}
impl Answer {
/// Reads the document `bytes`: a SOAP envelope whose body is not a fault. A fault is the
/// error it names.
pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
let root = parse_xml(bytes)?;
if root.name != "s:Envelope" {
return Err(Error::Unexpected);
}
let body = root.child("s:Body").ok_or(Error::Unexpected)?;
if let Some(fault) = body.child("s:Fault") {
return Err(read_fault(fault));
}
Ok(Self { body: body.clone() })
}
/// The `s:Body` element.
pub fn body(&self) -> &El {
&self.body
}
/// The busy error, when any response message of the answer says the server is busy: it is
/// the whole request that is to be made again, whatever the operation, and a busy server
/// answers one message however many questions there were.
pub fn busy(&self) -> Option<Error> {
let response = self.body.elements().next()?;
let all = response.child("m:ResponseMessages")?;
all.elements().find_map(|child| ResponseMessage::read(child).ok()?.ok().err().filter(|error| matches!(error, Error::Busy { .. })))
}
/// The response messages of the answer to `operation` (`GetFolder`: the
/// `GetFolderResponseMessage`s inside `GetFolderResponse`), in order.
pub fn messages(&self, operation: &str) -> Result<Vec<ResponseMessage<'_>>, Error> {
let response = self.body.child(format!("m:{operation}Response").as_str()).ok_or(Error::Unexpected)?;
let all = response.child("m:ResponseMessages").ok_or(Error::Unexpected)?;
let name = format!("m:{operation}ResponseMessage");
all.elements().filter(|child| child.name == name).map(ResponseMessage::read).collect()
}
}
impl<'a> ResponseMessage<'a> {
fn read(el: &'a El) -> Result<Self, Error> {
let class = el.attr("ResponseClass").and_then(ResponseClass::from_name).ok_or(Error::Unexpected)?;
let code = el.child("m:ResponseCode").map(|code| ResponseCode::from_name(&code.text())).ok_or(Error::Unexpected)?;
// (`MessageXml` and its values are told by their local names: a server puts them in the
// messages' namespace or the types'.)
let wait = el.child(Local("BackOffMilliseconds")).map(|wait| wait.text());
let (busy, back_off_ms) = busy_of(&code, el.child(Local("MessageXml")), wait.as_deref());
Ok(Self { class, code, busy, back_off_ms, el })
}
/// How the server says it went.
pub fn class(&self) -> ResponseClass {
self.class
}
/// The code the server gave.
pub fn code(&self) -> &ResponseCode {
&self.code
}
/// The element, for whoever reads what it holds.
pub fn el(&self) -> &'a El {
self.el
}
/// The element when the message says it was done; the error it names when it was not.
pub fn ok(&self) -> Result<&'a El, Error> {
if self.class != ResponseClass::Error {
return Ok(self.el);
}
Err(if self.busy { Error::Busy { back_off_ms: self.back_off_ms.unwrap_or(DEFAULT_BACK_OFF_MS) } } else { Error::Server(self.code.clone()) })
}
}
/// The text of the `Value` called `name` inside a `MessageXml`.
fn value_of(message_xml: &El, name: &str) -> Option<String> {
message_xml.children(Local("Value")).find(|value| value.attr("Name") == Some(name)).map(|value| value.text().trim().to_string())
}
/// Whether a message with this `code` and `MessageXml` says the server is busy, and the wait
/// it asks for. Busy is `ErrorServerBusy`, and also `ErrorInternalServerError` whose inner
/// error is `ErrorServerBusy` (Exchange's throttling says so in a 200 answer). The wait is a
/// `BackOffMilliseconds` of its own or a `Value` of that name.
fn busy_of(code: &ResponseCode, message_xml: Option<&El>, direct: Option<&str>) -> (bool, Option<u32>) {
let inner = message_xml.and_then(|xml| value_of(xml, "InnerErrorResponseCode")).map(|c| ResponseCode::from_name(&c));
let busy = *code == ResponseCode::ServerBusy || (*code == ResponseCode::InternalServerError && inner == Some(ResponseCode::ServerBusy));
let wait = direct.map(back_off_ms).or_else(|| message_xml.and_then(|xml| value_of(xml, "BackOffMilliseconds")).map(|w| back_off_ms(&w)));
(busy, wait)
}
/// A wait in milliseconds, as a server writes it: digits, at least [`MIN_BACK_OFF_MS`] and at
/// most [`MAX_BACK_OFF_MS`]; anything else is the default.
pub fn back_off_ms(text: &str) -> u32 {
let text = text.trim();
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
return DEFAULT_BACK_OFF_MS;
}
// (A number of any length: too many digits is simply the most.)
let ms = text.parse::<u64>().unwrap_or(u64::MAX);
u32::try_from(ms.clamp(u64::from(MIN_BACK_OFF_MS), u64::from(MAX_BACK_OFF_MS))).unwrap_or(MAX_BACK_OFF_MS)
}
/// The error a `Fault` names: the response code of its `detail`, and for a busy server the wait.
/// (`ResponseCode` and `MessageXml` are told by their local names, and `Value` too: the service
/// writes `MessageXml` in the types' namespace as often as the errors'.)
fn read_fault(fault: &El) -> Error {
let detail = fault.child(Local("detail"));
let code = detail
.and_then(|detail| detail.child(Local("ResponseCode")))
.map_or_else(|| ResponseCode::Other(String::new()), |code| ResponseCode::from_name(&code.text()));
let (busy, wait) = busy_of(&code, detail.and_then(|detail| detail.child(Local("MessageXml"))), None);
if busy { Error::Busy { back_off_ms: wait.unwrap_or(DEFAULT_BACK_OFF_MS) } } else { Error::Server(code) }
}
#[cfg(test)]
#[path = "answer_tests.rs"]
mod tests;
+234
View File
@@ -0,0 +1,234 @@
//! Answers and requests, from the tests of the SOAP layer of the source.
use proptest::prelude::*;
use super::*;
use crate::codes::{DEFAULT_BACK_OFF_MS, MAX_BACK_OFF_MS, MIN_BACK_OFF_MS};
use crate::soap::{Element, Version, envelope, escape};
fn reply_with(code: &str, extra: &str) -> String {
format!(
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body><m:GetFolderResponse xmlns:m=\"http://schemas.microsoft.com/exchange/services/2006/messages\">\
<m:ResponseMessages><m:GetFolderResponseMessage ResponseClass=\"Error\"><m:ResponseCode>{code}</m:ResponseCode>{extra}</m:GetFolderResponseMessage></m:ResponseMessages>\
</m:GetFolderResponse></s:Body></s:Envelope>"
)
}
/// The error the first message of a `GetFolder` answer names.
fn folder_error(body: &str) -> Error {
let answer = Answer::parse(body.as_bytes()).unwrap();
answer.messages("GetFolder").unwrap()[0].ok().unwrap_err()
}
#[test]
fn a_busy_server_says_how_long_and_is_believed_only_so_far() {
assert_eq!(
folder_error(&reply_with("ErrorServerBusy", "<m:BackOffMilliseconds>soon</m:BackOffMilliseconds>")),
Error::Busy { back_off_ms: DEFAULT_BACK_OFF_MS }
);
assert_eq!(
folder_error(&reply_with("ErrorServerBusy", "<m:BackOffMilliseconds>7000</m:BackOffMilliseconds>")),
Error::Busy { back_off_ms: 7_000 }
);
assert_eq!(
folder_error(&reply_with("ErrorServerBusy", "<m:BackOffMilliseconds>9999999999999999999999</m:BackOffMilliseconds>")),
Error::Busy { back_off_ms: MAX_BACK_OFF_MS }
);
assert_eq!(folder_error(&reply_with("ErrorServerBusy", "")), Error::Busy { back_off_ms: DEFAULT_BACK_OFF_MS });
// A wait of nothing is a wait.
assert_eq!(
folder_error(&reply_with("ErrorServerBusy", "<m:BackOffMilliseconds>0</m:BackOffMilliseconds>")),
Error::Busy { back_off_ms: MIN_BACK_OFF_MS }
);
// The whole answer says so.
let answer = Answer::parse(reply_with("ErrorServerBusy", "<m:BackOffMilliseconds>7000</m:BackOffMilliseconds>").as_bytes()).unwrap();
assert_eq!(answer.busy(), Some(Error::Busy { back_off_ms: 7_000 }));
assert_eq!(Answer::parse(reply_with("ErrorAccessDenied", "").as_bytes()).unwrap().busy(), None);
}
#[test]
fn a_fault_is_the_error_it_names() {
let fault = |code: &str| {
format!(
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body><s:Fault><faultcode>s:Client</faultcode><detail>\
<e:ResponseCode xmlns:e=\"http://schemas.microsoft.com/exchange/services/2006/errors\">{code}</e:ResponseCode></detail></s:Fault></s:Body></s:Envelope>"
)
};
assert_eq!(Answer::parse(fault("ErrorSchemaValidation").as_bytes()).unwrap_err(), Error::Server(ResponseCode::SchemaValidation));
assert_eq!(Answer::parse(fault("ErrorInvalidRequest").as_bytes()).unwrap_err(), Error::Server(ResponseCode::InvalidRequest));
assert_eq!(Answer::parse(fault("not a code").as_bytes()).unwrap_err(), Error::Server(ResponseCode::Other(String::new())));
}
#[test]
fn prefixes_are_the_documents_own_choice() {
let body = "<a:Envelope xmlns:a=\"http://schemas.xmlsoap.org/soap/envelope/\"><a:Body>\
<x:GetFolderResponse xmlns:x=\"http://schemas.microsoft.com/exchange/services/2006/messages\" xmlns:y=\"http://schemas.microsoft.com/exchange/services/2006/types\"><x:ResponseMessages>\
<x:GetFolderResponseMessage ResponseClass=\"Success\"><x:ResponseCode>NoError</x:ResponseCode><x:Folders><y:Folder><y:FolderId Id=\"AbC+/=-_\"/><y:DisplayName>Inbox</y:DisplayName></y:Folder></x:Folders>\
</x:GetFolderResponseMessage></x:ResponseMessages></x:GetFolderResponse></a:Body></a:Envelope>";
let answer = Answer::parse(body.as_bytes()).unwrap();
let messages = answer.messages("GetFolder").unwrap();
assert_eq!((messages[0].class(), messages[0].code()), (ResponseClass::Success, &ResponseCode::NoError));
let folder = messages[0].ok().unwrap().path(&["m:Folders", "t:Folder"]).unwrap();
assert_eq!(folder.child_attr("t:FolderId", "Id"), Some("AbC+/=-_"));
assert_eq!(folder.child_text("t:DisplayName").as_deref(), Some("Inbox"));
}
#[test]
fn an_element_in_the_wrong_namespace_is_not_the_one_asked_for() {
let body = reply_with("NoError", "").replace("services/2006/messages", "services/2006/elsewhere");
assert_eq!(Answer::parse(body.as_bytes()).unwrap().messages("GetFolder").unwrap_err(), Error::Unexpected);
// Nor is another operation's answer.
assert_eq!(Answer::parse(reply_with("NoError", "").as_bytes()).unwrap().messages("GetItem").unwrap_err(), Error::Unexpected);
}
#[test]
fn unknown_elements_are_skipped() {
let extra = "<m:Surprise><m:Deeper>1</m:Deeper></m:Surprise><x:Other xmlns:x=\"urn:other\"/>";
assert_eq!(folder_error(&reply_with("ErrorAccessDenied", extra)), Error::Server(ResponseCode::AccessDenied));
}
#[test]
fn response_codes_are_kept_only_as_short_identifiers() {
assert_eq!(ResponseCode::from_name(" ErrorServerBusy "), ResponseCode::ServerBusy);
assert_eq!(ResponseCode::from_name("ErrorSomethingNew"), ResponseCode::Other("ErrorSomethingNew".into()));
assert_eq!(ResponseCode::from_name("bad code\r\nX: y"), ResponseCode::Other(String::new()));
assert_eq!(ResponseCode::from_name(&"A".repeat(500)), ResponseCode::Other(String::new()));
assert_eq!(ResponseCode::from_name("bad\r\nX: y").to_string(), "an unnamed error");
}
#[test]
fn a_message_without_a_class_or_a_code_is_unexpected() {
let no_class = reply_with("NoError", "").replace(" ResponseClass=\"Error\"", "");
assert_eq!(Answer::parse(no_class.as_bytes()).unwrap().messages("GetFolder").unwrap_err(), Error::Unexpected);
let odd_class = reply_with("NoError", "").replace("\"Error\"", "\"Maybe\"");
assert_eq!(Answer::parse(odd_class.as_bytes()).unwrap().messages("GetFolder").unwrap_err(), Error::Unexpected);
let no_code = reply_with("NoError", "").replace("<m:ResponseCode>NoError</m:ResponseCode>", "");
assert_eq!(Answer::parse(no_code.as_bytes()).unwrap().messages("GetFolder").unwrap_err(), Error::Unexpected);
}
#[test]
fn hostile_answers_are_errors_never_panics() {
for body in [
"<!DOCTYPE x [<!ENTITY a 'b'>]><s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body/></s:Envelope>".to_string(),
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body>".to_string(),
"not xml at all".to_string(),
format!(
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body>{}{}</s:Body></s:Envelope>",
"<x>".repeat(MAX_DEPTH),
"</x>".repeat(MAX_DEPTH)
),
format!("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body>{}</s:Body></s:Envelope>", "<x/>".repeat(MAX_NODES)),
] {
assert_eq!(Answer::parse(body.as_bytes()).unwrap_err(), Error::Malformed, "{}", &body[..body.len().min(80)]);
}
assert_eq!(Answer::parse(&vec![b' '; MAX_BYTES + 1]).unwrap_err(), Error::Malformed);
assert_eq!(Answer::parse(b"<html><body>Sign in</body></html>").unwrap_err(), Error::Unexpected);
assert_eq!(Answer::parse(b"<s:Envelope xmlns:s='http://schemas.xmlsoap.org/soap/envelope/'/>").unwrap_err(), Error::Unexpected);
// An entity XML does not define is left out, and the rest is read.
let body = reply_with("Error&nonsense;AccessDenied", "");
assert_eq!(folder_error(&body), Error::Server(ResponseCode::AccessDenied));
}
#[test]
fn a_busy_fault_with_its_wait_in_the_types_namespace_is_read() {
let fault = |wait: &str| {
format!(
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body><s:Fault><faultcode>s:Server</faultcode><detail>\
<e:ResponseCode xmlns:e=\"http://schemas.microsoft.com/exchange/services/2006/errors\">ErrorServerBusy</e:ResponseCode>\
<t:MessageXml xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\"><t:Value Name=\"BackOffMilliseconds\">{wait}</t:Value></t:MessageXml>\
</detail></s:Fault></s:Body></s:Envelope>"
)
};
assert_eq!(Answer::parse(fault("297749").as_bytes()).unwrap_err(), Error::Busy { back_off_ms: 297_749 });
assert_eq!(Answer::parse(fault("9999999").as_bytes()).unwrap_err(), Error::Busy { back_off_ms: MAX_BACK_OFF_MS });
}
#[test]
fn a_busy_server_inside_an_internal_server_error_is_busy() {
let inner = "<m:MessageXml><t:Value xmlns:t=\"x\" Name=\"InnerErrorResponseCode\">ErrorServerBusy</t:Value><t:Value xmlns:t=\"x\" Name=\"BackOffMilliseconds\">4000</t:Value></m:MessageXml>";
assert_eq!(folder_error(&reply_with("ErrorInternalServerError", inner)), Error::Busy { back_off_ms: 4_000 });
// Without the inner error it is the server's own error, and not a reason to wait.
let body =
reply_with("ErrorInternalServerError", "<m:MessageXml><t:Value xmlns:t=\"x\" Name=\"BackOffMilliseconds\">4000</t:Value></m:MessageXml>");
assert_eq!(folder_error(&body), Error::Server(ResponseCode::InternalServerError));
// The same in a fault.
let fault = "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body><s:Fault><faultcode>s:Server</faultcode><detail>\
<e:ResponseCode xmlns:e=\"http://schemas.microsoft.com/exchange/services/2006/errors\">ErrorInternalServerError</e:ResponseCode>\
<e:MessageXml xmlns:e=\"http://schemas.microsoft.com/exchange/services/2006/errors\"><e:Value Name=\"InnerErrorResponseCode\">ErrorServerBusy</e:Value><e:Value Name=\"BackOffMilliseconds\">2500</e:Value></e:MessageXml></detail></s:Fault></s:Body></s:Envelope>";
assert_eq!(Answer::parse(fault.as_bytes()).unwrap_err(), Error::Busy { back_off_ms: 2_500 });
}
#[test]
fn debug_output_shows_nothing_a_server_chose() {
let body = "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body><m:GetFolderResponse xmlns:m=\"http://schemas.microsoft.com/exchange/services/2006/messages\" xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\"><m:ResponseMessages>\
<m:GetFolderResponseMessage ResponseClass=\"Success\"><m:ResponseCode>NoError</m:ResponseCode><m:Folders><t:Folder><t:FolderId Id=\"SecretId\" ChangeKey=\"SecretKey\"/><t:DisplayName>Secret Name</t:DisplayName></t:Folder></m:Folders></m:GetFolderResponseMessage>\
</m:ResponseMessages></m:GetFolderResponse></s:Body></s:Envelope>";
let answer = Answer::parse(body.as_bytes()).unwrap();
let messages = answer.messages("GetFolder").unwrap();
let shown = format!("{answer:?} {messages:?} {:?}", Element::new("t:DisplayName").attr("Id", "SecretId").text("Secret Name"));
assert!(!shown.contains("Secret") && !shown.contains("Folders"), "{shown}");
}
// ---- the request ----
#[test]
fn the_request_is_a_soap_envelope_with_the_version_and_one_operation() {
let operation = Element::new("m:GetFolder").child(Element::new("m:FolderIds").child(Element::new("t:DistinguishedFolderId").attr("Id", "inbox")));
let text = envelope(Version::Exchange2016, &operation);
let tree = parse_xml(text.as_bytes()).unwrap();
assert_eq!(tree.name, "s:Envelope");
assert_eq!(tree.path(&["s:Header", "t:RequestServerVersion"]).and_then(|v| v.attr("Version")), Some("Exchange2016"));
let body = tree.child("s:Body").unwrap();
assert_eq!(body.elements().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["m:GetFolder"]);
assert!(envelope(Version::Exchange2010Sp2, &operation).contains("Exchange2010_SP2"));
assert_eq!(Version::NAMES.len(), 9);
}
#[test]
fn nothing_written_can_break_out_of_its_place() {
let nasty = "a\r\nX-Injected: 1\0</t:BaseShape><m:Evil/>\"'&";
let element = Element::new("m:Op\r\n<x").attr("Na\nme", nasty).text(nasty).child(Element::new("t:Child").text(nasty));
let text = element.write();
// (A line feed in a text stays; a carriage return is a reference; an attribute has neither.)
assert!(!text.contains(['\r', '\0']), "{text:?}");
let attribute = text.split_once("Name=\"").and_then(|(_, rest)| rest.split_once('"')).map(|(value, _)| value).unwrap_or_default();
assert!(!attribute.is_empty() && !attribute.contains(['\r', '\n', '\0']), "{text:?}");
assert!(!text.contains("<m:Evil/>") && !text.contains("<x"));
// What is written reads back as the one element, holding the text as text.
let tree = parse_xml(envelope(Version::default(), &element).as_bytes()).unwrap();
let op = tree.child("s:Body").unwrap().elements().next().unwrap();
assert_eq!(op.name, "m:Opx");
assert!(op.text().contains("</t:BaseShape><m:Evil/>"));
assert_eq!(op.elements().count(), 1);
}
#[test]
fn a_text_keeps_its_lines() {
let text = "first\r\nsecond\tindented\nthird\0\u{1}\u{FFFE}<&>";
let written = Element::new("t:Body").text(text).write();
assert_eq!(written, "<t:Body>first&#13;\nsecond\tindented\nthird&lt;&amp;&gt;</t:Body>");
assert!(!written.contains(['\r', '\0']));
let tree = parse_xml(envelope(Version::default(), &Element::new("m:Op").text(text)).as_bytes()).unwrap();
let op = tree.child("s:Body").unwrap().elements().next().unwrap();
assert_eq!(op.text(), "first\r\nsecond\tindented\nthird<&>");
// An attribute, or a header, keeps none of it.
assert_eq!(escape("a\r\nb\tc\0"), "abc");
}
proptest! {
#[test]
fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
if let Ok(answer) = Answer::parse(&bytes) {
let _ = (answer.busy(), answer.messages("GetFolder").map(|m| m.len()));
}
}
#[test]
fn any_text_written_reads_back_as_itself(text in "\\PC{0,200}", name in "[a-zA-Z][a-zA-Z0-9]{0,10}") {
let tree = parse_xml(envelope(Version::default(), &Element::new(&format!("m:{name}")).attr("A", &text).text(&text)).as_bytes()).unwrap();
let op = tree.child("s:Body").unwrap().elements().next().unwrap();
let kept: String = text.chars().filter(|c| !c.is_control() && *c != '\u{FFFE}' && *c != '\u{FFFF}').collect();
prop_assert_eq!(op.attr("A"), Some(kept.as_str()));
prop_assert_eq!(op.text().into_owned(), kept);
}
}
+126
View File
@@ -0,0 +1,126 @@
//! How a response message says it went, and why not ([MS-OXWSCDATA]), and the waits a busy
//! server asks for.
use std::fmt;
/// The most a server may ask the client to wait, in milliseconds (five minutes); more is
/// taken as this.
pub const MAX_BACK_OFF_MS: u32 = 300_000;
/// The shortest wait a busy server is given (one second): a server that says 0 is not to be
/// asked again at once.
pub const MIN_BACK_OFF_MS: u32 = 1_000;
/// How long to wait when a server is busy and does not say for how long (one second).
pub const DEFAULT_BACK_OFF_MS: u32 = 1_000;
/// The longest response code kept.
const MAX_CODE: usize = 64;
/// How a response message says it went (`ResponseClass`, [MS-OXWSCDATA]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResponseClass {
/// It was done.
Success,
/// It was done, with a remark.
Warning,
/// It was not done.
Error,
}
impl ResponseClass {
/// The class a document names; none for a word the schema does not have.
pub fn from_name(name: &str) -> Option<Self> {
match name {
"Success" => Some(Self::Success),
"Warning" => Some(Self::Warning),
"Error" => Some(Self::Error),
_ => None,
}
}
}
/// Why a response message says no (`ResponseCodeType`, [MS-OXWSCDATA]): the ones the client
/// acts on by name, and any other as the identifier it was.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ResponseCode {
/// `NoError`.
NoError,
/// `ErrorServerBusy`: try again after the back-off.
ServerBusy,
/// `ErrorAccessDenied`.
AccessDenied,
/// `ErrorFolderNotFound`.
FolderNotFound,
/// `ErrorItemNotFound`.
ItemNotFound,
/// `ErrorNonExistentMailbox`.
NonExistentMailbox,
/// `ErrorInvalidSyncStateData`: the sync state is no longer known; read afresh.
InvalidSyncState,
/// `ErrorInvalidPropertyRequest`: the server doesn't know a property that was asked for.
InvalidPropertyRequest,
/// `ErrorSchemaValidation`: the request did not fit the schema (a bug here).
SchemaValidation,
/// `ErrorInvalidServerVersion`: the server does not know the version asked for.
InvalidServerVersion,
/// `ErrorInvalidRequest`.
InvalidRequest,
/// `ErrorInternalServerError` or `ErrorInternalServerTransientError`.
InternalServerError,
/// `ErrorMailboxStoreUnavailable` or `ErrorMailboxMoveInProgress`: the mailbox cannot be
/// reached for now.
MailboxUnavailable,
/// `ErrorTimeoutExpired`.
TimeoutExpired,
/// Any other code, as the short identifier it was.
Other(String),
}
impl ResponseCode {
/// The code a document names. A name that is not a short identifier (letters and digits)
/// is no code and reads as `Other("")`.
pub fn from_name(name: &str) -> Self {
match name.trim() {
"NoError" => Self::NoError,
"ErrorServerBusy" => Self::ServerBusy,
"ErrorAccessDenied" => Self::AccessDenied,
"ErrorFolderNotFound" => Self::FolderNotFound,
"ErrorItemNotFound" => Self::ItemNotFound,
"ErrorNonExistentMailbox" => Self::NonExistentMailbox,
"ErrorInvalidSyncStateData" => Self::InvalidSyncState,
"ErrorInvalidPropertyRequest" => Self::InvalidPropertyRequest,
"ErrorSchemaValidation" => Self::SchemaValidation,
"ErrorInvalidServerVersion" => Self::InvalidServerVersion,
"ErrorInvalidRequest" => Self::InvalidRequest,
"ErrorInternalServerError" | "ErrorInternalServerTransientError" => Self::InternalServerError,
"ErrorMailboxStoreUnavailable" | "ErrorMailboxMoveInProgress" => Self::MailboxUnavailable,
"ErrorTimeoutExpired" => Self::TimeoutExpired,
other if !other.is_empty() && other.len() <= MAX_CODE && other.bytes().all(|b| b.is_ascii_alphanumeric()) => {
Self::Other(other.to_owned())
}
_ => Self::Other(String::new()),
}
}
}
impl fmt::Display for ResponseCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoError => f.write_str("NoError"),
Self::ServerBusy => f.write_str("ErrorServerBusy"),
Self::AccessDenied => f.write_str("ErrorAccessDenied"),
Self::FolderNotFound => f.write_str("ErrorFolderNotFound"),
Self::ItemNotFound => f.write_str("ErrorItemNotFound"),
Self::NonExistentMailbox => f.write_str("ErrorNonExistentMailbox"),
Self::InvalidSyncState => f.write_str("ErrorInvalidSyncStateData"),
Self::InvalidPropertyRequest => f.write_str("ErrorInvalidPropertyRequest"),
Self::SchemaValidation => f.write_str("ErrorSchemaValidation"),
Self::InvalidServerVersion => f.write_str("ErrorInvalidServerVersion"),
Self::InvalidRequest => f.write_str("ErrorInvalidRequest"),
Self::InternalServerError => f.write_str("ErrorInternalServerError"),
Self::MailboxUnavailable => f.write_str("ErrorMailboxStoreUnavailable"),
Self::TimeoutExpired => f.write_str("ErrorTimeoutExpired"),
Self::Other(code) if code.is_empty() => f.write_str("an unnamed error"),
Self::Other(code) => f.write_str(code),
}
}
}
+25
View File
@@ -0,0 +1,25 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
//! Exchange Web Services: the SOAP layer, without sockets.
//!
//! - [`soap`]: the request envelope ([`envelope`]) with the schema version asked for ([`Version`]),
//! and [`Element`], a builder of the operation's elements that escapes everything it is given, so
//! nothing a caller hands in can end an element, open another or break a line of the protocol.
//! - [`answer`]: the answer envelope read with `craft-xml` ([`Answer::parse`]): a fault as the error
//! it names, the response messages of an operation ([`Answer::messages`]) with their class and code
//! ([`codes`]), and a busy server's wait.
//!
//! Written from Microsoft's public documentation: the EWS reference and the Open Specifications
//! [MS-OXWSCDATA] (types) and [MS-OXWSCORE] (operations and their messages).
//!
//! What stays with the caller: HTTP, Autodiscover, authentication, the operations themselves (which
//! elements to send, what to make of the elements that come back) and any sync state.
pub mod answer;
pub mod codes;
pub mod soap;
pub use answer::{Answer, Error, ResponseMessage};
pub use codes::{ResponseClass, ResponseCode};
pub use soap::{Element, Version, envelope};
+225
View File
@@ -0,0 +1,225 @@
//! The SOAP envelope of a request, and the elements it carries.
//!
//! Everything written here is escaped: attribute values pass through [`escape`] and
//! lose every control character (CR, LF and NUL among them); text passes through
//! [`escape_text`], which keeps its tabs and line feeds (a body of text keeps its
//! lines), writes a carriage return as a character reference and drops NUL and the other
//! controls XML forbids; and a name keeps only the characters a qualified name is made of.
//! Nothing a caller hands in can therefore end an element, open another, or break a line of the
//! protocol.
/// The SOAP 1.1 envelope's namespace.
pub const SOAP: &str = "http://schemas.xmlsoap.org/soap/envelope/";
/// The namespace of the types ([MS-OXWSCDATA]).
pub const TYPES: &str = "http://schemas.microsoft.com/exchange/services/2006/types";
/// The namespace of the messages (the operations and their answers).
pub const MESSAGES: &str = "http://schemas.microsoft.com/exchange/services/2006/messages";
/// The namespace of the errors a fault carries.
pub const ERRORS: &str = "http://schemas.microsoft.com/exchange/services/2006/errors";
/// The version of the schema a request asks the server to answer in (`RequestServerVersion`,
/// [MS-OXWSCDATA] `ExchangeVersionType`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Version {
/// Exchange 2007.
Exchange2007,
/// Exchange 2007 with service pack 1.
Exchange2007Sp1,
/// Exchange 2010.
Exchange2010,
/// Exchange 2010 with service pack 1.
Exchange2010Sp1,
/// Exchange 2010 with service pack 2.
Exchange2010Sp2,
/// Exchange 2013: what a request asks for unless told otherwise, as every server that
/// speaks the service today understands it.
#[default]
Exchange2013,
/// Exchange 2013 with service pack 1.
Exchange2013Sp1,
/// Exchange 2015.
Exchange2015,
/// Exchange 2016.
Exchange2016,
}
impl Version {
/// Every name the schema has for a version.
pub const NAMES: [&'static str; 9] = [
"Exchange2007",
"Exchange2007_SP1",
"Exchange2010",
"Exchange2010_SP1",
"Exchange2010_SP2",
"Exchange2013",
"Exchange2013_SP1",
"Exchange2015",
"Exchange2016",
];
/// The name the schema gives it.
pub fn as_str(self) -> &'static str {
match self {
Self::Exchange2007 => "Exchange2007",
Self::Exchange2007Sp1 => "Exchange2007_SP1",
Self::Exchange2010 => "Exchange2010",
Self::Exchange2010Sp1 => "Exchange2010_SP1",
Self::Exchange2010Sp2 => "Exchange2010_SP2",
Self::Exchange2013 => "Exchange2013",
Self::Exchange2013Sp1 => "Exchange2013_SP1",
Self::Exchange2015 => "Exchange2015",
Self::Exchange2016 => "Exchange2016",
}
}
}
/// A name as a qualified name may be written: letters, digits, `_`, `-`, `.` and one `:`
/// between the prefix and the local name; anything else is dropped.
fn clean_name(name: &str) -> String {
let mut colon = false;
name.chars()
.filter(|c| match c {
':' if !colon => {
colon = true;
true
}
c => c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'),
})
.collect()
}
/// One element of a request: a name (with its prefix, `m:GetFolder`), attributes, and text and
/// children.
#[derive(Clone, Default, PartialEq, Eq)]
pub struct Element {
name: String,
attributes: Vec<(String, String)>,
text: String,
children: Vec<Element>,
}
impl std::fmt::Debug for Element {
/// The name and how much is inside: the text and the attributes may be somebody's mail.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Element")
.field("name", &self.name)
.field("attributes", &self.attributes.len())
.field("children", &self.children.len())
.finish()
}
}
impl Element {
/// An element called `name`.
pub fn new(name: &str) -> Self {
Self { name: clean_name(name), ..Self::default() }
}
/// With the attribute `name` set to `value`.
#[must_use]
pub fn attr(mut self, name: &str, value: &str) -> Self {
self.attributes.push((clean_name(name), escape(value)));
self
}
/// With `text` inside it.
#[must_use]
pub fn text(mut self, text: &str) -> Self {
self.text = escape_text(text);
self
}
/// With `child` inside it, after the ones before.
#[must_use]
pub fn child(mut self, child: Element) -> Self {
self.children.push(child);
self
}
/// The element as text.
pub fn write(&self) -> String {
let mut out = String::new();
self.write_into(&mut out);
out
}
fn write_into(&self, out: &mut String) {
out.push('<');
out.push_str(&self.name);
for (name, value) in &self.attributes {
out.push(' ');
out.push_str(name);
out.push_str("=\"");
out.push_str(value);
out.push('"');
}
if self.text.is_empty() && self.children.is_empty() {
out.push_str("/>");
return;
}
out.push('>');
out.push_str(&self.text);
for child in &self.children {
child.write_into(out);
}
out.push_str("</");
out.push_str(&self.name);
out.push('>');
}
}
/// The whole request: a SOAP envelope whose header names `version` and whose body holds
/// `operation` (an element of the messages' namespace, `m:GetFolder`).
pub fn envelope(version: Version, operation: &Element) -> String {
let header = Element::new("t:RequestServerVersion").attr("Version", version.as_str());
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?><s:Envelope xmlns:s=\"{SOAP}\" xmlns:m=\"{MESSAGES}\" xmlns:t=\"{TYPES}\">\
<s:Header>{}</s:Header><s:Body>{}</s:Body></s:Envelope>",
header.write(),
operation.write()
)
}
/// `text` as it is written inside an attribute (or anywhere a line of a protocol could end): the
/// five characters XML has names for by those names, and nothing that could end a line or a
/// header: every control character (CR, LF, NUL and the tab too), and the characters XML 1.0
/// forbids (U+FFFE, U+FFFF).
pub fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
c if c.is_control() || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
c => out.push(c),
}
}
out
}
/// `text` as it is written inside an element: as [`escape`], except that the tab and the line
/// feed stay (a body of text keeps its lines) and a carriage return is written as `&#13;`, which
/// is not one on the wire. NUL and the other C0 controls, U+FFFE and U+FFFF are dropped.
/// (A `&str` holds no unpaired surrogate.)
pub fn escape_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'\r' => out.push_str("&#13;"),
c if allowed(c) => out.push(c),
_ => {}
}
}
out
}
/// Whether XML 1.0 can carry `c` in a text.
fn allowed(c: char) -> bool {
!((c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r')) && c != '\u{FFFE}' && c != '\u{FFFF}'
}