mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 23:04:39 +00:00
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:
co-authored by
Martin Ball
parent
be7fafd14f
commit
977208a013
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 ` `, 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`.
|
||||
@@ -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;
|
||||
@@ -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 \nsecond\tindented\nthird<&></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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
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 ` `, 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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'\r' => out.push_str(" "),
|
||||
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}'
|
||||
}
|
||||
Reference in New Issue
Block a user