diff --git a/Cargo.lock b/Cargo.lock index 6b1836a7..3e6f835a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d4a848d8..a7304151 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index d3a8debf..bba3a0ad 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = 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 { + 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, + 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 { + 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 { + 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>, 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 { + 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 { + 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) { + 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::().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; diff --git a/crates/ews/src/answer_tests.rs b/crates/ews/src/answer_tests.rs new file mode 100644 index 00000000..bdca77e3 --- /dev/null +++ b/crates/ews/src/answer_tests.rs @@ -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!( + "\ + {code}{extra}\ + " + ) +} + +/// 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", "soon")), + Error::Busy { back_off_ms: DEFAULT_BACK_OFF_MS } + ); + assert_eq!( + folder_error(&reply_with("ErrorServerBusy", "7000")), + Error::Busy { back_off_ms: 7_000 } + ); + assert_eq!( + folder_error(&reply_with("ErrorServerBusy", "9999999999999999999999")), + 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", "0")), + Error::Busy { back_off_ms: MIN_BACK_OFF_MS } + ); + // The whole answer says so. + let answer = Answer::parse(reply_with("ErrorServerBusy", "7000").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:Client\ + {code}" + ) + }; + 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 = "\ + \ + NoErrorInbox\ + "; + 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 = "1"; + 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("NoError", ""); + 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 [ + "]>".to_string(), + "".to_string(), + "not xml at all".to_string(), + format!( + "{}{}", + "".repeat(MAX_DEPTH), + "".repeat(MAX_DEPTH) + ), + format!("{}", "".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"Sign in").unwrap_err(), Error::Unexpected); + assert_eq!(Answer::parse(b"").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:Server\ + ErrorServerBusy\ + {wait}\ + " + ) + }; + 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 = "ErrorServerBusy4000"; + 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", "4000"); + assert_eq!(folder_error(&body), Error::Server(ResponseCode::InternalServerError)); + // The same in a fault. + let fault = "s:Server\ + ErrorInternalServerError\ + ErrorServerBusy2500"; + 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 = "\ + NoErrorSecret Name\ + "; + 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::>(), ["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\"'&"; + let element = Element::new("m:Op\r\n") && !text.contains("")); + 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, "first \nsecond\tindented\nthird<&>"); + 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::(), 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); + } +} diff --git a/crates/ews/src/codes.rs b/crates/ews/src/codes.rs new file mode 100644 index 00000000..09d6cf75 --- /dev/null +++ b/crates/ews/src/codes.rs @@ -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 { + 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), + } + } +} diff --git a/crates/ews/src/lib.rs b/crates/ews/src/lib.rs new file mode 100644 index 00000000..12590250 --- /dev/null +++ b/crates/ews/src/lib.rs @@ -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}; diff --git a/crates/ews/src/soap.rs b/crates/ews/src/soap.rs new file mode 100644 index 00000000..8cdb4d78 --- /dev/null +++ b/crates/ews/src/soap.rs @@ -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, +} + +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("'); + } +} + +/// 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!( + "\ + {}{}", + 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}' +}